Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion chat-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
import { join } from 'path';

const http = await import('http');
const [{ CHAT_PORT, CHAT_BIND_HOST, SECURE_COOKIES, MEMORY_DIR }, { handleRequest }, apiRequestLog, ws, sessionManager, triggers, { ensureDir }] = await Promise.all([
const [{ CHAT_PORT, CHAT_BIND_HOST, SECURE_COOKIES, MEMORY_DIR }, { handleRequest }, apiRequestLog, ws, sessionManager, triggers, { ensureDir }, sessionLabels] = await Promise.all([
import('./lib/config.mjs'),
import('./chat/router.mjs'),
import('./chat/api-request-log.mjs'),
import('./chat/ws.mjs'),
import('./chat/session-manager.mjs'),
import('./chat/triggers.mjs'),
import('./chat/fs-utils.mjs'),
import('./chat/session-labels.mjs'),
]);

for (const dir of [MEMORY_DIR, join(MEMORY_DIR, 'tasks')]) {
Expand All @@ -34,6 +35,7 @@ ws.attachWebSocket(server);
triggers.startTriggerScheduler();
void (async () => {
try {
await sessionLabels.recoverBootLabels();
await sessionManager.startDetachedRunObservers();
} catch (error) {
console.error('Failed to rehydrate detached runs on startup:', error);
Expand Down
154 changes: 154 additions & 0 deletions chat/session-auto-distill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { readFile, appendFile, mkdir } from 'fs/promises';
import { dirname, join } from 'path';
import { loadHistory } from './history.mjs';
import { createToolInvocation, resolveCommand, resolveCwd } from './process-runner.mjs';
import { buildToolProcessEnv } from '../lib/user-shell-env.mjs';
import { pathExists } from './fs-utils.mjs';

/**
* Run a prompt via a tool using the haiku model for cheap/fast inference.
* Returns the assistant response text, or null on failure.
*/
async function runHaikuPrompt(sessionMeta, prompt, { timeout = 45000 } = {}) {
const tool = sessionMeta.tool || 'claude';
const folder = sessionMeta.folder || '';
const sessionId = sessionMeta.id || '(unknown)';

const { command, adapter, args, envOverrides } = await createToolInvocation(tool, prompt, {
dangerouslySkipPermissions: true,
model: 'haiku',
systemPrefix: '',
});
const resolvedCmd = await resolveCommand(command);
const resolvedFolder = resolveCwd(folder);
console.log(
`[auto-distill] Calling tool=${tool} cmd=${resolvedCmd} model=haiku for session ${sessionId.slice(0, 8)}`
);

const subEnv = buildToolProcessEnv(envOverrides || {});
delete subEnv.CLAUDECODE;
delete subEnv.CLAUDE_CODE_ENTRYPOINT;

return new Promise((resolve, reject) => {
const proc = spawn(resolvedCmd, args, {
cwd: resolvedFolder,
env: subEnv,
stdio: ['pipe', 'pipe', 'pipe'],
});
proc.stdin.end();

const rl = createInterface({ input: proc.stdout });
const textParts = [];

rl.on('line', (line) => {
const events = adapter.parseLine(line);
for (const evt of events) {
if (evt.type === 'message' && evt.role === 'assistant') {
textParts.push(evt.content || '');
}
}
});

proc.stderr.on('data', () => {});
proc.on('error', (err) => {
console.error(`[auto-distill] tool error for ${sessionId.slice(0, 8)}: ${err.message}`);
reject(err);
});
proc.on('exit', (code) => {
const raw = textParts.join('').trim();
if (code !== 0 && !raw) {
reject(new Error(`tool exited with code ${code}`));
return;
}
resolve(raw || null);
});

setTimeout(() => { try { proc.kill(); } catch {} }, timeout);
});
}

/**
* Generate experience notes for a completed session using Haiku, then write
* directly to the workspace's memory/<today>.md file.
* Does NOT wake up the session — fully background operation.
*/
export async function runAutoDistill(sessionId, sessionMeta) {
console.log(`[auto-distill] Start for session ${sessionId.slice(0, 8)}`);

const allEvents = await loadHistory(sessionId, { includeBodies: true });
if (allEvents.length === 0) {
console.log(`[auto-distill] No history for ${sessionId.slice(0, 8)}, skipping`);
return;
}

// Build a condensed view of the session
const lines = [];
for (const evt of allEvents.slice(-60)) {
if (evt.type === 'message' && evt.role === 'user') {
lines.push(`USER: ${(evt.content || '').slice(0, 300)}`);
} else if (evt.type === 'message' && evt.role === 'assistant') {
lines.push(`ASSISTANT: ${(evt.content || '').slice(0, 400)}`);
} else if (evt.type === 'file_change') {
lines.push(`FILE ${(evt.changeType || 'changed').toUpperCase()}: ${evt.filePath}`);
}
}
const historyText = lines.join('\n').slice(0, 8000);

const today = new Date().toISOString().slice(0, 10);
const folder = sessionMeta.folder || '';
const memoryPath = join(folder, 'memory', `${today}.md`);

const prompt = [
`Session folder: ${folder}`,
`Session name: ${sessionMeta.name || '(unnamed)'}`,
'',
'Recent activity:',
historyText,
'',
`Write 3-5 concise experience notes in Chinese to append to ${memoryPath}.`,
'Format (plain markdown, no frontmatter):',
'',
`## Auto-distill ${sessionMeta.name || sessionId.slice(0, 8)}(${today})`,
'',
'1. **做了什么**:一句话',
'2. **踩了什么坑**:如无则省略',
'3. **可复用的模式**:',
'4. **遗留问题**:如无则省略',
'',
'Reply ONLY with the markdown block. No explanation.',
].join('\n');

let result;
try {
result = await runHaikuPrompt(sessionMeta, prompt, { timeout: 45000 });
} catch (err) {
console.error(`[auto-distill] Haiku call failed for ${sessionId.slice(0, 8)}: ${err.message}`);
return;
}

if (!result || !result.trim()) {
console.log(`[auto-distill] Haiku returned empty for ${sessionId.slice(0, 8)}`);
return;
}

// Ensure memory directory exists
const memDir = dirname(memoryPath);
await mkdir(memDir, { recursive: true });

// Dedup: check if an identical distill heading already exists in the file
const heading = result.trim().split('\n')[0];
if (await pathExists(memoryPath)) {
try {
const existing = await readFile(memoryPath, 'utf8');
if (existing.includes(heading)) {
console.log(`[auto-distill] Skipping duplicate for ${sessionId.slice(0, 8)} (heading already exists)`);
return;
}
} catch {}
}

await appendFile(memoryPath, '\n' + result.trim() + '\n', 'utf8');
console.log(`[auto-distill] Wrote to ${memoryPath} for session ${sessionId.slice(0, 8)}`);
}
132 changes: 132 additions & 0 deletions chat/session-labels.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { dirname } from 'path';
import { SESSION_LABELS_FILE } from '../lib/config.mjs';
import { ensureDir, readJson, writeJsonAtomic } from './fs-utils.mjs';
import {
mutateSessionMeta,
withSessionsMetaMutation,
} from './session-meta-store.mjs';
import { runAutoDistill } from './session-auto-distill.mjs';

// ---- Default label definitions ----

const DEFAULT_LABELS = [
{ id: 'started', name: 'Started', color: '#3b82f6' },
{ id: 'asked-for-restart', name: 'Asked for Restart', color: '#eab308' },
{ id: 'pending-review', name: 'Pending Review', color: '#f59e0b' },
{ id: 'planned', name: 'Planned', color: '#8b5cf6' },
{ id: 'done', name: 'Done', color: '#10b981' },
];

// ---- Persistence helpers ----

async function loadLabels() {
const stored = await readJson(SESSION_LABELS_FILE, null);
if (!Array.isArray(stored)) {
await saveLabels([...DEFAULT_LABELS]);
return [...DEFAULT_LABELS];
}
return stored;
}

async function saveLabels(labels) {
await ensureDir(dirname(SESSION_LABELS_FILE));
await writeJsonAtomic(SESSION_LABELS_FILE, labels);
}

// ---- Public API ----

export async function getLabels() {
return loadLabels();
}

export async function addLabel(label) {
const labels = await loadLabels();
labels.push(label);
await saveLabels(labels);
return label;
}

export async function removeLabel(labelId) {
const labels = await loadLabels();
const idx = labels.findIndex((l) => l.id === labelId);
if (idx === -1) return false;
labels.splice(idx, 1);
await saveLabels(labels);

// Clear this label from any sessions that use it
await withSessionsMetaMutation(async (metas, saveMetas) => {
let changed = false;
for (const m of metas) {
if (m.label === labelId) {
delete m.label;
changed = true;
}
}
if (changed) await saveMetas(metas);
});
return true;
}

export async function updateLabel(labelId, updates) {
const labels = await loadLabels();
const label = labels.find((l) => l.id === labelId);
if (!label) return null;
if (updates.name !== undefined) label.name = updates.name;
if (updates.color !== undefined) label.color = updates.color;
await saveLabels(labels);
return label;
}

/**
* Set or clear the label on a session.
* Pass `null` or `undefined` to clear the label.
* Returns the updated session meta, or null if the session was not found.
*/
export async function setSessionLabel(sessionId, labelId) {
let oldLabel = null;
const result = await mutateSessionMeta(sessionId, (draft, current) => {
oldLabel = current.label || null;
if (labelId === null || labelId === undefined) {
if (!Object.prototype.hasOwnProperty.call(draft, 'label')) return false;
delete draft.label;
} else {
if (draft.label === labelId) return false;
draft.label = labelId;
}
return true;
});

// Auto-distill: when label transitions to done/pending-review, generate experience notes
if (result.changed && result.meta) {
const triggerLabels = ['done', 'pending-review'];
if (triggerLabels.includes(labelId) && !triggerLabels.includes(oldLabel)) {
const distillMeta = { ...result.meta };
setTimeout(() => {
runAutoDistill(sessionId, distillMeta).catch((e) => {
console.error(`[session-labels] Auto-distill failed for session ${sessionId.slice(0, 8)}: ${e.message}`);
});
}, 2000);
}
}

return result.meta;
}

/**
* Boot-time recovery: convert all "asked-for-restart" labels back to "started".
*/
export async function recoverBootLabels() {
await withSessionsMetaMutation(async (metas, saveMetas) => {
let changed = false;
for (const m of metas) {
if (m.label === 'asked-for-restart') {
m.label = 'started';
changed = true;
}
}
if (changed) {
await saveMetas(metas);
console.log('[session-labels] Boot: recovered asked-for-restart sessions → started');
}
});
}
2 changes: 2 additions & 0 deletions chat/session-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3436,6 +3436,8 @@ export async function submitHttpMessage(sessionId, text, images, options = {}) {
const activeSession = (await mutateSessionMeta(sessionId, (draft) => {
draft.activeRunId = run.id;
draft.updatedAt = nowIso();
// Auto-set "started" label when session receives a message
draft.label = 'started';
return true;
})).meta;
if (activeSession) {
Expand Down
8 changes: 8 additions & 0 deletions lib/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ export const FILE_ASSET_ALLOWED_ORIGINS = [...new Set([
extractOrigin(FILE_ASSET_PUBLIC_BASE_URL),
].filter(Boolean))];

// Session labels (huiyuanClaw label system)
export const SESSION_LABELS_FILE = join(configDir, 'session-labels.json');

// huiyuanClaw custom paths
export const TASKS_FILE = join(configDir, 'tasks.json');
export const REPORTS_DIR = join(configDir, 'reports');
export const REPORTS_META_FILE = join(configDir, 'reports.json');

// RemoteLab memory directories (model-managed persistent storage)
// User-level: private to this machine (preferences, local paths, personal habits)
export const MEMORY_DIR = resolveOverridePath(process.env.REMOTELAB_MEMORY_DIR)
Expand Down
28 changes: 28 additions & 0 deletions static/chat/chat-sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,34 @@
color: var(--text-muted);
}
.session-item-count { font-variant-numeric: tabular-nums; }
.session-label-badge { font-weight: 600; font-size: 10px; cursor: pointer; border-radius: 3px; padding: 0 2px; }
.session-label-badge:hover { background: var(--bg-tertiary); }
.session-label-empty { color: var(--text-muted); opacity: 0.4; font-weight: 400; }
.session-item:hover .session-label-empty { opacity: 0.7; }
.label-picker-dropdown {
background: var(--bg-elevated, var(--bg-secondary, #ffffff));
color: var(--text, #111827);
border: 1px solid var(--border, rgba(15, 23, 42, 0.12));
border-radius: 10px;
padding: 6px 0;
min-width: 160px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(16px);
overflow: hidden;
}
.label-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
}
.label-picker-item:hover { background: var(--bg-tertiary, rgba(15, 23, 42, 0.06)); }
.label-picker-item.active { font-weight: 700; }
.label-picker-clear { color: var(--text-muted); }
.session-item-meta .status-running { color: var(--success); }
.session-item-meta .status-queued { color: var(--queue); }
.session-item-meta .status-compacting { color: var(--notice); }
Expand Down
3 changes: 2 additions & 1 deletion static/chat/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ async function initApp() {

const toolsPromise = loadInlineTools({ skipModelLoad: true });
const sessionsPromise = bootstrapViaHttp({ deferOwnerRestore: true });
await Promise.all([toolsPromise, sessionsPromise]);
const labelsPromise = fetch("/api/session-labels").then(r => r.ok ? r.json() : {}).then(data => { window._sessionLabelDefs = Array.isArray(data) ? data : (data.labels || []); }).catch(() => {});
await Promise.all([toolsPromise, sessionsPromise, labelsPromise]);
restoreOwnerSessionSelection();
connect();
setupForegroundRefreshHandlers();
Expand Down
Loading
Loading