diff --git a/chat-server.mjs b/chat-server.mjs index 985f1c5..feea45a 100644 --- a/chat-server.mjs +++ b/chat-server.mjs @@ -2,7 +2,7 @@ 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'), @@ -10,6 +10,7 @@ const [{ CHAT_PORT, CHAT_BIND_HOST, SECURE_COOKIES, MEMORY_DIR }, { handleReques 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')]) { @@ -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); diff --git a/chat/session-auto-distill.mjs b/chat/session-auto-distill.mjs new file mode 100644 index 0000000..994995f --- /dev/null +++ b/chat/session-auto-distill.mjs @@ -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/.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)}`); +} diff --git a/chat/session-labels.mjs b/chat/session-labels.mjs new file mode 100644 index 0000000..a3b2b94 --- /dev/null +++ b/chat/session-labels.mjs @@ -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'); + } + }); +} diff --git a/chat/session-manager.mjs b/chat/session-manager.mjs index 0760f91..c78fa21 100644 --- a/chat/session-manager.mjs +++ b/chat/session-manager.mjs @@ -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) { diff --git a/lib/config.mjs b/lib/config.mjs index c842119..b1a68d4 100644 --- a/lib/config.mjs +++ b/lib/config.mjs @@ -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) diff --git a/static/chat/chat-sidebar.css b/static/chat/chat-sidebar.css index ddba75c..651c3cd 100644 --- a/static/chat/chat-sidebar.css +++ b/static/chat/chat-sidebar.css @@ -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); } diff --git a/static/chat/init.js b/static/chat/init.js index a4b157e..09116a3 100644 --- a/static/chat/init.js +++ b/static/chat/init.js @@ -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(); diff --git a/static/chat/session-surface-ui.js b/static/chat/session-surface-ui.js index 19b3bbc..a15da02 100644 --- a/static/chat/session-surface-ui.js +++ b/static/chat/session-surface-ui.js @@ -142,8 +142,22 @@ function isSessionCompleteAndReviewed(session) { : false; } +function renderSessionLabelHtml(session) { + const labelId = session?.label || null; + const labelDefs = Array.isArray(window._sessionLabelDefs) ? window._sessionLabelDefs : []; + if (!labelId) { + return `◇ label`; + } + const def = labelDefs.find((l) => l.id === labelId); + const name = def ? def.name : labelId; + const color = def ? def.color : "var(--text-muted)"; + return `◆ ${esc(name)}`; +} + function buildSessionMetaParts(session) { const parts = []; + const labelHtml = renderSessionLabelHtml(session); + if (labelHtml) parts.push(labelHtml); const reviewHtml = renderSessionStatusHtml(getSessionReviewStatusInfo(session)); if (reviewHtml) parts.push(reviewHtml); const liveStatus = getSessionStatusSummary(session).primary; @@ -254,5 +268,70 @@ function createActiveSessionItem(session) { dispatchAction({ action: "archive", sessionId: session.id }); }); + // Label badge click → open label picker + const labelBadge = div.querySelector(".session-label-badge"); + if (labelBadge) { + labelBadge.addEventListener("click", (e) => { + e.stopPropagation(); + showLabelPicker(e.target, session); + }); + } + return div; } + +// ---- Label picker dropdown ---- +function showLabelPicker(anchor, session) { + document.querySelectorAll(".label-picker-dropdown").forEach((node) => node.remove()); + + const labels = window._sessionLabelDefs || []; + const dropdown = document.createElement("div"); + dropdown.className = "label-picker-dropdown"; + + const currentLabel = session.label || null; + let html = labels.map((l) => { + const active = l.id === currentLabel ? " active" : ""; + return `
◆ ${esc(l.name)}
`; + }).join(""); + html += `
${currentLabel ? "✕ Clear" : "—"}
`; + dropdown.innerHTML = html; + + const rect = anchor.getBoundingClientRect(); + dropdown.style.position = "fixed"; + dropdown.style.top = (rect.bottom + 2) + "px"; + dropdown.style.left = rect.left + "px"; + dropdown.style.zIndex = "9999"; + dropdown.style.background = "var(--bg-elevated, var(--bg-secondary, #ffffff))"; + dropdown.style.color = "var(--text, #111827)"; + dropdown.style.border = "1px solid var(--border, rgba(15, 23, 42, 0.12))"; + dropdown.style.borderRadius = "10px"; + dropdown.style.boxShadow = "0 16px 40px rgba(15, 23, 42, 0.18)"; + dropdown.style.overflow = "hidden"; + document.body.appendChild(dropdown); + + dropdown.addEventListener("click", async (e) => { + const item = e.target.closest(".label-picker-item"); + if (!item) return; + const labelId = item.dataset.label || null; + dropdown.remove(); + try { + await fetch(`/api/sessions/${session.id}/label`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label: labelId }), + }); + session.label = labelId; + if (typeof renderSessionList === "function") renderSessionList(); + } catch (err) { + console.error("[label] Failed to set label:", err); + } + }); + + const dismiss = (e) => { + if (!dropdown.contains(e.target)) { + dropdown.remove(); + document.removeEventListener("click", dismiss, true); + } + }; + setTimeout(() => document.addEventListener("click", dismiss, true), 0); +}