@matthewfl/pi-contemplator 0.0.10 → 0.1.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 (44) hide show
  1. package/README.md +12 -12
  2. package/package.json +6 -6
  3. package/src/agents/contemplator/agent.ts +325 -91
  4. package/src/agents/contemplator/prompts.ts +6 -6
  5. package/src/agents/observer/agent.ts +96 -39
  6. package/src/agents/observer/prompts.ts +19 -10
  7. package/src/agents/reviewer/agent.ts +24 -4
  8. package/src/agents/reviewer/prompts.ts +1 -1
  9. package/src/agents/reviewer/tools.ts +24 -9
  10. package/src/agents/stream-errors.ts +1 -1
  11. package/src/agents/summarizer/agent.ts +597 -0
  12. package/src/agents/summarizer/prompts.ts +46 -0
  13. package/src/agents/summarizer/sampling.ts +80 -0
  14. package/src/commands/contemplator-view.ts +22 -1
  15. package/src/commands/settings.ts +95 -70
  16. package/src/commands/status.ts +60 -36
  17. package/src/commands/summarizer-view.ts +58 -0
  18. package/src/commands/view.ts +22 -10
  19. package/src/config.ts +30 -37
  20. package/src/hooks/compaction-hook.ts +32 -17
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +33 -11
  23. package/src/hooks/consolidation-trigger.ts +245 -215
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +115 -32
  27. package/src/session-ledger/fold.ts +82 -53
  28. package/src/session-ledger/index.ts +1 -0
  29. package/src/session-ledger/pools.ts +77 -0
  30. package/src/session-ledger/progress.ts +8 -19
  31. package/src/session-ledger/projection.ts +45 -177
  32. package/src/session-ledger/recall.ts +129 -127
  33. package/src/session-ledger/render-summary.ts +20 -19
  34. package/src/session-ledger/search.ts +99 -115
  35. package/src/session-ledger/types.ts +103 -77
  36. package/src/tools/compact-context.ts +1 -1
  37. package/src/tools/recall-observation.ts +99 -459
  38. package/src/tools/search-memories.ts +31 -72
  39. package/src/agents/dropper/agent.ts +0 -291
  40. package/src/agents/dropper/coverage.ts +0 -128
  41. package/src/agents/dropper/pool.ts +0 -67
  42. package/src/agents/dropper/prompts.ts +0 -48
  43. package/src/agents/reflector/agent.ts +0 -213
  44. package/src/agents/reflector/prompts.ts +0 -81
@@ -6,7 +6,7 @@ export function buildContemplatorSystemPrompt(
6
6
 
7
7
  Neither you nor the primary agent should be assumed to know the correct solution. You are jointly exploring a problem space from different perspectives. The primary agent interacts with the actual environment and carries out the work. You maintain a longer-term view of the reasoning, evidence, assumptions, alternatives, unresolved questions${reviewerEnabled ? ", and recurring structural patterns" : ""} that emerge over time.
8
8
 
9
- You receive incremental observations and reflections produced by other agent loops. Some memories summarize user messages. Pay extra attention to memories about the user’s intent, priorities, constraints, corrections, and desired outcome.
9
+ You receive incremental observations and cited summaries produced by other agent loops. Some memories summarize user messages. Pay extra attention to memories about the user’s intent, priorities, constraints, corrections, and desired outcome.
10
10
 
11
11
  You see only the memory ledger, not the primary agent’s live activity. Your understanding may be incomplete or slightly stale. Do not infer inactivity, failure, or lack of progress from missing recent results. A result may simply not have reached memory yet.
12
12
 
@@ -42,7 +42,7 @@ Do not treat the absence of a recent test result, tool result, or implementation
42
42
 
43
43
  Do not encourage speculation merely because several possibilities can be imagined. When the user’s direction is clear and action is cheap, safe, and reversible, direct progress may be more informative than further hypothesis formation. Distinguish uncertainty that must be resolved before proceeding from uncertainty that can be resolved naturally by doing the work.
44
44
 
45
- Your other high-value responsibility is reality-checking. Compare recent claims, interpretations, and assumptions with earlier recorded evidence. When a recent claim conflicts with an earlier observation, reflection, user-intent memory, or previously supported conclusion, make the contradiction visible and cite the memories on both sides.
45
+ Your other high-value responsibility is reality-checking. Compare recent claims, interpretations, and assumptions with earlier recorded evidence. When a recent claim conflicts with an earlier observation, summary, user-intent memory, or previously supported conclusion, make the contradiction visible and cite the memories on both sides.
46
46
 
47
47
  Also look for opportunities to help the primary agent:
48
48
 
@@ -72,7 +72,7 @@ A useful probe may ask:
72
72
 
73
73
  Questions should be grounded in the actual memories rather than generic problem-solving advice.
74
74
 
75
- You have access to search_memories for finding older ${reviewerEnabled ? `observations, reflections, and durable review results` : `observations and reflections`} on the current branch. Use it when the updates provided do not contain enough context, searching with distinctive terms rather than broad questions. Results include memory identifiers that you can cite in a probe${reviewerEnabled ? ` or review request` : ``}.
75
+ You have access to search_memories for finding older ${reviewerEnabled ? `observations, summaries, and durable review results` : `observations and summaries`} on the current branch. Use it when the updates provided do not contain enough context, searching with distinctive terms rather than broad questions. Results include memory identifiers that you can cite in a probe${reviewerEnabled ? ` or review request` : ``}.
76
76
 
77
77
  You also have recall for recovering exact source context behind a specific memory identifier. Use it when a result is important but compressed.
78
78
 
@@ -185,8 +185,8 @@ A good review request should:
185
185
  }
