pi-context 1.1.4 → 2.0.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 CHANGED
@@ -2,14 +2,16 @@ import {
2
2
  type ExtensionAPI,
3
3
  type SessionManager,
4
4
  type SessionEntry,
5
- } from "@mariozechner/pi-coding-agent";
6
- import type {
7
- TextContent,
8
- ImageContent,
9
- ToolCall,
10
- } from "@mariozechner/pi-ai";
11
- import { Type, type Static } from "@sinclair/typebox";
12
- import { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
5
+ type ExtensionCommandContext,
6
+ type ContextUsage,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ Type,
10
+ type Static,
11
+ type TextContent,
12
+ type ImageContent,
13
+ type ToolCall,
14
+ } from "@earendil-works/pi-ai";
13
15
  import { formatTokens } from "./utils.js";
14
16
 
15
17
  // Define missing types locally as they are not exported from the main entry point
@@ -19,12 +21,21 @@ interface SessionTreeNode {
19
21
  label?: string;
20
22
  }
21
23
 
22
- const InternalTools = ["context_tag", "context_log", "context_checkout"];
24
+ const InternalTools = ["context_checkpoint", "context_timeline", "context_compact"];
23
25
  let CommandCtx: ExtensionCommandContext | null = null;
24
- let CheckoutParams: any = null;
26
+ let CompactParams: any = null;
25
27
 
26
28
  const isInternal = (name: string) => InternalTools.includes(name);
27
29
 
