pi-context 1.1.4 → 2.0.0-beta.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,15 @@ 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
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ Type,
9
+ type Static,
10
+ type TextContent,
11
+ type ImageContent,
12
+ type ToolCall,
13
+ } from "@earendil-works/pi-ai";
13
14
  import { formatTokens } from "./utils.js";
14
15
 
15
16
  // Define missing types locally as they are not exported from the main entry point
@@ -19,9 +20,9 @@ interface SessionTreeNode {
19
20
  label?: string;
20
21
  }
21
22
 
22
- const InternalTools = ["context_tag", "context_log", "context_checkout"];
23
+ const InternalTools = ["context_checkpoint", "context_timeline", "context_rewind"];
23
24
  let CommandCtx: ExtensionCommandContext | null = null;
24
- let CheckoutParams: any = null;
25
+ let RewindParams: any = null;
25
26
 
26
27
  const isInternal = (name: string) => InternalTools.includes(name);
27
28
 
@@ -46,20 +47,20 @@ const resolveTargetId = (sm: SessionManager, target: string): string => {
46
47
  return target;
47
48
  };
48
49
 
49
- const ContextLogParams = Type.Object({
50
+ const ContextTimelineParams = Type.Object({
50
51
  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." })),
52
+ verbose: Type.Optional(Type.Boolean({ description: "If true, show all messages. If false (default), collapse intermediate AI steps and only show milestones, user messages, checkpoints, branch points, and summaries." })),
52
53
  });
53
54
 
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." })),
55
+ const ContextRewindParams = Type.Object({
56
+ target: Type.String({ description: "Where to rewind or compact to. Can be a checkpoint name, a history node ID, or root. This becomes the starting point for a fresh continuation of the conversation." }),
57
+ message: Type.String({ description: "The carryover summary for the fresh continuation. Summarize current progress, lessons, important file changes, and the next step so the agent can continue from a clean context. A good summary message includes status, reason, important changes, and next step." }),
58
+ backupCheckpoint: Type.Optional(Type.String({ description: "Optional checkpoint name to apply to the current conversation state before rewinding. Use this to preserve a named backup of the noisy path you are about to compact away." })),
58
59
  });
59
60
 
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)." })),
61
+ const ContextCheckpointParams = Type.Object({
62
+ name: Type.String({ description: "The checkpoint or milestone name. Use meaningful names." }),
63
+ target: Type.Optional(Type.String({ description: "Optional history node ID or checkpoint name to label. Defaults to the current conversation position." })),
63
64
  });
64
65
 
65
66
  export default function (pi: ExtensionAPI) {
@@ -81,14 +82,14 @@ export default function (pi: ExtensionAPI) {
81
82
  }
82
83
  });
83
84
 
84
- // Helper: Check if a tag name already exists in the tree
85
+ // Helper: Check if a checkpoint name already exists in the tree
85
86
  // Iterative DFS to avoid call stack overflows on deep histories.
86
87
  // Push children in reverse order to preserve left-to-right pre-order semantics.
