pi-observational-memory 2.4.2 → 3.0.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.
@@ -0,0 +1,331 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { runDropper } from "../agents/dropper/agent.js";
3
+ import { observationPoolMetrics } from "../agents/dropper/pool.js";
4
+ import { runObserver } from "../agents/observer/agent.js";
5
+ import { runReflector } from "../agents/reflector/agent.js";
6
+ import { debugLog, withDebugLogContext } from "../debug-log.js";
7
+ import { type ResolveResult, type Runtime } from "../runtime.js";
8
+ import { serializeSourceAddressedBranchEntries } from "../serialize.js";
9
+ import {
10
+ OM_OBSERVATIONS_DROPPED,
11
+ OM_OBSERVATIONS_RECORDED,
12
+ OM_REFLECTIONS_RECORDED,
13
+ buildObservationsDroppedData,
14
+ buildObservationsRecordedData,
15
+ buildReflectionsRecordedData,
16
+ earlierCoverageMarkerId,
17
+ foldLedger,
18
+ fullProjection,
19
+ isSourceEntry,
20
+ latestCoverageIndex,
21
+ latestCoverageMarkerId,
22
+ observationToSummaryLine,
23
+ rawTokensSinceObservationCoverage,
24
+ rawTokensSinceReflectionCoverage,
25
+ reflectionToSummaryLine,
26
+ type Entry,
27
+ type Reflection,
28
+ } from "../session-ledger/index.js";
29
+
30
+ type ResolvedModel = Extract<ResolveResult, { ok: true }>;
31
+
32
+ type ConsolidationCtx = {
33
+ cwd: string;
34
+ hasUI: boolean;
35
+ ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
36
+ model: unknown;
37
+ modelRegistry: any;
38
+ sessionManager: { getBranch: () => unknown };
39
+ };
40
+
41
+ type StageOutcome = "continue" | "abort";
42
+
43
+ type ReflectorStageResult = {
44
+ outcome: StageOutcome;
45
+ sameRunReflections: Reflection[];
46
+ effectiveReflectionCoverageId?: string;
47
+ };
48
+
49
+ function sourceEntriesAfter(entries: Entry[], index: number): Entry[] {
50
+ return entries.slice(index + 1).filter(isSourceEntry);
51
+ }
52
+
53
+ function appendEntry(pi: ExtensionAPI, customType: string, data: unknown): void {
54
+ pi.appendEntry(customType, data);
55
+ }
56
+
57
+ function mergeReflections(existing: Reflection[], additional: Reflection[]): Reflection[] {
58
+ const seen = new Set(existing.map((reflection) => reflection.id));
59
+ const merged = [...existing];
60
+ for (const reflection of additional) {
61
+ if (seen.has(reflection.id)) continue;
62
+ seen.add(reflection.id);
63
+ merged.push(reflection);
64
+ }
65
+ return merged;
66
+ }
67
+
68
+ function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
69
+ return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
70
+ || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens;
71
+ }
72
+
73
+ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
74
+ let cached: ResolveResult | undefined;
75
+ return async (stage) => {
76
+ cached ??= await runtime.resolveModel({
77
+ model: ctx.model,
78
+ modelRegistry: ctx.modelRegistry,
79
+ hasUI: ctx.hasUI,
80
+ ui: ctx.ui,
81
+ });
82
+ if (cached.ok) {
83
+ runtime.resolveFailureNotified = false;
84
+ return cached;
85
+ }
86
+ debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
87
+ if (!runtime.resolveFailureNotified && ctx.hasUI && ctx.ui) {
88
+ ctx.ui.notify(`Observational memory: ${stage} skipped — ${cached.reason}`, "warning");
89
+ runtime.resolveFailureNotified = true;
90
+ }
91
+ return undefined;
92
+ };
93
+ }
94
+
95
+ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime): void {
96
+ const launch = (_event: unknown, ctx: ConsolidationCtx) => {
97
+ maybeLaunchConsolidation(pi, runtime, ctx);
98
+ };
99
+ pi.on("agent_start", launch);
100
+ pi.on("turn_end", launch);
101
+ }
102
+
103
+ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
104
+ runtime.ensureConfig(ctx.cwd);
105
+ if (runtime.config.passive === true) return;
106
+ if (runtime.consolidationInFlight) return;
107
+
108
+ const entries = ctx.sessionManager.getBranch() as Entry[];
109
+ if (!anyStageDue(entries, runtime)) return;
110
+
111
+ const runId = `consolidation-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
112
+ const consolidationCtx: ConsolidationCtx = {
113
+ cwd: ctx.cwd,
114
+ hasUI: ctx.hasUI,
115
+ ui: ctx.ui,
116
+ model: ctx.model,
117
+ modelRegistry: ctx.modelRegistry,
118
+ sessionManager: ctx.sessionManager,
119
+ };
120
+
121
+ void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd, runId }, async () => {
122
+ await runConsolidationPipeline(pi, runtime, consolidationCtx);
123
+ }));
124
+ }
125
+
126
+ export async function runConsolidationPipeline(
127
+ pi: ExtensionAPI,
128
+ runtime: Runtime,
129
+ ctx: ConsolidationCtx,
130
+ ): Promise<void> {
131
+ const resolveModel = makeModelResolver(runtime, ctx);
132
+
133
+ runtime.consolidationPhase = "observer";
134
+ try {
135
+ const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel);
136
+ if (observerOutcome === "abort") return;
137
+ } catch (error) {
138
+ debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
139
+ return;
140
+ }
141
+
142
+ runtime.consolidationPhase = "reflector";
143
+ let reflectorResult: ReflectorStageResult;
144
+ try {
145
+ reflectorResult = await runReflectorStage(pi, runtime, ctx, resolveModel);
146
+ if (reflectorResult.outcome === "abort") return;
147
+ } catch (error) {
148
+ debugLog("reflector.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "reflector", error) });
149
+ return;
150
+ }
151
+
152
+ runtime.consolidationPhase = "dropper";
153
+ try {
154
+ await runDropperStage(pi, runtime, ctx, resolveModel, reflectorResult.sameRunReflections, reflectorResult.effectiveReflectionCoverageId);
155
+ } catch (error) {
156
+ debugLog("dropper.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "dropper", error) });
157
+ }
158
+ }
159
+
160
+ async function runObserverStage(
161
+ pi: ExtensionAPI,
162
+ runtime: Runtime,
163
+ ctx: ConsolidationCtx,
164
+ resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
165
+ ): Promise<StageOutcome> {
166
+ const entries = ctx.sessionManager.getBranch() as Entry[];
167
+ const tokens = rawTokensSinceObservationCoverage(entries);
168
+ if (tokens < runtime.config.observeAfterTokens) return "continue";
169
+
170
+ const lastCoverageIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
171
+ const chunkEntries = sourceEntriesAfter(entries, lastCoverageIdx);
172
+ const coversUpToId = chunkEntries.at(-1)?.id;
173
+ if (!coversUpToId) return "continue";
174
+
175
+ const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
176
+ if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
177
+
178
+ const memory = fullProjection(entries);
179
+ const priorReflections = memory.reflections.map(reflectionToSummaryLine);
180
+ const priorObservations = memory.observations.map(observationToSummaryLine);
181
+
182
+ if (ctx.hasUI) ctx.ui?.notify(
183
+ `Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
184
+ "info",
185
+ );
186
+ debugLog("observer.start", {
187
+ tokens,
188
+ coversUpToId,
189
+ sourceEntryIds,
190
+ sourceEntryCount: sourceEntryIds.length,
191
+ priorReflections: priorReflections.length,
192
+ priorObservations: priorObservations.length,
193
+ });
194
+
195
+ const resolved = await resolveModel("observer");
196
+ if (!resolved) return "abort";
197
+
198
+ const observations = await runObserver({
199
+ model: resolved.model as any,
200
+ apiKey: resolved.apiKey,
201
+ headers: resolved.headers,
202
+ priorReflections,
203
+ priorObservations,
204
+ chunk,
205
+ allowedSourceEntryIds: sourceEntryIds,
206
+ maxTurns: runtime.config.agentMaxTurns,
207
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
208
+ });
209
+ if (!observations || observations.length === 0) {
210
+ debugLog("observer.empty", { coversUpToId });
211
+ if (ctx.hasUI) ctx.ui?.notify(
212
+ "Observational memory: observer returned no observations",
213
+ "warning",
214
+ );
215
+ return "continue";
216
+ }
217
+
218
+ const data = buildObservationsRecordedData(observations, coversUpToId);
219
+ if (!data) return "continue";
220
+ debugLog("observer.records", {
221
+ count: observations.length,
222
+ observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
223
+ coversUpToId,
224
+ observations,
225
+ });
226
+ appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
227
+ debugLog("observer.appended", { count: observations.length, coversUpToId });
228
+ if (ctx.hasUI) ctx.ui?.notify(
229
+ `Observational memory: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
230
+ "info",
231
+ );
232
+ return "continue";
233
+ }
234
+
235
+ async function runReflectorStage(
236
+ pi: ExtensionAPI,
237
+ runtime: Runtime,
238
+ ctx: ConsolidationCtx,
239
+ resolveModel: (stage: "reflector") => Promise<ResolvedModel | undefined>,
240
+ ): Promise<ReflectorStageResult> {
241
+ const entries = ctx.sessionManager.getBranch() as Entry[];
242
+ const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
243
+ if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
244
+
245
+ const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
246
+ if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
247
+
248
+ if (ctx.hasUI) ctx.ui?.notify(
249
+ `Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens)`,
250
+ "info",
251
+ );
252
+ const resolved = await resolveModel("reflector");
253
+ if (!resolved) return { outcome: "abort", sameRunReflections: [] };
254
+
255
+ const folded = foldLedger(entries);
256
+ const reflections = await runReflector({
257
+ model: resolved.model as any,
258
+ apiKey: resolved.apiKey,
259
+ headers: resolved.headers,
260
+ reflections: folded.reflections,
261
+ observations: folded.activeObservations,
262
+ maxTurns: runtime.config.agentMaxTurns,
263
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
264
+ });
265
+ if (!reflections) return { outcome: "continue", sameRunReflections: [] };
266
+
267
+ const data = buildReflectionsRecordedData(reflections, observationCoverageId);
268
+ if (!data) return { outcome: "continue", sameRunReflections: [] };
269
+ appendEntry(pi, OM_REFLECTIONS_RECORDED, data);
270
+ return {
271
+ outcome: "continue",
272
+ sameRunReflections: reflections,
273
+ effectiveReflectionCoverageId: data.coversUpToId,
274
+ };
275
+ }
276
+
277
+ async function runDropperStage(
278
+ pi: ExtensionAPI,
279
+ runtime: Runtime,
280
+ ctx: ConsolidationCtx,
281
+ resolveModel: (stage: "dropper") => Promise<ResolvedModel | undefined>,
282
+ sameRunReflections: Reflection[],
283
+ sameRunReflectionCoverageId: string | undefined,
284
+ ): Promise<StageOutcome> {
285
+ if (!sameRunReflectionCoverageId || sameRunReflections.length === 0) {
286
+ debugLog("dropper.waiting_for_reflection", { sameRunReflections: sameRunReflections.length });
287
+ return "continue";
288
+ }
289
+
290
+ const entries = ctx.sessionManager.getBranch() as Entry[];
291
+ const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
292
+ if (!observationCoverageId) return "continue";
293
+
294
+ const folded = foldLedger(entries);
295
+ const metrics = observationPoolMetrics(folded.activeObservations, runtime.config.observationsPoolTargetTokens);
296
+ if (!metrics.ready) {
297
+ debugLog("dropper.not_ready", {
298
+ observationTokens: metrics.observationTokens,
299
+ targetTokens: metrics.targetTokens,
300
+ tokensOverTarget: metrics.tokensOverTarget,
301
+ fullness: metrics.fullness,
302
+ activeObservationCount: metrics.activeObservationCount,
303
+ droppableCount: metrics.droppableCount,
304
+ maxDropsAllowed: metrics.maxDropsAllowed,
305
+ });
306
+ return "continue";
307
+ }
308
+
309
+ if (ctx.hasUI) ctx.ui?.notify(
310
+ `Observational memory: dropper running after reflection — active observation pool ~${metrics.observationTokens.toLocaleString()} / ${metrics.targetTokens.toLocaleString()} target tokens (${Math.round(metrics.fullness * 100).toLocaleString()}%)`,
311
+ "info",
312
+ );
313
+ const resolved = await resolveModel("dropper");
314
+ if (!resolved) return "abort";
315
+
316
+ const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
317
+ const droppedIds = await runDropper({
318
+ model: resolved.model as any,
319
+ apiKey: resolved.apiKey,
320
+ headers: resolved.headers,
321
+ reflections: reflectionsForDropper,
322
+ observations: folded.activeObservations,
323
+ targetTokens: runtime.config.observationsPoolTargetTokens,
324
+ maxTurns: runtime.config.agentMaxTurns,
325
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
326
+ });
327
+ const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, sameRunReflectionCoverageId);
328
+ const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
329
+ if (data) appendEntry(pi, OM_OBSERVATIONS_DROPPED, data);
330
+ return "continue";
331
+ }
package/src/index.ts CHANGED
@@ -1,16 +1,16 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerStatusCommand } from "./commands/status.js";
3
3
  import { registerViewCommand } from "./commands/view.js";
4
4
  import { registerCompactionHook } from "./hooks/compaction-hook.js";
5
5
  import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
6
- import { registerObserverTrigger } from "./hooks/observer-trigger.js";
6
+ import { registerConsolidationTrigger } from "./hooks/consolidation-trigger.js";
7
7
  import { Runtime } from "./runtime.js";
8
8
  import { registerRecallTool } from "./tools/recall-observation.js";
9
9
 
10
10
  export default function observationalMemory(pi: ExtensionAPI) {
11
11
  const runtime = new Runtime();
12
12
 
13
- registerObserverTrigger(pi, runtime);
13
+ registerConsolidationTrigger(pi, runtime);
14
14
  registerCompactionTrigger(pi, runtime);
15
15
  registerCompactionHook(pi, runtime);
16
16
 
@@ -0,0 +1,9 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+
3
+ export const AGENT_LOOP_MAX_TOKENS = 32_000;
4
+
5
+ export function boundedMaxTokens(model: Model<any>, requested: number = AGENT_LOOP_MAX_TOKENS): number {
6
+ return typeof model.maxTokens === "number" && model.maxTokens > 0
7
+ ? Math.min(model.maxTokens, requested)
8
+ : requested;
9
+ }
package/src/runtime.ts CHANGED
@@ -6,6 +6,7 @@ export type ResolveResult =
6
6
 
7
7
  type NotifyLevel = "warning" | "info" | "error";
8
8
  type Notify = (message: string, type?: NotifyLevel) => void;
9
+ export type ConsolidationPhase = "observer" | "reflector" | "dropper";
9
10
 
10
11
  export interface ResolveCtx {
11
12
  model: unknown;
@@ -22,11 +23,15 @@ export interface LaunchCtx {
22
23
  export class Runtime {
23
24
  config: Config = { ...DEFAULTS };
24
25
  configLoaded = false;
25
- observerInFlight = false;
26
- observerPromise: Promise<void> | null = null;
26
+ consolidationInFlight = false;
27
+ consolidationPromise: Promise<void> | null = null;
28
+ consolidationPhase: ConsolidationPhase | undefined;
27
29
  compactInFlight = false;
28
30
  compactHookInFlight = false;
29
31
  resolveFailureNotified = false;
32
+ lastObserverError: string | undefined;
33
+ lastReflectorError: string | undefined;
34
+ lastDropperError: string | undefined;
30
35
 
31
36
  ensureConfig(cwd: string): void {
32
37
  if (this.configLoaded) return;
@@ -36,18 +41,18 @@ export class Runtime {
36
41
 
37
42
  async resolveModel(ctx: ResolveCtx): Promise<ResolveResult> {
38
43
  let model = ctx.model;
39
- if (this.config.compactionModel) {
40
- const configured = ctx.modelRegistry.find(this.config.compactionModel.provider, this.config.compactionModel.id);
44
+ if (this.config.model) {
45
+ const configured = ctx.modelRegistry.find(this.config.model.provider, this.config.model.id);
41
46
  if (configured) {
42
47
  model = configured;
43
48
  } else if (ctx.hasUI && ctx.ui) {
44
49
  ctx.ui.notify(
45
- `Observational memory: configured model ${this.config.compactionModel.provider}/${this.config.compactionModel.id} not found, using session model`,
50
+ `Observational memory: configured model ${this.config.model.provider}/${this.config.model.id} not found, using session model`,
46
51
  "warning",
47
52
  );
48
53
  }
49
54
  }
50
- if (!model) return { ok: false, reason: "no model available (session has no model and no compactionModel configured)" };
55
+ if (!model) return { ok: false, reason: "no model available (session has no model and no observational-memory model configured)" };
51
56
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
52
57
  if (!auth.ok || !auth.apiKey) {
53
58
  const provider = (model as { provider?: string }).provider ?? "unknown";
@@ -56,26 +61,48 @@ export class Runtime {
56
61
  return { ok: true, model, apiKey: auth.apiKey as string, headers: auth.headers as Record<string, string> | undefined };
57
62
  }
58
63
 
59
- launchObserverTask(ctx: LaunchCtx, label: string, work: () => Promise<void>): Promise<void> {
60
- this.observerInFlight = true;
61
- // Capture ctx properties synchronously — after `await work()` the extension ctx
62
- // may be stale (e.g. after ctx.newSession/fork/switchSession/reload), and accessing
63
- // ctx.hasUI or ctx.ui on a stale proxy throws.
64
+ launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
65
+ this.consolidationInFlight = true;
66
+ this.consolidationPhase = undefined;
67
+ this.lastObserverError = undefined;
68
+ this.lastReflectorError = undefined;
69
+ this.lastDropperError = undefined;
70
+ const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
71
+ this.consolidationInFlight = false;
72
+ this.consolidationPhase = undefined;
73
+ if (this.consolidationPromise === promise) this.consolidationPromise = null;
74
+ });
75
+ this.consolidationPromise = promise;
76
+ return promise;
77
+ }
78
+
79
+ recordConsolidationStageError(ctx: LaunchCtx, phase: ConsolidationPhase, error: unknown): string {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ if (phase === "observer") this.lastObserverError = message;
82
+ if (phase === "reflector") this.lastReflectorError = message;
83
+ if (phase === "dropper") this.lastDropperError = message;
84
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
85
+ return message;
86
+ }
87
+
88
+ private launchTrackedTask(
89
+ ctx: LaunchCtx,
90
+ label: string,
91
+ work: () => Promise<void>,
92
+ onFinally: (error: string | undefined) => void,
93
+ ): Promise<void> {
64
94
  const hasUI = ctx.hasUI;
65
95
  const ui = ctx.ui;
66
- let promise!: Promise<void>;
67
- promise = (async () => {
96
+ return (async () => {
97
+ let errorMessage: string | undefined;
68
98
  try {
69
99
  await work();
70
100
  } catch (error) {
71
- const msg = error instanceof Error ? error.message : String(error);
72
- if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${msg}`, "warning");
101
+ errorMessage = error instanceof Error ? error.message : String(error);
102
+ if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
73
103
  } finally {
74
- this.observerInFlight = false;
75
- if (this.observerPromise === promise) this.observerPromise = null;
104
+ onFinally(errorMessage);
76
105
  }
