local-context-manager 0.3.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/src/index.ts ADDED
@@ -0,0 +1,941 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Type } from "typebox";
5
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
6
+ import type {
7
+ CompactOptions,
8
+ CompactionResult,
9
+ ExtensionAPI,
10
+ FileOperations,
11
+ ExtensionCommandContext,
12
+ ExtensionContext,
13
+ SessionEntry,
14
+ SessionBeforeCompactEvent,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { runHandoff } from "./handoff.js";
17
+ import {
18
+ getCheckpointStorageDirectory,
19
+ getLatestCheckpointResetRecord,
20
+ getRepositoryState,
21
+ listCheckpointFiles,
22
+ runCheckpointReset,
23
+ } from "./checkpoint-reset.js";
24
+ import { getRearmTokens, CompactionGate, shouldTriggerThresholdCompaction } from "./policy.js";
25
+ import {
26
+ DEFAULT_CONFIG,
27
+ loadConfig,
28
+ type LocalContextManagerConfig,
29
+ } from "./config.js";
30
+ import {
31
+ ContextTelemetry,
32
+ formatTelemetryDetails,
33
+ formatTelemetryStatus,
34
+ } from "./telemetry.js";
35
+ import {
36
+ appendFullOutputNotice,
37
+ extractFullOutputPath,
38
+ reduceToolOutput,
39
+ type ToolContentBlock,
40
+ } from "./tool-output.js";
41
+
42
+ const EXTENSION_STATUS_KEY = "local-context-manager";
43
+ const SEMANTIC_COMPACTION_INSTRUCTIONS =
44
+ "A meaningful task phase has completed. Preserve exact paths, decisions, verification, unresolved issues, and the next independent phase; do not preserve conversational filler.";
45
+ const SEMANTIC_PARAMETERS = Type.Object({
46
+ reason: Type.Optional(Type.String({ description: "Short description of the completed phase" })),
47
+ });
48
+
49
+ type CompactionRequestReason = "threshold" | "semantic";
50
+
51
+ interface PiPathSettings {
52
+ agentDir: string;
53
+ configDirName: string;
54
+ }
55
+
56
+ function debugLog(config: LocalContextManagerConfig, message: string, error?: unknown): void {
57
+ if (!config.debug) {
58
+ return;
59
+ }
60
+ if (error === undefined) {
61
+ console.error(`[local-context-manager] ${message}`);
62
+ } else {
63
+ console.error(`[local-context-manager] ${message}`, error);
64
+ }
65
+ }
66
+
67
+ function parseTimestamp(value: string): number | null {
68
+ const timestamp = Date.parse(value);
69
+ return Number.isFinite(timestamp) ? timestamp : null;
70
+ }
71
+
72
+ function countCompactions(entries: SessionEntry[]): { count: number; lastAt: number | null } {
73
+ let count = 0;
74
+ let lastAt: number | null = null;
75
+ for (const entry of entries) {
76
+ if (entry.type !== "compaction") {
77
+ continue;
78
+ }
79
+ count += 1;
80
+ const timestamp = parseTimestamp(entry.timestamp);
81
+ if (timestamp !== null && (lastAt === null || timestamp > lastAt)) {
82
+ lastAt = timestamp;
83
+ }
84
+ }
85
+ return { count, lastAt };
86
+ }
87
+
88
+ function estimateContentTokens(content: unknown): number {
89
+ if (typeof content === "string") {
90
+ return Math.ceil(content.length / 4);
91
+ }
92
+ if (!Array.isArray(content)) {
93
+ return 0;
94
+ }
95
+
96
+ let characters = 0;
97
+ for (const block of content) {
98
+ if (!block || typeof block !== "object") {
99
+ continue;
100
+ }
101
+ const value = block as { type?: unknown; text?: unknown; thinking?: unknown; name?: unknown; arguments?: unknown };
102
+ if (value.type === "text" && typeof value.text === "string") {
103
+ characters += value.text.length;
104
+ } else if (value.type === "thinking" && typeof value.thinking === "string") {
105
+ characters += value.thinking.length;
106
+ } else if (value.type === "toolCall") {
107
+ const name = typeof value.name === "string" ? value.name.length : 0;
108
+ let argumentsLength = 0;
109
+ try {
110
+ argumentsLength = JSON.stringify(value.arguments ?? {}).length;
111
+ } catch {
112
+ argumentsLength = 0;
113
+ }
114
+ characters += name + argumentsLength;
115
+ } else if (value.type === "image") {
116
+ characters += 4_800;
117
+ }
118
+ }
119
+ return Math.ceil(characters / 4);
120
+ }
121
+
122
+ function estimateToolContentTokens(content: ReadonlyArray<ToolContentBlock>): number {
123
+ return estimateContentTokens(content);
124
+ }
125
+
126
+ function estimateAgentMessageTokens(message: AgentMessage): number {
127
+ switch (message.role) {
128
+ case "user":
129
+ case "assistant":
130
+ case "toolResult":
131
+ case "custom":
132
+ return estimateContentTokens(message.content);
133
+ case "bashExecution": {
134
+ const command = typeof message.command === "string" ? message.command : "";
135
+ const output = typeof message.output === "string" ? message.output : "";
136
+ return Math.ceil((command.length + output.length) / 4);
137
+ }
138
+ case "branchSummary":
139
+ case "compactionSummary":
140
+ return typeof message.summary === "string" ? Math.ceil(message.summary.length / 4) : 0;
141
+ default:
142
+ return 0;
143
+ }
144
+ }
145
+
146
+ function estimateActiveContextTokens(entries: SessionEntry[]): number {
147
+ let tokens = 0;
148
+ for (const entry of entries) {
149
+ if (entry.type === "message") {
150
+ tokens += estimateAgentMessageTokens(entry.message);
151
+ } else if (entry.type === "compaction" || entry.type === "branch_summary") {
152
+ tokens += typeof entry.summary === "string" ? Math.ceil(entry.summary.length / 4) : 0;
153
+ } else if (entry.type === "custom_message") {
154
+ tokens += estimateContentTokens(entry.content);
155
+ }
156
+ }
157
+ return tokens;
158
+ }
159
+
160
+ function estimateActiveToolOutputTokens(entries: SessionEntry[]): number {
161
+ return entries.reduce((total, entry) => {
162
+ if (entry.type !== "message" || entry.message.role !== "toolResult") {
163
+ return total;
164
+ }
165
+ return total + estimateToolContentTokens(entry.message.content);
166
+ }, 0);
167
+ }
168
+
169
+ function cleanBoundaryReason(value: string | undefined): string | undefined {
170
+ const reason = value?.replace(/\s+/g, " ").trim();
171
+ return reason ? reason.slice(0, 240) : undefined;
172
+ }
173
+
174
+ function filePathFromToolArguments(argumentsValue: unknown): string | undefined {
175
+ if (!argumentsValue || typeof argumentsValue !== "object" || Array.isArray(argumentsValue)) {
176
+ return undefined;
177
+ }
178
+ const argumentsRecord = argumentsValue as Record<string, unknown>;
179
+ for (const key of ["path", "file_path", "filePath"]) {
180
+ const path = argumentsRecord[key];
181
+ if (typeof path === "string" && path.trim()) {
182
+ return path.trim();
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+
188
+ function extendFileOperations(messages: AgentMessage[], fileOps: FileOperations): void {
189
+ for (const message of messages) {
190
+ if (message.role !== "assistant") {
191
+ continue;
192
+ }
193
+ for (const block of message.content) {
194
+ if (block.type !== "toolCall") {
195
+ continue;
196
+ }
197
+ const path = filePathFromToolArguments(block.arguments);
198
+ if (!path) {
199
+ continue;
200
+ }
201
+ if (block.name === "read") {
202
+ fileOps.read.add(path);
203
+ } else if (block.name === "write") {
204
+ fileOps.written.add(path);
205
+ } else if (block.name === "edit") {
206
+ fileOps.edited.add(path);
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ async function getPiPathSettings(): Promise<PiPathSettings> {
213
+ const fallback: PiPathSettings = {
214
+ agentDir: process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
215
+ configDirName: ".pi",
216
+ };
217
+
218
+ // These helpers are not needed for the core policy. Keeping them optional lets the
219
+ // extension fall back to conventional paths if it is loaded by an older Pi build.
220
+ try {
221
+ const pi = await import("@earendil-works/pi-coding-agent");
222
+ return {
223
+ agentDir: typeof pi.getAgentDir === "function" ? pi.getAgentDir() : fallback.agentDir,
224
+ configDirName: typeof pi.CONFIG_DIR_NAME === "string" ? pi.CONFIG_DIR_NAME : fallback.configDirName,
225
+ };
226
+ } catch {
227
+ return fallback;
228
+ }
229
+ }
230
+
231
+ async function saveRecoveryCopy(text: string): Promise<string | undefined> {
232
+ try {
233
+ const directory = await mkdtemp(join(tmpdir(), "pi-local-context-"));
234
+ const path = join(directory, "tool-output.txt");
235
+ await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
236
+ return path;
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
242
+ function statusWithCeiling(
243
+ config: LocalContextManagerConfig,
244
+ telemetry: ContextTelemetry,
245
+ ): string {
246
+ const snapshot = telemetry.snapshot(config.compactThresholdTokens);
247
+ const status = formatTelemetryStatus(snapshot);
248
+ return snapshot.contextTokens !== null && snapshot.contextTokens >= config.hardCeilingTokens
249
+ ? `${status} · hard ceiling`
250
+ : status;
251
+ }
252
+
253
+ function updateStatus(
254
+ context: ExtensionContext,
255
+ config: LocalContextManagerConfig,
256
+ telemetry: ContextTelemetry,
257
+ ): void {
258
+ if (!context.hasUI) {
259
+ return;
260
+ }
261
+ context.ui.setStatus(
262
+ EXTENSION_STATUS_KEY,
263
+ config.enabled ? statusWithCeiling(config, telemetry) : "off",
264
+ );
265
+ }
266
+
267
+ function observeContext(
268
+ context: ExtensionContext,
269
+ config: LocalContextManagerConfig,
270
+ telemetry: ContextTelemetry,
271
+ gate: CompactionGate,
272
+ ): number | null {
273
+ const usage = context.getContextUsage();
274
+ telemetry.observe(usage);
275
+ if (usage?.tokens == null) {
276
+ try {
277
+ telemetry.observeEstimate(
278
+ estimateActiveContextTokens(context.sessionManager.buildContextEntries()),
279
+ usage?.contextWindow ?? context.model?.contextWindow,
280
+ );
281
+ } catch (error) {
282
+ debugLog(config, "could not estimate active context", error);
283
+ }
284
+ }
285
+ const snapshot = telemetry.snapshot(config.compactThresholdTokens);
286
+ gate.observe(snapshot.contextTokens);
287
+ updateStatus(context, config, telemetry);
288
+ return snapshot.contextTokens;
289
+ }
290
+
291
+ function notifySoftWarning(
292
+ context: ExtensionContext,
293
+ config: LocalContextManagerConfig,
294
+ telemetry: ContextTelemetry,
295
+ warned: { value: boolean },
296
+ tokens: number | null,
297
+ ): void {
298
+ if (tokens === null || warned.value || tokens < config.softWarningTokens) {
299
+ return;
300
+ }
301
+ warned.value = true;
302
+ if (context.hasUI) {
303
+ context.ui.notify(
304
+ `Context is approaching the local-context-manager threshold (${Math.round(tokens).toLocaleString()} tokens).`,
305
+ "warning",
306
+ );
307
+ }
308
+ debugLog(config, `soft warning at ${tokens} tokens`);
309
+ updateStatus(context, config, telemetry);
310
+ }
311
+
312
+ function buildCompactionOptions(
313
+ reason: CompactionRequestReason,
314
+ instructions: string | undefined,
315
+ onComplete: (result: { estimatedTokensAfter?: number }) => void,
316
+ onError: (error: Error) => void,
317
+ ): CompactOptions {
318
+ const options: CompactOptions = { onComplete, onError };
319
+ if (reason === "semantic") {
320
+ options.customInstructions = instructions || SEMANTIC_COMPACTION_INSTRUCTIONS;
321
+ }
322
+ return options;
323
+ }
324
+
325
+ async function buildCustomCompaction(
326
+ event: SessionBeforeCompactEvent,
327
+ context: ExtensionContext,
328
+ config: LocalContextManagerConfig,
329
+ ): Promise<{ compaction: CompactionResult } | undefined> {
330
+ const model = context.model;
331
+ const nativeKeepRecentTokens = event.preparation.settings.keepRecentTokens;
332
+ if (
333
+ !config.enabled ||
334
+ !model ||
335
+ !Number.isFinite(nativeKeepRecentTokens) ||
336
+ config.keepRecentTokens >= nativeKeepRecentTokens
337
+ ) {
338
+ return undefined;
339
+ }
340
+
341
+ try {
342
+ const pi = await import("@earendil-works/pi-coding-agent");
343
+ if (
344
+ typeof pi.findCutPoint !== "function" ||
345
+ typeof pi.sessionEntryToContextMessages !== "function" ||
346
+ typeof pi.compact !== "function"
347
+ ) {
348
+ debugLog(config, "native compaction helpers are unavailable; using Pi's default compaction");
349
+ return undefined;
350
+ }
351
+
352
+ if (event.branchEntries.at(-1)?.type === "compaction") {
353
+ return undefined;
354
+ }
355
+
356
+ let previousCompactionIndex = -1;
357
+ for (let index = event.branchEntries.length - 1; index >= 0; index--) {
358
+ if (event.branchEntries[index].type === "compaction") {
359
+ previousCompactionIndex = index;
360
+ break;
361
+ }
362
+ }
363
+ let boundaryStart = 0;
364
+ if (previousCompactionIndex >= 0) {
365
+ const previousCompaction = event.branchEntries[previousCompactionIndex];
366
+ if (previousCompaction.type === "compaction") {
367
+ const keptIndex = event.branchEntries.findIndex(
368
+ (entry) => entry.id === previousCompaction.firstKeptEntryId,
369
+ );
370
+ boundaryStart = keptIndex >= 0 ? keptIndex : previousCompactionIndex + 1;
371
+ }
372
+ }
373
+
374
+ const cutPoint = pi.findCutPoint(
375
+ event.branchEntries,
376
+ boundaryStart,
377
+ event.branchEntries.length,
378
+ config.keepRecentTokens,
379
+ );
380
+ const firstKeptEntry = event.branchEntries[cutPoint.firstKeptEntryIndex];
381
+ if (!firstKeptEntry?.id) {
382
+ return undefined;
383
+ }
384
+
385
+ const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
386
+ if (historyEnd < boundaryStart) {
387
+ return undefined;
388
+ }
389
+ const messagesToSummarize = event.branchEntries
390
+ .slice(boundaryStart, historyEnd)
391
+ .flatMap((entry) => (entry.type === "compaction" ? [] : pi.sessionEntryToContextMessages(entry)));
392
+ const turnPrefixMessages = cutPoint.isSplitTurn
393
+ ? event.branchEntries
394
+ .slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex)
395
+ .flatMap((entry) => (entry.type === "compaction" ? [] : pi.sessionEntryToContextMessages(entry)))
396
+ : [];
397
+ if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) {
398
+ return undefined;
399
+ }
400
+
401
+ const fileOps: FileOperations = {
402
+ read: new Set(event.preparation.fileOps.read),
403
+ written: new Set(event.preparation.fileOps.written),
404
+ edited: new Set(event.preparation.fileOps.edited),
405
+ };
406
+ extendFileOperations(messagesToSummarize, fileOps);
407
+ extendFileOperations(turnPrefixMessages, fileOps);
408
+
409
+ const preparation = {
410
+ ...event.preparation,
411
+ firstKeptEntryId: firstKeptEntry.id,
412
+ messagesToSummarize,
413
+ turnPrefixMessages,
414
+ isSplitTurn: cutPoint.isSplitTurn,
415
+ fileOps,
416
+ settings: {
417
+ ...event.preparation.settings,
418
+ keepRecentTokens: config.keepRecentTokens,
419
+ },
420
+ };
421
+
422
+ const auth = await context.modelRegistry.getApiKeyAndHeaders(model);
423
+ if (!auth.ok) {
424
+ debugLog(config, "could not resolve compaction authentication; using Pi's default compaction", auth.error);
425
+ return undefined;
426
+ }
427
+
428
+ const headers = auth.headers
429
+ ? Object.fromEntries(
430
+ Object.entries(auth.headers).filter(
431
+ (entry): entry is [string, string] => typeof entry[1] === "string",
432
+ ),
433
+ )
434
+ : undefined;
435
+ const result = await pi.compact(
436
+ preparation,
437
+ model,
438
+ auth.apiKey,
439
+ headers,
440
+ event.customInstructions,
441
+ event.signal,
442
+ context.thinkingLevel,
443
+ undefined,
444
+ auth.env,
445
+ );
446
+ if (!result.summary.trim() || !result.firstKeptEntryId) {
447
+ debugLog(config, "native compaction returned no usable summary; using Pi's default compaction");
448
+ return undefined;
449
+ }
450
+ return { compaction: result };
451
+ } catch (error) {
452
+ debugLog(config, "custom compaction failed; using Pi's default compaction", error);
453
+ return undefined;
454
+ }
455
+ }
456
+
457
+ export default function (pi: ExtensionAPI): void {
458
+ let config: LocalContextManagerConfig = { ...DEFAULT_CONFIG };
459
+ let pathSettings: PiPathSettings | undefined;
460
+ let telemetry = new ContextTelemetry();
461
+ let gate = new CompactionGate({
462
+ rearmTokens: getRearmTokens(config.softWarningTokens, config.compactThresholdTokens),
463
+ });
464
+ const warned = { value: false };
465
+ let turnSerial = 0;
466
+ let semanticRequested = false;
467
+ let semanticReason: string | undefined;
468
+ let checkpointResetRequested = false;
469
+ let checkpointResetReason: string | undefined;
470
+ let requestedCompaction: CompactionRequestReason | undefined;
471
+
472
+ const setSemanticRequest = (reason: string | undefined): void => {
473
+ semanticRequested = true;
474
+ semanticReason = cleanBoundaryReason(reason);
475
+ };
476
+
477
+ const setCheckpointResetRequest = (reason: string | undefined): void => {
478
+ checkpointResetRequested = true;
479
+ checkpointResetReason = cleanBoundaryReason(reason);
480
+ };
481
+
482
+ const runPiCommand = (command: string, args: string[], cwd: string) =>
483
+ pi.exec(command, args, { cwd, timeout: 3_000 });
484
+
485
+ const requestCompaction = (
486
+ context: ExtensionContext,
487
+ reason: CompactionRequestReason,
488
+ instructions?: string,
489
+ ): boolean => {
490
+ if (!config.enabled || !context.isIdle()) {
491
+ return false;
492
+ }
493
+ if (!gate.canRequest(turnSerial, reason === "semantic") || !gate.request(turnSerial)) {
494
+ return false;
495
+ }
496
+
497
+ requestedCompaction = reason;
498
+ semanticRequested = false;
499
+ semanticReason = undefined;
500
+ const options = buildCompactionOptions(
501
+ reason,
502
+ instructions,
503
+ (result) => {
504
+ // The session_compact event is the authoritative completion signal. This
505
+ // callback is only a compatibility fallback for minimal test hosts.
506
+ if (gate.isInFlight) {
507
+ gate.complete(result.estimatedTokensAfter ?? null, turnSerial);
508
+ requestedCompaction = undefined;
509
+ updateStatus(context, config, telemetry);
510
+ }
511
+ },
512
+ (error) => {
513
+ if (gate.isInFlight) {
514
+ gate.fail();
515
+ }
516
+ const failedRequest = requestedCompaction;
517
+ requestedCompaction = undefined;
518
+ debugLog(config, "compaction request failed", error);
519
+ if (failedRequest && context.hasUI) {
520
+ context.ui.notify(`Context compaction failed: ${error.message}`, "warning");
521
+ }
522
+ updateStatus(context, config, telemetry);
523
+ },
524
+ );
525
+
526
+ try {
527
+ context.compact(options);
528
+ return true;
529
+ } catch (error) {
530
+ gate.fail();
531
+ requestedCompaction = undefined;
532
+ const message = error instanceof Error ? error.message : String(error);
533
+ debugLog(config, "could not start compaction", error);
534
+ if (context.hasUI) {
535
+ context.ui.notify(`Context compaction could not start: ${message}`, "warning");
536
+ }
537
+ return false;
538
+ }
539
+ };
540
+
541
+ pi.on("session_start", async (event, context) => {
542
+ const paths = await getPiPathSettings();
543
+ pathSettings = paths;
544
+ const loaded = await loadConfig({
545
+ globalConfigPath: join(paths.agentDir, "local-context-manager.json"),
546
+ projectConfigPath: join(context.cwd, paths.configDirName, "local-context-manager.json"),
547
+ allowProjectConfig: context.isProjectTrusted(),
548
+ });
549
+ config = loaded.config;
550
+
551
+ const branch = context.sessionManager.getBranch();
552
+ const existing = countCompactions(branch);
553
+ const checkpointReset = getLatestCheckpointResetRecord(branch);
554
+ telemetry = new ContextTelemetry(
555
+ existing.count,
556
+ existing.lastAt,
557
+ checkpointReset?.count ?? 0,
558
+ checkpointReset?.createdAt ?? null,
559
+ checkpointReset?.path ?? null,
560
+ );
561
+ gate = new CompactionGate({
562
+ rearmTokens: getRearmTokens(config.softWarningTokens, config.compactThresholdTokens),
563
+ });
564
+ warned.value = false;
565
+ turnSerial = 0;
566
+ semanticRequested = false;
567
+ semanticReason = undefined;
568
+ checkpointResetRequested = false;
569
+ checkpointResetReason = undefined;
570
+ requestedCompaction = undefined;
571
+
572
+ let activeEntries: SessionEntry[] = [];
573
+ try {
574
+ activeEntries = context.sessionManager.buildContextEntries();
575
+ telemetry.setActiveToolOutputTokens(estimateActiveToolOutputTokens(activeEntries));
576
+ } catch (error) {
577
+ debugLog(config, "could not estimate active context", error);
578
+ }
579
+ observeContext(context, config, telemetry, gate);
580
+ if (branch.some((entry) => entry.type === "compaction") && activeEntries.length > 0) {
581
+ telemetry.setCompactionBaseline(estimateActiveContextTokens(activeEntries));
582
+ }
583
+
584
+ if (event.reason === "new") {
585
+ // newSession() runs its setup callback after session_start, so recover the
586
+ // reset marker once the new session's append-only state is available.
587
+ setImmediate(() => {
588
+ try {
589
+ const latestReset = getLatestCheckpointResetRecord(context.sessionManager.getBranch());
590
+ if (latestReset) {
591
+ telemetry.markCheckpointReset(latestReset.createdAt, latestReset.path, latestReset.count);
592
+ updateStatus(context, config, telemetry);
593
+ }
594
+ } catch (error) {
595
+ debugLog(config, "could not restore checkpoint reset telemetry", error);
596
+ }
597
+ });
598
+ }
599
+
600
+ if (loaded.errors.length > 0) {
601
+ const message = loaded.errors.join("; ");
602
+ debugLog(config, message);
603
+ if (context.hasUI) {
604
+ context.ui.notify(`local-context-manager configuration warning: ${message}`, "warning");
605
+ }
606
+ }
607
+ });
608
+
609
+ pi.on("session_shutdown", (_event, context) => {
610
+ if (context.hasUI) {
611
+ context.ui.setStatus(EXTENSION_STATUS_KEY, undefined);
612
+ }
613
+ });
614
+
615
+ pi.on("turn_start", (_event, context) => {
616
+ turnSerial += 1;
617
+ telemetry.markTurn(turnSerial);
618
+ const tokens = observeContext(context, config, telemetry, gate);
619
+ notifySoftWarning(context, config, telemetry, warned, tokens);
620
+ });
621
+
622
+ pi.on("turn_end", (_event, context) => {
623
+ const tokens = observeContext(context, config, telemetry, gate);
624
+ notifySoftWarning(context, config, telemetry, warned, tokens);
625
+
626
+ // turn_end is the first boundary after all tool results have landed. Only use
627
+ // it when the host reports idle; a continuing tool loop is handled at
628
+ // agent_settled instead so native compact() cannot abort active work.
629
+ if (
630
+ config.enabled &&
631
+ !semanticRequested &&
632
+ context.isIdle() &&
633
+ shouldTriggerThresholdCompaction(tokens, config.compactThresholdTokens)
634
+ ) {
635
+ requestCompaction(context, "threshold");
636
+ }
637
+ });
638
+
639
+ pi.on("agent_settled", (_event, context) => {
640
+ const tokens = observeContext(context, config, telemetry, gate);
641
+ notifySoftWarning(context, config, telemetry, warned, tokens);
642
+
643
+ if (checkpointResetRequested) {
644
+ const reason = checkpointResetReason;
645
+ checkpointResetRequested = false;
646
+ checkpointResetReason = undefined;
647
+ if (context.hasUI) {
648
+ context.ui.notify(
649
+ `Checkpoint reset recommended${reason ? ` (${reason})` : ""}. No session change was made; review it with /checkpoint-reset${reason ? ` ${reason}` : ""}.`,
650
+ "info",
651
+ );
652
+ }
653
+ }
654
+
655
+ if (semanticRequested && config.enabled && config.semanticCompaction) {
656
+ requestCompaction(
657
+ context,
658
+ "semantic",
659
+ semanticReason
660
+ ? `${SEMANTIC_COMPACTION_INSTRUCTIONS} Completed phase: ${semanticReason}`
661
+ : SEMANTIC_COMPACTION_INSTRUCTIONS,
662
+ );
663
+ return;
664
+ }
665
+ if (config.enabled && shouldTriggerThresholdCompaction(tokens, config.compactThresholdTokens)) {
666
+ requestCompaction(context, "threshold");
667
+ }
668
+ });
669
+
670
+ pi.on("session_compact", (event, context) => {
671
+ const usage = context.getContextUsage();
672
+ let activeEntries: SessionEntry[] = [];
673
+ let activeToolOutputTokens = 0;
674
+ try {
675
+ activeEntries = context.sessionManager.buildContextEntries();
676
+ activeToolOutputTokens = estimateActiveToolOutputTokens(activeEntries);
677
+ } catch (error) {
678
+ debugLog(config, "could not estimate post-compaction context", error);
679
+ }
680
+ const postTokens =
681
+ usage?.tokens ?? (activeEntries.length > 0 ? estimateActiveContextTokens(activeEntries) : null);
682
+
683
+ telemetry.markCompaction(
684
+ parseTimestamp(event.compactionEntry.timestamp) ?? Date.now(),
685
+ turnSerial,
686
+ postTokens,
687
+ activeToolOutputTokens,
688
+ );
689
+ gate.complete(postTokens, turnSerial);
690
+ requestedCompaction = undefined;
691
+ semanticRequested = false;
692
+ semanticReason = undefined;
693
+ warned.value = false;
694
+ updateStatus(context, config, telemetry);
695
+ debugLog(config, `compaction completed (${event.reason})`);
696
+ });
697
+
698
+ pi.on("session_compact_failed", (event, context) => {
699
+ const failedRequest = requestedCompaction;
700
+ if (gate.isInFlight) {
701
+ gate.fail();
702
+ }
703
+ requestedCompaction = undefined;
704
+ if (failedRequest && context.hasUI) {
705
+ context.ui.notify(
706
+ `local-context-manager compaction did not complete: ${event.errorMessage ?? "cancelled"}`,
707
+ "warning",
708
+ );
709
+ }
710
+ debugLog(config, `compaction failed (${event.reason})`, event.errorMessage);
711
+ updateStatus(context, config, telemetry);
712
+ });
713
+
714
+ pi.on("tool_result", async (event, context) => {
715
+ if (!config.enabled) {
716
+ return;
717
+ }
718
+
719
+ if (!config.toolOutputReduction) {
720
+ telemetry.recordToolOutput(estimateToolContentTokens(event.content));
721
+ updateStatus(context, config, telemetry);
722
+ return;
723
+ }
724
+
725
+ const reduction = reduceToolOutput({
726
+ toolName: event.toolName,
727
+ input: event.input,
728
+ content: event.content,
729
+ details: event.details,
730
+ isError: event.isError,
731
+ });
732
+ if (!reduction.changed) {
733
+ telemetry.recordToolOutput(estimateToolContentTokens(event.content));
734
+ updateStatus(context, config, telemetry);
735
+ return;
736
+ }
737
+
738
+ let content = reduction.content;
739
+ let fullOutputPath = extractFullOutputPath(event.details, reduction.originalText);
740
+ if (!fullOutputPath) {
741
+ fullOutputPath = await saveRecoveryCopy(reduction.originalText);
742
+ }
743
+ if (!fullOutputPath) {
744
+ // Do not discard recoverability when the host did not provide a full-output
745
+ // path and the fallback copy could not be written.
746
+ telemetry.recordToolOutput(reduction.originalTokens);
747
+ updateStatus(context, config, telemetry);
748
+ debugLog(config, "could not save full tool output; preserving the original result");
749
+ return;
750
+ }
751
+ if (
752
+ !content.some(
753
+ (block) => block.type === "text" && block.text.toLowerCase().includes("full output") && block.text.includes(fullOutputPath),
754
+ )
755
+ ) {
756
+ content = appendFullOutputNotice(content, fullOutputPath);
757
+ }
758
+
759
+ telemetry.recordToolReduction(reduction.originalTokens, reduction.retainedTokens);
760
+ updateStatus(context, config, telemetry);
761
+ debugLog(
762
+ config,
763
+ `reduced ${event.toolName} ${reduction.originalTokens} -> ${reduction.retainedTokens} tokens (${reduction.category})`,
764
+ );
765
+ return { content };
766
+ });
767
+
768
+ pi.on("session_before_compact", async (event, context) => {
769
+ return buildCustomCompaction(event, context, config);
770
+ });
771
+
772
+ pi.registerTool({
773
+ name: "request_context_compaction",
774
+ label: "Request context compaction",
775
+ description:
776
+ "Request context compaction after a meaningful task phase is complete. Use sparingly, not for routine turns.",
777
+ promptSnippet: "Queue compaction after a meaningful completed phase",
778
+ promptGuidelines: ["Use request_context_compaction only at meaningful phase boundaries, never on routine turns."],
779
+ parameters: SEMANTIC_PARAMETERS,
780
+ async execute(_toolCallId, params) {
781
+ if (!config.enabled || !config.semanticCompaction) {
782
+ return {
783
+ content: [{ type: "text", text: "Semantic compaction is disabled; continue normally." }],
784
+ details: { queued: false },
785
+ };
786
+ }
787
+ setSemanticRequest(params.reason);
788
+ return {
789
+ content: [
790
+ {
791
+ type: "text",
792
+ text: "Compaction request recorded for the end of this agent run. It may be skipped if the context is not idle, a compaction is already running, or cooldown is active; continue only with the next phase or final status.",
793
+ },
794
+ ],
795
+ details: { queued: true },
796
+ };
797
+ },
798
+ });
799
+
800
+ pi.registerTool({
801
+ name: "request_context_reset",
802
+ label: "Request checkpoint reset",
803
+ description:
804
+ "Request a user-reviewed checkpoint reset after a completed semantic episode. This queues a recommendation only; it never writes a checkpoint or switches sessions.",
805
+ promptSnippet: "Recommend a reviewed checkpoint reset after a completed semantic episode",
806
+ promptGuidelines: [
807
+ "Use request_context_reset only after a major semantic unit is complete and detailed context is unlikely to be needed immediately, such as a merged PR, resolved issue, completed release, deployment, investigation, experiment, or accepted independent milestone.",
808
+ "Do not use request_context_reset during routine coding, active debugging, review, or closely related follow-up work.",
809
+ "request_context_reset only recommends /checkpoint-reset; it never resets the session without explicit user approval.",
810
+ ],
811
+ parameters: Type.Object({
812
+ reason: Type.Optional(Type.String({ description: "Short description of the completed episode" })),
813
+ }),
814
+ async execute(_toolCallId, params) {
815
+ if (!config.enabled || !config.checkpointReset) {
816
+ return {
817
+ content: [{ type: "text", text: "Checkpoint reset is disabled; continue normally." }],
818
+ details: { queued: false },
819
+ };
820
+ }
821
+ setCheckpointResetRequest(params.reason);
822
+ return {
823
+ content: [
824
+ {
825
+ type: "text",
826
+ text: "Checkpoint reset recommendation recorded. No checkpoint was written and no session was changed. After this agent run settles, ask the user to review and invoke /checkpoint-reset if the boundary is still appropriate.",
827
+ },
828
+ ],
829
+ details: {
830
+ queued: true,
831
+ ...(checkpointResetReason ? { reason: checkpointResetReason } : {}),
832
+ },
833
+ };
834
+ },
835
+ });
836
+
837
+ pi.registerCommand("context-stats", {
838
+ description: "Show local context telemetry",
839
+ handler: async (_args, context) => {
840
+ const tokens = observeContext(context, config, telemetry, gate);
841
+ const snapshot = telemetry.snapshot(config.compactThresholdTokens);
842
+ const details = [
843
+ formatTelemetryDetails(snapshot),
844
+ `Soft warning: ${config.softWarningTokens.toLocaleString()} tokens`,
845
+ `Hard ceiling: ${config.hardCeilingTokens.toLocaleString()} tokens`,
846
+ `Enabled: ${config.enabled ? "yes" : "no"}`,
847
+ `Current reading: ${tokens === null ? "unknown" : `${Math.round(tokens).toLocaleString()} tokens`}`,
848
+ ].join("\n");
849
+ if (context.hasUI) {
850
+ context.ui.notify(details, "info");
851
+ } else if (config.debug) {
852
+ console.error(details);
853
+ }
854
+ },
855
+ });
856
+
857
+ pi.registerCommand("compact-phase", {
858
+ description: "Compact context at an intentional task-phase boundary",
859
+ handler: async (args, context) => {
860
+ if (!config.enabled || !config.semanticCompaction) {
861
+ context.ui.notify("Semantic compaction is disabled", "warning");
862
+ return;
863
+ }
864
+ await context.waitForIdle();
865
+ const reason = cleanBoundaryReason(args);
866
+ const instructions = reason
867
+ ? `${SEMANTIC_COMPACTION_INSTRUCTIONS} Completed phase: ${reason}`
868
+ : SEMANTIC_COMPACTION_INSTRUCTIONS;
869
+ if (!requestCompaction(context, "semantic", instructions)) {
870
+ context.ui.notify("No compaction was started (cooldown, already running, or insufficient history)", "info");
871
+ }
872
+ },
873
+ });
874
+
875
+ pi.registerCommand("checkpoint-reset", {
876
+ description: "Archive a completed episode and start a reviewed fresh session",
877
+ handler: async (args, context) => {
878
+ if (!config.enabled || !config.checkpointReset) {
879
+ context.ui.notify("Checkpoint reset is disabled", "warning");
880
+ return;
881
+ }
882
+ const paths = pathSettings ?? (await getPiPathSettings());
883
+ await runCheckpointReset(args, context, {
884
+ config,
885
+ agentDir: paths.agentDir,
886
+ runCommand: runPiCommand,
887
+ previousResetCount: telemetry.snapshot(config.compactThresholdTokens).checkpointResets,
888
+ });
889
+ },
890
+ });
891
+
892
+ pi.registerCommand("context-checkpoints", {
893
+ description: "List recent local context checkpoints for this repository",
894
+ handler: async (_args, context) => {
895
+ if (!config.enabled || !config.checkpointReset) {
896
+ context.ui.notify("Checkpoint reset is disabled", "warning");
897
+ return;
898
+ }
899
+
900
+ const paths = pathSettings ?? (await getPiPathSettings());
901
+ const state = await getRepositoryState(context.cwd, runPiCommand);
902
+ try {
903
+ const directory = getCheckpointStorageDirectory(config, paths.agentDir, context.cwd, state);
904
+ const checkpoints = await listCheckpointFiles(directory);
905
+ const details = checkpoints.length === 0
906
+ ? `No checkpoints found for this repository.\nDirectory: ${directory}`
907
+ : [
908
+ `Recent checkpoints (${checkpoints.length}):`,
909
+ ...checkpoints.map(
910
+ (checkpoint) => `${checkpoint.createdAt} · ${checkpoint.reason} · ${checkpoint.path}`,
911
+ ),
912
+ ].join("\n");
913
+ if (context.hasUI) {
914
+ context.ui.notify(details, "info");
915
+ } else if (config.debug) {
916
+ console.error(details);
917
+ }
918
+ } catch (error) {
919
+ const message = error instanceof Error ? error.message : String(error);
920
+ context.ui.notify(`Could not list checkpoints: ${message}`, "warning");
921
+ }
922
+ },
923
+ });
924
+
925
+ pi.registerCommand("handoff", {
926
+ description: "Draft a reviewed continuation prompt in a new session",
927
+ handler: async (args, context: ExtensionCommandContext) => {
928
+ if (!config.enabled || !config.handoff) {
929
+ context.ui.notify("Session handoff is disabled", "warning");
930
+ return;
931
+ }
932
+ const goal = args.trim();
933
+ if (!goal) {
934
+ context.ui.notify("Usage: /handoff <objective for the new session>", "error");
935
+ return;
936
+ }
937
+ await context.waitForIdle();
938
+ await runHandoff(goal, context);
939
+ },
940
+ });
941
+ }