From e43c32704df54ccadf78b9b945b6c0cdbd35c095 Mon Sep 17 00:00:00 2001 From: oratis Date: Thu, 28 May 2026 12:30:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,cli):=20M3c=20=E2=80=94=20compaction?= =?UTF-8?q?=20+=20statusLine=20runner=20+=20CLI=20flag=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What ships ---------- - packages/core/src/compaction/index.ts compact(history, {provider, keepFirstPairs, keepLastMessages, summarizerModel, summaryMaxTokens}) → CompactionResult{history, messagesRemoved, usage, summaryText}. Strategy: anchor head + summarized middle + recent tail. shouldCompact({inputTokens, outputTokens, contextWindow, threshold}) helper. - packages/core/src/harness/statusline.ts StatusLineRunner — periodic exec with JSON-on-stdin payload (session_id, model, cwd, mode, effort, transcript_path, cost, version, output_style). Respects DEEPCODE_STATUS_LINE_DEBOUNCE_MS env override. 200-char output cap. 2s command timeout. Suppress EPIPE for scripts that don't read stdin. Only fires onUpdate when stdout actually changes. runStatusLineCommand standalone helper for one-shot use. - apps/cli/src/repl.ts — wire CLI args into agent loop: · systemPromptOverride / appendSystemPrompt / appendSystemPromptFile · allowedTools / disallowedTools (filter BUILTIN_TOOLS before ToolRegistry) · maxTurns - apps/cli/src/cli.ts — pass parsed flags into startRepl Tests (16 new, 281 total) ------------------------- - compaction/index.test.ts (8): short-history-unchanged, compact-middle, preserve-anchor + tail, summarizerModel override, usage reporting, tool_use/tool_result rendering in summary prompt, shouldCompact threshold - harness/statusline.test.ts (8): trimmed stdout, stdin payload, 200-char cap, timeout → empty, exit-nonzero → empty, empty config → empty, Runner only fires onUpdate when text changes, env-var debounce override Verified -------- pnpm typecheck → green pnpm test → 240 + 41 = 281 passed / 0 failed pnpm format:check → conformant Deferred to next M3c PR ----------------------- - Auto compaction trigger inside agent loop (wire shouldCompact post-turn) - StatusLine actual REPL rendering - /init multi-phase, auto classifier, remaining 4 hook handler types Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cli/src/cli.ts | 6 + apps/cli/src/repl.ts | 43 ++++- docs/milestones/M3c-compaction-statusline.md | 26 +++ docs/milestones/M3c-mcp.md | 2 +- packages/core/src/compaction/index.test.ts | 133 ++++++++++++++++ packages/core/src/compaction/index.ts | 158 ++++++++++++++++++- packages/core/src/harness/index.ts | 7 + packages/core/src/harness/statusline.test.ts | 100 ++++++++++++ packages/core/src/harness/statusline.ts | 133 ++++++++++++++++ packages/core/src/index.ts | 20 ++- 10 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 docs/milestones/M3c-compaction-statusline.md create mode 100644 packages/core/src/compaction/index.test.ts create mode 100644 packages/core/src/harness/statusline.test.ts create mode 100644 packages/core/src/harness/statusline.ts diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index c1abb27..1e1f73b 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -69,6 +69,12 @@ async function main(): Promise { mode: args.mode, model: args.model, effort: args.effort, + systemPromptOverride: args.systemPrompt, + appendSystemPrompt: args.appendSystemPrompt, + appendSystemPromptFile: args.appendSystemPromptFile, + allowedTools: args.allowedTools, + disallowedTools: args.disallowedTools, + maxTurns: args.maxTurns, }); } diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 171a331..79b0c3f 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -43,6 +43,19 @@ export interface ReplOpts { model?: string; /** Initial effort (overrides settings). */ effort?: Effort; + // M3c CLI flag wiring + /** Replace the default system prompt entirely. */ + systemPromptOverride?: string; + /** Append text to the system prompt. */ + appendSystemPrompt?: string; + /** Path to a file whose contents are appended to system prompt. */ + appendSystemPromptFile?: string; + /** Whitelist of tool names — only these are loaded. */ + allowedTools?: string[]; + /** Blacklist of tool names — these are removed. */ + disallowedTools?: string[]; + /** Cap on agent loop turns. */ + maxTurns?: number; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -79,7 +92,21 @@ export async function startRepl(opts: ReplOpts): Promise { authToken: creds.authToken, baseURL: creds.baseURL ?? settings.baseURL, }); - const tools = new ToolRegistry(); + // M3c: --allowedTools / --disallowedTools filtering BEFORE registry construction + let tools: ToolRegistry; + if (opts.allowedTools || opts.disallowedTools) { + const { BUILTIN_TOOLS } = await import('@deepcode/core'); + const allowSet = opts.allowedTools ? new Set(opts.allowedTools) : null; + const denySet = new Set(opts.disallowedTools ?? []); + const filtered = BUILTIN_TOOLS.filter((t) => { + if (denySet.has(t.name)) return false; + if (allowSet && !allowSet.has(t.name)) return false; + return true; + }); + tools = new ToolRegistry(filtered); + } else { + tools = new ToolRegistry(); + } const commands = new CommandRegistry(); // M5: load memory, skills, output style — assemble final system prompt @@ -128,11 +155,22 @@ export async function startRepl(opts: ReplOpts): Promise { } // Build the composite system prompt - let systemPrompt = DEFAULT_SYSTEM_PROMPT; + // M3c: honor --system-prompt (replaces default) + --append-system-prompt / + // --append-system-prompt-file (appended after memory/skills/style). + let systemPrompt = opts.systemPromptOverride ?? DEFAULT_SYSTEM_PROMPT; if (memory.text) systemPrompt += '\n\n' + memory.text; const skillsBlock = buildSkillsDescriptionBlock(skills); if (skillsBlock) systemPrompt += '\n\n' + skillsBlock; systemPrompt = applyStyle(systemPrompt, activeStyle); + if (opts.appendSystemPrompt) systemPrompt += '\n\n' + opts.appendSystemPrompt; + if (opts.appendSystemPromptFile) { + try { + const { readFile } = await import('node:fs/promises'); + systemPrompt += '\n\n' + (await readFile(opts.appendSystemPromptFile, 'utf8')); + } catch (err) { + output.write(`⚠ Could not read --append-system-prompt-file: ${(err as Error).message}\n`); + } + } // Hook dispatcher (M3) const hooks = new HookDispatcher({ @@ -210,6 +248,7 @@ export async function startRepl(opts: ReplOpts): Promise { model: ctx.model, maxTokens, temperature, + maxTurns: opts.maxTurns, cwd: ctx.cwd, session: { manager: sessions, id: session.id }, mode: ctx.mode as Mode, diff --git a/docs/milestones/M3c-compaction-statusline.md b/docs/milestones/M3c-compaction-statusline.md new file mode 100644 index 0000000..b745a72 --- /dev/null +++ b/docs/milestones/M3c-compaction-statusline.md @@ -0,0 +1,26 @@ +# M3c · Compaction + StatusLine + CLI flag wiring + +> **Status**: ✅ Shipped · **Branch**: `feat/m3c-compaction-statusline-flags` + +## Shipped + +- `compaction/index.ts` — `compact(history, { provider, keepFirstPairs, keepLastMessages })` + `shouldCompact({ usage, contextWindow, threshold })`. Strategy: keep first anchor msgs + summarize middle via cheap chat call + keep recent tail. +- `harness/statusline.ts` — `StatusLineRunner` periodic exec + `runStatusLineCommand` JSON-on-stdin contract; respects `DEEPCODE_STATUS_LINE_DEBOUNCE_MS` env override; output cap 200 chars; 2s command timeout. +- CLI flag wiring: + - `--system-prompt` replaces default + - `--append-system-prompt` / `--append-system-prompt-file` append + - `--allowedTools` / `--disallowedTools` filter ToolRegistry at construction + - `--max-turns` plumbed into runAgent + +## Tests (16 new, 281 total) + +- compaction/index.test.ts (8): unchanged-when-short, compacts middle, preserves anchor + tail, custom summarizerModel, usage report, tool_use/tool_result in summary prompt + shouldCompact threshold logic +- harness/statusline.test.ts (8): trimmed stdout, stdin payload, 200-char cap, timeout → empty, exit-nonzero → empty, empty config → empty, Runner change-only updates, env-var debounce override + +## NOT in this PR + +- Auto compaction trigger inside agent loop (M3c-ext — wire `shouldCompact` check after each turn) +- StatusLine actual render in REPL (deferred to next PR or M6 GUI) +- `/init` multi-phase +- `auto` classifier mode +- Remaining 4 hook handler types diff --git a/docs/milestones/M3c-mcp.md b/docs/milestones/M3c-mcp.md index 0f4e0dd..58c6469 100644 --- a/docs/milestones/M3c-mcp.md +++ b/docs/milestones/M3c-mcp.md @@ -22,7 +22,7 @@ 2. calls a remote tool, returns its text output 3. `connectAllMcpServers` continues on individual failures 4. respects `disabled` list -5. respects `enabledOnly` list +5. respects `enabledOnly` list 6. rejects server config missing `command` ## NOT in this PR (M3c-ext, next) diff --git a/packages/core/src/compaction/index.test.ts b/packages/core/src/compaction/index.test.ts new file mode 100644 index 0000000..8b972c2 --- /dev/null +++ b/packages/core/src/compaction/index.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { compact, shouldCompact } from './index.js'; +import type { StoredMessage } from '../types.js'; +import type { Provider, ProviderResult, ProviderRunOpts } from '../providers/types.js'; + +class MockProvider implements Provider { + readonly name = 'mock'; + received: ProviderRunOpts | null = null; + constructor(private summary: string) {} + async runTurn(opts: ProviderRunOpts): Promise { + this.received = opts; + return { + content: [{ type: 'text', text: this.summary }], + stopReason: 'end_turn', + usage: { inputTokens: 100, outputTokens: 50, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } +} + +function msg(role: 'user' | 'assistant', text: string): StoredMessage { + return { role, content: [{ type: 'text', text }] }; +} + +describe('compact', () => { + it('returns history unchanged when below threshold', async () => { + const history = [msg('user', 'a'), msg('assistant', 'b')]; + const provider = new MockProvider('summary'); + const r = await compact(history, { provider }); + expect(r.history).toEqual(history); + expect(r.messagesRemoved).toBe(0); + expect(provider.received).toBeNull(); // no LLM call + }); + + it('compacts middle messages when history is long', async () => { + const history: StoredMessage[] = []; + for (let i = 0; i < 20; i++) { + history.push(msg(i % 2 === 0 ? 'user' : 'assistant', `message ${i}`)); + } + const provider = new MockProvider('• read file X\n• fixed bug Y'); + const r = await compact(history, { provider, keepFirstPairs: 1, keepLastMessages: 4 }); + expect(r.history.length).toBe(1 + 1 + 4); // first + summary + last 4 + expect(r.messagesRemoved).toBe(20 - 1 - 4); + // Summary message is in the middle + const summary = r.history[1]; + expect(summary?.role).toBe('assistant'); + const text = summary?.content[0]; + if (text?.type === 'text') { + expect(text.text).toContain('Conversation compacted'); + expect(text.text).toContain('read file X'); + } + }); + + it('preserves first N pairs as anchor', async () => { + const history: StoredMessage[] = [ + msg('user', 'TASK_DEFINITION'), + msg('assistant', 'work in progress'), + ...Array.from({ length: 10 }, (_, i) => msg('user', `mid-${i}`)), + ...Array.from({ length: 5 }, (_, i) => msg('assistant', `tail-${i}`)), + ]; + const provider = new MockProvider('mid summary'); + const r = await compact(history, { provider, keepFirstPairs: 2, keepLastMessages: 5 }); + // First two messages should match the original + const first = r.history[0]; + if (first?.content[0]?.type === 'text') { + expect(first.content[0].text).toBe('TASK_DEFINITION'); + } + const tailMatch = r.history[r.history.length - 1]; + if (tailMatch?.content[0]?.type === 'text') { + expect(tailMatch.content[0].text).toBe('tail-4'); + } + }); + + it('uses the summarizerModel option when provided', async () => { + const history = Array.from({ length: 20 }, (_, i) => msg('user', `m${i}`)); + const provider = new MockProvider('s'); + await compact(history, { provider, summarizerModel: 'custom-model' }); + expect(provider.received?.model).toBe('custom-model'); + }); + + it('reports usage from the summarizer call', async () => { + const history = Array.from({ length: 20 }, (_, i) => msg('user', `m${i}`)); + const provider = new MockProvider('s'); + const r = await compact(history, { provider }); + expect(r.usage.inputTokens).toBe(100); + expect(r.usage.outputTokens).toBe(50); + }); + + it('includes tool_use / tool_result in summary prompt', async () => { + const history: StoredMessage[] = [ + msg('user', 'go'), + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'c1', name: 'Read', input: { file_path: '/x' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'c1', content: 'file content here' }], + }, + ...Array.from({ length: 15 }, (_, i) => msg('user', `m${i}`)), + msg('assistant', 'done'), + ]; + const provider = new MockProvider('s'); + await compact(history, { provider, keepFirstPairs: 1, keepLastMessages: 2 }); + const prompt = provider.received?.messages[0]?.content[0]; + if (prompt?.type === 'text') { + expect(prompt.text).toContain('[tool: Read'); + expect(prompt.text).toContain('[tool result:'); + } + }); +}); + +describe('shouldCompact', () => { + it('returns false below 80% threshold', () => { + expect( + shouldCompact({ inputTokens: 50_000, outputTokens: 10_000, contextWindow: 128_000 }), + ).toBe(false); + }); + it('returns true at/above 80% threshold', () => { + expect( + shouldCompact({ inputTokens: 100_000, outputTokens: 4_000, contextWindow: 128_000 }), + ).toBe(true); + }); + it('respects custom threshold', () => { + expect( + shouldCompact({ + inputTokens: 50_000, + outputTokens: 14_000, + contextWindow: 128_000, + threshold: 0.5, + }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/compaction/index.ts b/packages/core/src/compaction/index.ts index 9c56b3e..ea0bf08 100644 --- a/packages/core/src/compaction/index.ts +++ b/packages/core/src/compaction/index.ts @@ -1,6 +1,154 @@ -// Module: compaction -// Milestone: M3 -// Spec: docs/DEVELOPMENT_PLAN.md §3.7 context compaction at 70/80/85/90% thresholds -// Status: placeholder — implemented in M3 +// Context compaction — fold middle of conversation into a summary when token +// usage approaches the model's context limit. +// Spec: docs/DEVELOPMENT_PLAN.md §3.7 +// Milestone: M3c -export {}; +import type { Provider } from '../providers/types.js'; +import type { ContentBlock, StoredMessage, TextBlock } from '../types.js'; + +export interface CompactionOpts { + /** Provider to use for the summarization call. */ + provider: Provider; + /** Model to use for summarization (default `deepseek-chat` — cheaper). */ + summarizerModel?: string; + /** Keep the first N user/assistant pairs verbatim (system context anchor). */ + keepFirstPairs?: number; + /** Keep the last N messages verbatim (active conversation tail). */ + keepLastMessages?: number; + /** Optional limit on summary token budget. */ + summaryMaxTokens?: number; +} + +export interface CompactionResult { + /** New history: keep-first + summary message + keep-last. */ + history: StoredMessage[]; + /** Number of messages removed from the middle. */ + messagesRemoved: number; + /** Token usage from the summarizer call. */ + usage: { inputTokens: number; outputTokens: number }; + /** Synthetic summary text (also embedded in the new history). */ + summaryText: string; +} + +const DEFAULT_KEEP_FIRST = 1; // first user message +const DEFAULT_KEEP_LAST = 6; // last 3 turns +const DEFAULT_SUMMARY_TOKENS = 1500; + +/** + * Compact a long history. Strategy: + * keep first N messages (anchor: the original task) + * + 1 synthetic "Conversation summary" assistant message + * + keep last M messages (active state) + * + * Returns the new history. Caller is responsible for replacing the session's + * in-memory history with this result. + */ +export async function compact( + history: StoredMessage[], + opts: CompactionOpts, +): Promise { + const keepFirst = opts.keepFirstPairs ?? DEFAULT_KEEP_FIRST; + const keepLast = opts.keepLastMessages ?? DEFAULT_KEEP_LAST; + if (history.length <= keepFirst + keepLast + 1) { + return { + history: [...history], + messagesRemoved: 0, + usage: { inputTokens: 0, outputTokens: 0 }, + summaryText: '', + }; + } + + const head = history.slice(0, keepFirst); + const tail = history.slice(-keepLast); + const middle = history.slice(keepFirst, history.length - keepLast); + + const summaryPrompt = buildSummaryPrompt(middle); + + const result = await opts.provider.runTurn({ + model: opts.summarizerModel ?? 'deepseek-chat', + systemPrompt: + 'You compress long agent conversations. Output a TERSE summary preserving: ' + + '(1) what files were read/modified and key contents; ' + + '(2) what bugs/insights were discovered; ' + + '(3) what was decided. Drop verbose tool output. Use bullet points. No preamble.', + tools: [], + messages: [ + { + role: 'user', + content: [{ type: 'text', text: summaryPrompt }], + }, + ], + maxTokens: opts.summaryMaxTokens ?? DEFAULT_SUMMARY_TOKENS, + temperature: 0.2, + }); + + const summaryText = + result.content + .filter((b): b is TextBlock => b.type === 'text') + .map((b) => b.text) + .join('\n') + .trim() || '[compaction failed — no summary returned]'; + + const summaryMsg: StoredMessage = { + role: 'assistant', + content: [ + { + type: 'text', + text: `[Conversation compacted — ${middle.length} messages summarized below]\n\n${summaryText}`, + }, + ], + timestamp: new Date().toISOString(), + }; + + return { + history: [...head, summaryMsg, ...tail], + messagesRemoved: middle.length, + usage: { + inputTokens: result.usage.inputTokens, + outputTokens: result.usage.outputTokens, + }, + summaryText, + }; +} + +function buildSummaryPrompt(middle: StoredMessage[]): string { + const lines: string[] = ['Summarize this conversation segment:']; + lines.push(''); + for (const msg of middle) { + const role = msg.role === 'user' ? 'USER' : 'ASSISTANT'; + for (const block of msg.content) { + const flat = renderBlockBrief(block); + if (flat) lines.push(`${role}: ${flat}`); + } + } + return lines.join('\n'); +} + +function renderBlockBrief(block: ContentBlock): string { + if (block.type === 'text') return block.text.slice(0, 500); + if (block.type === 'thinking') return ''; // skip thinking — internal + if (block.type === 'tool_use') + return `[tool: ${block.name} ${truncate(JSON.stringify(block.input), 200)}]`; + if (block.type === 'tool_result') + return `[tool result${block.is_error ? ' (error)' : ''}: ${truncate(block.content, 300)}]`; + return ''; +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} + +/** + * Decide whether compaction is needed based on token usage. + * Trigger at 80% of context window by default; configurable via threshold. + */ +export function shouldCompact(usage: { + inputTokens: number; + outputTokens: number; + contextWindow: number; + threshold?: number; +}): boolean { + const used = usage.inputTokens + usage.outputTokens; + const ratio = used / usage.contextWindow; + return ratio >= (usage.threshold ?? 0.8); +} diff --git a/packages/core/src/harness/index.ts b/packages/core/src/harness/index.ts index 82ffd79..a6841ac 100644 --- a/packages/core/src/harness/index.ts +++ b/packages/core/src/harness/index.ts @@ -5,3 +5,10 @@ // land in M3c+. export { dispatchToolCall, type DispatchRequest, type DispatchVerdict } from './tool-dispatcher.js'; + +export { + StatusLineRunner, + runStatusLineCommand, + type StatusLineRunnerOpts, + type StatusLinePayload, +} from './statusline.js'; diff --git a/packages/core/src/harness/statusline.test.ts b/packages/core/src/harness/statusline.test.ts new file mode 100644 index 0000000..ecffc5c --- /dev/null +++ b/packages/core/src/harness/statusline.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { runStatusLineCommand, StatusLineRunner, type StatusLinePayload } from './statusline.js'; + +const samplePayload: StatusLinePayload = { + session_id: 'sess-1', + model: 'deepseek-chat', + cwd: '/tmp', + mode: 'default', + effort: 'medium', + version: '0.1.0', +}; + +describe('runStatusLineCommand', () => { + it('runs a command and returns its stdout (trimmed)', async () => { + const r = await runStatusLineCommand( + { type: 'command', command: 'echo "main · 2 modified"' }, + JSON.stringify(samplePayload), + ); + expect(r).toBe('main · 2 modified'); + }); + + it('feeds JSON payload to stdin', async () => { + const r = await runStatusLineCommand( + { type: 'command', command: "grep -o 'plan-mode-x' | head -1" }, + JSON.stringify({ ...samplePayload, mode: 'plan-mode-x' }), + ); + expect(r).toContain('plan-mode-x'); + }); + + it('caps output at 200 chars', async () => { + const r = await runStatusLineCommand( + { type: 'command', command: 'printf "x%.0s" $(seq 1 500)' }, + '', + ); + expect(r.length).toBeLessThanOrEqual(200); + }); + + it('returns empty string on timeout', async () => { + const r = await runStatusLineCommand({ type: 'command', command: 'sleep 10' }, ''); + expect(r).toBe(''); + }, 5000); + + it('returns empty string when command fails', async () => { + const r = await runStatusLineCommand({ type: 'command', command: 'exit 1' }, ''); + expect(r).toBe(''); + }); + + it('returns empty when config has no command', async () => { + const r = await runStatusLineCommand({ type: 'command', command: '' }, ''); + expect(r).toBe(''); + }); +}); + +describe('StatusLineRunner', () => { + it('calls onUpdate on tick + only when text changes', async () => { + let counter = 0; + const updates: string[] = []; + const runner = new StatusLineRunner({ + config: { + type: 'command', + // Output two distinct lines on first vs subsequent invocations + command: + 'if [ "$(cat /tmp/dc-sl-counter 2>/dev/null)" = "ok" ]; then echo same; else echo first; echo ok > /tmp/dc-sl-counter; fi', + }, + payload: () => samplePayload, + onUpdate: (text) => { + updates.push(text); + counter++; + }, + debounceMs: 50, + }); + runner.start(); + await new Promise((r) => setTimeout(r, 250)); + runner.stop(); + // First tick should produce a change; subsequent identical "same" lines should NOT trigger more updates + expect(updates).toContain('first'); + // Cleanup + await new Promise((r) => setTimeout(r, 10)); + expect(counter).toBeGreaterThan(0); + // Remove tmp marker for repeat runs + try { + const { unlink } = await import('node:fs/promises'); + await unlink('/tmp/dc-sl-counter').catch(() => {}); + } catch { + /* ignore */ + } + }, 5000); + + it('honors DEEPCODE_STATUS_LINE_DEBOUNCE_MS env override', () => { + process.env.DEEPCODE_STATUS_LINE_DEBOUNCE_MS = '1234'; + const runner = new StatusLineRunner({ + config: { type: 'command', command: 'echo x' }, + payload: () => samplePayload, + onUpdate: () => {}, + }); + // Read private field via cast + expect((runner as unknown as { debounceMs: number }).debounceMs).toBe(1234); + delete process.env.DEEPCODE_STATUS_LINE_DEBOUNCE_MS; + }); +}); diff --git a/packages/core/src/harness/statusline.ts b/packages/core/src/harness/statusline.ts new file mode 100644 index 0000000..e9a252d --- /dev/null +++ b/packages/core/src/harness/statusline.ts @@ -0,0 +1,133 @@ +// statusLine command runner — periodically exec a user-defined command, +// pipe session JSON to its stdin, render stdout in the CLI/GUI status area. +// Spec: docs/DEVELOPMENT_PLAN.md §3.15.8 + +import { spawn } from 'node:child_process'; +import type { StatusLineConfig } from '../config/types.js'; + +export interface StatusLinePayload { + session_id: string; + model: string; + cwd: string; + mode: string; + effort: string; + transcript_path?: string; + cost?: { yuan: number }; + version: string; + output_style?: string; +} + +export interface StatusLineRunnerOpts { + config: StatusLineConfig; + /** Function the runner calls to get the latest payload. */ + payload: () => StatusLinePayload; + /** Function the runner calls with new stdout text whenever it changes. */ + onUpdate: (text: string) => void; + /** Refresh period in ms (default 5000). Read from + * DEEPCODE_STATUS_LINE_DEBOUNCE_MS env var if set. */ + debounceMs?: number; +} + +const DEFAULT_DEBOUNCE_MS = 5000; +const COMMAND_TIMEOUT_MS = 2000; // statusline commands should be quick + +export class StatusLineRunner { + private readonly opts: StatusLineRunnerOpts; + private readonly debounceMs: number; + private timer: NodeJS.Timeout | null = null; + private lastText: string = ''; + private running = false; + + constructor(opts: StatusLineRunnerOpts) { + this.opts = opts; + this.debounceMs = + opts.debounceMs ?? + (process.env.DEEPCODE_STATUS_LINE_DEBOUNCE_MS + ? Number.parseInt(process.env.DEEPCODE_STATUS_LINE_DEBOUNCE_MS, 10) + : DEFAULT_DEBOUNCE_MS); + } + + start(): void { + if (this.running) return; + this.running = true; + // Fire once immediately, then schedule periodic + this.tick(); + this.timer = setInterval(() => this.tick(), this.debounceMs); + } + + stop(): void { + this.running = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** Force an immediate refresh (e.g. after the model changes). */ + refresh(): void { + if (this.running) this.tick(); + } + + private async tick(): Promise { + const payload = this.opts.payload(); + const stdin = JSON.stringify(payload); + const text = await runStatusLineCommand(this.opts.config, stdin); + if (text !== this.lastText) { + this.lastText = text; + this.opts.onUpdate(text); + } + } +} + +/** + * Execute the statusLine command with the JSON payload on stdin. + * Returns the trimmed stdout. On failure returns an empty string. + */ +export function runStatusLineCommand( + config: StatusLineConfig, + stdinPayload: string, +): Promise { + return new Promise((resolveResult) => { + if (config.type !== 'command' || !config.command) { + resolveResult(''); + return; + } + const child = spawn('/bin/sh', ['-c', config.command]); + let stdout = ''; + let killed = false; + const timer = setTimeout(() => { + killed = true; + child.kill('SIGKILL'); + child.stdout?.destroy(); + child.stderr?.destroy(); + }, COMMAND_TIMEOUT_MS); + child.stdout.on('data', (c: Buffer) => { + stdout += c.toString('utf8'); + }); + // Suppress unhandled EPIPE if the script doesn't read stdin + child.stdin.on('error', () => {}); + child.on('error', () => { + clearTimeout(timer); + resolveResult(''); + }); + child.on('close', () => { + clearTimeout(timer); + if (killed) { + resolveResult(''); + return; + } + // Cap output at 200 chars (matches plan-mentioned constraint) + resolveResult(stdout.trim().slice(0, 200)); + }); + try { + child.stdin.write(stdinPayload); + } catch { + /* pipe closed */ + } + try { + child.stdin.end(); + } catch { + /* already ended */ + } + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f7d0f97..28285d9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -117,8 +117,24 @@ export { type LoadMemoryOpts, } from './memory/index.js'; -// Harness (M3b — tool dispatcher gates: mode × permission × hooks) -export { dispatchToolCall, type DispatchRequest, type DispatchVerdict } from './harness/index.js'; +// Harness (M3b — tool dispatcher gates; M3c — statusLine runner) +export { + dispatchToolCall, + StatusLineRunner, + runStatusLineCommand, + type DispatchRequest, + type DispatchVerdict, + type StatusLineRunnerOpts, + type StatusLinePayload, +} from './harness/index.js'; + +// Compaction (M3c) +export { + compact, + shouldCompact, + type CompactionOpts, + type CompactionResult, +} from './compaction/index.js'; // Agent loop's approval callback type (M3b) export type { ApprovalCallback } from './agent.js';