77
106
  })();
78
- this.observerPromise = promise;
79
- return promise;
80
107
  }
81
108
  }
package/src/serialize.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Message, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
1
+ import type { Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
2
2
 
3
3
  function pad(n: number): string {
4
4
  return n.toString().padStart(2, "0");
@@ -0,0 +1,100 @@
1
+ import {
2
+ isObservationsDroppedData,
3
+ isObservationsRecordedData,
4
+ isReflectionsRecordedData,
5
+ OM_OBSERVATIONS_DROPPED,
6
+ OM_OBSERVATIONS_RECORDED,
7
+ OM_REFLECTIONS_RECORDED,
8
+ type Entry,
9
+ type Observation,
10
+ type Reflection,
11
+ } from "./types.js";
12
+
13
+ export type FoldLedgerOptions = {
14
+ /** Fold entries from branch root through this entry id, inclusive. Omit to fold through branch tip. */
15
+ upToEntryId?: string;
16
+ };
17
+
18
+ export type FoldedLedger = {
19
+ /** All first-valid observation records encountered through the fold boundary, including dropped observations. */
20
+ observations: Observation[];
21
+ /** Observation records not tombstoned by a folded drop entry. */
22
+ activeObservations: Observation[];
23
+ /** Tombstoned observation ids, including ids that may not have a corresponding folded observation. */
24
+ droppedObservationIds: Set<string>;
25
+ /** All first-valid reflection records encountered through the fold boundary. */
26
+ reflections: Reflection[];
27
+ /** All first-valid observation records by id, including dropped observations. */
28
+ observationsById: Map<string, Observation>;
29
+ /** All first-valid reflection records by id. */
30
+ reflectionsById: Map<string, Reflection>;
31
+ };
32
+
33
+ function foldEndIndex(entries: Entry[], upToEntryId: string | undefined): number {
34
+ if (!upToEntryId) return entries.length - 1;
35
+ const idx = entries.findIndex((entry) => entry.id === upToEntryId);
36
+ return idx === -1 ? entries.length - 1 : idx;
37
+ }
38
+
39
+ function isCustomEntry(entry: Entry, customType: string): boolean {
40
+ return entry.type === "custom" && entry.customType === customType;
41
+ }
42
+
43
+ /**
44
+ * Fold valid V3 memory ledger entries from the branch root through the target entry.
45
+ *
46
+ * Unknown custom entries, old V2 entries, invalid V3-shaped data, and compaction details are ignored.
47
+ * Observations and reflections use first-valid-record-wins semantics. Drops are tombstones and are
48
+ * retained even when the dropped id is unknown at the time of folding.
49
+ */
50
+ export function foldLedger(entries: Entry[], options: FoldLedgerOptions = {}): FoldedLedger {
51
+ const observationsById = new Map<string, Observation>();
52
+ const reflectionsById = new Map<string, Reflection>();
53
+ const droppedObservationIds = new Set<string>();
54
+ const endIdx = foldEndIndex(entries, options.upToEntryId);
55
+
56
+ for (let i = 0; i <= endIdx; i++) {
57
+ const entry = entries[i];
58
+ if (!entry) continue;
59
+
60
+ if (isCustomEntry(entry, OM_OBSERVATIONS_RECORDED)) {
61
+ if (!isObservationsRecordedData(entry.data)) continue;
62
+ for (const observation of entry.data.observations) {
63
+ if (!observationsById.has(observation.id)) {
64
+ observationsById.set(observation.id, observation);
65
+ }
66
+ }
67
+ continue;
68
+ }
69
+
70
+ if (isCustomEntry(entry, OM_REFLECTIONS_RECORDED)) {
71
+ if (!isReflectionsRecordedData(entry.data)) continue;
72
+ for (const reflection of entry.data.reflections) {
73
+ if (!reflectionsById.has(reflection.id)) {
74
+ reflectionsById.set(reflection.id, reflection);
75
+ }
76
+ }
77
+ continue;
78
+ }
79
+
80
+ if (isCustomEntry(entry, OM_OBSERVATIONS_DROPPED)) {
81
+ if (!isObservationsDroppedData(entry.data)) continue;
82
+ for (const observationId of entry.data.observationIds) {
83
+ droppedObservationIds.add(observationId);
84
+ }
85
+ }
86
+ }
87
+
88
+ const observations = Array.from(observationsById.values());
89
+ const activeObservations = observations.filter((observation) => !droppedObservationIds.has(observation.id));
90
+ const reflections = Array.from(reflectionsById.values());
91
+
92
+ return {
93
+ observations,
94
+ activeObservations,
95
+ droppedObservationIds,
96
+ reflections,
97
+ observationsById,
98
+ reflectionsById,
99
+ };
100
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export * from "./progress.js";
3
+ export * from "./fold.js";
4
+ export * from "./projection.js";
5
+ export * from "./recall.js";
6
+ export * from "./render-summary.js";