pi-observational-memory 2.1.2 → 2.3.0

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/README.md CHANGED
@@ -112,6 +112,16 @@ Settings live in Pi's `settings.json` — globally at `~/.pi/agent/settings.json
112
112
  }
113
113
  ```
114
114
 
115
+ To run the background memory work (observer, reflector, pruner) on a cheaper / faster model than your main coding agent — often the single biggest cost lever the extension exposes — add `compactionModel`:
116
+
117
+ ```json
118
+ {
119
+ "observational-memory": {
120
+ "compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
121
+ }
122
+ }
123
+ ```
124
+
115
125
  The five settings most worth knowing:
116
126
 
117
127
  | Setting | Default | What it controls |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-observational-memory",
3
- "version": "2.1.2",
3
+ "version": "2.3.0",
4
4
  "description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,18 +30,22 @@
30
30
  "README.md"
31
31
  ],
32
32
  "scripts": {
33
- "typecheck": "tsc --noEmit"
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "vitest run"
34
35
  },
35
36
  "peerDependencies": {
36
- "@mariozechner/pi-coding-agent": "*",
37
+ "@mariozechner/pi-agent-core": "*",
37
38
  "@mariozechner/pi-ai": "*",
38
- "@mariozechner/pi-agent-core": "*"
39
+ "@mariozechner/pi-coding-agent": "*",
40
+ "@mariozechner/pi-tui": "*"
39
41
  },
40
42
  "devDependencies": {
41
- "@mariozechner/pi-ai": "^0.66.1",
42
43
  "@mariozechner/pi-agent-core": "^0.66.1",
44
+ "@mariozechner/pi-ai": "^0.66.1",
43
45
  "@mariozechner/pi-coding-agent": "^0.66.1",
46
+ "@mariozechner/pi-tui": "^0.66.1",
44
47
  "@types/node": "^22.0.0",
45
- "typescript": "^5.6.0"
48
+ "typescript": "^5.6.0",
49
+ "vitest": "^4.1.5"
46
50
  }
47
51
  }
package/src/branch.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  } from "./types.js";
10
10
  import { estimateEntryTokens } from "./tokens.js";
11
11
 
12
- type Entry = {
12
+ export type Entry = {
13
13
  type: string;
14
14
  id: string;
15
15
  timestamp?: string;
@@ -25,10 +25,50 @@ type Entry = {
25
25
 
26
26
  const RAW_TYPES = new Set(["message", "custom_message", "branch_summary"]);
27
27
 
28
+ export function isSourceEntry(entry: Entry): boolean {
29
+ return RAW_TYPES.has(entry.type);
30
+ }
31
+
28
32
  function isObservationEntry(entry: Entry): boolean {
29
33
  return entry.type === "custom" && entry.customType === OBSERVATION_CUSTOM_TYPE;
30
34
  }
31
35
 
36
+ export type RecallObservationMatch =
37
+ | {
38
+ status: "ok";
39
+ observation: ObservationRecord;
40
+ observationEntryId: string;
41
+ sourceEntryIds: string[];
42
+ sourceEntries: Entry[];
43
+ }
44
+ | {
45
+ status: "no_source";
46
+ observation: ObservationRecord;
47
+ observationEntryId: string;
48
+ }
49
+ | {
50
+ status: "source_unavailable";
51
+ observation: ObservationRecord;
52
+ observationEntryId: string;
53
+ sourceEntryIds: string[];
54
+ missingSourceEntryIds: string[];
55
+ nonSourceEntryIds: string[];
56
+ };
57
+
58
+ export type RecallObservationSourcesResult =
59
+ | {
60
+ status: "not_found";
61
+ observationId: string;
62
+ matches: [];
63
+ collision: false;
64
+ }
65
+ | {
66
+ status: "found";
67
+ observationId: string;
68
+ matches: RecallObservationMatch[];
69
+ collision: boolean;
70
+ };
71
+
32
72
  export function findLastCompactionIndex(entries: Entry[]): number {
33
73
  for (let i = entries.length - 1; i >= 0; i--) {
34
74
  if (entries[i].type === "compaction") return i;
@@ -103,11 +143,89 @@ export function rawTailEntriesBetween(entries: Entry[], fromId: string, untilId:
103
143
 
104
144
  const result: Entry[] = [];
105
145
  for (let i = fromIdx; i <= untilIdx; i++) {
106
- if (RAW_TYPES.has(entries[i].type)) result.push(entries[i]);
146
+ if (isSourceEntry(entries[i])) result.push(entries[i]);
107
147
  }
108
148
  return result;
109
149
  }
110
150
 
151
+ function uniqueIds(ids: string[]): string[] {
152
+ return Array.from(new Set(ids));
153
+ }
154
+
155
+ function resolveSourceEntries(entries: Entry[], sourceEntryIds: string[]): {
156
+ status: "ok" | "source_unavailable";
157
+ sourceEntryIds: string[];
158
+ sourceEntries: Entry[];
159
+ missingSourceEntryIds: string[];
160
+ nonSourceEntryIds: string[];
161
+ } {
162
+ const requested = uniqueIds(sourceEntryIds);
163
+ const requestedSet = new Set(requested);
164
+ const entriesById = new Map(entries.map((entry) => [entry.id, entry]));
165
+ const missingSourceEntryIds = requested.filter((id) => !entriesById.has(id));
166
+ const nonSourceEntryIds = requested.filter((id) => {
167
+ const entry = entriesById.get(id);
168
+ return entry !== undefined && !isSourceEntry(entry);
169
+ });
170
+ if (missingSourceEntryIds.length > 0 || nonSourceEntryIds.length > 0) {
171
+ return {
172
+ status: "source_unavailable",
173
+ sourceEntryIds: requested,
174
+ sourceEntries: [],
175
+ missingSourceEntryIds,
176
+ nonSourceEntryIds,
177
+ };
178
+ }
179
+
180
+ const sourceEntries = entries.filter((entry) => requestedSet.has(entry.id));
181
+ return {
182
+ status: "ok",
183
+ sourceEntryIds: sourceEntries.map((entry) => entry.id),
184
+ sourceEntries,
185
+ missingSourceEntryIds: [],
186
+ nonSourceEntryIds: [],
187
+ };
188
+ }
189
+
190
+ export function recallObservationSources(entries: Entry[], observationId: string): RecallObservationSourcesResult {
191
+ const matches: RecallObservationMatch[] = [];
192
+ for (const entry of entries) {
193
+ if (!isObservationEntry(entry)) continue;
194
+ if (!isObservationEntryData(entry.data)) continue;
195
+ for (const observation of entry.data.records) {
196
+ if (observation.id !== observationId) continue;
197
+ if (!observation.sourceEntryIds || observation.sourceEntryIds.length === 0) {
198
+ matches.push({ status: "no_source", observation, observationEntryId: entry.id });
199
+ continue;
200
+ }
201
+
202
+ const resolved = resolveSourceEntries(entries, observation.sourceEntryIds);
203
+ if (resolved.status === "source_unavailable") {
204
+ matches.push({
205
+ status: "source_unavailable",
206
+ observation,
207
+ observationEntryId: entry.id,
208
+ sourceEntryIds: resolved.sourceEntryIds,
209
+ missingSourceEntryIds: resolved.missingSourceEntryIds,
210
+ nonSourceEntryIds: resolved.nonSourceEntryIds,
211
+ });
212
+ continue;
213
+ }
214
+
215
+ matches.push({
216
+ status: "ok",
217
+ observation,
218
+ observationEntryId: entry.id,
219
+ sourceEntryIds: resolved.sourceEntryIds,
220
+ sourceEntries: resolved.sourceEntries,
221
+ });
222
+ }
223
+ }
224
+
225
+ if (matches.length === 0) return { status: "not_found", observationId, matches: [], collision: false };
226
+ return { status: "found", observationId, matches, collision: matches.length > 1 };
227
+ }
228
+
111
229
  function getPriorMemoryDetails(entries: Entry[]): MemoryDetails | undefined {
112
230
  const idx = findLastCompactionIndex(entries);
113
231
  if (idx === -1) return undefined;
package/src/compaction.ts CHANGED
@@ -323,7 +323,7 @@ export function renderSummary(reflections: Reflection[], observations: Observati
323
323
  parts.push(`## Reflections\n${reflections.join("\n")}`);
