@matthewfl/pi-contemplator 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/package.json +60 -0
  4. package/src/agents/contemplator/agent.ts +718 -0
  5. package/src/agents/contemplator/prompts.ts +212 -0
  6. package/src/agents/dropper/agent.ts +291 -0
  7. package/src/agents/dropper/coverage.ts +128 -0
  8. package/src/agents/dropper/pool.ts +67 -0
  9. package/src/agents/dropper/prompts.ts +48 -0
  10. package/src/agents/observer/agent.ts +207 -0
  11. package/src/agents/observer/prompts.ts +119 -0
  12. package/src/agents/reflector/agent.ts +213 -0
  13. package/src/agents/reflector/prompts.ts +81 -0
  14. package/src/agents/reviewer/agent.ts +187 -0
  15. package/src/agents/reviewer/history-tools.ts +337 -0
  16. package/src/agents/reviewer/prompts.ts +135 -0
  17. package/src/agents/reviewer/tools.ts +84 -0
  18. package/src/agents/stream-errors.ts +22 -0
  19. package/src/clipboard.ts +63 -0
  20. package/src/commands/contemplator-view.ts +128 -0
  21. package/src/commands/reviewer-view.ts +89 -0
  22. package/src/commands/settings.ts +257 -0
  23. package/src/commands/status.ts +176 -0
  24. package/src/commands/view.ts +171 -0
  25. package/src/config.ts +284 -0
  26. package/src/debug-log.ts +72 -0
  27. package/src/hooks/compaction-hook.ts +99 -0
  28. package/src/hooks/compaction-resume.ts +124 -0
  29. package/src/hooks/compaction-trigger.ts +122 -0
  30. package/src/hooks/consolidation-trigger.ts +488 -0
  31. package/src/ids.ts +5 -0
  32. package/src/index.ts +32 -0
  33. package/src/model-budget.ts +16 -0
  34. package/src/runtime.ts +316 -0
  35. package/src/serialize.ts +274 -0
  36. package/src/session-ledger/fold.ts +115 -0
  37. package/src/session-ledger/index.ts +7 -0
  38. package/src/session-ledger/progress.ts +156 -0
  39. package/src/session-ledger/projection.ts +243 -0
  40. package/src/session-ledger/recall.ts +258 -0
  41. package/src/session-ledger/render-summary.ts +31 -0
  42. package/src/session-ledger/search.ts +184 -0
  43. package/src/session-ledger/types.ts +329 -0
  44. package/src/tokens.ts +27 -0
  45. package/src/tools/compact-context.ts +54 -0
  46. package/src/tools/recall-observation.ts +532 -0
  47. package/src/tools/search-memories.ts +131 -0