30
+ const formatContextUsage = (usage: ContextUsage | undefined, includeTokens = false): string => {
31
+ if (usage?.percent == null) return "Unknown";
32
+
33
+ const percent = `${usage.percent.toFixed(1)}%`;
34
+ if (!includeTokens || usage.tokens == null) return percent;
35
+
36
+ return `${percent} (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
37
+ };
38
+
28
39
  const resolveTargetId = (sm: SessionManager, target: string): string => {
29
40
  if (target.toLowerCase() === "root") {
30
41
  const tree = sm.getTree();
@@ -46,20 +57,23 @@ const resolveTargetId = (sm: SessionManager, target: string): string => {
46
57
  return target;
47
58
  };
48
59
 
49
- const ContextLogParams = Type.Object({
50
- limit: Type.Optional(Type.Number({ description: "History limit for visible entries (default: 50)." })),
51
- verbose: Type.Optional(Type.Boolean({ description: "If true, show ALL messages. If false (default), collapses intermediate AI steps and only shows 'milestones': User messages, Tags, Branch Points, and Summaries." })),
60
+ const ContextTimelineDescription = "Inspect the active conversation path as a structural map: checkpoints, summaries/compactions, branch points, user turns, and current position. Use when orientation or compact target selection depends on the shape of history.";
61
+ const ContextTimelineParams = Type.Object({
62
+ limit: Type.Optional(Type.Number({ description: "Maximum visible timeline entries (default: 50)." })),
63
+ verbose: Type.Optional(Type.Boolean({ description: "If true, show all messages including internal context-tool traffic. If false (default), collapse to structural milestones." })),
52
64
  });
53
65
 
54
- const ContextCheckoutParams = Type.Object({
55
- target: Type.String({ description: "Where to jump/squash to. Can be a tag name (e.g., 'task-start'), a commit ID, or 'root'. This is the base for your new branch." }),
56
- message: Type.String({ description: "The 'Carryover Message' for the new branch. A summary of your *current* progress/lessons that you want to bring with you to the new state. This ensures you don't lose key information when switching contexts. Good summary message: '[Status] + [Reason] + [Important Changes] + [Carryover Data]'" }),
57
- backupTag: Type.Optional(Type.String({ description: "Optional tag name to apply to the CURRENT state before checking out. Use this to create an automatic backup of the history you are about to leave/squash." })),
66
+ const ContextCompactDescription = "Create a summarized continuation branch from an earlier checkpoint or history node. The selected target is the branch point; the summary must restore the useful state from the compacted path after that target. This changes conversation history only; it does not modify or roll back disk files or external systems.";
67
+ const ContextCompactParams = Type.Object({
68
+ target: Type.String({ description: "Checkpoint name, history node ID, or root to use as the branch point for the summarized continuation." }),
69
+ summary: Type.String({ description: "Handoff summary injected into the new continuation branch. Restore current task/state, decisions/constraints, important external side effects (changed files, processes, browser/tickets/remote state), validation status, source anchors/evidence/open questions likely needed soon, and explicit next step. Do not rely on backupCheckpoint for details needed in the next phase." }),
70
+ backupCheckpoint: Type.Optional(Type.String({ description: "Optional checkpoint name to label the current conversation state before branching. This is only a recovery pointer; the summary must still contain the state needed to continue." })),
58
71
  });
59
72
 
60
- const ContextTagParams = Type.Object({
61
- name: Type.String({ description: "The tag/milestone name. Use meaningful names." }),
62
- target: Type.Optional(Type.String({ description: "The commit ID to tag. Defaults to HEAD (current state)." })),
73
+ const ContextCheckpointDescription = "Create a named anchor by labeling a conversation history node. This does not branch, summarize, or affect external state; it only makes the point easy to find later in timeline or compact target selection.";
74
+ const ContextCheckpointParams = Type.Object({
75
+ name: Type.String({ description: "Unique semantic anchor name that encodes the task and phase/purpose, e.g. parser-fix-start or timeout-investigation-search. Avoid generic names like start, checkpoint-1, or retry." }),
76
+ target: Type.Optional(Type.String({ description: "Optional history node ID or checkpoint name to label. Defaults to the current meaningful position near the conversation head." })),
63
77
  });
64
78
 
65
79
  export default function (pi: ExtensionAPI) {
@@ -68,27 +82,20 @@ export default function (pi: ExtensionAPI) {
68
82
  handler: async (args, ctx) => {
69
83
  CommandCtx = ctx;
70
84
  ctx.ui.notify("Agentic Context Management enabled.", "info");
71
- pi.sendMessage({
72
- customType: "pi-context",
73
- content: "use context-management skill",
74
- display: false,
75
- }, {
76
- deliverAs: "followUp"
77
- });
78
85
  if (args) {
79
- pi.sendUserMessage(args)
86
+ pi.sendUserMessage(args, { deliverAs: "followUp" });
80
87
  }
81
88
  }
82
89
  });
83
90
 
84
- // Helper: Check if a tag name already exists in the tree
91
+ // Helper: Check if a checkpoint name already exists in the tree
85
92
  // Iterative DFS to avoid call stack overflows on deep histories.
86
93
  // Push children in reverse order to preserve left-to-right pre-order semantics.
87
- const findTagInTree = (sm: SessionManager, nodes: SessionTreeNode[], tagName: string): string | null => {
94
+ const findCheckpointInTree = (sm: SessionManager, nodes: SessionTreeNode[], checkpointName: string): string | null => {
88
95
  const stack: SessionTreeNode[] = [...nodes].reverse();
89
96
  while (stack.length > 0) {
90
97
  const n = stack.pop()!;
91
- if (sm.getLabel(n.entry.id) === tagName) return n.entry.id;
98
+ if (sm.getLabel(n.entry.id) === checkpointName) return n.entry.id;
92
99
  if (n.children?.length) {
93
100
  for (let i = n.children.length - 1; i >= 0; i--) {
94
101
  stack.push(n.children[i]);
@@ -99,20 +106,20 @@ export default function (pi: ExtensionAPI) {
99
106
  };
100
107
 
101
108
  pi.registerTool({
102
- name: "context_tag",
103
- label: "Context Tag",
104
- description: "Creates a 'Save Point' (Bookmark) in the history. Use this before trying risky changes or when a feature is stable. 'Untagged progress is risky'.",
105
- parameters: ContextTagParams,
106
- async execute(_id, params: Static<typeof ContextTagParams>, _signal, _onUpdate, ctx) {
109
+ name: "context_checkpoint",
110
+ label: "Context Checkpoint",
111
+ description: ContextCheckpointDescription,
112
+ parameters: ContextCheckpointParams,
113
+ async execute(_id, params: Static<typeof ContextCheckpointParams>, _signal, _onUpdate, ctx) {
107
114
  const sm = ctx.sessionManager as SessionManager;
108
115
 
109
- // Deduplication check: ensure tag name is unique
110
- const existingTagId = findTagInTree(sm, sm.getTree(), params.name);
111
- if (existingTagId) {
116
+ // Deduplication check: ensure checkpoint name is unique
117
+ const existingCheckpointId = findCheckpointInTree(sm, sm.getTree(), params.name);
118
+ if (existingCheckpointId) {
112
119
  return {
113
120
  content: [{
114
121
  type: "text",
115
- text: `Error: Tag '${params.name}' already exists at ${existingTagId}. Tag names must be unique. Use a different name or delete the existing tag first.`
122
+ text: `Error: Checkpoint '${params.name}' already exists at ${existingCheckpointId}. Checkpoint names must be unique. Use a different name or remove the existing one first.`
116
123
  }],
117
124
  details: {}
118
125
  };
@@ -121,8 +128,8 @@ export default function (pi: ExtensionAPI) {
121
128
  let id = params.target ? resolveTargetId(sm, params.target) : undefined;
122
129
 
123
130
  if (!id) {
124
- // Auto-resolve: Find the last "interesting" node to tag.
125
- // We skip ToolResults (which look ugly tagged) and internal-only Assistant messages (which look empty).
131
+ // Auto-resolve: Find the last interesting node to checkpoint.
132
+ // We skip ToolResults that look awkward when checkpointed and internal-only assistant messages that look empty.
126
133
  const branch = sm.getBranch();
127
134
  for (let i = branch.length - 1; i >= 0; i--) {
128
135
  const entry = branch[i];
@@ -156,16 +163,22 @@ export default function (pi: ExtensionAPI) {
156
163
  }
157
164
 
158
165
  pi.setLabel(id, params.name);
159
- return { content: [{ type: "text", text: `Created tag '${params.name}' at ${id}` }], details: {} };
166
+ return {
167
+ content: [{
168
+ type: "text",
169
+ text: `Created checkpoint '${params.name}' at ${id}.`
170
+ }],
171
+ details: {}
172
+ };
160
173
  },
161
174
  });
162
175
 
163
176
  pi.registerTool({
164
- name: "context_log",
165
- label: "Context Log",
166
- description: "Show the entire history structure (status, message, tags, milestones). Analogous to 'git log --graph --oneline --decorate'",
167
- parameters: ContextLogParams,
168
- async execute(_id, params: Static<typeof ContextLogParams>, _signal, _onUpdate, ctx) {
177
+ name: "context_timeline",
178
+ label: "Context Timeline",
179
+ description: ContextTimelineDescription,
180
+ parameters: ContextTimelineParams,
181
+ async execute(_id, params: Static<typeof ContextTimelineParams>, _signal, _onUpdate, ctx) {
169
182
  const sm = ctx.sessionManager as SessionManager;
170
183
  const branch = sm.getBranch();
171
184
  const currentLeafId = sm.getLeafId();
@@ -193,7 +206,7 @@ export default function (pi: ExtensionAPI) {
193
206
  return e.summary || "[No summary provided]";
194
207
  }
195
208
  if (entry.type === "label") {
196
- return `tag: ${entry.label}`;
209
+ return `checkpoint: ${entry.label}`;
197
210
  }
198
211
 
199
212
  if (entry.type === "message") {
@@ -257,7 +270,7 @@ export default function (pi: ExtensionAPI) {
257
270
  if (entry.id === currentLeafId) return true;
258
271
  if (branch.length > 0 && entry.id === branch[0].id) return true;
259
272
 
260
- // 2. Explicit Tags (Labels) - Only show the TAGGED node, not the label node itself
273
+ // 2. Explicit checkpoints (labels) - only show the checkpointed node, not the label node itself
261
274
  if (sm.getLabel(entry.id)) return true;
262
275
  if (entry.type === 'label') return false; // Hide label nodes, they are redundant
263
276
 
@@ -327,7 +340,7 @@ export default function (pi: ExtensionAPI) {
327
340
 
328
341
  const id = entry.id;
329
342
  const isRoot = branch.length > 0 && entry.id === branch[0].id;
330
- const meta = [isRoot ? "ROOT" : null, isHead ? "HEAD" : null, label ? `tag: ${label}` : null].filter(Boolean).join(", ");
343
+ const meta = [isRoot ? "ROOT" : null, isHead ? "HEAD" : null, label ? `checkpoint: ${label}` : null].filter(Boolean).join(", ");
331
344
 
332
345
  const body = content.length > 100 ? content.slice(0, 100) + "..." : content;
333
346
 
@@ -341,29 +354,30 @@ export default function (pi: ExtensionAPI) {
341
354
  }
342
355
 
343
356
  // --- Context Dashboard (HUD) ---
344
- const usage = await ctx.getContextUsage();
345
- let usageStr = "Unknown";
346
- if (usage) {
347
- usageStr = `${usage.percent.toFixed(1)}% (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
348
- }
357
+ const usageStr = formatContextUsage(ctx.getContextUsage(), true);
349
358
 
350
- // Find the distance to the nearest tag
351
- let stepsSinceTag = 0;
352
- let nearestTagName = "None";
359
+ // Find the distance to the nearest checkpoint
360
+ let stepsSinceCheckpoint = 0;
361
+ let nearestCheckpointName = "None";
353
362
  for (let i = branch.length - 1; i >= 0; i--) {
354
363
  const id = branch[i].id;
355
364
  const label = sm.getLabel(id);
356
365
  if (label) {
357
- nearestTagName = label;
366
+ nearestCheckpointName = label;
358
367
  break;
359
368
  }
360
- stepsSinceTag++;
369
+ stepsSinceCheckpoint++;
361
370
  }
362
371
 
372
+ const compactCue = nearestCheckpointName === "None"
373
+ ? "create a checkpoint before the next noisy phase"
374
+ : `if this segment has produced a stable result and another phase remains, compact to '${nearestCheckpointName}' with a handoff summary before continuing`;
375
+
363
376
  const hud = [
364
377
  `[Context Dashboard]`,
365
378
  `• Context Usage: ${usageStr}`,
366
- `• Segment Size: ${stepsSinceTag} steps since last tag '${nearestTagName}'`,
379
+ `• Segment Size: ${stepsSinceCheckpoint} steps since last checkpoint '${nearestCheckpointName}'`,
380
+ `• Compact Cue: ${compactCue}`,
367
381
  `---------------------------------------------------`
368
382
  ].join("\n");
369
383
 
@@ -372,11 +386,11 @@ export default function (pi: ExtensionAPI) {
372
386
  });
