ds4-context-engine 0.2.0 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1948 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import {
3
+ defineTool,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ type ToolDefinition,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { MemoryConflictError } from "ds4-context-core/memory/memory-manager";
9
+ import {
10
+ MEMORY_CUSTOM_ENTRY_TYPE,
11
+ PIN_CUSTOM_ENTRY_TYPE,
12
+ type MemoryItem,
13
+ type PinItem,
14
+ type ProjectMemorySource,
15
+ } from "ds4-context-core/memory/memory-types";
16
+ import type { PrivacyClassification } from "ds4-context-core/privacy/privacy-policy";
17
+ import { stableStringify } from "ds4-context-core/shared/stable-json";
18
+ import {
19
+ CONTEXT_PERSISTENCE_DESCRIPTION,
20
+ CONTEXT_PERSISTENCE_PARAMS,
21
+ CONTEXT_PERSISTENCE_PROMPT_GUIDELINES,
22
+ CONTEXT_PERSISTENCE_PROMPT_SNIPPET,
23
+ CONTEXT_PERSISTENCE_TOOL_NAME,
24
+ isReadAction,
25
+ validateContextPersistenceParams,
26
+ type ContextPersistenceAction,
27
+ type ContextPersistenceParams,
28
+ } from "./context-persistence-contract.ts";
29
+ import {
30
+ buildFailureResult,
31
+ buildMutationResult,
32
+ buildReadResult,
33
+ type ContextPersistenceDetails,
34
+ type ContextPersistenceReadItem,
35
+ type MatchKind,
36
+ type MemoryReadItem,
37
+ type PinReadItem,
38
+ type SourceReadItem,
39
+ } from "./context-persistence-result.ts";
40
+
41
+ const FIND_PAGE_SIZE = 128;
42
+ const FIND_SCAN_CAP = 4_096;
43
+ const BODY_CEILING = 20_000;
44
+ const PREVIEW_SCALARS = 160;
45
+ const SOURCE_REF_TTL_MS = 15 * 60 * 1_000;
46
+ const SOURCE_REF_CAP = 1_024;
47
+ const REVISION_TTL_MS = 15 * 60 * 1_000;
48
+ const REVISION_CAP = 4_096;
49
+ const SAFE_ITEM_ID = /^[A-Za-z0-9._:-]{1,128}$/u;
50
+
51
+ export interface ContextPersistenceRuntimeState {
52
+ available: boolean;
53
+ errorCode?: "memory-unavailable" | "runtime-unavailable";
54
+ phase: string;
55
+ sessionId: string;
56
+ projectIdentity: string;
57
+ projectTrusted: boolean;
58
+ crossSessionEnabled: boolean;
59
+ crossSessionReady: boolean;
60
+ defaultClassification: PrivacyClassification;
61
+ maxResults: number;
62
+ maxPinChars: number;
63
+ maxClaimChars: number;
64
+ provider: string;
65
+ }
66
+
67
+ export interface ContextPersistenceSanitization {
68
+ value: string;
69
+ classification: PrivacyClassification;
70
+ allowed: boolean;
71
+ }
72
+
73
+ export interface ContextPersistenceMutationPolicy {
74
+ classification: PrivacyClassification;
75
+ storedClassification?: PrivacyClassification;
76
+ allowed: boolean;
77
+ secretDetected: boolean;
78
+ markerDetected: boolean;
79
+ }
80
+
81
+ export interface ContextPersistenceProvenance {
82
+ sessionId: string;
83
+ leafId?: string;
84
+ branchEntryIds: string[];
85
+ primarySourceEntryId: string;
86
+ }
87
+
88
+ export type ContextPersistenceAppender = (customType: string, data: unknown) => void;
89
+
90
+ export interface ContextPersistenceOutcomeRecord {
91
+ action: ContextPersistenceAction;
92
+ outcome: ContextPersistenceDetails["outcome"];
93
+ persistenceClass: ContextPersistenceDetails["persistenceClass"];
94
+ scope?: "session" | "branch" | "project";
95
+ itemId?: string;
96
+ classification?: PrivacyClassification;
97
+ resultCount?: number;
98
+ duplicate?: boolean;
99
+ errorCode?: string;
100
+ }
101
+
102
+ export interface ContextPersistencePage<T> {
103
+ items: T[];
104
+ hasMore: boolean;
105
+ }
106
+
107
+ export interface ContextPersistenceBoundedRead<T> {
108
+ items: T[];
109
+ incomplete: boolean;
110
+ aborted: boolean;
111
+ }
112
+
113
+ export interface ContextPersistenceRuntimePort {
114
+ contextPersistenceState(ctx: ExtensionContext): ContextPersistenceRuntimeState;
115
+ contextPersistenceSanitizeText(
116
+ text: string,
117
+ classification: PrivacyClassification | undefined,
118
+ provider: string,
119
+ ): ContextPersistenceSanitization;
120
+ contextPersistenceMutationPolicy(
121
+ text: string,
122
+ requested: PrivacyClassification | undefined,
123
+ inherited: PrivacyClassification | undefined,
124
+ provider: string,
125
+ ): ContextPersistenceMutationPolicy;
126
+ contextPersistenceResolveProvenance(
127
+ ctx: ExtensionContext,
128
+ toolCallId: string,
129
+ ): ContextPersistenceProvenance | undefined;
130
+ contextPersistenceValidateProvenance(
131
+ ctx: ExtensionContext,
132
+ provenance: ContextPersistenceProvenance,
133
+ ): boolean;
134
+ contextPersistenceResolvePin(id: string, activeOnly: boolean): PinItem | undefined;
135
+ contextPersistenceResolveMemory(id: string, activeOnly: boolean): MemoryItem | undefined;
136
+ contextPersistenceListPinsPage(
137
+ ctx: ExtensionContext,
138
+ activeOnly: boolean,
139
+ limit: number,
140
+ ): ContextPersistencePage<PinItem>;
141
+ contextPersistenceListMemoriesPage(
142
+ activeOnly: boolean,
143
+ limit: number,
144
+ ): ContextPersistencePage<MemoryItem>;
145
+ contextPersistenceScanPins(
146
+ activeOnly: boolean,
147
+ pageSize: number,
148
+ scanCap: number,
149
+ signal?: AbortSignal,
150
+ ): ContextPersistenceBoundedRead<PinItem>;
151
+ contextPersistenceScanMemories(
152
+ activeOnly: boolean,
153
+ pageSize: number,
154
+ scanCap: number,
155
+ signal?: AbortSignal,
156
+ ): ContextPersistenceBoundedRead<MemoryItem>;
157
+ contextPersistenceProjectSourcesPage(
158
+ limit: number,
159
+ ): ContextPersistencePage<ProjectMemorySource>;
160
+ contextPersistenceResolveProjectSource(sessionId: string): ProjectMemorySource | undefined;
161
+ contextPersistenceSetProjectSourceExcluded(
162
+ sessionId: string,
163
+ excluded: boolean,
164
+ reason?: string,
165
+ ): ProjectMemorySource | undefined;
166
+ contextPersistenceCreatePin(
167
+ input: {
168
+ content: string;
169
+ scope: "session" | "branch" | "project";
170
+ sourceEntryId: string;
171
+ supersedes?: string;
172
+ classification?: PrivacyClassification;
173
+ },
174
+ ctx: ExtensionContext,
175
+ appendEntry: ContextPersistenceAppender,
176
+ ): { pin: PinItem; duplicate: boolean };
177
+ contextPersistenceUnpin(
178
+ id: string,
179
+ reason: string | undefined,
180
+ ctx: ExtensionContext,
181
+ appendEntry: ContextPersistenceAppender,
182
+ ): PinItem;
183
+ contextPersistenceCreateMemory(
184
+ input: {
185
+ claim: string;
186
+ scope: "session" | "project";
187
+ key?: string;
188
+ classification?: PrivacyClassification;
189
+ sourceEntryIds: string[];
190
+ },
191
+ ctx: ExtensionContext,
192
+ appendEntry: ContextPersistenceAppender,
193
+ ): { memory: MemoryItem; duplicate: boolean };
194
+ contextPersistenceSupersedeMemory(
195
+ id: string,
196
+ claim: string,
197
+ sourceEntryIds: string[],
198
+ classification: PrivacyClassification | undefined,
199
+ ctx: ExtensionContext,
200
+ appendEntry: ContextPersistenceAppender,
201
+ ): MemoryItem;
202
+ contextPersistenceSetMemoryStatus(
203
+ id: string,
204
+ status: "invalid" | "expired",
205
+ reason: string | undefined,
206
+ ctx: ExtensionContext,
207
+ appendEntry: ContextPersistenceAppender,
208
+ ): MemoryItem;
209
+ contextPersistenceRecordOutcome(record: ContextPersistenceOutcomeRecord): void;
210
+ }
211
+
212
+ export interface ContextPersistenceToolDependencies {
213
+ now?: () => number;
214
+ processSecret?: Uint8Array;
215
+ randomBytes?: (size: number) => Uint8Array;
216
+ appendEntry?: ContextPersistenceAppender;
217
+ }
218
+
219
+ interface SourceHandle {
220
+ sourceRef: string;
221
+ projectIdentity: string;
222
+ sessionId: string;
223
+ emittedAt: number;
224
+ lastAccessAt: number;
225
+ }
226
+
227
+ interface BranchRevisionHandle {
228
+ entryIds: string[];
229
+ emittedAt: number;
230
+ }
231
+
232
+ interface TargetRevisionHandle {
233
+ kind: "pin" | "memory";
234
+ id: string;
235
+ sessionId: string;
236
+ projectIdentity: string;
237
+ branchRevision: string;
238
+ fingerprint: string;
239
+ emittedAt: number;
240
+ }
241
+
242
+ interface SourceRevisionHandle {
243
+ sourceRef: string;
244
+ sourceSessionId: string;
245
+ sessionId: string;
246
+ projectIdentity: string;
247
+ fingerprint: string;
248
+ emittedAt: number;
249
+ }
250
+
251
+ interface ScoredPin {
252
+ item: PinItem;
253
+ applicable: boolean;
254
+ matchKind: MatchKind;
255
+ score: number;
256
+ preview?: string;
257
+ previewStatus: "included" | "omitted-by-policy";
258
+ }
259
+
260
+ interface ScoredMemory {
261
+ item: MemoryItem;
262
+ matchKind: MatchKind;
263
+ score: number;
264
+ preview?: string;
265
+ previewStatus: "included" | "omitted-by-policy";
266
+ }
267
+
268
+ function compareAscii(left: string, right: string): number {
269
+ return left < right ? -1 : left > right ? 1 : 0;
270
+ }
271
+
272
+ function normalizeSearch(value: string): string {
273
+ return value.normalize("NFKC").trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
274
+ }
275
+
276
+ function terms(value: string): string[] {
277
+ return value.match(/[\p{L}\p{N}]+/gu) ?? [];
278
+ }
279
+
280
+ function classification(
281
+ stored: PrivacyClassification | undefined,
282
+ state: ContextPersistenceRuntimeState,
283
+ ): PrivacyClassification {
284
+ return stored ?? state.defaultClassification;
285
+ }
286
+
287
+ const CLASSIFICATION_RANK: Record<PrivacyClassification, number> = {
288
+ normal: 0,
289
+ internal: 1,
290
+ sensitive: 2,
291
+ "local-only": 3,
292
+ };
293
+
294
+ function classificationDowngrade(
295
+ requested: PrivacyClassification | undefined,
296
+ inherited: PrivacyClassification,
297
+ ): boolean {
298
+ return requested !== undefined
299
+ && CLASSIFICATION_RANK[requested] < CLASSIFICATION_RANK[inherited];
300
+ }
301
+
302
+ function replacementStoredClassification(
303
+ requested: PrivacyClassification | undefined,
304
+ inheritedStored: PrivacyClassification | undefined,
305
+ inheritedEffective: PrivacyClassification,
306
+ policy: ContextPersistenceMutationPolicy,
307
+ ): PrivacyClassification | undefined {
308
+ if (CLASSIFICATION_RANK[policy.classification] > CLASSIFICATION_RANK[inheritedEffective]) {
309
+ return policy.classification;
310
+ }
311
+ return requested === undefined ? inheritedStored : policy.classification;
312
+ }
313
+
314
+ function activeFirst(status: string): number {
315
+ return status === "active" ? 0 : 1;
316
+ }
317
+
318
+ function pinApplicable(item: PinItem, branchIds: ReadonlySet<string>): boolean {
319
+ return item.scope !== "branch" || (Boolean(item.branchLeafId) && branchIds.has(item.branchLeafId ?? ""));
320
+ }
321
+
322
+ function scoredPinOrder(left: ScoredPin, right: ScoredPin): number {
323
+ return right.score - left.score
324
+ || activeFirst(left.item.status) - activeFirst(right.item.status)
325
+ || Number(right.applicable) - Number(left.applicable)
326
+ || right.item.updatedAt - left.item.updatedAt
327
+ || compareAscii(left.item.id, right.item.id);
328
+ }
329
+
330
+ function scoredMemoryOrder(left: ScoredMemory, right: ScoredMemory): number {
331
+ return right.score - left.score
332
+ || activeFirst(left.item.status) - activeFirst(right.item.status)
333
+ || right.item.updatedAt - left.item.updatedAt
334
+ || compareAscii(left.item.id, right.item.id);
335
+ }
336
+
337
+ function matchText(
338
+ query: string,
339
+ queryTerms: readonly string[],
340
+ body: string,
341
+ key?: string,
342
+ ): { matchKind: MatchKind; baseScore: number } | undefined {
343
+ if (key && normalizeSearch(key) === query) return { matchKind: "exact-key", baseScore: 100 };
344
+ const normalizedBody = normalizeSearch(body);
345
+ if (normalizedBody.includes(query)) return { matchKind: "exact-phrase", baseScore: 80 };
346
+ const bodyTerms = new Set(terms(normalizedBody));
347
+ if (queryTerms.length > 0 && queryTerms.every((term) => bodyTerms.has(term))) {
348
+ return { matchKind: "all-terms", baseScore: 60 };
349
+ }
350
+ if (queryTerms.some((term) => bodyTerms.has(term))) {
351
+ return { matchKind: "partial-terms", baseScore: 30 };
352
+ }
353
+ return undefined;
354
+ }
355
+
356
+ function opaqueId(value: string): boolean {
357
+ return SAFE_ITEM_ID.test(value);
358
+ }
359
+
360
+ type CanonicalCommitState = "not-invoked" | "invoking" | "committed" | "materialized";
361
+
362
+ class TrackedCanonicalAppender {
363
+ state: CanonicalCommitState = "not-invoked";
364
+ itemId?: string;
365
+ mutationType?: "pin" | "memory";
366
+
367
+ constructor(private readonly appendEntry: ContextPersistenceAppender) {}
368
+
369
+ append = (customType: string, data: unknown): void => {
370
+ if (this.state !== "not-invoked") throw new Error("context_persistence append invariant failed");
371
+ if (customType !== PIN_CUSTOM_ENTRY_TYPE && customType !== MEMORY_CUSTOM_ENTRY_TYPE) {
372
+ throw new Error("context_persistence append invariant failed");
373
+ }
374
+ this.mutationType = customType === PIN_CUSTOM_ENTRY_TYPE ? "pin" : "memory";
375
+ if (data && typeof data === "object") {
376
+ const mutation = data as Record<string, unknown>;
377
+ const item = mutation.item && typeof mutation.item === "object"
378
+ ? mutation.item as Record<string, unknown>
379
+ : undefined;
380
+ const candidate = item?.id ?? mutation.pinId ?? mutation.memoryId;
381
+ if (typeof candidate === "string" && opaqueId(candidate)) this.itemId = candidate;
382
+ }
383
+ this.state = "invoking";
384
+ this.appendEntry(customType, data);
385
+ this.state = "committed";
386
+ };
387
+
388
+ materialized(): void {
389
+ if (this.state === "committed") this.state = "materialized";
390
+ }
391
+ }
392
+
393
+ class ContextPersistenceToolController {
394
+ private readonly now: () => number;
395
+ private readonly processSecret: Uint8Array;
396
+ private readonly bytes: (size: number) => Uint8Array;
397
+ private readonly appendEntry?: ContextPersistenceAppender;
398
+ private readonly sourceByKey = new Map<string, SourceHandle>();
399
+ private readonly sourceByRef = new Map<string, SourceHandle>();
400
+ private readonly branchRevisions = new Map<string, BranchRevisionHandle>();
401
+ private readonly targetRevisions = new Map<string, TargetRevisionHandle>();
402
+ private readonly sourceRevisions = new Map<string, SourceRevisionHandle>();
403
+
404
+ constructor(
405
+ private readonly runtime: ContextPersistenceRuntimePort,
406
+ dependencies: ContextPersistenceToolDependencies = {},
407
+ ) {
408
+ this.now = dependencies.now ?? Date.now;
409
+ this.processSecret = dependencies.processSecret ?? randomBytes(32);
410
+ this.bytes = dependencies.randomBytes ?? randomBytes;
411
+ this.appendEntry = dependencies.appendEntry;
412
+ }
413
+
414
+ definition(): ToolDefinition<typeof CONTEXT_PERSISTENCE_PARAMS, ContextPersistenceDetails> {
415
+ return defineTool({
416
+ name: CONTEXT_PERSISTENCE_TOOL_NAME,
417
+ label: "DS4 Context Persistence",
418
+ description: CONTEXT_PERSISTENCE_DESCRIPTION,
419
+ promptSnippet: CONTEXT_PERSISTENCE_PROMPT_SNIPPET,
420
+ promptGuidelines: [...CONTEXT_PERSISTENCE_PROMPT_GUIDELINES],
421
+ parameters: CONTEXT_PERSISTENCE_PARAMS,
422
+ executionMode: "sequential",
423
+ execute: async (toolCallId, params, signal, _onUpdate, ctx) => {
424
+ try {
425
+ const result = await this.execute(toolCallId, params, signal, ctx);
426
+ this.recordOutcome(result.details);
427
+ return result;
428
+ } catch {
429
+ try {
430
+ this.runtime.contextPersistenceRecordOutcome({
431
+ action: params.action,
432
+ outcome: "unavailable",
433
+ persistenceClass: isReadAction(params.action)
434
+ ? "read-only"
435
+ : this.persistenceClass(params.action),
436
+ errorCode: "boundary-failure",
437
+ });
438
+ } catch {
439
+ // Logging must not change the fail-closed boundary.
440
+ }
441
+ throw new Error("context_persistence failed safely");
442
+ }
443
+ },
444
+ });
445
+ }
446
+
447
+ private async execute(
448
+ toolCallId: string,
449
+ params: ContextPersistenceParams,
450
+ signal: AbortSignal | undefined,
451
+ ctx: ExtensionContext,
452
+ ): Promise<
453
+ | ReturnType<typeof buildReadResult>
454
+ | ReturnType<typeof buildFailureResult>
455
+ | ReturnType<typeof buildMutationResult>
456
+ > {
457
+ const persistenceClass = isReadAction(params.action)
458
+ ? "read-only"
459
+ : this.persistenceClass(params.action);
460
+ if (signal?.aborted) {
461
+ return buildFailureResult(params.action, "cancelled", "aborted", persistenceClass);
462
+ }
463
+ const validated = validateContextPersistenceParams(params);
464
+ if (!validated.ok) {
465
+ return buildFailureResult(
466
+ params.action,
467
+ "rejected",
468
+ validated.errorCode,
469
+ persistenceClass,
470
+ );
471
+ }
472
+ const state = this.runtime.contextPersistenceState(ctx);
473
+ if (!state.available) {
474
+ return buildFailureResult(
475
+ params.action,
476
+ "unavailable",
477
+ state.errorCode ?? "runtime-unavailable",
478
+ persistenceClass,
479
+ );
480
+ }
481
+ if (signal?.aborted) {
482
+ return buildFailureResult(params.action, "cancelled", "aborted", persistenceClass);
483
+ }
484
+ if (!isReadAction(params.action)) {
485
+ return this.executeWrite(toolCallId, params, state, signal, ctx);
486
+ }
487
+ const maxResults = Math.min(params.maxResults ?? state.maxResults, state.maxResults, 100);
488
+ const activeOnly = params.activeOnly ?? true;
489
+ switch (params.action) {
490
+ case "pins_list":
491
+ return this.listPins(state, ctx, activeOnly, maxResults);
492
+ case "memory_list":
493
+ return this.listMemories(state, ctx, activeOnly, maxResults);
494
+ case "pins_find":
495
+ return this.findPins(state, ctx, params.query ?? "", activeOnly, maxResults, signal);
496
+ case "memory_find":
497
+ return this.findMemories(state, ctx, params.query ?? "", activeOnly, maxResults, signal);
498
+ case "memory_sources":
499
+ return this.listSources(state, maxResults);
500
+ }
501
+ }
502
+
503
+ private async executeWrite(
504
+ toolCallId: string,
505
+ params: ContextPersistenceParams,
506
+ state: ContextPersistenceRuntimeState,
507
+ signal: AbortSignal | undefined,
508
+ ctx: ExtensionContext,
509
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
510
+ const persistenceClass = this.persistenceClass(params.action);
511
+ if (signal?.aborted) return buildFailureResult(params.action, "cancelled", "aborted", persistenceClass);
512
+ if (!ctx.hasUI) {
513
+ return buildFailureResult(params.action, "unavailable", "confirmation-required", persistenceClass);
514
+ }
515
+ if (params.action === "memory_source_exclude" || params.action === "memory_source_include") {
516
+ return this.setSourcePolicy(params.action, params, state, signal, ctx);
517
+ }
518
+ if (!this.appendEntry) {
519
+ return buildFailureResult(params.action, "unavailable", "action-unavailable", persistenceClass);
520
+ }
521
+ switch (params.action) {
522
+ case "pin_add":
523
+ return this.addPin(toolCallId, params, state, signal, ctx);
524
+ case "pin_supersede":
525
+ return this.supersedePin(toolCallId, params, state, signal, ctx);
526
+ case "pin_unpin":
527
+ return this.unpin(params, state, signal, ctx);
528
+ case "memory_add":
529
+ return this.addMemory(toolCallId, params, state, signal, ctx);
530
+ case "memory_supersede":
531
+ return this.supersedeMemory(toolCallId, params, state, signal, ctx);
532
+ case "memory_invalidate":
533
+ return this.setMemoryStatus("memory_invalidate", "invalid", params, state, signal, ctx);
534
+ case "memory_expire":
535
+ return this.setMemoryStatus("memory_expire", "expired", params, state, signal, ctx);
536
+ default:
537
+ return buildFailureResult(params.action, "unavailable", "action-unavailable", persistenceClass);
538
+ }
539
+ }
540
+
541
+ private async addPin(
542
+ toolCallId: string,
543
+ params: ContextPersistenceParams,
544
+ state: ContextPersistenceRuntimeState,
545
+ signal: AbortSignal | undefined,
546
+ ctx: ExtensionContext,
547
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
548
+ const content = params.content ?? "";
549
+ const scope = params.scope ?? "session";
550
+ if (content.length > state.maxPinChars || content.length > BODY_CEILING) {
551
+ return buildFailureResult("pin_add", "rejected", "content-too-long", "canonical-jsonl");
552
+ }
553
+ if (scope === "project" && !state.projectTrusted) {
554
+ return buildFailureResult("pin_add", "rejected", "project-untrusted", "canonical-jsonl");
555
+ }
556
+ const policy = this.runtime.contextPersistenceMutationPolicy(
557
+ content,
558
+ params.classification,
559
+ undefined,
560
+ state.provider,
561
+ );
562
+ if (!policy.allowed) {
563
+ return buildFailureResult("pin_add", "rejected", "provider-policy-denied", "canonical-jsonl");
564
+ }
565
+ const provenance = this.runtime.contextPersistenceResolveProvenance(ctx, toolCallId);
566
+ if (!provenance) {
567
+ return buildFailureResult("pin_add", "rejected", "provenance-unavailable", "canonical-jsonl");
568
+ }
569
+ const confirmed = await this.confirm(
570
+ ctx,
571
+ "pin_add",
572
+ [
573
+ "Persistence: canonical Pi JSONL append",
574
+ `Scope: ${scope}`,
575
+ `Classification: ${policy.classification}`,
576
+ "",
577
+ content,
578
+ "",
579
+ "This appends history; it does not physically delete prior entries.",
580
+ ].join("\n"),
581
+ );
582
+ if (!confirmed) return buildFailureResult("pin_add", "cancelled", undefined, "canonical-jsonl");
583
+ if (signal?.aborted) return buildFailureResult("pin_add", "cancelled", "aborted", "canonical-jsonl");
584
+ if (!this.runtime.contextPersistenceValidateProvenance(ctx, provenance)) {
585
+ return buildFailureResult("pin_add", "rejected", "provenance-unavailable", "canonical-jsonl");
586
+ }
587
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
588
+ if (!refreshedState.available) {
589
+ return buildFailureResult(
590
+ "pin_add",
591
+ "unavailable",
592
+ refreshedState.errorCode ?? "runtime-unavailable",
593
+ "canonical-jsonl",
594
+ );
595
+ }
596
+ if (scope === "project" && !refreshedState.projectTrusted) {
597
+ return buildFailureResult("pin_add", "rejected", "project-untrusted", "canonical-jsonl");
598
+ }
599
+ const refreshedPolicy = this.runtime.contextPersistenceMutationPolicy(
600
+ content,
601
+ params.classification,
602
+ undefined,
603
+ refreshedState.provider,
604
+ );
605
+ if (!refreshedPolicy.allowed) {
606
+ return buildFailureResult("pin_add", "rejected", "provider-policy-denied", "canonical-jsonl");
607
+ }
608
+ return this.commitPin("pin_add", scope, ctx, (appendEntry) =>
609
+ this.runtime.contextPersistenceCreatePin({
610
+ content,
611
+ scope,
612
+ sourceEntryId: provenance.primarySourceEntryId,
613
+ ...(refreshedPolicy.storedClassification
614
+ ? { classification: refreshedPolicy.storedClassification }
615
+ : {}),
616
+ }, ctx, appendEntry));
617
+ }
618
+
619
+ private async supersedePin(
620
+ toolCallId: string,
621
+ params: ContextPersistenceParams,
622
+ state: ContextPersistenceRuntimeState,
623
+ signal: AbortSignal | undefined,
624
+ ctx: ExtensionContext,
625
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
626
+ const id = params.id ?? "";
627
+ const content = params.content ?? "";
628
+ if (content.length > state.maxPinChars || content.length > BODY_CEILING) {
629
+ return buildFailureResult("pin_supersede", "rejected", "content-too-long", "canonical-jsonl");
630
+ }
631
+ const target = this.runtime.contextPersistenceResolvePin(id, true);
632
+ if (!target) {
633
+ return buildFailureResult("pin_supersede", "rejected", "target-not-active", "canonical-jsonl");
634
+ }
635
+ if (!this.pinRevisionMatches(target, params.targetRevision, state, ctx)) {
636
+ return buildFailureResult("pin_supersede", "rejected", "stale-target", "canonical-jsonl");
637
+ }
638
+ if (target.scope === "project" && !state.projectTrusted) {
639
+ return buildFailureResult("pin_supersede", "rejected", "project-untrusted", "canonical-jsonl");
640
+ }
641
+ const inheritedClassification = classification(target.classification, state);
642
+ if (classificationDowngrade(params.classification, inheritedClassification)) {
643
+ return buildFailureResult("pin_supersede", "rejected", "invalid-classification", "canonical-jsonl");
644
+ }
645
+ const policy = this.runtime.contextPersistenceMutationPolicy(
646
+ content,
647
+ params.classification,
648
+ inheritedClassification,
649
+ state.provider,
650
+ );
651
+ if (!policy.allowed) {
652
+ return buildFailureResult("pin_supersede", "rejected", "provider-policy-denied", "canonical-jsonl");
653
+ }
654
+ const provenance = this.runtime.contextPersistenceResolveProvenance(ctx, toolCallId);
655
+ if (!provenance) {
656
+ return buildFailureResult("pin_supersede", "rejected", "provenance-unavailable", "canonical-jsonl");
657
+ }
658
+ const confirmed = await this.confirm(
659
+ ctx,
660
+ "pin_supersede",
661
+ [
662
+ "Persistence: canonical Pi JSONL append",
663
+ `Scope: ${target.scope}`,
664
+ `Target: ${target.id}`,
665
+ `Revision: ${(params.targetRevision ?? "").slice(0, 12)}…`,
666
+ `Classification: ${policy.classification}`,
667
+ "",
668
+ content,
669
+ "",
670
+ "This appends a supersession; it does not physically delete prior entries.",
671
+ ].join("\n"),
672
+ );
673
+ if (!confirmed) return buildFailureResult("pin_supersede", "cancelled", undefined, "canonical-jsonl");
674
+ if (signal?.aborted) return buildFailureResult("pin_supersede", "cancelled", "aborted", "canonical-jsonl");
675
+ if (!this.runtime.contextPersistenceValidateProvenance(ctx, provenance)) {
676
+ return buildFailureResult("pin_supersede", "rejected", "provenance-unavailable", "canonical-jsonl");
677
+ }
678
+ const refreshed = this.runtime.contextPersistenceResolvePin(id, true);
679
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
680
+ if (!refreshedState.available) {
681
+ return buildFailureResult(
682
+ "pin_supersede",
683
+ "unavailable",
684
+ refreshedState.errorCode ?? "runtime-unavailable",
685
+ "canonical-jsonl",
686
+ );
687
+ }
688
+ if (!refreshed || !this.pinRevisionMatches(refreshed, params.targetRevision, refreshedState, ctx)) {
689
+ return buildFailureResult("pin_supersede", "rejected", "stale-target", "canonical-jsonl");
690
+ }
691
+ if (refreshed.scope === "project" && !refreshedState.projectTrusted) {
692
+ return buildFailureResult("pin_supersede", "rejected", "project-untrusted", "canonical-jsonl");
693
+ }
694
+ const refreshedInherited = classification(refreshed.classification, refreshedState);
695
+ const refreshedPolicy = this.runtime.contextPersistenceMutationPolicy(
696
+ content,
697
+ params.classification,
698
+ refreshedInherited,
699
+ refreshedState.provider,
700
+ );
701
+ if (!refreshedPolicy.allowed) {
702
+ return buildFailureResult("pin_supersede", "rejected", "provider-policy-denied", "canonical-jsonl");
703
+ }
704
+ const storedClassification = replacementStoredClassification(
705
+ params.classification,
706
+ refreshed.classification,
707
+ refreshedInherited,
708
+ refreshedPolicy,
709
+ );
710
+ return this.commitPin("pin_supersede", refreshed.scope, ctx, (appendEntry) =>
711
+ this.runtime.contextPersistenceCreatePin({
712
+ content,
713
+ scope: refreshed.scope,
714
+ sourceEntryId: provenance.primarySourceEntryId,
715
+ supersedes: refreshed.id,
716
+ ...(storedClassification ? { classification: storedClassification } : {}),
717
+ }, ctx, appendEntry));
718
+ }
719
+
720
+ private async unpin(
721
+ params: ContextPersistenceParams,
722
+ state: ContextPersistenceRuntimeState,
723
+ signal: AbortSignal | undefined,
724
+ ctx: ExtensionContext,
725
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
726
+ const id = params.id ?? "";
727
+ const target = this.runtime.contextPersistenceResolvePin(id, true);
728
+ if (!target) return buildFailureResult("pin_unpin", "rejected", "target-not-active", "canonical-jsonl");
729
+ if (!this.pinRevisionMatches(target, params.targetRevision, state, ctx)) {
730
+ return buildFailureResult("pin_unpin", "rejected", "stale-target", "canonical-jsonl");
731
+ }
732
+ if (target.scope === "project" && !state.projectTrusted) {
733
+ return buildFailureResult("pin_unpin", "rejected", "project-untrusted", "canonical-jsonl");
734
+ }
735
+ const reason = params.reason;
736
+ if (reason) {
737
+ const reasonPolicy = this.runtime.contextPersistenceMutationPolicy(
738
+ reason,
739
+ undefined,
740
+ classification(target.classification, state),
741
+ state.provider,
742
+ );
743
+ if (reasonPolicy.secretDetected || reasonPolicy.markerDetected) {
744
+ return buildFailureResult("pin_unpin", "rejected", "secret-in-reason", "canonical-jsonl");
745
+ }
746
+ if (!reasonPolicy.allowed) {
747
+ return buildFailureResult("pin_unpin", "rejected", "provider-policy-denied", "canonical-jsonl");
748
+ }
749
+ }
750
+ const localPreview = target.content.length <= BODY_CEILING
751
+ ? Array.from(target.content).slice(0, 500).join("")
752
+ : "[legacy body omitted; inspect with /context]";
753
+ const confirmed = await this.confirm(
754
+ ctx,
755
+ "pin_unpin",
756
+ [
757
+ "Persistence: canonical Pi JSONL append",
758
+ `Scope: ${target.scope}`,
759
+ `Target: ${target.id}`,
760
+ `Revision: ${(params.targetRevision ?? "").slice(0, 12)}…`,
761
+ "",
762
+ localPreview,
763
+ ...(reason ? ["", `Reason: ${reason}`] : []),
764
+ "",
765
+ "This appends a deleted status; it does not physically delete prior entries.",
766
+ ].join("\n"),
767
+ );
768
+ if (!confirmed) return buildFailureResult("pin_unpin", "cancelled", undefined, "canonical-jsonl");
769
+ if (signal?.aborted) return buildFailureResult("pin_unpin", "cancelled", "aborted", "canonical-jsonl");
770
+ const refreshed = this.runtime.contextPersistenceResolvePin(id, true);
771
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
772
+ if (!refreshedState.available) {
773
+ return buildFailureResult(
774
+ "pin_unpin",
775
+ "unavailable",
776
+ refreshedState.errorCode ?? "runtime-unavailable",
777
+ "canonical-jsonl",
778
+ );
779
+ }
780
+ if (!refreshed || !this.pinRevisionMatches(refreshed, params.targetRevision, refreshedState, ctx)) {
781
+ return buildFailureResult("pin_unpin", "rejected", "stale-target", "canonical-jsonl");
782
+ }
783
+ if (refreshed.scope === "project" && !refreshedState.projectTrusted) {
784
+ return buildFailureResult("pin_unpin", "rejected", "project-untrusted", "canonical-jsonl");
785
+ }
786
+ if (reason) {
787
+ const refreshedReasonPolicy = this.runtime.contextPersistenceMutationPolicy(
788
+ reason,
789
+ undefined,
790
+ classification(refreshed.classification, refreshedState),
791
+ refreshedState.provider,
792
+ );
793
+ if (refreshedReasonPolicy.secretDetected || refreshedReasonPolicy.markerDetected) {
794
+ return buildFailureResult("pin_unpin", "rejected", "secret-in-reason", "canonical-jsonl");
795
+ }
796
+ if (!refreshedReasonPolicy.allowed) {
797
+ return buildFailureResult("pin_unpin", "rejected", "provider-policy-denied", "canonical-jsonl");
798
+ }
799
+ }
800
+ return this.commitPin("pin_unpin", refreshed.scope, ctx, (appendEntry) => ({
801
+ pin: this.runtime.contextPersistenceUnpin(refreshed.id, reason, ctx, appendEntry),
802
+ duplicate: false,
803
+ }));
804
+ }
805
+
806
+ private async addMemory(
807
+ toolCallId: string,
808
+ params: ContextPersistenceParams,
809
+ state: ContextPersistenceRuntimeState,
810
+ signal: AbortSignal | undefined,
811
+ ctx: ExtensionContext,
812
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
813
+ const content = params.content ?? "";
814
+ const scope = params.scope ?? "session";
815
+ if (scope === "branch") {
816
+ return buildFailureResult("memory_add", "rejected", "invalid-scope", "canonical-jsonl");
817
+ }
818
+ if (content.length > state.maxClaimChars || content.length > BODY_CEILING) {
819
+ return buildFailureResult("memory_add", "rejected", "claim-too-long", "canonical-jsonl");
820
+ }
821
+ if (scope === "project" && !state.projectTrusted) {
822
+ return buildFailureResult("memory_add", "rejected", "project-untrusted", "canonical-jsonl");
823
+ }
824
+ const policyText = params.key ? `${content}\n${params.key}` : content;
825
+ const policy = this.runtime.contextPersistenceMutationPolicy(
826
+ policyText,
827
+ params.classification,
828
+ undefined,
829
+ state.provider,
830
+ );
831
+ if (!policy.allowed) {
832
+ return buildFailureResult("memory_add", "rejected", "provider-policy-denied", "canonical-jsonl");
833
+ }
834
+ const provenance = this.runtime.contextPersistenceResolveProvenance(ctx, toolCallId);
835
+ if (!provenance) {
836
+ return buildFailureResult("memory_add", "rejected", "provenance-unavailable", "canonical-jsonl");
837
+ }
838
+ const confirmed = await this.confirm(
839
+ ctx,
840
+ "memory_add",
841
+ [
842
+ "Persistence: canonical Pi JSONL append",
843
+ `Scope: ${scope}`,
844
+ ...(params.key ? [`Key: ${params.key}`] : []),
845
+ `Classification: ${policy.classification}`,
846
+ "",
847
+ content,
848
+ "",
849
+ "This appends history; it does not physically delete prior entries.",
850
+ ].join("\n"),
851
+ );
852
+ if (!confirmed) return buildFailureResult("memory_add", "cancelled", undefined, "canonical-jsonl");
853
+ if (signal?.aborted) return buildFailureResult("memory_add", "cancelled", "aborted", "canonical-jsonl");
854
+ if (!this.runtime.contextPersistenceValidateProvenance(ctx, provenance)) {
855
+ return buildFailureResult("memory_add", "rejected", "provenance-unavailable", "canonical-jsonl");
856
+ }
857
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
858
+ if (!refreshedState.available) {
859
+ return buildFailureResult(
860
+ "memory_add",
861
+ "unavailable",
862
+ refreshedState.errorCode ?? "runtime-unavailable",
863
+ "canonical-jsonl",
864
+ );
865
+ }
866
+ if (scope === "project" && !refreshedState.projectTrusted) {
867
+ return buildFailureResult("memory_add", "rejected", "project-untrusted", "canonical-jsonl");
868
+ }
869
+ const refreshedPolicy = this.runtime.contextPersistenceMutationPolicy(
870
+ policyText,
871
+ params.classification,
872
+ undefined,
873
+ refreshedState.provider,
874
+ );
875
+ if (!refreshedPolicy.allowed) {
876
+ return buildFailureResult("memory_add", "rejected", "provider-policy-denied", "canonical-jsonl");
877
+ }
878
+ return this.commitMemory("memory_add", scope, ctx, (appendEntry) =>
879
+ this.runtime.contextPersistenceCreateMemory({
880
+ claim: content,
881
+ scope,
882
+ ...(params.key ? { key: params.key } : {}),
883
+ ...(refreshedPolicy.storedClassification
884
+ ? { classification: refreshedPolicy.storedClassification }
885
+ : {}),
886
+ sourceEntryIds: [provenance.primarySourceEntryId],
887
+ }, ctx, appendEntry));
888
+ }
889
+
890
+ private async supersedeMemory(
891
+ toolCallId: string,
892
+ params: ContextPersistenceParams,
893
+ state: ContextPersistenceRuntimeState,
894
+ signal: AbortSignal | undefined,
895
+ ctx: ExtensionContext,
896
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
897
+ const id = params.id ?? "";
898
+ const content = params.content ?? "";
899
+ if (content.length > state.maxClaimChars || content.length > BODY_CEILING) {
900
+ return buildFailureResult("memory_supersede", "rejected", "claim-too-long", "canonical-jsonl");
901
+ }
902
+ const target = this.runtime.contextPersistenceResolveMemory(id, true);
903
+ if (!target) {
904
+ return buildFailureResult("memory_supersede", "rejected", "target-not-active", "canonical-jsonl");
905
+ }
906
+ if (!this.memoryRevisionMatches(target, params.targetRevision, state, ctx)) {
907
+ return buildFailureResult("memory_supersede", "rejected", "stale-target", "canonical-jsonl");
908
+ }
909
+ if (target.scope === "project" && !state.projectTrusted) {
910
+ return buildFailureResult("memory_supersede", "rejected", "project-untrusted", "canonical-jsonl");
911
+ }
912
+ const inheritedClassification = classification(target.classification, state);
913
+ if (classificationDowngrade(params.classification, inheritedClassification)) {
914
+ return buildFailureResult("memory_supersede", "rejected", "invalid-classification", "canonical-jsonl");
915
+ }
916
+ const policy = this.runtime.contextPersistenceMutationPolicy(
917
+ content,
918
+ params.classification,
919
+ inheritedClassification,
920
+ state.provider,
921
+ );
922
+ if (!policy.allowed) {
923
+ return buildFailureResult("memory_supersede", "rejected", "provider-policy-denied", "canonical-jsonl");
924
+ }
925
+ const provenance = this.runtime.contextPersistenceResolveProvenance(ctx, toolCallId);
926
+ if (!provenance) {
927
+ return buildFailureResult("memory_supersede", "rejected", "provenance-unavailable", "canonical-jsonl");
928
+ }
929
+ const confirmed = await this.confirm(
930
+ ctx,
931
+ "memory_supersede",
932
+ [
933
+ "Persistence: canonical Pi JSONL append",
934
+ `Scope: ${target.scope}`,
935
+ `Target: ${target.id}`,
936
+ `Revision: ${(params.targetRevision ?? "").slice(0, 12)}…`,
937
+ ...(target.key ? [`Key: ${target.key}`] : []),
938
+ `Classification: ${policy.classification}`,
939
+ "",
940
+ content,
941
+ "",
942
+ "This appends a supersession; it does not physically delete prior entries.",
943
+ ].join("\n"),
944
+ );
945
+ if (!confirmed) return buildFailureResult("memory_supersede", "cancelled", undefined, "canonical-jsonl");
946
+ if (signal?.aborted) return buildFailureResult("memory_supersede", "cancelled", "aborted", "canonical-jsonl");
947
+ if (!this.runtime.contextPersistenceValidateProvenance(ctx, provenance)) {
948
+ return buildFailureResult("memory_supersede", "rejected", "provenance-unavailable", "canonical-jsonl");
949
+ }
950
+ const refreshed = this.runtime.contextPersistenceResolveMemory(id, true);
951
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
952
+ if (!refreshedState.available) {
953
+ return buildFailureResult(
954
+ "memory_supersede",
955
+ "unavailable",
956
+ refreshedState.errorCode ?? "runtime-unavailable",
957
+ "canonical-jsonl",
958
+ );
959
+ }
960
+ if (!refreshed || !this.memoryRevisionMatches(refreshed, params.targetRevision, refreshedState, ctx)) {
961
+ return buildFailureResult("memory_supersede", "rejected", "stale-target", "canonical-jsonl");
962
+ }
963
+ if (refreshed.scope === "project" && !refreshedState.projectTrusted) {
964
+ return buildFailureResult("memory_supersede", "rejected", "project-untrusted", "canonical-jsonl");
965
+ }
966
+ const refreshedInherited = classification(refreshed.classification, refreshedState);
967
+ const refreshedPolicy = this.runtime.contextPersistenceMutationPolicy(
968
+ content,
969
+ params.classification,
970
+ refreshedInherited,
971
+ refreshedState.provider,
972
+ );
973
+ if (!refreshedPolicy.allowed) {
974
+ return buildFailureResult("memory_supersede", "rejected", "provider-policy-denied", "canonical-jsonl");
975
+ }
976
+ const storedClassification = replacementStoredClassification(
977
+ params.classification,
978
+ refreshed.classification,
979
+ refreshedInherited,
980
+ refreshedPolicy,
981
+ );
982
+ return this.commitMemory("memory_supersede", refreshed.scope, ctx, (appendEntry) => ({
983
+ memory: this.runtime.contextPersistenceSupersedeMemory(
984
+ refreshed.id,
985
+ content,
986
+ [provenance.primarySourceEntryId],
987
+ storedClassification,
988
+ ctx,
989
+ appendEntry,
990
+ ),
991
+ duplicate: false,
992
+ }));
993
+ }
994
+
995
+ private async setMemoryStatus(
996
+ action: "memory_invalidate" | "memory_expire",
997
+ status: "invalid" | "expired",
998
+ params: ContextPersistenceParams,
999
+ state: ContextPersistenceRuntimeState,
1000
+ signal: AbortSignal | undefined,
1001
+ ctx: ExtensionContext,
1002
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
1003
+ const id = params.id ?? "";
1004
+ const target = this.runtime.contextPersistenceResolveMemory(id, true);
1005
+ if (!target) return buildFailureResult(action, "rejected", "target-not-active", "canonical-jsonl");
1006
+ if (!this.memoryRevisionMatches(target, params.targetRevision, state, ctx)) {
1007
+ return buildFailureResult(action, "rejected", "stale-target", "canonical-jsonl");
1008
+ }
1009
+ if (target.scope === "project" && !state.projectTrusted) {
1010
+ return buildFailureResult(action, "rejected", "project-untrusted", "canonical-jsonl");
1011
+ }
1012
+ const reason = params.reason;
1013
+ if (reason) {
1014
+ const reasonPolicy = this.runtime.contextPersistenceMutationPolicy(
1015
+ reason,
1016
+ undefined,
1017
+ classification(target.classification, state),
1018
+ state.provider,
1019
+ );
1020
+ if (reasonPolicy.secretDetected || reasonPolicy.markerDetected) {
1021
+ return buildFailureResult(action, "rejected", "secret-in-reason", "canonical-jsonl");
1022
+ }
1023
+ if (!reasonPolicy.allowed) {
1024
+ return buildFailureResult(action, "rejected", "provider-policy-denied", "canonical-jsonl");
1025
+ }
1026
+ }
1027
+ const localPreview = target.claim.length <= BODY_CEILING
1028
+ ? Array.from(target.claim).slice(0, 500).join("")
1029
+ : "[legacy body omitted; inspect with /context]";
1030
+ const confirmed = await this.confirm(
1031
+ ctx,
1032
+ action,
1033
+ [
1034
+ "Persistence: canonical Pi JSONL append",
1035
+ `Scope: ${target.scope}`,
1036
+ `Target: ${target.id}`,
1037
+ `Revision: ${(params.targetRevision ?? "").slice(0, 12)}…`,
1038
+ `Classification: ${classification(target.classification, state)}`,
1039
+ "",
1040
+ localPreview,
1041
+ ...(reason ? ["", `Reason: ${reason}`] : []),
1042
+ "",
1043
+ `This appends an ${status} status; it does not physically delete prior entries.`,
1044
+ ].join("\n"),
1045
+ );
1046
+ if (!confirmed) return buildFailureResult(action, "cancelled", undefined, "canonical-jsonl");
1047
+ if (signal?.aborted) return buildFailureResult(action, "cancelled", "aborted", "canonical-jsonl");
1048
+ const refreshed = this.runtime.contextPersistenceResolveMemory(id, true);
1049
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
1050
+ if (!refreshedState.available) {
1051
+ return buildFailureResult(
1052
+ action,
1053
+ "unavailable",
1054
+ refreshedState.errorCode ?? "runtime-unavailable",
1055
+ "canonical-jsonl",
1056
+ );
1057
+ }
1058
+ if (!refreshed || !this.memoryRevisionMatches(refreshed, params.targetRevision, refreshedState, ctx)) {
1059
+ return buildFailureResult(action, "rejected", "stale-target", "canonical-jsonl");
1060
+ }
1061
+ if (refreshed.scope === "project" && !refreshedState.projectTrusted) {
1062
+ return buildFailureResult(action, "rejected", "project-untrusted", "canonical-jsonl");
1063
+ }
1064
+ if (reason) {
1065
+ const refreshedReasonPolicy = this.runtime.contextPersistenceMutationPolicy(
1066
+ reason,
1067
+ undefined,
1068
+ classification(refreshed.classification, refreshedState),
1069
+ refreshedState.provider,
1070
+ );
1071
+ if (refreshedReasonPolicy.secretDetected || refreshedReasonPolicy.markerDetected) {
1072
+ return buildFailureResult(action, "rejected", "secret-in-reason", "canonical-jsonl");
1073
+ }
1074
+ if (!refreshedReasonPolicy.allowed) {
1075
+ return buildFailureResult(action, "rejected", "provider-policy-denied", "canonical-jsonl");
1076
+ }
1077
+ }
1078
+ return this.commitMemory(action, refreshed.scope, ctx, (appendEntry) => ({
1079
+ memory: this.runtime.contextPersistenceSetMemoryStatus(
1080
+ refreshed.id,
1081
+ status,
1082
+ reason,
1083
+ ctx,
1084
+ appendEntry,
1085
+ ),
1086
+ duplicate: false,
1087
+ }));
1088
+ }
1089
+
1090
+ private async setSourcePolicy(
1091
+ action: "memory_source_exclude" | "memory_source_include",
1092
+ params: ContextPersistenceParams,
1093
+ state: ContextPersistenceRuntimeState,
1094
+ signal: AbortSignal | undefined,
1095
+ ctx: ExtensionContext,
1096
+ ): Promise<ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult>> {
1097
+ if (!state.projectTrusted) {
1098
+ return buildFailureResult(action, "unavailable", "project-untrusted", "derived-local-policy");
1099
+ }
1100
+ if (!state.crossSessionEnabled) {
1101
+ return buildFailureResult(action, "unavailable", "cross-session-disabled", "derived-local-policy");
1102
+ }
1103
+ if (!state.crossSessionReady) {
1104
+ return buildFailureResult(action, "unavailable", "memory-unavailable", "derived-local-policy");
1105
+ }
1106
+ const sourceRef = params.id ?? "";
1107
+ const handle = this.resolveSourceHandle(sourceRef, state);
1108
+ if (!handle) {
1109
+ return buildFailureResult(action, "rejected", "source-not-found", "derived-local-policy");
1110
+ }
1111
+ const source = this.runtime.contextPersistenceResolveProjectSource(handle.sessionId);
1112
+ if (!source || !this.sourceRevisionMatches(source, sourceRef, params.targetRevision, state)) {
1113
+ return buildFailureResult(action, "rejected", source ? "stale-target" : "source-not-found", "derived-local-policy");
1114
+ }
1115
+ const excluded = action === "memory_source_exclude";
1116
+ if (excluded && handle.sessionId === state.sessionId) {
1117
+ return buildFailureResult(action, "rejected", "source-not-excludable", "derived-local-policy");
1118
+ }
1119
+ if (params.reason) {
1120
+ const reasonPolicy = this.runtime.contextPersistenceMutationPolicy(
1121
+ params.reason,
1122
+ undefined,
1123
+ undefined,
1124
+ state.provider,
1125
+ );
1126
+ if (reasonPolicy.secretDetected || reasonPolicy.markerDetected) {
1127
+ return buildFailureResult(action, "rejected", "secret-in-reason", "derived-local-policy");
1128
+ }
1129
+ if (!reasonPolicy.allowed) {
1130
+ return buildFailureResult(action, "rejected", "provider-policy-denied", "derived-local-policy");
1131
+ }
1132
+ }
1133
+ const confirmed = await this.confirm(
1134
+ ctx,
1135
+ action,
1136
+ [
1137
+ "Persistence: derived local SQLite policy (disposable)",
1138
+ `Source: ${sourceRef}`,
1139
+ `Status: ${source.status}`,
1140
+ `Revision: ${(params.targetRevision ?? "").slice(0, 12)}…`,
1141
+ `Indexed mutations: ${source.indexedMutations}`,
1142
+ `Active project memories: ${source.activeProjectMemories}`,
1143
+ `Active project pins: ${source.activeProjectPins}`,
1144
+ "",
1145
+ "No Pi JSONL entry will be appended. Deleting the local database resets this policy.",
1146
+ ].join("\n"),
1147
+ );
1148
+ if (!confirmed) return buildFailureResult(action, "cancelled", undefined, "derived-local-policy");
1149
+ if (signal?.aborted) return buildFailureResult(action, "cancelled", "aborted", "derived-local-policy");
1150
+ const refreshedState = this.runtime.contextPersistenceState(ctx);
1151
+ if (!refreshedState.available) {
1152
+ return buildFailureResult(
1153
+ action,
1154
+ "unavailable",
1155
+ refreshedState.errorCode ?? "runtime-unavailable",
1156
+ "derived-local-policy",
1157
+ );
1158
+ }
1159
+ if (!refreshedState.projectTrusted) {
1160
+ return buildFailureResult(action, "unavailable", "project-untrusted", "derived-local-policy");
1161
+ }
1162
+ if (!refreshedState.crossSessionEnabled) {
1163
+ return buildFailureResult(action, "unavailable", "cross-session-disabled", "derived-local-policy");
1164
+ }
1165
+ if (!refreshedState.crossSessionReady) {
1166
+ return buildFailureResult(action, "unavailable", "memory-unavailable", "derived-local-policy");
1167
+ }
1168
+ if (params.reason) {
1169
+ const refreshedReasonPolicy = this.runtime.contextPersistenceMutationPolicy(
1170
+ params.reason,
1171
+ undefined,
1172
+ undefined,
1173
+ refreshedState.provider,
1174
+ );
1175
+ if (refreshedReasonPolicy.secretDetected || refreshedReasonPolicy.markerDetected) {
1176
+ return buildFailureResult(action, "rejected", "secret-in-reason", "derived-local-policy");
1177
+ }
1178
+ if (!refreshedReasonPolicy.allowed) {
1179
+ return buildFailureResult(action, "rejected", "provider-policy-denied", "derived-local-policy");
1180
+ }
1181
+ }
1182
+ const refreshedHandle = this.resolveSourceHandle(sourceRef, refreshedState);
1183
+ const refreshed = refreshedHandle
1184
+ ? this.runtime.contextPersistenceResolveProjectSource(refreshedHandle.sessionId)
1185
+ : undefined;
1186
+ if (!refreshedHandle || !refreshed
1187
+ || !this.sourceRevisionMatches(refreshed, sourceRef, params.targetRevision, refreshedState)) {
1188
+ return buildFailureResult(
1189
+ action,
1190
+ "rejected",
1191
+ refreshed ? "stale-target" : "source-not-found",
1192
+ "derived-local-policy",
1193
+ );
1194
+ }
1195
+ if ((refreshed.status === "excluded") === excluded) {
1196
+ return this.sourceMutationResult(action, "ok", sourceRef, refreshed, refreshedState);
1197
+ }
1198
+ try {
1199
+ const updated = this.runtime.contextPersistenceSetProjectSourceExcluded(
1200
+ refreshedHandle.sessionId,
1201
+ excluded,
1202
+ excluded ? params.reason : undefined,
1203
+ );
1204
+ if (!updated || (updated.status === "excluded") !== excluded) {
1205
+ return buildFailureResult(action, "unavailable", "runtime-unavailable", "derived-local-policy");
1206
+ }
1207
+ return this.sourceMutationResult(action, "committed", sourceRef, updated, refreshedState);
1208
+ } catch {
1209
+ try {
1210
+ const observed = this.runtime.contextPersistenceResolveProjectSource(refreshedHandle.sessionId);
1211
+ if (observed && (observed.status === "excluded") === excluded) {
1212
+ return this.sourceMutationResult(action, "committed", sourceRef, observed, refreshedState);
1213
+ }
1214
+ } catch {
1215
+ // The derived-policy outcome could not be observed safely.
1216
+ }
1217
+ return buildFailureResult(action, "unavailable", "runtime-unavailable", "derived-local-policy");
1218
+ }
1219
+ }
1220
+
1221
+ private async confirm(ctx: ExtensionContext, action: ContextPersistenceAction, message: string): Promise<boolean> {
1222
+ try {
1223
+ return await ctx.ui.confirm("DS4 Context Persistence", `Action: ${action}\n${message}`);
1224
+ } catch {
1225
+ return false;
1226
+ }
1227
+ }
1228
+
1229
+ private pinRevisionMatches(
1230
+ item: PinItem,
1231
+ targetRevision: string | undefined,
1232
+ state: ContextPersistenceRuntimeState,
1233
+ ctx: ExtensionContext,
1234
+ ): boolean {
1235
+ if (!targetRevision) return false;
1236
+ this.pruneRevisions();
1237
+ const handle = this.targetRevisions.get(targetRevision);
1238
+ if (!handle || handle.kind !== "pin" || handle.id !== item.id) return false;
1239
+ if (handle.sessionId !== state.sessionId || handle.projectIdentity !== state.projectIdentity) return false;
1240
+ const branch = this.branchRevisions.get(handle.branchRevision);
1241
+ if (!branch) return false;
1242
+ const currentEntryIds = ctx.sessionManager.getBranch().map((entry) => entry.id);
1243
+ if (currentEntryIds.length < branch.entryIds.length) return false;
1244
+ for (let index = 0; index < branch.entryIds.length; index++) {
1245
+ if (currentEntryIds[index] !== branch.entryIds[index]) return false;
1246
+ }
1247
+ const branchIds = new Set(currentEntryIds);
1248
+ const effectiveClassification = classification(item.classification, state);
1249
+ const fingerprint = this.revision({
1250
+ kind: "pin",
1251
+ id: item.id,
1252
+ scope: item.scope,
1253
+ status: item.status,
1254
+ updatedAt: item.updatedAt,
1255
+ classification: effectiveClassification,
1256
+ applicable: pinApplicable(item, branchIds),
1257
+ sessionId: state.sessionId,
1258
+ projectIdentity: state.projectIdentity,
1259
+ });
1260
+ return fingerprint === handle.fingerprint;
1261
+ }
1262
+
1263
+ private memoryRevisionMatches(
1264
+ item: MemoryItem,
1265
+ targetRevision: string | undefined,
1266
+ state: ContextPersistenceRuntimeState,
1267
+ ctx: ExtensionContext,
1268
+ ): boolean {
1269
+ if (!targetRevision) return false;
1270
+ this.pruneRevisions();
1271
+ const handle = this.targetRevisions.get(targetRevision);
1272
+ if (!handle || handle.kind !== "memory" || handle.id !== item.id) return false;
1273
+ if (handle.sessionId !== state.sessionId || handle.projectIdentity !== state.projectIdentity) return false;
1274
+ const branch = this.branchRevisions.get(handle.branchRevision);
1275
+ if (!branch) return false;
1276
+ const currentEntryIds = ctx.sessionManager.getBranch().map((entry) => entry.id);
1277
+ if (currentEntryIds.length < branch.entryIds.length) return false;
1278
+ for (let index = 0; index < branch.entryIds.length; index++) {
1279
+ if (currentEntryIds[index] !== branch.entryIds[index]) return false;
1280
+ }
1281
+ const effectiveClassification = classification(item.classification, state);
1282
+ const fingerprint = this.revision({
1283
+ kind: "memory",
1284
+ id: item.id,
1285
+ scope: item.scope,
1286
+ status: item.status,
1287
+ updatedAt: item.updatedAt,
1288
+ classification: effectiveClassification,
1289
+ sessionId: state.sessionId,
1290
+ projectIdentity: state.projectIdentity,
1291
+ });
1292
+ return fingerprint === handle.fingerprint;
1293
+ }
1294
+
1295
+ private commitPin(
1296
+ action: "pin_add" | "pin_supersede" | "pin_unpin",
1297
+ scope: "session" | "branch" | "project",
1298
+ ctx: ExtensionContext,
1299
+ mutation: (appendEntry: ContextPersistenceAppender) => { pin: PinItem; duplicate: boolean },
1300
+ ): ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult> {
1301
+ const tracker = new TrackedCanonicalAppender(this.appendEntry as ContextPersistenceAppender);
1302
+ try {
1303
+ const result = mutation(tracker.append);
1304
+ if (!opaqueId(result.pin.id)) throw new Error("context_persistence materialization invariant failed");
1305
+ tracker.materialized();
1306
+ const state = this.runtime.contextPersistenceState(ctx);
1307
+ const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1308
+ const dto = this.pinDto(
1309
+ result.pin,
1310
+ state,
1311
+ this.branchRevision(ctx),
1312
+ pinApplicable(result.pin, branchIds),
1313
+ );
1314
+ return buildMutationResult({
1315
+ action,
1316
+ outcome: result.duplicate ? "ok" : "committed",
1317
+ persistenceClass: "canonical-jsonl",
1318
+ id: dto.id,
1319
+ kind: "pin",
1320
+ scope: dto.scope,
1321
+ status: dto.status,
1322
+ classification: dto.classification,
1323
+ targetRevision: dto.targetRevision,
1324
+ ...(result.duplicate ? { duplicate: true } : {}),
1325
+ });
1326
+ } catch {
1327
+ if (tracker.state === "invoking") {
1328
+ return buildMutationResult({
1329
+ action,
1330
+ outcome: "indeterminate",
1331
+ persistenceClass: "canonical-jsonl",
1332
+ ...(tracker.itemId ? { id: tracker.itemId } : {}),
1333
+ kind: "pin",
1334
+ errorCode: "append-indeterminate",
1335
+ });
1336
+ }
1337
+ if (tracker.state === "committed" || tracker.state === "materialized") {
1338
+ return buildMutationResult({
1339
+ action,
1340
+ outcome: "committed_projection_pending",
1341
+ persistenceClass: "canonical-jsonl",
1342
+ ...(tracker.itemId ? { id: tracker.itemId } : {}),
1343
+ kind: "pin",
1344
+ scope,
1345
+ errorCode: "committed-projection-pending",
1346
+ });
1347
+ }
1348
+ return buildFailureResult(action, "unavailable", "runtime-unavailable", "canonical-jsonl");
1349
+ }
1350
+ }
1351
+
1352
+ private commitMemory(
1353
+ action: "memory_add" | "memory_supersede" | "memory_invalidate" | "memory_expire",
1354
+ scope: "session" | "project",
1355
+ ctx: ExtensionContext,
1356
+ mutation: (appendEntry: ContextPersistenceAppender) => { memory: MemoryItem; duplicate: boolean },
1357
+ ): ReturnType<typeof buildFailureResult> | ReturnType<typeof buildMutationResult> {
1358
+ const tracker = new TrackedCanonicalAppender(this.appendEntry as ContextPersistenceAppender);
1359
+ try {
1360
+ const result = mutation(tracker.append);
1361
+ if (!opaqueId(result.memory.id)) throw new Error("context_persistence materialization invariant failed");
1362
+ tracker.materialized();
1363
+ const state = this.runtime.contextPersistenceState(ctx);
1364
+ const dto = this.memoryDto(result.memory, state, this.branchRevision(ctx));
1365
+ return buildMutationResult({
1366
+ action,
1367
+ outcome: result.duplicate ? "ok" : "committed",
1368
+ persistenceClass: "canonical-jsonl",
1369
+ id: dto.id,
1370
+ kind: "memory",
1371
+ scope: dto.scope,
1372
+ status: dto.status,
1373
+ classification: dto.classification,
1374
+ targetRevision: dto.targetRevision,
1375
+ ...(result.duplicate ? { duplicate: true } : {}),
1376
+ });
1377
+ } catch (error) {
1378
+ if (tracker.state === "invoking") {
1379
+ return buildMutationResult({
1380
+ action,
1381
+ outcome: "indeterminate",
1382
+ persistenceClass: "canonical-jsonl",
1383
+ ...(tracker.itemId ? { id: tracker.itemId } : {}),
1384
+ kind: "memory",
1385
+ errorCode: "append-indeterminate",
1386
+ });
1387
+ }
1388
+ if (tracker.state === "committed" || tracker.state === "materialized") {
1389
+ return buildMutationResult({
1390
+ action,
1391
+ outcome: "committed_projection_pending",
1392
+ persistenceClass: "canonical-jsonl",
1393
+ ...(tracker.itemId ? { id: tracker.itemId } : {}),
1394
+ kind: "memory",
1395
+ scope,
1396
+ errorCode: "committed-projection-pending",
1397
+ });
1398
+ }
1399
+ if (error instanceof MemoryConflictError) {
1400
+ return buildFailureResult(action, "rejected", "duplicate-conflict", "canonical-jsonl");
1401
+ }
1402
+ return buildFailureResult(action, "unavailable", "runtime-unavailable", "canonical-jsonl");
1403
+ }
1404
+ }
1405
+
1406
+ private recordOutcome(details: ContextPersistenceDetails): void {
1407
+ try {
1408
+ this.runtime.contextPersistenceRecordOutcome({
1409
+ action: details.action,
1410
+ outcome: details.outcome,
1411
+ persistenceClass: details.persistenceClass,
1412
+ ...(details.scope ? { scope: details.scope } : {}),
1413
+ ...(details.id ? { itemId: details.id } : {}),
1414
+ ...(details.classification ? { classification: details.classification } : {}),
1415
+ ...(details.count !== undefined ? { resultCount: details.count } : {}),
1416
+ ...(details.duplicate !== undefined ? { duplicate: details.duplicate } : {}),
1417
+ ...(details.errorCode ? { errorCode: details.errorCode } : {}),
1418
+ });
1419
+ } catch {
1420
+ // Outcome logging is best-effort and must not affect persistence semantics.
1421
+ }
1422
+ }
1423
+
1424
+ private persistenceClass(action: ContextPersistenceAction): "canonical-jsonl" | "derived-local-policy" {
1425
+ return action === "memory_source_exclude" || action === "memory_source_include"
1426
+ ? "derived-local-policy"
1427
+ : "canonical-jsonl";
1428
+ }
1429
+
1430
+ private revision(input: unknown): string {
1431
+ return `rev_${createHmac("sha256", this.processSecret)
1432
+ .update(stableStringify({ version: 1, input }))
1433
+ .digest("base64url")
1434
+ .slice(0, 22)}`;
1435
+ }
1436
+
1437
+ private rememberTargetRevision(token: string, handle: TargetRevisionHandle): void {
1438
+ this.targetRevisions.set(token, handle);
1439
+ this.pruneRevisions();
1440
+ }
1441
+
1442
+ private pruneRevisions(): void {
1443
+ const now = this.now();
1444
+ for (const [token, handle] of this.targetRevisions) {
1445
+ if (now - handle.emittedAt > REVISION_TTL_MS) this.targetRevisions.delete(token);
1446
+ }
1447
+ for (const [token, handle] of this.branchRevisions) {
1448
+ if (now - handle.emittedAt > REVISION_TTL_MS) this.branchRevisions.delete(token);
1449
+ }
1450
+ for (const [token, handle] of this.sourceRevisions) {
1451
+ if (now - handle.emittedAt > REVISION_TTL_MS) this.sourceRevisions.delete(token);
1452
+ }
1453
+ if (this.targetRevisions.size > REVISION_CAP) {
1454
+ const oldest = [...this.targetRevisions]
1455
+ .sort((left, right) => left[1].emittedAt - right[1].emittedAt || compareAscii(left[0], right[0]));
1456
+ for (const [token] of oldest.slice(0, this.targetRevisions.size - REVISION_CAP)) {
1457
+ this.targetRevisions.delete(token);
1458
+ }
1459
+ }
1460
+ if (this.branchRevisions.size > REVISION_CAP) {
1461
+ const oldest = [...this.branchRevisions]
1462
+ .sort((left, right) => left[1].emittedAt - right[1].emittedAt || compareAscii(left[0], right[0]));
1463
+ for (const [token] of oldest.slice(0, this.branchRevisions.size - REVISION_CAP)) {
1464
+ this.branchRevisions.delete(token);
1465
+ }
1466
+ }
1467
+ if (this.sourceRevisions.size > REVISION_CAP) {
1468
+ const oldest = [...this.sourceRevisions]
1469
+ .sort((left, right) => left[1].emittedAt - right[1].emittedAt || compareAscii(left[0], right[0]));
1470
+ for (const [token] of oldest.slice(0, this.sourceRevisions.size - REVISION_CAP)) {
1471
+ this.sourceRevisions.delete(token);
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ private branchRevision(ctx: ExtensionContext): string {
1477
+ const entryIds = ctx.sessionManager.getBranch().map((entry) => entry.id);
1478
+ const revision = this.revision({
1479
+ sessionId: ctx.sessionManager.getSessionId(),
1480
+ leafId: ctx.sessionManager.getLeafId(),
1481
+ branch: entryIds,
1482
+ });
1483
+ this.branchRevisions.set(revision, { entryIds, emittedAt: this.now() });
1484
+ this.pruneRevisions();
1485
+ return revision;
1486
+ }
1487
+
1488
+ private pinDto(
1489
+ item: PinItem,
1490
+ state: ContextPersistenceRuntimeState,
1491
+ branchRevision: string,
1492
+ applicable: boolean,
1493
+ ): PinReadItem {
1494
+ const effectiveClassification = classification(item.classification, state);
1495
+ const fingerprint = this.revision({
1496
+ kind: "pin",
1497
+ id: item.id,
1498
+ scope: item.scope,
1499
+ status: item.status,
1500
+ updatedAt: item.updatedAt,
1501
+ classification: effectiveClassification,
1502
+ applicable,
1503
+ sessionId: state.sessionId,
1504
+ projectIdentity: state.projectIdentity,
1505
+ });
1506
+ const targetRevision = this.revision({
1507
+ fingerprint,
1508
+ branchRevision,
1509
+ });
1510
+ this.rememberTargetRevision(targetRevision, {
1511
+ kind: "pin",
1512
+ id: item.id,
1513
+ sessionId: state.sessionId,
1514
+ projectIdentity: state.projectIdentity,
1515
+ branchRevision,
1516
+ fingerprint,
1517
+ emittedAt: this.now(),
1518
+ });
1519
+ return {
1520
+ id: item.id,
1521
+ kind: "pin",
1522
+ scope: item.scope,
1523
+ status: item.status,
1524
+ classification: effectiveClassification,
1525
+ applicableToActiveBranch: applicable,
1526
+ createdAt: item.createdAt,
1527
+ updatedAt: item.updatedAt,
1528
+ targetRevision,
1529
+ };
1530
+ }
1531
+
1532
+ private memoryDto(
1533
+ item: MemoryItem,
1534
+ state: ContextPersistenceRuntimeState,
1535
+ branchRevision: string,
1536
+ ): MemoryReadItem {
1537
+ const effectiveClassification = classification(item.classification, state);
1538
+ const fingerprint = this.revision({
1539
+ kind: "memory",
1540
+ id: item.id,
1541
+ scope: item.scope,
1542
+ status: item.status,
1543
+ updatedAt: item.updatedAt,
1544
+ classification: effectiveClassification,
1545
+ sessionId: state.sessionId,
1546
+ projectIdentity: state.projectIdentity,
1547
+ });
1548
+ const targetRevision = this.revision({ fingerprint, branchRevision });
1549
+ this.rememberTargetRevision(targetRevision, {
1550
+ kind: "memory",
1551
+ id: item.id,
1552
+ sessionId: state.sessionId,
1553
+ projectIdentity: state.projectIdentity,
1554
+ branchRevision,
1555
+ fingerprint,
1556
+ emittedAt: this.now(),
1557
+ });
1558
+ return {
1559
+ id: item.id,
1560
+ kind: "memory",
1561
+ scope: item.scope,
1562
+ status: item.status,
1563
+ classification: effectiveClassification,
1564
+ createdAt: item.createdAt,
1565
+ updatedAt: item.updatedAt,
1566
+ targetRevision,
1567
+ };
1568
+ }
1569
+
1570
+ private listPins(
1571
+ state: ContextPersistenceRuntimeState,
1572
+ ctx: ExtensionContext,
1573
+ activeOnly: boolean,
1574
+ maxResults: number,
1575
+ ): ReturnType<typeof buildReadResult> {
1576
+ const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1577
+ const branchRevision = this.branchRevision(ctx);
1578
+ const page = this.runtime.contextPersistenceListPinsPage(ctx, activeOnly, maxResults);
1579
+ const items = page.items
1580
+ .filter((item) => opaqueId(item.id))
1581
+ .map((item) => this.pinDto(item, state, branchRevision, pinApplicable(item, branchIds)));
1582
+ return buildReadResult({
1583
+ action: "pins_list",
1584
+ items,
1585
+ truncated: page.hasMore,
1586
+ incomplete: false,
1587
+ });
1588
+ }
1589
+
1590
+ private listMemories(
1591
+ state: ContextPersistenceRuntimeState,
1592
+ ctx: ExtensionContext,
1593
+ activeOnly: boolean,
1594
+ maxResults: number,
1595
+ ): ReturnType<typeof buildReadResult> {
1596
+ const branchRevision = this.branchRevision(ctx);
1597
+ const page = this.runtime.contextPersistenceListMemoriesPage(activeOnly, maxResults);
1598
+ const items = page.items
1599
+ .filter((item) => opaqueId(item.id))
1600
+ .map((item) => this.memoryDto(item, state, branchRevision));
1601
+ return buildReadResult({
1602
+ action: "memory_list",
1603
+ items,
1604
+ truncated: page.hasMore,
1605
+ incomplete: false,
1606
+ });
1607
+ }
1608
+
1609
+ private findPins(
1610
+ state: ContextPersistenceRuntimeState,
1611
+ ctx: ExtensionContext,
1612
+ rawQuery: string,
1613
+ activeOnly: boolean,
1614
+ maxResults: number,
1615
+ signal: AbortSignal | undefined,
1616
+ ): ReturnType<typeof buildReadResult> {
1617
+ const branchIds = new Set(ctx.sessionManager.getBranch().map((entry) => entry.id));
1618
+ const branchRevision = this.branchRevision(ctx);
1619
+ const query = normalizeSearch(rawQuery);
1620
+ const queryTerms = terms(query);
1621
+ const exactId = rawQuery.trim();
1622
+ const exact = opaqueId(exactId)
1623
+ ? this.runtime.contextPersistenceResolvePin(exactId, activeOnly)
1624
+ : undefined;
1625
+ const bounded = exact
1626
+ ? { items: [exact], incomplete: false, aborted: false }
1627
+ : this.runtime.contextPersistenceScanPins(
1628
+ activeOnly,
1629
+ FIND_PAGE_SIZE,
1630
+ FIND_SCAN_CAP,
1631
+ signal,
1632
+ );
1633
+ if (bounded.aborted) {
1634
+ return buildFailureResult("pins_find", "cancelled", "aborted") as ReturnType<typeof buildReadResult>;
1635
+ }
1636
+ let incomplete = bounded.incomplete;
1637
+ const scored: ScoredPin[] = [];
1638
+ const scan = bounded.items.filter((item) => opaqueId(item.id));
1639
+ for (let offset = 0; offset < scan.length; offset += FIND_PAGE_SIZE) {
1640
+ if (signal?.aborted) return buildFailureResult("pins_find", "cancelled", "aborted") as ReturnType<typeof buildReadResult>;
1641
+ for (const item of scan.slice(offset, offset + FIND_PAGE_SIZE)) {
1642
+ const applicable = pinApplicable(item, branchIds);
1643
+ let match: { matchKind: MatchKind; baseScore: number } | undefined;
1644
+ if (item === exact) match = { matchKind: "metadata-only", baseScore: 120 };
1645
+ else if (item.content.length > BODY_CEILING) {
1646
+ incomplete = true;
1647
+ continue;
1648
+ } else match = matchText(query, queryTerms, item.content);
1649
+ if (!match) continue;
1650
+ let preview: string | undefined;
1651
+ if (item !== exact) {
1652
+ const effectiveClassification = classification(item.classification, state);
1653
+ const sanitized = this.runtime.contextPersistenceSanitizeText(
1654
+ item.content,
1655
+ effectiveClassification,
1656
+ state.provider,
1657
+ );
1658
+ if (sanitized.allowed) {
1659
+ preview = Array.from(sanitized.value).slice(0, PREVIEW_SCALARS).join("") || undefined;
1660
+ }
1661
+ }
1662
+ scored.push({
1663
+ item,
1664
+ applicable,
1665
+ matchKind: match.matchKind,
1666
+ score: match.baseScore + (item.status === "active" ? 10 : 0),
1667
+ ...(preview ? { preview } : {}),
1668
+ previewStatus: preview ? "included" : "omitted-by-policy",
1669
+ });
1670
+ }
1671
+ }
1672
+ scored.sort(scoredPinOrder);
1673
+ const page = scored.slice(0, maxResults + 1);
1674
+ const previews = new Map<string, string>();
1675
+ const items: ContextPersistenceReadItem[] = page.slice(0, maxResults).map((candidate) => {
1676
+ if (candidate.preview) previews.set(candidate.item.id, candidate.preview);
1677
+ return {
1678
+ ...this.pinDto(candidate.item, state, branchRevision, candidate.applicable),
1679
+ matchKind: candidate.matchKind,
1680
+ score: candidate.score,
1681
+ previewStatus: candidate.previewStatus,
1682
+ };
1683
+ });
1684
+ return buildReadResult({
1685
+ action: "pins_find",
1686
+ items,
1687
+ truncated: page.length > maxResults,
1688
+ incomplete,
1689
+ previews,
1690
+ });
1691
+ }
1692
+
1693
+ private findMemories(
1694
+ state: ContextPersistenceRuntimeState,
1695
+ ctx: ExtensionContext,
1696
+ rawQuery: string,
1697
+ activeOnly: boolean,
1698
+ maxResults: number,
1699
+ signal: AbortSignal | undefined,
1700
+ ): ReturnType<typeof buildReadResult> {
1701
+ const branchRevision = this.branchRevision(ctx);
1702
+ const query = normalizeSearch(rawQuery);
1703
+ const queryTerms = terms(query);
1704
+ const exactId = rawQuery.trim();
1705
+ const exact = opaqueId(exactId)
1706
+ ? this.runtime.contextPersistenceResolveMemory(exactId, activeOnly)
1707
+ : undefined;
1708
+ const bounded = exact
1709
+ ? { items: [exact], incomplete: false, aborted: false }
1710
+ : this.runtime.contextPersistenceScanMemories(
1711
+ activeOnly,
1712
+ FIND_PAGE_SIZE,
1713
+ FIND_SCAN_CAP,
1714
+ signal,
1715
+ );
1716
+ if (bounded.aborted) {
1717
+ return buildFailureResult("memory_find", "cancelled", "aborted") as ReturnType<typeof buildReadResult>;
1718
+ }
1719
+ let incomplete = bounded.incomplete;
1720
+ const scored: ScoredMemory[] = [];
1721
+ const scan = bounded.items.filter((item) => opaqueId(item.id));
1722
+ for (let offset = 0; offset < scan.length; offset += FIND_PAGE_SIZE) {
1723
+ if (signal?.aborted) return buildFailureResult("memory_find", "cancelled", "aborted") as ReturnType<typeof buildReadResult>;
1724
+ for (const item of scan.slice(offset, offset + FIND_PAGE_SIZE)) {
1725
+ let match: { matchKind: MatchKind; baseScore: number } | undefined;
1726
+ if (item === exact) match = { matchKind: "metadata-only", baseScore: 120 };
1727
+ else if (item.claim.length > BODY_CEILING) {
1728
+ if (item.key && normalizeSearch(item.key) === query) match = { matchKind: "exact-key", baseScore: 100 };
1729
+ incomplete = true;
1730
+ } else match = matchText(query, queryTerms, item.claim, item.key);
1731
+ if (!match) continue;
1732
+ let preview: string | undefined;
1733
+ if (item !== exact && item.claim.length <= BODY_CEILING) {
1734
+ const effectiveClassification = classification(item.classification, state);
1735
+ const sanitized = this.runtime.contextPersistenceSanitizeText(
1736
+ item.claim,
1737
+ effectiveClassification,
1738
+ state.provider,
1739
+ );
1740
+ if (sanitized.allowed) {
1741
+ preview = Array.from(sanitized.value).slice(0, PREVIEW_SCALARS).join("") || undefined;
1742
+ }
1743
+ }
1744
+ scored.push({
1745
+ item,
1746
+ matchKind: match.matchKind,
1747
+ score: match.baseScore + (item.status === "active" ? 10 : 0),
1748
+ ...(preview ? { preview } : {}),
1749
+ previewStatus: preview ? "included" : "omitted-by-policy",
1750
+ });
1751
+ }
1752
+ }
1753
+ scored.sort(scoredMemoryOrder);
1754
+ const page = scored.slice(0, maxResults + 1);
1755
+ const previews = new Map<string, string>();
1756
+ const items: ContextPersistenceReadItem[] = page.slice(0, maxResults).map((candidate) => {
1757
+ if (candidate.preview) previews.set(candidate.item.id, candidate.preview);
1758
+ return {
1759
+ ...this.memoryDto(candidate.item, state, branchRevision),
1760
+ matchKind: candidate.matchKind,
1761
+ score: candidate.score,
1762
+ previewStatus: candidate.previewStatus,
1763
+ };
1764
+ });
1765
+ return buildReadResult({
1766
+ action: "memory_find",
1767
+ items,
1768
+ truncated: page.length > maxResults,
1769
+ incomplete,
1770
+ previews,
1771
+ });
1772
+ }
1773
+
1774
+ private listSources(
1775
+ state: ContextPersistenceRuntimeState,
1776
+ maxResults: number,
1777
+ ): ReturnType<typeof buildReadResult> | ReturnType<typeof buildFailureResult> {
1778
+ if (!state.projectTrusted) return buildFailureResult("memory_sources", "unavailable", "project-untrusted");
1779
+ if (!state.crossSessionEnabled) return buildFailureResult("memory_sources", "unavailable", "cross-session-disabled");
1780
+ if (!state.crossSessionReady) return buildFailureResult("memory_sources", "unavailable", "memory-unavailable");
1781
+ const page = this.runtime.contextPersistenceProjectSourcesPage(maxResults);
1782
+ const items: SourceReadItem[] = page.items.map((source) => {
1783
+ const sourceRef = this.sourceRef(state.projectIdentity, source.sessionId);
1784
+ const errorCode = source.status === "missing"
1785
+ ? "source-missing"
1786
+ : source.status === "corrupt"
1787
+ ? "source-corrupt"
1788
+ : source.malformedLines > 0
1789
+ ? "source-malformed"
1790
+ : undefined;
1791
+ const targetRevision = this.sourceFingerprint(source, state);
1792
+ this.sourceRevisions.set(targetRevision, {
1793
+ sourceRef,
1794
+ sourceSessionId: source.sessionId,
1795
+ sessionId: state.sessionId,
1796
+ projectIdentity: state.projectIdentity,
1797
+ fingerprint: targetRevision,
1798
+ emittedAt: this.now(),
1799
+ });
1800
+ this.pruneRevisions();
1801
+ return {
1802
+ sourceRef,
1803
+ kind: "project-memory-source",
1804
+ status: source.status,
1805
+ indexedMutations: source.indexedMutations,
1806
+ activeProjectMemories: source.activeProjectMemories,
1807
+ activeProjectPins: source.activeProjectPins,
1808
+ hasMalformedLines: source.malformedLines > 0,
1809
+ targetRevision,
1810
+ ...(errorCode ? { errorCode } : {}),
1811
+ };
1812
+ });
1813
+ return buildReadResult({
1814
+ action: "memory_sources",
1815
+ items,
1816
+ truncated: page.hasMore,
1817
+ incomplete: false,
1818
+ });
1819
+ }
1820
+
1821
+ private sourceFingerprint(
1822
+ source: ProjectMemorySource,
1823
+ state: ContextPersistenceRuntimeState,
1824
+ ): string {
1825
+ return this.revision({
1826
+ kind: "project-memory-source",
1827
+ projectIdentity: state.projectIdentity,
1828
+ sessionId: source.sessionId,
1829
+ indexedAt: source.indexedAt,
1830
+ status: source.status,
1831
+ indexedMutations: source.indexedMutations,
1832
+ activeProjectMemories: source.activeProjectMemories,
1833
+ activeProjectPins: source.activeProjectPins,
1834
+ malformedLines: source.malformedLines,
1835
+ });
1836
+ }
1837
+
1838
+ private resolveSourceHandle(
1839
+ sourceRef: string,
1840
+ state: ContextPersistenceRuntimeState,
1841
+ ): SourceHandle | undefined {
1842
+ const now = this.now();
1843
+ this.pruneSourceRefs(now);
1844
+ const handle = this.sourceByRef.get(sourceRef);
1845
+ if (!handle || handle.projectIdentity !== state.projectIdentity) return undefined;
1846
+ handle.lastAccessAt = now;
1847
+ return handle;
1848
+ }
1849
+
1850
+ private sourceRevisionMatches(
1851
+ source: ProjectMemorySource,
1852
+ sourceRef: string,
1853
+ targetRevision: string | undefined,
1854
+ state: ContextPersistenceRuntimeState,
1855
+ ): boolean {
1856
+ if (!targetRevision) return false;
1857
+ this.pruneRevisions();
1858
+ const revision = this.sourceRevisions.get(targetRevision);
1859
+ if (!revision
1860
+ || revision.sourceRef !== sourceRef
1861
+ || revision.sourceSessionId !== source.sessionId
1862
+ || revision.sessionId !== state.sessionId
1863
+ || revision.projectIdentity !== state.projectIdentity) return false;
1864
+ return revision.fingerprint === this.sourceFingerprint(source, state);
1865
+ }
1866
+
1867
+ private sourceMutationResult(
1868
+ action: "memory_source_exclude" | "memory_source_include",
1869
+ outcome: "ok" | "committed",
1870
+ sourceRef: string,
1871
+ source: ProjectMemorySource,
1872
+ state: ContextPersistenceRuntimeState,
1873
+ ): ReturnType<typeof buildMutationResult> {
1874
+ const targetRevision = this.sourceFingerprint(source, state);
1875
+ this.sourceRevisions.set(targetRevision, {
1876
+ sourceRef,
1877
+ sourceSessionId: source.sessionId,
1878
+ sessionId: state.sessionId,
1879
+ projectIdentity: state.projectIdentity,
1880
+ fingerprint: targetRevision,
1881
+ emittedAt: this.now(),
1882
+ });
1883
+ this.pruneRevisions();
1884
+ return buildMutationResult({
1885
+ action,
1886
+ outcome,
1887
+ persistenceClass: "derived-local-policy",
1888
+ sourceRef,
1889
+ kind: "project-memory-source",
1890
+ status: source.status,
1891
+ targetRevision,
1892
+ });
1893
+ }
1894
+
1895
+ private sourceRef(projectIdentity: string, sessionId: string): string {
1896
+ const now = this.now();
1897
+ this.pruneSourceRefs(now);
1898
+ const key = `${projectIdentity}\0${sessionId}`;
1899
+ const existing = this.sourceByKey.get(key);
1900
+ if (existing && now - existing.emittedAt <= SOURCE_REF_TTL_MS) {
1901
+ existing.emittedAt = now;
1902
+ existing.lastAccessAt = now;
1903
+ return existing.sourceRef;
1904
+ }
1905
+ if (existing) this.deleteSourceHandle(existing);
1906
+ let sourceRef: string;
1907
+ do sourceRef = `source_${Buffer.from(this.bytes(16)).toString("base64url")}`;
1908
+ while (this.sourceByRef.has(sourceRef));
1909
+ const handle = { sourceRef, projectIdentity, sessionId, emittedAt: now, lastAccessAt: now };
1910
+ this.sourceByKey.set(key, handle);
1911
+ this.sourceByRef.set(sourceRef, handle);
1912
+ this.pruneSourceRefs(now);
1913
+ return sourceRef;
1914
+ }
1915
+
1916
+ private pruneSourceRefs(now: number): void {
1917
+ for (const handle of this.sourceByRef.values()) {
1918
+ if (now - handle.emittedAt > SOURCE_REF_TTL_MS) this.deleteSourceHandle(handle);
1919
+ }
1920
+ if (this.sourceByRef.size <= SOURCE_REF_CAP) return;
1921
+ const oldest = [...this.sourceByRef.values()]
1922
+ .sort((left, right) => left.lastAccessAt - right.lastAccessAt || compareAscii(left.sourceRef, right.sourceRef));
1923
+ for (const handle of oldest.slice(0, this.sourceByRef.size - SOURCE_REF_CAP)) this.deleteSourceHandle(handle);
1924
+ }
1925
+
1926
+ private deleteSourceHandle(handle: SourceHandle): void {
1927
+ this.sourceByRef.delete(handle.sourceRef);
1928
+ this.sourceByKey.delete(`${handle.projectIdentity}\0${handle.sessionId}`);
1929
+ }
1930
+ }
1931
+
1932
+ export function createContextPersistenceTool(
1933
+ runtime: ContextPersistenceRuntimePort,
1934
+ dependencies: ContextPersistenceToolDependencies = {},
1935
+ ): ToolDefinition<typeof CONTEXT_PERSISTENCE_PARAMS, ContextPersistenceDetails> {
1936
+ return new ContextPersistenceToolController(runtime, dependencies).definition();
1937
+ }
1938
+
1939
+ export function registerContextPersistenceTool(
1940
+ pi: ExtensionAPI,
1941
+ runtime: ContextPersistenceRuntimePort,
1942
+ dependencies: ContextPersistenceToolDependencies = {},
1943
+ ): void {
1944
+ pi.registerTool(createContextPersistenceTool(runtime, {
1945
+ ...dependencies,
1946
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
1947
+ }));
1948
+ }