186
186
 
187
187
  ${reviewerEnabled ?
188
- `Your interventions are asynchronous. Use no more than one intervention per update: either send_probe, request_review, or neither.` :
189
- `Your probes are delivered asynchronously. Send no more than one probe per update.`}
188
+ `Your interventions are asynchronous. You must finish every update by calling one final-action tool: send_probe, request_review, or no_intervention. This requirement is bookkeeping, not pressure to intervene. no_intervention takes no arguments and is the preferred default whenever no specific, grounded, materially useful intervention is clearly warranted or usefulness is uncertain. Never send a probe merely to satisfy the tool requirement. A valid final-action call ends your turn immediately, so do not plan to add narration afterward. If an intervention tool reports an invalid memory citation, correct it and call the appropriate tool again; the later call replaces the earlier one.` :
189
+ `Your probes are delivered asynchronously. You must finish every update by calling one final-action tool: send_probe or no_intervention. This requirement is bookkeeping, not pressure to intervene. no_intervention takes no arguments and is the preferred default whenever no specific, grounded, materially useful probe is clearly warranted or usefulness is uncertain. Never send a probe merely to satisfy the tool requirement. A valid final-action call ends your turn immediately, so do not plan to add narration afterward. If send_probe reports an invalid memory citation, correct it and call send_probe again; the later call replaces the earlier one.`}
190
190
 
191
191
  Prioritize:
192
192
 
@@ -203,7 +203,7 @@ ${
203
203
  : ``
204
204
  }
205
205
 
206
- If no specific, grounded, materially useful intervention exists, do not call an intervention tool.`
206
+ If no specific, grounded, materially useful intervention clearly exists, or if its usefulness is uncertain, call the argument-free no_intervention tool.`
207
207
  .replace(/\n{3,}/g, "\n\n")
208
208
  .trim();
209
209
  }
@@ -1,4 +1,4 @@
1
- import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
@@ -8,7 +8,7 @@ import { logAgentStreamError } from "../stream-errors.js";
8
8
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
9
9
  import { OBSERVER_SYSTEM } from "./prompts.js";
10
10
  import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
11
- import type { Observation, Relevance } from "../../session-ledger/index.js";
11
+ import type { Observation, Relevance, Retention } from "../../session-ledger/index.js";
12
12
  import { estimateStringTokens } from "../../tokens.js";
13
13
  import type { LlmUsageInput } from "../../runtime.js";
14
14
 