373
387
 
374
388
  pi.registerTool({
375
- name: "context_checkout",
376
- label: "Context Checkout",
377
- description: "Navigate to ANY point in the conversation history. This checkout only resets *conversation history*, NOT disk files. ALWAYS provide a detailed 'message' to bridge context.",
378
- parameters: ContextCheckoutParams,
379
- async execute(_id, params: Static<typeof ContextCheckoutParams>, _signal, _onUpdate, ctx) {
389
+ name: "context_compact",
390
+ label: "Context Compact",
391
+ description: ContextCompactDescription,
392
+ parameters: ContextCompactParams,
393
+ async execute(_id, params: Static<typeof ContextCompactParams>, _signal, _onUpdate, ctx) {
380
394
  if (!CommandCtx) {
381
395
  ctx.ui.setEditorText(`/acm ${ctx.ui.getEditorText() || "continue"}`)
382
396
  return {
@@ -388,6 +402,7 @@ export default function (pi: ExtensionAPI) {
388
402
  };
389
403
  }
390
404
  const sm = ctx.sessionManager as SessionManager;
405
+ const usageBeforeText = formatContextUsage(ctx.getContextUsage());
391
406
 
392
407
  const tid = resolveTargetId(sm, params.target);
393
408
 
@@ -395,52 +410,75 @@ export default function (pi: ExtensionAPI) {
395
410
  if (currentLeaf === tid) {
396
411
  return { content: [{ type: "text", text: `Already at target ${tid}` }], details: {} };
397
412
  }
398
- if (params.backupTag && currentLeaf) {
399
- pi.setLabel(currentLeaf, params.backupTag);
413
+ if (params.backupCheckpoint && currentLeaf) {
414
+ pi.setLabel(currentLeaf, params.backupCheckpoint);
400
415
  }
401
416
  const currentLabel = currentLeaf ? sm.getLabel(currentLeaf) : undefined;
402
- const origin = currentLabel ? `tag: ${currentLabel}` : (currentLeaf || "unknown");
417
+ const origin = currentLabel ? `checkpoint: ${currentLabel}` : (currentLeaf || "unknown");
403
418
 
404
- const enrichedMessage = `(summary from ${origin})\n${params.message}`;
419
+ const enrichedMessage = `(handoff summary from ${origin})\n${params.summary}`;
405
420
 
406
421
  const nid = await sm.branchWithSummary(tid, enrichedMessage);
407
- CheckoutParams = params;
408
- CheckoutParams.nid = nid;
409
- CheckoutParams.tid = tid;
410
- CheckoutParams.enrichedMessage = enrichedMessage;
422
+ CompactParams = params;
423
+ CompactParams.nid = nid;
424
+ CompactParams.tid = tid;
425
+ CompactParams.enrichedMessage = enrichedMessage;
426
+ CompactParams.usageBeforeText = usageBeforeText;
411
427
 
412
- return { content: [{ type: "text", text: "checkout start" }], details: {} };
428
+ return { content: [{ type: "text", text: "compact start" }], details: {} };
413
429
  },
414
430
  });
415
431
 
416
- pi.on("turn_end", async (event, ctx) => {
417
- if (!CheckoutParams) {
432
+ pi.on("turn_end", async (_event, ctx) => {
433
+ if (!CompactParams) {
418
434
  return
419
435
  }
420
436
  ctx.abort()
421
437
  });
422
438
 
423
- pi.on("agent_end", async (_event, ctx) => {
424
- if (!CheckoutParams) {
439
+ pi.on("agent_end", async () => {
440
+ if (!CompactParams) {
425
441
  return
426
442
  }
427
443
  if (!CommandCtx) {
428
444
  return
429
445
  }
430
446
 
431
- await CommandCtx.navigateTree(CheckoutParams.nid, {
432
- summarize: false,
433
- });
434
-
435
- ctx.ui.notify(`Checked out ${CheckoutParams.target}${CheckoutParams.target === CheckoutParams.tid ? "" : `(${CheckoutParams.tid})`}\nBackup tag created: ${CheckoutParams.backupTag || "none"}\nmessage: ${CheckoutParams.enrichedMessage}`, "info");
436
- CheckoutParams = null;
447
+ const compactParams = CompactParams;
448
+ const commandCtx = CommandCtx;
449
+ CompactParams = null;
450
+
451
+ // `agent_end` is emitted before the core Agent is actually idle. If we
452
+ // call pi.sendMessage({ triggerTurn: true }) inside this handler, pi still
453
+ // sees an active stream and queues the message as steering; after
454
+ // `agent_end` the loop has already stopped, so that queued message is not
455
+ // drained. Defer navigation + continuation until the current run settles.
456
+ setTimeout(async () => {
457
+ try {
458
+ await commandCtx.waitForIdle();
459
+ await commandCtx.navigateTree(compactParams.nid, {
460
+ summarize: false,
461
+ });
437
462
 
438
- pi.sendMessage({
439
- customType: "pi-context",
440
- content: "context_checkout complete. A summary of your previous branch was injected above. Read it to understand your new state. Execute the 'Next Step' from the summary",
441
- display: false,
442
- }, {
443
- triggerTurn: true,
444
- });
463
+ const usageAfter = commandCtx.getContextUsage();
464
+ commandCtx.ui.notify([
465
+ `Compacted to ${compactParams.target}${compactParams.target === compactParams.tid ? "" : `(${compactParams.tid})`}`,
466
+ `Context Usage: ${compactParams.usageBeforeText} -> ${formatContextUsage(usageAfter)}`,
467
+ `Backup checkpoint created: ${compactParams.backupCheckpoint || "none"}`,
468
+ `Summary: ${compactParams.enrichedMessage}`,
469
+ ].join("\n"), "info");
470
+
471
+ pi.sendMessage({
472
+ customType: "pi-context",
473
+ content: "context_compact complete. A handoff summary of your previous conversation path was injected above. Read it to understand your new state. Execute the Next Step from the summary",
474
+ display: false,
475
+ }, {
476
+ triggerTurn: true,
477
+ deliverAs: "followUp",
478
+ });
479
+ } catch (err) {
480
+ commandCtx.ui.notify(`context_compact failed to continue: ${err instanceof Error ? err.message : String(err)}`, "error");
481
+ }
482
+ }, 0);
445
483
  });
446
484
  }