diff --git a/packages/coding-agent/src/hindsight/backend.ts b/packages/coding-agent/src/hindsight/backend.ts index 534636af2..5599fc0e6 100644 --- a/packages/coding-agent/src/hindsight/backend.ts +++ b/packages/coding-agent/src/hindsight/backend.ts @@ -24,7 +24,6 @@ import { type HindsightMessage, prepareRetentionTranscript, sliceLastTurnsByUserBoundary, - stripMemoryTags, truncateRecallQuery, } from "./content"; import { extractMessages } from "./transcript"; @@ -111,7 +110,11 @@ async function recallForContext( } } -async function retainSession(state: HindsightSessionState, sessionId: string, messages: HindsightMessage[]): Promise { +async function retainSession( + state: HindsightSessionState, + sessionId: string, + messages: HindsightMessage[], +): Promise { const { client, bankId, config, missionsSet } = state; const retainFullWindow = config.retainMode === "full-session"; @@ -174,11 +177,13 @@ async function maybeRecallOnAgentStart(state: HindsightSessionState): Promise m.role === "user"); if (!lastUser) return; - state.hasRecalledForFirstTurn = true; const query = composeRecallQuery(lastUser.content, messages, state.config.recallContextTurns); const truncated = truncateRecallQuery(query, lastUser.content, state.config.recallMaxQueryChars); - const { context } = await recallForContext(state, truncated); + const { context, ok } = await recallForContext(state, truncated); + if (!ok) return; + + state.hasRecalledForFirstTurn = true; if (!context) return; state.lastRecallSnippet = context; @@ -254,11 +259,34 @@ export const hindsightBackend: MemoryBackend = { const parts = [STATIC_INSTRUCTIONS]; if (recallSnippet) { - parts.push(stripMemoryTags(recallSnippet) || recallSnippet); + parts.push(recallSnippet); } return parts.join("\n\n"); }, + async beforeAgentStartPrompt(session: AgentSession, promptText: string): Promise { + const sessionId = session.sessionId; + if (!sessionId) return undefined; + const state = STATE_BY_SESSION_ID.get(sessionId); + if (!state?.config.autoRecall || state.hasRecalledForFirstTurn) return undefined; + + const latestPrompt = promptText.trim(); + if (!latestPrompt) return undefined; + + const history = extractMessages(session.sessionManager); + const queryMessages = [...history, { role: "user", content: latestPrompt }]; + const query = composeRecallQuery(latestPrompt, queryMessages, state.config.recallContextTurns); + const truncated = truncateRecallQuery(query, latestPrompt, state.config.recallMaxQueryChars); + const { context, ok } = await recallForContext(state, truncated); + if (!ok) return undefined; + + state.hasRecalledForFirstTurn = true; + if (!context) return undefined; + + state.lastRecallSnippet = context; + return context; + }, + async clear(_agentDir, _cwd): Promise { // Hindsight memory is server-side. The local cache (per-session WeakMap- // equivalent) is what we can wipe — operators who want to delete the diff --git a/packages/coding-agent/src/hindsight/config.ts b/packages/coding-agent/src/hindsight/config.ts index 159f4810d..ebc074cfb 100644 --- a/packages/coding-agent/src/hindsight/config.ts +++ b/packages/coding-agent/src/hindsight/config.ts @@ -148,6 +148,8 @@ export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv = } /** Whether the caller has enough config to talk to a Hindsight server. */ -export function isHindsightConfigured(config: HindsightConfig): config is HindsightConfig & { hindsightApiUrl: string } { +export function isHindsightConfigured( + config: HindsightConfig, +): config is HindsightConfig & { hindsightApiUrl: string } { return typeof config.hindsightApiUrl === "string" && config.hindsightApiUrl.length > 0; } diff --git a/packages/coding-agent/src/hindsight/content.ts b/packages/coding-agent/src/hindsight/content.ts index 3eeb13bc0..fb954045a 100644 --- a/packages/coding-agent/src/hindsight/content.ts +++ b/packages/coding-agent/src/hindsight/content.ts @@ -167,7 +167,10 @@ export interface RetentionTranscript { * Messages are tag-stripped before framing to break the recall→retain loop. * Returns `{ transcript: null }` when nothing meaningful survives. */ -export function prepareRetentionTranscript(messages: HindsightMessage[], retainFullWindow = false): RetentionTranscript { +export function prepareRetentionTranscript( + messages: HindsightMessage[], + retainFullWindow = false, +): RetentionTranscript { if (messages.length === 0) return { transcript: null, messageCount: 0 }; let targetMessages: HindsightMessage[]; diff --git a/packages/coding-agent/src/memory-backend/types.ts b/packages/coding-agent/src/memory-backend/types.ts index 1215aa75a..67e83b082 100644 --- a/packages/coding-agent/src/memory-backend/types.ts +++ b/packages/coding-agent/src/memory-backend/types.ts @@ -45,6 +45,17 @@ export interface MemoryBackend { /** Force consolidation/retain to happen now (slash `/memory enqueue`). */ enqueue(agentDir: string, cwd: string): Promise; + /** + * Optional hook to inject a backend-specific block into the current turn's + * system prompt before the agent starts generating. + * + * This is the only place a backend can affect the very first answer of a + * fresh session. The returned text is appended to the already-built base + * system prompt for this turn only; callers may separately cache it and + * surface it through `buildDeveloperInstructions()` on later rebuilds. + */ + beforeAgentStartPrompt?(session: AgentSession, promptText: string): Promise; + /** * Optional hook to splice extra context into a compaction summarization. * diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e39fb031e..ef1f6191a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2290,6 +2290,23 @@ export class AgentSession { this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools); } + async #buildSystemPromptForAgentStart(promptText: string): Promise { + const backend = resolveMemoryBackend(this.settings); + if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt; + + try { + const injected = await backend.beforeAgentStartPrompt(this, promptText); + if (!injected) return this.#baseSystemPrompt; + return `${this.#baseSystemPrompt}\n\n${injected}`; + } catch (err) { + logger.debug("Memory backend beforeAgentStartPrompt failed", { + backend: backend.id, + error: String(err), + }); + return this.#baseSystemPrompt; + } + } + /** * Compose a stable signature for the inputs that `rebuildSystemPrompt` reads. * Two calls producing identical signatures are guaranteed to produce identical @@ -2909,12 +2926,14 @@ export class AgentSession { messages.push(...fileMentionMessages); } + const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText); + // Emit before_agent_start extension event if (this.#extensionRunner) { const result = await this.#extensionRunner.emitBeforeAgentStart( expandedText, options?.images, - this.#baseSystemPrompt, + beforeAgentStartSystemPrompt, ); if (result?.messages) { const promptAttribution: "user" | "agent" | undefined = @@ -2935,8 +2954,10 @@ export class AgentSession { if (result?.systemPrompt !== undefined) { this.agent.setSystemPrompt(result.systemPrompt); } else { - this.agent.setSystemPrompt(this.#baseSystemPrompt); + this.agent.setSystemPrompt(beforeAgentStartSystemPrompt); } + } else { + this.agent.setSystemPrompt(beforeAgentStartSystemPrompt); } // Bail out if a newer abort/prompt cycle has started since we began setup diff --git a/packages/coding-agent/src/tools/hindsight-reflect.ts b/packages/coding-agent/src/tools/hindsight-reflect.ts index 72c151b9e..2dcba20aa 100644 --- a/packages/coding-agent/src/tools/hindsight-reflect.ts +++ b/packages/coding-agent/src/tools/hindsight-reflect.ts @@ -7,17 +7,15 @@ import type { ToolSession } from "."; const hindsightReflectSchema = Type.Object({ query: Type.String({ description: "The question to answer using long-term memory." }), - context: Type.Optional( - Type.String({ description: "Optional additional context to guide the reflection." }), - ), + context: Type.Optional(Type.String({ description: "Optional additional context to guide the reflection." })), }); export type HindsightReflectParams = Static; const DESCRIPTION = [ "Generate a synthesised answer using long-term memory. Unlike recall (which returns raw memories),", - "reflect blends memories into a coherent answer. Use for questions like \"What do you know about", - "this user?\" or \"Summarize project decisions.\"", + 'reflect blends memories into a coherent answer. Use for questions like "What do you know about', + 'this user?" or "Summarize project decisions."', ].join(" "); export class HindsightReflectTool implements AgentTool { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 0cc780032..31dba4479 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -30,10 +30,10 @@ import { EvalTool } from "./eval"; import { ExitPlanModeTool } from "./exit-plan-mode"; import { FindTool } from "./find"; import { GithubTool } from "./gh"; -import { InspectImageTool } from "./inspect-image"; import { HindsightRecallTool } from "./hindsight-recall"; import { HindsightReflectTool } from "./hindsight-reflect"; import { HindsightRetainTool } from "./hindsight-retain"; +import { InspectImageTool } from "./inspect-image"; import { IrcTool } from "./irc"; import { JobTool } from "./job"; import { NotebookTool } from "./notebook"; @@ -72,11 +72,11 @@ export * from "./eval"; export * from "./exit-plan-mode"; export * from "./find"; export * from "./gh"; -export * from "./image-gen"; -export * from "./inspect-image"; export * from "./hindsight-recall"; export * from "./hindsight-reflect"; export * from "./hindsight-retain"; +export * from "./image-gen"; +export * from "./inspect-image"; export * from "./irc"; export * from "./job"; export * from "./notebook"; diff --git a/packages/coding-agent/test/hindsight-backend.test.ts b/packages/coding-agent/test/hindsight-backend.test.ts index 743ef4da7..25c0f4b3a 100644 --- a/packages/coding-agent/test/hindsight-backend.test.ts +++ b/packages/coding-agent/test/hindsight-backend.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { clearHindsightSessionStateForTest, getHindsightSessionState, @@ -182,9 +182,7 @@ describe("hindsightBackend.preCompactionContext", () => { it("returns undefined when no apiUrl is configured", async () => { const settings = Settings.isolated({ "memory.backend": "hindsight", "hindsight.apiUrl": "" }); - const messages: AgentMessage[] = [ - { role: "user", content: "hi", timestamp: 0 } as never, - ]; + const messages: AgentMessage[] = [{ role: "user", content: "hi", timestamp: 0 } as never]; const ctx = await hindsightBackend.preCompactionContext?.(messages, settings); expect(ctx).toBeUndefined(); }); @@ -207,9 +205,7 @@ describe("hindsightBackend.preCompactionContext", () => { results: [{ id: "1", text: "remembered fact" }], } as never); - const messages: AgentMessage[] = [ - { role: "user", content: "What did we decide?", timestamp: 0 } as never, - ]; + const messages: AgentMessage[] = [{ role: "user", content: "What did we decide?", timestamp: 0 } as never]; const ctx = await hindsightBackend.preCompactionContext?.(messages, settings); expect(ctx).toBeDefined(); expect(ctx).toContain(""); @@ -237,6 +233,73 @@ describe("hindsightBackend.preCompactionContext", () => { }); }); +describe("hindsightBackend first-turn injection", () => { + beforeEach(() => { + _resetSettingsForTest(); + clearHindsightSessionStateForTest(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + clearHindsightSessionStateForTest(); + }); + + it("returns a tagged block for the current first turn before agent_start", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ + sessionId: "s8", + entries: [{ role: "assistant", text: "previous assistant context" }], + }); + await hindsightBackend.start({ + session: session as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + + vi.spyOn(HindsightClient.prototype, "recall").mockResolvedValue({ + results: [{ id: "1", text: "Can prefers concise communication" }], + } as never); + + const block = await hindsightBackend.beforeAgentStartPrompt?.( + session as never, + "What do I know about this user?", + ); + expect(block).toContain(""); + expect(block).toContain("Can prefers concise communication"); + expect(getHindsightSessionState("s8")?.hasRecalledForFirstTurn).toBe(true); + expect(getHindsightSessionState("s8")?.lastRecallSnippet).toBe(block); + }); + + it("keeps the wrapper in buildDeveloperInstructions", async () => { + const settings = Settings.isolated({ + "memory.backend": "hindsight", + "hindsight.apiUrl": "http://localhost:8888", + }); + const session = makeFakeSession({ sessionId: "s9" }); + await hindsightBackend.start({ + session: session as never, + settings, + modelRegistry: {} as never, + agentDir: "/tmp", + taskDepth: 0, + }); + + const state = getHindsightSessionState("s9"); + expect(state).toBeDefined(); + state!.lastRecallSnippet = "\nremembered fact\n"; + + const prompt = await hindsightBackend.buildDeveloperInstructions("/tmp", settings); + expect(prompt).toContain(""); + expect(prompt).toContain(""); + expect(prompt).toContain("remembered fact"); + }); +}); + describe("hindsightBackend.clear", () => { beforeEach(() => { _resetSettingsForTest(); diff --git a/packages/coding-agent/test/hindsight-bank.test.ts b/packages/coding-agent/test/hindsight-bank.test.ts index d1aa08b0a..54e566a56 100644 --- a/packages/coding-agent/test/hindsight-bank.test.ts +++ b/packages/coding-agent/test/hindsight-bank.test.ts @@ -114,9 +114,7 @@ describe("ensureBankMission", () => { }); it("swallows API failures and does not mark the bank as initialised", async () => { - createSpy = vi - .spyOn(HindsightClient.prototype, "createBank") - .mockRejectedValue(new Error("HTTP 500")); + createSpy = vi.spyOn(HindsightClient.prototype, "createBank").mockRejectedValue(new Error("HTTP 500")); const seen = new Set(); const config = baseConfig({ bankMission: "do the thing" }); diff --git a/packages/coding-agent/test/hindsight-config.test.ts b/packages/coding-agent/test/hindsight-config.test.ts index b99492917..95fcf342c 100644 --- a/packages/coding-agent/test/hindsight-config.test.ts +++ b/packages/coding-agent/test/hindsight-config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { isHindsightConfigured, loadHindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config"; describe("loadHindsightConfig", () => { diff --git a/packages/coding-agent/test/hindsight-content.test.ts b/packages/coding-agent/test/hindsight-content.test.ts index 5990672dc..b9cbf4359 100644 --- a/packages/coding-agent/test/hindsight-content.test.ts +++ b/packages/coding-agent/test/hindsight-content.test.ts @@ -162,7 +162,10 @@ describe("prepareRetentionTranscript", () => { }); it("returns null when nothing meaningful remains", () => { - const empty = prepareRetentionTranscript([{ role: "user", content: "x" }], true); + const empty = prepareRetentionTranscript( + [{ role: "user", content: "x" }], + true, + ); expect(empty.transcript).toBeNull(); }); }); diff --git a/packages/coding-agent/test/hindsight-tools.test.ts b/packages/coding-agent/test/hindsight-tools.test.ts index 628d63c09..77182fe73 100644 --- a/packages/coding-agent/test/hindsight-tools.test.ts +++ b/packages/coding-agent/test/hindsight-tools.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { clearHindsightSessionStateForTest, setHindsightSessionStateForTest, diff --git a/packages/coding-agent/test/memory-backend-resolve.test.ts b/packages/coding-agent/test/memory-backend-resolve.test.ts index 1c47fb444..590a6584b 100644 --- a/packages/coding-agent/test/memory-backend-resolve.test.ts +++ b/packages/coding-agent/test/memory-backend-resolve.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { Settings, _resetSettingsForTest } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { resolveMemoryBackend } from "@oh-my-pi/pi-coding-agent/memory-backend"; describe("resolveMemoryBackend", () => {