@@ -0,0 +1,81 @@
1
+ export const REFLECTOR_SYSTEM = `You are the reflection agent for a coding assistant.
2
+
3
+ These records are the ONLY information the assistant will have about past interactions once the raw conversation is compacted out of context. Anything you fail to preserve may be forgotten. Anything you distort may be remembered wrong. Take this seriously. Over-reflection is also memory distortion: it makes transient details look durable and crowds out the few facts future runs actually need.
4
+
5
+ Your task is different from the observer's: you are not recording events, you are distilling stable, long-lived facts and patterns from active observations into new reflections by calling record_reflections. Reflections are scarce, expensive durable orientation anchors, not a second observation layer.
6
+
7
+ You receive:
8
+ - Current reflections: durable facts already crystallized.
9
+ - Current observations: active timestamped evidence lines, each shown as "[id] YYYY-MM-DD HH:MM [relevance] [coverage: none|partial|strong] content".
10
+ - Coverage tiers are review context: none means no current reflection supports the observation id, partial means exactly one current reflection supports it, and strong means two or more current reflections support it. Coverage is not a quota, target, priority score, or instruction to emit reflections.
11
+
12
+ What to emit:
13
+ - Emit only new durable reflections not already present in current reflections.
14
+ - A good reflection captures meaning that should survive after individual observations are dropped from active compacted memory.
15
+ - High and critical observations deserve careful review, not automatic reflection. Many high observations are still active working evidence and should remain observations until completed, superseded, or generalized into a durable decision, invariant, or rationale.
16
+ - Ignore low observations unless a repeated pattern across many low observations is itself significant.
17
+ - Do not lightly reword existing reflections. Rewording creates a separate reflection, so only use different wording when the durable meaning is materially different, more specific, or corrects/refines an existing reflection.
18
+ - Do not emit update-style records or provenance metadata. Reflections are plain durable facts, not patches.
19
+ - It is fine to emit zero reflections when nothing new is stable enough; in that case do not call the tool and reply briefly.
20
+
21
+ Decision procedure:
22
+ 1. First reject observations that are transient, low-level, partial, routine, or only useful as current working state.
23
+ 2. From the remaining observations, identify only durable orientation facts: user preferences, constraints, corrections, decisions, invariants, completed outcomes, long-lived blockers, stable project goals, or rationale that future runs must know.
24
+ 3. Apply the future-agent utility test: would a future assistant need this fact automatically in compressed context to avoid a wrong decision, repeated work, or user-preference violation?
25
+ 4. If the candidate fails that future-agent utility test, leave it as an observation.
26
+ 5. If unsure, emit no reflection.
27
+
28
+ Abstraction gate:
29
+ - Do not turn each observation into a reflection. Observations are evidence; reflections are compressed durable conclusions.
30
+ - A reflection should usually do at least one of these: combine multiple observations into one durable pattern, preserve a user preference/constraint/correction/decision, record a completed outcome future runs must not redo, or capture durable rationale that explains why a decision was made.
31
+ - Single-observation reflections are allowed when the observation itself contains a durable user preference, constraint, correction, decision, invariant, completed outcome, or long-lived blocker.
32
+ - Do not copy or lightly paraphrase observation lines just because they are high or critical. If the reflection would say nearly the same thing as one observation with a few words removed, usually emit no reflection unless that observation contains a durable user assertion, durable decision, invariant, or completed outcome.
33
+ - Most transient task-log observations, tool status, one-off attempts, files inspected, commands run, failed attempts, partial implementation, and current working state should not become reflections. Let them remain observations until they are completed, superseded, repeated into a pattern, or captured by a higher-value reflection.
34
+ - Prefer fewer, higher-value reflections. It is better to emit zero reflections than to create one reflection per observation.
35
+
36
+ Focus on:
37
+ - User identity, role, preferences, constraints, and durable corrections.
38
+ - Project goals, architecture, technical decisions, and the rationale behind them.
39
+ - Recurring user behavior or preferences that will matter in future turns.
40
+ - Completed outcomes future runs must not redo.
41
+ - Durable blockers, invariants, and open decisions that should survive compaction.
42
+
43
+ Support ids and coverage stewardship:
44
+ - Every reflection must include supportingObservationIds from the current observations list.
45
+ - First decide whether the reflection content passes the durable-value bar. Then audit support ids for that already-worthy reflection.
46
+ - supportingObservationIds are a coverage/provenance set and downstream dropper coverage evidence: include all current observation ids whose durable meaning is preserved by the reflection with equivalent fidelity and can later be treated as redundant active-memory detail.
47
+ - supportingObservationIds are not a checklist to cover every observation. Do not add ids merely to improve coverage counts, maximize support ids, maximize strong coverage, or unlock the dropper.
48
+ - False or inflated support ids can cause unsafe downstream dropper pruning, including removal of high-resistance active observations whose meaning was not actually preserved.
49
+ - Include additional observation ids only when the reflection preserves their durable meaning with equivalent fidelity.
50
+ - Leave observations unsupported when their details are still active working state, too specific to compress safely, or not yet durable enough.
51
+ - Do not include observations whose unique exact detail, current task state, user correction, user constraint, or concrete completion is not captured by the reflection.
52
+ - If no candidate reflection passes the durable-value bar, emit zero reflections even when observations have coverage: none.
53
+ - Never invent observation ids. Proposals with missing, empty, or invalid supportingObservationIds are rejected.
54
+
55
+ User assertions are authoritative. If the observation pool contains both "User stated they use Postgres" and a later "User asked which db they are on", the assertion answers the question — crystallize the assertion, never the question, as the durable fact.
56
+
57
+ Reflection content rules:
58
+ - Single line of plain prose. No markdown, no bullets, no code fences, no XML/HTML tags, no emojis.
59
+ - No timestamp, no priority marker, no bracketed tags, no "key: value" fields, no JSON.
60
+ - Lead with the fact or pattern; include the reason or mechanism when known so future readers can judge edge cases.
61
+ - Preserve user assertions exactly. Use the user's exact words when non-standard.
62
+ - Preserve named identifiers, paths, commands, package names, error codes, dates, decisions, constraints, and rationale when those details are part of the durable meaning.
63
+
64
+ Examples:
65
+ - BAD: User discussed databases.
66
+ - GOOD: User stated they use Postgres for the project database.
67
+ - BAD: User asked about database setup.
68
+ - GOOD: User stated they use Postgres for the project database.
69
+ - BAD: User ran npm test and it failed.
70
+ - GOOD: The test suite currently fails because auth middleware rejects expired JWT fixtures.
71
+ - BAD: User prefers React Query.
72
+ - BAD: User switched from SWR.
73
+ - GOOD: User chose React Query over SWR for server-state caching.
74
+ - BAD: completed: edited src/hooks/reflect-drop-trigger.ts.
75
+ - GOOD: completed: V3 reflect/drop coverage now uses raw progress watermarks, so same-turn reflection entries are no longer used as drop progress markers.
76
+ - BAD: npm test passed.
77
+ - GOOD: completed: V3 package namespace migration passed full tests and typecheck.
78
+ - BAD: Observation aaaaaaaaaaaa says the user likes short answers.
79
+ - GOOD: User prefers short answers without generic summaries.
80
+ - ZERO REFLECTIONS: The only new observations are files inspected, commands run, failed attempts, partial implementation, transient debugging, or current working state with no durable conclusion yet.
81
+ - ZERO REFLECTIONS: The only new observations are routine command outputs, transient debugging attempts, or partial work with no durable conclusion yet.`;
@@ -0,0 +1,187 @@
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
2
+ import type { Message, Model } from "@earendil-works/pi-ai";
3
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
4
+ import { hashId } from "../../ids.js";
5
+ import { boundedMaxTokens, REVIEWER_TOTAL_TOKEN_LIMIT } from "../../model-budget.js";
6
+ import type { LlmUsageInput } from "../../runtime.js";
7
+ import type { Entry, ReviewResult, StructuralReviewRequest } from "../../session-ledger/types.js";
8
+ import { createRecallAgentTool } from "../../tools/recall-observation.js";
9
+ import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
10
+ import { logAgentStreamError } from "../stream-errors.js";
11
+ import { createReadChatHistoryAgentTool, createSearchChatHistoryAgentTool } from "./history-tools.js";
12
+ import { buildReviewerSystemPrompt } from "./prompts.js";
13
+ import { createNoProposalTool, createSoftwareProposalTool, createWorkflowProposalTool, type ReviewTerminalResult } from "./tools.js";
14
+
15
+ export const REVIEWER_KEEP_GOING_MESSAGE =
16
+ "You have not yet produced a terminal review outcome. Continue investigating the memories and, when you are ready, call exactly one terminal tool: the available proposal tool if a durable conceptual proposal is supported, or review_concluded_no_proposal otherwise. Do not call any terminal tool more than once.";
17
+
18
+ /** Avoid an expensive live spin when a reviewer repeatedly stops with ordinary text. */
19
+ export const REVIEWER_MAX_INVOCATIONS_PER_LAUNCH = 5;
20
+
21
+ export interface RunStructuralReviewArgs {
22
+ request: StructuralReviewRequest;
23
+ model: Model<any>;
24
+ apiKey: string;
25
+ headers?: Record<string, string>;
26
+ getBranch: () => Entry[];
27
+ signal?: AbortSignal;
28
+ agentLoop?: typeof agentLoop;
29
+ recordUsage?: (usage: LlmUsageInput) => void;
30
+ /** Receives the reviewer's assistant output for durable debug/view rendering. */
31
+ onMessages?: (messages: AgentMessage[]) => void;
32
+ /** Previously persisted reviewer transcript; a non-empty history resumes work. */
33
+ history?: AgentMessage[];
34
+ }
35
+
36
+ export function buildReviewRequestMessage(request: StructuralReviewRequest): Message {
37
+ return {
38
+ role: "user",
39
+ content: [{ type: "text", text: `STRUCTURAL REVIEW REQUEST
40
+
41
+ Review request id:
42
+ ${request.id}
43
+
44
+ Scope:
45
+ ${request.scope}
46
+
47
+ Evidence identified by the contemplator:
48
+ ${request.evidence}
49
+
50
+ Suspected concern:
51
+ ${request.concern}
52
+
53
+ Review focus:
54
+ ${request.reviewFocus}
55
+
56
+ Relevant constraints:
57
+ ${request.constraints ?? "(none recorded)"}
58
+
59
+ Recall the cited memories first. Then search for surrounding, supporting, contrary, and previously proposed material before reaching a conclusion.` }],
60
+ timestamp: Date.now(),
61
+ };
62
+ }
63
+
64
+ function assistantOutputTokens(messages: AgentMessage[]): number {
65
+ let total = 0;
66
+ for (const message of messages) {
67
+ if (message.role !== "assistant") continue;
68
+ const usage = message.usage as { output?: unknown } | undefined;
69
+ if (typeof usage?.output === "number" && Number.isFinite(usage.output)) total += Math.max(0, usage.output);
70
+ }
71
+ return total;
72
+ }
73
+
74
+ export async function runStructuralReview(args: RunStructuralReviewArgs): Promise<ReviewResult | undefined> {
75
+ let terminal: ReviewTerminalResult | undefined;
76
+ const acceptTerminal = (candidate: ReviewTerminalResult): void => {
77
+ if (terminal) throw new Error("A structural reviewer may make only one terminal tool call.");
78
+ terminal = candidate;
79
+ };
80
+ const searchMemories = createSearchMemoriesAgentTool(args.getBranch);
81
+ const recall = createRecallAgentTool(args.getBranch);
82
+ const searchChatHistory = createSearchChatHistoryAgentTool(args.getBranch);
83
+ const readChatHistory = createReadChatHistoryAgentTool(args.getBranch);
84
+ const scopeTool = args.request.scope === "workflow"
85
+ ? createWorkflowProposalTool(acceptTerminal)
86
+ : createSoftwareProposalTool(acceptTerminal);
87
+ const noProposal = createNoProposalTool(args.request.scope, acceptTerminal);
88
+ const tools = [
89
+ searchMemories as AgentTool<any>,
90
+ recall as AgentTool<any>,
91
+ searchChatHistory as AgentTool<any>,
92
+ readChatHistory as AgentTool<any>,
93
+ scopeTool as AgentTool<any>,
94
+ noProposal as AgentTool<any>,
95
+ ];
96
+ const loop = args.agentLoop ?? agentLoop;
97
+ const history = [...(args.history ?? [])];
98
+ // Usage on persisted assistant messages makes this a lifetime request budget,
99
+ // rather than a fresh allowance on each session/tree resumption.
100
+ let totalOutputTokens = assistantOutputTokens(history);
101
+ if (totalOutputTokens >= REVIEWER_TOTAL_TOKEN_LIMIT) return undefined;
102
+
103
+ // Persist both the user continuation and the returned messages immediately.
104
+ // This makes the transcript sufficient to resume a review after shutdown.
105
+ const runOnce = async (prompt: Message): Promise<number> => {
106
+ const iterationStartTokens = totalOutputTokens;
107
+ let streamedOutputTokens = 0;
108
+ const remainingBudget = () => Math.max(0, REVIEWER_TOTAL_TOKEN_LIMIT - totalOutputTokens);
109
+ // agentLoop can make several model calls while following tool calls. Wrap its
110
+ // stream function so every internal response gets only the lifetime budget
111
+ // remaining after earlier responses, not a fresh per-turn allowance.
112
+ const budgetedStreamSimple: typeof streamSimple = ((model: Model<any>, context: any, options: any) => {
113
+ const response = streamSimple(model, context, {
114
+ ...options,
115
+ maxTokens: boundedMaxTokens(model, remainingBudget()),
116
+ });
117
+ let accounted = false;
118
+ const result = async () => {
119
+ const message = await response.result();
120
+ if (!accounted) {
121
+ accounted = true;
122
+ const output = assistantOutputTokens([message as AgentMessage]);
123
+ streamedOutputTokens += output;
124
+ totalOutputTokens += output;
125
+ }
126
+ return message;
127
+ };
128
+ return { [Symbol.asyncIterator]: () => response[Symbol.asyncIterator](), result } as ReturnType<typeof streamSimple>;
129
+ }) as typeof streamSimple;
130
+ const config: AgentLoopConfig = {
131
+ model: args.model,
132
+ apiKey: args.apiKey,
133
+ headers: args.headers,
134
+ maxTokens: boundedMaxTokens(args.model, remainingBudget()),
135
+ convertToLlm: (messages) => messages as Message[],
136
+ toolExecution: "sequential",
137
+ shouldStopAfterTurn: () => terminal !== undefined || remainingBudget() === 0,
138
+ };
139
+ const promptMessage = prompt as AgentMessage;
140
+ // agentLoop receives the new prompt separately. Its context must therefore
141
+ // contain only prior messages, otherwise a resumed prompt is sent twice.
142
+ const context: AgentContext = { systemPrompt: buildReviewerSystemPrompt(args.request.scope), messages: history.slice(), tools };
143
+ history.push(promptMessage);
144
+ args.onMessages?.([promptMessage]);
145
+ const stream = loop([prompt], context, config, args.signal, budgetedStreamSimple);
146
+ for await (const event of stream) logAgentStreamError("reviewer", event);
147
+ const newMessages = await stream.result();
148
+ history.push(...newMessages);
149
+ args.onMessages?.(newMessages);
150
+ const assistants = newMessages.filter((message): message is AgentMessage => message.role === "assistant");
151
+ if (args.recordUsage) {
152
+ for (const message of assistants) {
153
+ const usage = (message as { usage?: LlmUsageInput }).usage;
154
+ if (usage) args.recordUsage(usage);
155
+ }
156
+ }
157
+ // Injected test loops do not use the supplied stream function. Count any
158
+ // assistant usage that the real stream wrapper did not already account for.
159
+ const reportedOutputTokens = assistantOutputTokens(assistants);
160
+ totalOutputTokens += Math.max(0, reportedOutputTokens - streamedOutputTokens);
161
+ return totalOutputTokens - iterationStartTokens;
162
+ };
163
+
164
+ let invocations = 0;
165
+ let progress = await runOnce(history.length === 0
166
+ ? buildReviewRequestMessage(args.request)
167
+ : { role: "user", content: [{ type: "text", text: REVIEWER_KEEP_GOING_MESSAGE }], timestamp: Date.now() });
168
+ invocations++;
169
+
170
+ // agentLoop already continues through memory/history tool calls. Permit a
171
+ // small number of explicit retries when it stops with ordinary text, but do
172
+ // not re-feed an ever-growing transcript until the token budget is exhausted.
173
+ while (!terminal && progress > 0 && totalOutputTokens < REVIEWER_TOTAL_TOKEN_LIMIT && invocations < REVIEWER_MAX_INVOCATIONS_PER_LAUNCH) {
174
+ const keepGoing: Message = { role: "user", content: [{ type: "text", text: REVIEWER_KEEP_GOING_MESSAGE }], timestamp: Date.now() };
175
+ progress = await runOnce(keepGoing);
176
+ invocations++;
177
+ }
178
+ if (!terminal) return undefined;
179
+ return {
180
+ ...terminal,
181
+ id: hashId(`${args.request.id}:${JSON.stringify(terminal)}:${Date.now()}`),
182
+ version: 1,
183
+ reviewRequestId: args.request.id,
184
+ createdAt: Date.now(),
185
+ requestedBy: "contemplator",
186
+ } as ReviewResult;
187
+ }
@@ -0,0 +1,337 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ import {
3
+ Type,
4
+ type Message,
5
+ type ToolResultMessage,
6
+ } from "@earendil-works/pi-ai";
7
+ import type { Static } from "typebox";
8
+ import { renderRecallSourceEntry } from "../../serialize.js";
9
+ import type { Entry } from "../../session-ledger/types.js";
10
+
11
+ export const SEARCH_CHAT_HISTORY_TOOL_NAME = "search_chat_history";
12
+ export const READ_CHAT_HISTORY_TOOL_NAME = "read_chat_history";
13
+
14
+ export const SEARCH_CHAT_HISTORY_DESCRIPTION = `Search the primary agent's recorded chat history with a regular expression, newest entries first.
15
+
16
+ The expression is matched globally against each complete rendered chat entry. Every match is returned separately, so one entry may appear more than once. Matching stops when the limit is reached. Matching is deterministic and does not perform semantic interpretation or relevance ranking. Zero-width matches are returned, with scanning advanced by one Unicode code point afterward.
17
+
18
+ To include text surrounding a term, put the context in the expression itself, for example .{0,100}target.{0,100}. Dot matches newlines. Results include stable entry ids that can be passed to read_chat_history.`;
19
+
20
+ export const READ_CHAT_HISTORY_DESCRIPTION = `Read one exact primary-chat entry by the entry id returned from search_chat_history, optionally with preceding and following primary-chat entries for conversational context.`;
21
+
22
+ export const SEARCH_CHAT_HISTORY_PARAMETERS = Type.Object({
23
+ pattern: Type.String({
24
+ minLength: 1,
25
+ maxLength: 500,
26
+ description:
27
+ "Regular expression matched against complete rendered primary-chat entries. Use constructs such as .{0,100}target.{0,100} to return surrounding text.",
28
+ }),
29
+ case_sensitive: Type.Optional(
30
+ Type.Boolean({
31
+ description: "Whether matching is case-sensitive. Defaults to false.",
32
+ }),
33
+ ),
34
+ limit: Type.Optional(
35
+ Type.Integer({
36
+ minimum: 1,
37
+ maximum: 100,
38
+ description:
39
+ "Maximum number of individual matches to return. Defaults to 20.",
40
+ }),
41
+ ),
42
+ });
43
+
44
+ export const READ_CHAT_HISTORY_PARAMETERS = Type.Object({
45
+ entry_id: Type.String({
46
+ minLength: 1,
47
+ description: "Entry identifier returned by search_chat_history.",
48
+ }),
49
+ context_before: Type.Optional(
50
+ Type.Integer({
51
+ minimum: 0,
52
+ maximum: 10,
53
+ description:
54
+ "Number of preceding primary-chat entries to return. Defaults to 0.",
55
+ }),
56
+ ),
57
+ context_after: Type.Optional(
58
+ Type.Integer({
59
+ minimum: 0,
60
+ maximum: 10,
61
+ description:
62
+ "Number of following primary-chat entries to return. Defaults to 0.",
63
+ }),
64
+ ),
65
+ });
66
+
67
+ export type SearchChatHistoryArgs = Static<
68
+ typeof SEARCH_CHAT_HISTORY_PARAMETERS
69
+ >;
70
+ export type ReadChatHistoryArgs = Static<typeof READ_CHAT_HISTORY_PARAMETERS>;
71
+
72
+ export type ChatHistoryMatch = {
73
+ entryId: string;
74
+ origin: string;
75
+ timestamp?: string | number;
76
+ match: string;
77
+ start: number;
78
+ end: number;
79
+ };
80
+
81
+ export type SearchChatHistoryDetails = {
82
+ pattern: string;
83
+ caseSensitive: boolean;
84
+ limit: number;
85
+ limitReached: boolean;
86
+ matches: ChatHistoryMatch[];
87
+ error?: string;
88
+ };
89
+
90
+ export type ReadChatHistoryDetails = {
91
+ entryId: string;
92
+ contextBefore: number;
93
+ contextAfter: number;
94
+ entries: Array<{
95
+ id: string;
96
+ origin: string;
97
+ timestamp?: string | number;
98
+ selected: boolean;
99
+ content: string;
100
+ }>;
101
+ found: boolean;
102
+ };
103
+
104
+ type RenderedHistoryEntry = {
105
+ entry: Entry;
106
+ text: string;
107
+ origin: string;
108
+ timestamp?: string | number;
109
+ };
110
+
111
+ function messageTimestamp(entry: Entry): string | number | undefined {
112
+ if (
113
+ entry.type === "message" &&
114
+ entry.message &&
115
+ typeof entry.message === "object"
116
+ ) {
117
+ const timestamp = (entry.message as Message).timestamp;
118
+ if (timestamp !== undefined) return timestamp;
119
+ }
120
+ return entry.timestamp;
121
+ }
122
+
123
+ function entryOrigin(entry: Entry): string {
124
+ if (
125
+ entry.type === "message" &&
126
+ entry.message &&
127
+ typeof entry.message === "object"
128
+ ) {
129
+ const message = entry.message as Message;
130
+ if (message.role === "user") return "user";
131
+ if (message.role === "assistant") return "assistant";
132
+ const toolName = (message as ToolResultMessage).toolName;
133
+ return `tool result${typeof toolName === "string" && toolName ? `: ${toolName}` : ""}`;
134
+ }
135
+ if (entry.type === "custom_message")
136
+ return entry.customType
137
+ ? `background message: ${entry.customType}`
138
+ : "background message";
139
+ if (entry.type === "branch_summary") return "branch summary";
140
+ return entry.type;
141
+ }
142
+
143
+ function historyEntries(entries: Entry[]): RenderedHistoryEntry[] {
144
+ const rendered: RenderedHistoryEntry[] = [];
145
+ for (const entry of entries) {
146
+ const text = renderRecallSourceEntry(entry);
147
+ if (!text?.trim()) continue;
148
+ rendered.push({
149
+ entry,
150
+ text,
151
+ origin: entryOrigin(entry),
152
+ timestamp: messageTimestamp(entry),
153
+ });
154
+ }
155
+ return rendered;
156
+ }
157
+
158
+ function advanceAfterEmptyMatch(text: string, index: number): number {
159
+ if (index >= text.length) return index + 1;
160
+ const first = text.charCodeAt(index);
161
+ if (first >= 0xd800 && first <= 0xdbff && index + 1 < text.length) {
162
+ const second = text.charCodeAt(index + 1);
163
+ if (second >= 0xdc00 && second <= 0xdfff) return index + 2;
164
+ }
165
+ return index + 1;
166
+ }
167
+
168
+ function textResult<T>(text: string, details: T) {
169
+ return { content: [{ type: "text" as const, text }], details };
170
+ }
171
+
172
+ export function executeSearchChatHistory(
173
+ entries: Entry[],
174
+ args: SearchChatHistoryArgs,
175
+ ) {
176
+ const caseSensitive = args.case_sensitive ?? false;
177
+ const limit = args.limit ?? 20;
178
+ let expression: RegExp;
179
+ try {
180
+ expression = new RegExp(args.pattern, caseSensitive ? "gsu" : "gisu");
181
+ } catch (error) {
182
+ const message = `Invalid regular expression: ${error instanceof Error ? error.message : String(error)}`;
183
+ return textResult(message, {
184
+ pattern: args.pattern,
185
+ caseSensitive,
186
+ limit,
187
+ limitReached: false,
188
+ matches: [],
189
+ error: message,
190
+ } satisfies SearchChatHistoryDetails);
191
+ }
192
+
193
+ const matches: ChatHistoryMatch[] = [];
194
+ let limitReached = false;
195
+ const searchable = historyEntries(entries);
196
+ outer: for (
197
+ let entryIndex = searchable.length - 1;
198
+ entryIndex >= 0;
199
+ entryIndex--
200
+ ) {
201
+ const item = searchable[entryIndex];
202
+ expression.lastIndex = 0;
203
+ while (true) {
204
+ const match = expression.exec(item.text);
205
+ if (!match) break;
206
+ matches.push({
207
+ entryId: item.entry.id,
208
+ origin: item.origin,
209
+ timestamp: item.timestamp,
210
+ match: match[0],
211
+ start: match.index,
212
+ end: match.index + match[0].length,
213
+ });
214
+ if (matches.length >= limit) {
215
+ limitReached = true;
216
+ break outer;
217
+ }
218
+ if (match[0].length === 0)
219
+ expression.lastIndex = advanceAfterEmptyMatch(
220
+ item.text,
221
+ expression.lastIndex,
222
+ );
223
+ }
224
+ }
225
+
226
+ const flags = caseSensitive ? "gsu" : "gisu";
227
+ const lines = [
228
+ `Pattern /${args.pattern}/${flags} produced ${matches.length} match${matches.length === 1 ? "" : "es"}, newest entries first.`,
229
+ ];
230
+ matches.forEach((match, index) => {
231
+ lines.push(
232
+ "",
233
+ `MATCH ${index + 1}`,
234
+ `Entry: [${match.entryId}]`,
235
+ `Origin: ${match.origin}`,
236
+ );
237
+ if (match.timestamp !== undefined)
238
+ lines.push(`Time: ${String(match.timestamp)}`);
239
+ lines.push(
240
+ `Range: ${match.start}-${match.end}`,
241
+ "",
242
+ match.match || "(empty match)",
243
+ );
244
+ });
245
+ if (limitReached)
246
+ lines.push(
247
+ "",
248
+ `Stopped after ${limit} matches. Older matching entries may exist.`,
249
+ );
250
+ return textResult(lines.join("\n"), {
251
+ pattern: args.pattern,
252
+ caseSensitive,
253
+ limit,
254
+ limitReached,
255
+ matches,
256
+ } satisfies SearchChatHistoryDetails);
257
+ }
258
+
259
+ export function executeReadChatHistory(
260
+ entries: Entry[],
261
+ args: ReadChatHistoryArgs,
262
+ ) {
263
+ const contextBefore = args.context_before ?? 0;
264
+ const contextAfter = args.context_after ?? 0;
265
+ const searchable = historyEntries(entries);
266
+ const selectedIndex = searchable.findIndex(
267
+ (item) => item.entry.id === args.entry_id,
268
+ );
269
+ if (selectedIndex < 0) {
270
+ return textResult(
271
+ `No primary-chat entry with id ${args.entry_id} was found on the current branch.`,
272
+ {
273
+ entryId: args.entry_id,
274
+ contextBefore,
275
+ contextAfter,
276
+ entries: [],
277
+ found: false,
278
+ } satisfies ReadChatHistoryDetails,
279
+ );
280
+ }
281
+
282
+ const selected = searchable.slice(
283
+ Math.max(0, selectedIndex - contextBefore),
284
+ selectedIndex + contextAfter + 1,
285
+ );
286
+ const detailsEntries = selected.map((item) => ({
287
+ id: item.entry.id,
288
+ origin: item.origin,
289
+ timestamp: item.timestamp,
290
+ selected: item.entry.id === args.entry_id,
291
+ content: item.text,
292
+ }));
293
+ const lines = [
294
+ `CHAT HISTORY CONTEXT FOR [${args.entry_id}]`,
295
+ `${selectedIndex - Math.max(0, selectedIndex - contextBefore)} entries before, ${Math.min(searchable.length, selectedIndex + contextAfter + 1) - selectedIndex - 1} entries after`,
296
+ ];
297
+ for (const item of detailsEntries) {
298
+ lines.push(
299
+ "",
300
+ `${item.selected ? ">>> " : ""}[${item.id}] ${item.origin}${item.timestamp !== undefined ? ` @ ${String(item.timestamp)}` : ""}`,
301
+ item.content,
302
+ );
303
+ }
304
+ return textResult(lines.join("\n"), {
305
+ entryId: args.entry_id,
306
+ contextBefore,
307
+ contextAfter,
308
+ entries: detailsEntries,
309
+ found: true,
310
+ } satisfies ReadChatHistoryDetails);
311
+ }
312
+
313
+ export function createSearchChatHistoryAgentTool(
314
+ getBranch: () => Entry[],
315
+ ): AgentTool<typeof SEARCH_CHAT_HISTORY_PARAMETERS> {
316
+ return {
317
+ name: SEARCH_CHAT_HISTORY_TOOL_NAME,
318
+ label: "Search primary chat history",
319
+ description: SEARCH_CHAT_HISTORY_DESCRIPTION,
320
+ parameters: SEARCH_CHAT_HISTORY_PARAMETERS,
321
+ execute: async (_toolCallId, args) =>
322
+ executeSearchChatHistory(getBranch(), args),
323
+ };
324
+ }
325
+
326
+ export function createReadChatHistoryAgentTool(
327
+ getBranch: () => Entry[],
328
+ ): AgentTool<typeof READ_CHAT_HISTORY_PARAMETERS> {
329
+ return {
330
+ name: READ_CHAT_HISTORY_TOOL_NAME,
331
+ label: "Read primary chat history",
332
+ description: READ_CHAT_HISTORY_DESCRIPTION,
333
+ parameters: READ_CHAT_HISTORY_PARAMETERS,
334
+ execute: async (_toolCallId, args) =>
335
+ executeReadChatHistory(getBranch(), args),
336
+ };
337
+ }