324
324
  }
325
325
  if (observations.length > 0) {
326
- const body = observations.map((o) => `${o.timestamp} [${o.relevance}] ${o.content}`).join("\n");
326
+ const body = observationsToPromptLines(observations).join("\n");
327
327
  parts.push(`## Observations\n${body}`);
328
328
  }
329
329
 
@@ -8,7 +8,7 @@ import {
8
8
  import { renderSummary, runPruner, runReflector } from "../compaction.js";
9
9
  import { observationsToPromptLines, runObserver } from "../observer.js";
10
10
  import type { Runtime } from "../runtime.js";
11
- import { serializeBranchEntries } from "../serialize.js";
11
+ import { serializeSourceAddressedBranchEntries } from "../serialize.js";
12
12
  import { estimateStringTokens } from "../tokens.js";
13
13
  import {
14
14
  OBSERVATION_CUSTOM_TYPE,
@@ -49,7 +49,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
49
49
  if (runtime.observerPromise) {
50
50
  try { await runtime.observerPromise; } catch { /* already notified via launchObserverTask */ }
51
51
  // In-flight observer may have appended a new observation entry during the await;
52
- // refresh from sessionManager so gap computation and coverage collection see it.
52
+ // refresh from sessionManager so gap computation and coverage collection see it
53
53
  entries = ctx.sessionManager.getBranch() as typeof entries;
54
54
  }
55
55
 
@@ -58,8 +58,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
58
58
  let gapObservationData: ObservationEntryData | null = null;
59
59
  const gap = gapRawEntries(entries, firstKeptEntryId);
60
60
  if (gap.length > 0) {
61
- const gapChunk = serializeBranchEntries(gap);
62
- if (gapChunk.trim()) {
61
+ const { text: gapChunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(gap);
62
+ if (gapChunk.trim() && sourceEntryIds.length > 0) {
63
63
  const gapFromId = gap[0].id;
64
64
  const gapUpToId = gap[gap.length - 1].id;
65
65
  const priorObservationLines = observationsToPromptLines([
@@ -79,6 +79,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
79
79
  priorReflections: memoryState.reflections,
80
80
  priorObservations: priorObservationLines,
81
81
  chunk: gapChunk,
82
+ allowedSourceEntryIds: sourceEntryIds,
82
83
  signal,
83
84
  });
84
85
  const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
@@ -8,7 +8,7 @@ import {
8
8
  } from "../branch.js";
9
9
  import { observationsToPromptLines, runObserver } from "../observer.js";
10
10
  import type { Runtime } from "../runtime.js";
11
- import { serializeBranchEntries } from "../serialize.js";
11
+ import { serializeSourceAddressedBranchEntries } from "../serialize.js";
12
12
  import { estimateStringTokens } from "../tokens.js";
13
13
  import { OBSERVATION_CUSTOM_TYPE, type ObservationEntryData } from "../types.js";
14
14
 
@@ -34,8 +34,8 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
34
34
 
35
35
  const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
36
36
  if (chunkEntries.length === 0) return;
37
- const chunk = serializeBranchEntries(chunkEntries);
38
- if (!chunk.trim()) return;
37
+ const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
38
+ if (!chunk.trim() || sourceEntryIds.length === 0) return;
39
39
 
40
40
  if (ctx.hasUI) ctx.ui.notify(
41
41
  `Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
@@ -63,6 +63,7 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
63
63
  priorReflections: reflections,
64
64
  priorObservations: priorObservationLines,
65
65
  chunk,
66
+ allowedSourceEntryIds: sourceEntryIds,
66
67
  });
67
68
  if (!records || records.length === 0) {
68
69
  if (ctx.hasUI && ctx.ui) ctx.ui.notify(
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { registerCompactionHook } from "./hooks/compaction-hook.js";
5
5
  import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
6
6
  import { registerObserverTrigger } from "./hooks/observer-trigger.js";
7
7
  import { Runtime } from "./runtime.js";
8
+ import { registerRecallTool } from "./tools/recall-observation.js";
8
9
 
9
10
  export default function observationalMemory(pi: ExtensionAPI) {
10
11
  const runtime = new Runtime();
@@ -15,4 +16,5 @@ export default function observationalMemory(pi: ExtensionAPI) {
15
16
 
16
17
  registerStatusCommand(pi, runtime);
17
18
  registerViewCommand(pi, runtime);
19
+ registerRecallTool(pi);
18
20
  }
package/src/observer.ts CHANGED
@@ -14,6 +14,7 @@ interface RunObserverArgs {
14
14
  priorReflections: string[];
15
15
  priorObservations: string[];
16
16
  chunk: string;
17
+ allowedSourceEntryIds: string[];
17
18
  signal?: AbortSignal;
18
19
  }
19
20
 
@@ -36,6 +37,15 @@ const RecordObservationsSchema = Type.Object({
36
37
  description: "Single-line plain prose. No markdown, no tags, no embedded timestamp.",
37
38
  }),
38
39
  relevance: RelevanceSchema,
40
+ sourceEntryIds: Type.Array(
41
+ Type.String({ minLength: 1 }),
42
+ {
43
+ minItems: 1,
44
+ description:
45
+ "Exact source entry ids from the chunk that directly support this observation. " +
46
+ "Use only ids shown in '[Source entry id: ...]' labels; never invent ids.",
47
+ },
48
+ ),
39
49
  }),
40
50
  { description: "Batch of new observations. May be empty only if the tool is not called at all." },
41
51
  ),
@@ -47,8 +57,25 @@ function joinOrEmpty(items: string[]): string {
47
57
  return items.length ? items.join("\n") : "(none yet)";
48
58
  }
49
59
 
60
+ export function normalizeSourceEntryIds(
61
+ sourceEntryIds: readonly string[] | undefined,
62
+ allowedSourceEntryIds: readonly string[],
63
+ ): string[] | undefined {
64
+ if (!sourceEntryIds || sourceEntryIds.length === 0) return undefined;
65
+ const allowedOrder = new Map<string, number>();
66
+ for (let i = 0; i < allowedSourceEntryIds.length; i++) allowedOrder.set(allowedSourceEntryIds[i], i);
67
+
68
+ const seen = new Set<string>();
69
+ for (const id of sourceEntryIds) {
70
+ if (!allowedOrder.has(id)) return undefined;
71
+ seen.add(id);
72
+ }
73
+ if (seen.size === 0) return undefined;
74
+ return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
75
+ }
76
+
50
77
  export async function runObserver(args: RunObserverArgs): Promise<ObservationRecord[] | undefined> {
51
- const { model, apiKey, headers, priorReflections, priorObservations, chunk, signal } = args;
78
+ const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
52
79
  const conversation = chunk.trim();
53
80
  if (!conversation) return undefined;
54
81
 
@@ -65,7 +92,13 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
65
92
  execute: async (_id, params: RecordObservationsArgs) => {
66
93
  let added = 0;
67
94
  let duplicates = 0;
95
+ let rejected = 0;
68
96
  for (const obs of params.observations) {
97
+ const sourceEntryIds = normalizeSourceEntryIds(obs.sourceEntryIds, allowedSourceEntryIds);
98
+ if (!sourceEntryIds) {
99
+ rejected++;
100
+ continue;
101
+ }
69
102
  const content = truncateRecordContent(obs.content);
70
103
  const id = hashId(content);
71
104
  if (accumulated.has(id)) {
@@ -77,15 +110,20 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
77
110
  content,
78
111
  timestamp: obs.timestamp,
79
112
  relevance: obs.relevance as Relevance,
113
+ sourceEntryIds,
80
114
  });
81
115
  added++;
82
116
  }
117
+ const rejectedPart = rejected > 0
118
+ ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
119
+ : "";
83
120
  const ack =
84
121
  `Recorded ${added} new observation${added === 1 ? "" : "s"} ` +
85
- (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped). ` : ". ") +
86
- `Total so far this run: ${accumulated.size}. ` +
122
+ (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
123
+ rejectedPart +
124
+ ` Total so far this run: ${accumulated.size}. ` +
87
125
  `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 } };
126
+ return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
89
127
  },
90
128
  };
91
129
 
package/src/prompts.ts CHANGED
@@ -97,7 +97,7 @@ Your job is to compress a chunk of recent conversation into timestamped, rated o
97
97
  You receive:
98
98
  - Current reflections (long-lived facts already crystallized).
99
99
  - Current observations (already-recorded observations, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content").
100
- - A new chunk of conversation with inline message timestamps formatted as "[User @ YYYY-MM-DD HH:MM]:", "[Assistant @ ...]:", "[Tool result for <name> @ ...]:".
100
+ - 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.
101
101
  - A current local time fallback for observations that have no obvious message timestamp.
102
102
 
103
103
  How you work:
@@ -110,6 +110,9 @@ How you work:
110
110
  What to emit:
111
111
  - Produce NEW observations for the new chunk only. Do not restate facts already present in reflections or current observations unless something has materially changed.
112
112
  - Use the timestamp from the relevant conversation message. Fall back to current local time ONLY when no message timestamp applies.
113
+ - For every observation, include sourceEntryIds: the smallest exact set of "[Source entry id: ...]" ids that directly support the observation.
114
+ - 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.
115
+ - 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.
113
116
  - Group repeated similar tool calls into a single observation rather than one per call.
114
117
  - 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.
115
118
 
@@ -267,6 +270,8 @@ export function buildPrunerPassGuidance(pass: number, maxPasses: number): string
267
270
  export const CONTEXT_USAGE_INSTRUCTIONS = `These are condensed memories from earlier in this session.
268
271
 
269
272
  - Reflections: stable, long-lived facts about the user, project, decisions, and constraints.
270
- - Observations: timestamped events from the conversation history, in chronological order.
273
+ - Observations: timestamped events from the conversation history, in chronological order. Observation lines include ids in brackets.
271
274
 
272
- Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.`;
275
+ Treat these as past records. When entries conflict, the most recent observation reflects the latest known state. Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
276
+
277
+ When exact source context is needed for precision or traceability, use the recall tool with the relevant observation id. Do not use recall as broad search or inject raw source unless it is needed.`;
package/src/serialize.ts CHANGED
@@ -14,35 +14,78 @@ function formatTimestamp(v: number | string | undefined): string {
14
14
  return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
15
15
  }
16
16
 
17
+ function formatRecallTimestamp(...values: Array<number | string | undefined>): string {
18
+ for (const v of values) {
19
+ if (v === undefined) continue;
20
+ const d = new Date(v);
21
+ if (!Number.isNaN(d.getTime())) return fmtLocal(d);
22
+ }
23
+ return "Unknown time";
24
+ }
25
+
26
+ function textAndPlaceholders(
27
+ content: unknown,
28
+ options: { omitRedactedThinking?: boolean; includeThinking?: boolean } = {},
29
+ ): string {
30
+ if (typeof content === "string") return content;
31
+ if (!Array.isArray(content)) return "[non-text content omitted]";
32
+
33
+ const parts: string[] = [];
34
+ for (const block of content as Array<Record<string, unknown>>) {
35
+ if (!block || typeof block !== "object") {
36
+ parts.push("[non-text content omitted]");
37
+ continue;
38
+ }
39
+ if (block.type === "text" && typeof block.text === "string") {
40
+ parts.push(block.text);
41
+ continue;
42
+ }
43
+ if (block.type === "thinking") {
44
+ if (options.omitRedactedThinking && block.redacted === true) continue;
45
+ if (options.includeThinking && typeof block.thinking === "string") {
46
+ parts.push(`[thinking: ${block.thinking}]`);
47
+ continue;
48
+ }
49
+ parts.push("[non-text content omitted]");
50
+ continue;
51
+ }
52
+ if (block.type === "toolCall" && typeof block.name === "string") {
53
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
54
+ continue;
55
+ }
56
+ parts.push("[non-text content omitted]");
57
+ }
58
+ return parts.join("\n");
59
+ }
60
+
61
+ function textOnly(content: string | Array<{ type?: string; text?: string }>): string {
62
+ if (typeof content === "string") return content;
63
+ return content
64
+ .filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
65
+ .map((b) => b.text)
66
+ .join("\n");
67
+ }
68
+
17
69
  export function serializeConversation(messages: Message[]): string {
18
70
  return messages
19
71
  .map((msg): string | null => {
20
72
  const time = formatTimestamp(msg.timestamp);
21
73
  if (msg.role === "user") {
22
- const text =
23
- typeof msg.content === "string"
24
- ? msg.content
25
- : msg.content
26
- .filter((b): b is TextContent => b.type === "text")
27
- .map((b) => b.text)
28
- .join("\n");
74
+ const text = textOnly(msg.content);
29
75
  return `[User @ ${time}]: ${text}`;
30
76
  }
31
77
  if (msg.role === "assistant") {
32
- const parts = msg.content.map((b) => {
33
- if (b.type === "text") return b.text;
34
- if (b.type === "thinking") return b.redacted ? "" : `[thinking: ${b.thinking}]`;
35
- if (b.type === "toolCall") return `[${b.name}(${JSON.stringify(b.arguments)})]`;
36
- return "";
37
- });
38
- const body = parts.filter(Boolean).join("\n");
78
+ const body = textAndPlaceholders(msg.content, {
79
+ includeThinking: true,
80
+ omitRedactedThinking: true,
81
+ })
82
+ .split("\n")
83
+ .filter(Boolean)
84
+ .join("\n");
39
85
  if (!body) return null;
40
86
  return `[Assistant @ ${time}]: ${body}`;
41
87
  }
42
- const text = msg.content
43
- .filter((b): b is TextContent => b.type === "text")
44
- .map((b) => b.text)
45
- .join("\n");
88
+ const text = textOnly(msg.content);
46
89
  return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
47
90
  })
48
91
  .filter((line): line is string => line !== null)
@@ -62,8 +105,9 @@ export function truncateRecordContent(content: string): string {
62
105
  return `${head} … [truncated ${dropped} chars]`;
63
106
  }
64
107
 
65
- type RenderableEntry = {
108
+ export type RenderableEntry = {
66
109
  type: string;
110
+ id?: string;
67
111
  timestamp?: string;
68
112
  message?: unknown;
69
113
  customType?: string;
@@ -71,6 +115,26 @@ type RenderableEntry = {
71
115
  summary?: unknown;
72
116
  };
73
117
 
118
+ function renderCustomMessage(entry: RenderableEntry, options: { recallFormat: boolean }): string {
119
+ const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
120
+ const text = options.recallFormat
121
+ ? textAndPlaceholders(entry.content)
122
+ : typeof entry.content === "string"
123
+ ? entry.content
124
+ : Array.isArray(entry.content)
125
+ ? (entry.content as Array<{ type?: string; text?: string }>)
126
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
127
+ .map((b) => b.text as string)
128
+ .join("\n")
129
+ : "";
130
+ if (options.recallFormat) {
131
+ const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
132
+ return `[${origin} @ ${time}]: ${text}`;
133
+ }
134
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
135
+ return `[${tag} @ ${time}]: ${text}`;
136
+ }
137
+
74
138
  export function serializeBranchEntries(entries: RenderableEntry[]): string {
75
139
  const blocks: string[] = [];
76
140
  for (const entry of entries) {
@@ -80,18 +144,7 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
80
144
  continue;
81
145
  }
82
146
  if (entry.type === "custom_message") {
83
- const time = formatTimestamp(entry.timestamp);
84
- let text = "";
85
- if (typeof entry.content === "string") {
86
- text = entry.content;
87
- } else if (Array.isArray(entry.content)) {
88
- text = (entry.content as Array<{ type?: string; text?: string }>)
89
- .filter((b) => b?.type === "text" && typeof b.text === "string")
90
- .map((b) => b.text as string)
91
- .join("\n");
92
- }
93
- const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
94
- blocks.push(`[${tag} @ ${time}]: ${text}`);
147
+ blocks.push(renderCustomMessage(entry, { recallFormat: false }));
95
148
  continue;
96
149
  }
97
150
  if (entry.type === "branch_summary" && typeof entry.summary === "string") {
@@ -101,3 +154,63 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
101
154
  }
102
155
  return blocks.join("\n\n");
103
156
  }
157
+
158
+ export type SourceAddressedSerialization = {
159
+ text: string;
160
+ sourceEntryIds: string[];
161
+ };
162
+
163
+ function isSourceRenderableEntry(entry: RenderableEntry): boolean {
164
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
165
+ }
166
+
167
+ export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
168
+ const blocks: string[] = [];
169
+ const sourceEntryIds: string[] = [];
170
+ for (const entry of entries) {
171
+ if (!entry.id || !isSourceRenderableEntry(entry)) continue;
172
+ const rendered = serializeBranchEntries([entry]);
173
+ if (!rendered.trim()) continue;
174
+ sourceEntryIds.push(entry.id);
175
+ blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
176
+ }
177
+ return { text: blocks.join("\n\n"), sourceEntryIds };
178
+ }
179
+
180
+ function renderRecallMessage(entry: RenderableEntry): string | null {
181
+ if (!entry.message || typeof entry.message !== "object") return null;
182
+ const msg = entry.message as Message;
183
+ const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
184
+ if (msg.role === "user") {
185
+ return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
186
+ }
187
+ if (msg.role === "assistant") {
188
+ const body = textAndPlaceholders(msg.content, {
189
+ includeThinking: true,
190
+ omitRedactedThinking: true,
191
+ })
192
+ .split("\n")
193
+ .filter(Boolean)
194
+ .join("\n");
195
+ if (!body) return null;
196
+ return `[Assistant @ ${time}]: ${body}`;
197
+ }
198
+ return `[Tool result: ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
199
+ }
200
+
201
+ export function renderRecallSourceEntry(entry: RenderableEntry): string | null {
202
+ if (entry.type === "message") return renderRecallMessage(entry);
203
+ if (entry.type === "custom_message") return renderCustomMessage(entry, { recallFormat: true });
204
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
205
+ const time = formatRecallTimestamp(entry.timestamp);
206
+ return `[Branch summary @ ${time}]: ${entry.summary}`;
207
+ }
208
+ return null;
209
+ }
210
+
211
+ export function renderRecallSourceEntries(entries: RenderableEntry[]): string {
212
+ return entries
213
+ .map(renderRecallSourceEntry)
214
+ .filter((block): block is string => block !== null && block.trim().length > 0)
215
+ .join("\n\n");
216
+ }
@@ -0,0 +1,409 @@
1
+ import { Type } from "@mariozechner/pi-ai";
2
+ import type { Message, ToolResultMessage } from "@mariozechner/pi-ai";
3
+ import { defineTool, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
5
+ import { Text } from "@mariozechner/pi-tui";
6
+ import {
7
+ recallObservationSources,
8
+ type Entry,
9
+ type RecallObservationMatch,
10
+ type RecallObservationSourcesResult,
11
+ } from "../branch.js";
12
+ import { renderRecallSourceEntries, renderRecallSourceEntry } from "../serialize.js";
13
+ import { estimateEntryTokens } from "../tokens.js";
14
+ import type { ObservationRecord } from "../types.js";
15
+
16
+ export const RECALL_OBSERVATION_TOOL_NAME = "recall";
17
+
18
+ const OBSERVATION_ID_PATTERN = /^[a-f0-9]{12}$/;
19
+
20
+ type RecallObservationToolStatus =
21
+ | "ok"
22
+ | "invalid_id"
23
+ | "not_found"
24
+ | "no_source"
25
+ | "source_unavailable";
26
+
27
+ type ObservationDetails = Pick<ObservationRecord, "id" | "content" | "timestamp" | "relevance">;
28
+
29
+ export type RecallSourceEntryDetails = {
30
+ id: string;
31
+ origin: string;
32
+ timestamp: string;
33
+ tokens: number;
34
+ qualifiers: string[];
35
+ content?: string;
36
+ };
37
+
38
+ type RecallObservationMatchDetails = {
39
+ status: RecallObservationMatch["status"];
40
+ observationEntryId: string;
41
+ observation: ObservationDetails;
42
+ sourceEntryIds?: string[];
43
+ sourceEntries?: RecallSourceEntryDetails[];
44
+ missingSourceEntryIds?: string[];
45
+ nonSourceEntryIds?: string[];
46
+ sourceCharacterCount?: number;
47
+ };
48
+
49
+ export type RecallObservationToolDetails = {
50
+ status: RecallObservationToolStatus;
51
+ observationId: string;
52
+ collision: boolean;
53
+ matches: RecallObservationMatchDetails[];
54
+ sourceCharacterCount?: number;
55
+ message?: string;
56
+ };
57
+
58
+ function pad(n: number): string {
59
+ return n.toString().padStart(2, "0");
60
+ }
61
+
62
+ function fmtLocal(d: Date): string {
63
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
64
+ }
65
+
66
+ function formatDisplayTimestamp(...values: Array<number | string | undefined>): string {
67
+ for (const v of values) {
68
+ if (v === undefined) continue;
69
+ const d = new Date(v);
70
+ if (!Number.isNaN(d.getTime())) return fmtLocal(d);
71
+ }
72
+ return "Unknown time";
73
+ }
74
+
75
+ function textContentBlocks(content: unknown): Array<Record<string, unknown>> {
76
+ return Array.isArray(content) ? content.filter((block): block is Record<string, unknown> => !!block && typeof block === "object") : [];
77
+ }
78
+
79
+ function uniqueStrings(items: string[]): string[] {
80
+ return Array.from(new Set(items));
81
+ }
82
+
83
+ function sourceOriginAndQualifiers(entry: Entry): { origin: string; timestamp: string; qualifiers: string[] } {
84
+ if (entry.type === "message" && entry.message && typeof entry.message === "object") {
85
+ const msg = entry.message as Message;
86
+ const timestamp = formatDisplayTimestamp(msg.timestamp, entry.timestamp);
87
+ if (msg.role === "user") return { origin: "User", timestamp, qualifiers: [] };
88
+ if (msg.role === "assistant") {
89
+ const toolCalls = uniqueStrings(
90
+ textContentBlocks(msg.content)
91
+ .filter((block) => block.type === "toolCall" && typeof block.name === "string")
92
+ .map((block) => block.name as string),
93
+ );
94
+ return {
95
+ origin: "Assistant",
96
+ timestamp,
97
+ qualifiers: toolCalls.length > 0 ? [`tool calls: ${toolCalls.join(", ")}`] : [],
98
+ };
99
+ }
100
+ const toolName = (msg as ToolResultMessage).toolName;
101
+ return { origin: `Tool result: ${typeof toolName === "string" && toolName ? toolName : "unknown"}`, timestamp, qualifiers: [] };
102
+ }
103
+
104
+ if (entry.type === "custom_message") {
105
+ return {
106
+ origin: "Custom message",
107
+ timestamp: formatDisplayTimestamp(entry.timestamp),
108
+ qualifiers: typeof entry.customType === "string" && entry.customType ? [`custom: ${entry.customType}`] : [],
109
+ };
110
+ }
111
+
112
+ if (entry.type === "branch_summary") {
113
+ return { origin: "Branch summary", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
114
+ }
115
+
116
+ return { origin: entry.type || "Entry", timestamp: formatDisplayTimestamp(entry.timestamp), qualifiers: [] };
117
+ }
118
+
119
+ function renderSourceEntryContentOnly(entry: Entry): string | undefined {
120
+ const rendered = renderRecallSourceEntry(entry);
121
+ return rendered?.replace(/^\[[^\]]+\]:\s?/, "") || undefined;
122
+ }
123
+
124
+ function sourceEntryDetails(entry: Entry, includeContent: boolean): RecallSourceEntryDetails {
125
+ const { origin, timestamp, qualifiers } = sourceOriginAndQualifiers(entry);
126
+ const content = renderSourceEntryContentOnly(entry);
127
+ return {
128
+ id: entry.id,
129
+ origin,
130
+ timestamp,
131
+ tokens: estimateEntryTokens(entry),
132
+ qualifiers,
133
+ ...(includeContent && content ? { content } : {}),
134
+ };
135
+ }
136
+
137
+ function observationDetails(observation: ObservationRecord): ObservationDetails {
138
+ return {
139
+ id: observation.id,
140
+ content: observation.content,
141
+ timestamp: observation.timestamp,
142
+ relevance: observation.relevance,
143
+ };
144
+ }
145
+
146
+ function matchDetails(match: RecallObservationMatch, sourceText?: string, includeSourceContent = true): RecallObservationMatchDetails {
147
+ if (match.status === "ok") {
148
+ return {
149
+ status: "ok",
150
+ observationEntryId: match.observationEntryId,
151
+ observation: observationDetails(match.observation),
152
+ sourceEntryIds: match.sourceEntryIds,
153
+ sourceEntries: match.sourceEntries.map((entry) => sourceEntryDetails(entry, includeSourceContent)),
154
+ sourceCharacterCount: sourceText?.length ?? 0,
155
+ };
156
+ }
157
+ if (match.status === "source_unavailable") {
158
+ return {
159
+ status: "source_unavailable",
160
+ observationEntryId: match.observationEntryId,
161
+ observation: observationDetails(match.observation),
162
+ sourceEntryIds: match.sourceEntryIds,
163
+ missingSourceEntryIds: match.missingSourceEntryIds,
164
+ nonSourceEntryIds: match.nonSourceEntryIds,
165
+ };
166
+ }
167
+ return {
168
+ status: "no_source",
169
+ observationEntryId: match.observationEntryId,
170
+ observation: observationDetails(match.observation),
171
+ };
172
+ }
173
+
174
+ function textResult(text: string, details: RecallObservationToolDetails) {
175
+ return {
176
+ content: [{ type: "text" as const, text }],
177
+ details,
178
+ };
179
+ }
180
+
181
+ function aggregateStatus(matches: RecallObservationMatch[]): RecallObservationToolStatus {
182
+ if (matches.some((match) => match.status === "ok")) return "ok";
183
+ if (matches.some((match) => match.status === "source_unavailable")) return "source_unavailable";
184
+ return "no_source";
185
+ }
186
+
187
+ function friendlyNoSourceMessage(observationId: string): string {
188
+ return `Observation ${observationId} has no source entries associated with it. This can happen for legacy observations created before source recall was available.`;
189
+ }
190
+
191
+ function friendlySourceUnavailableMessage(match: Extract<RecallObservationMatch, { status: "source_unavailable" }>): string {
192
+ const missing = match.missingSourceEntryIds.length > 0 ? ` missing: ${match.missingSourceEntryIds.join(", ")}` : "";
193
+ const nonSource = match.nonSourceEntryIds.length > 0 ? ` non-source: ${match.nonSourceEntryIds.join(", ")}` : "";
194
+ return `Observation ${match.observation.id} has source entries associated, but some are unavailable on the current branch or are not source-renderable.${missing}${nonSource}`;
195
+ }
196
+
197
+ function renderFoundResult(result: Extract<RecallObservationSourcesResult, { status: "found" }>): ReturnType<typeof textResult> {
198
+ const sections: string[] = [];
199
+ const detailsMatches: RecallObservationMatchDetails[] = [];
200
+ let sourceCharacterCount = 0;
201
+
202
+ if (result.collision) {
203
+ sections.push(`Multiple observations share id ${result.observationId}; returning all matching source results from the current branch.`);
204
+ }
205
+
206
+ for (const match of result.matches) {
207
+ if (match.status === "ok") {
208
+ const sourceText = renderRecallSourceEntries(match.sourceEntries);
209
+ sourceCharacterCount += sourceText.length;
210
+ detailsMatches.push(matchDetails(match, sourceText));
211
+ if (sourceText.trim()) sections.push(sourceText);
212
+ else sections.push(`Observation ${match.observation.id} has source entries associated, but they rendered no text content.`);
213
+ continue;
214
+ }
215
+
216
+ if (match.status === "source_unavailable") {
217
+ detailsMatches.push(matchDetails(match));
218
+ sections.push(friendlySourceUnavailableMessage(match));
219
+ continue;
220
+ }
221
+
222
+ detailsMatches.push(matchDetails(match));
223
+ sections.push(friendlyNoSourceMessage(match.observation.id));
224
+ }
225
+
226
+ const text = sections.join("\n\n");
227
+ return textResult(text, {
228
+ status: aggregateStatus(result.matches),
229
+ observationId: result.observationId,
230
+ collision: result.collision,
231
+ matches: detailsMatches,
232
+ sourceCharacterCount,
233
+ });
234
+ }
235
+
236
+ function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
237
+ return `${n.toLocaleString()} ${n === 1 ? singular : pluralForm}`;
238
+ }
239
+
240
+ function sourceEntriesFromDetails(details: RecallObservationToolDetails): RecallSourceEntryDetails[] {
241
+ return details.matches.flatMap((match) => match.sourceEntries ?? []);
242
+ }
243
+
244
+ function tokenSummary(tokens: number): string {
245
+ return `~${tokens.toLocaleString()} ${tokens === 1 ? "token" : "tokens"}`;
246
+ }
247
+
248
+ function statusIcon(details: RecallObservationToolDetails): string {
249
+ if (details.status === "ok") return details.collision ? "⚠" : "✓";
250
+ return "×";
251
+ }
252
+
253
+ function statusSummary(details: RecallObservationToolDetails): string {
254
+ if (details.status === "invalid_id") return "invalid id";
255
+ if (details.status === "not_found") return "not found";
256
+ if (details.status === "source_unavailable") return "source unavailable";
257
+ if (details.status === "no_source") return "no source";
258
+ return details.collision ? "recalled · id collision" : "recalled";
259
+ }
260
+
261
+ export function formatRecallHeaderForTui(details: RecallObservationToolDetails): string {
262
+ const parts = [`${statusIcon(details)} ${statusSummary(details)}`];
263
+ if (details.matches.length > 0) parts.push(plural(details.matches.length, "match", "matches"));
264
+ const sources = sourceEntriesFromDetails(details);
265
+ if (sources.length > 0) parts.push(plural(sources.length, "source entry", "source entries"));
266
+ const tokens = sources.reduce((sum, source) => sum + source.tokens, 0);
267
+ if (tokens > 0) parts.push(tokenSummary(tokens));
268
+ return parts.join(" · ");
269
+ }
270
+
271
+ function sourceLabel(source: RecallSourceEntryDetails): string {
272
+ return source.origin ? `${source.origin[0].toLowerCase()}${source.origin.slice(1)}` : "entry";
273
+ }
274
+
275
+ function sourceMetadataLine(source: RecallSourceEntryDetails): string {
276
+ const qualifiers = source.qualifiers.length > 0 ? ` · ${source.qualifiers.join(" · ")}` : "";
277
+ return `✓ ${sourceLabel(source)} · ${source.timestamp} · entry ${source.id} · ${tokenSummary(source.tokens)}${qualifiers}`;
278
+ }
279
+
280
+ function observationLine(observation: ObservationDetails): string {
281
+ return `✓ observation · ${observation.timestamp} · [${observation.relevance}] · ${observation.content}`;
282
+ }
283
+
284
+ function indentContent(content: string): string {
285
+ return content
286
+ .split("\n")
287
+ .map((line) => ` ${line}`)
288
+ .join("\n");
289
+ }
290
+
291
+ function unavailableSourceLine(match: RecallObservationMatchDetails): string {
292
+ const parts: string[] = [];
293
+ if (match.missingSourceEntryIds && match.missingSourceEntryIds.length > 0) {
294
+ parts.push(`missing: ${match.missingSourceEntryIds.join(", ")}`);
295
+ }
296
+ if (match.nonSourceEntryIds && match.nonSourceEntryIds.length > 0) {
297
+ parts.push(`non-source: ${match.nonSourceEntryIds.join(", ")}`);
298
+ }
299
+ return `× source unavailable${parts.length > 0 ? ` · ${parts.join(" · ")}` : ""}`;
300
+ }
301
+
302
+ function matchLines(match: RecallObservationMatchDetails, expanded: boolean): string[] {
303
+ const lines = [observationLine(match.observation), ""];
304
+ if (match.status === "ok") {
305
+ for (const source of match.sourceEntries ?? []) {
306
+ lines.push(sourceMetadataLine(source));
307
+ if (expanded && source.content) {
308
+ lines.push(indentContent(source.content));
309
+ lines.push("");
310
+ }
311
+ }
312
+ return lines;
313
+ }
314
+ if (match.status === "source_unavailable") return [...lines, unavailableSourceLine(match)];
315
+ return [...lines, "× no source · legacy/unattributed observation"];
316
+ }
317
+
318
+ export function formatRecallResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
319
+ const details = result.details;
320
+ if (!details) {
321
+ const text = result.content
322
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
323
+ .map((part) => part.text)
324
+ .join("\n");
325
+ return text || "recall";
326
+ }
327
+
328
+ const lines: string[] = [];
329
+ if (details.matches.length > 0) {
330
+ for (const match of details.matches) {
331
+ if (lines.length > 0) lines.push("");
332
+ lines.push(...matchLines(match, expanded));
333
+ }
334
+ } else if (details.message) {
335
+ lines.push(details.message);
336
+ }
337
+ if (!expanded && details.matches.some((match) => match.status === "ok" && (match.sourceEntries?.length ?? 0) > 0)) {
338
+ lines.push("", "(Ctrl+O to expand)");
339
+ }
340
+ return lines.join("\n").trimEnd();
341
+ }
342
+
343
+ export function formatRecallCallForTui(id: string | undefined): string {
344
+ return `recall ${id ?? "..."}`;
345
+ }
346
+
347
+ export function formatRecallRenderedResultForTui(result: AgentToolResult<RecallObservationToolDetails>, expanded: boolean): string {
348
+ const body = formatRecallResultForTui(result, expanded);
349
+ const header = result.details ? formatRecallHeaderForTui(result.details) : undefined;
350
+ if (header && body) return `\n${header}\n\n${body}`;
351
+ if (header) return `\n${header}`;
352
+ return body ? `\n${body}` : "";
353
+ }
354
+
355
+ export const recallObservationTool = defineTool({
356
+ name: RECALL_OBSERVATION_TOOL_NAME,
357
+ label: "Recall observation source",
358
+ description: "Recall exact source entries for an observational-memory observation id on the current branch.",
359
+ promptSnippet: "Recall exact source entries for a compacted observational-memory observation id.",
360
+ promptGuidelines: [
361
+ "Use recall when a compacted observation id needs exact source context or the user asks what supports a remembered claim.",
362
+ "This is not general search: pass a specific observation id from the compacted Observations list.",
363
+ "Do not call recall for broad transcript browsing or off-branch history.",
364
+ ],
365
+ parameters: Type.Object({
366
+ id: Type.String({
367
+ pattern: "^[a-f0-9]{12}$",
368
+ description: "12-character lowercase hex observational-memory observation id.",
369
+ }),
370
+ }),
371
+ renderCall(args) {
372
+ return new Text(formatRecallCallForTui(args.id), 0, 0);
373
+ },
374
+ renderResult(result, options) {
375
+ return new Text(formatRecallRenderedResultForTui(result as AgentToolResult<RecallObservationToolDetails>, options.expanded), 0, 0);
376
+ },
377
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
378
+ const observationId = params.id;
379
+ if (!OBSERVATION_ID_PATTERN.test(observationId)) {
380
+ const message = `Observation id must be 12 lowercase hex characters. Received: ${observationId}`;
381
+ return textResult(message, {
382
+ status: "invalid_id",
383
+ observationId,
384
+ collision: false,
385
+ matches: [],
386
+ message,
387
+ });
388
+ }
389
+
390
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
391
+ const result = recallObservationSources(branchEntries, observationId);
392
+ if (result.status === "not_found") {
393
+ const message = `No observation with id ${observationId} was found on the current branch.`;
394
+ return textResult(message, {
395
+ status: "not_found",
396
+ observationId,
397
+ collision: false,
398
+ matches: [],
399
+ message,
400
+ });
401
+ }
402
+
403
+ return renderFoundResult(result);
404
+ },
405
+ });
406
+
407
+ export function registerRecallTool(pi: ExtensionAPI): void {
408
+ pi.registerTool(recallObservationTool);
409
+ }
package/src/types.ts CHANGED
@@ -9,6 +9,7 @@ export interface ObservationRecord {
9
9
  content: string;
10
10
  timestamp: string;
11
11
  relevance: Relevance;
12
+ sourceEntryIds?: string[];
12
13
  }
13
14
 
14
15
  export type Reflection = string;
@@ -34,11 +35,19 @@ function isRelevance(v: unknown): v is Relevance {
34
35
  function isObservationRecord(v: unknown): v is ObservationRecord {
35
36
  if (!v || typeof v !== "object") return false;
36
37
  const o = v as Record<string, unknown>;
38
+ if (
39
+ typeof o.id !== "string" ||
40
+ typeof o.content !== "string" ||
41
+ typeof o.timestamp !== "string" ||
42
+ !isRelevance(o.relevance)
43
+ ) {
44
+ return false;
45
+ }
46
+ if (o.sourceEntryIds === undefined) return true;
37
47
  return (
38
- typeof o.id === "string" &&
39
- typeof o.content === "string" &&
40
- typeof o.timestamp === "string" &&
41
- isRelevance(o.relevance)
48
+ Array.isArray(o.sourceEntryIds) &&
49
+ o.sourceEntryIds.length > 0 &&
50
+ o.sourceEntryIds.every((id) => typeof id === "string" && id.length > 0)
42
51
  );
43
52
  }
44
53