prime-agent-dsh 0.2.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,186 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment";
3
+ import { LocalAttachmentStore } from "@deepseek-ai/dsh-attachment-local";
4
+ import { lstat, readdir, rm } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
6
+
7
+ const ID = /^sha256:([a-f0-9]{64})$/;
8
+
9
+ export interface DurableFileAttachmentOptions {
10
+ /** Dedicated DSH home. DSH stores files below attachments/v1. */
11
+ readonly dshHome: string;
12
+ readonly maxObjectBytes?: number;
13
+ readonly maxTotalBytes?: number;
14
+ readonly maxObjects?: number;
15
+ }
16
+
17
+ export interface AttachmentUsage { readonly objects: number; readonly bytes: number }
18
+ export interface AttachmentCleanupResult extends AttachmentUsage { readonly removedObjects: number; readonly removedBytes: number }
19
+
20
+ /**
21
+ * Provider-neutral durable files backed by DSH 0.1.6's verbatim attachment
22
+ * primitives. References contain no model, message, or tool owner.
23
+ */
24
+ export class DurableFileAttachments {
25
+ readonly store: LocalAttachmentStore;
26
+ private readonly maxObjectBytes: number;
27
+ private readonly maxTotalBytes: number;
28
+ private readonly maxObjects: number;
29
+ private operation: Promise<void> = Promise.resolve();
30
+
31
+ constructor(options: DurableFileAttachmentOptions) {
32
+ if (!isAbsolute(options.dshHome)) throw new Error("dshHome must be absolute");
33
+ this.maxObjectBytes = positive(options.maxObjectBytes ?? 64 * 1024 * 1024, "maxObjectBytes");
34
+ this.maxTotalBytes = positive(options.maxTotalBytes ?? 512 * 1024 * 1024, "maxTotalBytes");
35
+ this.maxObjects = positive(options.maxObjects ?? 10_000, "maxObjects");
36
+ this.store = new LocalAttachmentStore(new Context(), { dshHome: options.dshHome });
37
+ }
38
+
39
+ async save(data: Uint8Array, name = "attachment.bin"): Promise<FileAttachmentRef> {
40
+ if (data.byteLength > this.maxObjectBytes) throw new Error("attachment exceeds maxObjectBytes");
41
+ return this.exclusive(async () => {
42
+ const digest = await sha256(data);
43
+ const usage = await this.usage();
44
+ const duplicate = await this.objectExists(digest);
45
+ if (!duplicate && (usage.objects >= this.maxObjects || usage.bytes + data.byteLength > this.maxTotalBytes)) {
46
+ throw new Error("attachment quota exceeded");
47
+ }
48
+ return this.store.saveFile({ data, name });
49
+ });
50
+ }
51
+
52
+ /** Read only after DSH verifies both byte length and sha256 digest. */
53
+ async read(ref: FileAttachmentRef, signal?: AbortSignal): Promise<Uint8Array> {
54
+ await this.assertSafeLocator(ref);
55
+ if (ref.bytes > this.maxObjectBytes) throw new Error("attachment exceeds maxObjectBytes");
56
+ const chunks: Uint8Array[] = []; let size = 0;
57
+ for await (const chunk of this.store.readFileStream(ref, signal)) { size += chunk.byteLength; if (size > this.maxObjectBytes || size > ref.bytes) throw new Error("attachment read exceeds declared bound"); chunks.push(chunk); }
58
+ if (size !== ref.bytes) throw new Error("attachment byte length mismatch");
59
+ const value = new Uint8Array(size); let offset = 0;
60
+ for (const chunk of chunks) { value.set(chunk, offset); offset += chunk.byteLength; }
61
+ return value;
62
+ }
63
+
64
+ async *readStream(ref: FileAttachmentRef, signal?: AbortSignal): AsyncIterable<Uint8Array> {
65
+ await this.assertSafeLocator(ref); if (ref.bytes > this.maxObjectBytes) throw new Error("attachment exceeds maxObjectBytes");
66
+ let size = 0; for await (const chunk of this.store.readFileStream(ref, signal)) { size += chunk.byteLength; if (size > this.maxObjectBytes || size > ref.bytes) throw new Error("attachment stream exceeds declared bound"); yield chunk; }
67
+ if (size !== ref.bytes) throw new Error("attachment byte length mismatch");
68
+ }
69
+
70
+ /** Absolute read-only host locator. Invalid/traversing references are rejected by DSH. */
71
+ hostPath(ref: FileAttachmentRef): string {
72
+ return this.store.fileHostPath(ref);
73
+ }
74
+
75
+ async usage(): Promise<AttachmentUsage> {
76
+ const root = join(this.store.root, "file-objects");
77
+ let objects = 0; let bytes = 0;
78
+ for (const path of await objectPaths(root)) {
79
+ const stat = await lstat(path);
80
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`unsafe attachment object: ${path}`);
81
+ objects++; bytes += stat.size;
82
+ }
83
+ return { objects, bytes };
84
+ }
85
+
86
+ /**
87
+ * Delete generic files not present in `retain`. Callers pass all live durable
88
+ * references. Image objects are never touched. Unsafe links abort cleanup.
89
+ */
90
+ async cleanup(retain: readonly FileAttachmentRef[]): Promise<AttachmentCleanupResult> {
91
+ return this.exclusive(async () => {
92
+ const keep = new Set(retain.map(digestOf));
93
+ const root = join(this.store.root, "file-objects");
94
+ let removedObjects = 0; let removedBytes = 0;
95
+ for (const path of await objectPaths(root)) {
96
+ const stat = await lstat(path);
97
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`unsafe attachment object: ${path}`);
98
+ const digest = path.slice(path.lastIndexOf("/") + 1);
99
+ if (!keep.has(digest)) {
100
+ const aliases = join(this.store.root, "files", digest.slice(0, 2), digest);
101
+ await assertSafeTree(aliases);
102
+ await rm(path); removedObjects++; removedBytes += stat.size;
103
+ // Aliases are hard links, but may use many safe display names.
104
+ await rm(aliases, { recursive: true, force: true });
105
+ }
106
+ }
107
+ const usage = await this.usage();
108
+ return { ...usage, removedObjects, removedBytes };
109
+ });
110
+ }
111
+
112
+ private async assertSafeLocator(ref: FileAttachmentRef): Promise<void> {
113
+ digestOf(ref);
114
+ const root = this.store.root;
115
+ const path = this.store.fileHostPath(ref);
116
+ const rel = relative(root, path); if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("attachment path escapes root");
117
+ const rootStat = await lstat(root); if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("unsafe attachment root");
118
+ for (let cursor = path; cursor !== root; cursor = dirname(cursor)) {
119
+ const stat = await lstat(cursor);
120
+ if (stat.isSymbolicLink()) throw new Error(`symlink is not allowed in attachment path: ${cursor}`);
121
+ if (cursor === path && !stat.isFile()) throw new Error("attachment locator is not a file");
122
+ if (dirname(cursor) === cursor) throw new Error("attachment path escapes root");
123
+ }
124
+ }
125
+
126
+ private async objectExists(digest: string): Promise<boolean> {
127
+ try {
128
+ const stat = await lstat(join(this.store.root, "file-objects", digest.slice(0, 2), digest));
129
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("unsafe attachment object");
130
+ return true;
131
+ } catch (error) {
132
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ private async exclusive<T>(work: () => Promise<T>): Promise<T> {
138
+ const prior = this.operation; let release!: () => void;
139
+ this.operation = new Promise<void>((resolve) => { release = resolve; });
140
+ await prior;
141
+ try { return await work(); } finally { release(); }
142
+ }
143
+ }
144
+
145
+ function positive(value: number, name: string): number {
146
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
147
+ return value;
148
+ }
149
+ function digestOf(ref: FileAttachmentRef): string {
150
+ const match = ID.exec(String(ref.attachmentId));
151
+ if (!match || !Number.isSafeInteger(ref.bytes) || ref.bytes < 0) throw new Error("invalid attachment reference");
152
+ return match[1];
153
+ }
154
+ async function sha256(data: Uint8Array): Promise<string> {
155
+ const { createHash } = await import("node:crypto");
156
+ return createHash("sha256").update(data).digest("hex");
157
+ }
158
+ async function objectPaths(root: string): Promise<string[]> {
159
+ let buckets;
160
+ try { buckets = await readdir(root, { withFileTypes: true }); }
161
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
162
+ const result: string[] = [];
163
+ for (const bucket of buckets) {
164
+ if (!bucket.isDirectory() || bucket.isSymbolicLink() || !/^[a-f0-9]{2}$/.test(bucket.name)) throw new Error("unsafe attachment object directory");
165
+ for (const item of await readdir(join(root, bucket.name), { withFileTypes: true })) {
166
+ if (!item.isFile() || item.isSymbolicLink() || !/^[a-f0-9]{64}$/.test(item.name) || !item.name.startsWith(bucket.name)) throw new Error("unsafe attachment object entry");
167
+ result.push(join(root, bucket.name, item.name));
168
+ }
169
+ }
170
+ return result;
171
+ }
172
+
173
+ async function assertSafeTree(root: string): Promise<void> {
174
+ let entries;
175
+ try {
176
+ const stat = await lstat(root);
177
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`unsafe attachment alias directory: ${root}`);
178
+ entries = await readdir(root, { withFileTypes: true });
179
+ } catch (error) {
180
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
181
+ throw error;
182
+ }
183
+ for (const entry of entries) {
184
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`unsafe attachment alias: ${entry.name}`);
185
+ }
186
+ }
@@ -0,0 +1,91 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface PrefixMeasurement {
4
+ request: number;
5
+ bytes: number;
6
+ previousBytes: number;
7
+ commonPrefixBytes: number;
8
+ prefixRatio: number;
9
+ digest: string;
10
+ changedAt: number;
11
+ reason: "initial" | "append" | "model-or-envelope" | "history-rewrite";
12
+ }
13
+
14
+ export function stableJson(value: unknown, sortKeys = true): string {
15
+ const seen = new WeakSet<object>();
16
+ const visit = (item: unknown): unknown => {
17
+ if (Array.isArray(item)) return item.map(visit);
18
+ if (item && typeof item === "object") {
19
+ if (seen.has(item)) throw new Error("cyclic provider payload");
20
+ seen.add(item);
21
+ const result: Record<string, unknown> = {};
22
+ for (const key of (sortKeys ? Object.keys(item).sort() : Object.keys(item))) {
23
+ if (/authorization|api[_-]?key|token|secret|password|cookie/i.test(key)) { result[key] = "[REDACTED]"; continue; }
24
+ const value = (item as Record<string, unknown>)[key];
25
+ if (value !== undefined) result[key] = visit(value);
26
+ }
27
+ return result;
28
+ }
29
+ return typeof item === "bigint" ? item.toString() : item;
30
+ };
31
+ return JSON.stringify(visit(value));
32
+ }
33
+
34
+ export function commonPrefixBytes(left: string, right: string): number {
35
+ const a = Buffer.from(left), b = Buffer.from(right); const n = Math.min(a.length, b.length);
36
+ let i = 0; while (i < n && a[i] === b[i]) i++; return i;
37
+ }
38
+
39
+ interface PrefixFingerprint {
40
+ readonly bytes: number;
41
+ readonly digest: string;
42
+ /** Fixed-size chunk digests. They retain no provider or transcript text. */
43
+ readonly chunks: readonly string[];
44
+ }
45
+
46
+ const PREFIX_CHUNK_BYTES = 256;
47
+
48
+ function fingerprint(value: string): PrefixFingerprint {
49
+ const bytes = Buffer.from(value);
50
+ const chunks: string[] = [];
51
+ for (let offset = 0; offset < bytes.length; offset += PREFIX_CHUNK_BYTES) {
52
+ chunks.push(createHash("sha256").update(bytes.subarray(offset, offset + PREFIX_CHUNK_BYTES)).digest("hex"));
53
+ }
54
+ return { bytes: bytes.length, digest: createHash("sha256").update(bytes).digest("hex"), chunks: Object.freeze(chunks) };
55
+ }
56
+
57
+ function measuredCommonPrefix(previous: PrefixFingerprint, currentText: string, current: PrefixFingerprint): number {
58
+ const bytes = Buffer.from(currentText);
59
+ // Exact append detection needs only the prior digest, not the prior content.
60
+ if (bytes.length >= previous.bytes && createHash("sha256").update(bytes.subarray(0, previous.bytes)).digest("hex") === previous.digest) {
61
+ return previous.bytes;
62
+ }
63
+ let chunks = 0;
64
+ const complete = Math.floor(Math.min(previous.bytes, current.bytes) / PREFIX_CHUNK_BYTES);
65
+ while (chunks < complete && previous.chunks[chunks] === current.chunks[chunks]) chunks++;
66
+ // For rewrites this is a privacy-preserving lower bound, accurate to one chunk.
67
+ return chunks * PREFIX_CHUNK_BYTES;
68
+ }
69
+
70
+ export class PrefixTracker {
71
+ private previous?: PrefixFingerprint;
72
+ private count = 0;
73
+ constructor(private readonly sortKeys = true) {}
74
+ measure(payload: unknown): PrefixMeasurement {
75
+ const canonical = stableJson(payload, this.sortKeys);
76
+ const current = fingerprint(canonical), previous = this.previous;
77
+ const common = previous === undefined ? 0 : measuredCommonPrefix(previous, canonical, current);
78
+ const bytes = current.bytes, previousBytes = previous?.bytes ?? 0;
79
+ let reason: PrefixMeasurement["reason"] = "initial";
80
+ if (previous !== undefined) {
81
+ if (common === previousBytes || common / Math.max(1, previousBytes) >= 0.9) reason = "append";
82
+ else if (common < Math.min(previousBytes, 1024)) reason = "model-or-envelope";
83
+ else reason = "history-rewrite";
84
+ }
85
+ this.previous = current;
86
+ return { request: ++this.count, bytes, previousBytes, commonPrefixBytes: common,
87
+ prefixRatio: previousBytes === 0 ? 0 : common / previousBytes,
88
+ digest: current.digest, changedAt: common, reason };
89
+ }
90
+ reset(): void { this.previous = undefined; this.count = 0; }
91
+ }
@@ -0,0 +1,56 @@
1
+ /** Provider cache accounting. Byte-prefix eligibility belongs in prefix-metrics, not here. */
2
+ export interface ProviderCacheSample {
3
+ readonly request: number;
4
+ readonly inputTokens?: number;
5
+ readonly cacheReadTokens?: number;
6
+ readonly cacheWriteTokens?: number;
7
+ }
8
+ export interface ProviderCachePoint extends ProviderCacheSample {
9
+ /** null means the provider did not report enough data. */
10
+ readonly efficiency: number | null;
11
+ }
12
+ export interface ProviderCacheAggregate {
13
+ readonly requests: number;
14
+ readonly reportedReadRequests: number;
15
+ readonly reportedWriteRequests: number;
16
+ readonly cacheReadTokens: number;
17
+ readonly cacheWriteTokens: number;
18
+ readonly inputTokens: number;
19
+ readonly efficiency: number | null;
20
+ readonly readP50: number | null;
21
+ readonly readP90: number | null;
22
+ readonly writeP50: number | null;
23
+ readonly writeP90: number | null;
24
+ readonly efficiencyP50: number | null;
25
+ readonly efficiencyP90: number | null;
26
+ }
27
+ const valid = (x: unknown): x is number => typeof x === "number" && Number.isFinite(x) && x >= 0;
28
+ function percentile(values: readonly number[], p: number): number | null {
29
+ if (!values.length) return null;
30
+ const sorted = [...values].sort((a, b) => a - b);
31
+ return sorted[Math.ceil(p * sorted.length) - 1] ?? null;
32
+ }
33
+ export class ProviderCacheSeries {
34
+ private readonly values: ProviderCachePoint[] = [];
35
+ add(sample: ProviderCacheSample): ProviderCachePoint {
36
+ if (!Number.isSafeInteger(sample.request) || sample.request <= 0) throw new TypeError("request must be a positive integer");
37
+ for (const [key, value] of Object.entries(sample)) if (key !== "request" && value !== undefined && !valid(value)) throw new TypeError(`${key} must be non-negative`);
38
+ const read = sample.cacheReadTokens, input = sample.inputTokens;
39
+ const efficiency = valid(read) && valid(input) && input + read > 0 ? read / (input + read) : null;
40
+ const point = Object.freeze({ ...sample, efficiency }); this.values.push(point); return point;
41
+ }
42
+ points(): readonly ProviderCachePoint[] { return Object.freeze([...this.values]); }
43
+ aggregate(): ProviderCacheAggregate {
44
+ const reads = this.values.flatMap(x => valid(x.cacheReadTokens) ? [x.cacheReadTokens] : []);
45
+ const writes = this.values.flatMap(x => valid(x.cacheWriteTokens) ? [x.cacheWriteTokens] : []);
46
+ const inputs = this.values.flatMap(x => valid(x.inputTokens) ? [x.inputTokens] : []);
47
+ const cacheReadTokens = reads.reduce((a, b) => a + b, 0), inputTokens = inputs.reduce((a, b) => a + b, 0);
48
+ return Object.freeze({ requests: this.values.length, reportedReadRequests: reads.length, reportedWriteRequests: writes.length,
49
+ cacheReadTokens, cacheWriteTokens: writes.reduce((a, b) => a + b, 0), inputTokens,
50
+ efficiency: reads.length && inputs.length && inputTokens + cacheReadTokens > 0 ? cacheReadTokens / (inputTokens + cacheReadTokens) : null,
51
+ readP50: percentile(reads, .5), readP90: percentile(reads, .9),
52
+ writeP50: percentile(writes, .5), writeP90: percentile(writes, .9),
53
+ efficiencyP50: percentile(this.values.flatMap(x => x.efficiency === null ? [] : [x.efficiency]), .5),
54
+ efficiencyP90: percentile(this.values.flatMap(x => x.efficiency === null ? [] : [x.efficiency]), .9) });
55
+ }
56
+ }
@@ -0,0 +1,215 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { ContextObjectStore, type ContextObjectSyncResult } from "./context-objects.js";
3
+ import { ProviderCacheSeries, type ProviderCacheAggregate, type ProviderCachePoint } from "./provider-cache-series.js";
4
+
5
+ const number = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
6
+
7
+ interface PendingSync {
8
+ readonly ctx: ExtensionContext;
9
+ readonly messages?: readonly unknown[];
10
+ }
11
+
12
+ export interface SessionContextScope {
13
+ readonly sessionId: string;
14
+ readonly cwd: string;
15
+ readonly bindingKey: string;
16
+ enabled: boolean;
17
+ disabledReason?: string;
18
+ lastSync?: ContextObjectSyncResult;
19
+ /** Process-local completion time for status/doctor freshness. */
20
+ lastSyncAt?: number;
21
+ lastError?: string;
22
+ syncs: number;
23
+ errors: number;
24
+ cache?: ProviderCacheAggregate;
25
+ latestCache?: ProviderCachePoint;
26
+ cacheSeries?: ProviderCacheSeries;
27
+ dirty?: boolean;
28
+ pending?: PendingSync;
29
+ running?: Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * Binds one derived DSH context scope to every Prime AgentSession, including
34
+ * independently rebound RLM descendants. Prime remains the only loop and log
35
+ * authority; scopes contain rebuildable projections and private artifacts.
36
+ */
37
+ export class RecursiveContextLoader {
38
+ private readonly scopes = new Map<string, SessionContextScope>();
39
+
40
+ constructor(private readonly store = new ContextObjectStore()) {}
41
+
42
+ register(pi: Pick<ExtensionAPI, "on">): void {
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ const scope = this.bind(ctx);
45
+ await this.requestSync(ctx, scope, undefined, true);
46
+ });
47
+ pi.on("context", (event, ctx) => {
48
+ const scope = this.scopeFor(ctx);
49
+ this.observeProviderCache(scope, event.messages);
50
+ void this.requestSync(ctx, scope, event.messages, false);
51
+ // Observation only: durable conversion and fsync run after this hook returns.
52
+ });
53
+ pi.on("message_end", (_event, ctx) => {
54
+ const scope = this.scopeFor(ctx);
55
+ void this.requestSync(ctx, scope, undefined, false);
56
+ });
57
+ pi.on("turn_end", async (_event, ctx) => {
58
+ const scope = this.scopeFor(ctx);
59
+ await this.requestSync(ctx, scope, undefined, true);
60
+ });
61
+ pi.on("session_compact", async (_event, ctx) => {
62
+ const scope = this.scopeFor(ctx);
63
+ await this.requestSync(ctx, scope, undefined, true);
64
+ });
65
+ pi.on("session_shutdown", async (_event, ctx) => {
66
+ if (!ctx?.sessionManager) { this.scopes.clear(); return; }
67
+ const sessionId = this.sessionId(ctx);
68
+ const scope = this.scopes.get(sessionId);
69
+ if (!scope || scope.bindingKey !== this.bindingKey(ctx)) return;
70
+ await this.requestSync(ctx, scope, undefined, true);
71
+ if (this.scopes.get(sessionId) === scope) this.scopes.delete(sessionId);
72
+ });
73
+ }
74
+
75
+ status(ctx: ExtensionContext): Readonly<SessionContextScope> | undefined {
76
+ return this.scopes.get(this.sessionId(ctx));
77
+ }
78
+
79
+ setEnabled(ctx: ExtensionContext, enabled: boolean): boolean {
80
+ const scope = this.scopes.get(this.sessionId(ctx)) ?? this.bind(ctx);
81
+ scope.enabled = enabled;
82
+ if (enabled) { delete scope.disabledReason; delete scope.lastError; }
83
+ return scope.enabled;
84
+ }
85
+
86
+ isEnabled(ctx: ExtensionContext): boolean {
87
+ return this.scopes.get(this.sessionId(ctx))?.enabled ?? true;
88
+ }
89
+
90
+ /** Record finalized provider usage before Prime emits the next context event. */
91
+ observeFinalizedAssistant(ctx: ExtensionContext, message: unknown): ProviderCachePoint | undefined {
92
+ if (!message || typeof message !== "object") return undefined;
93
+ const value = message as Record<string, unknown>;
94
+ if (value.role !== "assistant" || !value.usage || typeof value.usage !== "object") return undefined;
95
+ const usage = value.usage as Record<string, unknown>;
96
+ const scope = this.scopeFor(ctx);
97
+ const series = scope.cacheSeries ?? new ProviderCacheSeries();
98
+ const latest = series.add({
99
+ request: series.points().length + 1,
100
+ inputTokens: number(usage.input),
101
+ cacheReadTokens: number(usage.cacheRead),
102
+ cacheWriteTokens: number(usage.cacheWrite),
103
+ });
104
+ scope.cacheSeries = series;
105
+ scope.latestCache = latest;
106
+ scope.cache = series.aggregate();
107
+ return latest;
108
+ }
109
+
110
+ private async requestSync(ctx: ExtensionContext, scope: SessionContextScope, messages: readonly unknown[] | undefined, wait: boolean): Promise<void> {
111
+ if (!scope.enabled || this.scopes.get(scope.sessionId) !== scope) return;
112
+ scope.pending = { ctx: this.detach(ctx), ...(messages === undefined ? {} : { messages: [...messages] }) };
113
+ scope.dirty = true;
114
+ this.startWorker(scope);
115
+ if (wait) while (scope.running) await scope.running;
116
+ }
117
+
118
+ private startWorker(scope: SessionContextScope): void {
119
+ if (scope.running || !scope.enabled) return;
120
+ scope.running = Promise.resolve().then(async () => {
121
+ while (scope.dirty && scope.enabled && this.scopes.get(scope.sessionId) === scope) {
122
+ scope.dirty = false;
123
+ const pending = scope.pending;
124
+ if (!pending) continue;
125
+ await this.synchronize(pending.ctx, scope, pending.messages);
126
+ }
127
+ }).finally(() => {
128
+ scope.running = undefined;
129
+ if (scope.dirty && scope.enabled && this.scopes.get(scope.sessionId) === scope) this.startWorker(scope);
130
+ });
131
+ }
132
+
133
+ private async synchronize(ctx: ExtensionContext, scope: SessionContextScope, messages?: readonly unknown[]): Promise<void> {
134
+ if (!scope.enabled || this.scopes.get(scope.sessionId) !== scope) return;
135
+ try {
136
+ const synced = await this.store.sync(ctx, messages);
137
+ if (this.scopes.get(scope.sessionId) !== scope) return;
138
+ if (synced) {
139
+ scope.lastSync = synced;
140
+ scope.lastSyncAt = Date.now();
141
+ }
142
+ scope.lastError = undefined;
143
+ scope.syncs++;
144
+ } catch (error) {
145
+ if (this.scopes.get(scope.sessionId) !== scope) return;
146
+ scope.errors++;
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ scope.lastError = message;
149
+ if (error instanceof Error && (error.name === "DurablePublicationUnavailableError" || "code" in error && (error as Error & { code?: string }).code === "DURABLE_PUBLICATION_UNAVAILABLE")) {
150
+ scope.enabled = false;
151
+ scope.disabledReason = message;
152
+ scope.dirty = false;
153
+ scope.pending = undefined;
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Copy every host-owned value needed by deferred work while ctx is valid. */
159
+ private detach(ctx: ExtensionContext): ExtensionContext {
160
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? "";
161
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
162
+ const leafId = ctx.sessionManager.getLeafId?.();
163
+ const branch = [...(ctx.sessionManager.getBranch?.() ?? [])];
164
+ const cwd = ctx.cwd;
165
+ return {
166
+ cwd,
167
+ sessionManager: {
168
+ getSessionId: () => sessionId,
169
+ getSessionFile: () => sessionFile,
170
+ getLeafId: () => leafId,
171
+ getBranch: () => branch,
172
+ },
173
+ } as unknown as ExtensionContext;
174
+ }
175
+
176
+
177
+ /** Rebuild provider-reported cache accounting from the current native context. */
178
+ private observeProviderCache(scope: SessionContextScope, input: readonly unknown[]): void {
179
+ const series = new ProviderCacheSeries();
180
+ let request = 0;
181
+ for (const raw of input) {
182
+ if (!raw || typeof raw !== "object") continue;
183
+ const value = raw as Record<string, unknown>;
184
+ if (value.role !== "assistant" || !value.usage || typeof value.usage !== "object") continue;
185
+ const usage = value.usage as Record<string, unknown>;
186
+ request++;
187
+ series.add({ request, inputTokens: number(usage.input), cacheReadTokens: number(usage.cacheRead), cacheWriteTokens: number(usage.cacheWrite) });
188
+ }
189
+ scope.cacheSeries = series;
190
+ scope.cache = series.aggregate();
191
+ const latest = series.points().at(-1);
192
+ if (latest) scope.latestCache = latest; else delete scope.latestCache;
193
+ }
194
+
195
+ private sessionId(ctx: ExtensionContext): string {
196
+ return ctx.sessionManager.getSessionId?.() ?? ctx.cwd;
197
+ }
198
+
199
+ private bindingKey(ctx: ExtensionContext): string {
200
+ return JSON.stringify([this.sessionId(ctx), ctx.sessionManager.getSessionFile?.() ?? "", ctx.cwd]);
201
+ }
202
+
203
+ private scopeFor(ctx: ExtensionContext): SessionContextScope {
204
+ const prior = this.scopes.get(this.sessionId(ctx));
205
+ return prior?.bindingKey === this.bindingKey(ctx) ? prior : this.bind(ctx);
206
+ }
207
+
208
+ private bind(ctx: ExtensionContext): SessionContextScope {
209
+ const sessionId = this.sessionId(ctx);
210
+ const prior = this.scopes.get(sessionId);
211
+ const scope: SessionContextScope = { sessionId, cwd: ctx.cwd, bindingKey: this.bindingKey(ctx), enabled: prior?.enabled ?? true, syncs: 0, errors: 0 };
212
+ this.scopes.set(sessionId, scope);
213
+ return scope;
214
+ }
215
+ }