@@ -16,7 +16,7 @@ interface RunObserverArgs {
16
16
  model: Model<any>;
17
17
  apiKey: string;
18
18
  headers?: Record<string, string>;
19
- priorReflections: string[];
19
+ priorSummaries?: string[];
20
20
  priorObservations: string[];
21
21
  chunk: string;
22
22
  allowedSourceEntryIds: string[];
@@ -34,6 +34,12 @@ const RelevanceSchema = Type.Union([
34
34
  Type.Literal("critical"),
35
35
  ]);
36
36
 
37
+ const RetentionSchema = Type.Union([
38
+ Type.Literal("ephemeral"),
39
+ Type.Literal("contextual"),
40
+ Type.Literal("durable"),
41
+ ]);
42
+
37
43
  export const OBSERVATION_TIMESTAMP_PATTERN = "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$";
38
44
 
39
45
  const RecordObservationsSchema = Type.Object({
@@ -48,6 +54,7 @@ const RecordObservationsSchema = Type.Object({
48
54
  description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
49
55
  }),
50
56
  relevance: RelevanceSchema,
57
+ retention: Type.Optional(RetentionSchema),
51
58
  sourceEntryIds: Type.Array(
52
59
  Type.String({ minLength: 1 }),
53
60
  {
@@ -64,6 +71,16 @@ const RecordObservationsSchema = Type.Object({
64
71
 
65
72
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
66
73
 
74
+ /** A terminal provider/agent-loop failure that must not advance observation coverage. */
75
+ export class ObserverStreamError extends Error {
76
+ readonly stopReason: string;
77
+ constructor(stopReason: string, errorMessage?: string) {
78
+ super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
79
+ this.name = "ObserverStreamError";
80
+ this.stopReason = stopReason;
81
+ }
82
+ }
83
+
67
84
  function joinOrEmpty(items: string[]): string {
68
85
  return items.length ? items.join("\n") : "(none yet)";
69
86
  }
@@ -86,19 +103,20 @@ export function normalizeSourceEntryIds(
86
103
  }
87
104
 
88
105
  export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
89
- const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
106
+ const { model, apiKey, headers, priorSummaries = [], priorObservations, chunk, allowedSourceEntryIds, signal } = args;
90
107
  const conversation = chunk.trim();
91
108
  if (!conversation) return undefined;
92
109
 
93
110
  const accumulated = new Map<string, Observation>();
111
+ let rejectedTotal = 0;
112
+ let doneCalled = false;
94
113
 
95
114
  const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
96
115
  name: "record_observations",
97
116
  label: "Record observations",
98
117
  description:
99
118
  "Record a batch of new observations distilled from the conversation chunk. " +
100
- "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
101
- "then emit a short plain-text confirmation to end the run.",
119
+ "Call this multiple times as you work through the chunk, then call done alone when coverage is complete.",
102
120
  parameters: RecordObservationsSchema,
103
121
  execute: async (_id, params: RecordObservationsArgs) => {
104
122
  let added = 0;
@@ -121,11 +139,13 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
121
139
  content,
122
140
  timestamp: obs.timestamp,
123
141
  relevance: obs.relevance as Relevance,
142
+ retention: (obs.retention ?? "contextual") as Retention,
124
143
  sourceEntryIds,
125
144
  tokenCount: estimateStringTokens(content),
126
145
  });
127
146
  added++;
128
147
  }
148
+ rejectedTotal += rejected;
129
149
  const rejectedPart = rejected > 0
130
150
  ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
131
151
  : "";
@@ -134,74 +154,111 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
134
154
  (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
135
155
  rejectedPart +
136
156
  ` Total so far this run: ${accumulated.size}. ` +
137
- `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
157
+ `Continue if the chunk still has uncovered content; otherwise call done alone.`;
138
158
  return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
139
159
  },
140
160
  };
141
161
 
162
+ const doneTool: AgentTool<any> = {
163
+ name: "done",
164
+ label: "Done",
165
+ description: "Confirm that the entire provided conversation chunk has been inspected and all useful new observations have been recorded. Call alone, including when there is nothing new to record.",
166
+ parameters: Type.Object({}),
167
+ execute: async () => {
168
+ doneCalled = true;
169
+ return { content: [{ type: "text", text: "Observer coverage confirmed." }], details: {}, terminate: true };
170
+ },
171
+ };
172
+
142
173
  const now = nowTimestamp();
143
174
  const userText = `Current local time: ${now}
144
175
 
145
- CURRENT REFLECTIONS:
146
- ${joinOrEmpty(priorReflections)}
176
+ CURRENT SUMMARIES:
177
+ ${joinOrEmpty(priorSummaries)}
147
178
 
148
179
  CURRENT OBSERVATIONS:
149
180
  ${joinOrEmpty(priorObservations)}
150
181
 
151
- Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current reflections or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. Stop calling the tool and reply with a short plain-text confirmation once the chunk is fully covered.
182
+ Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
152
183
 
153
184
  NEW CONVERSATION CHUNK:
154
185
  ${conversation}`;
155
186
 
156
- const prompts: Message[] = [
157
- {
158
- role: "user",
159
- content: [{ type: "text", text: userText }],
160
- timestamp: Date.now(),
161
- },
162
- ];
163
-
164
- const context: AgentContext = {
165
- systemPrompt: OBSERVER_SYSTEM,
166
- messages: [],
167
- tools: [recordObservations as AgentTool<any>],
187
+ const initialPrompt: Message = {
188
+ role: "user",
189
+ content: [{ type: "text", text: userText }],
190
+ timestamp: Date.now(),
168
191
  };
169
192
 
170
193
  const reasoning = (model as { reasoning?: unknown }).reasoning;
171
194
  const thinkingLevel = args.thinkingLevel ?? "low";
172
195
  const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
173
196
  let turnCount = 0;
174
- const config: AgentLoopConfig = {
197
+ const baseConfig: AgentLoopConfig = {
175
198
  model,
176
199
  apiKey,
177
200
  headers,
178
201
  maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
179
202
  convertToLlm: (msgs) => msgs as Message[],
180
203
  toolExecution: "sequential",
204
+ shouldStopAfterTurn: () => {
205
+ turnCount++;
206
+ return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns);
207
+ },
181
208
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
182
- ...(effectiveMaxTurns !== undefined
183
- ? {
184
- shouldStopAfterTurn: () => {
185
- turnCount++;
186
- return turnCount >= effectiveMaxTurns;
187
- },
188
- }
189
- : {}),
190
209
  };
191
210
 
192
211
  const loop = args.agentLoop ?? agentLoop;
193
- const stream = loop(prompts, context, config, signal, streamSimple);
194
- for await (const event of stream) {
195
- // Drain events; the tool's execute already collects records.
196
- logAgentStreamError("observer", event);
197
- }
198
- const result = await stream.result();
199
- if (args.recordUsage) {
212
+ const history: AgentMessage[] = [];
213
+ let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
214
+ const runInvocation = async (prompt: Message): Promise<void> => {
215
+ const context: AgentContext = {
216
+ systemPrompt: OBSERVER_SYSTEM,
217
+ messages: history.slice(),
218
+ tools: [recordObservations as AgentTool<any>, doneTool],
219
+ };
220
+ const stream = loop([prompt], context, baseConfig, signal, streamSimple);
221
+ for await (const event of stream) {
222
+ logAgentStreamError("observer", event);
223
+ const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
224
+ if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
225
+ terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
226
+ }
227
+ }
228
+ const result = await stream.result();
229
+ if (!Array.isArray(result)) return;
230
+ history.push(...result);
200
231
  for (const message of result) {
201
- if (message.role === "assistant" && message.usage) args.recordUsage(message.usage);
232
+ if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
233
+ terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
234
+ }
235
+ if (args.recordUsage && message.role === "assistant" && message.usage) args.recordUsage(message.usage);
202
236
  }
237
+ };
238
+
239
+ await runInvocation(initialPrompt);
240
+ if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
241
+ const reminder: Message = {
242
+ role: "user",
243
+ content: [{ type: "text", text: `You stopped without confirming coverage. Observations recorded so far: ${accumulated.size}. If the chunk is fully covered, call done now. Otherwise call record_observations for anything still missing, then call done.` }],
244
+ timestamp: Date.now(),
245
+ };
246
+ await runInvocation(reminder);
203
247
  }
204
248
 
249
+ // Accepted observations remain useful even if the model neglected the final
250
+ // confirmation. Zero-observation coverage is advanced only by an explicit
251
+ // done call; failures, truncation, malformed records, and repeated prose do
252
+ // not silently discard the source chunk.
253
+ if (accumulated.size === 0 && terminalFailure) {
254
+ throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
255
+ }
256
+ if (accumulated.size === 0 && rejectedTotal > 0) {
257
+ throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
258
+ }
259
+ if (accumulated.size === 0 && !doneCalled) {
260
+ throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
261
+ }
205
262
  if (accumulated.size === 0) return undefined;
206
263
  return Array.from(accumulated.values());
207
264
  }
@@ -2,29 +2,30 @@ export const OBSERVER_SYSTEM = `You are the observation agent for a coding assis
2
2
 
3
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 do not capture here will be forgotten. Anything you distort here will be remembered wrong. Take this seriously.
4
4
 
5
- Your job is to compress a chunk of recent conversation into timestamped, rated observations by calling the record_observations tool. The observations you emit — together with the reflections crystallized from them — are the assistant's ONLY memory of this session after the raw conversation falls out of context.
5
+ Your job is to compress a chunk of recent conversation into timestamped observations with separate relevance and retention judgments by calling the record_observations tool. The observations you emit — together with cited summaries compressed from them — are the assistant's ONLY memory of this session after the raw conversation falls out of context.
6
6
 
7
7
  You receive:
8
- - Current reflections (long-lived facts already crystallized).
8
+ - Current summaries (compressed memories already recorded).
9
9
  - Current observations (already-recorded observations, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content").
10
10
  - A new chunk of conversation with source entry labels and inline message timestamps. Each source block starts with "[Source entry id: <id>]" followed by content formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:", custom messages, or branch summaries.
11
11
  - A current local time fallback for observations that have no obvious message timestamp.
12
12
 
13
13
  How you work:
14
- 1. Read reflections and current observations so you know what is already captured.
14
+ 1. Read summaries and current observations so you know what is already captured.
15
15
  2. Read the conversation chunk and identify what new information it contains.
16
16
  3. Call record_observations with a batch covering part (or all) of the chunk.
17
17
  4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
18
- 5. When the chunk is fully covered, STOP calling the tool and reply with a brief plain-text confirmation (one short sentence). That ends the run.
18
+ 5. When the chunk is fully covered, call done alone. If there is no useful new information, call done without calling record_observations. Prose does not confirm coverage.
19
19
 
20
20
  What to emit:
21
- - Produce NEW observations for the new chunk only. Do not restate facts already present in reflections or current observations unless something has materially changed.
21
+ - Produce NEW observations for the new chunk only. Do not restate facts already present in summaries or current observations unless something has materially changed.
22
22
  - Use the timestamp from the relevant conversation message. Fall back to current local time ONLY when no message timestamp applies.
23
23
  - For every observation, include sourceEntryIds: the smallest exact set of "[Source entry id: ...]" ids that directly support the observation.
24
24
  - Never invent source entry ids. Use only ids printed in the chunk. If an observation spans multiple turns or tool results, include every supporting source entry id.
25
+ - For every observation, choose retention independently from relevance. Recording the observation correctly comes first; never skip useful evidence because retention is uncertain.
25
26
  - Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
26
27
  - Group repeated similar tool calls into a single observation rather than one per call.
27
- - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, simply do not call the tool and end with a plain-text confirmation.
28
+ - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, do not call record_observations and call done alone. Ignoring a chunk or replying in prose does not mark it covered.
28
29
 
29
30
  Observation content rules:
30
31
 
@@ -57,7 +58,7 @@ Use precise action verbs. Replace vague verbs with ones that clarify the nature
57
58
  Frame state changes as supersession so the old state is explicit.
58
59
  BAD: User prefers React Query now.
59
60
  GOOD: User will use React Query (switching from SWR).
60
- Why this matters: without supersession framing, the reflector may crystallize both the old and the new as equally valid preferences.
61
+ Why this matters: without supersession framing, a later summary may preserve both the old and the new as equally valid preferences.
61
62
 
62
63
  Mark concrete completions explicitly.
63
64
  Use "completed:", "resolved:", "confirmed working", or similar phrasing so future runs know not to redo the work.
@@ -73,7 +74,7 @@ If a single message contains multiple independent facts, intents, or events, emi
73
74
  GOOD: User started a new job. + User will move to a new apartment next week.
74
75
  BAD: Assistant recommended Lucia, NextAuth, and Clerk for auth, and user chose Lucia.
75
76
  GOOD: Assistant recommended auth libraries: Lucia (session-based, minimal), NextAuth (OAuth-heavy, Next-native), Clerk (hosted, paid). + User chose Lucia.
76
- Why this matters: a future query like "which auth library did the user pick?" can match a single-fact observation cleanly; a compound observation hides the decision inside a recommendation list.
77
+ Why this matters: a future query like "which auth library did the user pick?" can match a single-fact observation cleanly; a compound observation hides the decision inside a recommendation list and makes safe summarization harder.
77
78
 
78
79
  Group repeated similar tool calls into a single observation rather than one per call.
79
80
  BAD: Agent viewed src/auth.ts. Agent viewed src/users.ts. Agent viewed src/routes.ts.
@@ -104,10 +105,18 @@ Relevance levels (pick one per observation; this field drives future dropping):
104
105
  - critical: user assertions about identity, role, or persistent preferences; explicit corrections ("no, don't do X"); concrete completions that future runs MUST NOT redo. These are highest-resistance, load-bearing observations and require the strongest evidence before leaving active memory. Why this matters: if a "critical" item is lost, the assistant may redo finished work, contradict a correction, or misrepresent who the user is.
105
106
  - high: non-trivial technical decisions, architectural direction, unresolved blockers, key constraints. Worth keeping across many compactions.
106
107
  - medium: task-level context that helps within the current work but isn't durable. The default when you are unsure between medium and high.
107
- - low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The dropper will drop these first.
108
+ - low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The summarizer considers relevance together with retention, age, and later evidence.
108
109
 
109
110
  Do NOT default to "critical" or "high". Most observations are medium or low. Reserve "critical" for things that would cause real damage if forgotten.
110
111
 
112
+ Retention horizons (pick one independently from relevance):
113
+
114
+ - ephemeral: likely useful for only the next few steps; intermediate attempts, routine outputs, temporary readings, and local status.
115
+ - contextual: useful while related work continues or may soon resume; exact errors, hypotheses, partial state, subsystem details, and unresolved local choices. Use this when uncertain.
116
+ - durable: likely useful across context changes; persistent preferences, constraints, corrections, decisions, rationale, reusable findings, and significant outcomes.
117
+
118
+ A critical exact blocker can be contextual; a medium stable preference can be durable. Retention is a hint for summarizer sampling and judgment, never an instruction for code to remove anything automatically. Notice factual transitions—replacement, abandonment, resolution, moving away from a subject, resuming it, or invalidating an old result—but record the transition as ordinary evidence rather than commands to the summarizer.
119
+
111
120
  BAD: relevance=critical for "Agent ran tests and they passed."
112
121
  GOOD: relevance=low for "Agent ran tests and they passed." (routine; captured by a completion observation if it matters)
113
122
 
@@ -116,4 +125,4 @@ Do NOT default to "critical" or "high". Most observations are medium or low. Res
116
125
 
117
126
  Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
118
127
 
119
- Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
128
+ Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count. Always finish by calling done alone.`;
@@ -4,6 +4,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
4
4
  import { hashId } from "../../ids.js";
5
5
  import { boundedMaxTokens, REVIEWER_TOTAL_TOKEN_LIMIT } from "../../model-budget.js";
6
6
  import type { LlmUsageInput } from "../../runtime.js";
7
+ import { recallMemorySources } from "../../session-ledger/recall.js";
7
8
  import type { Entry, ReviewResult, StructuralReviewRequest } from "../../session-ledger/types.js";
8
9
  import { createRecallAgentTool } from "../../tools/recall-observation.js";
9
10
  import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
@@ -94,18 +95,30 @@ function budgetExhaustedResult(request: StructuralReviewRequest): ReviewResult {
94
95
 
95
96
  export async function runStructuralReview(args: RunStructuralReviewArgs): Promise<ReviewResult | undefined> {
96
97
  let terminal: ReviewTerminalResult | undefined;
97
- const acceptTerminal = (candidate: ReviewTerminalResult): void => {
98
+ let warnedTerminal: ReviewTerminalResult | undefined;
99
+ const acceptTerminal = (candidate: ReviewTerminalResult, missingReferenceIds: string[]) => {
98
100
  if (terminal) throw new Error("A structural reviewer may make only one terminal tool call.");
101
+ const overwritten = warnedTerminal !== undefined;
102
+ if (missingReferenceIds.length > 0) {
103
+ warnedTerminal = candidate;
104
+ return { terminal: false, overwritten };
105
+ }
99
106
  terminal = candidate;
107
+ warnedTerminal = undefined;
108
+ return { terminal: true, overwritten };
109
+ };
110
+ const referenceExists = (id: string): boolean => {
111
+ const branch = args.getBranch();
112
+ return branch.some((entry) => entry.id === id) || recallMemorySources(branch, id).status === "found";
100
113
  };
101
114
  const searchMemories = createSearchMemoriesAgentTool(args.getBranch);
102
115
  const recall = createRecallAgentTool(args.getBranch);
103
116
  const searchChatHistory = createSearchChatHistoryAgentTool(args.getBranch);
104
117
  const readChatHistory = createReadChatHistoryAgentTool(args.getBranch);
105
118
  const scopeTool = args.request.scope === "workflow"
106
- ? createWorkflowProposalTool(acceptTerminal)
107
- : createSoftwareProposalTool(acceptTerminal);
108
- const noProposal = createNoProposalTool(args.request.scope, acceptTerminal);
119
+ ? createWorkflowProposalTool(acceptTerminal, referenceExists)
120
+ : createSoftwareProposalTool(acceptTerminal, referenceExists);
121
+ const noProposal = createNoProposalTool(args.request.scope, acceptTerminal, referenceExists);
109
122
  const tools = [
110
123
  searchMemories as AgentTool<any>,
111
124
  recall as AgentTool<any>,
@@ -179,6 +192,13 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
179
192
  // assistant usage that the real stream wrapper did not already account for.
180
193
  const reportedOutputTokens = assistantOutputTokens(assistants);
181
194
  totalOutputTokens += Math.max(0, reportedOutputTokens - streamedOutputTokens);
195
+ // A warned terminal call deliberately leaves shouldStopAfterTurn false so
196
+ // the reviewer can search/recall and replace it. If the reviewer instead
197
+ // ends its turn, honor the staged outcome exactly as the warning promised.
198
+ if (!terminal && warnedTerminal) {
199
+ terminal = warnedTerminal;
200
+ warnedTerminal = undefined;
201
+ }
182
202
  return totalOutputTokens - iterationStartTokens;
183
203
  };
184
204
 
@@ -28,7 +28,7 @@ First recall every memory cited in the request. Then search for:
28
28
  - existing proposals, tools, abstractions, scripts, representations, or workflows that may already address it;
29
29
  - evidence showing whether the pattern is isolated, temporary, or recurring.
30
30
 
31
- Observations and reflections are compressed. When they do not preserve enough detail to evaluate the suspected pattern, use search_chat_history to search the primary agent's recorded chat with a regular expression. The search is deterministic, scans newest entries first, and returns each match separately. It does not interpret concepts for you. Form concrete patterns from the review concern and use regular expression wild cards around a term when the returned match should include nearby text. Use read_chat_history with a returned entry id when you need the complete entry or preceding and following messages.
31
+ Observations and summaries are compressed. When they do not preserve enough detail to evaluate the suspected pattern, use search_chat_history to search the primary agent's recorded chat with a regular expression. The search is deterministic, scans newest entries first, and returns each match separately. It does not interpret concepts for you. Form concrete patterns from the review concern and use regular expression wild cards around a term when the returned match should include nearby text. Use read_chat_history with a returned entry id when you need the complete entry or preceding and following messages.
32
32
 
33
33
  Do not search chat history indiscriminately or treat a missing regex match as proof that an event did not occur. Search for specific evidence that can support, weaken, or contextualize the concern.
34
34
 
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
3
  import type { Static, TSchema } from "typebox";
4
+ import { memoryReferenceIds } from "../../memory-citations.js";
4
5
  import type { ReviewNoProposal, SoftwareReviewProposal, WorkflowReviewProposal } from "../../session-ledger/types.js";
5
6
 
6
7
  const prose = (description: string) => Type.String({ minLength: 1, description });
@@ -39,19 +40,26 @@ export type WorkflowProposalArgs = Static<typeof WorkflowProposalSchema>;
39
40
  export type SoftwareProposalArgs = Static<typeof SoftwareProposalSchema>;
40
41
  export type NoProposalArgs = Static<typeof NoProposalSchema>;
41
42
  export type ReviewTerminalResult = Omit<WorkflowReviewProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy"> | Omit<SoftwareReviewProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy"> | Omit<ReviewNoProposal, "id" | "version" | "reviewRequestId" | "createdAt" | "requestedBy">;
43
+ export type TerminalWriteResult = { terminal: boolean; overwritten: boolean };
44
+ export type ReviewTerminalWriter = (result: ReviewTerminalResult, missingReferenceIds: string[]) => TerminalWriteResult;
42
45
 
43
46
  function trimOptional(value: string | undefined): string | undefined {
44
47
  const normalized = value?.trim();
45
48
  return normalized || undefined;
46
49
  }
47
50
 
51
+ function citedIds(params: Record<string, unknown>): string[] {
52
+ return memoryReferenceIds(Object.values(params).filter((value): value is string => typeof value === "string").join("\n"));
53
+ }
54
+
48
55
  function terminalTool<T extends TSchema>(
49
56
  name: string,
50
57
  label: string,
51
58
  description: string,
52
59
  parameters: T,
53
60
  build: (params: Static<T>) => ReviewTerminalResult,
54
- onTerminal: (result: ReviewTerminalResult) => void,
61
+ onTerminal: ReviewTerminalWriter,
62
+ referenceExists: (id: string) => boolean,
55
63
  ): AgentTool<T> {
56
64
  return {
57
65
  name,
@@ -59,26 +67,33 @@ function terminalTool<T extends TSchema>(
59
67
  description,
60
68
  parameters,
61
69
  execute: async (_id, params) => {
62
- onTerminal(build(params));
63
- return { content: [{ type: "text", text: "Terminal review outcome recorded." }], details: { terminal: true } };
70
+ const missingReferenceIds = citedIds(params as Record<string, unknown>).filter((id) => !referenceExists(id));
71
+ const write = onTerminal(build(params), missingReferenceIds);
72
+ if (missingReferenceIds.length === 0) {
73
+ return { content: [{ type: "text", text: "Terminal review outcome recorded." }], details: { terminal: true, overwritten: write.overwritten, missingReferenceIds } };
74
+ }
75
+ const warnings = missingReferenceIds.map((id) => `WARNING: memory or primary-chat entry ${id} not found; use search_memories and recall, or search_chat_history and read_chat_history, to find the correct reference.`);
76
+ if (write.overwritten) warnings.push("WARNING: overwriting the prior review outcome; only the latest outcome will be delivered.");
77
+ warnings.push(`You can call ${name} again to replace this review, or end your turn and it will be delivered as-is.`);
78
+ return { content: [{ type: "text", text: warnings.join("\n") }], details: { terminal: false, replaceable: true, overwritten: write.overwritten, missingReferenceIds } };
64
79
  },
65
80
  };
66
81
  }
67
82
 
68
- export function createWorkflowProposalTool(onTerminal: (result: ReviewTerminalResult) => void): AgentTool<typeof WorkflowProposalSchema> {
83
+ export function createWorkflowProposalTool(onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof WorkflowProposalSchema> {
69
84
  return terminalTool("submit_workflow_proposal", "Submit workflow proposal", "Record the one durable workflow proposal for this review.", WorkflowProposalSchema, (params: WorkflowProposalArgs) => ({
70
85
  outcome: "proposal", proposalKind: "workflow", scope: "workflow", title: params.title.trim(), summary: params.summary.trim(), evidence: params.evidence.trim(), inefficiency: params.inefficiency.trim(), conceptualDesign: params.conceptual_design.trim(), inputs: trimOptional(params.inputs), outputs: trimOptional(params.outputs), integration: trimOptional(params.integration), expectedEffect: params.expected_effect.trim(), uncertainties: params.uncertainties.trim(),
71
- }), onTerminal);
86
+ }), onTerminal, referenceExists);
72
87
  }
73
88
 
74
- export function createSoftwareProposalTool(onTerminal: (result: ReviewTerminalResult) => void): AgentTool<typeof SoftwareProposalSchema> {
89
+ export function createSoftwareProposalTool(onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof SoftwareProposalSchema> {
75
90
  return terminalTool("submit_software_proposal", "Submit software proposal", "Record the one durable software design proposal for this review.", SoftwareProposalSchema, (params: SoftwareProposalArgs) => ({
76
91
  outcome: "proposal", proposalKind: "software", scope: "software", title: params.title.trim(), summary: params.summary.trim(), evidence: params.evidence.trim(), structuralIssue: params.structural_issue.trim(), conceptualDesign: params.conceptual_design.trim(), preservedBehavior: params.preserved_behavior.trim(), expectedEffect: params.expected_effect.trim(), uncertainties: params.uncertainties.trim(),
77
- }), onTerminal);
92
+ }), onTerminal, referenceExists);
78
93
  }
79
94
 
80
- export function createNoProposalTool(scope: "workflow" | "software", onTerminal: (result: ReviewTerminalResult) => void): AgentTool<typeof NoProposalSchema> {
95
+ export function createNoProposalTool(scope: "workflow" | "software", onTerminal: ReviewTerminalWriter, referenceExists: (id: string) => boolean = () => true): AgentTool<typeof NoProposalSchema> {
81
96
  return terminalTool("review_concluded_no_proposal", "Conclude no proposal", "Record that this review found no durable proposal justified.", NoProposalSchema, (params: NoProposalArgs) => ({
82
97
  outcome: "no_proposal", scope, reason: params.reason.trim(), evidenceReviewed: params.evidence_reviewed.trim(), reconsiderIf: trimOptional(params.reconsider_if),
83
- }), onTerminal);
98
+ }), onTerminal, referenceExists);
84
99
  }
@@ -10,7 +10,7 @@ import { debugLog } from "../debug-log.js";
10
10
  * "the model chose not to call the tool", which hides the real cause
11
11
  * (rate limits, oversized prompts, auth failures, ...) from the debug log.
12
12
  */
13
- export function logAgentStreamError(stage: "observer" | "reflector" | "dropper" | "contemplator" | "reviewer", event: AgentEvent): void {
13
+ export function logAgentStreamError(stage: "observer" | "summarizer" | "contemplator" | "reviewer", event: AgentEvent): void {
14
14
  if (event.type !== "message_end") return;
15
15
  const message = event.message;
16
16
  if (message.role !== "assistant") return;