ds4-context-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +399 -0
- package/docs/ADR/README.md +60 -0
- package/docs/ARCHITECTURE.md +200 -0
- package/docs/ARTIFACTS.md +146 -0
- package/docs/COMPACTION.md +77 -0
- package/docs/CONTEXT_MANIFEST.md +67 -0
- package/docs/CONTEXT_PLANNER.md +81 -0
- package/docs/MEMORY_AND_PINS.md +165 -0
- package/docs/MODEL_AWARENESS.md +131 -0
- package/docs/NATIVE_CONTINUATION.md +127 -0
- package/docs/PORTABLE_CORE.md +92 -0
- package/docs/PRIVACY.md +157 -0
- package/docs/PROJECT_KNOWLEDGE.md +143 -0
- package/docs/RELEASING.md +72 -0
- package/docs/RETRIEVAL.md +95 -0
- package/docs/STORAGE.md +102 -0
- package/docs/SUMMARY_GRAPH.md +57 -0
- package/package.json +69 -0
- package/src/extension/commands.ts +872 -0
- package/src/extension/index.ts +141 -0
- package/src/extension/runtime.ts +1989 -0
- package/src/pi-adapter/compaction-adapter.ts +150 -0
- package/src/pi-adapter/compaction-coordinator.ts +779 -0
- package/src/pi-adapter/context-observer.ts +400 -0
- package/src/pi-adapter/indexed-entry.ts +116 -0
- package/src/pi-adapter/memory-adapter.ts +113 -0
- package/src/pi-adapter/message-converter.ts +181 -0
- package/src/pi-adapter/openai-responses-stream.ts +226 -0
- package/src/pi-adapter/session-indexer.ts +255 -0
- package/src/pi-adapter/session-jsonl.ts +183 -0
- package/src/pi-adapter/session-reader.ts +39 -0
- package/src/pi-adapter/summary-generator.ts +157 -0
- package/src/pi-adapter/version.ts +5 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { sha256 } from "ds4-context-core/shared/hash";
|
|
3
|
+
|
|
4
|
+
const READ_BUFFER_SIZE = 1024 * 1024;
|
|
5
|
+
const MAX_HEADER_BYTES = 1024 * 1024;
|
|
6
|
+
const MAX_CHECKPOINT_HASH_BYTES = 64 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface JsonlRecord {
|
|
9
|
+
value: Record<string, unknown>;
|
|
10
|
+
raw: string;
|
|
11
|
+
rawHash: string;
|
|
12
|
+
startOffset: number;
|
|
13
|
+
endOffset: number;
|
|
14
|
+
terminated: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SessionHeaderRecord extends JsonlRecord {
|
|
18
|
+
value: Record<string, unknown> & { type: "session"; id: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface JsonlReadResult {
|
|
22
|
+
records: JsonlRecord[];
|
|
23
|
+
malformedLines: number;
|
|
24
|
+
fileSize: number;
|
|
25
|
+
fileMtimeMs: number;
|
|
26
|
+
safeCheckpointOffset: number;
|
|
27
|
+
checkpointHashStart: number;
|
|
28
|
+
checkpointHash?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseLine(
|
|
36
|
+
bytes: Buffer,
|
|
37
|
+
startOffset: number,
|
|
38
|
+
endOffset: number,
|
|
39
|
+
terminated: boolean,
|
|
40
|
+
): JsonlRecord | "blank" | "malformed" {
|
|
41
|
+
let parseBytes = bytes;
|
|
42
|
+
if (parseBytes.at(-1) === 0x0d) parseBytes = parseBytes.subarray(0, -1);
|
|
43
|
+
const raw = parseBytes.toString("utf8");
|
|
44
|
+
if (raw.trim().length === 0) return "blank";
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const value: unknown = JSON.parse(raw);
|
|
48
|
+
if (!isRecord(value) || typeof value.type !== "string") return "malformed";
|
|
49
|
+
return {
|
|
50
|
+
value,
|
|
51
|
+
raw,
|
|
52
|
+
rawHash: sha256(bytes),
|
|
53
|
+
startOffset,
|
|
54
|
+
endOffset,
|
|
55
|
+
terminated,
|
|
56
|
+
};
|
|
57
|
+
} catch {
|
|
58
|
+
return "malformed";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readSessionHeaderRecord(filePath: string): SessionHeaderRecord {
|
|
63
|
+
if (!existsSync(filePath)) throw new Error(`Session file does not exist: ${filePath}`);
|
|
64
|
+
|
|
65
|
+
const fd = openSync(filePath, "r");
|
|
66
|
+
try {
|
|
67
|
+
const stat = fstatSync(fd);
|
|
68
|
+
const readLength = Math.min(stat.size, MAX_HEADER_BYTES);
|
|
69
|
+
const buffer = Buffer.allocUnsafe(readLength);
|
|
70
|
+
const bytesRead = readLength > 0 ? readSync(fd, buffer, 0, readLength, 0) : 0;
|
|
71
|
+
const data = buffer.subarray(0, bytesRead);
|
|
72
|
+
let lineStart = 0;
|
|
73
|
+
|
|
74
|
+
for (let index = 0; index <= data.length; index++) {
|
|
75
|
+
const atNewline = index < data.length && data[index] === 0x0a;
|
|
76
|
+
const atCompleteEof = index === data.length && stat.size <= MAX_HEADER_BYTES;
|
|
77
|
+
if (!atNewline && !atCompleteEof) continue;
|
|
78
|
+
|
|
79
|
+
const line = data.subarray(lineStart, index);
|
|
80
|
+
const parsed = parseLine(line, lineStart, atNewline ? index + 1 : index, atNewline);
|
|
81
|
+
lineStart = index + 1;
|
|
82
|
+
if (parsed === "blank" || parsed === "malformed") continue;
|
|
83
|
+
if (parsed.value.type !== "session" || typeof parsed.value.id !== "string") {
|
|
84
|
+
throw new Error(`First parsed JSONL record is not a Pi session header: ${filePath}`);
|
|
85
|
+
}
|
|
86
|
+
return parsed as SessionHeaderRecord;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
throw new Error(`No Pi session header found within ${MAX_HEADER_BYTES} bytes: ${filePath}`);
|
|
90
|
+
} finally {
|
|
91
|
+
closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readJsonlRecords(filePath: string, startOffset = 0): JsonlReadResult {
|
|
96
|
+
if (!Number.isSafeInteger(startOffset) || startOffset < 0) {
|
|
97
|
+
throw new Error("JSONL start offset must be a non-negative safe integer");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fd = openSync(filePath, "r");
|
|
101
|
+
try {
|
|
102
|
+
const stat = fstatSync(fd);
|
|
103
|
+
if (startOffset > stat.size) throw new Error("JSONL start offset is beyond end of file");
|
|
104
|
+
|
|
105
|
+
const records: JsonlRecord[] = [];
|
|
106
|
+
let malformedLines = 0;
|
|
107
|
+
let safeCheckpointOffset = startOffset;
|
|
108
|
+
let checkpointHashStart = startOffset;
|
|
109
|
+
let checkpointHash: string | undefined;
|
|
110
|
+
let pending = Buffer.alloc(0);
|
|
111
|
+
let pendingStart = startOffset;
|
|
112
|
+
let position = startOffset;
|
|
113
|
+
|
|
114
|
+
while (position < stat.size) {
|
|
115
|
+
const length = Math.min(READ_BUFFER_SIZE, stat.size - position);
|
|
116
|
+
const chunk = Buffer.allocUnsafe(length);
|
|
117
|
+
const bytesRead = readSync(fd, chunk, 0, length, position);
|
|
118
|
+
if (bytesRead === 0) break;
|
|
119
|
+
position += bytesRead;
|
|
120
|
+
|
|
121
|
+
const combined = pending.length === 0
|
|
122
|
+
? chunk.subarray(0, bytesRead)
|
|
123
|
+
: Buffer.concat([pending, chunk.subarray(0, bytesRead)]);
|
|
124
|
+
let lineStart = 0;
|
|
125
|
+
|
|
126
|
+
for (let index = 0; index < combined.length; index++) {
|
|
127
|
+
if (combined[index] !== 0x0a) continue;
|
|
128
|
+
|
|
129
|
+
const absoluteStart = pendingStart + lineStart;
|
|
130
|
+
const absoluteEnd = pendingStart + index + 1;
|
|
131
|
+
const line = combined.subarray(lineStart, index);
|
|
132
|
+
const parsed = parseLine(line, absoluteStart, absoluteEnd, true);
|
|
133
|
+
if (parsed === "malformed") malformedLines++;
|
|
134
|
+
else if (parsed !== "blank") records.push(parsed);
|
|
135
|
+
|
|
136
|
+
safeCheckpointOffset = absoluteEnd;
|
|
137
|
+
const boundedHashStart = Math.max(lineStart, index + 1 - MAX_CHECKPOINT_HASH_BYTES);
|
|
138
|
+
checkpointHashStart = pendingStart + boundedHashStart;
|
|
139
|
+
checkpointHash = sha256(combined.subarray(boundedHashStart, index + 1));
|
|
140
|
+
lineStart = index + 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pending = Buffer.from(combined.subarray(lineStart));
|
|
144
|
+
pendingStart += lineStart;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (pending.length > 0) {
|
|
148
|
+
const parsed = parseLine(pending, pendingStart, stat.size, false);
|
|
149
|
+
if (parsed === "malformed") malformedLines++;
|
|
150
|
+
else if (parsed !== "blank") records.push(parsed);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
records,
|
|
155
|
+
malformedLines,
|
|
156
|
+
fileSize: stat.size,
|
|
157
|
+
fileMtimeMs: stat.mtimeMs,
|
|
158
|
+
safeCheckpointOffset,
|
|
159
|
+
checkpointHashStart,
|
|
160
|
+
...(checkpointHash ? { checkpointHash } : {}),
|
|
161
|
+
};
|
|
162
|
+
} finally {
|
|
163
|
+
closeSync(fd);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hashFileRange(filePath: string, startOffset: number, endOffset: number): string {
|
|
168
|
+
if (startOffset < 0 || endOffset < startOffset) throw new Error("Invalid file range");
|
|
169
|
+
const length = endOffset - startOffset;
|
|
170
|
+
const fd = openSync(filePath, "r");
|
|
171
|
+
try {
|
|
172
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
173
|
+
let total = 0;
|
|
174
|
+
while (total < length) {
|
|
175
|
+
const bytesRead = readSync(fd, buffer, total, length - total, startOffset + total);
|
|
176
|
+
if (bytesRead === 0) throw new Error("Unexpected end of file while validating index checkpoint");
|
|
177
|
+
total += bytesRead;
|
|
178
|
+
}
|
|
179
|
+
return sha256(buffer);
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync(fd);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelDescriptor } from "ds4-context-core/core/model-profile";
|
|
3
|
+
|
|
4
|
+
export interface PiSessionSnapshot {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
sessionFile?: string;
|
|
7
|
+
projectPath: string;
|
|
8
|
+
leafId?: string;
|
|
9
|
+
totalEntries: number;
|
|
10
|
+
branchEntries: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type SessionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
|
|
14
|
+
|
|
15
|
+
export function snapshotSession(ctx: SessionContext): PiSessionSnapshot {
|
|
16
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
17
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
21
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
22
|
+
projectPath: ctx.cwd,
|
|
23
|
+
...(leafId ? { leafId } : {}),
|
|
24
|
+
totalEntries: ctx.sessionManager.getEntries().length,
|
|
25
|
+
branchEntries: ctx.sessionManager.getBranch().length,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function snapshotModel(ctx: Pick<ExtensionContext, "model">): ModelDescriptor | undefined {
|
|
30
|
+
if (!ctx.model) return undefined;
|
|
31
|
+
return {
|
|
32
|
+
provider: ctx.model.provider,
|
|
33
|
+
id: ctx.model.id,
|
|
34
|
+
contextWindow: ctx.model.contextWindow,
|
|
35
|
+
maxTokens: ctx.model.maxTokens,
|
|
36
|
+
reasoning: ctx.model.reasoning,
|
|
37
|
+
input: ctx.model.input,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
SessionBeforeCompactEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
groundSummaryFileSections,
|
|
9
|
+
pruneUnsupportedExactValueBullets,
|
|
10
|
+
validateSummary,
|
|
11
|
+
type SummaryValidationInput,
|
|
12
|
+
type SummaryValidationResult,
|
|
13
|
+
} from "ds4-context-core/compaction/summary-contract";
|
|
14
|
+
|
|
15
|
+
export interface GenerateValidatedSummaryInput {
|
|
16
|
+
stage: "segment" | "aggregate";
|
|
17
|
+
prompt: string;
|
|
18
|
+
validationSource: string;
|
|
19
|
+
readFiles: readonly string[];
|
|
20
|
+
modifiedFiles: readonly string[];
|
|
21
|
+
validate: boolean;
|
|
22
|
+
maxSummaryTokens: number;
|
|
23
|
+
event: SessionBeforeCompactEvent;
|
|
24
|
+
ctx: ExtensionContext;
|
|
25
|
+
now: () => number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GeneratedSummary {
|
|
29
|
+
content: string;
|
|
30
|
+
validation: SummaryValidationResult;
|
|
31
|
+
usage: Usage;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unique(values: readonly string[]): string[] {
|
|
35
|
+
return [...new Set(values)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function responseText(response: unknown): string {
|
|
39
|
+
if (!response || typeof response !== "object" || !("content" in response)) return "";
|
|
40
|
+
const content = (response as { content?: unknown }).content;
|
|
41
|
+
if (!Array.isArray(content)) return "";
|
|
42
|
+
return content.flatMap((block) => {
|
|
43
|
+
if (!block || typeof block !== "object" || !("type" in block) || !("text" in block)) return [];
|
|
44
|
+
return block.type === "text" && typeof block.text === "string" ? [block.text] : [];
|
|
45
|
+
}).join("\n").trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function responseStopReason(response: unknown): string | undefined {
|
|
49
|
+
if (!response || typeof response !== "object" || !("stopReason" in response)) return undefined;
|
|
50
|
+
return typeof response.stopReason === "string" ? response.stopReason : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function generateValidatedSummary(
|
|
54
|
+
input: GenerateValidatedSummaryInput,
|
|
55
|
+
): Promise<GeneratedSummary> {
|
|
56
|
+
const model = input.ctx.model;
|
|
57
|
+
if (!model) throw new Error("Compaction summary generation requires an active model");
|
|
58
|
+
const maxTokens = Math.max(1, Math.min(input.maxSummaryTokens, model.maxTokens ?? input.maxSummaryTokens));
|
|
59
|
+
const response = await input.ctx.modelRegistry.complete(
|
|
60
|
+
model,
|
|
61
|
+
{
|
|
62
|
+
messages: [{
|
|
63
|
+
role: "user",
|
|
64
|
+
content: [{ type: "text", text: input.prompt }],
|
|
65
|
+
timestamp: input.now(),
|
|
66
|
+
}],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
maxTokens,
|
|
70
|
+
signal: input.event.signal,
|
|
71
|
+
cacheRetention: "none",
|
|
72
|
+
sessionId: randomUUID(),
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
if (input.event.signal.aborted) throw new Error("Compaction summary generation aborted");
|
|
76
|
+
const stopReason = responseStopReason(response);
|
|
77
|
+
if (stopReason === "length") throw new Error("Compaction summary hit the model output limit");
|
|
78
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
79
|
+
throw new Error(`Compaction summary stopped with ${stopReason}`);
|
|
80
|
+
}
|
|
81
|
+
if (response.content.some((block) => block.type === "toolCall")) {
|
|
82
|
+
throw new Error("Compaction summarizer attempted to call a tool");
|
|
83
|
+
}
|
|
84
|
+
const generatedContent = responseText(response);
|
|
85
|
+
if (!generatedContent) throw new Error("Compaction summarizer returned empty text");
|
|
86
|
+
let content = groundSummaryFileSections(generatedContent, {
|
|
87
|
+
readFiles: input.readFiles,
|
|
88
|
+
modifiedFiles: input.modifiedFiles,
|
|
89
|
+
});
|
|
90
|
+
const validationInput: SummaryValidationInput = {
|
|
91
|
+
sourceText: input.validationSource,
|
|
92
|
+
readFiles: input.readFiles,
|
|
93
|
+
modifiedFiles: input.modifiedFiles,
|
|
94
|
+
};
|
|
95
|
+
let validation: SummaryValidationResult = input.validate
|
|
96
|
+
? validateSummary(content, validationInput)
|
|
97
|
+
: {
|
|
98
|
+
status: "warning",
|
|
99
|
+
issues: [{
|
|
100
|
+
code: "validation-disabled",
|
|
101
|
+
severity: "warning",
|
|
102
|
+
message: "Deterministic validation disabled by configuration",
|
|
103
|
+
}],
|
|
104
|
+
};
|
|
105
|
+
if (validation.status === "invalid") {
|
|
106
|
+
const errors = validation.issues.filter((issue) => issue.severity === "error");
|
|
107
|
+
const exactOnly = errors.length > 0
|
|
108
|
+
&& errors.every((issue) => issue.code === "unsupported-exact-value");
|
|
109
|
+
const pruned = exactOnly
|
|
110
|
+
? pruneUnsupportedExactValueBullets(content, validationInput)
|
|
111
|
+
: undefined;
|
|
112
|
+
if (pruned) {
|
|
113
|
+
const repairedValidation = validateSummary(pruned.content, validationInput);
|
|
114
|
+
if (repairedValidation.status !== "invalid") {
|
|
115
|
+
content = pruned.content;
|
|
116
|
+
validation = {
|
|
117
|
+
status: "warning",
|
|
118
|
+
issues: [
|
|
119
|
+
...repairedValidation.issues,
|
|
120
|
+
{
|
|
121
|
+
code: "unsupported-exact-bullets-pruned",
|
|
122
|
+
severity: "warning",
|
|
123
|
+
message: `Removed ${pruned.removedBullets} bullet(s) containing unsupported exact values`,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (validation.status === "invalid") {
|
|
131
|
+
const codes = unique(validation.issues.map((issue) => issue.code)).join(", ");
|
|
132
|
+
throw new Error(`Compaction ${input.stage} summary validation failed: ${codes}`);
|
|
133
|
+
}
|
|
134
|
+
return { content, validation, usage: response.usage };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sumUsage(usages: readonly Usage[]): Usage {
|
|
138
|
+
const sum = (read: (usage: Usage) => number): number => usages.reduce((total, usage) => total + read(usage), 0);
|
|
139
|
+
const hasReasoning = usages.some((usage) => usage.reasoning !== undefined);
|
|
140
|
+
const hasCacheWrite1h = usages.some((usage) => usage.cacheWrite1h !== undefined);
|
|
141
|
+
return {
|
|
142
|
+
input: sum((usage) => usage.input),
|
|
143
|
+
output: sum((usage) => usage.output),
|
|
144
|
+
cacheRead: sum((usage) => usage.cacheRead),
|
|
145
|
+
cacheWrite: sum((usage) => usage.cacheWrite),
|
|
146
|
+
...(hasCacheWrite1h ? { cacheWrite1h: sum((usage) => usage.cacheWrite1h ?? 0) } : {}),
|
|
147
|
+
...(hasReasoning ? { reasoning: sum((usage) => usage.reasoning ?? 0) } : {}),
|
|
148
|
+
totalTokens: sum((usage) => usage.totalTokens),
|
|
149
|
+
cost: {
|
|
150
|
+
input: sum((usage) => usage.cost.input),
|
|
151
|
+
output: sum((usage) => usage.cost.output),
|
|
152
|
+
cacheRead: sum((usage) => usage.cost.cacheRead),
|
|
153
|
+
cacheWrite: sum((usage) => usage.cost.cacheWrite),
|
|
154
|
+
total: sum((usage) => usage.cost.total),
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|