Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ async function main(): Promise<number> {
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,
});
}

Expand Down
43 changes: 41 additions & 2 deletions apps/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand Down Expand Up @@ -79,7 +92,21 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
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
Expand Down Expand Up @@ -128,11 +155,22 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
}

// 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({
Expand Down Expand Up @@ -210,6 +248,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
model: ctx.model,
maxTokens,
temperature,
maxTurns: opts.maxTurns,
cwd: ctx.cwd,
session: { manager: sessions, id: session.id },
mode: ctx.mode as Mode,
Expand Down
26 changes: 26 additions & 0 deletions docs/milestones/M3c-compaction-statusline.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion docs/milestones/M3c-mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/compaction/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProviderResult> {
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);
});
});
Loading
Loading