pi-observational-memory 1.0.4 → 2.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.
@@ -0,0 +1,89 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ firstRawIdAfter,
4
+ getMemoryState,
5
+ lastObservationCoverEndIdx,
6
+ rawTailEntriesBetween,
7
+ rawTokensSinceLastBound,
8
+ } from "../branch.js";
9
+ import { observationsToPromptLines, runObserver } from "../observer.js";
10
+ import type { Runtime } from "../runtime.js";
11
+ import { serializeBranchEntries } from "../serialize.js";
12
+ import { estimateStringTokens } from "../tokens.js";
13
+ import { OBSERVATION_CUSTOM_TYPE, type ObservationEntryData } from "../types.js";
14
+
15
+ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): void {
16
+ pi.on("turn_end", (_event, ctx) => {
17
+ runtime.ensureConfig(ctx.cwd);
18
+ if (runtime.observerInFlight) return;
19
+
20
+ const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastBound>[0];
21
+ const tokens = rawTokensSinceLastBound(entries);
22
+ if (tokens < runtime.config.observationThresholdTokens) return;
23
+
24
+ const lastBoundIdx = lastObservationCoverEndIdx(entries);
25
+ const coversFromId = firstRawIdAfter(entries, lastBoundIdx);
26
+ if (!coversFromId) return;
27
+
28
+ const leafId = ctx.sessionManager.getLeafId();
29
+ if (!leafId) return;
30
+ const coversUpToId = leafId;
31
+
32
+ const { reflections, committedObs, pendingObs } = getMemoryState(entries);
33
+ const priorObservationLines = observationsToPromptLines([...committedObs, ...pendingObs]);
34
+
35
+ const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
36
+ if (chunkEntries.length === 0) return;
37
+ const chunk = serializeBranchEntries(chunkEntries);
38
+ if (!chunk.trim()) return;
39
+
40
+ if (ctx.hasUI) ctx.ui.notify(
41
+ `Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
42
+ "info",
43
+ );
44
+
45
+ void runtime.launchObserverTask(ctx, "observer", async () => {
46
+ const resolved = await runtime.resolveModel(ctx as any);
47
+ if (!resolved.ok) {
48
+ if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
49
+ ctx.ui.notify(
50
+ `Observational memory: observer skipped — ${resolved.reason}`,
51
+ "warning",
52
+ );
53
+ runtime.resolveFailureNotified = true;
54
+ }
55
+ return;
56
+ }
57
+ runtime.resolveFailureNotified = false;
58
+
59
+ const records = await runObserver({
60
+ model: resolved.model as any,
61
+ apiKey: resolved.apiKey,
62
+ headers: resolved.headers,
63
+ priorReflections: reflections,
64
+ priorObservations: priorObservationLines,
65
+ chunk,
66
+ });
67
+ if (!records || records.length === 0) {
68
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(
69
+ "Observational memory: observer returned no observations",
70
+ "warning",
71
+ );
72
+ return;
73
+ }
74
+
75
+ const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
76
+ const data: ObservationEntryData = {
77
+ records,
78
+ coversFromId,
79
+ coversUpToId,
80
+ tokenCount: observationTokens,
81
+ };
82
+ pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
83
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(
84
+ `Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
85
+ "info",
86
+ );
87
+ });
88
+ });
89
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function hashId(content: string): string {
4
+ return createHash("sha256").update(content).digest("hex").slice(0, 12);
5
+ }
package/src/index.ts CHANGED
@@ -1,336 +1,18 @@
1
- import { completeSimple, type Message, type TextContent, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
2
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { convertToLlm, SettingsManager } from "@mariozechner/pi-coding-agent";
4
- import { DEFAULTS, loadConfig } from "./config.js";
5
- import type { Config } from "./config.js";
6
- import { CONTEXT_USAGE_INSTRUCTIONS, OBSERVER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
7
- import { estimateRawTailTokens, estimateTokens, extractText } from "./tokens.js";
8
- import type { MemoryDetails, MemoryState } from "./types.js";
9
- import { isMemoryDetails } from "./types.js";
10
-
11
- function utcDate(epochMs: number): string {
12
- if (!Number.isFinite(epochMs)) return "????-??-??";
13
- return new Date(epochMs).toISOString().slice(0, 10);
14
- }
15
-
16
- function utcTime(epochMs: number): string {
17
- if (!Number.isFinite(epochMs)) return "??:??";
18
- return new Date(epochMs).toISOString().slice(11, 16);
19
- }
20
-
21
- function serializeWithTimestamps(messages: Message[]): string {
22
- return messages
23
- .map((msg): string | null => {
24
- const time = utcTime(msg.timestamp);
25
- if (msg.role === "user") {
26
- const text =
27
- typeof msg.content === "string"
28
- ? msg.content
29
- : msg.content
30
- .filter((b): b is TextContent => b.type === "text")
31
- .map((b) => b.text)
32
- .join("\n");
33
- return `[User @ ${time} UTC]: ${text}`;
34
- }
35
- if (msg.role === "assistant") {
36
- const parts = msg.content.map((b) => {
37
- if (b.type === "text") return b.text;
38
- if (b.type === "thinking") return b.redacted ? "" : `[thinking: ${b.thinking}]`;
39
- if (b.type === "toolCall") return `[${b.name}(${JSON.stringify(b.arguments)})]`;
40
- return "";
41
- });
42
- const body = parts.filter(Boolean).join("\n");
43
- if (!body) return null;
44
- return `[Assistant @ ${time} UTC]: ${body}`;
45
- }
46
- // toolResult
47
- const text = msg.content
48
- .filter((b): b is TextContent => b.type === "text")
49
- .map((b) => b.text)
50
- .join("\n");
51
- return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time} UTC]: ${text}`;
52
- })
53
- .filter((line): line is string => line !== null)
54
- .join("\n\n");
55
- }
2
+ import { registerStatusCommand } from "./commands/status.js";
3
+ import { registerViewCommand } from "./commands/view.js";
4
+ import { registerCompactionHook } from "./hooks/compaction-hook.js";
5
+ import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
6
+ import { registerObserverTrigger } from "./hooks/observer-trigger.js";
7
+ import { Runtime } from "./runtime.js";
56
8
 
57
9
  export default function observationalMemory(pi: ExtensionAPI) {
58
- let config: Config = { ...DEFAULTS };
59
- let state: MemoryState = { observations: "", reflections: "" };
60
- let compactInFlight = false;
61
-
62
- pi.on("session_start", (_event, ctx) => {
63
- config = loadConfig(ctx.cwd);
64
- state = { observations: "", reflections: "" };
65
- compactInFlight = false;
66
-
67
- const entries = ctx.sessionManager.getBranch();
68
- for (let i = entries.length - 1; i >= 0; i--) {
69
- const entry = entries[i];
70
- if (entry.type === "compaction" && isMemoryDetails(entry.details)) {
71
- state.observations = entry.details.observations;
72
- state.reflections = entry.details.reflections;
73
- break;
74
- }
75
- }
76
- });
77
-
78
- pi.on("agent_end", (_event, ctx) => {
79
- if (compactInFlight) return;
80
-
81
- const entries = ctx.sessionManager.getBranch();
82
- const tokens = estimateRawTailTokens(entries);
83
- if (tokens < config.observationThreshold) return;
84
-
85
- compactInFlight = true;
86
- setTimeout(() => {
87
- if (!ctx.isIdle()) {
88
- compactInFlight = false;
89
- return;
90
- }
91
- try {
92
- ctx.compact({
93
- onComplete: () => {
94
- compactInFlight = false;
95
- if (ctx.hasUI) ctx.ui.notify("Observational memory: compaction complete", "info");
96
- },
97
- onError: (error) => {
98
- compactInFlight = false;
99
- if (ctx.hasUI) ctx.ui.notify(`Observational memory: ${error.message}`, "error");
100
- },
101
- });
102
- } catch (error) {
103
- compactInFlight = false;
104
- const msg = error instanceof Error ? error.message : String(error);
105
- if (ctx.hasUI) ctx.ui.notify(`Observational memory: compact threw: ${msg}`, "error");
106
- }
107
- }, 0);
108
- });
109
-
110
- pi.on("session_before_compact", async (event, ctx) => {
111
- const { preparation, signal } = event;
112
- const { messagesToSummarize, turnPrefixMessages, firstKeptEntryId, tokensBefore } = preparation;
113
-
114
- let model = ctx.model;
115
- if (config.compactionModel) {
116
- const configured = ctx.modelRegistry.find(config.compactionModel.provider, config.compactionModel.id);
117
- if (configured) {
118
- model = configured;
119
- } else if (ctx.hasUI) {
120
- ctx.ui.notify(
121
- `Observational memory: configured model ${config.compactionModel.provider}/${config.compactionModel.id} not found, using session model`,
122
- "warning",
123
- );
124
- }
125
- }
126
- if (!model) return;
127
-
128
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
129
- if (!auth.ok || !auth.apiKey) return;
130
-
131
- const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
132
- if (allMessages.length === 0) return;
133
-
134
- const now = new Date();
135
- const dateStr = utcDate(now.getTime());
136
- const timeStr = utcTime(now.getTime());
137
-
138
- const llmMessages = convertToLlm(allMessages);
139
- const conversationText = serializeWithTimestamps(llmMessages);
140
-
141
- let dateRangeNote = "";
142
- if (llmMessages.length > 0) {
143
- const timestamps = llmMessages.map((m) => m.timestamp);
144
- const firstTs = timestamps.reduce((a, b) => Math.min(a, b));
145
- const lastTs = timestamps.reduce((a, b) => Math.max(a, b));
146
- const firstMsgDate = utcDate(firstTs);
147
- const lastMsgDate = utcDate(lastTs);
148
- dateRangeNote =
149
- firstMsgDate === lastMsgDate
150
- ? ` Messages in this batch are from ${firstMsgDate} (UTC).`
151
- : ` Messages in this batch span ${firstMsgDate} to ${lastMsgDate} (UTC).`;
152
- }
153
-
154
- ctx.ui.notify("Observational memory: running observer...", "info");
155
-
156
- try {
157
- const observerOptions = model.reasoning
158
- ? { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal, reasoning: "high" as const }
159
- : { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal };
160
-
161
- const observerResponse = await completeSimple(
162
- model,
163
- {
164
- systemPrompt: OBSERVER_SYSTEM,
165
- messages: [
166
- {
167
- role: "user" as const,
168
- content: [
169
- {
170
- type: "text" as const,
171
- text: `Today is ${dateStr}, current time is ${timeStr} UTC.${dateRangeNote}\n\n<current-reflections>\n${state.reflections || "(none yet)"}\n</current-reflections>\n\n<current-observations>\n${state.observations || "(none yet)"}\n</current-observations>\n\nCompress the following conversation into new observations:\n\n<conversation>\n${conversationText}\n</conversation>`,
172
- },
173
- ],
174
- timestamp: Date.now(),
175
- },
176
- ],
177
- },
178
- observerOptions,
179
- );
180
-
181
- const newObservations = extractText(observerResponse);
182
- if (!newObservations.trim()) return;
183
-
184
- state.observations = state.observations
185
- ? `${state.observations}\n\n${newObservations}`
186
- : newObservations;
187
- } catch (error) {
188
- const msg = error instanceof Error ? error.message : String(error);
189
- if (ctx.hasUI) ctx.ui.notify(`Observer failed: ${msg}`, "error");
190
- return;
191
- }
192
-
193
- if (estimateTokens(state.observations) > config.reflectionThreshold) {
194
- ctx.ui.notify("Observational memory: running reflector...", "info");
195
-
196
- try {
197
- const reflectorOptions = model.reasoning
198
- ? { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal, reasoning: "high" as const }
199
- : { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal };
200
-
201
- const reflectorResponse = await completeSimple(
202
- model,
203
- {
204
- systemPrompt: REFLECTOR_SYSTEM,
205
- messages: [
206
- {
207
- role: "user" as const,
208
- content: [
209
- {
210
- type: "text" as const,
211
- text: `Today is ${dateStr} (UTC).\n\n<current-reflections>\n${state.reflections || "(none yet)"}\n</current-reflections>\n\n<current-observations>\n${state.observations}\n</current-observations>\n\nGarbage-collect these observations. Promote long-lived facts to reflections, prune what's no longer needed, keep what's still active.`,
212
- },
213
- ],
214
- timestamp: Date.now(),
215
- },
216
- ],
217
- },
218
- reflectorOptions,
219
- );
220
-
221
- const output = extractText(reflectorResponse);
222
- const reflectionsMatch = output.match(/<reflections>\n?([\s\S]*?)\n?<\/reflections>/);
223
- const observationsMatch = output.match(/<observations>\n?([\s\S]*?)\n?<\/observations>/);
224
-
225
- if (reflectionsMatch) state.reflections = reflectionsMatch[1].trim();
226
- if (observationsMatch) state.observations = observationsMatch[1].trim();
227
- } catch (error) {
228
- const msg = error instanceof Error ? error.message : String(error);
229
- if (ctx.hasUI) ctx.ui.notify(`Reflector failed: ${msg}`, "warning");
230
- }
231
- }
232
-
233
- let summary = "";
234
- if (state.reflections) {
235
- summary += `<reflections>\n${state.reflections}\n</reflections>\n\n`;
236
- }
237
- if (state.observations) {
238
- summary += `<observations>\n${state.observations}\n</observations>`;
239
- }
240
-
241
- if (!summary.trim()) return;
242
-
243
- summary += `\n\n${CONTEXT_USAGE_INSTRUCTIONS}`;
244
-
245
- const details: MemoryDetails = {
246
- type: "observational-memory",
247
- version: 1,
248
- observations: state.observations,
249
- reflections: state.reflections,
250
- };
251
-
252
- return {
253
- compaction: {
254
- summary,
255
- firstKeptEntryId,
256
- tokensBefore,
257
- details,
258
- },
259
- };
260
- });
261
-
262
- pi.registerCommand("om-status", {
263
- description: "Show observational memory status",
264
- handler: async (_args, ctx) => {
265
- const entries = ctx.sessionManager.getBranch();
266
- const rawTokens = estimateRawTailTokens(entries);
267
- const obsTokens = estimateTokens(state.observations);
268
- const refTokens = estimateTokens(state.reflections);
269
- const keepRecentTokens = SettingsManager.create(ctx.cwd).getCompactionKeepRecentTokens();
270
-
271
- const lines = [
272
- "── Observational Memory ──",
273
- `Raw messages: ~${rawTokens.toLocaleString()} tokens`,
274
- `Observations: ~${obsTokens.toLocaleString()} tokens`,
275
- `Reflections: ~${refTokens.toLocaleString()} tokens`,
276
- "",
277
- "── Parameters ──",
278
- `Observation threshold: ${config.observationThreshold.toLocaleString()}`,
279
- `Reflection threshold: ${config.reflectionThreshold.toLocaleString()} (interpreted as observations token budget)`,
280
- `Keep recent tokens: ${keepRecentTokens.toLocaleString()} (pi compaction, how many tokens in raw messages)`,
281
- ];
282
-
283
- ctx.ui.notify(lines.join("\n"), "info");
284
- },
285
- });
286
-
287
- pi.registerCommand("om-view", {
288
- description: "Print full observational memory contents (--full to include raw messages)",
289
- handler: async (args, ctx) => {
290
- const full = args.includes("--full");
291
- const sections: string[] = [];
292
-
293
- sections.push("── Reflections ──");
294
- sections.push(state.reflections || "(none)");
295
- sections.push("");
296
- sections.push("── Observations ──");
297
- sections.push(state.observations || "(none)");
298
-
299
- if (full) {
300
- const entries = ctx.sessionManager.getBranch();
301
- let startIndex = 0;
302
- for (let i = entries.length - 1; i >= 0; i--) {
303
- const entry = entries[i];
304
- if (entry.type === "compaction") {
305
- const keptId = entry.firstKeptEntryId;
306
- let found = false;
307
- for (let j = 0; j < entries.length; j++) {
308
- if (entries[j].id === keptId) {
309
- startIndex = j;
310
- found = true;
311
- break;
312
- }
313
- }
314
- if (!found) startIndex = i + 1;
315
- break;
316
- }
317
- }
318
-
319
- const rawMessages = entries
320
- .slice(startIndex)
321
- .filter((e): e is typeof e & { type: "message"; message: unknown } => e.type === "message")
322
- .map((e) => e.message);
10
+ const runtime = new Runtime();
323
11
 
324
- sections.push("");
325
- sections.push("── Raw Messages ──");
326
- if (rawMessages.length > 0) {
327
- sections.push(serializeWithTimestamps(convertToLlm(rawMessages)));
328
- } else {
329
- sections.push("(none)");
330
- }
331
- }
12
+ registerObserverTrigger(pi, runtime);
13
+ registerCompactionTrigger(pi, runtime);
14
+ registerCompactionHook(pi, runtime);
332
15
 
333
- ctx.ui.notify(sections.join("\n"), "info");
334
- },
335
- });
16
+ registerStatusCommand(pi, runtime);
17
+ registerViewCommand(pi, runtime);
336
18
  }
@@ -0,0 +1,143 @@
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type { Message, Model } from "@mariozechner/pi-ai";
3
+ import { Type } from "@mariozechner/pi-ai";
4
+ import type { Static } from "@sinclair/typebox";
5
+ import { hashId } from "./ids.js";
6
+ import { OBSERVER_SYSTEM } from "./prompts.js";
7
+ import { nowTimestamp, truncateRecordContent } from "./serialize.js";
8
+ import type { ObservationRecord, Relevance } from "./types.js";
9
+
10
+ interface RunObserverArgs {
11
+ model: Model<any>;
12
+ apiKey: string;
13
+ headers?: Record<string, string>;
14
+ priorReflections: string[];
15
+ priorObservations: string[];
16
+ chunk: string;
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ const RelevanceSchema = Type.Union([
21
+ Type.Literal("low"),
22
+ Type.Literal("medium"),
23
+ Type.Literal("high"),
24
+ Type.Literal("critical"),
25
+ ]);
26
+
27
+ const RecordObservationsSchema = Type.Object({
28
+ observations: Type.Array(
29
+ Type.Object({
30
+ timestamp: Type.String({
31
+ pattern: "^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$",
32
+ description: "Observation time in local 'YYYY-MM-DD HH:MM' format.",
33
+ }),
34
+ content: Type.String({
35
+ minLength: 1,
36
+ description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
37
+ }),
38
+ relevance: RelevanceSchema,
39
+ }),
40
+ { description: "Batch of new observations. May be empty only if the tool is not called at all." },
41
+ ),
42
+ });
43
+
44
+ type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
45
+
46
+ function joinOrEmpty(items: string[]): string {
47
+ return items.length ? items.join("\n") : "(none yet)";
48
+ }
49
+
50
+ export async function runObserver(args: RunObserverArgs): Promise<ObservationRecord[] | undefined> {
51
+ const { model, apiKey, headers, priorReflections, priorObservations, chunk, signal } = args;
52
+ const conversation = chunk.trim();
53
+ if (!conversation) return undefined;
54
+
55
+ const accumulated = new Map<string, ObservationRecord>();
56
+
57
+ const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
58
+ name: "record_observations",
59
+ label: "Record observations",
60
+ description:
61
+ "Record a batch of new observations distilled from the conversation chunk. " +
62
+ "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
63
+ "then emit a short plain-text confirmation to end the run.",
64
+ parameters: RecordObservationsSchema,
65
+ execute: async (_id, params: RecordObservationsArgs) => {
66
+ let added = 0;
67
+ let duplicates = 0;
68
+ for (const obs of params.observations) {
69
+ const content = truncateRecordContent(obs.content);
70
+ const id = hashId(content);
71
+ if (accumulated.has(id)) {
72
+ duplicates++;
73
+ continue;
74
+ }
75
+ accumulated.set(id, {
76
+ id,
77
+ content,
78
+ timestamp: obs.timestamp,
79
+ relevance: obs.relevance as Relevance,
80
+ });
81
+ added++;
82
+ }
83
+ const ack =
84
+ `Recorded ${added} new observation${added === 1 ? "" : "s"} ` +
85
+ (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped). ` : ". ") +
86
+ `Total so far this run: ${accumulated.size}. ` +
87
+ `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
88
+ return { content: [{ type: "text", text: ack }], details: { added, duplicates, total: accumulated.size } };
89
+ },
90
+ };
91
+
92
+ const now = nowTimestamp();
93
+ const userText = `Current local time: ${now}
94
+
95
+ CURRENT REFLECTIONS:
96
+ ${joinOrEmpty(priorReflections)}
97
+
98
+ CURRENT OBSERVATIONS:
99
+ ${joinOrEmpty(priorObservations)}
100
+
101
+ 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.
102
+
103
+ NEW CONVERSATION CHUNK:
104
+ ${conversation}`;
105
+
106
+ const prompts: Message[] = [
107
+ {
108
+ role: "user",
109
+ content: [{ type: "text", text: userText }],
110
+ timestamp: Date.now(),
111
+ },
112
+ ];
113
+
114
+ const context: AgentContext = {
115
+ systemPrompt: OBSERVER_SYSTEM,
116
+ messages: [],
117
+ tools: [recordObservations as AgentTool<any>],
118
+ };
119
+
120
+ const reasoning = (model as { reasoning?: unknown }).reasoning;
121
+ const config: AgentLoopConfig = {
122
+ model,
123
+ apiKey,
124
+ headers,
125
+ maxTokens: 4096,
126
+ convertToLlm: (msgs) => msgs as Message[],
127
+ toolExecution: "sequential",
128
+ ...(reasoning ? { reasoning: "high" as const } : {}),
129
+ };
130
+
131
+ const stream = agentLoop(prompts, context, config, signal);
132
+ for await (const _event of stream) {
133
+ // Drain events; the tool's execute already collects records.
134
+ }
135
+ await stream.result();
136
+
137
+ if (accumulated.size === 0) return undefined;
138
+ return Array.from(accumulated.values());
139
+ }
140
+
141
+ export function observationsToPromptLines(records: ObservationRecord[]): string[] {
142
+ return records.map((r) => `[${r.id}] ${r.timestamp} [${r.relevance}] ${r.content}`);
143
+ }