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.
- package/CHANGELOG.md +50 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/SECURITY.md +27 -0
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/docs/context-spill.md +7 -0
- package/docs/durable-context-query.md +34 -0
- package/docs/getting-started.md +253 -0
- package/docs/security.md +78 -0
- package/docs/shadow-telemetry-validation.md +18 -0
- package/docs/single-window-cache-architecture.md +24 -0
- package/extensions/index.ts +161 -0
- package/extensions/shadow-context.ts +168 -0
- package/package.json +109 -0
- package/scripts/package-smoke.mjs +148 -0
- package/scripts/patch-pi-ai-partial-json.mjs +14 -0
- package/skills/dsh-context/SKILL.md +62 -0
- package/skills/dsh-context/pyproject.toml +13 -0
- package/skills/dsh-context/src/dsh_context/__init__.py +573 -0
- package/src/context-converter.ts +176 -0
- package/src/context-objects.ts +247 -0
- package/src/context-protocol.ts +37 -0
- package/src/context-spill.ts +109 -0
- package/src/dsh-context-service.ts +38 -0
- package/src/dsh-image-attachments.ts +99 -0
- package/src/durable-context-query.ts +233 -0
- package/src/durable-context-store.ts +838 -0
- package/src/durable-file-attachments.ts +186 -0
- package/src/prefix-metrics.ts +91 -0
- package/src/provider-cache-series.ts +56 -0
- package/src/recursive-context-loader.ts +215 -0
- package/src/rlm-context-bootstrap.ts +333 -0
- package/src/rlm-context-inheritance.ts +389 -0
- package/src/shadow-telemetry.ts +84 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { freezeMessage, type ContentBlock, type Message } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import { MessageId, ToolCallId } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
|
4
|
+
|
|
5
|
+
/** Prime's model-context message shape. Kept structural so this bridge does not depend on Prime internals. */
|
|
6
|
+
export type PrimeMessage = Record<string, unknown> & { role: string };
|
|
7
|
+
export type PrimeEnvelope = { id?: string; parentId?: string | null; timestamp?: string; message: PrimeMessage };
|
|
8
|
+
export type ConversionCapability = "prime-image-admission" | "dsh-image-resolution";
|
|
9
|
+
export class ConversionCapabilityError extends Error {
|
|
10
|
+
readonly code = "CAPABILITY_UNAVAILABLE";
|
|
11
|
+
constructor(readonly capability: ConversionCapability, message: string, readonly data?: unknown) { super(message); }
|
|
12
|
+
}
|
|
13
|
+
export interface ConverterCapabilities {
|
|
14
|
+
/** Admit inline Prime bytes into DSH's durable attachment store. */
|
|
15
|
+
admitImage?: (image: { data: string; mimeType: string; name?: string }) => ImageAttachmentRef;
|
|
16
|
+
/** Resolve a DSH durable reference back to inline Prime bytes. */
|
|
17
|
+
resolveImage?: (attachment: ImageAttachmentRef) => { data: string; mimeType: string };
|
|
18
|
+
}
|
|
19
|
+
export interface AsyncConverterCapabilities {
|
|
20
|
+
/** Admit inline Prime bytes before constructing the immutable DSH message. */
|
|
21
|
+
admitImages?: (images: readonly { data: string; mimeType: string; name?: string }[]) => Promise<readonly ImageAttachmentRef[]>;
|
|
22
|
+
/** Resolve and verify DSH bytes before constructing a Prime message. */
|
|
23
|
+
resolveImage?: (attachment: ImageAttachmentRef) => Promise<{ data: string; mimeType: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PrimeMeta = { role: string; envelope?: Omit<PrimeEnvelope, "message">; fields: Record<string, unknown> };
|
|
27
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
28
|
+
const string = (v: unknown, what: string): string => { if (typeof v !== "string") throw new TypeError(`${what} must be a string`); return v; };
|
|
29
|
+
|
|
30
|
+
/** Detach metadata into the lossless-JSON subset required by DSH session events. */
|
|
31
|
+
function jsonBoundary(value: unknown, ancestors = new Set<object>()): unknown {
|
|
32
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
33
|
+
if (typeof value === "number") return Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : null;
|
|
34
|
+
if (typeof value === "bigint") return value.toString();
|
|
35
|
+
if (typeof value !== "object" || ancestors.has(value)) return undefined;
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
ancestors.add(value);
|
|
38
|
+
const result = Array.from({ length: value.length }, (_, index) => jsonBoundary(value[index], ancestors) ?? null);
|
|
39
|
+
ancestors.delete(value);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
const prototype = Reflect.getPrototypeOf(value);
|
|
43
|
+
if (prototype !== Object.prototype && prototype !== null) return undefined;
|
|
44
|
+
ancestors.add(value);
|
|
45
|
+
const result: Record<string, unknown> = {};
|
|
46
|
+
for (const key of Object.keys(value)) {
|
|
47
|
+
let item: unknown;
|
|
48
|
+
try { item = jsonBoundary((value as Record<string, unknown>)[key], ancestors); } catch { continue; }
|
|
49
|
+
if (item !== undefined) result[key] = item;
|
|
50
|
+
}
|
|
51
|
+
ancestors.delete(value);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
function jsonRecord(value: Record<string, unknown>): Record<string, unknown> {
|
|
55
|
+
return jsonBoundary(value) as Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
function parts(content: unknown): Record<string, unknown>[] {
|
|
58
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
59
|
+
if (!Array.isArray(content)) throw new TypeError("message content must be a string or array");
|
|
60
|
+
return content.map((p, i) => { if (!object(p) || typeof p.type !== "string") throw new TypeError(`invalid content block at index ${i}`); return p; });
|
|
61
|
+
}
|
|
62
|
+
function toDshBlocks(content: unknown, caps: ConverterCapabilities, assistant: boolean): ContentBlock[] {
|
|
63
|
+
return parts(content).map((p): ContentBlock => {
|
|
64
|
+
switch (p.type) {
|
|
65
|
+
case "text": return { type: "text", text: string(p.text, "text") };
|
|
66
|
+
case "thinking": if (!assistant) throw new TypeError("thinking is only valid in assistant content"); return { type: "reasoning", text: string(p.thinking, "thinking") };
|
|
67
|
+
case "toolCall": {
|
|
68
|
+
if (!assistant) throw new TypeError("toolCall is only valid in assistant content");
|
|
69
|
+
const args = p.arguments;
|
|
70
|
+
return { type: "tool-call", id: ToolCallId(string(p.id, "toolCall.id")), name: string(p.name, "toolCall.name"), arguments: typeof args === "string" ? args : JSON.stringify(jsonBoundary(args ?? {}) ?? {}) };
|
|
71
|
+
}
|
|
72
|
+
case "image": {
|
|
73
|
+
if (!caps.admitImage) throw new ConversionCapabilityError("prime-image-admission", "inline Prime images require an attachment admission capability", { mimeType: p.mimeType });
|
|
74
|
+
return { type: "image", attachment: caps.admitImage({ data: string(p.data, "image.data"), mimeType: string(p.mimeType, "image.mimeType"), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
|
|
75
|
+
}
|
|
76
|
+
default: throw new ConversionCapabilityError("prime-image-admission", `unsupported Prime content block: ${String(p.type)}`, { block: p });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function split(input: PrimeMessage | PrimeEnvelope): { message: PrimeMessage; envelope?: Omit<PrimeEnvelope, "message"> } {
|
|
81
|
+
if (object(input) && object(input.message)) { const { message, ...envelope } = input; return { message: message as PrimeMessage, envelope: envelope as Omit<PrimeEnvelope, "message"> }; }
|
|
82
|
+
return { message: input as PrimeMessage };
|
|
83
|
+
}
|
|
84
|
+
/** Lossless Prime -> DSH projection. Prime-only fields ride source.prime for the reverse projection. */
|
|
85
|
+
export function primeToDsh(input: PrimeMessage | PrimeEnvelope, caps: ConverterCapabilities = {}, idOverride?: string): Message {
|
|
86
|
+
const { message: p, envelope } = split(input); const role = string(p.role, "role");
|
|
87
|
+
const rawFields: Record<string, unknown> = {}; for (const [k, v] of Object.entries(p)) if (k !== "role" && k !== "content") rawFields[k] = v;
|
|
88
|
+
const fields = jsonRecord(rawFields);
|
|
89
|
+
const safeEnvelope = envelope ? jsonRecord(envelope) as Omit<PrimeEnvelope, "message"> : undefined;
|
|
90
|
+
const meta: PrimeMeta = { role, ...(safeEnvelope ? { envelope: safeEnvelope } : {}), fields };
|
|
91
|
+
let content: ContentBlock[]; let dshRole: "user" | "assistant"; let source: Message["source"] & { prime: PrimeMeta };
|
|
92
|
+
if (role === "user") { content = toDshBlocks(p.content, caps, false); dshRole = "user"; source = { kind: "user", prime: meta }; }
|
|
93
|
+
else if (role === "assistant") { const replayState = jsonBoundary(p.replayState); content = toDshBlocks(p.content, caps, true); dshRole = "assistant"; source = { kind: "model", provider: typeof p.provider === "string" ? p.provider : "external", model: typeof p.model === "string" ? p.model : "unknown", ...(replayState === undefined ? {} : { replayState }), prime: meta }; }
|
|
94
|
+
else if (role === "toolResult") {
|
|
95
|
+
const callId = ToolCallId(string(p.toolCallId, "toolCallId"));
|
|
96
|
+
content = [{ type: "tool-result", toolCallId: callId, content: toDshBlocks(p.content, caps, false), isError: p.isError === true }]; dshRole = "user"; source = { kind: "tool", callId, prime: meta };
|
|
97
|
+
} else if (["bashExecution", "custom", "branchSummary", "compactionSummary"].includes(role)) {
|
|
98
|
+
const text = role === "bashExecution" ? `${typeof p.command === "string" ? p.command : ""}
|
|
99
|
+
${typeof p.output === "string" ? p.output : ""}` : typeof p.summary === "string" ? p.summary : "";
|
|
100
|
+
content = role === "custom" ? toDshBlocks(p.content, caps, false) : [{ type: "text", text }]; dshRole = "user"; source = role.includes("Summary")
|
|
101
|
+
? { kind: "plugin", plugin: `prime:${role}`, form: "recall", prime: meta }
|
|
102
|
+
: { kind: "plugin", plugin: `prime:${role}`, form: "notice", summary: role, prime: meta };
|
|
103
|
+
} else throw new TypeError(`unsupported Prime role: ${role}`);
|
|
104
|
+
const id = idOverride ?? (typeof envelope?.id === "string" ? envelope.id : typeof p.id === "string" ? p.id : crypto.randomUUID());
|
|
105
|
+
return freezeMessage({ id: MessageId(id), role: dshRole, content, source });
|
|
106
|
+
}
|
|
107
|
+
function fromDshBlocks(content: readonly ContentBlock[], caps: ConverterCapabilities): Record<string, unknown>[] {
|
|
108
|
+
return content.flatMap((b): Record<string, unknown>[] => {
|
|
109
|
+
switch (b.type) {
|
|
110
|
+
case "text": return [{ type: "text", text: b.text }];
|
|
111
|
+
case "reasoning": return [{ type: "thinking", thinking: b.text }];
|
|
112
|
+
case "tool-call": { let args: unknown; try { args = JSON.parse(b.arguments); } catch { args = b.arguments; } return [{ type: "toolCall", id: b.id, name: b.name, arguments: args }]; }
|
|
113
|
+
case "image": { if (!caps.resolveImage) throw new ConversionCapabilityError("dsh-image-resolution", "DSH image references require an attachment resolution capability", { attachment: b.attachment }); const x = caps.resolveImage(b.attachment); return [{ type: "image", data: x.data, mimeType: x.mimeType, ...(b.attachment.name ? { name: b.attachment.name } : {}) }]; }
|
|
114
|
+
case "tool-result": return fromDshBlocks(b.content, caps);
|
|
115
|
+
default: throw new TypeError(`unsupported DSH content block: ${String((b as unknown as { type?: unknown }).type)}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Lossless DSH -> Prime projection for messages produced by this bridge; canonical mapping otherwise. */
|
|
120
|
+
export function dshToPrime(message: Message, caps: ConverterCapabilities = {}): PrimeMessage | PrimeEnvelope {
|
|
121
|
+
const sourceWithPrime = message.source as Message["source"] & { prime?: unknown };
|
|
122
|
+
const meta = object(sourceWithPrime.prime) ? sourceWithPrime.prime as PrimeMeta : undefined;
|
|
123
|
+
if (meta) {
|
|
124
|
+
const restored: PrimeMessage = { role: meta.role, ...meta.fields };
|
|
125
|
+
if (["user", "assistant", "toolResult", "custom"].includes(meta.role)) restored.content = fromDshBlocks(message.content[0]?.type === "tool-result" ? message.content[0].content : message.content, caps);
|
|
126
|
+
if (meta.role === "toolResult") { const block = message.content[0] as Extract<ContentBlock, { type: "tool-result" }>; restored.toolCallId = block.toolCallId; restored.isError = block.isError === true; }
|
|
127
|
+
return meta.envelope ? { ...meta.envelope, message: restored } : restored;
|
|
128
|
+
}
|
|
129
|
+
if (message.role === "assistant") return { role: "assistant", content: fromDshBlocks(message.content, caps), provider: message.source.kind === "model" ? message.source.provider : "external", model: message.source.kind === "model" ? message.source.model : "unknown" };
|
|
130
|
+
if (message.source.kind === "tool") { const b = message.content[0]; if (b?.type !== "tool-result") throw new TypeError("tool-source message lacks tool-result block"); return { role: "toolResult", toolCallId: b.toolCallId, toolName: "unknown", content: fromDshBlocks(b.content, caps), isError: b.isError === true }; }
|
|
131
|
+
return { role: "user", content: fromDshBlocks(message.content, caps) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
/** Async Prime -> DSH projection for attachment stores with durable I/O. */
|
|
136
|
+
export async function primeToDshAsync(input: PrimeMessage | PrimeEnvelope, caps: AsyncConverterCapabilities = {}, idOverride?: string): Promise<Message> {
|
|
137
|
+
const { message, envelope } = split(input);
|
|
138
|
+
const images = parts(message.content).filter((part) => part.type === "image");
|
|
139
|
+
if (images.length > 0 && !caps.admitImages) throw new ConversionCapabilityError("prime-image-admission", "inline Prime images require an attachment admission capability");
|
|
140
|
+
const uploads = images.map((part) => ({
|
|
141
|
+
data: string(part.data, "image.data"),
|
|
142
|
+
mimeType: string(part.mimeType, "image.mimeType"),
|
|
143
|
+
...(typeof part.name === "string" ? { name: part.name } : {}),
|
|
144
|
+
}));
|
|
145
|
+
const admitted = caps.admitImages ? await caps.admitImages(uploads) : [];
|
|
146
|
+
let next = 0;
|
|
147
|
+
const syncCaps: ConverterCapabilities = { admitImage: () => {
|
|
148
|
+
const attachment = admitted[next++];
|
|
149
|
+
if (!attachment) throw new Error("admitted image reference is missing");
|
|
150
|
+
return attachment;
|
|
151
|
+
} };
|
|
152
|
+
return primeToDsh(envelope ? { ...envelope, message } : message, syncCaps, idOverride);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveDshBlocks(content: readonly ContentBlock[], caps: AsyncConverterCapabilities): Promise<ConverterCapabilities> {
|
|
156
|
+
const resolved = new Map<ImageAttachmentRef, { data: string; mimeType: string }>();
|
|
157
|
+
const visit = async (blocks: readonly ContentBlock[]): Promise<void> => {
|
|
158
|
+
for (const block of blocks) {
|
|
159
|
+
if (block.type === "image") {
|
|
160
|
+
if (!caps.resolveImage) throw new ConversionCapabilityError("dsh-image-resolution", "DSH image references require an attachment resolution capability", { attachment: block.attachment });
|
|
161
|
+
resolved.set(block.attachment, await caps.resolveImage(block.attachment));
|
|
162
|
+
} else if (block.type === "tool-result") await visit(block.content);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
await visit(content);
|
|
166
|
+
return { resolveImage: (attachment) => {
|
|
167
|
+
const image = resolved.get(attachment);
|
|
168
|
+
if (!image) throw new Error("resolved image bytes are missing");
|
|
169
|
+
return image;
|
|
170
|
+
} };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Async DSH -> Prime projection that verifies and resolves durable image references. */
|
|
174
|
+
export async function dshToPrimeAsync(message: Message, caps: AsyncConverterCapabilities = {}): Promise<PrimeMessage | PrimeEnvelope> {
|
|
175
|
+
return dshToPrime(message, await resolveDshBlocks(message.content, caps));
|
|
176
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { closeSync, chmodSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { buildContextEntries, sessionEntryToContextMessages, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { primeToDshAsync, type PrimeEnvelope, type PrimeMessage } from "./context-converter.js";
|
|
6
|
+
import { DurableContextStore, type PublicationDiagnostics, type PublishResult } from "./durable-context-store.js";
|
|
7
|
+
import { stableJson } from "./prefix-metrics.js";
|
|
8
|
+
import { LocalDshImageAttachments, type DshImageAttachmentGateway } from "./dsh-image-attachments.js";
|
|
9
|
+
|
|
10
|
+
export const CONTEXT_OBJECT_VERSION = "prime-agent-dsh/context-object-v1" as const;
|
|
11
|
+
|
|
12
|
+
type JsonObject = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
export interface ContextObjectMetrics {
|
|
15
|
+
readonly assistantMessages: number;
|
|
16
|
+
readonly inputTokens: number;
|
|
17
|
+
readonly outputTokens: number;
|
|
18
|
+
readonly cacheReadTokens: number;
|
|
19
|
+
readonly cacheWriteTokens: number;
|
|
20
|
+
readonly totalTokens: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ContextObjectManifest {
|
|
24
|
+
readonly version: typeof CONTEXT_OBJECT_VERSION;
|
|
25
|
+
readonly sessionId: string;
|
|
26
|
+
readonly branchId: string;
|
|
27
|
+
readonly revision: number;
|
|
28
|
+
readonly observedAt: number;
|
|
29
|
+
readonly messageCount: number;
|
|
30
|
+
readonly entryCount: number;
|
|
31
|
+
readonly cropped: boolean;
|
|
32
|
+
readonly syncMode: "append" | "noop" | "rebuild";
|
|
33
|
+
readonly commonPrefixMessages: number;
|
|
34
|
+
readonly metrics: ContextObjectMetrics;
|
|
35
|
+
/** Digest of the immutable derived object. */
|
|
36
|
+
readonly digest: string;
|
|
37
|
+
/** Path to the current immutable v3 reference object. */
|
|
38
|
+
readonly snapshot: string;
|
|
39
|
+
readonly commit?: string;
|
|
40
|
+
readonly sourceDigest?: string;
|
|
41
|
+
readonly effectiveDigest?: string;
|
|
42
|
+
readonly publication?: PublicationDiagnostics;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ContextObjectSyncResult {
|
|
46
|
+
readonly manifest: ContextObjectManifest;
|
|
47
|
+
readonly root: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isObject(value: unknown): value is JsonObject {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function tokenCount(value: unknown): number {
|
|
55
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function metricsFromBranch(branch: readonly unknown[]): ContextObjectMetrics {
|
|
59
|
+
const totals = { assistantMessages: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, totalTokens: 0 };
|
|
60
|
+
for (const raw of branch) {
|
|
61
|
+
if (!isObject(raw) || raw.type !== "message" || !isObject(raw.message) || raw.message.role !== "assistant") continue;
|
|
62
|
+
const usage = isObject(raw.message.usage) ? raw.message.usage : undefined;
|
|
63
|
+
if (!usage) continue;
|
|
64
|
+
totals.assistantMessages++;
|
|
65
|
+
totals.inputTokens += tokenCount(usage.input);
|
|
66
|
+
totals.outputTokens += tokenCount(usage.output);
|
|
67
|
+
totals.cacheReadTokens += tokenCount(usage.cacheRead);
|
|
68
|
+
totals.cacheWriteTokens += tokenCount(usage.cacheWrite);
|
|
69
|
+
totals.totalTokens += tokenCount(usage.totalTokens);
|
|
70
|
+
}
|
|
71
|
+
return totals;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asPrimeInput(value: unknown): PrimeMessage | PrimeEnvelope {
|
|
75
|
+
if (!isObject(value)) throw new TypeError("Prime context message must be an object");
|
|
76
|
+
if (isObject(value.message) && typeof value.message.role === "string") return value as PrimeEnvelope;
|
|
77
|
+
if (typeof value.role === "string") return value as PrimeMessage;
|
|
78
|
+
throw new TypeError("Prime context message has no role");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lstatExists(path: string): boolean {
|
|
82
|
+
try { lstatSync(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensurePrivateDirectory(path: string): void {
|
|
86
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
87
|
+
const stat = lstatSync(path);
|
|
88
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Context object root is not a private directory: ${path}`);
|
|
89
|
+
chmodSync(path, 0o700);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function atomicPrivateWrite(path: string, content: string): void {
|
|
93
|
+
const temp = `${path}.${randomBytes(12).toString("hex")}.tmp`;
|
|
94
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(fd, content, "utf8");
|
|
97
|
+
fsyncSync(fd);
|
|
98
|
+
} finally {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
renameSync(temp, path);
|
|
102
|
+
chmodSync(path, 0o600);
|
|
103
|
+
let directory: number | undefined;
|
|
104
|
+
try { directory = openSync(dirname(path), constants.O_RDONLY); fsyncSync(directory); }
|
|
105
|
+
finally { if (directory !== undefined) closeSync(directory); }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function effectiveSourceIndexes(rawBranch: readonly unknown[], messages: readonly unknown[]): Array<number | null> {
|
|
109
|
+
const candidates: Array<{ sourceIndex: number; fingerprint: string }> = [];
|
|
110
|
+
rawBranch.forEach((entry, sourceIndex) => {
|
|
111
|
+
try {
|
|
112
|
+
for (const message of sessionEntryToContextMessages(entry as SessionEntry)) {
|
|
113
|
+
candidates.push({ sourceIndex, fingerprint: stableJson(message) });
|
|
114
|
+
}
|
|
115
|
+
} catch { /* unsupported/log-only entries do not enter model context */ }
|
|
116
|
+
});
|
|
117
|
+
let cursor = 0;
|
|
118
|
+
return messages.map((message) => {
|
|
119
|
+
const fingerprint = stableJson(message);
|
|
120
|
+
let found = candidates.findIndex((candidate, index) => index >= cursor && candidate.fingerprint === fingerprint);
|
|
121
|
+
if (found < 0) found = candidates.findIndex((candidate) => candidate.fingerprint === fingerprint);
|
|
122
|
+
if (found < 0) return null;
|
|
123
|
+
cursor = found + 1;
|
|
124
|
+
return candidates[found]?.sourceIndex ?? null;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Resolve the artifact directory shared with Prime's per-session Python kernel. */
|
|
129
|
+
export function contextObjectRoot(sessionId: string, sessionFile: string | undefined): string | undefined {
|
|
130
|
+
if (!sessionFile || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return undefined;
|
|
131
|
+
const sessionDir = dirname(sessionFile);
|
|
132
|
+
// RLM child JSONL files already live inside their inherited artifact tree and
|
|
133
|
+
// PRIME_AGENT sets RLM_SESSION_DIR to that directory. Root sessions—including
|
|
134
|
+
// custom --session-dir roots—use the sibling session-artifacts/<id> layout.
|
|
135
|
+
if (sessionDir.split(sep).includes("session-artifacts")) return join(sessionDir, "dsh-context");
|
|
136
|
+
return join(dirname(sessionDir), "session-artifacts", sessionId, "dsh-context");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Maintains a rebuildable DSH projection and immutable filesystem snapshots.
|
|
141
|
+
* Prime JSONL remains canonical; this store never edits Prime or DSH history.
|
|
142
|
+
*/
|
|
143
|
+
export class ContextObjectStore {
|
|
144
|
+
constructor(private readonly attachments: DshImageAttachmentGateway = new LocalDshImageAttachments()) {}
|
|
145
|
+
|
|
146
|
+
/** Recover the newest valid committed generation without trusting CURRENT or manifest.json. */
|
|
147
|
+
recover(ctx: ExtensionContext): PublishResult | undefined {
|
|
148
|
+
const binding = this.binding(ctx);
|
|
149
|
+
if (!binding) return undefined;
|
|
150
|
+
const recovered = new DurableContextStore(binding).recover();
|
|
151
|
+
if (!recovered) return undefined;
|
|
152
|
+
const publication = recovered.commit.publication ?? {
|
|
153
|
+
source: { mode: "noop" as const, reused: recovered.object.sourceEntryDigests.length, new: 0, reindexed: 0 },
|
|
154
|
+
effective: { reused: recovered.object.effectiveEntryDigests.length, new: 0, reindexed: 0, rebuildReason: "none" as const },
|
|
155
|
+
};
|
|
156
|
+
return { ...recovered, mode: "noop", publication };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async sync(ctx: ExtensionContext, inputMessages?: readonly unknown[]): Promise<ContextObjectSyncResult | undefined> {
|
|
160
|
+
const binding = this.binding(ctx);
|
|
161
|
+
if (!binding) return undefined;
|
|
162
|
+
const { sessionId } = binding.binding;
|
|
163
|
+
const { root } = binding;
|
|
164
|
+
const branchId = ctx.sessionManager.getLeafId?.() ?? "root";
|
|
165
|
+
const rawBranch = (ctx.sessionManager.getBranch?.() ?? []) as readonly unknown[];
|
|
166
|
+
// ReadonlySessionManager has no buildSessionContext() at runtime. Rebuild the
|
|
167
|
+
// confirmed public context from its durable branch after persistence.
|
|
168
|
+
const messages = inputMessages ?? buildContextEntries(rawBranch as SessionEntry[], branchId)
|
|
169
|
+
.flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
170
|
+
const sourceIndexes = effectiveSourceIndexes(rawBranch, messages);
|
|
171
|
+
const selected = messages.flatMap((message, index) => {
|
|
172
|
+
const sourceIndex = sourceIndexes[index];
|
|
173
|
+
return sourceIndex === null || sourceIndex === undefined ? [] : [{ message, originalIndex: index, sourceIndex }];
|
|
174
|
+
});
|
|
175
|
+
// A context event can include the current user message before Prime commits
|
|
176
|
+
// it. Reference-only DSH indexes only confirmed Prime entries and catches up
|
|
177
|
+
// on the next lifecycle observation instead of copying pending content.
|
|
178
|
+
const canonical = await Promise.all(selected.map(({ message, originalIndex }) => primeToDshAsync(
|
|
179
|
+
asPrimeInput(message),
|
|
180
|
+
{ admitImages: (images) => this.attachments.admitPrimeImages(images) },
|
|
181
|
+
`prime-${createHash("sha256").update(`${originalIndex}:`).update(stableJson(message)).digest("hex").slice(0, 32)}`,
|
|
182
|
+
)));
|
|
183
|
+
const metrics = metricsFromBranch(rawBranch);
|
|
184
|
+
const store = new DurableContextStore(binding);
|
|
185
|
+
const published = await store.publish({
|
|
186
|
+
source: rawBranch,
|
|
187
|
+
effective: canonical,
|
|
188
|
+
effectiveSourceIndexes: selected.map(({ sourceIndex }) => sourceIndex),
|
|
189
|
+
converterVersion: "prime-to-dsh-v2-reference",
|
|
190
|
+
schemaVersion: CONTEXT_OBJECT_VERSION,
|
|
191
|
+
branchId,
|
|
192
|
+
observedAt: Date.now(),
|
|
193
|
+
compatibilityMetrics: { ...metrics },
|
|
194
|
+
cropped: selected.length !== messages.length,
|
|
195
|
+
});
|
|
196
|
+
const view = published.object.compatibility;
|
|
197
|
+
const manifest: ContextObjectManifest = {
|
|
198
|
+
version: CONTEXT_OBJECT_VERSION,
|
|
199
|
+
sessionId,
|
|
200
|
+
branchId: view.branchId,
|
|
201
|
+
revision: published.commit.generation,
|
|
202
|
+
observedAt: published.commit.observedAt,
|
|
203
|
+
messageCount: view.messageCount,
|
|
204
|
+
entryCount: published.object.sourceEntryDigests.length,
|
|
205
|
+
cropped: view.cropped,
|
|
206
|
+
syncMode: published.mode,
|
|
207
|
+
commonPrefixMessages: published.commit.commonPrefix,
|
|
208
|
+
metrics,
|
|
209
|
+
digest: published.commit.object,
|
|
210
|
+
snapshot: `objects/${published.commit.object}.json`,
|
|
211
|
+
commit: published.commitDigest,
|
|
212
|
+
sourceDigest: published.commit.sourceDigest,
|
|
213
|
+
effectiveDigest: published.commit.effectiveDigest,
|
|
214
|
+
publication: published.publication,
|
|
215
|
+
};
|
|
216
|
+
ensurePrivateDirectory(root);
|
|
217
|
+
const branchKey = createHash("sha256").update(branchId).digest("hex");
|
|
218
|
+
const immutableManifest = join(root, `manifest-${branchKey}-${published.commitDigest}.json`);
|
|
219
|
+
if (!lstatExists(immutableManifest)) atomicPrivateWrite(immutableManifest, `${JSON.stringify(manifest)}\n`);
|
|
220
|
+
atomicPrivateWrite(join(root, `manifest-${branchKey}.json`), `${JSON.stringify(manifest)}\n`);
|
|
221
|
+
// Current-view pointer only. Authoritative readers pass an immutable digest or expected branch.
|
|
222
|
+
atomicPrivateWrite(join(root, "manifest.json"), `${JSON.stringify(manifest)}\n`);
|
|
223
|
+
this.pruneCompatibilityManifests(root, branchKey);
|
|
224
|
+
return { manifest, root };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
private pruneCompatibilityManifests(root: string, currentBranchKey: string): void {
|
|
229
|
+
const retained = new Set(readdirSync(join(root, "heads"))
|
|
230
|
+
.flatMap((name) => /^\d{16}-([a-f0-9]{64})$/.exec(name)?.[1] ?? []));
|
|
231
|
+
for (const name of readdirSync(root)) {
|
|
232
|
+
const immutable = /^manifest-[a-f0-9]{64}-([a-f0-9]{64})\.json$/.exec(name);
|
|
233
|
+
const branch = /^manifest-([a-f0-9]{64})\.json$/.exec(name);
|
|
234
|
+
if ((immutable && !retained.has(immutable[1] ?? "")) || (branch && branch[1] !== currentBranchKey)) {
|
|
235
|
+
try { unlinkSync(join(root, name)); } catch { /* best effort; compatibility views are rebuildable */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private binding(ctx: ExtensionContext): ConstructorParameters<typeof DurableContextStore>[0] | undefined {
|
|
241
|
+
const sessionId = ctx.sessionManager.getSessionId?.() ?? "";
|
|
242
|
+
const primeSessionFile = ctx.sessionManager.getSessionFile?.();
|
|
243
|
+
const root = contextObjectRoot(sessionId, primeSessionFile);
|
|
244
|
+
if (!sessionId || !primeSessionFile || !root) return undefined;
|
|
245
|
+
return { root, binding: { sessionId, primeSessionFile } };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Message } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
|
|
3
|
+
export const PROTOCOL = "dsh-context/1" as const;
|
|
4
|
+
export type RequestId = string | number;
|
|
5
|
+
export interface BranchKey { sessionId: string; branchId: string }
|
|
6
|
+
export type SimpleMessage =
|
|
7
|
+
| { role: "user"; content: string; source?: string }
|
|
8
|
+
| { role: "assistant"; content: string; provider?: string; model?: string };
|
|
9
|
+
export interface SyncParams { key: BranchKey; messages: SimpleMessage[]; expectedRevision?: number }
|
|
10
|
+
export interface CanonicalSyncParams { key: BranchKey; messages: Message[]; expectedRevision?: number }
|
|
11
|
+
export interface ProjectParams { key: BranchKey; from?: number; limit?: number }
|
|
12
|
+
export type Method = "initialize" | "session/sync" | "session/sync-canonical" | "project" | "status" | "shutdown";
|
|
13
|
+
export interface Request<M extends string = string, P = unknown> { version: typeof PROTOCOL; id: RequestId; method: M; params?: P }
|
|
14
|
+
export interface InitializeResult { protocol: typeof PROTOCOL; implementation: { name: string; version: string }; capabilities: { transport: readonly ["in-process"]; methods: readonly Method[]; dshSession: true; agentLoop: false } }
|
|
15
|
+
export interface SyncResult { key: BranchKey; revision: number; eventCount: number; messageCount: number; mode: "append" | "noop" | "rebuild"; commonPrefixMessages: number }
|
|
16
|
+
export interface ProjectResult { key: BranchKey; revision: number; total: number; from: number; messages: Message[] }
|
|
17
|
+
export interface SessionSummary { key: BranchKey; revision: number; eventCount: number; messageCount: number }
|
|
18
|
+
export interface StatusResult { initialized: boolean; shuttingDown: boolean; sessionCount: number; sessions: SessionSummary[] }
|
|
19
|
+
export interface ShutdownResult { accepted: true }
|
|
20
|
+
export interface ResultMap { initialize: InitializeResult; "session/sync": SyncResult; "session/sync-canonical": SyncResult; project: ProjectResult; status: StatusResult; shutdown: ShutdownResult }
|
|
21
|
+
export type MethodResult = ResultMap[keyof ResultMap];
|
|
22
|
+
export interface Success<R extends MethodResult = MethodResult> { version: typeof PROTOCOL; id: RequestId; ok: true; result: R }
|
|
23
|
+
export type ProtocolErrorCode = "INVALID_REQUEST" | "UNSUPPORTED_VERSION" | "SHUTTING_DOWN" | "ALREADY_INITIALIZED" | "NOT_INITIALIZED" | "METHOD_NOT_FOUND" | "INVALID_PARAMS" | "REVISION_CONFLICT" | "SESSION_NOT_FOUND" | "INTERNAL";
|
|
24
|
+
export interface Failure { version: typeof PROTOCOL; id: RequestId | null; ok: false; error: { code: ProtocolErrorCode; message: string; data?: unknown } }
|
|
25
|
+
export type Response<R extends MethodResult = MethodResult> = Success<R> | Failure;
|
|
26
|
+
|
|
27
|
+
/** Typed in-process client; method/result relationships cannot be lost at call sites. */
|
|
28
|
+
export class ContextProtocolClient {
|
|
29
|
+
private nextId = 0;
|
|
30
|
+
constructor(private readonly transport: (request: Request) => Response) {}
|
|
31
|
+
call<M extends keyof ResultMap>(method: M, params?: RequestParams<M>): Response<ResultMap[M]> {
|
|
32
|
+
const response = this.transport({ version: PROTOCOL, id: ++this.nextId, method, params });
|
|
33
|
+
return response as Response<ResultMap[M]>;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export type RequestParams<M extends keyof ResultMap> =
|
|
37
|
+
M extends "session/sync" ? SyncParams : M extends "session/sync-canonical" ? CanonicalSyncParams : M extends "project" ? ProjectParams : undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import type { FileAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
|
3
|
+
import type { DurableFileAttachments } from "./durable-file-attachments.js";
|
|
4
|
+
|
|
5
|
+
export const CONTEXT_SPILL_VERSION = "prime-agent-dsh/context-spill-v1" as const;
|
|
6
|
+
|
|
7
|
+
export interface ContextSpillLocator {
|
|
8
|
+
readonly version: typeof CONTEXT_SPILL_VERSION;
|
|
9
|
+
readonly encoding: "utf-8";
|
|
10
|
+
readonly attachment: FileAttachmentRef;
|
|
11
|
+
}
|
|
12
|
+
export interface InlineContextText { readonly text: string; readonly spilled: false }
|
|
13
|
+
export interface SpilledContextText {
|
|
14
|
+
readonly text: string;
|
|
15
|
+
readonly spilled: true;
|
|
16
|
+
readonly locator: ContextSpillLocator;
|
|
17
|
+
readonly originalBytes: number;
|
|
18
|
+
}
|
|
19
|
+
export type ContextText = InlineContextText | SpilledContextText;
|
|
20
|
+
export interface SpillOptions {
|
|
21
|
+
/** Spill only when exact UTF-8 size is greater than this value. */
|
|
22
|
+
readonly thresholdBytes?: number;
|
|
23
|
+
/** Maximum UTF-8 bytes in the returned preview, including its marker. */
|
|
24
|
+
readonly previewBytes?: number;
|
|
25
|
+
readonly name?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Persist oversized context without making persistence a new failure mode.
|
|
30
|
+
* Any write/quota error returns the exact original inline string.
|
|
31
|
+
*/
|
|
32
|
+
export async function spillContextText(
|
|
33
|
+
attachments: Pick<DurableFileAttachments, "save">,
|
|
34
|
+
value: string,
|
|
35
|
+
options: SpillOptions = {},
|
|
36
|
+
): Promise<ContextText> {
|
|
37
|
+
const threshold = limit(options.thresholdBytes ?? 32 * 1024, "thresholdBytes");
|
|
38
|
+
const previewBytes = limit(options.previewBytes ?? 4 * 1024, "previewBytes");
|
|
39
|
+
if (!wellFormed(value)) return { text: value, spilled: false };
|
|
40
|
+
const data = Buffer.from(value, "utf8");
|
|
41
|
+
if (data.byteLength <= threshold) return { text: value, spilled: false };
|
|
42
|
+
try {
|
|
43
|
+
const attachment = await attachments.save(data, options.name ?? "context.txt");
|
|
44
|
+
return {
|
|
45
|
+
text: utf8Preview(data, previewBytes),
|
|
46
|
+
spilled: true,
|
|
47
|
+
locator: { version: CONTEXT_SPILL_VERSION, encoding: "utf-8", attachment },
|
|
48
|
+
originalBytes: data.byteLength,
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return { text: value, spilled: false };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
export interface TextToolResult {
|
|
57
|
+
readonly content: string;
|
|
58
|
+
readonly [key: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
export type SpilledToolResult<T extends TextToolResult> = T & { readonly contextSpill?: ContextSpillLocator };
|
|
61
|
+
|
|
62
|
+
/** Spill the string payload of a tool result while preserving all other fields. */
|
|
63
|
+
export async function spillToolResult<T extends TextToolResult>(
|
|
64
|
+
attachments: Pick<DurableFileAttachments, "save">,
|
|
65
|
+
result: T,
|
|
66
|
+
options: SpillOptions = {},
|
|
67
|
+
): Promise<SpilledToolResult<T>> {
|
|
68
|
+
const spilled = await spillContextText(attachments, result.content, {
|
|
69
|
+
...options,
|
|
70
|
+
name: options.name ?? "tool-result.txt",
|
|
71
|
+
});
|
|
72
|
+
if (!spilled.spilled) return result;
|
|
73
|
+
return { ...result, content: spilled.text, contextSpill: spilled.locator };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve a locator after the attachment backend verifies its size and digest. */
|
|
77
|
+
export async function resolveContextSpill(
|
|
78
|
+
attachments: Pick<DurableFileAttachments, "read">,
|
|
79
|
+
locator: ContextSpillLocator,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
if (locator.version !== CONTEXT_SPILL_VERSION || locator.encoding !== "utf-8") throw new Error("invalid context spill locator");
|
|
83
|
+
const bytes = await attachments.read(locator.attachment, signal);
|
|
84
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** UTF-8-safe prefix whose encoded size never exceeds `maximum`. */
|
|
88
|
+
export function utf8Preview(data: Uint8Array, maximum: number): string {
|
|
89
|
+
limit(maximum, "maximum");
|
|
90
|
+
if (data.byteLength <= maximum) return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
91
|
+
const marker = `\n[… ${data.byteLength} UTF-8 bytes total; full content at locator …]`;
|
|
92
|
+
const markerBytes = Buffer.byteLength(marker);
|
|
93
|
+
if (markerBytes > maximum) return truncateUtf8(Buffer.from(marker), maximum);
|
|
94
|
+
return truncateUtf8(data, maximum - markerBytes) + marker;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncateUtf8(data: Uint8Array, maximum: number): string {
|
|
98
|
+
let end = Math.min(maximum, data.byteLength);
|
|
99
|
+
while (end > 0 && (data[end] & 0xc0) === 0x80) end--;
|
|
100
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(0, end));
|
|
101
|
+
}
|
|
102
|
+
function limit(value: number, name: string): number {
|
|
103
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wellFormed(value: string): boolean {
|
|
108
|
+
for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i); if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(++i); if (!(next >= 0xdc00 && next <= 0xdfff)) return false; } else if (code >= 0xdc00 && code <= 0xdfff) return false; } return true;
|
|
109
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Session, SessionId } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import { createAssistantMessage, createUserMessage, freezeMessage, type AssistantMessage, type Message, type ToolResultMessage, type UserMessage } from "@deepseek-ai/dsh-llm";
|
|
3
|
+
import { PROTOCOL, type BranchKey, type CanonicalSyncParams, type MethodResult, type ProjectParams, type ProjectResult, type ProtocolErrorCode, type Request, type RequestId, type Response, type SessionSummary, type ShutdownResult, type SimpleMessage, type StatusResult, type Success, type SyncParams, type SyncResult, type InitializeResult } from "./context-protocol.js";
|
|
4
|
+
|
|
5
|
+
type State = { session: Session; revision: number; canonical: readonly Message[] };
|
|
6
|
+
type ObjectValue = Record<string, unknown>;
|
|
7
|
+
const object = (value: unknown): ObjectValue | undefined => typeof value === "object" && value !== null && !Array.isArray(value) ? value as ObjectValue : undefined;
|
|
8
|
+
const integer = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
9
|
+
const stateKey = (key: BranchKey): string => JSON.stringify([key.sessionId, key.branchId]);
|
|
10
|
+
class ProtocolFault extends Error { constructor(readonly code: ProtocolErrorCode, message: string, readonly data?: unknown) { super(message); } }
|
|
11
|
+
const fault = (code: ProtocolErrorCode, message: string, data?: unknown): ProtocolFault => new ProtocolFault(code, message, data);
|
|
12
|
+
|
|
13
|
+
class Validator {
|
|
14
|
+
request(raw: unknown): Request { const v=object(raw); if(!v) throw fault("INVALID_REQUEST","request must be an object"); if(v.version!==PROTOCOL) throw fault("UNSUPPORTED_VERSION",`expected ${PROTOCOL}`); if((typeof v.id!=="string"&&typeof v.id!=="number")||typeof v.method!=="string") throw fault("INVALID_REQUEST","id and method are required"); return {version:PROTOCOL,id:v.id,method:v.method,...(v.params===undefined?{}:{params:v.params})}; }
|
|
15
|
+
key(raw: unknown): BranchKey { const v=object(raw); if(!v||typeof v.sessionId!=="string"||!v.sessionId||typeof v.branchId!=="string"||!v.branchId) throw fault("INVALID_PARAMS","key requires non-empty sessionId and branchId"); return {sessionId:v.sessionId,branchId:v.branchId}; }
|
|
16
|
+
revision(v:ObjectValue): number|undefined { if(v.expectedRevision!==undefined&&!integer(v.expectedRevision)) throw fault("INVALID_PARAMS","expectedRevision must be a non-negative integer"); return v.expectedRevision; }
|
|
17
|
+
sync(raw:unknown):SyncParams { const v=object(raw); if(!v||!Array.isArray(v.messages)) throw fault("INVALID_PARAMS","session/sync requires key and messages[]"); const messages=v.messages.map((x,i)=>this.simple(x,i)); const expectedRevision=this.revision(v); return {key:this.key(v.key),messages,...(expectedRevision===undefined?{}:{expectedRevision})}; }
|
|
18
|
+
canonical(raw:unknown):CanonicalSyncParams { const v=object(raw); if(!v||!Array.isArray(v.messages)) throw fault("INVALID_PARAMS","session/sync-canonical requires key and messages[]"); let messages:Message[]; try { messages=v.messages.map(m=>freezeMessage(m as Message)); } catch(error) { throw fault("INVALID_PARAMS",error instanceof Error?error.message:"invalid canonical message"); } const expectedRevision=this.revision(v); return {key:this.key(v.key),messages,...(expectedRevision===undefined?{}:{expectedRevision})}; }
|
|
19
|
+
project(raw:unknown):ProjectParams { const v=object(raw); if(!v) throw fault("INVALID_PARAMS","project requires key"); for(const name of ["from","limit"] as const) if(v[name]!==undefined&&!integer(v[name])) throw fault("INVALID_PARAMS","from and limit must be non-negative integers"); return {key:this.key(v.key),...(v.from===undefined?{}:{from:v.from as number}),...(v.limit===undefined?{}:{limit:v.limit as number})}; }
|
|
20
|
+
private simple(raw:unknown,index:number):SimpleMessage { const v=object(raw); if(!v||(v.role!=="user"&&v.role!=="assistant")||typeof v.content!=="string") throw fault("INVALID_PARAMS",`invalid message at index ${index}`); if(v.role==="user") { if(v.source!==undefined&&typeof v.source!=="string") throw fault("INVALID_PARAMS",`invalid message at index ${index}`); return {role:"user",content:v.content,...(v.source===undefined?{}:{source:v.source})}; } if(v.provider!==undefined&&typeof v.provider!=="string"||v.model!==undefined&&typeof v.model!=="string") throw fault("INVALID_PARAMS",`invalid message at index ${index}`); return {role:"assistant",content:v.content,...(v.provider===undefined?{}:{provider:v.provider}),...(v.model===undefined?{}:{model:v.model})}; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ContextService {
|
|
24
|
+
private initialized=false; private stopping=false; private readonly sessions=new Map<string,State>(); private readonly validator=new Validator();
|
|
25
|
+
constructor(private readonly onShutdown:()=>void=()=>{}){}
|
|
26
|
+
handle(raw:unknown):Response { let id:RequestId|null=null; try { const req=this.validator.request(raw); id=req.id; if(this.stopping&&req.method!=="status") throw fault("SHUTTING_DOWN","service is shutting down"); return this.success(id,this.dispatch(req)); } catch(error) { const f=error instanceof ProtocolFault?error:fault("INTERNAL",error instanceof Error?error.message:String(error)); return {version:PROTOCOL,id,ok:false,error:{code:f.code,message:f.message,...(f.data===undefined?{}:{data:f.data})}}; } }
|
|
27
|
+
private success(id:RequestId,result:MethodResult):Success{return {version:PROTOCOL,id,ok:true,result};}
|
|
28
|
+
private dispatch(req:Request):MethodResult { if(req.method==="initialize") return this.initialize(); if(!this.initialized) throw fault("NOT_INITIALIZED","call initialize first"); switch(req.method){case "session/sync":return this.syncSimple(this.validator.sync(req.params));case "session/sync-canonical":return this.syncCanonical(this.validator.canonical(req.params));case "project":return this.project(this.validator.project(req.params));case "status":return this.status();case "shutdown":return this.shutdown();default:throw fault("METHOD_NOT_FOUND",`unknown method: ${req.method}`);} }
|
|
29
|
+
private initialize():InitializeResult { if(this.initialized) throw fault("ALREADY_INITIALIZED","initialize may be called once"); this.initialized=true; return {protocol:PROTOCOL,implementation:{name:"dsh-inference-context",version:"0.0.1"},capabilities:{transport:["in-process"],methods:["initialize","session/sync","session/sync-canonical","project","status","shutdown"],dshSession:true,agentLoop:false}}; }
|
|
30
|
+
private syncSimple(p:SyncParams):SyncResult { const messages=p.messages.map(input=>input.role==="user"?createUserMessage({content:[{type:"text",text:input.content}],source:input.source&&input.source!=="user"?{kind:"plugin",plugin:input.source}:{kind:"user"}}):createAssistantMessage({content:[{type:"text",text:input.content}],source:{provider:input.provider??"external",model:input.model??"unknown"}})); return this.syncCanonical({key:p.key,messages,...(p.expectedRevision===undefined?{}:{expectedRevision:p.expectedRevision})}); }
|
|
31
|
+
private syncCanonical(p:CanonicalSyncParams):SyncResult { const sk=stateKey(p.key), prior=this.sessions.get(sk), actual=prior?.revision??0; if(p.expectedRevision!==undefined&&p.expectedRevision!==actual) throw fault("REVISION_CONFLICT","expectedRevision does not match",{actualRevision:actual}); const incoming=p.messages.map(m=>freezeMessage(m)); let common=0; if(prior) while(common<prior.canonical.length&&common<incoming.length&&JSON.stringify(prior.canonical[common])===JSON.stringify(incoming[common])) common++; if(prior&&common===prior.canonical.length&&common===incoming.length) return this.syncResult(p.key,prior,"noop",common); let session:Session,mode:"append"|"rebuild"; if(prior&&common===prior.canonical.length){ session=Session.create(SessionId(p.key.sessionId),prior.session.snapshotEvents()); mode="append"; this.append(session,incoming.slice(common),common); } else { session=Session.create(SessionId(p.key.sessionId)); mode="rebuild"; this.append(session,incoming,0); } const next={session,revision:actual+1,canonical:incoming}; this.sessions.set(sk,next); return this.syncResult(p.key,next,mode,common); }
|
|
32
|
+
private append(session:Session,messages:readonly Message[],offset:number):void { messages.forEach((message,index)=>{const step=offset+index;if(message.role==="assistant"){const assistant=message as AssistantMessage;session.append("assistant/message",{turn:step,step,message:assistant,stream:[]},{surfaceOp:"append"});for(const block of assistant.content)if(block.type==="tool-call")session.append("tool/call",{turn:step,step,callId:block.id,name:block.name,arguments:block.arguments});}else if(message.source.kind==="tool")session.append("tool/result",{turn:step,step,message:message as ToolResultMessage},{surfaceOp:"append"});else session.append("user/message",message as UserMessage,{surfaceOp:"append"});}); }
|
|
33
|
+
private syncResult(key:BranchKey,state:State,mode:SyncResult["mode"],commonPrefixMessages:number):SyncResult{return {key,revision:state.revision,eventCount:state.session.seq,messageCount:state.canonical.length,mode,commonPrefixMessages};}
|
|
34
|
+
private project(p:ProjectParams):ProjectResult { const state=this.sessions.get(stateKey(p.key));if(!state)throw fault("SESSION_NOT_FOUND",`unknown branch: ${p.key.sessionId}/${p.key.branchId}`);const derived=state.session.deriveMessages();const from=p.from??0,limit=p.limit??derived.length;return {key:p.key,revision:state.revision,total:derived.length,from,messages:derived.slice(from,from+limit)}; }
|
|
35
|
+
private status():StatusResult { const sessions:SessionSummary[]=[...this.sessions.values()].map(s=>({key:this.keyFor(s),revision:s.revision,eventCount:s.session.seq,messageCount:s.canonical.length}));return {initialized:true,shuttingDown:this.stopping,sessionCount:sessions.length,sessions}; }
|
|
36
|
+
private keyFor(target:State):BranchKey { for(const [encoded,state] of this.sessions)if(state===target){const parsed:unknown=JSON.parse(encoded);if(Array.isArray(parsed)&&typeof parsed[0]==="string"&&typeof parsed[1]==="string")return {sessionId:parsed[0],branchId:parsed[1]};}throw new Error("orphan session state"); }
|
|
37
|
+
private shutdown():ShutdownResult{this.stopping=true;queueMicrotask(this.onShutdown);return {accepted:true};}
|
|
38
|
+
}
|