ds4-context-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,779 @@
1
+ import type {
2
+ CompactionResult,
3
+ ExtensionContext,
4
+ SessionBeforeCompactEvent,
5
+ SessionCompactEvent,
6
+ SessionEntry,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { Ds4ContextConfig } from "ds4-context-core/config/config";
9
+ import { calculateContextBudget, type ContextBudget } from "ds4-context-core/core/budget-manager";
10
+ import { createModelProfile, type ModelDescriptor } from "ds4-context-core/core/model-profile";
11
+ import type { ContextManifest } from "ds4-context-core/manifest/context-manifest";
12
+ import type { ContextDatabase } from "ds4-context-core/persistence/sqlite";
13
+ import {
14
+ highestClassification,
15
+ type PrivacyClassification,
16
+ } from "ds4-context-core/privacy/privacy-policy";
17
+ import { prepareCompactionSource } from "./compaction-adapter.ts";
18
+ import { adaptiveRecentTailLimit } from "ds4-context-core/planner/context-planner";
19
+ import type { Logger } from "ds4-context-core/shared/logging";
20
+ import {
21
+ type CompactionTrigger,
22
+ type Ds4CompactionDetails,
23
+ type EmbeddedSummaryNode,
24
+ parseDs4CompactionDetails,
25
+ type SummaryRecord,
26
+ } from "ds4-context-core/compaction/compaction-record";
27
+ import {
28
+ generateValidatedSummary,
29
+ sumUsage,
30
+ type GeneratedSummary,
31
+ } from "./summary-generator.ts";
32
+ import {
33
+ createSummaryRecord,
34
+ importUntrackedPreviousSummary,
35
+ recordsFromCompactionEntry,
36
+ type SummaryBoundary,
37
+ } from "ds4-context-core/compaction/summary-graph";
38
+ import {
39
+ buildAggregateSummaryPrompt,
40
+ buildSummaryPrompt,
41
+ computeAggregateSourceHash,
42
+ type SummaryValidationStatus,
43
+ } from "ds4-context-core/compaction/summary-contract";
44
+
45
+ export type CompactionPhase =
46
+ | "idle"
47
+ | "requested"
48
+ | "generating"
49
+ | "prepared"
50
+ | "committed"
51
+ | "pi-default"
52
+ | "failed";
53
+
54
+ export interface CompactionDiagnostics {
55
+ enabled: boolean;
56
+ validate: boolean;
57
+ preserveRecentVerbatim: boolean;
58
+ segmentTargetTokens: number;
59
+ phase: CompactionPhase;
60
+ trigger?: CompactionTrigger;
61
+ summaryId?: string;
62
+ sourceEntries?: number;
63
+ validationStatus?: SummaryValidationStatus;
64
+ firstKeptEntryId?: string;
65
+ tokensBefore?: number;
66
+ requestedAt?: number;
67
+ completedAt?: number;
68
+ lastError?: string;
69
+ contextTokens?: number;
70
+ softLimitTokens?: number;
71
+ proactiveThresholdTokens?: number;
72
+ proactiveEligible: boolean;
73
+ }
74
+
75
+ export interface SummaryGraphNodeDiagnostic {
76
+ id: string;
77
+ kind: SummaryRecord["kind"];
78
+ graphLevel: number;
79
+ lifecycleStatus: SummaryRecord["lifecycleStatus"];
80
+ validationStatus: SummaryValidationStatus;
81
+ sourceEntries: number;
82
+ children: string[];
83
+ createdAt: number;
84
+ activePath: boolean;
85
+ piCompactionEntryId?: string;
86
+ }
87
+
88
+ export interface SummaryGraphDiagnostics {
89
+ totalNodes: number;
90
+ committedNodes: number;
91
+ preparedNodes: number;
92
+ failedNodes: number;
93
+ segmentNodes: number;
94
+ aggregateNodes: number;
95
+ branchNodes: number;
96
+ maxGraphLevel: number;
97
+ rootSummaryIds: string[];
98
+ activeSummaryId?: string;
99
+ activePathIds: string[];
100
+ nodes: SummaryGraphNodeDiagnostic[];
101
+ }
102
+
103
+ export interface SessionCompactFailedLike {
104
+ reason: "manual" | "threshold" | "overflow";
105
+ errorMessage?: string;
106
+ aborted: boolean;
107
+ willRetry: boolean;
108
+ fromExtension: boolean;
109
+ }
110
+
111
+ interface CompactionCoordinatorDependencies {
112
+ config: Ds4ContextConfig;
113
+ database?: ContextDatabase;
114
+ sessionId: string;
115
+ persisted: boolean;
116
+ logger: Logger;
117
+ now: () => number;
118
+ idGenerator: () => string;
119
+ syncSessionIndex: (ctx: ExtensionContext) => void;
120
+ latestManifest: () => ContextManifest | undefined;
121
+ resolveModelBudget?: (model: ModelDescriptor) => {
122
+ budget: ContextBudget;
123
+ recentTailTokens: number;
124
+ };
125
+ sanitizeContent?: (text: string, provider: string) => string;
126
+ classifyContent?: (
127
+ text: string,
128
+ provider: string,
129
+ ) => { value: string; classification: PrivacyClassification };
130
+ }
131
+
132
+ type MutableCompactionState = Omit<
133
+ CompactionDiagnostics,
134
+ "enabled" | "validate" | "preserveRecentVerbatim" | "segmentTargetTokens" | "proactiveEligible"
135
+ >;
136
+
137
+ function classifiedSummary(content: string, classification: PrivacyClassification): string {
138
+ return classification === "normal"
139
+ ? content
140
+ : `[ds4:${classification}]${content}[/ds4:${classification}]`;
141
+ }
142
+
143
+ function unique(values: readonly string[]): string[] {
144
+ return [...new Set(values)];
145
+ }
146
+
147
+ function activeSummaryId(entries: readonly SessionEntry[]): string | undefined {
148
+ for (let index = entries.length - 1; index >= 0; index--) {
149
+ const entry = entries[index];
150
+ if (entry?.type !== "compaction") continue;
151
+ return parseDs4CompactionDetails(entry.details)?.ds4ContextEngine.summaryId;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ export class CompactionCoordinator {
157
+ private state: MutableCompactionState = { phase: "idle" };
158
+ private pendingSummaryIds: string[] = [];
159
+ private proactiveRequested = false;
160
+ private lastProactiveLeafId?: string;
161
+ private readonly graphRecords = new Map<string, SummaryRecord>();
162
+
163
+ constructor(private readonly dependencies: CompactionCoordinatorDependencies) {}
164
+
165
+ initialize(entries: readonly SessionEntry[]): void {
166
+ if (!this.dependencies.database || !this.dependencies.persisted) return;
167
+ this.dependencies.database.summaries.failPreparedForSession(this.dependencies.sessionId);
168
+ this.reconcile(entries);
169
+ this.reloadGraphRecords();
170
+ const latest = this.dependencies.database.summaries.getLatest(this.dependencies.sessionId);
171
+ if (latest) this.restoreState(latest);
172
+ }
173
+
174
+ async beforeCompact(
175
+ event: SessionBeforeCompactEvent,
176
+ ctx: ExtensionContext,
177
+ ): Promise<{ compaction?: CompactionResult } | undefined> {
178
+ const config = this.dependencies.config;
179
+ if (!config.compaction.enabled || !config.enabled || !ctx.model || config.context.maxSummaryTokens <= 0) {
180
+ return undefined;
181
+ }
182
+
183
+ const requestedAt = this.dependencies.now();
184
+ const trigger: CompactionTrigger = this.proactiveRequested ? "proactive" : event.reason;
185
+ this.state = {
186
+ phase: "generating",
187
+ trigger,
188
+ requestedAt,
189
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
190
+ tokensBefore: event.preparation.tokensBefore,
191
+ };
192
+
193
+ try {
194
+ this.dependencies.syncSessionIndex(ctx);
195
+ const source = prepareCompactionSource(event);
196
+ this.state.sourceEntries = source.sourceEntryIds.length;
197
+ const usedIds = new Set(this.graphRecords.keys());
198
+ if (source.previousNode) usedIds.add(source.previousNode.id);
199
+ const nextId = (): string => {
200
+ const id = this.dependencies.idGenerator();
201
+ if (!id || usedIds.has(id)) throw new Error(`Summary ID collision: ${id || "<empty>"}`);
202
+ usedIds.add(id);
203
+ return id;
204
+ };
205
+ const boundary: SummaryBoundary = {
206
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
207
+ tokensBefore: event.preparation.tokensBefore,
208
+ reason: trigger,
209
+ isSplitTurn: event.preparation.isSplitTurn,
210
+ messageCount: source.messages.length,
211
+ provider: ctx.model.provider,
212
+ model: ctx.model.id,
213
+ };
214
+ const classifyContent = (text: string): { value: string; classification: PrivacyClassification } => {
215
+ const classified = this.dependencies.classifyContent?.(text, ctx.model!.provider);
216
+ if (classified) return classified;
217
+ return {
218
+ value: this.dependencies.sanitizeContent?.(text, ctx.model!.provider) ?? text,
219
+ classification: "normal",
220
+ };
221
+ };
222
+ const sourcePrivacy = classifyContent(source.conversationText);
223
+ const validationPrivacy = classifyContent(source.sourceText);
224
+ const instructionPrivacy = event.customInstructions
225
+ ? classifyContent(event.customInstructions)
226
+ : undefined;
227
+ const readFilePrivacy = source.segmentReadFiles.map(classifyContent);
228
+ const modifiedFilePrivacy = source.segmentModifiedFiles.map(classifyContent);
229
+ const segmentClassification = [
230
+ sourcePrivacy.classification,
231
+ validationPrivacy.classification,
232
+ ...(instructionPrivacy ? [instructionPrivacy.classification] : []),
233
+ ...readFilePrivacy.map((item) => item.classification),
234
+ ...modifiedFilePrivacy.map((item) => item.classification),
235
+ ].reduce(highestClassification, "normal" as PrivacyClassification);
236
+ const segmentGenerated = await this.generateSummary({
237
+ stage: "segment",
238
+ prompt: buildSummaryPrompt({
239
+ conversationText: sourcePrivacy.value,
240
+ ...(instructionPrivacy ? { customInstructions: instructionPrivacy.value } : {}),
241
+ readFiles: readFilePrivacy.map((item) => item.value),
242
+ modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
243
+ isSplitTurn: event.preparation.isSplitTurn,
244
+ }),
245
+ validationSource: validationPrivacy.value,
246
+ readFiles: readFilePrivacy.map((item) => item.value),
247
+ modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
248
+ event,
249
+ ctx,
250
+ });
251
+ const segmentId = nextId();
252
+ const segmentNode: EmbeddedSummaryNode = {
253
+ id: segmentId,
254
+ kind: "segment",
255
+ content: classifiedSummary(segmentGenerated.content, segmentClassification),
256
+ sourceHash: source.sourceHash,
257
+ sourceEntryIds: [...source.sourceEntryIds],
258
+ childSummaryIds: [],
259
+ graphLevel: 0,
260
+ createdAt: this.dependencies.now(),
261
+ validationStatus: segmentGenerated.validation.status,
262
+ validationIssueCodes: unique(segmentGenerated.validation.issues.map((issue) => issue.code)),
263
+ provider: ctx.model.provider,
264
+ model: ctx.model.id,
265
+ };
266
+
267
+ const embeddedNodes: EmbeddedSummaryNode[] = [];
268
+ let previousNode = source.previousNode;
269
+ if (!previousNode && source.previousSummary) {
270
+ previousNode = importUntrackedPreviousSummary({
271
+ id: nextId(),
272
+ content: source.previousSummary,
273
+ createdAt: this.dependencies.now(),
274
+ provider: ctx.model.provider,
275
+ model: ctx.model.id,
276
+ });
277
+ embeddedNodes.push(previousNode);
278
+ }
279
+
280
+ const usages = [segmentGenerated.usage];
281
+ let activeNode = segmentNode;
282
+ if (previousNode) {
283
+ const aggregateChildren = [previousNode, segmentNode];
284
+ const classifiedChildren = aggregateChildren.map((child) => ({
285
+ child,
286
+ privacy: classifyContent(child.content),
287
+ }));
288
+ const promptChildren = classifiedChildren.map(({ child, privacy }) => ({
289
+ ...child,
290
+ content: privacy.value,
291
+ }));
292
+ const aggregateReadFiles = source.readFiles.map(classifyContent);
293
+ const aggregateModifiedFiles = source.modifiedFiles.map(classifyContent);
294
+ const aggregateInstruction = event.customInstructions
295
+ ? classifyContent(event.customInstructions)
296
+ : undefined;
297
+ const aggregateClassification = [
298
+ ...classifiedChildren.map((item) => item.privacy.classification),
299
+ ...(aggregateInstruction ? [aggregateInstruction.classification] : []),
300
+ ...aggregateReadFiles.map((item) => item.classification),
301
+ ...aggregateModifiedFiles.map((item) => item.classification),
302
+ ].reduce(highestClassification, "normal" as PrivacyClassification);
303
+ const aggregateGenerated = await this.generateSummary({
304
+ stage: "aggregate",
305
+ prompt: buildAggregateSummaryPrompt({
306
+ children: promptChildren,
307
+ ...(aggregateInstruction ? { customInstructions: aggregateInstruction.value } : {}),
308
+ readFiles: aggregateReadFiles.map((item) => item.value),
309
+ modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
310
+ }),
311
+ validationSource: promptChildren.map((child) => child.content).join("\n\n"),
312
+ readFiles: aggregateReadFiles.map((item) => item.value),
313
+ modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
314
+ event,
315
+ ctx,
316
+ });
317
+ usages.push(aggregateGenerated.usage);
318
+ activeNode = {
319
+ id: nextId(),
320
+ kind: "aggregate",
321
+ content: classifiedSummary(aggregateGenerated.content, aggregateClassification),
322
+ sourceHash: computeAggregateSourceHash(aggregateChildren),
323
+ sourceEntryIds: unique(aggregateChildren.flatMap((child) => child.sourceEntryIds)),
324
+ childSummaryIds: aggregateChildren.map((child) => child.id),
325
+ graphLevel: Math.max(...aggregateChildren.map((child) => child.graphLevel)) + 1,
326
+ createdAt: this.dependencies.now(),
327
+ validationStatus: aggregateGenerated.validation.status,
328
+ validationIssueCodes: unique(aggregateGenerated.validation.issues.map((issue) => issue.code)),
329
+ provider: ctx.model.provider,
330
+ model: ctx.model.id,
331
+ };
332
+ embeddedNodes.push(segmentNode);
333
+ }
334
+
335
+ const records = embeddedNodes.map((node) => createSummaryRecord({
336
+ sessionId: this.dependencies.sessionId,
337
+ node,
338
+ boundary,
339
+ segmentSummaryId: node.kind === "segment" ? node.id : segmentId,
340
+ lifecycleStatus: "prepared",
341
+ }));
342
+ records.push(createSummaryRecord({
343
+ sessionId: this.dependencies.sessionId,
344
+ node: activeNode,
345
+ boundary,
346
+ segmentSummaryId: segmentId,
347
+ lifecycleStatus: "prepared",
348
+ embeddedNodes,
349
+ }));
350
+ if (this.dependencies.persisted) this.dependencies.database?.summaries.saveGraph(records);
351
+ for (const record of records) this.graphRecords.set(record.id, record);
352
+ this.pendingSummaryIds = records.map((record) => record.id);
353
+ this.state = {
354
+ phase: "prepared",
355
+ trigger,
356
+ summaryId: activeNode.id,
357
+ sourceEntries: activeNode.sourceEntryIds.length,
358
+ validationStatus: activeNode.validationStatus,
359
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
360
+ tokensBefore: event.preparation.tokensBefore,
361
+ requestedAt,
362
+ };
363
+ this.dependencies.logger.info("compaction.summary_graph_prepared", {
364
+ activeSummaryId: activeNode.id,
365
+ segmentSummaryId: segmentId,
366
+ graphLevel: activeNode.graphLevel,
367
+ createdNodes: records.length,
368
+ sourceEntries: activeNode.sourceEntryIds.length,
369
+ validationStatus: activeNode.validationStatus,
370
+ trigger,
371
+ });
372
+ const activeRecord = records.at(-1);
373
+ if (!activeRecord) throw new Error("Compaction graph produced no active summary node");
374
+ const details: Ds4CompactionDetails = {
375
+ readFiles: source.readFiles,
376
+ modifiedFiles: source.modifiedFiles,
377
+ ds4ContextEngine: activeRecord.metadata,
378
+ };
379
+ return {
380
+ compaction: {
381
+ summary: activeNode.content,
382
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
383
+ tokensBefore: event.preparation.tokensBefore,
384
+ usage: sumUsage(usages),
385
+ details,
386
+ },
387
+ };
388
+ } catch (error) {
389
+ const message = error instanceof Error ? error.message : String(error);
390
+ this.state = {
391
+ ...this.state,
392
+ phase: "failed",
393
+ trigger,
394
+ completedAt: this.dependencies.now(),
395
+ lastError: message,
396
+ };
397
+ this.dependencies.logger.warn("compaction.custom_fallback", { trigger, error: message });
398
+ if (!event.signal.aborted && ctx.hasUI) {
399
+ ctx.ui.notify(`DS4 compaction unavailable; using Pi default. ${message}`, "warning");
400
+ }
401
+ return undefined;
402
+ }
403
+ }
404
+
405
+ afterCompaction(event: SessionCompactEvent, ctx: ExtensionContext): void {
406
+ this.proactiveRequested = false;
407
+ this.dependencies.syncSessionIndex(ctx);
408
+ const expectedSummaryId = this.state.phase === "prepared" ? this.state.summaryId : undefined;
409
+ const eventSummaryId = parseDs4CompactionDetails(event.compactionEntry.details)?.ds4ContextEngine.summaryId;
410
+ const effectiveEntry = expectedSummaryId && eventSummaryId === expectedSummaryId
411
+ ? event.compactionEntry
412
+ : expectedSummaryId
413
+ ? [...ctx.sessionManager.getEntries()].reverse().find((entry): entry is Extract<SessionEntry, { type: "compaction" }> => {
414
+ if (entry.type !== "compaction") return false;
415
+ return parseDs4CompactionDetails(entry.details)?.ds4ContextEngine.summaryId === expectedSummaryId;
416
+ })
417
+ : undefined;
418
+ const details = expectedSummaryId && effectiveEntry
419
+ ? parseDs4CompactionDetails(effectiveEntry.details)
420
+ : undefined;
421
+ if (!details || !effectiveEntry) {
422
+ this.failPendingNodes();
423
+ const customError = this.state.lastError;
424
+ this.state = {
425
+ phase: "pi-default",
426
+ trigger: event.reason,
427
+ firstKeptEntryId: event.compactionEntry.firstKeptEntryId,
428
+ tokensBefore: event.compactionEntry.tokensBefore,
429
+ completedAt: this.dependencies.now(),
430
+ ...(customError ? { lastError: `Custom fallback: ${customError}` } : {}),
431
+ };
432
+ return;
433
+ }
434
+
435
+ if (effectiveEntry.id !== event.compactionEntry.id) {
436
+ this.dependencies.logger.debug("compaction.stale_event_entry_corrected", {
437
+ eventEntryId: event.compactionEntry.id,
438
+ committedEntryId: effectiveEntry.id,
439
+ summaryId: expectedSummaryId,
440
+ });
441
+ }
442
+ try {
443
+ this.persistCommitted(effectiveEntry, details);
444
+ } catch (error) {
445
+ this.dependencies.logger.warn("compaction.summary_graph_commit_persist_failed", {
446
+ summaryId: details.ds4ContextEngine.summaryId,
447
+ error: error instanceof Error ? error.message : String(error),
448
+ });
449
+ }
450
+ this.pendingSummaryIds = [];
451
+ const metadata = details.ds4ContextEngine;
452
+ this.state = {
453
+ phase: "committed",
454
+ trigger: metadata.reason,
455
+ summaryId: metadata.summaryId,
456
+ sourceEntries: metadata.sourceEntryIds.length,
457
+ validationStatus: metadata.validationStatus,
458
+ firstKeptEntryId: effectiveEntry.firstKeptEntryId,
459
+ tokensBefore: effectiveEntry.tokensBefore,
460
+ requestedAt: metadata.generatedAt,
461
+ completedAt: this.dependencies.now(),
462
+ };
463
+ this.dependencies.logger.info("compaction.summary_graph_committed", {
464
+ activeSummaryId: metadata.summaryId,
465
+ segmentSummaryId: metadata.segmentSummaryId,
466
+ graphLevel: metadata.graphLevel,
467
+ compactionEntryId: effectiveEntry.id,
468
+ trigger: metadata.reason,
469
+ });
470
+ }
471
+
472
+ compactionFailed(event: SessionCompactFailedLike): void {
473
+ this.proactiveRequested = false;
474
+ this.failPendingNodes();
475
+ const message = event.errorMessage ?? (event.aborted ? "Compaction aborted" : "Compaction failed");
476
+ this.state = {
477
+ ...this.state,
478
+ phase: "failed",
479
+ trigger: this.state.trigger ?? event.reason,
480
+ completedAt: this.dependencies.now(),
481
+ lastError: message,
482
+ };
483
+ this.dependencies.logger.warn("compaction.failed", {
484
+ reason: event.reason,
485
+ fromExtension: event.fromExtension,
486
+ aborted: event.aborted,
487
+ error: message,
488
+ });
489
+ }
490
+
491
+ afterAgentSettled(ctx: ExtensionContext): void {
492
+ this.dependencies.syncSessionIndex(ctx);
493
+ const config = this.dependencies.config;
494
+ if (!config.compaction.enabled || !config.enabled || this.proactiveRequested) return;
495
+ if (!ctx.model || !ctx.isIdle() || ctx.hasPendingMessages()) return;
496
+ if (ctx.sessionManager.getBranch().at(-1)?.type === "compaction") return;
497
+
498
+ const usage = ctx.getContextUsage();
499
+ if (usage?.tokens === null || usage?.tokens === undefined) return;
500
+ const resolved = this.dependencies.resolveModelBudget?.(ctx.model)
501
+ ?? {
502
+ budget: calculateContextBudget(createModelProfile(ctx.model), config.context),
503
+ recentTailTokens: adaptiveRecentTailLimit(
504
+ ctx.model.contextWindow,
505
+ config.context.recentTailTokens,
506
+ ),
507
+ };
508
+ const budget = resolved.budget;
509
+ const leafId = ctx.sessionManager.getLeafId() ?? "root";
510
+ const threshold = this.proactiveThreshold(budget, resolved.recentTailTokens);
511
+ if (usage.tokens < threshold || this.lastProactiveLeafId === leafId) return;
512
+
513
+ this.proactiveRequested = true;
514
+ this.lastProactiveLeafId = leafId;
515
+ this.state = {
516
+ phase: "requested",
517
+ trigger: "proactive",
518
+ requestedAt: this.dependencies.now(),
519
+ contextTokens: usage.tokens,
520
+ softLimitTokens: this.providerSoftLimit(budget),
521
+ proactiveThresholdTokens: threshold,
522
+ };
523
+ if (ctx.hasUI) {
524
+ ctx.ui.notify(
525
+ `DS4 proactive compaction: ${usage.tokens.toLocaleString()} tokens reached the ${threshold.toLocaleString()} proactive threshold.`,
526
+ "info",
527
+ );
528
+ }
529
+ try {
530
+ ctx.compact({
531
+ onComplete: () => {
532
+ this.proactiveRequested = false;
533
+ },
534
+ onError: (error) => {
535
+ this.proactiveRequested = false;
536
+ if (this.state.phase === "requested") {
537
+ this.state = {
538
+ ...this.state,
539
+ phase: "failed",
540
+ completedAt: this.dependencies.now(),
541
+ lastError: error.message,
542
+ };
543
+ }
544
+ },
545
+ });
546
+ } catch (error) {
547
+ this.proactiveRequested = false;
548
+ this.state = {
549
+ ...this.state,
550
+ phase: "failed",
551
+ completedAt: this.dependencies.now(),
552
+ lastError: error instanceof Error ? error.message : String(error),
553
+ };
554
+ }
555
+ }
556
+
557
+ diagnostics(ctx: ExtensionContext): CompactionDiagnostics {
558
+ const config = this.dependencies.config;
559
+ const usage = ctx.getContextUsage();
560
+ const resolved = ctx.model
561
+ ? this.dependencies.resolveModelBudget?.(ctx.model)
562
+ ?? {
563
+ budget: calculateContextBudget(createModelProfile(ctx.model), config.context),
564
+ recentTailTokens: adaptiveRecentTailLimit(
565
+ ctx.model.contextWindow,
566
+ config.context.recentTailTokens,
567
+ ),
568
+ }
569
+ : undefined;
570
+ const budget = resolved?.budget;
571
+ const contextTokens = usage?.tokens ?? undefined;
572
+ const threshold = resolved
573
+ ? this.proactiveThreshold(resolved.budget, resolved.recentTailTokens)
574
+ : undefined;
575
+ const leafId = ctx.sessionManager.getLeafId() ?? "root";
576
+ const lastEntryIsCompaction = ctx.sessionManager.getBranch().at(-1)?.type === "compaction";
577
+ const proactiveEligible = Boolean(
578
+ config.enabled
579
+ && config.compaction.enabled
580
+ && contextTokens !== undefined
581
+ && threshold !== undefined
582
+ && contextTokens >= threshold
583
+ && !lastEntryIsCompaction
584
+ && this.lastProactiveLeafId !== leafId,
585
+ );
586
+
587
+ return {
588
+ enabled: config.compaction.enabled,
589
+ validate: config.compaction.validate,
590
+ preserveRecentVerbatim: config.compaction.preserveRecentVerbatim,
591
+ segmentTargetTokens: config.compaction.segmentTargetTokens,
592
+ ...this.state,
593
+ ...(contextTokens !== undefined ? { contextTokens } : {}),
594
+ ...(budget ? { softLimitTokens: this.providerSoftLimit(budget) } : {}),
595
+ ...(threshold !== undefined ? { proactiveThresholdTokens: threshold } : {}),
596
+ proactiveEligible,
597
+ };
598
+ }
599
+
600
+ summaryGraph(ctx: ExtensionContext): SummaryGraphDiagnostics {
601
+ const records = [...this.graphRecords.values()];
602
+ const activeId = activeSummaryId(ctx.sessionManager.getBranch());
603
+ const activePath = new Set<string>();
604
+ const visit = (id: string): void => {
605
+ if (activePath.has(id)) return;
606
+ activePath.add(id);
607
+ for (const child of this.graphRecords.get(id)?.childSummaryIds ?? []) visit(child);
608
+ };
609
+ if (activeId) visit(activeId);
610
+ const committed = records.filter((record) => record.lifecycleStatus === "committed");
611
+ const childIds = new Set(committed.flatMap((record) => record.childSummaryIds));
612
+ const roots = committed.filter((record) => !childIds.has(record.id)).map((record) => record.id);
613
+ const nodes = records
614
+ .sort((left, right) => right.createdAt - left.createdAt || right.graphLevel - left.graphLevel || left.id.localeCompare(right.id))
615
+ .map((record) => ({
616
+ id: record.id,
617
+ kind: record.kind,
618
+ graphLevel: record.graphLevel,
619
+ lifecycleStatus: record.lifecycleStatus,
620
+ validationStatus: record.validationStatus,
621
+ sourceEntries: record.sourceEntryIds.length,
622
+ children: [...record.childSummaryIds],
623
+ createdAt: record.createdAt,
624
+ activePath: activePath.has(record.id),
625
+ ...(record.piCompactionEntryId ? { piCompactionEntryId: record.piCompactionEntryId } : {}),
626
+ }));
627
+ return {
628
+ totalNodes: records.length,
629
+ committedNodes: committed.length,
630
+ preparedNodes: records.filter((record) => record.lifecycleStatus === "prepared").length,
631
+ failedNodes: records.filter((record) => record.lifecycleStatus === "failed").length,
632
+ segmentNodes: records.filter((record) => record.kind === "segment").length,
633
+ aggregateNodes: records.filter((record) => record.kind === "aggregate").length,
634
+ branchNodes: records.filter((record) => record.kind === "branch").length,
635
+ maxGraphLevel: records.reduce((maximum, record) => Math.max(maximum, record.graphLevel), 0),
636
+ rootSummaryIds: roots,
637
+ ...(activeId ? { activeSummaryId: activeId } : {}),
638
+ activePathIds: [...activePath],
639
+ nodes,
640
+ };
641
+ }
642
+
643
+ private generateSummary(input: {
644
+ stage: "segment" | "aggregate";
645
+ prompt: string;
646
+ validationSource: string;
647
+ readFiles: readonly string[];
648
+ modifiedFiles: readonly string[];
649
+ event: SessionBeforeCompactEvent;
650
+ ctx: ExtensionContext;
651
+ }): Promise<GeneratedSummary> {
652
+ return generateValidatedSummary({
653
+ ...input,
654
+ validate: this.dependencies.config.compaction.validate,
655
+ maxSummaryTokens: this.dependencies.config.context.maxSummaryTokens,
656
+ now: this.dependencies.now,
657
+ });
658
+ }
659
+
660
+ private providerSoftLimit(budget: ContextBudget): number {
661
+ return budget.nominalSoftInputLimit ?? budget.softInputLimit;
662
+ }
663
+
664
+ private proactiveThreshold(budget: ContextBudget, recentTailTokens?: number): number {
665
+ const manifest = this.dependencies.latestManifest();
666
+ const fixedTokens = (manifest?.composition.systemTokens ?? 0) + (manifest?.composition.toolTokens ?? 0);
667
+ const recentTail = recentTailTokens ?? adaptiveRecentTailLimit(
668
+ budget.contextWindow,
669
+ this.dependencies.config.context.recentTailTokens,
670
+ );
671
+ const estimatorThreshold = fixedTokens
672
+ + recentTail
673
+ + this.dependencies.config.compaction.segmentTargetTokens;
674
+ const providerThreshold = Math.floor(estimatorThreshold * (budget.calibrationRatio ?? 1));
675
+ return Math.min(this.providerSoftLimit(budget), providerThreshold);
676
+ }
677
+
678
+ private persistCommitted(
679
+ entry: Extract<SessionEntry, { type: "compaction" }>,
680
+ details: Ds4CompactionDetails,
681
+ ): void {
682
+ const records = recordsFromCompactionEntry({
683
+ sessionId: this.dependencies.sessionId,
684
+ entry,
685
+ details,
686
+ lifecycleStatus: "committed",
687
+ });
688
+ for (const record of records) this.graphRecords.set(record.id, record);
689
+ if (this.dependencies.database && this.dependencies.persisted) {
690
+ this.dependencies.database.summaries.saveGraph(records);
691
+ }
692
+ }
693
+
694
+ private reconcile(entries: readonly SessionEntry[]): void {
695
+ for (const entry of entries) {
696
+ if (entry.type !== "compaction") continue;
697
+ const details = parseDs4CompactionDetails(entry.details);
698
+ if (!details) continue;
699
+ try {
700
+ this.persistCommitted(entry, details);
701
+ } catch (error) {
702
+ this.dependencies.logger.warn("compaction.summary_graph_reconcile_failed", {
703
+ summaryId: details.ds4ContextEngine.summaryId,
704
+ entryId: entry.id,
705
+ error: error instanceof Error ? error.message : String(error),
706
+ });
707
+ }
708
+ }
709
+ }
710
+
711
+ private failPendingNodes(): void {
712
+ if (this.dependencies.database && this.dependencies.persisted) {
713
+ try {
714
+ this.dependencies.database.summaries.markFailedMany(this.pendingSummaryIds);
715
+ } catch (error) {
716
+ this.dependencies.logger.warn("compaction.summary_graph_failure_persist_failed", {
717
+ error: error instanceof Error ? error.message : String(error),
718
+ });
719
+ }
720
+ }
721
+ for (const id of this.pendingSummaryIds) {
722
+ const record = this.graphRecords.get(id);
723
+ if (record?.lifecycleStatus === "prepared") {
724
+ this.graphRecords.set(id, { ...record, lifecycleStatus: "failed" });
725
+ }
726
+ }
727
+ this.pendingSummaryIds = [];
728
+ }
729
+
730
+ private reloadGraphRecords(): void {
731
+ if (!this.dependencies.database || !this.dependencies.persisted) return;
732
+ this.graphRecords.clear();
733
+ for (const record of this.dependencies.database.summaries.listBySession(this.dependencies.sessionId)) {
734
+ this.graphRecords.set(record.id, record);
735
+ }
736
+ }
737
+
738
+ private restoreState(summary: SummaryRecord): void {
739
+ this.state = {
740
+ phase: summary.lifecycleStatus === "committed" ? "committed" : "failed",
741
+ trigger: summary.reason,
742
+ summaryId: summary.id,
743
+ sourceEntries: summary.sourceEntryIds.length,
744
+ validationStatus: summary.validationStatus,
745
+ firstKeptEntryId: summary.firstKeptEntryId,
746
+ tokensBefore: summary.tokensBefore,
747
+ requestedAt: summary.createdAt,
748
+ ...(summary.lifecycleStatus !== "prepared" ? { completedAt: summary.createdAt } : {}),
749
+ ...(summary.lifecycleStatus === "failed" ? { lastError: "Prepared summary was not committed by Pi" } : {}),
750
+ };
751
+ }
752
+ }
753
+
754
+ export function defaultCompactionDiagnostics(config: Ds4ContextConfig): CompactionDiagnostics {
755
+ return {
756
+ enabled: config.compaction.enabled,
757
+ validate: config.compaction.validate,
758
+ preserveRecentVerbatim: config.compaction.preserveRecentVerbatim,
759
+ segmentTargetTokens: config.compaction.segmentTargetTokens,
760
+ phase: "idle",
761
+ proactiveEligible: false,
762
+ };
763
+ }
764
+
765
+ export function defaultSummaryGraphDiagnostics(): SummaryGraphDiagnostics {
766
+ return {
767
+ totalNodes: 0,
768
+ committedNodes: 0,
769
+ preparedNodes: 0,
770
+ failedNodes: 0,
771
+ segmentNodes: 0,
772
+ aggregateNodes: 0,
773
+ branchNodes: 0,
774
+ maxGraphLevel: 0,
775
+ rootSummaryIds: [],
776
+ activePathIds: [],
777
+ nodes: [],
778
+ };
779
+ }