87
- const findTagInTree = (sm: SessionManager, nodes: SessionTreeNode[], tagName: string): string | null => {
88
+ const findCheckpointInTree = (sm: SessionManager, nodes: SessionTreeNode[], checkpointName: string): string | null => {
88
89
  const stack: SessionTreeNode[] = [...nodes].reverse();
89
90
  while (stack.length > 0) {
90
91
  const n = stack.pop()!;
91
- if (sm.getLabel(n.entry.id) === tagName) return n.entry.id;
92
+ if (sm.getLabel(n.entry.id) === checkpointName) return n.entry.id;
92
93
  if (n.children?.length) {
93
94
  for (let i = n.children.length - 1; i >= 0; i--) {
94
95
  stack.push(n.children[i]);
@@ -99,20 +100,20 @@ export default function (pi: ExtensionAPI) {
99
100
  };
100
101
 
101
102
  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) {
103
+ name: "context_checkpoint",
104
+ label: "Context Checkpoint",
105
+ description: "Create a named checkpoint in the conversation history. Use this before risky work, before a long research pass, or whenever you reach a stable milestone.",
106
+ parameters: ContextCheckpointParams,
107
+ async execute(_id, params: Static<typeof ContextCheckpointParams>, _signal, _onUpdate, ctx) {
107
108
  const sm = ctx.sessionManager as SessionManager;
108
109
 
109
- // Deduplication check: ensure tag name is unique
110
- const existingTagId = findTagInTree(sm, sm.getTree(), params.name);
111
- if (existingTagId) {
110
+ // Deduplication check: ensure checkpoint name is unique
111
+ const existingCheckpointId = findCheckpointInTree(sm, sm.getTree(), params.name);
112
+ if (existingCheckpointId) {
112
113
  return {
113
114
  content: [{
114
115
  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.`
116
+ 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
117
  }],
117
118
  details: {}
118
119
  };
@@ -121,8 +122,8 @@ export default function (pi: ExtensionAPI) {
121
122
  let id = params.target ? resolveTargetId(sm, params.target) : undefined;
122
123
 
123
124
  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).
125
+ // Auto-resolve: Find the last interesting node to checkpoint.
126
+ // We skip ToolResults that look awkward when checkpointed and internal-only assistant messages that look empty.
126
127
  const branch = sm.getBranch();
127
128
  for (let i = branch.length - 1; i >= 0; i--) {
128
129
  const entry = branch[i];
@@ -156,16 +157,16 @@ export default function (pi: ExtensionAPI) {
156
157
  }
157
158
 
158
159
  pi.setLabel(id, params.name);
159
- return { content: [{ type: "text", text: `Created tag '${params.name}' at ${id}` }], details: {} };
160
+ return { content: [{ type: "text", text: `Created checkpoint '${params.name}' at ${id}` }], details: {} };
160
161
  },
161
162
  });
162
163
 
163
164
  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) {
165
+ name: "context_timeline",
166
+ label: "Context Timeline",
167
+ description: "Show the conversation timeline, checkpoints, summaries, and current position. Use this to understand session structure, monitor drift, and choose a checkpoint to return to.",
168
+ parameters: ContextTimelineParams,
169
+ async execute(_id, params: Static<typeof ContextTimelineParams>, _signal, _onUpdate, ctx) {
169
170
  const sm = ctx.sessionManager as SessionManager;
170
171
  const branch = sm.getBranch();
171
172
  const currentLeafId = sm.getLeafId();
@@ -193,7 +194,7 @@ export default function (pi: ExtensionAPI) {
193
194
  return e.summary || "[No summary provided]";
194
195
  }
195
196
  if (entry.type === "label") {
196
- return `tag: ${entry.label}`;
197
+ return `checkpoint: ${entry.label}`;
197
198
  }
198
199
 
199
200
  if (entry.type === "message") {
@@ -257,7 +258,7 @@ export default function (pi: ExtensionAPI) {
257
258
  if (entry.id === currentLeafId) return true;
258
259
  if (branch.length > 0 && entry.id === branch[0].id) return true;
259
260
 
260
- // 2. Explicit Tags (Labels) - Only show the TAGGED node, not the label node itself
261
+ // 2. Explicit checkpoints (labels) - only show the checkpointed node, not the label node itself
261
262
  if (sm.getLabel(entry.id)) return true;
262
263
  if (entry.type === 'label') return false; // Hide label nodes, they are redundant
263
264
 
@@ -327,7 +328,7 @@ export default function (pi: ExtensionAPI) {
327
328
 
328
329
  const id = entry.id;
329
330
  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(", ");
331
+ const meta = [isRoot ? "ROOT" : null, isHead ? "HEAD" : null, label ? `checkpoint: ${label}` : null].filter(Boolean).join(", ");
331
332
 
332
333
  const body = content.length > 100 ? content.slice(0, 100) + "..." : content;
333
334
 
@@ -343,27 +344,27 @@ export default function (pi: ExtensionAPI) {
343
344
  // --- Context Dashboard (HUD) ---
344
345
  const usage = await ctx.getContextUsage();
345
346
  let usageStr = "Unknown";
346
- if (usage) {
347
+ if (usage?.percent != null && usage.tokens != null && usage.contextWindow != null) {
347
348
  usageStr = `${usage.percent.toFixed(1)}% (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
348
349
  }
349
350
 
350
- // Find the distance to the nearest tag
351
- let stepsSinceTag = 0;
352
- let nearestTagName = "None";
351
+ // Find the distance to the nearest checkpoint
352
+ let stepsSinceCheckpoint = 0;
353
+ let nearestCheckpointName = "None";
353
354
  for (let i = branch.length - 1; i >= 0; i--) {
354
355
  const id = branch[i].id;
355
356
  const label = sm.getLabel(id);
356
357
  if (label) {
357
- nearestTagName = label;
358
+ nearestCheckpointName = label;
358
359
  break;
359
360
  }
360
- stepsSinceTag++;
361
+ stepsSinceCheckpoint++;
361
362
  }
362
363
 
363
364
  const hud = [
364
365
  `[Context Dashboard]`,
365
366
  `• Context Usage: ${usageStr}`,
366
- `• Segment Size: ${stepsSinceTag} steps since last tag '${nearestTagName}'`,
367
+ `• Segment Size: ${stepsSinceCheckpoint} steps since last checkpoint '${nearestCheckpointName}'`,
367
368
  `---------------------------------------------------`
368
369
  ].join("\n");
369
370
 
@@ -372,11 +373,11 @@ export default function (pi: ExtensionAPI) {
372
373
  });
373
374
 
374
375
  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) {
376
+ name: "context_rewind",
377
+ label: "Conversation Rewind",
378
+ description: "Return to an earlier conversation checkpoint and start a fresh continuation with a carryover summary. This changes session history only and does not modify disk files. Always provide a detailed message.",
379
+ parameters: ContextRewindParams,
380
+ async execute(_id, params: Static<typeof ContextRewindParams>, _signal, _onUpdate, ctx) {
380
381
  if (!CommandCtx) {
381
382
  ctx.ui.setEditorText(`/acm ${ctx.ui.getEditorText() || "continue"}`)
382
383
  return {
@@ -395,49 +396,49 @@ export default function (pi: ExtensionAPI) {
395
396
  if (currentLeaf === tid) {
396
397
  return { content: [{ type: "text", text: `Already at target ${tid}` }], details: {} };
397
398
  }
398
- if (params.backupTag && currentLeaf) {
399
- pi.setLabel(currentLeaf, params.backupTag);
399
+ if (params.backupCheckpoint && currentLeaf) {
400
+ pi.setLabel(currentLeaf, params.backupCheckpoint);
400
401
  }
401
402
  const currentLabel = currentLeaf ? sm.getLabel(currentLeaf) : undefined;
402
- const origin = currentLabel ? `tag: ${currentLabel}` : (currentLeaf || "unknown");
403
+ const origin = currentLabel ? `checkpoint: ${currentLabel}` : (currentLeaf || "unknown");
403
404
 
404
405
  const enrichedMessage = `(summary from ${origin})\n${params.message}`;
405
406
 
406
407
  const nid = await sm.branchWithSummary(tid, enrichedMessage);
407
- CheckoutParams = params;
408
- CheckoutParams.nid = nid;
409
- CheckoutParams.tid = tid;
410
- CheckoutParams.enrichedMessage = enrichedMessage;
408
+ RewindParams = params;
409
+ RewindParams.nid = nid;
410
+ RewindParams.tid = tid;
411
+ RewindParams.enrichedMessage = enrichedMessage;
411
412
 
412
- return { content: [{ type: "text", text: "checkout start" }], details: {} };
413
+ return { content: [{ type: "text", text: "rewind start" }], details: {} };
413
414
  },
414
415
  });
415
416
 
416
- pi.on("turn_end", async (event, ctx) => {
417
- if (!CheckoutParams) {
417
+ pi.on("turn_end", async (_event, ctx) => {
418
+ if (!RewindParams) {
418
419
  return
419
420
  }
420
421
  ctx.abort()
421
422
  });
422
423
 
423
424
  pi.on("agent_end", async (_event, ctx) => {
424
- if (!CheckoutParams) {
425
+ if (!RewindParams) {
425
426
  return
426
427
  }
427
428
  if (!CommandCtx) {
428
429
  return
429
430
  }
430
431
 
431
- await CommandCtx.navigateTree(CheckoutParams.nid, {
432
+ await CommandCtx.navigateTree(RewindParams.nid, {
432
433
  summarize: false,
433
434
  });
434
435
 
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;
436
+ ctx.ui.notify(`Rewound to ${RewindParams.target}${RewindParams.target === RewindParams.tid ? "" : `(${RewindParams.tid})`}\nBackup checkpoint created: ${RewindParams.backupCheckpoint || "none"}\nmessage: ${RewindParams.enrichedMessage}`, "info");
437
+ RewindParams = null;
437
438
 
438
439
  pi.sendMessage({
439
440
  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
+ content: "context_rewind complete. A summary of your previous conversation path was injected above. Read it to understand your new state. Execute the 'Next Step' from the summary",
441
442
  display: false,
442
443
  }, {
443
444
  triggerTurn: true,