ds4-context-engine 0.1.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,400 @@
1
+ import {
2
+ sessionEntryToContextMessages,
3
+ type ContextEvent,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ type SessionEntry,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { ContextConfig } from "ds4-context-core/config/config";
9
+ import { calculateContextBudget, type ContextBudget } from "ds4-context-core/core/budget-manager";
10
+ import { createModelProfile, type ModelProfile } from "ds4-context-core/core/model-profile";
11
+ import { estimateMessageTokens } from "ds4-context-core/core/token-estimator";
12
+ import type {
13
+ ArtifactManifestRef,
14
+ ContextManifest,
15
+ MemoryManifestRef,
16
+ ModelAwarenessManifest,
17
+ PinManifestRef,
18
+ PrivacyManifest,
19
+ ContextManifestItemKind,
20
+ ProjectRevision,
21
+ } from "ds4-context-core/manifest/context-manifest";
22
+ import {
23
+ buildObserverManifest,
24
+ type ExcludedContextSource,
25
+ type ObservedMessageSource,
26
+ type ObservedTool,
27
+ } from "ds4-context-core/manifest/observer";
28
+ import type { PrivacyClassification } from "ds4-context-core/privacy/privacy-policy";
29
+ import type { ManagedContextPlan, PlannedMessageMetadata } from "ds4-context-core/planner/context-planner";
30
+ import { sha256 } from "ds4-context-core/shared/hash";
31
+ import { stableStringify } from "ds4-context-core/shared/stable-json";
32
+ import { snapshotModel, snapshotSession } from "./session-reader.ts";
33
+
34
+ interface SourceCandidate {
35
+ entry: SessionEntry;
36
+ message: unknown;
37
+ fingerprint: string;
38
+ role?: string;
39
+ tokens: number;
40
+ used: boolean;
41
+ }
42
+
43
+ type PiAgentMessage = ContextEvent["messages"][number];
44
+
45
+ export interface BuildPiObserverManifestOptions {
46
+ pi: ExtensionAPI;
47
+ event: ContextEvent;
48
+ ctx: ExtensionContext;
49
+ contextConfig: ContextConfig;
50
+ manifestId: string;
51
+ createdAt: number;
52
+ policyVersion: string;
53
+ plannerVersion: string;
54
+ profile?: ModelProfile;
55
+ budget?: ContextBudget;
56
+ modelAwareness?: ModelAwarenessManifest;
57
+ plan?: ManagedContextPlan<PiAgentMessage>;
58
+ projectRevision?: ProjectRevision;
59
+ pins?: readonly PinManifestRef[];
60
+ memories?: readonly MemoryManifestRef[];
61
+ artifacts?: readonly ArtifactManifestRef[];
62
+ artifactSources?: readonly ArtifactManifestRef[];
63
+ systemPrompt?: string;
64
+ systemClassification?: PrivacyClassification;
65
+ systemPrivacyReason?: string;
66
+ tools?: readonly ObservedTool[];
67
+ messageClassifications?: readonly PrivacyClassification[];
68
+ messagePrivacyReasons?: readonly (string | undefined)[];
69
+ privacyExcludedSources?: readonly ExcludedContextSource[];
70
+ privacy?: PrivacyManifest;
71
+ }
72
+
73
+ function roleOf(message: unknown): string | undefined {
74
+ if (!message || typeof message !== "object" || !("role" in message)) return undefined;
75
+ return typeof message.role === "string" ? message.role : undefined;
76
+ }
77
+
78
+ function toolCallIdOf(message: unknown): string | undefined {
79
+ if (!message || typeof message !== "object" || !("toolCallId" in message)) return undefined;
80
+ return typeof message.toolCallId === "string" ? message.toolCallId : undefined;
81
+ }
82
+
83
+ function sourceKind(entry: SessionEntry, role?: string): ContextManifestItemKind {
84
+ if (entry.type === "compaction" || entry.type === "branch_summary" || role === "compactionSummary" || role === "branchSummary") {
85
+ return "summary";
86
+ }
87
+ return "history";
88
+ }
89
+
90
+ function fingerprint(message: unknown): string {
91
+ return sha256(stableStringify(message));
92
+ }
93
+
94
+ function sourceCandidates(entries: readonly SessionEntry[]): SourceCandidate[] {
95
+ const candidates: SourceCandidate[] = [];
96
+ for (const entry of entries) {
97
+ for (const message of sessionEntryToContextMessages(entry)) {
98
+ candidates.push({
99
+ entry,
100
+ message,
101
+ fingerprint: fingerprint(message),
102
+ ...(roleOf(message) ? { role: roleOf(message) } : {}),
103
+ tokens: estimateMessageTokens(message),
104
+ used: false,
105
+ });
106
+ }
107
+ }
108
+ return candidates;
109
+ }
110
+
111
+ function mapMessageSources(
112
+ messages: readonly unknown[],
113
+ candidates: SourceCandidate[],
114
+ syntheticIndices: ReadonlySet<number> = new Set(),
115
+ ): ObservedMessageSource[] {
116
+ const queues = new Map<string, SourceCandidate[]>();
117
+ for (const candidate of candidates) {
118
+ const queue = queues.get(candidate.fingerprint) ?? [];
119
+ queue.push(candidate);
120
+ queues.set(candidate.fingerprint, queue);
121
+ }
122
+
123
+ return messages.map((message, index) => {
124
+ if (syntheticIndices.has(index)) {
125
+ const role = roleOf(message);
126
+ return {
127
+ ...(role ? { role } : {}),
128
+ mappingReason: "Synthetic DS4 evidence with explicit source provenance",
129
+ };
130
+ }
131
+ const exact = queues.get(fingerprint(message))?.find((candidate) => !candidate.used);
132
+ if (exact) {
133
+ exact.used = true;
134
+ return {
135
+ sourceId: exact.entry.id,
136
+ ...(exact.role ? { role: exact.role } : {}),
137
+ mappingReason: "Exact fingerprint match to Pi session entry",
138
+ };
139
+ }
140
+
141
+ const role = roleOf(message);
142
+ const fallback = candidates.find((candidate) => !candidate.used && candidate.role === role);
143
+ if (fallback) {
144
+ fallback.used = true;
145
+ return {
146
+ sourceId: fallback.entry.id,
147
+ ...(role ? { role } : {}),
148
+ mappingReason: "Role/order match after an earlier context extension transformed the message",
149
+ };
150
+ }
151
+
152
+ return {
153
+ ...(role ? { role } : {}),
154
+ mappingReason: "Transient or extension-injected message without a Pi session entry",
155
+ };
156
+ });
157
+ }
158
+
159
+ function excludedSources(
160
+ branchEntries: readonly SessionEntry[],
161
+ contextEntries: readonly SessionEntry[],
162
+ candidates: readonly SourceCandidate[],
163
+ ): ExcludedContextSource[] {
164
+ const activeIds = new Set(contextEntries.map((entry) => entry.id));
165
+ const excluded: ExcludedContextSource[] = [];
166
+
167
+ for (const entry of branchEntries) {
168
+ if (!activeIds.has(entry.id)) {
169
+ const messages = sessionEntryToContextMessages(entry);
170
+ excluded.push({
171
+ sourceId: entry.id,
172
+ ...(roleOf(messages[0]) ? { role: roleOf(messages[0]) } : {}),
173
+ tokens: messages.reduce((total, message) => total + estimateMessageTokens(message), 0),
174
+ kind: sourceKind(entry, roleOf(messages[0])),
175
+ reason: "Excluded by Pi branch/compaction context reconstruction",
176
+ });
177
+ continue;
178
+ }
179
+
180
+ if (sessionEntryToContextMessages(entry).length === 0) {
181
+ excluded.push({
182
+ sourceId: entry.id,
183
+ tokens: 0,
184
+ kind: sourceKind(entry),
185
+ reason: "Pi session metadata entry does not participate in model context",
186
+ });
187
+ }
188
+ }
189
+
190
+ for (const candidate of candidates) {
191
+ if (candidate.used) continue;
192
+ excluded.push({
193
+ sourceId: candidate.entry.id,
194
+ ...(candidate.role ? { role: candidate.role } : {}),
195
+ tokens: candidate.tokens,
196
+ kind: sourceKind(candidate.entry, candidate.role),
197
+ reason: "Message was removed by an earlier Pi context extension",
198
+ });
199
+ }
200
+
201
+ return excluded;
202
+ }
203
+
204
+ export function activeTools(pi: ExtensionAPI): ObservedTool[] {
205
+ const allTools = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
206
+ return pi.getActiveTools().flatMap((name) => {
207
+ const tool = allTools.get(name);
208
+ if (!tool) return [];
209
+ return [{
210
+ name: tool.name,
211
+ description: tool.description,
212
+ parameters: tool.parameters,
213
+ source: tool.sourceInfo.source,
214
+ }];
215
+ });
216
+ }
217
+
218
+ function pinnedSourceIds(entries: readonly SessionEntry[]): Set<string> {
219
+ const labels = new Map<string, string | undefined>();
220
+ for (const entry of entries) {
221
+ if (entry.type === "label") labels.set(entry.targetId, entry.label);
222
+ }
223
+ return new Set(
224
+ [...labels]
225
+ .filter(([, label]) => label !== undefined && /^ds4:pin(?:\s|$)/iu.test(label.trim()))
226
+ .map(([targetId]) => targetId),
227
+ );
228
+ }
229
+
230
+ export function findExactPiMessageSourceIds(
231
+ messages: readonly unknown[],
232
+ ctx: Pick<ExtensionContext, "sessionManager">,
233
+ ): Array<string | undefined> {
234
+ const candidates = sourceCandidates(ctx.sessionManager.buildContextEntries());
235
+ const queues = new Map<string, SourceCandidate[]>();
236
+ for (const candidate of candidates) {
237
+ const queue = queues.get(candidate.fingerprint) ?? [];
238
+ queue.push(candidate);
239
+ queues.set(candidate.fingerprint, queue);
240
+ }
241
+ return messages.map((message) => {
242
+ const candidate = queues.get(fingerprint(message))?.shift();
243
+ return candidate?.entry.id;
244
+ });
245
+ }
246
+
247
+ export function findPiPinnedMessageIndices(event: ContextEvent, ctx: ExtensionContext): number[] {
248
+ const candidates = sourceCandidates(ctx.sessionManager.buildContextEntries());
249
+ const sources = mapMessageSources(event.messages, candidates);
250
+ const pinned = pinnedSourceIds(ctx.sessionManager.getBranch());
251
+ return sources.flatMap((source, index) => source.sourceId && pinned.has(source.sourceId) ? [index] : []);
252
+ }
253
+
254
+ function applySelection(
255
+ metadata: PlannedMessageMetadata,
256
+ source: ObservedMessageSource | undefined,
257
+ ): ObservedMessageSource {
258
+ return {
259
+ ...(source ?? { mappingReason: "Transient or extension-injected message without a Pi session entry" }),
260
+ ...(metadata.sourceId ? { sourceId: metadata.sourceId } : {}),
261
+ groupId: metadata.groupId,
262
+ kind: metadata.kind,
263
+ score: metadata.score,
264
+ ...(metadata.classification ? { classification: metadata.classification } : {}),
265
+ ...(metadata.privacyReason ? { privacyReason: metadata.privacyReason } : {}),
266
+ selectionReason: metadata.reason,
267
+ };
268
+ }
269
+
270
+ function plannedExclusions(
271
+ plan: ManagedContextPlan<PiAgentMessage>,
272
+ originalSources: readonly ObservedMessageSource[],
273
+ ): ExcludedContextSource[] {
274
+ return plan.excluded.map((metadata) => {
275
+ const source = originalSources[metadata.originalIndex];
276
+ const role = roleOf(plan.originalMessages[metadata.originalIndex]) ?? source?.role;
277
+ return {
278
+ ...(metadata.sourceId
279
+ ? { sourceId: metadata.sourceId }
280
+ : source?.sourceId
281
+ ? { sourceId: source.sourceId }
282
+ : {}),
283
+ ...(role ? { role } : {}),
284
+ groupId: metadata.groupId,
285
+ tokens: metadata.tokens,
286
+ kind: metadata.kind,
287
+ score: metadata.score,
288
+ ...(metadata.classification ? { classification: metadata.classification } : {}),
289
+ reason: [
290
+ `${metadata.reason}; provenance: ${source?.mappingReason ?? "transient message"}`,
291
+ metadata.privacyReason,
292
+ ].filter(Boolean).join("; "),
293
+ };
294
+ });
295
+ }
296
+
297
+ export function buildPiObserverManifest(options: BuildPiObserverManifestOptions): ContextManifest {
298
+ const session = snapshotSession(options.ctx);
299
+ const model = snapshotModel(options.ctx);
300
+ if (!model) throw new Error("Cannot build a Context Manifest without an active Pi model");
301
+
302
+ const profile = options.profile ?? createModelProfile(model);
303
+ const budget = options.budget ?? calculateContextBudget(profile, options.contextConfig);
304
+ const contextEntries = options.ctx.sessionManager.buildContextEntries();
305
+ const candidates = sourceCandidates(contextEntries);
306
+ const originalMessages = options.plan?.originalMessages ?? options.event.messages;
307
+ const syntheticIndices = new Set(
308
+ options.plan
309
+ ? [...options.plan.selected, ...options.plan.excluded]
310
+ .filter((metadata) =>
311
+ (metadata.retrievedEventIds?.length ?? 0) > 0
312
+ || metadata.projectSnippet !== undefined
313
+ || metadata.kind === "pin"
314
+ || metadata.kind === "memory"
315
+ )
316
+ .map((metadata) => metadata.originalIndex)
317
+ : [],
318
+ );
319
+ const originalSources = mapMessageSources(originalMessages, candidates, syntheticIndices);
320
+ originalSources.forEach((source, index) => {
321
+ const classification = options.messageClassifications?.[index];
322
+ const privacyReason = options.messagePrivacyReasons?.[index];
323
+ if (classification) source.classification = classification;
324
+ if (privacyReason) source.privacyReason = privacyReason;
325
+ });
326
+ const artifactByToolCall = new Map(
327
+ (options.artifactSources ?? options.artifacts ?? []).map((artifact) => [artifact.toolCallId, artifact]),
328
+ );
329
+ originalMessages.forEach((message, index) => {
330
+ const artifact = artifactByToolCall.get(toolCallIdOf(message) ?? "");
331
+ if (!artifact) return;
332
+ originalSources[index] = {
333
+ sourceId: artifact.sourceEntryId,
334
+ ...(roleOf(message) ? { role: roleOf(message) } : {}),
335
+ ...(originalSources[index]?.classification
336
+ ? { classification: originalSources[index]?.classification }
337
+ : {}),
338
+ ...(originalSources[index]?.privacyReason
339
+ ? { privacyReason: originalSources[index]?.privacyReason }
340
+ : {}),
341
+ mappingReason: "Exact canonical source recorded by DS4 artifact offload",
342
+ };
343
+ const candidate = candidates.find((item) => item.entry.id === artifact.sourceEntryId);
344
+ if (candidate) candidate.used = true;
345
+ });
346
+ const messageSources = options.plan
347
+ ? options.plan.selected.map((metadata) => applySelection(metadata, originalSources[metadata.originalIndex]))
348
+ : originalSources;
349
+ const baseExcluded = excludedSources(
350
+ options.ctx.sessionManager.getBranch(),
351
+ contextEntries,
352
+ candidates,
353
+ );
354
+ const plannerExcluded = options.plan ? plannedExclusions(options.plan, originalSources) : [];
355
+ const privacyExcluded = [...(options.privacyExcludedSources ?? [])];
356
+ const usage = options.ctx.getContextUsage();
357
+ const summarySourceIds = new Set(
358
+ contextEntries
359
+ .filter((entry) => entry.type === "compaction" || entry.type === "branch_summary")
360
+ .map((entry) => entry.id),
361
+ );
362
+ const selectedSummaryIds = new Set(
363
+ messageSources.flatMap((source) => source.sourceId && summarySourceIds.has(source.sourceId) ? [source.sourceId] : []),
364
+ );
365
+
366
+ return buildObserverManifest({
367
+ id: options.manifestId,
368
+ sessionId: session.sessionId,
369
+ ...(session.leafId ? { branchLeafId: session.leafId } : {}),
370
+ profile,
371
+ budget,
372
+ systemPrompt: options.systemPrompt ?? options.ctx.getSystemPrompt(),
373
+ ...(options.systemClassification ? { systemClassification: options.systemClassification } : {}),
374
+ ...(options.systemPrivacyReason ? { systemPrivacyReason: options.systemPrivacyReason } : {}),
375
+ tools: options.tools ?? activeTools(options.pi),
376
+ messages: options.event.messages,
377
+ messageSources,
378
+ excludedSources: [...baseExcluded, ...plannerExcluded, ...privacyExcluded],
379
+ summaryIds: [...selectedSummaryIds],
380
+ retrievedEventIds: options.plan
381
+ ? options.plan.selected.flatMap((metadata) => metadata.retrievedEventIds ?? [])
382
+ : [],
383
+ projectSnippets: options.plan
384
+ ? options.plan.selected.flatMap((metadata) => metadata.projectSnippet ? [{ ...metadata.projectSnippet }] : [])
385
+ : [],
386
+ ...(options.projectRevision ? { projectRevision: options.projectRevision } : {}),
387
+ pins: options.pins ?? [],
388
+ memories: options.memories ?? [],
389
+ artifacts: options.artifacts ?? [],
390
+ ...(options.privacy ? { privacy: options.privacy } : {}),
391
+ ...(options.modelAwareness ? { modelAwareness: options.modelAwareness } : {}),
392
+ ...(options.plan ? { planning: options.plan.planning } : {}),
393
+ ...(usage?.tokens !== null && usage?.tokens !== undefined
394
+ ? { piReportedContextTokens: usage.tokens }
395
+ : {}),
396
+ policyVersion: options.policyVersion,
397
+ plannerVersion: options.plannerVersion,
398
+ createdAt: options.createdAt,
399
+ });
400
+ }
@@ -0,0 +1,116 @@
1
+ import { estimateTextTokens } from "ds4-context-core/core/token-estimator";
2
+ import { canonicalMessageSearchText, toCanonicalMessage } from "./message-converter.ts";
3
+ import type { JsonlRecord } from "./session-jsonl.ts";
4
+
5
+ export interface PiSessionEntryRecord extends Record<string, unknown> {
6
+ type: string;
7
+ id: string;
8
+ parentId: string | null;
9
+ timestamp: string;
10
+ }
11
+
12
+ export interface IndexedSessionEntry {
13
+ entryKey: string;
14
+ entryId: string;
15
+ sessionId: string;
16
+ parentId: string | null;
17
+ entryType: string;
18
+ role?: string;
19
+ createdAt?: number;
20
+ contentHash: string;
21
+ searchableText: string;
22
+ tokenEstimate: number;
23
+ indexedAt: number;
24
+ }
25
+
26
+ function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return value !== null && typeof value === "object" && !Array.isArray(value);
28
+ }
29
+
30
+ function text(value: unknown): string {
31
+ return typeof value === "string" ? value : "";
32
+ }
33
+
34
+ export function isPiSessionEntryRecord(value: Record<string, unknown>): value is PiSessionEntryRecord {
35
+ return value.type !== "session" &&
36
+ typeof value.type === "string" &&
37
+ typeof value.id === "string" &&
38
+ (typeof value.parentId === "string" || value.parentId === null) &&
39
+ typeof value.timestamp === "string";
40
+ }
41
+
42
+ function searchableText(sessionId: string, entry: PiSessionEntryRecord): { role?: string; value: string; tokens?: number } {
43
+ if (entry.type === "message") {
44
+ const message = entry.message;
45
+ const canonical = toCanonicalMessage({
46
+ sessionId,
47
+ entryId: entry.id,
48
+ entryTimestamp: entry.timestamp,
49
+ message,
50
+ });
51
+ const originalRole = isRecord(message) ? text(message.role) : "";
52
+ return {
53
+ ...(originalRole ? { role: originalRole } : {}),
54
+ value: canonicalMessageSearchText(canonical),
55
+ tokens: canonical.tokenEstimate,
56
+ };
57
+ }
58
+
59
+ if (entry.type === "custom_message") {
60
+ const canonical = toCanonicalMessage({
61
+ sessionId,
62
+ entryId: entry.id,
63
+ entryTimestamp: entry.timestamp,
64
+ message: { role: "custom", content: entry.content },
65
+ });
66
+ return { role: "custom", value: canonicalMessageSearchText(canonical), tokens: canonical.tokenEstimate };
67
+ }
68
+
69
+ switch (entry.type) {
70
+ case "compaction":
71
+ return { role: "summary", value: text(entry.summary) };
72
+ case "branch_summary":
73
+ return { role: "summary", value: [text(entry.fromId), text(entry.summary)].filter(Boolean).join("\n") };
74
+ case "model_change":
75
+ return { value: [text(entry.provider), text(entry.modelId)].filter(Boolean).join("\n") };
76
+ case "thinking_level_change":
77
+ return { value: text(entry.thinkingLevel) };
78
+ case "label":
79
+ return { value: [text(entry.targetId), text(entry.label)].filter(Boolean).join("\n") };
80
+ case "session_info":
81
+ return { value: text(entry.name) };
82
+ // Extension state is intentionally not indexed: it does not participate in
83
+ // model context and may contain private implementation details.
84
+ case "custom":
85
+ default:
86
+ return { value: "" };
87
+ }
88
+ }
89
+
90
+ export function toIndexedSessionEntry(
91
+ sessionId: string,
92
+ record: JsonlRecord,
93
+ indexedAt: number,
94
+ ): IndexedSessionEntry {
95
+ if (!isPiSessionEntryRecord(record.value)) {
96
+ throw new Error(`Invalid Pi session entry at byte ${record.startOffset}`);
97
+ }
98
+
99
+ const extracted = searchableText(sessionId, record.value);
100
+ const parsedTimestamp = Date.parse(record.value.timestamp);
101
+ const createdAt = Number.isNaN(parsedTimestamp) ? undefined : parsedTimestamp;
102
+
103
+ return {
104
+ entryKey: `${sessionId}:${record.value.id}`,
105
+ entryId: record.value.id,
106
+ sessionId,
107
+ parentId: record.value.parentId,
108
+ entryType: record.value.type,
109
+ ...(extracted.role ? { role: extracted.role } : {}),
110
+ ...(createdAt !== undefined ? { createdAt } : {}),
111
+ contentHash: record.rawHash,
112
+ searchableText: extracted.value,
113
+ tokenEstimate: extracted.tokens ?? estimateTextTokens(extracted.value),
114
+ indexedAt,
115
+ };
116
+ }
@@ -0,0 +1,113 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { readJsonlRecords } from "./session-jsonl.ts";
3
+ import {
4
+ MEMORY_CUSTOM_ENTRY_TYPE,
5
+ PIN_CUSTOM_ENTRY_TYPE,
6
+ parseMemoryMutation,
7
+ parsePinMutation,
8
+ type MemoryMutation,
9
+ type PinMutation,
10
+ type SessionMutationProjection,
11
+ type StoredMemoryMutation,
12
+ type StoredPinMutation,
13
+ } from "ds4-context-core/memory/memory-types";
14
+
15
+ export type { SessionMutationProjection } from "ds4-context-core/memory/memory-types";
16
+
17
+ function normalizedTimestamp(entry: SessionEntry, fallback: number): number {
18
+ const parsed = Date.parse(entry.timestamp);
19
+ return Number.isNaN(parsed) ? fallback : parsed;
20
+ }
21
+
22
+ function normalizeMemoryTimestamp(mutation: MemoryMutation, createdAt: number): MemoryMutation {
23
+ if (mutation.operation === "add" || mutation.operation === "supersede") {
24
+ return { ...mutation, createdAt, item: { ...mutation.item, createdAt } };
25
+ }
26
+ return { ...mutation, createdAt };
27
+ }
28
+
29
+ function normalizePinTimestamp(mutation: PinMutation, createdAt: number): PinMutation {
30
+ if (mutation.operation === "add" || mutation.operation === "supersede") {
31
+ return { ...mutation, createdAt, item: { ...mutation.item, createdAt } };
32
+ }
33
+ return { ...mutation, createdAt };
34
+ }
35
+
36
+ export function projectSessionFileMutations(
37
+ sessionFile: string,
38
+ sessionId: string,
39
+ ): SessionMutationProjection {
40
+ const entries = readJsonlRecords(sessionFile).records.flatMap((record) => {
41
+ const value = record.value;
42
+ if (value.type !== "custom"
43
+ || typeof value.id !== "string"
44
+ || (typeof value.parentId !== "string" && value.parentId !== null)
45
+ || typeof value.timestamp !== "string"
46
+ || typeof value.customType !== "string") {
47
+ return [];
48
+ }
49
+ return [value as unknown as SessionEntry];
50
+ });
51
+ return projectSessionMutations(entries, sessionId);
52
+ }
53
+
54
+ export function projectSessionMutations(
55
+ entries: readonly SessionEntry[],
56
+ sessionId: string,
57
+ ): SessionMutationProjection {
58
+ const memoryMutations: StoredMemoryMutation[] = [];
59
+ const pinMutations: StoredPinMutation[] = [];
60
+ const warnings: string[] = [];
61
+ const memoryMutationIds = new Set<string>();
62
+ const pinMutationIds = new Set<string>();
63
+
64
+ for (let entryOrder = 0; entryOrder < entries.length; entryOrder++) {
65
+ const entry = entries[entryOrder];
66
+ if (!entry || entry.type !== "custom") continue;
67
+ if (entry.customType === MEMORY_CUSTOM_ENTRY_TYPE) {
68
+ const parsed = parseMemoryMutation(entry.data);
69
+ if (!parsed) {
70
+ warnings.push(`Ignored malformed memory custom entry ${entry.id}`);
71
+ continue;
72
+ }
73
+ if (memoryMutationIds.has(parsed.mutationId)) {
74
+ warnings.push(`Ignored duplicate memory mutation ${parsed.mutationId} at ${entry.id}`);
75
+ continue;
76
+ }
77
+ memoryMutationIds.add(parsed.mutationId);
78
+ const createdAt = normalizedTimestamp(entry, parsed.createdAt);
79
+ memoryMutations.push({
80
+ mutationKey: `${sessionId}:${entry.id}`,
81
+ mutationId: parsed.mutationId,
82
+ sessionId,
83
+ createdAt,
84
+ entryOrder,
85
+ payload: normalizeMemoryTimestamp(parsed, createdAt),
86
+ });
87
+ continue;
88
+ }
89
+ if (entry.customType === PIN_CUSTOM_ENTRY_TYPE) {
90
+ const parsed = parsePinMutation(entry.data);
91
+ if (!parsed) {
92
+ warnings.push(`Ignored malformed pin custom entry ${entry.id}`);
93
+ continue;
94
+ }
95
+ if (pinMutationIds.has(parsed.mutationId)) {
96
+ warnings.push(`Ignored duplicate pin mutation ${parsed.mutationId} at ${entry.id}`);
97
+ continue;
98
+ }
99
+ pinMutationIds.add(parsed.mutationId);
100
+ const createdAt = normalizedTimestamp(entry, parsed.createdAt);
101
+ pinMutations.push({
102
+ mutationKey: `${sessionId}:${entry.id}`,
103
+ mutationId: parsed.mutationId,
104
+ sessionId,
105
+ createdAt,
106
+ entryOrder,
107
+ payload: normalizePinTimestamp(parsed, createdAt),
108
+ });
109
+ }
110
+ }
111
+
112
+ return { memoryMutations, pinMutations, warnings };
113
+ }