From 26efb04fd2d18cea03e1eafbf1f34a5426cfb183 Mon Sep 17 00:00:00 2001 From: Edmund Raile Date: Sat, 17 Oct 2026 21:08:22 +0200 Subject: [PATCH 1/2] Refresh current project guidance at every agent request --- README.md | 5 +- src/agents.ts | 10 ++ src/guidance.ts | 195 ++++++++++++++++++++++++++++++++ src/index.ts | 18 +-- test/agents.test.ts | 10 +- test/guidance-lifecycle.test.ts | 136 ++++++++++++++++++++++ test/guidance.test.ts | 114 +++++++++++++++++++ 7 files changed, 476 insertions(+), 12 deletions(-) create mode 100644 src/guidance.ts create mode 100644 test/guidance-lifecycle.test.ts create mode 100644 test/guidance.test.ts diff --git a/README.md b/README.md index 7e337d6..48e4412 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ Profile data lives in `~/.optchat/profiles//` (override the root with `OPT | `active-memory.json`, `memories//` | After an import: pointer to the active `main/` and `tree/`. Older generations are kept. | | `imports/pending.json` | Resumable import state | | `AGENTS.md` | Profile instructions | +| `guidance/` | Immutable guidance revisions and original source metadata | | `config.json` | Compactor and subagent models | | `pending-inputs.json`, `pending-reports.json` | Recovery journals | | `runs/` | Subagent sessions and run metadata | @@ -228,7 +229,9 @@ Pipe names and permissions: [docs/storage.md](docs/storage.md#windows-difference - **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials. - **Use worktrees** when parallel agents edit the same repository; they share the filesystem. -- **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only. +- **Instructions**: before every model request, the main agent and subagents check applicable global, ancestor/project and profile guidance, in that order, using Pi's file priority (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) and worktree shadowing. The profile's `AGENTS.md` comes last; a newer file revision does not override an explicit user instruction. Skills remain available; prompt templates work in the main session only. +- **Changing guidance**: edits apply at each agent's next request, including tool continuations, report turns and resumed work. Stable instructions precede complete current guidance, then memory and recent work. A real change adds a short notice after the recent work, with mechanical excerpts and a path to the previous copy. That notice stays at its original boundary; after refresh it is labeled as an earlier transition. Unchanged text and timestamp-only touches add nothing. No manual reread is needed. +- **Guidance failures**: missing sources are removed at the next request; broken links, read errors, invalid UTF-8, files or combined guidance over 1 MB, and archive-write failures stop that request. Guidance is never silently truncated or replaced by stale text. Copies live under `guidance/sources/`, retain the original basename and source path, and deduplicate contents. Original bytes and BOM-stripped supplied text are distinguished. Keep this directory with your profile; no automatic archive pruning occurs. - **Tab title**: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename). - **Images** you paste, or a tool returns, are kept under the profile's `images/` (once each, shrunk to 2,048 px on the long side and about 1.5 MB with Pi's own resizer; smaller ones stay as they are). The log names each as `[image ]`, and `zoom(id, 1)` returns a message's images with its text. Summaries stay text. Imported conversations keep a text placeholder. - **Long turns** (#146): Pi's auto-compaction is off for the main agent, since memory replaces it. Once the current turn's verbatim messages pass the Turn refresh threshold (30% of the model's window by default), they are flushed to memory, the view is rebuilt, and only the task message plus the last tool call and its result stay verbatim; the earlier steps are the view's last lines, a zoom away. In an A/B on a 200K model, turns that otherwise gave up around 100K tokens kept going and covered the whole task. `OPTCHAT_REFRESH_AT` (a fraction, e.g. `0.3`; `0` is off) overrides the setting. Subagents use Pi's own compaction instead, starting at about the same point. diff --git a/src/agents.ts b/src/agents.ts index d65280d..2605252 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { Guidance, guidanceState, GUIDANCE_STATE } from './guidance.ts'; import { existsSync, readFileSync, statSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; @@ -246,6 +247,7 @@ export class Children { private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model; thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) { const { id, directory, depth, parentId, connected } = o; + const guidance = new Guidance(this.profileDirectory); const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels; const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n'); // The user's settings list their installed packages; a copy in memory keeps the child from writing them back. @@ -260,8 +262,16 @@ export class Children { // Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs. pi.on('before_agent_start', event => { event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch); + event.systemPromptOptions.contextFiles = []; event.systemPromptOptions.sections.instructions = instructions; }); + pi.on('context_with_system', (event, ctx) => { + try { return guidance.prepare(event.messages, directory, guidanceState(ctx), state => pi.appendEntry(GUIDANCE_STATE, state)); } + catch (error) { + ctx.abort(); this.warn(String(error)); + return { messages: [{ role: 'system' as const, content: 'OptChat guidance unavailable. Stop.', timestamp: 0 }] }; + } + }); pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload); }], }); diff --git a/src/guidance.ts b/src/guidance.ts new file mode 100644 index 0000000..3fd999f --- /dev/null +++ b/src/guidance.ts @@ -0,0 +1,195 @@ +import { createHash } from 'node:crypto'; +import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { getCurrentSystemMessage, getSystemMessageText } from '@earendil-works/pi-ai'; +import { getAgentDir, loadProjectContextFiles, type ExtensionContext } from '@earendil-works/pi-coding-agent'; +import { atomicWrite } from './store.ts'; + +export const GUIDANCE_STATE = 'optchat.guidance'; +const LIMIT = 1_000_000; +const CANDIDATES = ['AGENTS.override.md', 'AGENTS.md', 'AGENTS.MD', 'CLAUDE.md', 'CLAUDE.MD']; +const hash = (text: string) => createHash('sha256').update(text).digest('hex'); +const normalize = (text: string) => text.replace(/^\uFEFF/, ''); +const xml = (text: string) => text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); +export interface GuidanceSource { path: string; scope: string; content: string; supplied: string; modified: string } +export interface GuidanceFile { path: string; scope: string; version: string; archive: string; supplied: string } +export interface GuidanceRevision { id: string; files: GuidanceFile[] } +interface Transition { before: string; after: string; anchor: string; text: string } +export interface GuidanceState { revision: GuidanceRevision; transitions: Transition[] } + +/** Read once through one handle. Never truncate, silently fall back, or mistake a failed read for deletion. */ +function readSource(path: string, scope: string): GuidanceSource | undefined { + let fd: number; + try { fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // A dangling symlink is a broken source, not a deliberate removal. + try { lstatSync(path); } catch (missing) { if ((missing as NodeJS.ErrnoException).code === 'ENOENT') return undefined; } + } + throw new Error(`Cannot read current guidance ${path}: ${String(error)}`); + } + try { + const before = fstatSync(fd); + if (!before.isFile()) throw new Error('not a regular file'); + if (before.size > LIMIT) throw new Error(`exceeds ${LIMIT.toLocaleString('en-US')} bytes; no partial guidance will be sent`); + const buffer = Buffer.alloc(Math.min(before.size + 1, LIMIT + 1)); + const length = readSync(fd, buffer, 0, buffer.length, 0), bytes = buffer.subarray(0, length), after = fstatSync(fd); + if (bytes.length > LIMIT || bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) + throw new Error('changed while being read; retry the request'); + const content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + return { path: resolve(path), scope, content, supplied: normalize(content), modified: after.mtime.toISOString() }; + } catch (error) { throw new Error(`Cannot read current guidance ${path}: ${String(error)}`); } + finally { closeSync(fd); } +} + +/** Match Pi's candidate priority, global → ancestors → cwd ordering and linked-worktree shadowing. + * Pi's loader warns and skips unreadable sources; preflight prevents that weaker guarantee here. */ +export function currentGuidance(cwd: string, profile: string, agentDir = getAgentDir()): GuidanceSource[] { + const dirs = new Set([resolve(agentDir)]); + for (let dir = resolve(cwd); ; dir = dirname(dir)) { dirs.add(dir); if (dirname(dir) === dir) break; } + const checked = new Map(); + for (const dir of dirs) { + for (const name of CANDIDATES) { + const source = readSource(join(dir, name), dir === resolve(agentDir) ? 'global' : `project:${dir}`); + if (source) { checked.set(source.path, source); break; } + } + } + const files = loadProjectContextFiles({ cwd, agentDir }).map(file => { + const source = checked.get(resolve(file.path)); + if (!source || source.supplied !== file.content) throw new Error(`Guidance changed during discovery: ${file.path}; retry the request`); + return source; + }); + const personal = readSource(join(profile, 'AGENTS.md'), `profile:${resolve(profile)}`); + if (personal) { + // Profile instructions have always followed project instructions, even when the profile is the cwd. + const duplicate = files.findIndex(file => file.path === personal.path); + if (duplicate >= 0) files.splice(duplicate, 1); + files.push(personal); + } + if (files.reduce((sum, file) => sum + Buffer.byteLength(file.supplied), 0) > LIMIT) + throw new Error(`Current guidance exceeds ${LIMIT.toLocaleString('en-US')} bytes in total; no partial guidance will be sent`); + return files; +} + +/** Immutable, human-readable source copies. Metadata and supplied text are separate from the original bytes. */ +export class GuidanceArchive { + readonly directory: string; + constructor(profile: string) { this.directory = join(profile, 'guidance'); } + preserve(sources: GuidanceSource[]): GuidanceRevision { + const files = sources.map(source => { + const version = hash(source.content); + const label = source.path.replace(/[^a-zA-Z0-9._-]+/g, '%').slice(-100); + const dir = join(this.directory, 'sources', `${label}-${hash(source.path).slice(0, 16)}`, version); + const archive = join(dir, basename(source.path)); + const supplied = source.content === source.supplied ? archive : join(dir, 'supplied.txt'); + const keep = (path: string, text: string) => { + if (existsSync(path)) { if (readFileSync(path, 'utf8') !== text) throw new Error(`Damaged guidance archive: ${path}`); } + else atomicWrite(path, text); + }; + keep(archive, source.content); + if (supplied !== archive) keep(supplied, source.supplied); + const metadata = join(dir, 'metadata.json'); + if (!existsSync(metadata)) atomicWrite(metadata, JSON.stringify({ source: source.path, version, modified: source.modified, + captured: new Date().toISOString(), transformation: supplied === archive ? 'none' : 'leading UTF-8 BOM removed', supplied: basename(supplied) }, null, 2)); + return { path: source.path, scope: source.scope, version, archive, supplied }; + }); + const id = hash(JSON.stringify(files)); + const file = join(this.directory, 'revisions', `${id}.json`), text = JSON.stringify({ id, files }, null, 2); + if (existsSync(file)) { if (readFileSync(file, 'utf8') !== text) throw new Error(`Damaged guidance revision: ${file}`); } + else atomicWrite(file, text); + return { id, files }; + } + revision(id: string): GuidanceRevision { + if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid guidance revision'); + const revision = JSON.parse(readFileSync(join(this.directory, 'revisions', `${id}.json`), 'utf8')) as GuidanceRevision; + if (revision.id !== id || hash(JSON.stringify(revision.files)) !== id) throw new Error(`Damaged guidance revision: ${id}`); + return revision; + } +} + +const quote = (text: string) => JSON.stringify(text.replace(/\s+/g, ' ').trim().slice(0, 160)); +/** Mechanical excerpts, never a guessed semantic summary. Counts make broader edits explicit. */ +function difference(before: GuidanceRevision, after: GuidanceRevision) { + const changes: string[] = []; + for (const old of before.files) { + const next = after.files.find(file => file.path === old.path); + if (!next) { changes.push(`${old.path} no longer applies`); continue; } + if (old.version !== next.version) { + const a = readFileSync(old.supplied, 'utf8').split('\n'), b = readFileSync(next.supplied, 'utf8').split('\n'); + const oldLines = new Set(a), newLines = new Set(b); + const removed = a.filter(line => line.trim() && !newLines.has(line)), added = b.filter(line => line.trim() && !oldLines.has(line)); + changes.push(`${old.path}: ${removed.length || added.length ? `${removed.length} previous lines absent from current text and ${added.length} current lines absent from previous text` : 'line ordering, repetition or whitespace changed'} (previous excerpt ${quote(removed[0] ?? a.join('\n'))}; current excerpt ${quote(added[0] ?? b.join('\n'))}; excerpts are not an exhaustive description)`); + } + } + for (const file of after.files) if (!before.files.some(old => old.path === file.path)) changes.push(`${file.path} now applies`); + if (JSON.stringify(before.files.map(f => [f.path, f.scope])) !== JSON.stringify(after.files.map(f => [f.path, f.scope]))) + changes.push('the applicable sources, scopes or ordering changed'); + return `${changes.join('; ')}.`; +} + +export function guidanceKey(message: AgentMessage) { + return hash(JSON.stringify(message)); +} +export function guidanceState(ctx: ExtensionContext): GuidanceState | undefined { + const entry = ctx.sessionManager.getBranch().findLast(e => e.type === 'custom' && e.customType === GUIDANCE_STATE); + if (!entry || entry.type !== 'custom') return undefined; + const state = entry.data as GuidanceState; + if (!state?.revision?.id || !Array.isArray(state.transitions)) throw new Error('Invalid saved guidance state'); + return state; +} + +/** Project guidance is independent of the prompt Pi cached at turn start. Strip its original injection at construction. */ +export function guidancePrompt(prompt: string, sources: GuidanceSource[]) { + return `${prompt}\n\n\nCurrent applicable guidance, in established source order. A newer revision supersedes that source's older revision; it does not override explicit user instructions. Historical copies explain earlier work and do not become current instructions.\n${sources.map(source => `\n${source.supplied}\n`).join('\n\n')}\n`; +} + +// Pi maps hidden custom context to the provider's user-compatible role, including APIs that +// collapse later system messages into the head. It never enters the canonical session or memory. +function notice(content: string): AgentMessage { + return { role: 'custom', customType: 'optchat-guidance-update', display: false, + content: `\nHarness-supplied guidance transition, not a new user task.\n${content}\n`, timestamp: 0 }; +} + +export class Guidance { + readonly archive: GuidanceArchive; + private agentDir = getAgentDir(); + /** A custom Pi agent directory is visible in its loaded global source. */ + configure(files: readonly { path: string; content: string }[], cwd: string) { + const ancestors = new Set(); + for (let dir = resolve(cwd); ; dir = dirname(dir)) { ancestors.add(dir); if (dir === dirname(dir)) break; } + const global = files.find(file => !ancestors.has(dirname(resolve(file.path)))); + if (global) this.agentDir = dirname(resolve(global.path)); + } + constructor(readonly profile: string) { this.archive = new GuidanceArchive(profile); } + prepare(messages: AgentMessage[], cwd: string, previous: GuidanceState | undefined, save: (state: GuidanceState) => void, agentDir?: string) { + const sources = currentGuidance(cwd, this.profile, agentDir ?? this.agentDir), revision = this.archive.preserve(sources); + let state = previous; + if (previous?.revision.id !== revision.id) { + const transitions = [...previous?.transitions ?? []]; + if (previous) { + const before = this.archive.revision(previous.revision.id); + const paths = before.files.map(file => `${file.path} → ${file.supplied}`).join('\n') || '(no previous sources)'; + const text = `Project guidance has been updated directly in your context; the complete current contents are already supplied, so no manual reread is needed.\n${difference(before, revision)}\nThis revision applies from this step onward; earlier work may reflect earlier instructions and may need adaptation, but a difference alone does not establish an earlier instruction violation.\nPrevious guidance (historical copies):\n${paths}\nOpen a previous copy only if needed to understand something relevant to continuing the task; it does not replace current guidance.`; + const last = messages.findLast(message => message.role !== 'system'); + transitions.push({ before: before.id, after: revision.id, anchor: last ? guidanceKey(last) : '', text }); + } + state = { revision, transitions }; + save(state); + } + const head = getCurrentSystemMessage(messages); + if (!head) throw new Error('Cannot inject guidance without a system prompt'); + const output: AgentMessage[] = [{ role: 'system', content: guidancePrompt(getSystemMessageText(head), sources), toolsAdded: head.toolsAdded, timestamp: 0 }]; + const body = messages.filter(message => message.role !== 'system'); + const keys = body.map(guidanceKey); + // Once a boundary leaves the visible work, retain the latest such transition, explicitly as history. + const historical = state!.transitions.findLast(transition => !keys.includes(transition.anchor)); + for (const [index, message] of body.entries()) { + output.push(message); + if (index === 0 && historical) output.push(notice(`Earlier guidance transition (already applied, not a new change):\n${historical.text}`)); + for (const transition of state!.transitions) if (transition.anchor === keys[index]) + output.push(notice(transition.text)); + } + return { messages: output, revision }; + } +} diff --git a/src/index.ts b/src/index.ts index 2346a7a..acee6bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; import { Memory } from './memory.ts'; +import { Guidance, guidanceState, GUIDANCE_STATE } from './guidance.ts'; import { atomicWrite, storeFor } from './store.ts'; import { createCompressor } from './compactor.ts'; import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts'; @@ -48,7 +49,7 @@ const toggle = (prompt: string, line: string, on: boolean, after: string) => on /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) => allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch); -interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise } +interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; guidance: Guidance; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise } /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */ export const version = ((): string | undefined => { @@ -209,7 +210,7 @@ export default function optchat(pi: ExtensionAPI) { const inbox = new Inbox(dir); const recovered = pendingImport(dir) ? 0 : inbox.recover(memory); if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info'); - const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`, + const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => IMPORT_GUIDANCE, deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi), settings: () => config, hold: holdReports, summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) }); const loggedReports = new Set(memory.root.map(e => e.receipt)); @@ -218,7 +219,7 @@ export default function optchat(pi: ExtensionAPI) { .filter(r => !loggedReports.has(reportReceipt(r.text))); atomicWrite(pending, JSON.stringify(reports)); rememberProfile(name); - active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle(); + active = { name, dir, config, memory, guidance: new Guidance(dir), inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle(); if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); }); // A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it. if (ctx.mode !== 'print' && ctx.mode !== 'json') closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport); @@ -383,10 +384,12 @@ export default function optchat(pi: ExtensionAPI) { if (!active) return; // Plain Pi run: leave Pi's own prompt untouched. startRun(ctx); const a = required(); - // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last. + // Guidance is loaded once per model request below; Pi must not also inject its cached copy. syncSearch(a.config); event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch); - event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`; + a.guidance.configure(event.systemPromptOptions.contextFiles, ctx.cwd); + event.systemPromptOptions.contextFiles = []; + event.systemPromptOptions.sections.instructions = IMPORT_GUIDANCE; prompt = event.systemPrompt; }); const DUPLICATE_REPORT = '(duplicate report, already delivered)'; @@ -461,7 +464,8 @@ export default function optchat(pi: ExtensionAPI) { ctx.ui.notify(`OptChat moved ${Math.round(size / 4000)}k tokens of this turn into memory (${((Date.now() - began) / 1000).toFixed(1)}s).`, 'info'); } // Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached. - return { messages: buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, promptFor(prompt, a.config), previous, atWork(a.children.working)) }; + const messages = buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, promptFor(prompt, a.config), previous, atWork(a.children.working)); + return a.guidance.prepare(messages, ctx.cwd, guidanceState(ctx), state => pi.appendEntry(GUIDANCE_STATE, state)); } catch (error) { // Pi catches extension errors. Explicitly abort so it cannot fall back to old context. ctx.abort(); @@ -626,7 +630,7 @@ export default function optchat(pi: ExtensionAPI) { if (action === 'instructions') { const a = required(); const edited = await ctx.ui.editor(`${a.name} · AGENTS.md`, instructions(a.dir)); - if (edited !== undefined) { atomicWrite(join(a.dir, 'AGENTS.md'), edited); ctx.ui.notify('Saved. Applies to the next turn and newly spawned agents.', 'info'); } + if (edited !== undefined) { atomicWrite(join(a.dir, 'AGENTS.md'), edited); ctx.ui.notify('Saved. Applies to each agent’s next model request.', 'info'); } return; } if (action === 'browse') { diff --git a/test/agents.test.ts b/test/agents.test.ts index 7e4ac8a..e0b4f2a 100644 --- a/test/agents.test.ts +++ b/test/agents.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { createAssistantMessageEventStream, type AssistantMessage } from '@earendil-works/pi-ai'; +import { createAssistantMessageEventStream, getSystemMessageText, type AssistantMessage } from '@earendil-works/pi-ai'; import { createAgentSession, ModelRegistry, ModelRuntime } from '@earendil-works/pi-coding-agent'; import { Children, taskDirectory } from '../src/agents.ts'; import { STEERABLE } from '../src/prompts.ts'; @@ -257,6 +257,8 @@ test('children get the main agent\'s extensions, AGENTS.md files and skills, but writeFileSync(join(agentDir, 'settings.json'), JSON.stringify({ packages: [copy] })); writeFileSync(join(dir, 'AGENTS.md'), 'REPO_RULES'); writeFileSync(join(agentDir, 'AGENTS.md'), 'GLOBAL_RULES'); + const profile = join(dir, 'profile'); mkdirSync(profile); + writeFileSync(join(profile, 'AGENTS.md'), 'PROFILE_RULES'); mkdirSync(join(agentDir, 'skills', 'demo-skill'), { recursive: true }); writeFileSync(join(agentDir, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: Demo skill.\n---\nBody'); let system = ''; @@ -266,15 +268,15 @@ test('children get the main agent\'s extensions, AGENTS.md files and skills, but models: [{ id: 'child', name: 'Synthetic child', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 1000 }], streamSimple: (model, context) => { const head = context.messages.find(m => m.role === 'system'); - system = Object.values(head && 'sections' in head ? head.sections ?? {} : {}).join('\n'); + system = head?.role === 'system' ? getSystemMessageText(head) : ''; const stream = createAssistantMessageEventStream(); const message: AssistantMessage = { role: 'assistant', content: [{ type: 'text', text: 'done' }], api: model.api, model: model.id, provider: model.provider, stopReason: 'stop', timestamp: Date.now(), usage: emptyUsage() }; queueMicrotask(() => { stream.push({ type: 'done', reason: 'stop', message }); stream.end(); }); return stream; }, }); - const children = new Children(new Memory(dir, async input => input.source.slice(0, 100), () => {}), new ModelRegistry(runtime), () => ({ provider: 'optchat-test', model: 'child', thinking: 'minimal' }), () => 'PROFILE_RULES', - async () => {}, () => {}, dir, { settings: nested, createSession: options => createAgentSession({ ...options, modelRuntime: runtime }) }); + const children = new Children(new Memory(dir, async input => input.source.slice(0, 100), () => {}), new ModelRegistry(runtime), () => ({ provider: 'optchat-test', model: 'child', thinking: 'minimal' }), () => '', + async () => {}, () => {}, profile, { settings: nested, createSession: options => createAgentSession({ ...options, modelRuntime: runtime }) }); try { const [id] = await children.spawn([{ task: 'inspect tools' }], dir); const names = children.live(id)?.session.getAllTools().map(t => t.name) ?? []; diff --git a/test/guidance-lifecycle.test.ts b/test/guidance-lifecycle.test.ts new file mode 100644 index 0000000..eba4b56 --- /dev/null +++ b/test/guidance-lifecycle.test.ts @@ -0,0 +1,136 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createAssistantMessageEventStream, getSystemMessageText, type AssistantMessage, type Context } from '@earendil-works/pi-ai'; +import { createAgentSession, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, SettingsManager } from '@earendil-works/pi-coding-agent'; +import optchat from '../src/index.ts'; +import { Children } from '../src/agents.ts'; +import { Memory } from '../src/memory.ts'; +import { createProfile, loadConfig, profilePath, saveConfig } from '../src/profiles.ts'; +import { COMPACT, TURN_REFRESHED } from '../src/prompts.ts'; +import { REPORT_TYPE, textContent } from '../src/transcript.ts'; +import { emptyUsage } from '../src/usage.ts'; +import { GUIDANCE_STATE, type GuidanceState } from '../src/guidance.ts'; +import { removeDir } from './support.ts'; + +const system = (context: Context) => { const head = context.messages[0]; return head?.role === 'system' ? getSystemMessageText(head) : ''; }; +const notices = (context: Context) => context.messages.filter(m => textContent(m.content).includes('')); +async function runtime(dir: string, respond: (context: Context) => AssistantMessage['content']) { + const runtime = await ModelRuntime.create({ authPath: join(dir, 'auth.json'), modelsPath: null, modelsStorePath: join(dir, 'models.json'), refreshOnCreate: false }); + runtime.registerProvider('guidance-test', { baseUrl: 'https://invalid.local', apiKey: 'synthetic', api: 'openai-completions', + models: [{ id: 'model', name: 'Fixture', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }], + streamSimple(model, context) { + const content = system(context) === COMPACT ? [{ type: 'text' as const, text: 'Summary.' }] : respond(context); + const stopReason = content.some(b => b.type === 'toolCall') ? 'toolUse' : 'stop'; + const message: AssistantMessage = { role: 'assistant', content, stopReason, api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), usage: emptyUsage() }; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { stream.push({ type: 'done', reason: stopReason, message }); stream.end(); }); + return stream; + } }); + return runtime; +} + +test('main requests refresh guidance after tools, report wake, memory refresh and restart without logging notices', async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-main-')), cwd = join(dir, 'project'); mkdirSync(cwd); + const oldHome = process.env.OPTCHAT_HOME, oldBudget = process.env.OPTCHAT_REFRESH_AT; + process.env.OPTCHAT_HOME = dir; process.env.OPTCHAT_REFRESH_AT = '1'; + createProfile('fixture'); const profile = profilePath('fixture'), source = join(cwd, 'AGENTS.md'); + const config = loadConfig(profile); saveConfig(profile, { ...config, compactor: { provider: 'guidance-test', model: 'model', thinking: 'off' } }); + writeFileSync(source, 'Use SQLite.'); + const captured: Context[] = []; let calls = 0; + const rt = await runtime(dir, context => { + captured.push(structuredClone(context)); calls++; + if (calls === 1) writeFileSync(source, 'Use PostgreSQL.'); // While the first response is issued. + if (calls === 2) utimesSync(source, new Date(), new Date(Date.now() + 10000)); + return calls <= 8 ? [{ type: 'toolCall', id: `zoom-${calls}`, name: 'zoom', arguments: { id: 0 } }] : [{ type: 'text', text: 'Done.' }]; + }); + let session: Awaited>['session'] | undefined; + const errors: string[] = []; + async function open(manager: SessionManager) { + const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false }, cacheWarming: 'off', retry: { enabled: false } }); + const loader = new DefaultResourceLoader({ cwd, agentDir: join(dir, 'agent'), settingsManager, noExtensions: true, noSkills: true, noPromptTemplates: true, extensionFactories: [optchat] }); + await loader.reload(); + const result = await createAgentSession({ cwd, modelRuntime: rt, model: rt.getModel('guidance-test', 'model'), resourceLoader: loader, settingsManager, sessionManager: manager, tools: ['zoom'] }); + session = result.session; await session.bindExtensions({ onError: error => errors.push(error.error) }); + } + try { + const manager = SessionManager.create(cwd, join(dir, 'sessions')); manager.appendCustomEntry('optchat.profile', { name: 'fixture' }); + await open(manager); + const task = 'Inspect message zero eight times. ' + 'Task background. '.repeat(45); + await session!.prompt(task); + assert.equal(captured.length, 9); + assert.match(system(captured[0]), /Use SQLite/); + assert.equal(notices(captured[0]).length, 0); + assert.match(system(captured[1]), /Use PostgreSQL/); + assert.doesNotMatch(system(captured[1]), /Use SQLite/); + assert.equal(system(captured[1]), system(captured[2]), 'a touch does not change the leading prompt'); + assert.equal(notices(captured[1]).length, 1); + assert.equal(captured[1].messages.at(-1), notices(captured[1])[0], 'the first notice follows the completed tool result'); + assert.deepEqual(notices(captured[1]), notices(captured[2]), 'the same notice stays at its work boundary'); + assert.notEqual(captured[2].messages.at(-1), notices(captured[2])[0]); + assert.ok(captured.some(c => JSON.stringify(c).includes(TURN_REFRESHED)), 'real memory refresh occurred'); + assert.ok(captured.some(c => JSON.stringify(notices(c)).includes('already applied, not a new change'))); + const entries = manager.getBranch().filter(e => e.type === 'custom' && e.customType === GUIDANCE_STATE); + assert.equal(entries.length, 2, 'only initial injection and actual revision change persist state'); + const first = entries[0]; assert.equal(first.type, 'custom'); + const old = (first.data as GuidanceState).revision.files.find(f => f.path === source)!; + assert.equal(readFileSync(old.archive, 'utf8'), 'Use SQLite.'); + // Idle reports bypass before_agent_start, but still get the new source and matching notice. + writeFileSync(source, 'Use PostgreSQL with migrations.'); + await session!.sendCustomMessage({ customType: REPORT_TYPE, content: '[child] Finished.', display: true }, { triggerTurn: true, deliverAs: 'steer' }); + await session!.agent.waitForIdle(); + assert.match(system(captured.at(-1)!), /Use PostgreSQL with migrations/); + assert.match(JSON.stringify(notices(captured.at(-1)!)), /previous excerpt.*Use PostgreSQL/); + const file = manager.getSessionFile()!; + await session!.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); session!.dispose(); session = undefined; + await open(SessionManager.open(file)); + await session!.prompt('Continue after restart.'); + assert.match(system(captured.at(-1)!), /Use PostgreSQL with migrations/); + assert.match(JSON.stringify(notices(captured.at(-1)!)), /already applied, not a new change/); + rmSync(source); + await session!.prompt('Continue after removal.'); + assert.doesNotMatch(system(captured.at(-1)!), /Use PostgreSQL/); + assert.equal(readFileSync(old.archive, 'utf8'), 'Use SQLite.'); + const log = readdirSync(join(profile, 'main')).map(name => readFileSync(join(profile, 'main', name), 'utf8')).join(''); + const tree = readdirSync(join(profile, 'tree')).map(name => readFileSync(join(profile, 'tree', name), 'utf8')).join(''); + assert.doesNotMatch(log + tree, /guidance_update|guidance\/sources|historical copies|Use SQLite|Use PostgreSQL/); + assert.deepEqual(errors, []); + } finally { + if (session) { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); session.dispose(); } + if (oldHome === undefined) delete process.env.OPTCHAT_HOME; else process.env.OPTCHAT_HOME = oldHome; + if (oldBudget === undefined) delete process.env.OPTCHAT_REFRESH_AT; else process.env.OPTCHAT_REFRESH_AT = oldBudget; + removeDir(dir); + } +}); + +test('running and resumed subagents observe their own project and their own revision boundary', async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-child-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); + mkdirSync(cwd); mkdirSync(profile); const source = join(cwd, 'AGENTS.md'); writeFileSync(source, 'Use SQLite.'); + const memory = new Memory(profile, async input => input.source.slice(0, 100), () => {}); memory.append('user', 'Task.'); + const captured: Context[] = []; let calls = 0; + const rt = await runtime(dir, context => { + captured.push(structuredClone(context)); calls++; + if (calls === 1) writeFileSync(source, 'Use PostgreSQL.'); + return calls === 1 ? [{ type: 'toolCall', id: 'date', name: 'date', arguments: { id: 0 } }] : [{ type: 'text', text: 'Done.' }]; + }); + const warnings: string[] = []; + const children = new Children(memory, new ModelRegistry(rt), () => ({ provider: 'guidance-test', model: 'model', thinking: 'off' }), () => '', async () => {}, message => warnings.push(message), profile, + { createSession: options => createAgentSession({ ...options, modelRuntime: rt }), settings: () => ({ refreshAt: 0 }) }); + const settled = async () => { + const deadline = Date.now() + 10000; + while (children.active) { if (Date.now() > deadline) throw new Error('Child did not settle'); await new Promise(resolve => setTimeout(resolve, 5)); } + }; + try { + const [id] = await children.spawn([{ task: 'Check date.', cwd }], dir); await settled(); + assert.equal(captured.length, 2); + assert.match(system(captured[0]), /Use SQLite/); assert.match(system(captured[1]), /Use PostgreSQL/); + assert.equal(notices(captured[0]).length, 0); assert.equal(notices(captured[1]).length, 1); + await children.tell(id, 'Continue.', 'manager'); await settled(); + assert.match(system(captured[2]), /Use PostgreSQL/); + assert.equal(notices(captured[2]).length, 1); + assert.notEqual(captured[2].messages.at(-1), notices(captured[2])[0], 'resumed work follows the original notice'); + assert.deepEqual(warnings, []); + } finally { await children.close(); await memory.close(); removeDir(dir); } +}); diff --git a/test/guidance.test.ts b/test/guidance.test.ts new file mode 100644 index 0000000..3813c45 --- /dev/null +++ b/test/guidance.test.ts @@ -0,0 +1,114 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { Guidance, currentGuidance, type GuidanceState } from '../src/guidance.ts'; +import { removeDir } from './support.ts'; + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-')); + const cwd = join(dir, 'project', 'child'), profile = join(dir, 'profile'), agent = join(dir, 'agent'); + for (const path of [cwd, profile, agent]) mkdirSync(path, { recursive: true }); + const source = join(dir, 'project', 'AGENTS.md'); + writeFileSync(source, 'Use SQLite.'); + const guidance = new Guidance(profile); + let state: GuidanceState | undefined; + let saves = 0; + const messages: AgentMessage[] = [{ role: 'system', content: 'Stable instructions.', timestamp: 0 }, { role: 'user', content: 'memory\nTask.', timestamp: 1 }]; + return { dir, cwd, profile, agent, source, guidance, messages, get state() { return state; }, get saves() { return saves; }, + prepare(input = messages) { return guidance.prepare(input, cwd, state, next => { state = next; saves++; }, agent); } }; +} +const text = (messages: AgentMessage[]) => JSON.stringify(messages); + +test('one complete revision per request, exact previous copy, stable unchanged requests and anchored transitions', () => { + const f = fixture(); + try { + const first = f.prepare(); + assert.match(text(first.messages), /Use SQLite/); + assert.equal(first.messages.length, 2); + const snapshots = () => readdirSync(join(f.profile, 'guidance', 'sources'), { recursive: true }).filter(p => String(p).endsWith('AGENTS.md')); + assert.equal(snapshots().length, 1); + utimesSync(f.source, new Date(), new Date(Date.now() + 10000)); + assert.deepEqual(f.prepare(), first); + assert.equal(f.saves, 1); + writeFileSync(f.source, 'Use PostgreSQL.'); + const changed = f.prepare(); + assert.equal(changed.messages.length, 3); + assert.match(text(changed.messages[0] ? [changed.messages[0]] : []), /Use PostgreSQL/); + assert.doesNotMatch(text([changed.messages[0]]), /Use SQLite/); + const notice = text(changed.messages.slice(2)); + assert.match(notice, /previous excerpt.*SQLite.*current excerpt.*PostgreSQL/); + assert.match(notice, /already supplied.*no manual reread/); + assert.match(notice, /earlier instruction violation/); + assert.equal(readFileSync(first.revision.files[0].archive, 'utf8'), 'Use SQLite.'); + assert.equal(snapshots().length, 2); + assert.deepEqual(f.prepare(), changed); + const next: AgentMessage = { role: 'user', content: 'Continue.', timestamp: 2 }; + const continued = f.prepare([...f.messages, next]); + assert.deepEqual(continued.messages.slice(0, -1), changed.messages, 'the notice remains before subsequent work'); + assert.deepEqual(continued.messages.at(-1), next); + // Context refresh/restart: the boundary has left the visible work, and is described as already applied. + const restarted = new Guidance(f.profile).prepare([f.messages[0], next], f.cwd, f.state, () => assert.fail('unchanged revision'), f.agent); + assert.match(text(restarted.messages), /Earlier guidance transition \(already applied, not a new change\)/); + assert.equal(f.saves, 2); + } finally { removeDir(f.dir); } +}); + +test('source priority, scope, additions, removal and reordering are effective changes; unobserved edits are not archived', () => { + const f = fixture(); + try { + writeFileSync(join(f.agent, 'AGENTS.md'), 'Global.'); + writeFileSync(join(f.profile, 'AGENTS.md'), 'Profile.'); + assert.deepEqual(currentGuidance(f.cwd, f.profile, f.agent).map(s => s.supplied), ['Global.', 'Use SQLite.', 'Profile.']); + const old = f.prepare(); + writeFileSync(f.source, 'Never observed.'); writeFileSync(f.source, 'Use SQLite.'); + assert.deepEqual(f.prepare(), old); + const override = join(f.dir, 'project', 'AGENTS.override.md'); writeFileSync(override, 'Use SQLite.'); + const next = f.prepare(); + assert.notEqual(next.revision.id, old.revision.id); + assert.match(text(next.messages), /no longer applies/); + rmSync(override); renameSync(f.source, join(f.dir, 'moved.md')); + const removed = f.prepare(); + assert.equal(removed.revision.files.length, 2); + assert.equal(readFileSync(old.revision.files[1].archive, 'utf8'), 'Use SQLite.'); + } finally { removeDir(f.dir); } +}); + +test('BOM transformation keeps original bytes and the actual supplied text separately', () => { + const f = fixture(); + try { + writeFileSync(f.source, '\uFEFFUse SQLite.'); + const file = f.prepare().revision.files[0]; + assert.equal(readFileSync(file.archive, 'utf8'), '\uFEFFUse SQLite.'); + assert.equal(readFileSync(file.supplied, 'utf8'), 'Use SQLite.'); + assert.equal(JSON.parse(readFileSync(join(file.archive, '..', 'metadata.json'), 'utf8')).transformation, 'leading UTF-8 BOM removed'); + } finally { removeDir(f.dir); } +}); + +test('invalid, oversized, unreadable, racing or unpreservable guidance cannot advance the supplied revision', () => { + const f = fixture(); + try { + f.prepare(); const saved = f.state; + writeFileSync(f.source, Buffer.from([0xff])); + assert.throws(() => f.prepare(), /Cannot read current guidance/); + writeFileSync(f.source, 'x'.repeat(1_000_001)); + assert.throws(() => f.prepare(), /exceeds/); + rmSync(f.source); mkdirSync(f.source); + assert.throws(() => f.prepare(), /not a regular file/); + rmSync(f.source, { recursive: true }); writeFileSync(f.source, 'Changed.'); + rmSync(join(f.profile, 'guidance', 'sources'), { recursive: true }); + writeFileSync(join(f.profile, 'guidance', 'sources'), 'cannot write here'); + assert.throws(() => f.prepare()); + assert.equal(f.state, saved); + } finally { removeDir(f.dir); } +}); + +test('a broken symlink is a failed source, not removal', { skip: process.platform === 'win32' }, () => { + const f = fixture(); + try { + rmSync(f.source); symlinkSync(join(f.dir, 'missing'), f.source); + assert.throws(() => f.prepare(), /Cannot read current guidance/); + } finally { removeDir(f.dir); } +}); From 4ac1a732b07761866b7787c7f69d70efac16022e Mon Sep 17 00:00:00 2001 From: Edmund Raile Date: Sat, 17 Oct 2026 21:37:50 +0200 Subject: [PATCH 2/2] Preserve request-specific guidance for historical recall --- README.md | 10 +- docs/storage.md | 14 ++- package.json | 3 +- src/agents.ts | 26 +++++- src/checkpoint.ts | 3 +- src/guidance-recall.ts | 92 +++++++++++++++++++ src/guidance-ref.ts | 6 ++ src/guidance.ts | 103 +++++++++++++++------ src/import/job.ts | 2 +- src/index.ts | 29 ++++-- src/memory.ts | 10 +- src/profiles.ts | 5 +- src/store.ts | 2 +- src/tools.ts | 29 +++++- src/transcript.ts | 20 ++-- test/checkpoint.test.ts | 11 +++ test/guidance-lifecycle.test.ts | 99 +++++++++++++++++++- test/guidance-live.ts | 49 ++++++++++ test/guidance-provider.test.ts | 63 +++++++++++++ test/guidance-recall.test.ts | 156 ++++++++++++++++++++++++++++++++ test/guidance.test.ts | 88 +++++++++++++++++- 21 files changed, 746 insertions(+), 74 deletions(-) create mode 100644 src/guidance-recall.ts create mode 100644 src/guidance-ref.ts create mode 100644 test/guidance-live.ts create mode 100644 test/guidance-provider.test.ts create mode 100644 test/guidance-recall.test.ts diff --git a/README.md b/README.md index 48e4412..f913da0 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ Profile data lives in `~/.optchat/profiles//` (override the root with `OPT | `active-memory.json`, `memories//` | After an import: pointer to the active `main/` and `tree/`. Older generations are kept. | | `imports/pending.json` | Resumable import state | | `AGENTS.md` | Profile instructions | -| `guidance/` | Immutable guidance revisions and original source metadata | +| `guidance/` | Deduplicated guidance contents, source metadata, ordered revisions and request associations | | `config.json` | Compactor and subagent models | | `pending-inputs.json`, `pending-reports.json` | Recovery journals | | `runs/` | Subagent sessions and run metadata | @@ -231,7 +231,8 @@ Pipe names and permissions: [docs/storage.md](docs/storage.md#windows-difference - **Use worktrees** when parallel agents edit the same repository; they share the filesystem. - **Instructions**: before every model request, the main agent and subagents check applicable global, ancestor/project and profile guidance, in that order, using Pi's file priority (`AGENTS.override.md`, `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md`, `CLAUDE.MD`) and worktree shadowing. The profile's `AGENTS.md` comes last; a newer file revision does not override an explicit user instruction. Skills remain available; prompt templates work in the main session only. - **Changing guidance**: edits apply at each agent's next request, including tool continuations, report turns and resumed work. Stable instructions precede complete current guidance, then memory and recent work. A real change adds a short notice after the recent work, with mechanical excerpts and a path to the previous copy. That notice stays at its original boundary; after refresh it is labeled as an earlier transition. Unchanged text and timestamp-only touches add nothing. No manual reread is needed. -- **Guidance failures**: missing sources are removed at the next request; broken links, read errors, invalid UTF-8, files or combined guidance over 1 MB, and archive-write failures stop that request. Guidance is never silently truncated or replaced by stale text. Copies live under `guidance/sources/`, retain the original basename and source path, and deduplicate contents. Original bytes and BOM-stripped supplied text are distinguished. Keep this directory with your profile; no automatic archive pruning occurs. +- **Guidance failures**: missing sources are removed at the next request; broken links, read errors, invalid UTF-8, files or combined guidance over 1 MB, and archive-write failures stop that request. Guidance is never silently truncated or replaced by stale text. Copies live under `guidance/sources/`, retain the original basename and source path, and share content objects through hard links (the profile filesystem must support them). Original bytes and BOM-stripped supplied text are distinguished. Keep this directory with your profile; no automatic archive pruning occurs. +- **Historical guidance**: `zoom(id, 1)` and paged subagent transcripts expose references to the exact guidance associated with the original work, including after restart, source removal or an import rebuild. References appear only in the model’s recall result; they are not added to the memory view, logged tool text or summaries. Opening a historical copy explains earlier work and does not activate its instructions. Older work recorded before this feature has no invented association. - **Tab title**: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename). - **Images** you paste, or a tool returns, are kept under the profile's `images/` (once each, shrunk to 2,048 px on the long side and about 1.5 MB with Pi's own resizer; smaller ones stay as they are). The log names each as `[image ]`, and `zoom(id, 1)` returns a message's images with its text. Summaries stay text. Imported conversations keep a text placeholder. - **Long turns** (#146): Pi's auto-compaction is off for the main agent, since memory replaces it. Once the current turn's verbatim messages pass the Turn refresh threshold (30% of the model's window by default), they are flushed to memory, the view is rebuilt, and only the task message plus the last tool call and its result stay verbatim; the earlier steps are the view's last lines, a zoom away. In an A/B on a 200K model, turns that otherwise gave up around 100K tokens kept going and covered the whole task. `OPTCHAT_REFRESH_AT` (a fraction, e.g. `0.3`; `0` is off) overrides the setting. Subagents use Pi's own compaction instead, starting at about the same point. @@ -274,8 +275,13 @@ Restart Pi after source changes. Use `OPTCHAT_HOME` to test against a throwaway npm run check # type check npm test # offline tests, no paid model calls npm run test:live # paid Anthropic calls on synthetic data in a disposable profile +npm run test:guidance:live # paid guidance/cache validation; defaults to openai/gpt-6.1-sol ``` +The guidance live check accepts `OPTCHAT_GUIDANCE_PROVIDER` and `OPTCHAT_GUIDANCE_MODEL`. It uses synthetic files, checks SQLite → PostgreSQL behavior and interpretation of earlier work, and prints the provider’s cache counters for ordinary requests and edits. Offline tests inspect the actual Anthropic, OpenAI Responses and Chat Completions adapters; those checks establish placement and stable payloads, not a guaranteed cache hit rate or cost saving. + +SDK hosts using a custom resource loader can pass `optchat(pi, { agentDir, discover })`. Supply its agent directory explicitly when it initially has no global guidance file; set `discover: false` to disable project/global discovery. Pi does not expose those resource-loader options to extension events. The CLI’s `--no-context-files`/`-nc` is respected for the main agent; profile instructions still apply. Subagents retain their own project discovery. + ### Publishing to npm Pi's [package directory](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md) lists npm packages with the `pi-package` keyword, which the manifest already has. GitHub alone is not enough. diff --git a/docs/storage.md b/docs/storage.md index 07a1205..d260c12 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -10,11 +10,23 @@ Each profile folder is a local Git repository, committed after each turn and on An import builds a new memory generation (`memories//`) and switches `active-memory.json` to it only when the whole tree is ready. Older generations are kept. +## Guidance history + +`guidance/contents/.txt` holds each distinct observed source or supplied text once. Human-readable paths under `guidance/sources/-//` hard-link to those contents. Beside each original, `metadata.json` records its original path, content version, first capture time and source modification time at that capture. If injection removes a UTF-8 BOM, `supplied.txt` identifies the exact transformed text separately. Source scope and ordering belong to immutable `guidance/revisions/.json` manifests. Archive paths in these manifests are relative, so moving the whole profile preserves retrieval. + +`guidance/requests/.json` binds an individual agent/session request to its ordered revision. Main-memory entries carry only a request reference as metadata; import append and rebuild preserve it when entries are renumbered. Subagent transcripts keep custom request boundaries, allowing retained messages to be matched before or after Pi compaction. Associations use request identities, not timestamp proximity between parallel agents. + +A request record is evidence of the guidance prepared for injection, not proof that a provider accepted the request or the agent obeyed it. Inputs interrupted before preparation can have a reserved reference without a request record; recall explicitly describes that gap. Work predating the feature is left unannotated. Corrupt or missing archived contents produce an error rather than a reference presented as valid history. + +Guidance checks do not add conversation messages. Transition state is kept in custom Pi transcript entries, and notices are projected at their original work boundary, or labeled as an earlier transition when that boundary has left the visible context. Recall references are tool-result metadata projected into the model request; canonical tool text and both OptChat and Pi summaries omit them. Actual conversation about instructions, or an explicit tool read of an archive, remains ordinary conversation. + +Original source edits, moves and deletion never remove archived versions. There is no automatic archive retention limit or pruning; copy the whole profile for backup. A failed read, invalid UTF-8, source larger than 1 MB, combined guidance larger than 1 MB, or failed archive/request write aborts the next request. A missing source removes its scope; a dangling symlink is an error. This history covers injected guidance, not every filesystem edit or the rest of an execution environment. + ## The `Store` seam Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. -- Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files. +- Runs, recovery journals, imports and their pointer, usage, settings, `AGENTS.md` and the guidance archive stay local files. A future remote store must also transfer the archive to make its request references readable on another machine. - `FileStore` (the layout in the README) is the only store today. Another, such as Postgres, would implement the same few operations. - Its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock. - Import records (`imports/*.json`, the staging generations and the `active-memory.json` pointer) are per machine, so a second machine with the same `~/.claude` or Codex history on disk may offer those sessions for import again. diff --git a/package.json b/package.json index 4d12a28..d3b16c0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "scripts": { "check": "tsc --noEmit", "test": "tsx --import ./test/support.ts --test test/*.test.ts", - "test:live": "tsx test/live.ts" + "test:live": "tsx test/live.ts", + "test:guidance:live": "tsx test/guidance-live.ts" }, "devDependencies": { "@earendil-works/pi-agent-core": "1.0.2", diff --git a/src/agents.ts b/src/agents.ts index 2605252..50046a1 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import { Guidance, guidanceState, GUIDANCE_STATE } from './guidance.ts'; +import { Guidance, guidanceState, GUIDANCE_STATE, type GuidanceState } from './guidance.ts'; +import { GuidanceRequests, GUIDANCE_REQUEST, transcriptGuidance, withGuidanceRecall } from './guidance-recall.ts'; import { existsSync, readFileSync, statSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { homedir } from 'node:os'; @@ -138,6 +139,14 @@ export class Children { const tail = live.streaming ?? live.session.messages.at(-1); return tail && !retained.includes(tail) ? [...retained, tail] : retained; } + /** Called only after transcript() has checked visibility. Uses retained request boundaries, including before compaction. */ + guidanceForTranscript(id: string) { + const live = this.running.get(id), file = this.history.records.get(id)?.sessionFile; + const entries = live?.session.sessionManager.getEntries() ?? (file ? SessionManager.open(file).getEntries() : []); + const retained = entries.flatMap(entry => entry.type === 'message' ? [entry.message] : []); + const tail = live?.streaming ?? live?.session.messages.at(-1); + return transcriptGuidance(entries, tail && !retained.includes(tail) ? tail : undefined); + } /** Runs at work now: running, waiting for children, or stopping; not paused. */ get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); } subscribe(listener: () => void) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } @@ -247,7 +256,7 @@ export class Children { private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model; thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) { const { id, directory, depth, parentId, connected } = o; - const guidance = new Guidance(this.profileDirectory); + const guidance = new Guidance(this.profileDirectory), requests = new GuidanceRequests(this.profileDirectory); const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels; const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n'); // The user's settings list their installed packages; a copy in memory keeps the child from writing them back. @@ -266,8 +275,17 @@ export class Children { event.systemPromptOptions.sections.instructions = instructions; }); pi.on('context_with_system', (event, ctx) => { - try { return guidance.prepare(event.messages, directory, guidanceState(ctx), state => pi.appendEntry(GUIDANCE_STATE, state)); } + try { + // A failed preparation has its own boundary too, with no prepared snapshot record. + pi.appendEntry(GUIDANCE_REQUEST, requests.input()); + let nextGuidance: GuidanceState | undefined; + const prepared = guidance.prepare(event.messages, directory, guidanceState(ctx), state => { nextGuidance = state; }); + requests.prepare(prepared.revision, ctx.sessionManager.getSessionId(), event.messages); + if (nextGuidance) pi.appendEntry(GUIDANCE_STATE, nextGuidance); + return { messages: withGuidanceRecall(prepared.messages, refs => requests.describe(refs)) }; + } catch (error) { + requests.resetPending(); ctx.abort(); this.warn(String(error)); return { messages: [{ role: 'system' as const, content: 'OptChat guidance unavailable. Stop.', timestamp: 0 }] }; } @@ -278,7 +296,7 @@ export class Children { await loader.reload(); const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager, model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager, - customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)], + customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id), { run: run => this.guidanceForTranscript(run) }), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)], excludeTools: delegates ? [] : ['spawn', 'tell'], }); // Callers track the session only after this returns: clean up here if its extensions fail to start. diff --git a/src/checkpoint.ts b/src/checkpoint.ts index f19552e..e8ea3f7 100644 --- a/src/checkpoint.ts +++ b/src/checkpoint.ts @@ -8,7 +8,8 @@ const exec = promisify(execFile); const LOOSE_LIMIT_KIB = 16_384; export async function checkpoint(directory: string) { - const git = (args: string[]) => exec('git', ['-C', directory, ...args], { maxBuffer: 1_000_000 }); + // Source identity plus version can exceed Windows' legacy 260-character path limit. + const git = (args: string[]) => exec('git', ['-c', 'core.longpaths=true', '-C', directory, ...args], { maxBuffer: 1_000_000 }); if (!existsSync(join(directory, '.git'))) await git(['init', '-q', '-b', 'main']); if (!existsSync(join(directory, '.gitignore'))) atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n'); await git(['add', '-A', '--', '.']); diff --git a/src/guidance-recall.ts b/src/guidance-recall.ts new file mode 100644 index 0000000..cf21c6c --- /dev/null +++ b/src/guidance-recall.ts @@ -0,0 +1,92 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import type { SessionEntry } from '@earendil-works/pi-coding-agent'; +import { GuidanceArchive, guidanceKey, type GuidanceRevision } from './guidance.ts'; +import { atomicWrite } from './store.ts'; +import { record } from './cache.ts'; +import { validGuidanceRef, validRequestId, type GuidanceRef } from './guidance-ref.ts'; +export { validGuidanceRef, type GuidanceRef } from './guidance-ref.ts'; + +export const GUIDANCE_REQUEST = 'optchat.guidance.request'; +export interface GuidanceRecallRef extends GuidanceRef { work?: string } +interface Request extends GuidanceRef { agent: string; revision: string; prepared: string; after?: string } + +/** One identity per agent request, independent of timestamps and of other agents' observations. */ +export class GuidanceRequests { + private pending?: GuidanceRef; + latest?: GuidanceRef; + readonly archive: GuidanceArchive; + constructor(profile: string) { this.archive = new GuidanceArchive(profile); } + input() { return this.pending ??= { request: randomUUID() }; } + resetPending() { this.pending = undefined; this.latest = undefined; } + prepare(revision: GuidanceRevision, agent: string, messages: readonly AgentMessage[]): GuidanceRef { + const ref = this.pending ?? { request: randomUUID() }; + const last = messages.findLast(message => message.role !== 'system'); + const request: Request = { ...ref, agent, revision: revision.id, prepared: new Date().toISOString(), ...(last ? { after: guidanceKey(last) } : {}) }; + atomicWrite(join(this.archive.directory, 'requests', `${ref.request}.json`), JSON.stringify(request, null, 2)); + this.pending = undefined; + return this.latest = ref; + } + describe(refs: readonly GuidanceRecallRef[]) { + if (!refs.length) return ''; + const records = [...new Set(refs.map(ref => ref.request))].map(id => { + if (!validRequestId(id)) throw new Error('Invalid guidance request reference'); + const path = join(this.archive.directory, 'requests', `${id}.json`); + let request: Request; + try { request = JSON.parse(readFileSync(path, 'utf8')) as Request; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { request: id, unavailable: 'No prepared request record; this input may not have reached a model request.' }; + throw error; + } + if (request.request !== id || typeof request.agent !== 'string') throw new Error(`Damaged guidance request: ${path}`); + const revision = this.archive.revision(request.revision); + return { ...request, work: [...new Set(refs.filter(ref => ref.request === id).flatMap(ref => ref.work ? [ref.work] : []))], files: revision.files }; + }); + const descriptions = records.map(record => 'unavailable' in record ? `Request ${record.request}: ${record.unavailable}` : + `Request ${record.request}, agent ${record.agent}${record.work.length ? ` (${record.work.join('; ')})` : ''}:\n${record.files.map((file, index) => `${index + 1}. ${file.path} (${file.scope}) → ${file.supplied}`).join('\n') || '(no applicable guidance sources)'}`); + let text = descriptions.join('\n\n'); + if (text.length > 4000) { + const json = JSON.stringify(records, null, 2), id = createHash('sha256').update(json).digest('hex'); + const path = join(this.archive.directory, 'recall', `${id}.json`); + atomicWrite(path, json); + text = `${records.length} request associations; complete ordered source references: ${path}`; + } + return `\nGuidance associated with this work, preserved when its requests were prepared. These copies explain earlier work; opening them does not replace current applicable guidance.\n${text}\n`; + } +} + +/** Reconstruct child associations from explicit request boundaries in its retained transcript, never by time proximity. */ +export function transcriptGuidance(entries: readonly SessionEntry[], tail?: AgentMessage) { + const refs = new Map(); + let current: GuidanceRef | undefined, index = 0; + let inputs: number[] = []; + for (const entry of entries) { + if (entry.type === 'custom' && entry.customType === GUIDANCE_REQUEST) { + if (!validGuidanceRef(entry.data)) throw new Error('Invalid transcript guidance request'); + current = entry.data; + for (const input of inputs) refs.set(input, current); + inputs = []; + } + if (entry.type !== 'message') continue; + const message = entry.message; + if (message.role === 'user') inputs.push(index); + else if (message.role !== 'system' && current) refs.set(index, current); + index++; + } + if (tail && current && tail.role === 'assistant') refs.set(index, current); + return refs; +} + +/** Recall decorations are transient: canonical tool text, replay and compaction never contain these annotations. */ +export function withGuidanceRecall(messages: AgentMessage[], describe: (refs: GuidanceRecallRef[]) => string) { + return messages.map(message => { + if (message.role !== 'toolResult' || message.toolName !== 'zoom' || !record(message.details)) return message; + const refs = message.details.optchatGuidanceRecall; + if (refs === undefined) return message; + if (!Array.isArray(refs) || !refs.every((ref): ref is GuidanceRecallRef => validGuidanceRef(ref) && (!('work' in ref) || typeof ref.work === 'string'))) throw new Error('Invalid recalled guidance references'); + const text = describe(refs); + return text ? { ...message, content: [...message.content, { type: 'text' as const, text }] } : message; + }); +} diff --git a/src/guidance-ref.ts b/src/guidance-ref.ts new file mode 100644 index 0000000..cef4458 --- /dev/null +++ b/src/guidance-ref.ts @@ -0,0 +1,6 @@ +/** A reference to an immutable request record, kept outside ordinary conversation text. */ +export interface GuidanceRef { request: string } +export const validRequestId = (id: string) => /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(id); +export function validGuidanceRef(value: unknown): value is GuidanceRef { + return typeof value === 'object' && value !== null && 'request' in value && typeof value.request === 'string' && validRequestId(value.request); +} diff --git a/src/guidance.ts b/src/guidance.ts index 3fd999f..753e968 100644 --- a/src/guidance.ts +++ b/src/guidance.ts @@ -1,10 +1,11 @@ import { createHash } from 'node:crypto'; -import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync } from 'node:fs'; -import { basename, dirname, join, resolve } from 'node:path'; +import { closeSync, constants, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, linkSync, mkdirSync } from 'node:fs'; +import { basename, dirname, join, resolve, relative, isAbsolute } from 'node:path'; import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { getCurrentSystemMessage, getSystemMessageText } from '@earendil-works/pi-ai'; import { getAgentDir, loadProjectContextFiles, type ExtensionContext } from '@earendil-works/pi-coding-agent'; import { atomicWrite } from './store.ts'; +import { isAtWork } from './cache.ts'; export const GUIDANCE_STATE = 'optchat.guidance'; const LIMIT = 1_000_000; @@ -15,7 +16,7 @@ const xml = (text: string) => text.replaceAll('&', '&').replaceAll('"', '&qu export interface GuidanceSource { path: string; scope: string; content: string; supplied: string; modified: string } export interface GuidanceFile { path: string; scope: string; version: string; archive: string; supplied: string } export interface GuidanceRevision { id: string; files: GuidanceFile[] } -interface Transition { before: string; after: string; anchor: string; text: string } +interface Transition { before: string; after: string; anchor: string; occurrence?: number } export interface GuidanceState { revision: GuidanceRevision; transitions: Transition[] } /** Read once through one handle. Never truncate, silently fall back, or mistake a failed read for deletion. */ @@ -45,17 +46,22 @@ function readSource(path: string, scope: string): GuidanceSource | undefined { /** Match Pi's candidate priority, global → ancestors → cwd ordering and linked-worktree shadowing. * Pi's loader warns and skips unreadable sources; preflight prevents that weaker guarantee here. */ -export function currentGuidance(cwd: string, profile: string, agentDir = getAgentDir()): GuidanceSource[] { - const dirs = new Set([resolve(agentDir)]); - for (let dir = resolve(cwd); ; dir = dirname(dir)) { dirs.add(dir); if (dirname(dir) === dir) break; } +export function currentGuidance(cwd: string, profile: string, agentDir = getAgentDir(), discover = true): GuidanceSource[] { + const archived = (path: string) => { + const part = relative(join(profile, 'guidance', 'sources'), resolve(path)); + return !isAbsolute(part) && part !== '..' && !part.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`); + }; + const dirs = new Set(discover ? [resolve(agentDir)] : []); + if (discover) for (let dir = resolve(cwd); ; dir = dirname(dir)) { dirs.add(dir); if (dirname(dir) === dir) break; } const checked = new Map(); for (const dir of dirs) { + if (archived(dir)) continue; for (const name of CANDIDATES) { const source = readSource(join(dir, name), dir === resolve(agentDir) ? 'global' : `project:${dir}`); if (source) { checked.set(source.path, source); break; } } } - const files = loadProjectContextFiles({ cwd, agentDir }).map(file => { + const files = (discover ? loadProjectContextFiles({ cwd, agentDir }) : []).filter(file => !archived(file.path)).map(file => { const source = checked.get(resolve(file.path)); if (!source || source.supplied !== file.content) throw new Error(`Guidance changed during discovery: ${file.path}; retry the request`); return source; @@ -79,13 +85,19 @@ export class GuidanceArchive { preserve(sources: GuidanceSource[]): GuidanceRevision { const files = sources.map(source => { const version = hash(source.content); - const label = source.path.replace(/[^a-zA-Z0-9._-]+/g, '%').slice(-100); + const label = source.path.replace(/[^a-zA-Z0-9._-]+/g, '%').slice(-48); const dir = join(this.directory, 'sources', `${label}-${hash(source.path).slice(0, 16)}`, version); const archive = join(dir, basename(source.path)); const supplied = source.content === source.supplied ? archive : join(dir, 'supplied.txt'); const keep = (path: string, text: string) => { + const object = join(this.directory, 'contents', `${hash(text)}.txt`); + if (existsSync(object)) { if (readFileSync(object, 'utf8') !== text) throw new Error(`Damaged guidance content: ${object}`); } + else atomicWrite(object, text); if (existsSync(path)) { if (readFileSync(path, 'utf8') !== text) throw new Error(`Damaged guidance archive: ${path}`); } - else atomicWrite(path, text); + else { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + linkSync(object, path); // Same bytes once, with a recognizable name at every original source location. + } }; keep(archive, source.content); if (supplied !== archive) keep(supplied, source.supplied); @@ -94,8 +106,9 @@ export class GuidanceArchive { captured: new Date().toISOString(), transformation: supplied === archive ? 'none' : 'leading UTF-8 BOM removed', supplied: basename(supplied) }, null, 2)); return { path: source.path, scope: source.scope, version, archive, supplied }; }); - const id = hash(JSON.stringify(files)); - const file = join(this.directory, 'revisions', `${id}.json`), text = JSON.stringify({ id, files }, null, 2); + const stored = files.map(file => ({ ...file, archive: relative(this.directory, file.archive).split('\\').join('/'), supplied: relative(this.directory, file.supplied).split('\\').join('/') })); + const id = hash(JSON.stringify(stored)); + const file = join(this.directory, 'revisions', `${id}.json`), text = JSON.stringify({ id, files: stored }, null, 2); if (existsSync(file)) { if (readFileSync(file, 'utf8') !== text) throw new Error(`Damaged guidance revision: ${file}`); } else atomicWrite(file, text); return { id, files }; @@ -104,7 +117,16 @@ export class GuidanceArchive { if (!/^[a-f0-9]{64}$/.test(id)) throw new Error('Invalid guidance revision'); const revision = JSON.parse(readFileSync(join(this.directory, 'revisions', `${id}.json`), 'utf8')) as GuidanceRevision; if (revision.id !== id || hash(JSON.stringify(revision.files)) !== id) throw new Error(`Damaged guidance revision: ${id}`); - return revision; + const locate = (path: string) => { + if (isAbsolute(path) || path.split(/[\\/]/).includes('..')) throw new Error(`Invalid guidance archive path: ${path}`); + return join(this.directory, path); + }; + const files = revision.files.map(file => ({ ...file, archive: locate(file.archive), supplied: locate(file.supplied) })); + for (const file of files) { + const raw = readFileSync(file.archive, 'utf8'); + if (hash(raw) !== file.version || readFileSync(file.supplied, 'utf8') !== normalize(raw)) throw new Error(`Damaged historical guidance: ${file.archive}`); + } + return { ...revision, files }; } } @@ -125,7 +147,16 @@ function difference(before: GuidanceRevision, after: GuidanceRevision) { for (const file of after.files) if (!before.files.some(old => old.path === file.path)) changes.push(`${file.path} now applies`); if (JSON.stringify(before.files.map(f => [f.path, f.scope])) !== JSON.stringify(after.files.map(f => [f.path, f.scope]))) changes.push('the applicable sources, scopes or ordering changed'); - return `${changes.join('; ')}.`; + return `${changes.slice(0, 4).join('; ')}${changes.length > 4 ? `; ${changes.length - 4} additional source/order changes (consult the full revisions)` : ''}.`; +} + +/** Source bytes can change without changing supplied instructions (for example, adding a BOM). */ +function sameEffectiveGuidance(before: GuidanceRevision, after: GuidanceRevision) { + return before.files.length === after.files.length && before.files.every((old, index) => { + const next = after.files[index]; + return old.path === next.path && old.scope === next.scope && + (old.version === next.version || readFileSync(old.supplied, 'utf8') === readFileSync(next.supplied, 'utf8')); + }); } export function guidanceKey(message: AgentMessage) { @@ -151,6 +182,8 @@ function notice(content: string): AgentMessage { content: `\nHarness-supplied guidance transition, not a new user task.\n${content}\n`, timestamp: 0 }; } +export interface GuidanceOptions { agentDir?: string; discover?: boolean } + export class Guidance { readonly archive: GuidanceArchive; private agentDir = getAgentDir(); @@ -159,36 +192,48 @@ export class Guidance { const ancestors = new Set(); for (let dir = resolve(cwd); ; dir = dirname(dir)) { ancestors.add(dir); if (dir === dirname(dir)) break; } const global = files.find(file => !ancestors.has(dirname(resolve(file.path)))); - if (global) this.agentDir = dirname(resolve(global.path)); + if (global && !this.options.agentDir) this.agentDir = dirname(resolve(global.path)); + } + constructor(readonly profile: string, private readonly options: GuidanceOptions = {}) { + this.archive = new GuidanceArchive(profile); + this.agentDir = options.agentDir ?? getAgentDir(); + } + private transitionText(transition: Transition) { + const before = this.archive.revision(transition.before), after = this.archive.revision(transition.after); + const paths = before.files.slice(0, 3).map(file => `${file.path} → ${file.supplied}`).join('\n') || '(no previous sources)'; + const all = before.files.length > 3 ? `\nAll previous sources in order: ${join(this.archive.directory, 'revisions', `${before.id}.json`)}` : ''; + return `Project guidance has been updated directly in your context; the complete current contents are already supplied, so no manual reread is needed.\n${difference(before, after)}\nThis revision applies from this step onward; earlier work may reflect earlier instructions and may need adaptation, but a difference alone does not establish an earlier instruction violation.\nPrevious guidance (historical copies):\n${paths}${all}\nOpen a previous copy only if needed to understand something relevant to continuing the task; it does not replace current guidance.`; } - constructor(readonly profile: string) { this.archive = new GuidanceArchive(profile); } prepare(messages: AgentMessage[], cwd: string, previous: GuidanceState | undefined, save: (state: GuidanceState) => void, agentDir?: string) { - const sources = currentGuidance(cwd, this.profile, agentDir ?? this.agentDir), revision = this.archive.preserve(sources); + const head = getCurrentSystemMessage(messages); + if (!head) throw new Error('Cannot inject guidance without a system prompt'); + if (previous) previous = { ...previous, revision: this.archive.revision(previous.revision.id) }; + const sources = currentGuidance(cwd, this.profile, agentDir ?? this.agentDir, this.options.discover), revision = this.archive.preserve(sources); let state = previous; if (previous?.revision.id !== revision.id) { - const transitions = [...previous?.transitions ?? []]; - if (previous) { - const before = this.archive.revision(previous.revision.id); - const paths = before.files.map(file => `${file.path} → ${file.supplied}`).join('\n') || '(no previous sources)'; - const text = `Project guidance has been updated directly in your context; the complete current contents are already supplied, so no manual reread is needed.\n${difference(before, revision)}\nThis revision applies from this step onward; earlier work may reflect earlier instructions and may need adaptation, but a difference alone does not establish an earlier instruction violation.\nPrevious guidance (historical copies):\n${paths}\nOpen a previous copy only if needed to understand something relevant to continuing the task; it does not replace current guidance.`; - const last = messages.findLast(message => message.role !== 'system'); - transitions.push({ before: before.id, after: revision.id, anchor: last ? guidanceKey(last) : '', text }); + const keys = new Set(messages.filter(message => message.role !== 'system').map(guidanceKey)); + const earlier = previous?.transitions.findLast(t => !keys.has(t.anchor)); + const transitions = (previous?.transitions ?? []).filter(t => keys.has(t.anchor) || t === earlier); + if (previous && !sameEffectiveGuidance(previous.revision, revision)) { + const work = messages.filter(message => message.role !== 'system' && !isAtWork(message)); + const last = work.at(-1), anchor = last ? guidanceKey(last) : ''; + transitions.push({ before: previous.revision.id, after: revision.id, anchor, occurrence: work.filter(message => guidanceKey(message) === anchor).length - 1 }); } state = { revision, transitions }; save(state); } - const head = getCurrentSystemMessage(messages); - if (!head) throw new Error('Cannot inject guidance without a system prompt'); const output: AgentMessage[] = [{ role: 'system', content: guidancePrompt(getSystemMessageText(head), sources), toolsAdded: head.toolsAdded, timestamp: 0 }]; const body = messages.filter(message => message.role !== 'system'); const keys = body.map(guidanceKey); // Once a boundary leaves the visible work, retain the latest such transition, explicitly as history. - const historical = state!.transitions.findLast(transition => !keys.includes(transition.anchor)); + const historical = state!.transitions.findLast(transition => keys.filter(key => key === transition.anchor).length <= (transition.occurrence ?? 0)); + const occurrences = new Map(); for (const [index, message] of body.entries()) { output.push(message); - if (index === 0 && historical) output.push(notice(`Earlier guidance transition (already applied, not a new change):\n${historical.text}`)); - for (const transition of state!.transitions) if (transition.anchor === keys[index]) - output.push(notice(transition.text)); + if (index === 0 && historical) output.push(notice(`Earlier guidance transition (already applied, not a new change):\n${this.transitionText(historical)}`)); + const occurrence = occurrences.get(keys[index]) ?? 0; occurrences.set(keys[index], occurrence + 1); + for (const transition of state!.transitions) if (transition.anchor === keys[index] && occurrence === (transition.occurrence ?? 0)) + output.push(notice(this.transitionText(transition))); } return { messages: output, revision }; } diff --git a/src/import/job.ts b/src/import/job.ts index 633f56b..a45f6bd 100644 --- a/src/import/job.ts +++ b/src/import/job.ts @@ -114,7 +114,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort report(); // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live // messages is, so up to `jobs` summaries run at once (the "Import: summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error). - for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); } + for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin, e.guidance); } await memory.settle(signal, 'tree'); signal.throwIfAborted(); // Close all writers before the single atomic pointer swap. The previous generation stays intact. await memory.close(); diff --git a/src/index.ts b/src/index.ts index acee6bb..f565f8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,8 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; import { Type } from 'typebox'; import { Memory } from './memory.ts'; -import { Guidance, guidanceState, GUIDANCE_STATE } from './guidance.ts'; +import { Guidance, guidanceState, GUIDANCE_STATE, type GuidanceState, type GuidanceOptions } from './guidance.ts'; +import { GuidanceRequests, GUIDANCE_REQUEST, withGuidanceRecall, type GuidanceRef } from './guidance-recall.ts'; import { atomicWrite, storeFor } from './store.ts'; import { createCompressor } from './compactor.ts'; import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts'; @@ -49,7 +50,7 @@ const toggle = (prompt: string, line: string, on: boolean, after: string) => on /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) => allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch); -interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; guidance: Guidance; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise } +interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; guidance: Guidance; guidanceRequests: GuidanceRequests; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise } /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */ export const version = ((): string | undefined => { @@ -60,7 +61,7 @@ export const version = ((): string | undefined => { })(); const brand = version ? `OptChat v${version}` : 'OptChat'; -export default function optchat(pi: ExtensionAPI) { +export default function optchat(pi: ExtensionAPI, guidanceOptions: GuidanceOptions = { discover: !process.argv.slice(2).some(arg => arg === '--no-context-files' || arg === '-nc') }) { let active: Active | undefined; let remote: Awaited> | undefined; /** `pi -p` on a profile another Pi owns: the prompt goes to a subagent in that Pi (--optchat-connect). */ @@ -82,6 +83,7 @@ export default function optchat(pi: ExtensionAPI) { type Report = (typeof reports)[number]; let idle = () => true; const receipts = new Map(); + const guidanceRefs = new Map(); let checkpoints = Promise.resolve(); let importing = false; let stopping = false; @@ -132,7 +134,7 @@ export default function optchat(pi: ExtensionAPI) { while (logged < run.length) { const message = run[logged]; const receipt = receipts.get(message); - logMessage(active.memory, message, receipt); logged++; + logMessage(active.memory, message, receipt, guidanceRefs.get(message)); guidanceRefs.delete(message); logged++; if (receipt && !receipt.startsWith(REPORT_RECEIPT)) active.inbox.acknowledge(receipt); receipts.delete(message); if (message.role === 'user') { @@ -173,7 +175,7 @@ export default function optchat(pi: ExtensionAPI) { await checkpoint(old.dir); } finally { await old.memory.close(); await old.unlock(); active = undefined; - run = []; previous = []; logged = 0; keep = 0; view = undefined; runStarted = false; receipts.clear(); prompt = ''; idle = () => true; + run = []; previous = []; logged = 0; keep = 0; view = undefined; runStarted = false; receipts.clear(); guidanceRefs.clear(); prompt = ''; idle = () => true; untitle?.(); untitle = undefined; title.clear(); working = false; } }; @@ -219,7 +221,7 @@ export default function optchat(pi: ExtensionAPI) { .filter(r => !loggedReports.has(reportReceipt(r.text))); atomicWrite(pending, JSON.stringify(reports)); rememberProfile(name); - active = { name, dir, config, memory, guidance: new Guidance(dir), inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle(); + active = { name, dir, config, memory, guidance: new Guidance(dir, guidanceOptions), guidanceRequests: new GuidanceRequests(dir), inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle(); if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); }); // A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it. if (ctx.mode !== 'print' && ctx.mode !== 'json') closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport); @@ -371,6 +373,7 @@ export default function optchat(pi: ExtensionAPI) { }); const startRun = (ctx: ExtensionContext) => { flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true; + active?.guidanceRequests.resetPending(); const config = active?.config; previous = config?.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), config.previousExchangeKB * 1000) : []; pi.appendEntry(RUN_BOUNDARY, { state: 'start' }); @@ -424,6 +427,8 @@ export default function optchat(pi: ExtensionAPI) { } } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); } } + const guidance = message.role === 'user' ? active.guidanceRequests.input() : active.guidanceRequests.latest; + if (guidance) guidanceRefs.set(message, guidance); run.push(message); if (view !== undefined) { try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); } @@ -465,9 +470,15 @@ export default function optchat(pi: ExtensionAPI) { } // Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached. const messages = buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, promptFor(prompt, a.config), previous, atWork(a.children.working)); - return a.guidance.prepare(messages, ctx.cwd, guidanceState(ctx), state => pi.appendEntry(GUIDANCE_STATE, state)); + let nextGuidance: GuidanceState | undefined; + const prepared = a.guidance.prepare(messages, ctx.cwd, guidanceState(ctx), state => { nextGuidance = state; }); + const request = a.guidanceRequests.prepare(prepared.revision, ctx.sessionManager.getSessionId(), messages); + if (nextGuidance) pi.appendEntry(GUIDANCE_STATE, nextGuidance); + pi.appendEntry(GUIDANCE_REQUEST, request); + return { messages: withGuidanceRecall(prepared.messages, refs => a.guidanceRequests.describe(refs)) }; } catch (error) { // Pi catches extension errors. Explicitly abort so it cannot fall back to old context. + if (active) active.guidanceRequests.latest = undefined; ctx.abort(); try { flush(); } catch (persistenceError) { fault = errorText(persistenceError); } ctx.ui.notify(errorText(error), 'error'); @@ -501,7 +512,9 @@ export default function optchat(pi: ExtensionAPI) { }); registerConnectedRenderer(pi); registerReportRenderer(pi); - const [zoom, date] = memoryTools(() => required().memory, id => required().children.transcript(id)); + const [zoom, date] = memoryTools(() => required().memory, id => required().children.transcript(id), { + run: id => required().children.guidanceForTranscript(id), + }); pi.registerTool(zoom); pi.registerTool(date); pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false }); // The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable. diff --git a/src/memory.ts b/src/memory.ts index 02a3934..a2893ea 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; +import { validGuidanceRef, type GuidanceRef } from './guidance-ref.ts'; import { FileStore, storeFor, type Saved, type Store } from './store.ts'; export const NODE = 512; @@ -9,7 +10,7 @@ export const PAGE = 25_000; export const KINDS = ['user', 'talk', 'tool', 'echo', 'note', 'work'] as const; export type Kind = typeof KINDS[number]; export interface Origin { source: 'claude' | 'claude-memory' | 'codex' | 'pi' | 'chatgpt'; conversation: string; message: string; title: string; project?: string } -export interface Entry { i: number; kind: Kind; text: string; size: number; date: string; receipt?: string; origin?: Origin } +export interface Entry { i: number; kind: Kind; text: string; size: number; date: string; receipt?: string; origin?: Origin; guidance?: GuidanceRef } export interface Part { l: number; i: number } export interface Summary extends Part { text: string; size: number } /** `part` is the node to build: a message's leaf, or the merge of its two halves. `call` is the tool call a tool result's leaf @@ -87,7 +88,8 @@ function object(value: unknown): value is Record { export function isEntry(value: unknown): value is Entry { return object(value) && Number.isSafeInteger(value.i) && typeof value.kind === 'string' && KINDS.some(kind => kind === value.kind) - && typeof value.text === 'string' && typeof value.date === 'string'; + && typeof value.text === 'string' && typeof value.date === 'string' + && (value.guidance === undefined || validGuidanceRef(value.guidance)); } function isSummary(value: unknown): value is Summary { return object(value) && Number.isSafeInteger(value.l) && Number.isSafeInteger(value.i) @@ -157,10 +159,10 @@ export class Memory { for (let i = loaded; i < this.root.length; i++) { this.push(i); this.fit(i + 1); } this.fit(); this.save(); this.schedule(); } - append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin) { + append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string, origin?: Origin, guidance?: GuidanceRef) { if (this.stopped) throw new Error('Memory is closed.'); if (this.failed) throw new Error(`Memory could not be saved: ${this.failed}`); - const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}) }; + const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}), ...(origin ? { origin } : {}), ...(guidance ? { guidance } : {}) }; void this.enqueue(() => this.store.append(entry), error => { this.failed ??= error instanceof Error ? error.message : String(error); this.warn(`Memory could not be saved: ${this.failed}`); diff --git a/src/profiles.ts b/src/profiles.ts index 24a0ad9..a464c5d 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -54,7 +54,10 @@ export function loadConfig(dir: string): ProfileConfig { try { return { compactor: value.compactor, subagent: value.subagent, ...readSettings(value) }; } catch (error) { throw new Error(`Invalid profile config: ${dir}/config.json: ${error instanceof Error ? error.message : String(error)}`); } } -export function instructions(dir: string) { return readFileSync(join(dir, 'AGENTS.md'), 'utf8'); } +export function instructions(dir: string) { + try { return readFileSync(join(dir, 'AGENTS.md'), 'utf8'); } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''; throw error; } +} export function lastProfile() { try { const name = readFileSync(join(dataHome(), 'last-profile'), 'utf8').trim(); return listProfiles().includes(name) ? name : undefined; } catch { return undefined; } diff --git a/src/store.ts b/src/store.ts index 56b8897..5d5645b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -28,7 +28,7 @@ export interface Lease { } /** Where one memory keeps what travels between machines: its message log, summary nodes, saved view and images. Everything else - * in a profile (runs, recovery journals, the active-memory pointer and imports, usage, settings, AGENTS.md) stays on the machine. + * in a profile (runs, recovery journals, the active-memory pointer and imports, usage, settings, AGENTS.md and its guidance archive) stays on the machine. * Memory loads once when it opens, then works in RAM and issues every write in order. */ export interface Store { load(): Maybe; diff --git a/src/tools.ts b/src/tools.ts index d6ac223..acc1365 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -2,7 +2,8 @@ import { Type, type Static } from 'typebox'; import type { AgentMessage } from '@earendil-works/pi-agent-core'; import { flat, PAGE, start, type Memory } from './memory.ts'; import { loadImages } from './images.ts'; -import { runTranscript } from './transcript.ts'; +import { runTranscriptParts } from './transcript.ts'; +import type { GuidanceRef, GuidanceRecallRef } from './guidance-recall.ts'; export const result = (text: string) => ({ content: [{ type: 'text' as const, text }], details: {} }); export const SEARCH_PAGE = 20; const SNIPPET = 200; @@ -38,18 +39,36 @@ export function runPage(text: string, offset = 0, limit = PAGE) { } /** `runs` gives a subagent's messages by run id, live or finished; undefined for no such run. */ -export function memoryTools(memory: () => Memory, runs?: (id: string) => readonly AgentMessage[] | undefined) { +export function memoryTools(memory: () => Memory, runs?: (id: string) => readonly AgentMessage[] | undefined, + recall?: { run: (id: string) => Map }) { + const decorate = (text: string, refs: GuidanceRecallRef[]) => { + const details: Record = {}; + if (refs.length && recall) details.optchatGuidanceRecall = refs.map(({ request, work }) => ({ request, work: work ?? '' })); + return { ...result(text), details }; + }; return [ - { name: 'zoom', label: 'Zoom memory', description: `Open the line id+n of the view into the two lines of n/2 under it; n = 1 (the default) gives the message whole. A message over ${PAGE.toLocaleString('en-US')} characters comes in pages; offset and limit (characters) read any part of it, and are not needed for a shorter one. A message's images come back with it. zoom("") gives a subagent's whole chat so far, in the same pages.`, + { name: 'zoom', label: 'Zoom memory', description: `Open the line id+n of the view into the two lines of n/2 under it; n = 1 (the default) gives the message whole. A message over ${PAGE.toLocaleString('en-US')} characters comes in pages; offset and limit (characters) read any part of it, and are not needed for a shorter one. A message's images come back with it. zoom("") gives a subagent's whole chat so far, in the same pages. Original work includes references to its historical guidance when recorded; these copies do not override current instructions.`, parameters: zoomParameters, async execute(_id: string, { id, n = 1, offset, limit }: Static) { if (typeof id === 'string') { const messages = runs?.(id); - if (messages) return result(runPage(runTranscript(messages), offset, limit)); + if (messages) { + const parts = runTranscriptParts(messages), text = parts.map(part => part.text).join('\n'); + const page = runPage(text, offset, limit), refs: GuidanceRecallRef[] = []; + const associations = recall?.run(id); + let at = 0; + for (const part of parts) { + const end = at + part.text.length, ref = associations?.get(part.index); + if (ref && end > (offset ?? 0) && at < (offset ?? 0) + (limit ?? PAGE)) refs.push({ ...ref, work: `run ${id}, characters ${at}-${end}` }); + at = end + 1; + } + return decorate(page, refs); + } // A model may quote a message id; eight digits can be a run id (src/agents.ts), so those never open a message. if (!/^\d+$/.test(id) || id.length === 8) throw new Error(`No run ${id}.`); } - const m = memory(), text = m.zoom(Number(id), n, offset, limit), page = result(text); + const m = memory(), text = m.zoom(Number(id), n, offset, limit), ref = m.root[Number(id)]?.guidance; + const page = decorate(text, n === 1 && ref ? [{ ...ref, work: `memory message ${id}` }] : []); // A message's images come back with its text, as read returns a PNG; summaries stay text. return n === 1 ? { ...page, content: [...page.content, ...await loadImages(m.store, text)] } : page; } }, diff --git a/src/transcript.ts b/src/transcript.ts index eac10ad..6df4154 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import type { GuidanceRef } from './guidance-recall.ts'; import type { AgentMessage } from '@earendil-works/pi-agent-core'; import type { SessionEntry } from '@earendil-works/pi-coding-agent'; import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai'; @@ -46,19 +47,19 @@ export function asUser(message: AgentMessage): AgentMessage { if (first?.type === 'text') return { role: 'user', content: [{ ...first, text: tag + first.text }, ...rest], timestamp }; return { role: 'user', content: [{ type: 'text', text: tag.trimEnd() }, ...content], timestamp }; } -export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) { +export function logMessage(memory: Memory, message: AgentMessage, receipt?: string, guidance?: GuidanceRef) { const date = new Date(message.timestamp).toISOString(); - if (message.role === 'user') memory.append(receipt?.startsWith(REPORT_RECEIPT) ? 'work' : 'user', textContent(message.content), date, receipt); + if (message.role === 'user') memory.append(receipt?.startsWith(REPORT_RECEIPT) ? 'work' : 'user', textContent(message.content), date, receipt, undefined, guidance); else if (message.role === 'assistant') { for (const block of message.content) { - if (block.type === 'text' && block.text.trim()) memory.append('talk', block.text, date); - if (block.type === 'toolCall') memory.append('tool', `${block.name} ${JSON.stringify(block.arguments)}`, date); + if (block.type === 'text' && block.text.trim()) memory.append('talk', block.text, date, undefined, undefined, guidance); + if (block.type === 'toolCall') memory.append('tool', `${block.name} ${JSON.stringify(block.arguments)}`, date, undefined, undefined, guidance); } if (message.stopReason === 'error' || message.stopReason === 'aborted') - memory.append('echo', `Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`, date); + memory.append('echo', `Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`, date, undefined, undefined, guidance); } else if (message.role === 'toolResult') // A zoom's page already names the images it returns. - memory.append('echo', cap(`${message.toolName}: ${textContent(message.content, message.toolName !== 'zoom')}`), date); + memory.append('echo', cap(`${message.toolName}: ${textContent(message.content, message.toolName !== 'zoom')}`), date, undefined, undefined, guidance); } export function boundedMessage(message: AgentMessage): AgentMessage { if (message.role !== 'toolResult') return message; @@ -132,9 +133,9 @@ const TASK = '\n\n\nYour task:\n'; /** Tool calls and results in a run transcript keep their head and tail, so a page holds many steps. */ const STEP = 1_000; /** A subagent's chat for zoom, as `kind|text` lines: its task without the memory view, replies, tool calls and results, and what it was told. */ -export function runTranscript(messages: readonly AgentMessage[]) { +export function runTranscriptParts(messages: readonly AgentMessage[]) { const task = messages.find(message => message.role === 'user'); - return messages.flatMap(message => { + return messages.map((message, index) => ({ index, text: (() => { if (message.role === 'user') { // Only the task prompt holds the memory view (src/agents.ts): `${view}\n\nYour task:\n${task}`. const text = textContent(message.content, false), at = text.indexOf(TASK); @@ -145,8 +146,9 @@ export function runTranscript(messages: readonly AgentMessage[]) { return [...message.content.flatMap(block => block.type === 'text' && block.text.trim() ? [`talk|${block.text}`] : block.type === 'toolCall' ? [`tool|${cap(`${block.name} ${JSON.stringify(block.arguments)}`, STEP)}`] : []), ...message.stopReason === 'error' || message.stopReason === 'aborted' ? [`echo|Agent ${message.stopReason}: ${message.errorMessage ?? 'No further details'}`] : []]; - }).join('\n'); + })().join('\n') })).filter(part => part.text); } +export function runTranscript(messages: readonly AgentMessage[]) { return runTranscriptParts(messages).map(part => part.text).join('\n'); } /** The status line naming running agents by id and the first words of their task; none while nothing runs. */ export function atWork(runs: readonly { id: string; task: string }[]) { diff --git a/test/checkpoint.test.ts b/test/checkpoint.test.ts index a5e5fa6..7c7cb2c 100644 --- a/test/checkpoint.test.ts +++ b/test/checkpoint.test.ts @@ -48,3 +48,14 @@ test('a checkpoint whose commit lands resolves even when git cannot pack afterwa assert.equal(execFileSync('git', ['-C', dir, 'log', '--format=%s'], { encoding: 'utf8' }).trim(), 'Save OptChat memory'); } finally { removeDir(dir); } }); + +test('checkpoints retain guidance artifacts beyond the Windows legacy path limit', async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-long-checkpoint-')); + try { + const name = ['guidance', 'sources', 'project-'.repeat(8), 'a'.repeat(64), 'nested-'.repeat(8), 'AGENTS.md'].join('/'); + const path = join(dir, name); assert.ok(path.length > 260); + mkdirSync(join(path, '..'), { recursive: true }); writeFileSync(path, 'Preserved guidance.'); + await checkpoint(dir); + assert.ok(tracked(dir).includes(name)); + } finally { removeDir(dir); } +}); diff --git a/test/guidance-lifecycle.test.ts b/test/guidance-lifecycle.test.ts index eba4b56..5c5bca0 100644 --- a/test/guidance-lifecycle.test.ts +++ b/test/guidance-lifecycle.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createAssistantMessageEventStream, getSystemMessageText, type AssistantMessage, type Context } from '@earendil-works/pi-ai'; @@ -13,18 +13,20 @@ import { COMPACT, TURN_REFRESHED } from '../src/prompts.ts'; import { REPORT_TYPE, textContent } from '../src/transcript.ts'; import { emptyUsage } from '../src/usage.ts'; import { GUIDANCE_STATE, type GuidanceState } from '../src/guidance.ts'; +import { GuidanceRequests } from '../src/guidance-recall.ts'; import { removeDir } from './support.ts'; const system = (context: Context) => { const head = context.messages[0]; return head?.role === 'system' ? getSystemMessageText(head) : ''; }; const notices = (context: Context) => context.messages.filter(m => textContent(m.content).includes('')); -async function runtime(dir: string, respond: (context: Context) => AssistantMessage['content']) { +async function runtime(dir: string, respond: (context: Context) => AssistantMessage['content'], contextWindow = 1000, usageInput = () => 0) { const runtime = await ModelRuntime.create({ authPath: join(dir, 'auth.json'), modelsPath: null, modelsStorePath: join(dir, 'models.json'), refreshOnCreate: false }); runtime.registerProvider('guidance-test', { baseUrl: 'https://invalid.local', apiKey: 'synthetic', api: 'openai-completions', - models: [{ id: 'model', name: 'Fixture', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 }], + models: [{ id: 'model', name: 'Fixture', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow, maxTokens: 100 }], streamSimple(model, context) { const content = system(context) === COMPACT ? [{ type: 'text' as const, text: 'Summary.' }] : respond(context); const stopReason = content.some(b => b.type === 'toolCall') ? 'toolUse' : 'stop'; - const message: AssistantMessage = { role: 'assistant', content, stopReason, api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), usage: emptyUsage() }; + const usage = emptyUsage(); usage.input = usage.totalTokens = usageInput(); + const message: AssistantMessage = { role: 'assistant', content, stopReason, api: model.api, provider: model.provider, model: model.id, timestamp: Date.now(), usage }; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { stream.push({ type: 'done', reason: stopReason, message }); stream.end(); }); return stream; @@ -93,7 +95,24 @@ test('main requests refresh guidance after tools, report wake, memory refresh an await session!.prompt('Continue after removal.'); assert.doesNotMatch(system(captured.at(-1)!), /Use PostgreSQL/); assert.equal(readFileSync(old.archive, 'utf8'), 'Use SQLite.'); + const beforeFailure = captured.length, states = session!.sessionManager.getBranch().filter(e => e.type === 'custom' && e.customType === GUIDANCE_STATE).length; + const requests = join(profile, 'guidance', 'requests'), backup = join(profile, 'guidance', 'requests-backup'); + renameSync(requests, backup); writeFileSync(requests, 'unwritable request directory'); + writeFileSync(source, 'A new revision that cannot be associated.'); + await session!.prompt('Continue despite the simulated storage failure.'); + assert.equal(captured.length, beforeFailure, 'archive association failure must stop provider dispatch'); + assert.equal(session!.sessionManager.getBranch().filter(e => e.type === 'custom' && e.customType === GUIDANCE_STATE).length, states, + 'failed request persistence must not advance the last supplied revision'); + rmSync(requests); renameSync(backup, requests); const log = readdirSync(join(profile, 'main')).map(name => readFileSync(join(profile, 'main', name), 'utf8')).join(''); + const rows = log.trim().split('\n').map(line => JSON.parse(line)); + const firstTool = rows.find(e => e.kind === 'tool'), firstEcho = rows.find(e => e.kind === 'echo'); + assert.equal(firstTool.guidance.request, firstEcho.guidance.request, 'a tool action and its result share the request that issued it'); + assert.equal(rows[0].guidance.request, firstTool.guidance.request, 'the original input is bound to the initial request'); + const readRequest = (id: string) => JSON.parse(readFileSync(join(profile, 'guidance', 'requests', `${id}.json`), 'utf8')); + assert.equal(readRequest(firstTool.guidance.request).revision, (first.data as GuidanceState).revision.id); + assert.notEqual(rows.filter(e => e.kind === 'tool')[1].guidance.request, firstTool.guidance.request); + assert.ok(captured.some(c => JSON.stringify(c).includes('')), 'zoom annotations actually reach the model'); const tree = readdirSync(join(profile, 'tree')).map(name => readFileSync(join(profile, 'tree', name), 'utf8')).join(''); assert.doesNotMatch(log + tree, /guidance_update|guidance\/sources|historical copies|Use SQLite|Use PostgreSQL/); assert.deepEqual(errors, []); @@ -131,6 +150,78 @@ test('running and resumed subagents observe their own project and their own revi assert.match(system(captured[2]), /Use PostgreSQL/); assert.equal(notices(captured[2]).length, 1); assert.notEqual(captured[2].messages.at(-1), notices(captured[2])[0], 'resumed work follows the original notice'); + const associations = children.guidanceForTranscript(id); + assert.ok(associations.size >= 4); + const archive = new GuidanceRequests(profile); + const descriptions = [...associations.values()].map(ref => archive.describe([ref])); + assert.ok(descriptions.some(text => text.includes('historical_guidance'))); + assert.equal(new Set([...associations.values()].map(ref => ref.request)).size, 3, 'initial, post-tool and resumed requests stay distinct'); + assert.deepEqual(warnings, []); + } finally { await children.close(); await memory.close(); removeDir(dir); } +}); + +test('a running child aborts before another provider call when current guidance cannot be read', async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-failure-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); + mkdirSync(cwd); mkdirSync(profile); const source = join(cwd, 'AGENTS.md'); writeFileSync(source, 'Use SQLite.'); + const memory = new Memory(profile, async () => 'Summary.'); memory.append('user', 'Task.'); + let calls = 0; + const rt = await runtime(dir, () => { + calls++; writeFileSync(source, Buffer.from([0xff])); + return [{ type: 'toolCall', id: 'date', name: 'date', arguments: { id: 0 } }]; + }); + const warnings: string[] = []; + const children = new Children(memory, new ModelRegistry(rt), () => ({ provider: 'guidance-test', model: 'model', thinking: 'off' }), () => '', async () => {}, message => warnings.push(message), profile, + { createSession: options => createAgentSession({ ...options, modelRuntime: rt }), settings: () => ({ refreshAt: 0 }) }); + try { + await children.spawn([{ task: 'Check date.', cwd }], dir); + const deadline = Date.now() + 5000; + while (children.active) { assert.ok(Date.now() < deadline, 'child must stop after guidance failure'); await new Promise(resolve => setTimeout(resolve, 5)); } + assert.equal(calls, 1, 'Pi must not fall back to its stale cached prompt after an extension error'); + assert.ok(warnings.some(warning => warning.includes('Cannot read current guidance'))); + } finally { await children.close(); await memory.close(); removeDir(dir); } +}); + + +test('actual Pi subagent compaction retains current guidance, earlier transitions and exact retained-work associations', async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-compact-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); + mkdirSync(cwd); mkdirSync(profile); + const extensions = join(cwd, '.pi', 'extensions'); mkdirSync(extensions, { recursive: true }); + writeFileSync(join(extensions, 'fixture.js'), `export default pi => pi.on('session_before_compact', event => ({ compaction: { + summary: 'Earlier work summarized.', firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore + } }));`); + const source = join(cwd, 'AGENTS.md'); writeFileSync(source, 'Use SQLite.'); + const memory = new Memory(profile, async () => 'Summary.'); memory.append('user', 'Reference material. '.repeat(2000)); + const captured: Context[] = []; let calls = 0; + const rt = await runtime(dir, context => { + captured.push(structuredClone(context)); calls++; + if (calls === 1) writeFileSync(source, 'Use PostgreSQL.'); + return calls <= 2 ? [...(calls === 1 ? [{ type: 'text' as const, text: 'EARLY_WORK '.repeat(3000) }] : []), + { type: 'toolCall', id: `zoom-${calls}`, name: 'zoom', arguments: { id: 0 } }] : [{ type: 'text', text: 'Done.' }]; + }, 100000, () => calls === 2 ? 70000 : 100); + const warnings: string[] = []; + const children = new Children(memory, new ModelRegistry(rt), () => ({ provider: 'guidance-test', model: 'model', thinking: 'off' }), () => '', async () => {}, warning => warnings.push(warning), profile, { + createSession: options => createAgentSession({ ...options, modelRuntime: rt, + settingsManager: SettingsManager.inMemory({ compaction: { enabled: true, reserveTokens: 38000, keepRecentTokens: 1000 }, retry: { enabled: false }, cacheWarming: 'off' }) }), + }); + try { + const [id] = await children.spawn([{ task: 'Inspect reference twice.', cwd }], dir); + const deadline = Date.now() + 10000; + while (children.active) { assert.ok(Date.now() < deadline); await new Promise(resolve => setTimeout(resolve, 5)); } + assert.equal(captured.length, 3); + const manager = SessionManager.open(children.history.records.get(id)!.sessionFile!); + assert.equal(manager.getEntries().filter(e => e.type === 'compaction').length, 1); + assert.match(system(captured[2]), /Use PostgreSQL/); + assert.doesNotMatch(system(captured[2]), /Use SQLite/); + assert.match(JSON.stringify(notices(captured[2])), /already applied, not a new change/); + const messages = children.transcript(id)!, refs = children.guidanceForTranscript(id); + const early = messages.findIndex(m => m.role === 'assistant' && textContent(m.content).includes('EARLY_WORK')); + assert.ok(early >= 0 && refs.has(early)); + const archived = new GuidanceRequests(profile).describe([refs.get(early)!]); + const firstState = manager.getEntries().find(e => e.type === 'custom' && e.customType === GUIDANCE_STATE)!; + assert.equal(firstState.type, 'custom'); + const original = (firstState.data as GuidanceState).revision.files.find(f => f.path === source)!; + assert.ok(archived.includes(original.archive)); + assert.equal(readFileSync(original.archive, 'utf8'), 'Use SQLite.'); assert.deepEqual(warnings, []); } finally { await children.close(); await memory.close(); removeDir(dir); } }); diff --git a/test/guidance-live.ts b/test/guidance-live.ts new file mode 100644 index 0000000..b95843d --- /dev/null +++ b/test/guidance-live.ts @@ -0,0 +1,49 @@ +// Explicit opt-in: real provider requests with synthetic instructions and no private project/profile context. +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { ModelRuntime, convertToLlm } from '@earendil-works/pi-coding-agent'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { Guidance, type GuidanceState } from '../src/guidance.ts'; +import { textContent } from '../src/transcript.ts'; +import { cachePayload } from '../src/cache.ts'; + +const runtime = await ModelRuntime.create({ refreshOnCreate: false }); +const provider = process.env.OPTCHAT_GUIDANCE_PROVIDER ?? 'openai'; +const modelId = process.env.OPTCHAT_GUIDANCE_MODEL ?? 'gpt-6.1-sol'; +const model = (await runtime.getAvailable(provider)).find(model => model.id === modelId); +assert.ok(model, `No usable login/model for ${provider}/${modelId}. Log in with Pi or set OPTCHAT_GUIDANCE_PROVIDER and OPTCHAT_GUIDANCE_MODEL; no live requests were sent.`); +const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-live-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); +mkdirSync(cwd); mkdirSync(profile); +const file = join(profile, 'AGENTS.md'), guidance = new Guidance(profile, { discover: false }); +const background = Array.from({ length: 500 }, (_, i) => `Synthetic project convention ${i}: keep deliberate architecture choices consistent with the current guidance.`).join('\n'); +const set = (database: string) => writeFileSync(file, `The required project database is ${database}.\n${background}`); +let state: GuidanceState | undefined; +const rows: { phase: string; answer: string; input: number; cacheRead: number; cacheWrite: number; promptHash: string }[] = []; +const messages: AgentMessage[] = [{ role: 'system', content: 'Follow the current project guidance. This is a synthetic validation; answer only the requested short answer.', timestamp: 0 }]; +try { + for (const [index, phase] of ['initial', 'unchanged', 'edited', 'unchanged-after-edit'].entries()) { + if (index === 0) set('SQLite'); + if (index === 2) set('PostgreSQL'); + messages.push({ role: 'user', content: `\n${index}+1|Synthetic progress.\n\n\n${index < 2 ? 'Which database is required now? Reply with its name only.' : 'Which database is required now, and does earlier SQLite work necessarily imply an instruction violation? Reply: database; yes/no.'}`, timestamp: index + 1 }); + const prepared = guidance.prepare(messages, cwd, state, next => { state = next; }); + const head = prepared.messages[0]; assert.equal(head.role, 'system'); + const promptHash = createHash('sha256').update(textContent(head.content)).digest('hex'); + const reply = await runtime.streamSimple(model, { messages: convertToLlm(prepared.messages) }, { + maxTokens: 512, sessionId: `guidance-validation-${dir}`, + onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload, + }).result(); + assert.notEqual(reply.stopReason, 'error', reply.errorMessage); + const answer = textContent(reply.content); + rows.push({ phase, answer, input: reply.usage.input, cacheRead: reply.usage.cacheRead, cacheWrite: reply.usage.cacheWrite, promptHash }); + console.log(JSON.stringify(rows.at(-1))); + assert.match(answer, index < 2 ? /SQLite/i : /PostgreSQL.*no/i); + messages.push(reply); + } + assert.equal(rows[0].promptHash, rows[1].promptHash); + assert.notEqual(rows[1].promptHash, rows[2].promptHash); + assert.equal(rows[2].promptHash, rows[3].promptHash); + console.log('PASS: current guidance and transition interpretation. Cache counters above are observations, not guaranteed savings.'); +} finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/test/guidance-provider.test.ts b/test/guidance-provider.test.ts new file mode 100644 index 0000000..2243e83 --- /dev/null +++ b/test/guidance-provider.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import type { Model } from '@earendil-works/pi-ai'; +import { normalizeContext } from '@earendil-works/pi-ai/utils/transcript'; +import { convertToLlm } from '@earendil-works/pi-coding-agent'; +import { stream as anthropic } from '@earendil-works/pi-ai/api/anthropic-messages'; +import { stream as responses } from '@earendil-works/pi-ai/api/openai-responses'; +import { stream as completions } from '@earendil-works/pi-ai/api/openai-completions'; +import { Guidance, type GuidanceState } from '../src/guidance.ts'; +import { cachePayload, record } from '../src/cache.ts'; +import { removeDir } from './support.ts'; + +for (const api of ['anthropic-messages', 'openai-responses', 'openai-completions'] as const) { + test(`${api}: actual adapter payload keeps guidance before changing memory and the transition after work`, async () => { + const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-wire-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); + mkdirSync(cwd); mkdirSync(profile); const source = join(cwd, 'AGENTS.md'); writeFileSync(source, 'Use SQLite.'); + const guidance = new Guidance(profile); let state: GuidanceState | undefined; + const model: Model = { api, provider: api === 'anthropic-messages' ? 'anthropic' : 'openai', id: 'fixture', name: 'Fixture', + baseUrl: 'https://invalid.local', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 20 }; + async function capture(memory: string) { + const base: AgentMessage[] = [{ role: 'system', content: 'STABLE_INSTRUCTIONS', timestamp: 0 }, + { role: 'user', content: [{ type: 'text', text: `\n0+1|memory\n1+1|more\n2+1|more\n3+1|more\n${memory}\n` }, { type: 'text', text: '\n\nRECENT_WORK' }], timestamp: 1 }]; + const prepared = guidance.prepare(base, cwd, state, s => { state = s; }, join(dir, 'agent')); + let payload: unknown; + const options = { apiKey: 'synthetic', maxTokens: 20, + onPayload(value: unknown) { payload = structuredClone(api === 'anthropic-messages' ? cachePayload(value) : value); throw new Error('Captured before network'); }, + fetch: (() => { throw new Error('Unexpected network request'); }) as typeof fetch }; + const context = normalizeContext({ messages: convertToLlm(prepared.messages) }); + const stream = api === 'anthropic-messages' ? anthropic(model as Model<'anthropic-messages'>, context, options) + : api === 'openai-responses' ? responses(model as Model<'openai-responses'>, context, options) + : completions(model as Model<'openai-completions'>, context, options); + const result = await stream.result(); + assert.match(result.errorMessage ?? '', /Captured before network/); + assert.ok(record(payload)); return payload; + } + try { + const first = await capture('view one'); + const unchanged = await capture('view two'); + const head = (payload: Record) => api === 'anthropic-messages' ? JSON.stringify(payload.system) + : JSON.stringify((api === 'openai-responses' ? payload.input : payload.messages as unknown[]) && ((api === 'openai-responses' ? payload.input : payload.messages) as unknown[])[0]); + assert.equal(head(first), head(unchanged), 'changing memory leaves the guidance prefix byte-identical'); + utimesSync(source, new Date(), new Date(Date.now() + 10000)); + assert.deepEqual(await capture('view two'), unchanged, 'touching guidance produces an identical provider payload'); + writeFileSync(source, 'Use PostgreSQL.'); + const changed = await capture('view three'), serialized = JSON.stringify(changed); + assert.match(head(changed), /Use PostgreSQL/); assert.doesNotMatch(head(changed), /Use SQLite/); + assert.ok(head(changed).indexOf('STABLE_INSTRUCTIONS') < head(changed).indexOf('Use PostgreSQL')); + const ordered = head(changed) + JSON.stringify(api === 'openai-responses' ? changed.input : changed.messages); + assert.ok(ordered.indexOf('Use PostgreSQL') < ordered.indexOf('')); + assert.ok(serialized.indexOf('RECENT_WORK') < serialized.indexOf('')); + assert.equal(serialized.split('').length - 1, 1); + if (api === 'anthropic-messages') { + assert.ok(JSON.stringify(changed.messages).includes('cache_control')); + assert.deepEqual(changed.cache_control, { type: 'ephemeral' }); + assert.doesNotMatch(head(changed), /cache_control/, 'existing OptChat view cache marks are preserved'); + } + } finally { removeDir(dir); } + }); +} diff --git a/test/guidance-recall.test.ts b/test/guidance-recall.test.ts new file mode 100644 index 0000000..84da855 --- /dev/null +++ b/test/guidance-recall.test.ts @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { SessionManager, serializeConversation, convertToLlm } from '@earendil-works/pi-coding-agent'; +import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { Guidance, currentGuidance } from '../src/guidance.ts'; +import { GuidanceRequests, GUIDANCE_REQUEST, transcriptGuidance, withGuidanceRecall } from '../src/guidance-recall.ts'; +import { Memory } from '../src/memory.ts'; +import { memoryTools } from '../src/tools.ts'; +import { logMessage, runTranscript, textContent } from '../src/transcript.ts'; +import { emptyUsage } from '../src/usage.ts'; +import { prepareImport, runImport, memoryDirectory } from '../src/import/job.ts'; +import { removeDir } from './support.ts'; + +const user: AgentMessage = { role: 'user', content: 'Build the database.', timestamp: 1 }; +const reply: AgentMessage = { role: 'assistant', content: [{ type: 'text', text: 'Built the database.' }], timestamp: 2, stopReason: 'stop', api: 'openai-completions', provider: 'fixture', model: 'fixture', usage: emptyUsage() }; +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'optchat-recall-')), cwd = join(dir, 'project'), profile = join(dir, 'profile'); + mkdirSync(cwd); mkdirSync(profile); const source = join(cwd, 'AGENTS.md'); writeFileSync(source, 'Use SQLite.'); + const requests = new GuidanceRequests(profile), guidance = new Guidance(profile); + const revision = () => guidance.archive.preserve(currentGuidance(cwd, profile, join(dir, 'agent'))); + return { dir, cwd, profile, source, requests, guidance, revision }; +} + +test('original-message recall survives restart, source moves/deletion and rebuilt memory without summarizing annotations', async () => { + const f = fixture(), summaries: string[] = []; + const compress = async (input: { source: string; context: string }) => { summaries.push(input.source + input.context); return 'Summary.'; }; + let memory = new Memory(f.profile, compress); + try { + const old = f.revision(), input = f.requests.input(); + logMessage(memory, user, undefined, input); + assert.deepEqual(f.requests.prepare(old, 'main', [user]), input); + logMessage(memory, reply, undefined, f.requests.latest); + writeFileSync(f.source, 'Use PostgreSQL.'); + const current = f.revision(), latest = f.requests.prepare(current, 'main', [reply]); + logMessage(memory, { ...reply, timestamp: 3 }, undefined, latest); + await memory.settle(undefined, 'tree'); await memory.close(); + renameSync(f.source, join(f.dir, 'moved.md')); rmSync(join(f.dir, 'moved.md')); + memory = await Memory.open(f.profile, compress); + const restored = new GuidanceRequests(f.profile); + const zoom = memoryTools(() => memory, undefined, { run: () => new Map() })[0]; + const result = await zoom.execute('recall', { id: 1 }); + assert.doesNotMatch(textContent(result.content), /historical_guidance/); + const message: AgentMessage = { role: 'toolResult', toolCallId: 'recall', toolName: 'zoom', isError: false, ...result, timestamp: 4 }; + const annotated = withGuidanceRecall([message], refs => restored.describe(refs)); + assert.equal(annotated[0].role, 'toolResult'); + assert.match(textContent(annotated[0].content), /historical_guidance/); + assert.match(textContent(annotated[0].content), new RegExp(input.request)); + assert.ok(textContent(annotated[0].content).includes(old.files[0].archive)); + assert.equal(readFileSync(old.files[0].archive, 'utf8'), 'Use SQLite.'); + assert.doesNotMatch(textContent(annotated[0].content), new RegExp(latest.request)); + logMessage(memory, message, undefined, latest); + await memory.settle(undefined, 'tree'); + assert.doesNotMatch(summaries.join('\n') + memory.render(), /historical_guidance|guidance\/sources/); + assert.doesNotMatch(serializeConversation(convertToLlm([message])), /historical_guidance|guidance\/sources|optchatGuidanceRecall/, 'Pi compaction sees only canonical tool text too'); + const summary = await zoom.execute('summary', { id: 0, n: 2 }); + assert.deepEqual(summary.details, {}, 'summary zoom is never automatically annotated'); + // Rebuild renumbers native messages; metadata must travel with those messages, not their former ids. + await prepareImport(f.profile, memory, [{ kind: 'user', text: 'Older imported input.', date: '1970-01-01T00:00:00.000Z', receipt: 'import:old' }], 'rebuild'); + await memory.close(); + await runImport(f.profile, compress, new AbortController().signal); + memory = await Memory.open(memoryDirectory(f.profile), compress); + const native = memory.root.find(e => e.text === 'Built the database.' && e.date === new Date(2).toISOString())!; + assert.equal(native.guidance?.request, input.request); + assert.ok(restored.describe([native.guidance!]).includes(old.files[0].archive)); + } finally { await memory.close(); removeDir(f.dir); } +}); + +test('parallel agents receive independently associated snapshots; retained transcript boundaries survive compaction and reload', () => { + const f = fixture(); + try { + const old = f.revision(), first = f.requests.prepare(old, 'child-A', [user]); + writeFileSync(f.source, 'Use PostgreSQL.'); + const current = f.revision(), second = f.requests.prepare(current, 'child-B', [user]); + assert.notEqual(first.request, second.request); + const manager = SessionManager.create(f.cwd, join(f.dir, 'sessions')); + manager.appendMessage(user); manager.appendCustomEntry(GUIDANCE_REQUEST, first); manager.appendMessage(reply); + const u = manager.appendMessage({ ...user, content: 'Continue.', timestamp: 3 }); + manager.appendCustomEntry(GUIDANCE_REQUEST, second); manager.appendMessage({ ...reply, timestamp: 4 }); + manager.appendCompaction('Earlier work summarized.', u, 1000); + const entries = SessionManager.open(manager.getSessionFile()!).getEntries(), refs = transcriptGuidance(entries); + assert.deepEqual([...refs.values()], [first, first, second, second]); + assert.match(f.requests.describe([refs.get(1)!]), /agent child-A/); + assert.match(f.requests.describe([refs.get(3)!]), /agent child-B/); + const streamed = { ...reply, timestamp: 5 }; + assert.deepEqual(transcriptGuidance(entries, streamed).get(4), second); + } finally { removeDir(f.dir); } +}); + +test('paged child recall includes only the requests accompanying that page, without changing offsets or stored transcript', async () => { + const f = fixture(), memory = new Memory(f.profile, async () => 'Summary.'); + try { + const a = f.requests.prepare(f.revision(), 'child', [user]); + writeFileSync(f.source, 'Use PostgreSQL.'); const b = f.requests.prepare(f.revision(), 'child', [reply]); + const messages = [user, reply], plain = runTranscript(messages), at = plain.indexOf('talk|'); + const zoom = memoryTools(() => memory, () => messages, { run: () => new Map([[0, a], [1, b]]) })[0]; + const page = await zoom.execute('call', { id: 'child', offset: at, limit: 10 }); + assert.equal(textContent(page.content), `${plain.slice(at, at + 10)}\n[characters ${at}-${at + 10} of ${plain.length}; go on with offset ${at + 10}]`); + assert.match(JSON.stringify(page.details), new RegExp(b.request)); + assert.ok(JSON.stringify(page.details).includes(`run child, characters ${at}-${plain.length}`), 'the archive reference identifies its original work span'); + assert.doesNotMatch(JSON.stringify(page.details), new RegExp(a.request)); + assert.doesNotMatch(runTranscript(messages), /historical_guidance/); + } finally { await memory.close(); removeDir(f.dir); } +}); + +test('missing request records are explicit, corrupt or missing archives fail honestly, and reading history never activates it', () => { + const f = fixture(); + try { + assert.match(f.requests.describe([f.requests.input()]), /may not have reached/); + const old = f.revision(), ref = f.requests.prepare(old, 'main', [user]); + writeFileSync(f.source, 'Use PostgreSQL.'); + const before = f.guidance.prepare([{ role: 'system', content: 'Stable.', timestamp: 0 }, user], f.cwd, undefined, () => {}); + f.requests.describe([ref]); + const after = f.guidance.prepare([{ role: 'system', content: 'Stable.', timestamp: 0 }, user], f.cwd, undefined, () => {}); + assert.deepEqual(after, before); + writeFileSync(old.files[0].archive, 'tampered'); + assert.throws(() => f.requests.describe([ref]), /Damaged historical guidance/); + rmSync(old.files[0].archive); + assert.throws(() => f.requests.describe([ref]), /ENOENT/); + } finally { removeDir(f.dir); } +}); + +test('moving the profile preserves request recall and reconstructs transition links at the new archive location', () => { + const f = fixture(); + try { + const messages: AgentMessage[] = [{ role: 'system', content: 'Stable.', timestamp: 0 }, user]; + let state: Parameters[2]; + const old = f.guidance.prepare(messages, f.cwd, state, next => { state = next; }); + const ref = f.requests.prepare(old.revision, 'main', [user]); + writeFileSync(f.source, 'Use PostgreSQL.'); + f.guidance.prepare(messages, f.cwd, state, next => { state = next; }); + const moved = join(f.dir, 'moved-profile'); renameSync(f.profile, moved); + const guidance = new Guidance(moved), requests = new GuidanceRequests(moved); + const described = requests.describe([ref]); + assert.ok(described.includes(join(moved, 'guidance', 'sources'))); + assert.ok(!described.includes(join(f.profile, 'guidance', 'sources'))); + const next = guidance.prepare(messages, f.cwd, state, () => assert.fail('same effective revision')); + assert.ok(next.messages.some(message => 'content' in message && textContent(message.content).includes(join(moved, 'guidance', 'sources')))); + const file = guidance.archive.revision(old.revision.id).files[0]; + assert.equal(readFileSync(file.archive, 'utf8'), 'Use SQLite.'); + } finally { removeDir(f.dir); } +}); + + +test('working inside a preserved source folder cannot turn its AGENTS.md into current guidance', () => { + const f = fixture(); + try { + const old = f.revision(); + writeFileSync(join(f.profile, 'AGENTS.md'), 'Use PostgreSQL.'); + const sources = currentGuidance(join(old.files[0].archive, '..'), f.profile, join(f.dir, 'agent')); + assert.ok(sources.some(source => source.supplied === 'Use PostgreSQL.')); + assert.ok(!sources.some(source => source.supplied === 'Use SQLite.')); + } finally { removeDir(f.dir); } +}); diff --git a/test/guidance.test.ts b/test/guidance.test.ts index 3813c45..a4e82d1 100644 --- a/test/guidance.test.ts +++ b/test/guidance.test.ts @@ -1,11 +1,14 @@ import { test } from 'node:test'; +import { execFileSync } from 'node:child_process'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, statSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { AgentMessage } from '@earendil-works/pi-agent-core'; +import { loadProjectContextFiles } from '@earendil-works/pi-coding-agent'; import { Guidance, currentGuidance, type GuidanceState } from '../src/guidance.ts'; import { removeDir } from './support.ts'; +import { AT_WORK } from '../src/prompts.ts'; function fixture() { const dir = mkdtempSync(join(tmpdir(), 'optchat-guidance-')); @@ -95,11 +98,13 @@ test('invalid, oversized, unreadable, racing or unpreservable guidance cannot ad assert.throws(() => f.prepare(), /Cannot read current guidance/); writeFileSync(f.source, 'x'.repeat(1_000_001)); assert.throws(() => f.prepare(), /exceeds/); + writeFileSync(f.source, 'x'.repeat(600_000)); writeFileSync(join(f.profile, 'AGENTS.md'), 'y'.repeat(600_000)); + assert.throws(() => f.prepare(), /in total/); rmSync(join(f.profile, 'AGENTS.md')); rmSync(f.source); mkdirSync(f.source); assert.throws(() => f.prepare(), /not a regular file/); rmSync(f.source, { recursive: true }); writeFileSync(f.source, 'Changed.'); - rmSync(join(f.profile, 'guidance', 'sources'), { recursive: true }); - writeFileSync(join(f.profile, 'guidance', 'sources'), 'cannot write here'); + rmSync(join(f.profile, 'guidance', 'contents'), { recursive: true }); + writeFileSync(join(f.profile, 'guidance', 'contents'), 'cannot write here'); assert.throws(() => f.prepare()); assert.equal(f.state, saved); } finally { removeDir(f.dir); } @@ -112,3 +117,80 @@ test('a broken symlink is a failed source, not removal', { skip: process.platfor assert.throws(() => f.prepare(), /Cannot read current guidance/); } finally { removeDir(f.dir); } }); + + +test('identical contents across sources and source moves share one preserved object while keeping distinct readable paths', () => { + const f = fixture(); + try { + writeFileSync(join(f.agent, 'AGENTS.md'), 'Use SQLite.'); + const first = f.prepare(); + assert.equal(first.revision.files.length, 2); + assert.equal(readdirSync(join(f.profile, 'guidance', 'contents')).length, 1); + assert.equal(statSync(first.revision.files[0].archive).ino, statSync(first.revision.files[1].archive).ino); + renameSync(f.source, join(f.cwd, 'AGENTS.md')); + const moved = f.prepare(); + assert.equal(readdirSync(join(f.profile, 'guidance', 'contents')).length, 1); + assert.notEqual(first.revision.id, moved.revision.id); + for (const file of [...first.revision.files, ...moved.revision.files]) assert.equal(readFileSync(file.archive, 'utf8'), 'Use SQLite.'); + } finally { removeDir(f.dir); } +}); + +test('an explicit discovery opt-out keeps profile guidance but never discovers project or global sources', () => { + const f = fixture(); + try { + writeFileSync(join(f.profile, 'AGENTS.md'), 'Profile only.'); + const guidance = new Guidance(f.profile, { discover: false, agentDir: f.agent }); + const prepared = guidance.prepare(f.messages, f.cwd, undefined, () => {}); + assert.deepEqual(prepared.revision.files.map(f => f.scope), [`profile:${f.profile}`]); + assert.doesNotMatch(text(prepared.messages), /SQLite/); + } finally { removeDir(f.dir); } +}); + + +test('a source-byte change stripped by injection is preserved without an effective-guidance notification', () => { + const f = fixture(); + try { + const first = f.prepare(); + writeFileSync(f.source, '\uFEFFUse SQLite.'); + const next = f.prepare(); + assert.deepEqual(next.messages, first.messages); + assert.notEqual(next.revision.id, first.revision.id, 'source-byte evidence still distinguishes the originals'); + assert.equal(readdirSync(join(f.profile, 'guidance', 'contents')).length, 2); + } finally { removeDir(f.dir); } +}); + + +test('nested linked worktrees follow Pi shadowing and keep the worktree scope', () => { + const f = fixture(); + try { + const repo = join(f.dir, 'project'), worktree = join(repo, 'worktree'); + const git = (...args: string[]) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' }); + git('init', '-q', '-b', 'main'); git('add', 'AGENTS.md'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Initial'); + git('worktree', 'add', '-qb', 'topic', worktree); + writeFileSync(join(worktree, 'AGENTS.md'), 'Worktree conventions.'); + const sources = currentGuidance(worktree, f.profile, f.agent); + // Preserve the installed Pi loader's selection on each OS; its Windows path canonicalization can retain the ancestor too. + const native = loadProjectContextFiles({ cwd: worktree, agentDir: f.agent }); + assert.deepEqual(sources.map(source => ({ path: source.path, content: source.supplied })), native); + assert.equal(sources.at(-1)?.supplied, 'Worktree conventions.'); + assert.equal(sources.at(-1)?.scope, `project:${worktree}`); + } finally { removeDir(f.dir); } +}); + + +test('a transition anchors to actual work, never to a recurring synthetic status footer or every equal message', () => { + const f = fixture(); + try { + f.prepare(); writeFileSync(f.source, 'Use PostgreSQL.'); + const status: AgentMessage = { role: 'user', content: [{ type: 'text', text: AT_WORK + 'child' }], timestamp: 0 }; + const doubled = [...f.messages, f.messages[1], status]; + const changed = f.prepare(doubled); + assert.equal(changed.messages.filter(m => m.role === 'custom').length, 1); + assert.equal(changed.messages[3].role, 'custom', 'only the last equal work message receives the notice'); + const next: AgentMessage = { role: 'user', content: 'Subsequent work.', timestamp: 3 }; + const continued = f.prepare([...doubled.slice(0, -1), next, status]); + assert.equal(continued.messages[3].role, 'custom'); + assert.deepEqual(continued.messages.slice(-2), [next, status], 'the repeated footer must not pull the notice to the bottom'); + } finally { removeDir(f.dir); } +});