@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.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +60 -0
- package/src/agents/contemplator/agent.ts +718 -0
- package/src/agents/contemplator/prompts.ts +212 -0
- package/src/agents/dropper/agent.ts +291 -0
- package/src/agents/dropper/coverage.ts +128 -0
- package/src/agents/dropper/pool.ts +67 -0
- package/src/agents/dropper/prompts.ts +48 -0
- package/src/agents/observer/agent.ts +207 -0
- package/src/agents/observer/prompts.ts +119 -0
- package/src/agents/reflector/agent.ts +213 -0
- package/src/agents/reflector/prompts.ts +81 -0
- package/src/agents/reviewer/agent.ts +187 -0
- package/src/agents/reviewer/history-tools.ts +337 -0
- package/src/agents/reviewer/prompts.ts +135 -0
- package/src/agents/reviewer/tools.ts +84 -0
- package/src/agents/stream-errors.ts +22 -0
- package/src/clipboard.ts +63 -0
- package/src/commands/contemplator-view.ts +128 -0
- package/src/commands/reviewer-view.ts +89 -0
- package/src/commands/settings.ts +257 -0
- package/src/commands/status.ts +176 -0
- package/src/commands/view.ts +171 -0
- package/src/config.ts +284 -0
- package/src/debug-log.ts +72 -0
- package/src/hooks/compaction-hook.ts +99 -0
- package/src/hooks/compaction-resume.ts +124 -0
- package/src/hooks/compaction-trigger.ts +122 -0
- package/src/hooks/consolidation-trigger.ts +488 -0
- package/src/ids.ts +5 -0
- package/src/index.ts +32 -0
- package/src/model-budget.ts +16 -0
- package/src/runtime.ts +316 -0
- package/src/serialize.ts +274 -0
- package/src/session-ledger/fold.ts +115 -0
- package/src/session-ledger/index.ts +7 -0
- package/src/session-ledger/progress.ts +156 -0
- package/src/session-ledger/projection.ts +243 -0
- package/src/session-ledger/recall.ts +258 -0
- package/src/session-ledger/render-summary.ts +31 -0
- package/src/session-ledger/search.ts +184 -0
- package/src/session-ledger/types.ts +329 -0
- package/src/tokens.ts +27 -0
- package/src/tools/compact-context.ts +54 -0
- package/src/tools/recall-observation.ts +532 -0
- package/src/tools/search-memories.ts +131 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { Static } from "typebox";
|
|
4
|
+
import type { Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
5
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
+
import {
|
|
8
|
+
recallMemorySources,
|
|
9
|
+
type Entry,
|
|
10
|
+
type RecallResult,
|
|
11
|
+
type RecalledObservation,
|
|
12
|
+
} from "../session-ledger/recall.js";
|
|
13
|
+
import type { Observation, Reflection, ReviewResult } from "../session-ledger/index.js";
|
|
14
|
+
import { renderRecallSourceEntries, renderRecallSourceEntry } from "../serialize.js";
|
|
15
|
+
import { estimateEntryTokens } from "../tokens.js";
|
|
16
|
+
|
|
17
|
+
export const RECALL_OBSERVATION_TOOL_NAME = "recall";
|
|
18
|
+
export const RECALL_DESCRIPTION =
|
|
19
|
+
"Recover exact evidence and source context behind a compacted observational-memory observation or reflection id on the current branch. Use when compressed memory is important and original source context is needed before acting.";
|
|
20
|
+
|
|
21
|
+
const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
|
|
22
|
+
|
|
23
|
+
type RecallObservationToolStatus =
|
|
24
|
+
| "ok"
|
|
25
|
+
| "partial"
|
|
26
|
+
| "invalid_id"
|
|
27
|
+
| "not_found"
|
|
28
|
+
| "no_source"
|
|
29
|
+
| "source_unavailable";
|
|
30
|
+
|
|
31
|
+
type ObservationDetails = Pick<Observation, "id" | "content" | "timestamp" | "relevance"> & { status?: "active" | "dropped" };
|
|
32
|
+
type ReflectionDetails = Pick<Reflection, "id" | "content" | "supportingObservationIds"> & { reflectionIndex: number };
|
|
33
|
+
|
|
34
|
+
export type RecallSourceEntryDetails = {
|
|
35
|
+
id: string;
|
|
36
|
+
origin: string;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
tokens: number;
|
|
39
|
+
qualifiers: string[];
|
|
40
|
+
content?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type RecallObservationMatchDetails = {
|
|
44
|
+
status: "active" | "dropped" | "source_unavailable" | "no_source";
|
|
45
|
+
observationEntryId: string;
|
|
46
|
+
observationRecordIndex: number;
|
|
47
|
+
observation: ObservationDetails;
|
|
48
|
+
sourceEntryIds?: string[];
|
|
49
|
+
sourceEntries?: RecallSourceEntryDetails[];
|
|
50
|
+
missingSourceEntryIds?: string[];
|
|
51
|
+
nonSourceEntryIds?: string[];
|
|
52
|
+
sourceCharacterCount?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type RecallUnavailableSupportingObservationDetails = {
|
|
56
|
+
observationId: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type RecallObservationToolDetails = {
|
|
60
|
+
status: RecallObservationToolStatus;
|
|
61
|
+
memoryId: string;
|
|
62
|
+
observationId: string;
|
|
63
|
+
collision: boolean;
|
|
64
|
+
partial: boolean;
|
|
65
|
+
reflections: ReflectionDetails[];
|
|
66
|
+
reviews: ReviewResult[];
|
|
67
|
+
directObservationMatches: RecallObservationMatchDetails[];
|
|
68
|
+
observations: RecallObservationMatchDetails[];
|
|
69
|
+
matches: RecallObservationMatchDetails[];
|
|
70
|
+
sourceEntries: RecallSourceEntryDetails[];
|
|
71
|
+
unavailableSupportingObservations: RecallUnavailableSupportingObservationDetails[];
|
|
72
|
+
missingSourceEntryIds: string[];
|
|
73
|
+
nonSourceEntryIds: string[];
|
|
74
|
+
sourceCharacterCount?: number;
|
|
75
|
+
message?: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function pad(n: number): string {
|
|
79
|
+
return n.toString().padStart(2, "0");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fmtLocal(d: Date): string {
|
|
83
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatDisplayTimestamp(...values: Array<number | string | undefined>): string {
|
|
87
|
+
for (const v of values) {
|
|
88
|
+
if (v === undefined) continue;
|
|
89
|
+
const d = new Date(v);
|
|
90
|
+
if (!Number.isNaN(d.getTime())) return fmtLocal(d);
|
|
91
|
+
}
|
|
92
|
+
return "Unknown time";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function textContentBlocks(content: unknown): Array<Record<string, unknown>> {
|
|
96
|
+
return Array.isArray(content) ? content.filter((block): block is Record<string, unknown> => !!block && typeof block === "object") : [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function uniqueStrings(items: string[]): string[] {
|
|
100
|
+
return Array.from(new Set(items));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sourceOriginAndQualifiers(entry: Entry): { origin: string; timestamp: string; qualifiers: string[] } {
|
|
104
|
+
if (entry.type === "message" && entry.message && typeof entry.message === "object") {
|
|
105
|
+
const msg = entry.message as Message;
|
|
106
|
+
const timestamp = formatDisplayTimestamp(msg.timestamp, entry.timestamp);
|
|
107
|
+
if (msg.role === "user") return { origin: "User", timestamp, qualifiers: [] };
|
|
108
|
+
if (msg.role === "assistant") {
|
|
109
|
+
const toolCalls = uniqueStrings(
|
|
110
|
+
textContentBlocks(msg.content)
|
|
111
|
+
.filter((block) => block.type === "toolCall" && typeof block.name === "string")
|
|
112
|
+
.map((block) => block.name as string),
|
|
113
|
+
);
|
|
114
|
+
return { origin: "Assistant", timestamp, qualifiers: toolCalls.length > 0 ? [`tool calls: ${toolCalls.join(", ")}`] : [] };
|
|
115
|
+
}
|
|
116
|
+
const toolName = (msg as ToolResultMessage).toolName;
|
|
117
|
+
return { origin: `Tool result: ${typeof toolName === "string" && toolName ? toolName : "unknown"}`, timestamp, qualifiers: [] };
|
|
118
|
+
}
|
|
119
|
+
if (entry.type === "custom_message") {
|
|
120
|
+
return {
|
|
121
|
+
origin: "Custom message",
|
|
122
|
+
timestamp: formatDisplayTimestamp(entry.timestamp),
|
|
123
|
+
qualifiers: typeof entry.customType === "string" && entry.customType ? [`custom: ${entry.customType}`] : [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (entry.type === "branch_summary") return { origin: "Branch summary", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
|
|
127
|
+
return { origin: entry.type || "Entry", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderSourceEntryContentOnly(entry: Entry): string | undefined {
|
|
131
|
+
const rendered = renderRecallSourceEntry(entry);
|
|
132
|
+
return rendered?.replace(/^\[[^\]]+\]:\s?/, "") || undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sourceEntryDetails(entry: Entry, includeContent: boolean): RecallSourceEntryDetails {
|
|
136
|
+
const { origin, timestamp, qualifiers } = sourceOriginAndQualifiers(entry);
|
|
137
|
+
const content = renderSourceEntryContentOnly(entry);
|
|
138
|
+
return {
|
|
139
|
+
id: entry.id,
|
|
140
|
+
origin,
|
|
141
|
+
timestamp,
|
|
142
|
+
tokens: estimateEntryTokens(entry),
|
|
143
|
+
qualifiers,
|
|
144
|
+
...(includeContent && content ? { content } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function observationDetails(observation: Observation, status?: "active" | "dropped"): ObservationDetails {
|
|
149
|
+
return { id: observation.id, content: observation.content, timestamp: observation.timestamp, relevance: observation.relevance, ...(status ? { status } : {}) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function reflectionDetails(reflection: Reflection, reflectionIndex: number): ReflectionDetails {
|
|
153
|
+
return { id: reflection.id, content: reflection.content, supportingObservationIds: reflection.supportingObservationIds, reflectionIndex };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function observationMatchDetails(match: RecalledObservation, includeSourceContent = true): RecallObservationMatchDetails {
|
|
157
|
+
const unavailable = match.missingSourceEntryIds.length > 0 || match.nonSourceEntryIds.length > 0;
|
|
158
|
+
const status = unavailable ? "source_unavailable" : match.sourceEntries.length === 0 ? "no_source" : match.status;
|
|
159
|
+
return {
|
|
160
|
+
status,
|
|
161
|
+
observationEntryId: match.observationEntryId,
|
|
162
|
+
observationRecordIndex: match.observationRecordIndex,
|
|
163
|
+
observation: observationDetails(match.observation, match.status),
|
|
164
|
+
sourceEntryIds: match.sourceEntryIds,
|
|
165
|
+
sourceEntries: match.sourceEntries.map((entry) => sourceEntryDetails(entry, includeSourceContent)),
|
|
166
|
+
missingSourceEntryIds: match.missingSourceEntryIds,
|
|
167
|
+
nonSourceEntryIds: match.nonSourceEntryIds,
|
|
168
|
+
sourceCharacterCount: renderRecallSourceEntries(match.sourceEntries).length,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function textResult(text: string, details: RecallObservationToolDetails) {
|
|
173
|
+
return { content: [{ type: "text" as const, text }], details };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function emptyDetails(status: RecallObservationToolStatus, memoryId: string, message: string): RecallObservationToolDetails {
|
|
177
|
+
return {
|
|
178
|
+
status,
|
|
179
|
+
memoryId,
|
|
180
|
+
observationId: memoryId,
|
|
181
|
+
collision: false,
|
|
182
|
+
partial: false,
|
|
183
|
+
reflections: [],
|
|
184
|
+
reviews: [],
|
|
185
|
+
directObservationMatches: [],
|
|
186
|
+
observations: [],
|
|
187
|
+
matches: [],
|
|
188
|
+
sourceEntries: [],
|
|
189
|
+
unavailableSupportingObservations: [],
|
|
190
|
+
missingSourceEntryIds: [],
|
|
191
|
+
nonSourceEntryIds: [],
|
|
192
|
+
message,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function aggregateStatus(details: Omit<RecallObservationToolDetails, "status">): RecallObservationToolStatus {
|
|
197
|
+
const observationOnly = details.reflections.length === 0 && details.unavailableSupportingObservations.length === 0;
|
|
198
|
+
if (details.partial) return "partial";
|
|
199
|
+
if (observationOnly && details.observations.some((match) => match.status === "source_unavailable")) return "source_unavailable";
|
|
200
|
+
if (observationOnly && details.observations.length > 0 && details.sourceEntries.length === 0 && details.matches.every((match) => (match.sourceEntries ?? []).length === 0)) return "no_source";
|
|
201
|
+
return "ok";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function friendlyNoSourceMessage(memoryId: string): string {
|
|
205
|
+
return `Observation ${memoryId} has no source entries associated with it.`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function friendlySourceUnavailableMessage(match: RecallObservationMatchDetails): string {
|
|
209
|
+
const missing = match.missingSourceEntryIds && match.missingSourceEntryIds.length > 0 ? ` missing: ${match.missingSourceEntryIds.join(", ")}` : "";
|
|
210
|
+
const nonSource = match.nonSourceEntryIds && match.nonSourceEntryIds.length > 0 ? ` non-source: ${match.nonSourceEntryIds.join(", ")}` : "";
|
|
211
|
+
return `Observation ${match.observation.id} has source entries associated, but some are unavailable on the current branch or are not source-renderable.${missing}${nonSource}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function reflectionLineText(reflection: ReflectionDetails): string {
|
|
215
|
+
return `[${reflection.id}] ${reflection.content}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function observationLineText(observation: ObservationDetails): string {
|
|
219
|
+
const status = observation.status === "dropped" ? " [dropped]" : "";
|
|
220
|
+
return `[${observation.id}]${status} ${observation.timestamp} [${observation.relevance}] ${observation.content}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function directObservationMatches(result: Extract<RecallResult, { status: "found" }>): RecalledObservation[] {
|
|
224
|
+
return result.observations.filter((match) => match.observation.id === result.memoryId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderObservationOnlyTextFromResult(result: Extract<RecallResult, { status: "found" }>): string {
|
|
228
|
+
const sections: string[] = [];
|
|
229
|
+
if (result.collision) sections.push(`Memory id ${result.memoryId} matched multiple observations; returning all matching source results from the current branch.`);
|
|
230
|
+
for (const match of directObservationMatches(result)) {
|
|
231
|
+
if (match.status === "dropped") sections.push(`Observation ${match.observation.id} is dropped from active memory but remains recallable.`);
|
|
232
|
+
if (match.missingSourceEntryIds.length > 0 || match.nonSourceEntryIds.length > 0) {
|
|
233
|
+
sections.push(friendlySourceUnavailableMessage(observationMatchDetails(match, false)));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (match.sourceEntries.length === 0) {
|
|
237
|
+
sections.push(friendlyNoSourceMessage(match.observation.id));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const sourceText = renderRecallSourceEntries(match.sourceEntries);
|
|
241
|
+
sections.push(sourceText.trim() ? sourceText : `Observation ${match.observation.id} has source entries associated, but they rendered no text content.`);
|
|
242
|
+
}
|
|
243
|
+
return sections.join("\n\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function unavailableSupportingLineText(item: RecallUnavailableSupportingObservationDetails): string {
|
|
247
|
+
return `Supporting observation ${item.observationId} is unavailable on the current branch.`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function renderReviewText(review: ReviewResult): string {
|
|
251
|
+
const lines = [
|
|
252
|
+
`[${review.id}] ${review.scope.toUpperCase()} REVIEW ${review.outcome === "proposal" ? "PROPOSAL" : "CONCLUDED WITH NO PROPOSAL"}`,
|
|
253
|
+
"Author: background reviewer",
|
|
254
|
+
"Requested by: contemplator",
|
|
255
|
+
"Status: advisory; not evidence of implementation or validation",
|
|
256
|
+
`Review request: ${review.reviewRequestId}`,
|
|
257
|
+
];
|
|
258
|
+
if (review.outcome === "no_proposal") {
|
|
259
|
+
lines.push("", "Reason:", review.reason, "", "Evidence reviewed:", review.evidenceReviewed);
|
|
260
|
+
if (review.reconsiderIf) lines.push("", "Reconsider if:", review.reconsiderIf);
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
lines.push("", "Title:", review.title, "", "Summary:", review.summary, "", "Evidence:", review.evidence);
|
|
264
|
+
if (review.proposalKind === "workflow") {
|
|
265
|
+
lines.push("", "Inefficiency:", review.inefficiency, "", "Conceptual design:", review.conceptualDesign);
|
|
266
|
+
if (review.inputs) lines.push("", "Inputs:", review.inputs);
|
|
267
|
+
if (review.outputs) lines.push("", "Outputs:", review.outputs);
|
|
268
|
+
if (review.integration) lines.push("", "Integration:", review.integration);
|
|
269
|
+
} else {
|
|
270
|
+
lines.push("", "Structural issue:", review.structuralIssue, "", "Conceptual design:", review.conceptualDesign, "", "Preserved behavior:", review.preservedBehavior);
|
|
271
|
+
}
|
|
272
|
+
lines.push("", "Expected effect:", review.expectedEffect, "", "Uncertainties:", review.uncertainties);
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function renderMemoryText(result: Extract<RecallResult, { status: "found" }>): string {
|
|
277
|
+
const sections: string[] = [];
|
|
278
|
+
if (result.collision) sections.push(`Memory id ${result.memoryId} matched multiple observations/reflections; returning all available evidence from the current branch.`);
|
|
279
|
+
if (result.reflections.length > 0) sections.push(`Reflections:\n${result.reflections.map((match) => reflectionLineText(reflectionDetails(match.reflection, match.reflectionRecordIndex))).join("\n")}`);
|
|
280
|
+
if (result.observations.length > 0) sections.push(`Observations:\n${result.observations.map((match) => observationLineText(observationDetails(match.observation, match.status))).join("\n")}`);
|
|
281
|
+
if (result.missingSupportingObservationIds.length > 0) sections.push(`Unavailable supporting observations:\n${result.missingSupportingObservationIds.map((id) => unavailableSupportingLineText({ observationId: id })).join("\n")}`);
|
|
282
|
+
if (result.missingSourceEntryIds.length > 0 || result.nonSourceEntryIds.length > 0) {
|
|
283
|
+
const parts: string[] = [];
|
|
284
|
+
if (result.missingSourceEntryIds.length > 0) parts.push(`missing: ${result.missingSourceEntryIds.join(", ")}`);
|
|
285
|
+
if (result.nonSourceEntryIds.length > 0) parts.push(`non-source: ${result.nonSourceEntryIds.join(", ")}`);
|
|
286
|
+
sections.push(`Unavailable source entries: ${parts.join("; ")}`);
|
|
287
|
+
}
|
|
288
|
+
const sourceText = renderRecallSourceEntries(result.sourceEntries);
|
|
289
|
+
if (sourceText.trim()) sections.push(`Sources:\n${sourceText}`);
|
|
290
|
+
if (sections.length === 0) sections.push(`Memory ${result.memoryId} was found, but no source evidence rendered.`);
|
|
291
|
+
return sections.join("\n\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resultDetails(result: Extract<RecallResult, { status: "found" }>, includeSourceContent = true): RecallObservationToolDetails {
|
|
295
|
+
const reflections = result.reflections.map((match) => reflectionDetails(match.reflection, match.reflectionRecordIndex));
|
|
296
|
+
const reviews = result.reviews.map((match) => match.review);
|
|
297
|
+
const observations = result.observations.map((match) => observationMatchDetails(match, includeSourceContent));
|
|
298
|
+
const directMatches = directObservationMatches(result).map((match) => observationMatchDetails(match, includeSourceContent));
|
|
299
|
+
const sourceEntries = result.sourceEntries.map((entry) => sourceEntryDetails(entry, includeSourceContent));
|
|
300
|
+
const detailWithoutStatus = {
|
|
301
|
+
memoryId: result.memoryId,
|
|
302
|
+
observationId: result.memoryId,
|
|
303
|
+
collision: result.collision,
|
|
304
|
+
partial: result.partial,
|
|
305
|
+
reflections,
|
|
306
|
+
reviews,
|
|
307
|
+
directObservationMatches: directMatches,
|
|
308
|
+
observations,
|
|
309
|
+
matches: directMatches,
|
|
310
|
+
sourceEntries,
|
|
311
|
+
unavailableSupportingObservations: result.missingSupportingObservationIds.map((observationId) => ({ observationId })),
|
|
312
|
+
missingSourceEntryIds: result.missingSourceEntryIds,
|
|
313
|
+
nonSourceEntryIds: result.nonSourceEntryIds,
|
|
314
|
+
sourceCharacterCount: renderRecallSourceEntries(result.sourceEntries).length,
|
|
315
|
+
};
|
|
316
|
+
return { status: aggregateStatus(detailWithoutStatus), ...detailWithoutStatus };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isObservationOnly(details: RecallObservationToolDetails): boolean {
|
|
320
|
+
return details.reflections.length === 0 && details.unavailableSupportingObservations.length === 0;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderFoundResult(result: Extract<RecallResult, { status: "found" }>): ReturnType<typeof textResult> {
|
|
324
|
+
const details = resultDetails(result);
|
|
325
|
+
const text = result.kind === "review"
|
|
326
|
+
? result.reviews.map((match) => renderReviewText(match.review)).join("\n\n")
|
|
327
|
+
: result.kind === "observation" ? renderObservationOnlyTextFromResult(result) : renderMemoryText(result);
|
|
328
|
+
return textResult(text, details);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
|
332
|
+
return `${n.toLocaleString()} ${n === 1 ? singular : pluralForm}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function sourceEntriesFromDetails(details: RecallObservationToolDetails): RecallSourceEntryDetails[] {
|
|
336
|
+
if (!isObservationOnly(details)) return details.sourceEntries;
|
|
337
|
+
return details.matches.flatMap((match) => match.sourceEntries ?? []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function tokenSummary(tokens: number): string {
|
|
341
|
+
return `~${tokens.toLocaleString()} ${tokens === 1 ? "token" : "tokens"}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isFailureStatus(status: RecallObservationToolStatus): boolean {
|
|
345
|
+
return status === "invalid_id" || status === "not_found";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function observationCountForHeader(details: RecallObservationToolDetails): number {
|
|
349
|
+
return isObservationOnly(details) ? details.matches.length : details.observations.length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function formatRecallHeaderForTui(details: RecallObservationToolDetails): string {
|
|
353
|
+
if (isFailureStatus(details.status)) return "× failure";
|
|
354
|
+
const parts = ["✓ success"];
|
|
355
|
+
if (details.reflections.length > 0) parts.push(plural(details.reflections.length, "reflection"));
|
|
356
|
+
const observations = observationCountForHeader(details);
|
|
357
|
+
if (observations > 0) parts.push(plural(observations, "observation"));
|
|
358
|
+
const sources = sourceEntriesFromDetails(details);
|
|
359
|
+
if (sources.length > 0) parts.push(plural(sources.length, "source"));
|
|
360
|
+
const tokens = sources.reduce((sum, source) => sum + source.tokens, 0);
|
|
361
|
+
if (tokens > 0) parts.push(tokenSummary(tokens));
|
|
362
|
+
if (details.partial && details.status !== "ok") parts.push(details.status.replace(/_/g, " "));
|
|
363
|
+
return parts.join(" · ");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const TUI_TYPE_WIDTH = 15;
|
|
367
|
+
const TUI_META_WIDTH = 31;
|
|
368
|
+
|
|
369
|
+
function alignedRow(type: string, meta: string, text: string): string {
|
|
370
|
+
return `${type.padEnd(TUI_TYPE_WIDTH)} ${meta.padEnd(TUI_META_WIDTH)} ${text}`.trimEnd();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function sourceTag(source: RecallSourceEntryDetails): string {
|
|
374
|
+
const origin = source.origin.trim().toLowerCase();
|
|
375
|
+
if (origin === "user") return "user";
|
|
376
|
+
if (origin === "assistant") return "assistant";
|
|
377
|
+
if (origin.startsWith("tool result")) return "tool";
|
|
378
|
+
if (origin.startsWith("custom message")) return "custom";
|
|
379
|
+
if (origin.startsWith("branch summary")) return "summary";
|
|
380
|
+
return origin.split(/[^a-z0-9]+/).find(Boolean) ?? "entry";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function sourceMetadataLine(source: RecallSourceEntryDetails): string {
|
|
384
|
+
return alignedRow("✓ source", `${source.timestamp} [${sourceTag(source)}]`, tokenSummary(source.tokens));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function observationLine(observation: ObservationDetails): string {
|
|
388
|
+
const status = observation.status === "dropped" ? " dropped" : "";
|
|
389
|
+
return alignedRow("✓ observation", `${observation.timestamp} [${observation.relevance}]${status}`, observation.content);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function reflectionLine(reflection: ReflectionDetails): string {
|
|
393
|
+
return alignedRow("✓ reflection", "", reflection.content);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function noteLine(kind: string, text: string): string {
|
|
397
|
+
return alignedRow("• note", `[${kind}]`, text);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function indentContent(content: string): string {
|
|
401
|
+
return content.split("\n").map((line) => ` ${line}`).join("\n");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function unavailableEvidenceMessage(_details: RecallObservationToolDetails): string {
|
|
405
|
+
return "no source entries are available for this memory id";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function pushSourceLines(lines: string[], sources: RecallSourceEntryDetails[], expanded: boolean): void {
|
|
409
|
+
for (const source of sources) {
|
|
410
|
+
lines.push(sourceMetadataLine(source));
|
|
411
|
+
if (expanded && source.content) {
|
|
412
|
+
lines.push(indentContent(source.content));
|
|
413
|
+
lines.push("");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function memoryRows(details: RecallObservationToolDetails): string[] {
|
|
419
|
+
if (isObservationOnly(details)) return details.matches.map((match) => observationLine(match.observation));
|
|
420
|
+
return [...details.reflections.map((reflection) => reflectionLine(reflection)), ...details.observations.map((observation) => observationLine(observation.observation))];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function noteRows(details: RecallObservationToolDetails, sources: RecallSourceEntryDetails[]): string[] {
|
|
424
|
+
const notes: string[] = [];
|
|
425
|
+
if (details.status === "invalid_id") {
|
|
426
|
+
notes.push(noteLine("invalid id", `memory ids must be 12 lowercase hex characters; received ${details.memoryId}`));
|
|
427
|
+
return notes;
|
|
428
|
+
}
|
|
429
|
+
if (details.status === "not_found") {
|
|
430
|
+
notes.push(noteLine("not found", `no observation or reflection with id ${details.memoryId} was found on the current branch`));
|
|
431
|
+
return notes;
|
|
432
|
+
}
|
|
433
|
+
if (details.collision) notes.push(noteLine("id collision", `multiple memory items share ${details.memoryId}`));
|
|
434
|
+
if (details.observations.some((match) => match.observation.status === "dropped")) notes.push(noteLine("dropped", "one or more observations are dropped from active memory but remain recallable"));
|
|
435
|
+
if (details.unavailableSupportingObservations.length > 0) notes.push(noteLine("missing support", details.unavailableSupportingObservations.map((item) => item.observationId).join(", ")));
|
|
436
|
+
if (details.missingSourceEntryIds.length > 0) notes.push(noteLine("missing source", details.missingSourceEntryIds.join(", ")));
|
|
437
|
+
if (details.nonSourceEntryIds.length > 0) notes.push(noteLine("non-source", details.nonSourceEntryIds.join(", ")));
|
|
438
|
+
if (sources.length === 0 && (details.reflections.length > 0 || details.observations.length > 0 || details.matches.length > 0)) notes.push(noteLine("unavailable evidence", unavailableEvidenceMessage(details)));
|
|
439
|
+
return notes;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function formatRecallResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
|
|
443
|
+
const details = result.details;
|
|
444
|
+
if (!details) {
|
|
445
|
+
const text = result.content.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n");
|
|
446
|
+
return text || "recall";
|
|
447
|
+
}
|
|
448
|
+
const sources = sourceEntriesFromDetails(details);
|
|
449
|
+
const lines: string[] = [];
|
|
450
|
+
const rows = memoryRows(details);
|
|
451
|
+
const notes = noteRows(details, sources);
|
|
452
|
+
lines.push(...rows);
|
|
453
|
+
if (rows.length > 0 && notes.length > 0) lines.push("");
|
|
454
|
+
lines.push(...notes);
|
|
455
|
+
if ((rows.length > 0 || notes.length > 0) && sources.length > 0) lines.push("");
|
|
456
|
+
pushSourceLines(lines, sources, expanded);
|
|
457
|
+
if (!expanded && sources.some((source) => source.content)) lines.push("", "(Ctrl+O to expand)");
|
|
458
|
+
return lines.join("\n").trimEnd();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function formatRecallCallForTui(id: string | undefined): string {
|
|
462
|
+
return `recall ${id ?? "..."}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function formatRecallRenderedResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
|
|
466
|
+
const body = formatRecallResultForTui(result, expanded);
|
|
467
|
+
const header = result.details ? formatRecallHeaderForTui(result.details) : undefined;
|
|
468
|
+
if (header && body) return `\n${header}\n\n${body}`;
|
|
469
|
+
if (header) return `\n${header}`;
|
|
470
|
+
return body ? `\n${body}` : "";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export const RECALL_PARAMETERS = Type.Object({
|
|
474
|
+
id: Type.String({
|
|
475
|
+
pattern: "^[a-f0-9]{12}$",
|
|
476
|
+
description: "12-character lowercase hex observation or reflection id shown in compacted memory, /om:view, or a previous recall result. Must be a specific id; this tool does not search by topic.",
|
|
477
|
+
}),
|
|
478
|
+
});
|
|
479
|
+
export type RecallArgs = Static<typeof RECALL_PARAMETERS>;
|
|
480
|
+
|
|
481
|
+
export function executeRecall(params: RecallArgs, getBranch: () => Entry[]) {
|
|
482
|
+
const memoryId = params.id;
|
|
483
|
+
if (!MEMORY_ID_PATTERN.test(memoryId)) {
|
|
484
|
+
const message = `Memory id must be 12 lowercase hex characters. Received: ${memoryId}`;
|
|
485
|
+
return textResult(message, emptyDetails("invalid_id", memoryId, message));
|
|
486
|
+
}
|
|
487
|
+
const result = recallMemorySources(getBranch(), memoryId);
|
|
488
|
+
if (result.status === "not_found") {
|
|
489
|
+
const message = `No observation or reflection with id ${memoryId} was found on the current branch.`;
|
|
490
|
+
return textResult(message, emptyDetails("not_found", memoryId, message));
|
|
491
|
+
}
|
|
492
|
+
return renderFoundResult(result);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function createRecallAgentTool(getBranch: () => Entry[]): AgentTool<typeof RECALL_PARAMETERS> {
|
|
496
|
+
return {
|
|
497
|
+
name: RECALL_OBSERVATION_TOOL_NAME,
|
|
498
|
+
label: "Recall memory evidence",
|
|
499
|
+
description: RECALL_DESCRIPTION,
|
|
500
|
+
parameters: RECALL_PARAMETERS,
|
|
501
|
+
execute: async (_toolCallId, params) => executeRecall(params, getBranch),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export const recallObservationTool = defineTool({
|
|
506
|
+
name: RECALL_OBSERVATION_TOOL_NAME,
|
|
507
|
+
label: "Recall memory evidence",
|
|
508
|
+
description: RECALL_DESCRIPTION,
|
|
509
|
+
promptSnippet: "Use recall(<id>) to recover exact source context behind compacted memory observations/reflections when precision matters.",
|
|
510
|
+
promptGuidelines: [
|
|
511
|
+
"Use recall before making an important decision that depends on a compacted observation or reflection whose details are unclear.",
|
|
512
|
+
"Use recall when you need exact wording, rationale, file paths, commands, errors, commits, user constraints, or provenance behind a remembered claim.",
|
|
513
|
+
"Use recall when a broad reflection is relevant but you need its supporting observations or raw sources to continue safely.",
|
|
514
|
+
"Use recall when the user asks why you believe something, what supports a memory, or what was decided earlier.",
|
|
515
|
+
"Do not use recall as semantic search or transcript browsing; you must already have a specific 12-character memory id.",
|
|
516
|
+
"Do not recall every id preemptively. Recall only when exact source context will materially improve the next action.",
|
|
517
|
+
],
|
|
518
|
+
parameters: RECALL_PARAMETERS,
|
|
519
|
+
renderCall(args) {
|
|
520
|
+
return new Text(formatRecallCallForTui(args.id), 0, 0);
|
|
521
|
+
},
|
|
522
|
+
renderResult(result, options) {
|
|
523
|
+
return new Text(formatRecallRenderedResultForTui(result as AgentToolResult<RecallObservationToolDetails>, options.expanded), 0, 0);
|
|
524
|
+
},
|
|
525
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
526
|
+
return executeRecall(params, () => ctx.sessionManager.getBranch() as Entry[]);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
export function registerRecallTool(pi: ExtensionAPI): void {
|
|
531
|
+
pi.registerTool(recallObservationTool);
|
|
532
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { Static } from "typebox";
|
|
4
|
+
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
searchMemories,
|
|
7
|
+
type MemorySearchResult,
|
|
8
|
+
} from "../session-ledger/search.js";
|
|
9
|
+
import type { Entry } from "../session-ledger/index.js";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
export const SEARCH_MEMORIES_TOOL_NAME = "search_memories";
|
|
13
|
+
export const SEARCH_MEMORIES_DESCRIPTION =
|
|
14
|
+
"Search recorded observational-memory observations, reflections, and advisory review results by topic or keywords on the current branch. Use the returned memory id with recall to recover exact source context or a full advisory proposal.";
|
|
15
|
+
|
|
16
|
+
export type SearchMemoriesArgs = Static<typeof SEARCH_MEMORIES_PARAMETERS>;
|
|
17
|
+
|
|
18
|
+
export type SearchDetails = {
|
|
19
|
+
query: string;
|
|
20
|
+
limit: number;
|
|
21
|
+
observationsSearched: number;
|
|
22
|
+
reflectionsSearched: number;
|
|
23
|
+
reviewsSearched: number;
|
|
24
|
+
results: MemorySearchResult[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function formatResult(result: MemorySearchResult): string {
|
|
28
|
+
if (result.kind === "review") {
|
|
29
|
+
const label = result.outcome === "proposal"
|
|
30
|
+
? `${result.scope} proposal${result.title ? ` — ${result.title}` : ""}`
|
|
31
|
+
: `${result.scope} review concluded with no proposal`;
|
|
32
|
+
return `- [${result.id}] ${label}: ${result.content}`;
|
|
33
|
+
}
|
|
34
|
+
const status = result.status === "dropped" ? " [dropped]" : "";
|
|
35
|
+
const relevance = result.relevance ? ` [${result.relevance}]` : "";
|
|
36
|
+
const timestamp = result.timestamp ? ` ${result.timestamp}` : "";
|
|
37
|
+
return `- ${result.kind} [${result.id}]${status}${timestamp}${relevance}: ${result.content}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const SEARCH_MEMORIES_PARAMETERS = Type.Object({
|
|
41
|
+
query: Type.String({
|
|
42
|
+
description: "Topic, phrase, or distinctive keywords to search for.",
|
|
43
|
+
}),
|
|
44
|
+
limit: Type.Optional(
|
|
45
|
+
Type.Integer({
|
|
46
|
+
minimum: 1,
|
|
47
|
+
maximum: 20,
|
|
48
|
+
description: "Maximum results to return (default 8).",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export function executeSearchMemories(branchEntries: Entry[], params: SearchMemoriesArgs): { content: [{ type: "text"; text: string }]; details: SearchDetails } {
|
|
54
|
+
const query = params.query.trim();
|
|
55
|
+
const limit = params.limit ?? 8;
|
|
56
|
+
if (!query) {
|
|
57
|
+
const details: SearchDetails = {
|
|
58
|
+
query,
|
|
59
|
+
limit,
|
|
60
|
+
observationsSearched: 0,
|
|
61
|
+
reflectionsSearched: 0,
|
|
62
|
+
reviewsSearched: 0,
|
|
63
|
+
results: [],
|
|
64
|
+
};
|
|
65
|
+
return { content: [{ type: "text", text: "Search query must not be empty." }], details };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const search = searchMemories(branchEntries, query, limit);
|
|
69
|
+
const details: SearchDetails = { ...search, limit };
|
|
70
|
+
const text = search.results.length
|
|
71
|
+
? [
|
|
72
|
+
`Found ${search.results.length} matching memories (searched ${search.observationsSearched} observations, ${search.reflectionsSearched} reflections, and ${search.reviewsSearched} review results):`,
|
|
73
|
+
...search.results.map(formatResult),
|
|
74
|
+
"Use recall(<id>) for exact source context.",
|
|
75
|
+
].join("\n")
|
|
76
|
+
: `No memories matched ${JSON.stringify(query)} (searched ${search.observationsSearched} observations, ${search.reflectionsSearched} reflections, and ${search.reviewsSearched} review results). Try alternate or more distinctive keywords.`;
|
|
77
|
+
return { content: [{ type: "text", text }], details };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createSearchMemoriesAgentTool(getBranch: () => Entry[]): AgentTool<typeof SEARCH_MEMORIES_PARAMETERS> {
|
|
81
|
+
return {
|
|
82
|
+
name: SEARCH_MEMORIES_TOOL_NAME,
|
|
83
|
+
label: "Search memories",
|
|
84
|
+
description: SEARCH_MEMORIES_DESCRIPTION,
|
|
85
|
+
parameters: SEARCH_MEMORIES_PARAMETERS,
|
|
86
|
+
execute: async (_toolCallId, params) => executeSearchMemories(getBranch(), params),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const searchMemoriesTool = defineTool({
|
|
91
|
+
name: SEARCH_MEMORIES_TOOL_NAME,
|
|
92
|
+
label: "Search observational memories",
|
|
93
|
+
description: SEARCH_MEMORIES_DESCRIPTION,
|
|
94
|
+
promptSnippet:
|
|
95
|
+
"Use search_memories(query) to find relevant older observations or reflections, then use recall(id) when exact source context matters.",
|
|
96
|
+
promptGuidelines: [
|
|
97
|
+
"Use search_memories when the current context may be missing earlier decisions, constraints, user preferences, completed work, or rationale.",
|
|
98
|
+
"Search with a few distinctive keywords or a short topic phrase; the search covers both active and dropped observations plus reflections on the current branch.",
|
|
99
|
+
"After finding a relevant memory, use recall with its exact 12-character id when you need supporting evidence or original source entries.",
|
|
100
|
+
"Do not assume the absence of results means the fact never occurred; search with alternate wording or narrower keywords.",
|
|
101
|
+
],
|
|
102
|
+
parameters: SEARCH_MEMORIES_PARAMETERS,
|
|
103
|
+
renderCall(args) {
|
|
104
|
+
return new Text(`search_memories ${JSON.stringify(args.query)}`, 0, 0);
|
|
105
|
+
},
|
|
106
|
+
renderResult(result) {
|
|
107
|
+
const details = result.details as SearchDetails | undefined;
|
|
108
|
+
const text = result.content
|
|
109
|
+
.filter(
|
|
110
|
+
(part): part is { type: "text"; text: string } =>
|
|
111
|
+
part.type === "text" && typeof part.text === "string",
|
|
112
|
+
)
|
|
113
|
+
.map((part) => part.text)
|
|
114
|
+
.join("\n");
|
|
115
|
+
return new Text(
|
|
116
|
+
text ||
|
|
117
|
+
(details
|
|
118
|
+
? `${details.results.length} memory results`
|
|
119
|
+
: "search_memories"),
|
|
120
|
+
0,
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
125
|
+
return executeSearchMemories(ctx.sessionManager.getBranch() as Entry[], params);
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export function registerSearchMemoriesTool(pi: ExtensionAPI): void {
|
|
130
|
+
pi.registerTool(searchMemoriesTool);
|
|
131
|
+
}
|