diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 9dc3792..5d3a019 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -254,4 +254,49 @@ describe('runAgent', () => { expect(lastMsg.content[0].content).toContain('X-content'); } }); + + it('prepends a block to the user message by default', async () => { + const provider = new MockProvider([endTurn('hi')]); + const tools = new ToolRegistry(); + await runAgent({ + provider, + tools, + systemPrompt: '', + userMessage: 'do the thing', + model: 'deepseek-chat', + cwd, + }); + const sentMessages = provider.received[0]!.messages; + const firstUser = sentMessages[0] as StoredMessage; + const text = firstUser.content.find((c) => c.type === 'text'); + expect(text?.type).toBe('text'); + if (text?.type === 'text') { + expect(text.text).toMatch(//); + expect(text.text).toMatch(/Today's date/); + expect(text.text).toMatch(/Current working directory/); + expect(text.text).toMatch(/do the thing$/); + } + }); + + it('honors systemReminders: false to skip injection entirely', async () => { + const provider = new MockProvider([endTurn('hi')]); + const tools = new ToolRegistry(); + await runAgent({ + provider, + tools, + systemPrompt: '', + userMessage: 'no reminder please', + model: 'deepseek-chat', + cwd, + systemReminders: false, + }); + const firstUser = provider.received[0]!.messages[0] as StoredMessage; + const text = firstUser.content[0]; + if (text?.type === 'text') { + expect(text.text).toBe('no reminder please'); + expect(text.text).not.toMatch(//); + } else { + expect.fail('expected text block'); + } + }); }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7f5142e..579e386 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -7,6 +7,7 @@ import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatche import type { HookDispatcher } from './hooks/index.js'; import type { Mode } from './types.js'; import type { Provider } from './providers/types.js'; +import { buildSystemReminders, type ReminderType } from './reminders/index.js'; import { SessionManager } from './sessions/index.js'; import type { ToolRegistry } from './tools/registry.js'; import type { @@ -63,6 +64,9 @@ export interface RunAgentOptions { keepFirstPairs?: number; keepLastMessages?: number; }; + /** Inject system reminders before the user message (date, todos, etc). + * Pass `false` to disable; pass a partial list to limit which builders run. */ + systemReminders?: false | { enabled?: ReminderType[] }; } export interface RunAgentResult { @@ -87,11 +91,30 @@ export async function runAgent(opts: RunAgentOptions): Promise { let history: StoredMessage[] = [...(opts.history ?? [])]; let snapshotSeq = (await opts.session?.manager.snapshots(opts.session.id))?.length ?? 0; - // Append the user message first (if provided) + // Append the user message first (if provided). When systemReminders is + // enabled (default), prepend a block ahead of the user + // text so the model sees pending todos / date / cwd / etc. if (opts.userMessage !== undefined) { + let userText = opts.userMessage; + if (opts.systemReminders !== false) { + try { + const block = await buildSystemReminders( + { + cwd: opts.cwd, + sessionDir: opts.session + ? `${opts.session.manager.root}/${opts.session.id}` + : undefined, + }, + opts.systemReminders ?? {}, + ); + if (block) userText = `${block}\n\n${userText}`; + } catch { + /* reminder failures must not abort the agent */ + } + } const userMsg: StoredMessage = { role: 'user', - content: [{ type: 'text', text: opts.userMessage }], + content: [{ type: 'text', text: userText }], timestamp: new Date().toISOString(), }; history.push(userMsg); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c9ef159..adbf83e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -216,6 +216,20 @@ export { type PluginCapabilityBridge, } from './plugins/index.js'; +// System reminders (M3c-rest — date / cwd / todos / external file mods / AGENTS.md missing) +export { + buildSystemReminders, + prependReminders, + dateReminder, + cwdReminder, + agentsMdMissingReminder, + todosPendingReminder, + externalFileModifiedReminder, + type ReminderContext, + type ReminderOptions, + type ReminderType, +} from './reminders/index.js'; + // Sub-agents (M4 — .deepcode/agents/*.md) export { loadSubAgents, diff --git a/packages/core/src/reminders/index.test.ts b/packages/core/src/reminders/index.test.ts new file mode 100644 index 0000000..3c57767 --- /dev/null +++ b/packages/core/src/reminders/index.test.ts @@ -0,0 +1,222 @@ +import { promises as fs } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { TodoWriteTool } from '../tools/todo.js'; +import { + agentsMdMissingReminder, + buildSystemReminders, + cwdReminder, + dateReminder, + externalFileModifiedReminder, + prependReminders, + todosPendingReminder, +} from './index.js'; + +describe('dateReminder', () => { + it('formats today as YYYY-MM-DD UTC', () => { + const r = dateReminder({ cwd: '/x', now: () => new Date(Date.UTC(2026, 4, 7)) }); + expect(r).toContain('2026-05-07'); + expect(r).toContain('UTC'); + }); +}); + +describe('cwdReminder', () => { + it('shows the cwd literally', () => { + expect(cwdReminder({ cwd: '/my/project' })).toBe('Current working directory: /my/project'); + }); +}); + +describe('agentsMdMissingReminder', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dc-rem-agents-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns null when AGENTS.md exists', async () => { + await fs.writeFile(join(dir, 'AGENTS.md'), 'hello'); + expect(await agentsMdMissingReminder({ cwd: dir })).toBeNull(); + }); + + it('returns null when DEEPCODE.md exists', async () => { + await fs.writeFile(join(dir, 'DEEPCODE.md'), 'x'); + expect(await agentsMdMissingReminder({ cwd: dir })).toBeNull(); + }); + + it('returns null when CLAUDE.md exists (compat)', async () => { + await fs.writeFile(join(dir, 'CLAUDE.md'), 'x'); + expect(await agentsMdMissingReminder({ cwd: dir })).toBeNull(); + }); + + it('returns nudge when neither exists', async () => { + const r = await agentsMdMissingReminder({ cwd: dir }); + expect(r).toBeTruthy(); + expect(r).toMatch(/AGENTS\.md/); + expect(r).toMatch(/\/init/); + }); +}); + +describe('todosPendingReminder', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dc-rem-todos-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns null when sessionDir is undefined', async () => { + expect(await todosPendingReminder({ cwd: '/x' })).toBeNull(); + }); + + it('returns null when no todos exist', async () => { + expect(await todosPendingReminder({ cwd: '/x', sessionDir: dir })).toBeNull(); + }); + + it('returns null when all todos are completed', async () => { + await TodoWriteTool.execute( + { + todos: [ + { content: 'A', activeForm: 'A-ing', status: 'completed' }, + { content: 'B', activeForm: 'B-ing', status: 'completed' }, + ], + }, + { cwd: '/x', sessionDir: dir }, + ); + expect(await todosPendingReminder({ cwd: '/x', sessionDir: dir })).toBeNull(); + }); + + it('lists in_progress + pending items, with activeForm for in_progress', async () => { + await TodoWriteTool.execute( + { + todos: [ + { content: 'Write tests', activeForm: 'Writing tests', status: 'in_progress' }, + { content: 'Open PR', activeForm: 'Opening PR', status: 'pending' }, + { content: 'Plan', activeForm: 'Planning', status: 'completed' }, + ], + }, + { cwd: '/x', sessionDir: dir }, + ); + const r = await todosPendingReminder({ cwd: '/x', sessionDir: dir }); + expect(r).toBeTruthy(); + expect(r).toMatch(/Writing tests/); // in_progress uses activeForm + expect(r).toMatch(/Open PR/); + expect(r).not.toMatch(/Plan(?!ning)/); // completed is excluded + }); +}); + +describe('externalFileModifiedReminder', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dc-rem-files-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('returns null when no known files', async () => { + expect(await externalFileModifiedReminder({ cwd: '/x' })).toBeNull(); + }); + + it('returns null when known files mtimes match', async () => { + const fp = join(dir, 'a.txt'); + await fs.writeFile(fp, 'hi'); + const stat = await fs.stat(fp); + const r = await externalFileModifiedReminder({ + cwd: '/x', + knownFiles: new Map([[fp, stat.mtimeMs]]), + }); + expect(r).toBeNull(); + }); + + it('lists files whose mtime drifted by more than 1s', async () => { + const fp = join(dir, 'a.txt'); + await fs.writeFile(fp, 'hi'); + // Simulate "agent saw it 10s ago" by providing an older mtime + const old = Date.now() - 10_000; + const r = await externalFileModifiedReminder({ + cwd: '/x', + knownFiles: new Map([[fp, old]]), + }); + expect(r).toBeTruthy(); + expect(r).toMatch(/Files modified externally/); + expect(r).toContain(fp); + }); + + it('flags files that have been deleted', async () => { + const r = await externalFileModifiedReminder({ + cwd: '/x', + knownFiles: new Map([['/tmp/does-not-exist-' + Date.now(), Date.now()]]), + }); + expect(r).toBeTruthy(); + }); + + it('truncates list at 5 items with a "more" suffix', async () => { + const knownFiles = new Map(); + for (let i = 0; i < 10; i++) { + const p = join(dir, `f${i}.txt`); + await fs.writeFile(p, 'x'); + knownFiles.set(p, Date.now() - 10_000); + } + const r = await externalFileModifiedReminder({ cwd: '/x', knownFiles }); + expect(r).toMatch(/and 5 more/); + }); +}); + +describe('buildSystemReminders', () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dc-rem-build-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('wraps every reminder in a single block', async () => { + const r = await buildSystemReminders({ cwd: dir }); + expect(r).toMatch(/^/); + expect(r).toMatch(/<\/system-reminder>$/); + expect(r).toMatch(/Today's date/); + expect(r).toMatch(/Current working directory/); + }); + + it('returns null when every builder returns null', async () => { + // Existing AGENTS.md + no sessionDir + no known files → only date+cwd + // remain, both always fire. So we need to disable them via opts to test. + const r = await buildSystemReminders({ cwd: dir }, { enabled: ['todos-pending'] }); + expect(r).toBeNull(); + }); + + it('respects `enabled` filter', async () => { + const r = await buildSystemReminders({ cwd: dir }, { enabled: ['date'] }); + expect(r).toMatch(/Today's date/); + expect(r).not.toMatch(/Current working directory/); + }); + + it('does not poison the batch on one builder error', async () => { + // sessionDir points at a non-existent location → todos read returns [] + // (silent), so other builders still fire. + const r = await buildSystemReminders({ cwd: dir, sessionDir: '/no/such/path' }); + expect(r).toMatch(/Today's date/); + }); +}); + +describe('prependReminders', () => { + it('prepends block + blank line + user message', async () => { + const out = await prependReminders('hi', { cwd: '/x' }, { enabled: ['date'] }); + expect(out).toMatch(/^[\s\S]+<\/system-reminder>\n\nhi$/); + }); + + it('returns the user message unchanged when no reminders fire', async () => { + const out = await prependReminders( + 'hi', + { cwd: '/x', sessionDir: '/no' }, + { enabled: ['todos-pending'] }, + ); + expect(out).toBe('hi'); + }); +}); diff --git a/packages/core/src/reminders/index.ts b/packages/core/src/reminders/index.ts new file mode 100644 index 0000000..84f73ca --- /dev/null +++ b/packages/core/src/reminders/index.ts @@ -0,0 +1,176 @@ +// System reminders — short, context-aware messages prepended to a user turn +// to refresh the agent's awareness of things it might forget over a long +// conversation (today's date, pending todos, files modified externally, etc.). +// +// Spec: docs/DEVELOPMENT_PLAN.md §3.6 "System-reminder injector (7 types)". +// Wired in by the agent loop just before sending the user message. +// +// Design choices: +// · Each builder is a pure function that returns `string | null`. +// · The composite buildSystemReminders() returns null if no reminders fire, +// so the agent loop can skip the injection entirely. +// · Reminders are wrapped in ... tags +// before the user message so the model treats them as authoritative. +// · Per-builder failure does NOT poison the whole batch — we catch + drop. + +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import type { TodoItem } from '../tools/todo.js'; +import { readTodos } from '../tools/todo.js'; + +export interface ReminderContext { + /** Current working directory. */ + cwd: string; + /** Optional session dir (where todos.json lives). */ + sessionDir?: string; + /** + * Files the agent has read or written in this session, with mtime at the + * time of access. Used to detect external modifications between turns. + */ + knownFiles?: Map; + /** Override `now()` for tests. */ + now?: () => Date; +} + +export interface ReminderOptions { + /** + * Which reminders to evaluate. If omitted, all builders run. + * Useful for opt-out via settings. + */ + enabled?: ReminderType[]; +} + +export type ReminderType = + | 'date' + | 'cwd' + | 'agents-md-missing' + | 'todos-pending' + | 'external-file-modified' + | 'plan-mode-active' + | 'no-test-yet'; + +/** + * Build the composite system-reminder block. Returns null if no individual + * reminder fires. + */ +export async function buildSystemReminders( + ctx: ReminderContext, + opts: ReminderOptions = {}, +): Promise { + const enabled = new Set(opts.enabled ?? ALL_TYPES); + const builders: Array<{ type: ReminderType; build: () => Promise }> = [ + { type: 'date', build: () => Promise.resolve(dateReminder(ctx)) }, + { type: 'cwd', build: () => Promise.resolve(cwdReminder(ctx)) }, + { type: 'agents-md-missing', build: () => agentsMdMissingReminder(ctx) }, + { type: 'todos-pending', build: () => todosPendingReminder(ctx) }, + { type: 'external-file-modified', build: () => externalFileModifiedReminder(ctx) }, + ]; + + const parts: string[] = []; + for (const { type, build } of builders) { + if (!enabled.has(type)) continue; + try { + const out = await build(); + if (out) parts.push(out); + } catch { + // ignore individual builder failures + } + } + if (parts.length === 0) return null; + return `\n${parts.join('\n\n')}\n`; +} + +const ALL_TYPES: ReminderType[] = [ + 'date', + 'cwd', + 'agents-md-missing', + 'todos-pending', + 'external-file-modified', +]; + +// ────────────────────────────────────────────────────────────────────────── +// Individual reminder builders +// ────────────────────────────────────────────────────────────────────────── + +export function dateReminder(ctx: ReminderContext): string { + const now = ctx.now ? ctx.now() : new Date(); + const yyyy = now.getUTCFullYear(); + const mm = String(now.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(now.getUTCDate()).padStart(2, '0'); + return `Today's date is ${yyyy}-${mm}-${dd} (UTC).`; +} + +export function cwdReminder(ctx: ReminderContext): string { + return `Current working directory: ${ctx.cwd}`; +} + +export async function agentsMdMissingReminder(ctx: ReminderContext): Promise { + // If AGENTS.md or DEEPCODE.md exists in cwd, no reminder. Otherwise nudge. + for (const name of ['AGENTS.md', 'DEEPCODE.md', 'CLAUDE.md']) { + try { + await fs.access(join(ctx.cwd, name)); + return null; + } catch { + /* keep checking */ + } + } + return `No AGENTS.md / DEEPCODE.md found in cwd. You can ask the user to run \`/init\` to create one.`; +} + +export async function todosPendingReminder(ctx: ReminderContext): Promise { + if (!ctx.sessionDir) return null; + let todos: TodoItem[]; + try { + todos = await readTodos(ctx.sessionDir); + } catch { + return null; + } + if (todos.length === 0) return null; + const counts = { pending: 0, in_progress: 0, completed: 0 }; + for (const t of todos) counts[t.status]++; + // Surface only if any work remains and at least one is in_progress OR pending. + if (counts.in_progress === 0 && counts.pending === 0) return null; + const lines = [`Pending todos (${counts.pending + counts.in_progress} of ${todos.length}):`]; + for (const t of todos) { + if (t.status === 'completed') continue; + const marker = t.status === 'in_progress' ? '●' : '○'; + const txt = t.status === 'in_progress' ? t.activeForm : t.content; + lines.push(` ${marker} ${txt}`); + } + return lines.join('\n'); +} + +export async function externalFileModifiedReminder( + ctx: ReminderContext, +): Promise { + if (!ctx.knownFiles || ctx.knownFiles.size === 0) return null; + const drifted: Array<{ path: string; was: number; now: number }> = []; + for (const [path, was] of ctx.knownFiles) { + try { + const stat = await fs.stat(path); + const now = stat.mtimeMs; + if (Math.abs(now - was) > 1000) drifted.push({ path, was, now }); + } catch { + // file no longer exists — count as drifted + drifted.push({ path, was, now: 0 }); + } + } + if (drifted.length === 0) return null; + const list = drifted.slice(0, 5).map((d) => ` - ${d.path}`).join('\n'); + const more = drifted.length > 5 ? `\n ... and ${drifted.length - 5} more` : ''; + return `Files modified externally since you last read them:\n${list}${more}\nRe-read them with the Read tool before editing.`; +} + +/** + * Convenience: format reminders to be appended to the front of the user + * message text. Returns the original text unchanged if no reminders fire. + */ +export async function prependReminders( + userMessage: string, + ctx: ReminderContext, + opts?: ReminderOptions, +): Promise { + const block = await buildSystemReminders(ctx, opts); + if (!block) return userMessage; + return `${block}\n\n${userMessage}`; +}