pi-context 1.1.3 → 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,31 +82,38 @@ export default function (pi: ExtensionAPI) {
81
82
  }
82
83
  });
83
84
 
84
- // Helper: Check if a tag name already exists in the tree
85
- const findTagInTree = (sm: SessionManager, nodes: SessionTreeNode[], tagName: string): string | null => {
86
- for (const n of nodes) {
87
- if (sm.getLabel(n.entry.id) === tagName) return n.entry.id;
88
- const r = findTagInTree(sm, n.children, tagName);
89
- if (r) return r;
85
+ // Helper: Check if a checkpoint name already exists in the tree
86
+ // Iterative DFS to avoid call stack overflows on deep histories.
87
+ // Push children in reverse order to preserve left-to-right pre-order semantics.
88
+ const findCheckpointInTree = (sm: SessionManager, nodes: SessionTreeNode[], checkpointName: string): string | null => {
89
+ const stack: SessionTreeNode[] = [...nodes].reverse();
90
+ while (stack.length > 0) {
91
+ const n = stack.pop()!;
92
+ if (sm.getLabel(n.entry.id) === checkpointName) return n.entry.id;
93
+ if (n.children?.length) {
94
+ for (let i = n.children.length - 1; i >= 0; i--) {
95
+ stack.push(n.children[i]);
96
+ }
97
+ }
90
98
  }
91
99
  return null;
92
100
  };
93
101
 
94
102
  pi.registerTool({
95
- name: "context_tag",
96
- label: "Context Tag",
97
- 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'.",
98
- parameters: ContextTagParams,
99
- 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) {
100
108
  const sm = ctx.sessionManager as SessionManager;
101
109
 
102
- // Deduplication check: ensure tag name is unique
103
- const existingTagId = findTagInTree(sm, sm.getTree(), params.name);
104
- if (existingTagId) {
110
+ // Deduplication check: ensure checkpoint name is unique
111
+ const existingCheckpointId = findCheckpointInTree(sm, sm.getTree(), params.name);
112
+ if (existingCheckpointId) {
105
113
  return {
106
114
  content: [{
107
115
  type: "text",
108
- 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.`
109
117
  }],
110
118
  details: {}
111
119
  };
@@ -114,8 +122,8 @@ export default function (pi: ExtensionAPI) {
114
122
  let id = params.target ? resolveTargetId(sm, params.target) : undefined;
115
123
 
116
124
  if (!id) {
117
- // Auto-resolve: Find the last "interesting" node to tag.
118
- // 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.
119
127
  const branch = sm.getBranch();
120
128
  for (let i = branch.length - 1; i >= 0; i--) {
121
129
  const entry = branch[i];
@@ -149,16 +157,16 @@ export default function (pi: ExtensionAPI) {
149
157
  }
150
158
 
151
159
  pi.setLabel(id, params.name);
152
- 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: {} };
153
161
  },
154
162
  });
155
163
 
156
164
  pi.registerTool({
157
- name: "context_log",
158
- label: "Context Log",
159
- description: "Show the entire history structure (status, message, tags, milestones). Analogous to 'git log --graph --oneline --decorate'",
160
- parameters: ContextLogParams,
161
- 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) {
162
170
  const sm = ctx.sessionManager as SessionManager;
163
171
  const branch = sm.getBranch();
164
172
  const currentLeafId = sm.getLeafId();
@@ -186,7 +194,7 @@ export default function (pi: ExtensionAPI) {
186
194
  return e.summary || "[No summary provided]";
187
195
  }
188
196
  if (entry.type === "label") {
189
- return `tag: ${entry.label}`;
197
+ return `checkpoint: ${entry.label}`;
190
198
  }
191
199
 
192
200
  if (entry.type === "message") {
@@ -250,7 +258,7 @@ export default function (pi: ExtensionAPI) {
250
258
  if (entry.id === currentLeafId) return true;
251
259
  if (branch.length > 0 && entry.id === branch[0].id) return true;
252
260
 
253
- // 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
254
262
  if (sm.getLabel(entry.id)) return true;
255
263
  if (entry.type === 'label') return false; // Hide label nodes, they are redundant
256
264
 
@@ -320,7 +328,7 @@ export default function (pi: ExtensionAPI) {
320
328
 
321
329
  const id = entry.id;
322
330
  const isRoot = branch.length > 0 && entry.id === branch[0].id;
323
- 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(", ");
324
332
 
325
333
  const body = content.length > 100 ? content.slice(0, 100) + "..." : content;
326
334
 
@@ -336,27 +344,27 @@ export default function (pi: ExtensionAPI) {
336
344
  // --- Context Dashboard (HUD) ---
337
345
  const usage = await ctx.getContextUsage();
338
346
  let usageStr = "Unknown";
339
- if (usage) {
347
+ if (usage?.percent != null && usage.tokens != null && usage.contextWindow != null) {
340
348
  usageStr = `${usage.percent.toFixed(1)}% (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
341
349
  }
342
350
 
343
- // Find the distance to the nearest tag
344
- let stepsSinceTag = 0;
345
- let nearestTagName = "None";
351
+ // Find the distance to the nearest checkpoint
352
+ let stepsSinceCheckpoint = 0;
353
+ let nearestCheckpointName = "None";
346
354
  for (let i = branch.length - 1; i >= 0; i--) {
347
355
  const id = branch[i].id;
348
356
  const label = sm.getLabel(id);
349
357
  if (label) {
350
- nearestTagName = label;
358
+ nearestCheckpointName = label;
351
359
  break;
352
360
  }
353
- stepsSinceTag++;
361
+ stepsSinceCheckpoint++;
354
362
  }
355
363
 
356
364
  const hud = [
357
365
  `[Context Dashboard]`,
358
366
  `• Context Usage: ${usageStr}`,
359
- `• Segment Size: ${stepsSinceTag} steps since last tag '${nearestTagName}'`,
367
+ `• Segment Size: ${stepsSinceCheckpoint} steps since last checkpoint '${nearestCheckpointName}'`,
360
368
  `---------------------------------------------------`
361
369
  ].join("\n");
362
370
 
@@ -365,11 +373,11 @@ export default function (pi: ExtensionAPI) {
365
373
  });
366
374
 
367
375
  pi.registerTool({
368
- name: "context_checkout",
369
- label: "Context Checkout",
370
- 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.",
371
- parameters: ContextCheckoutParams,
372
- 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) {
373
381
  if (!CommandCtx) {
374
382
  ctx.ui.setEditorText(`/acm ${ctx.ui.getEditorText() || "continue"}`)
375
383
  return {
@@ -388,49 +396,49 @@ export default function (pi: ExtensionAPI) {
388
396
  if (currentLeaf === tid) {
389
397
  return { content: [{ type: "text", text: `Already at target ${tid}` }], details: {} };
390
398
  }
391
- if (params.backupTag && currentLeaf) {
392
- pi.setLabel(currentLeaf, params.backupTag);
399
+ if (params.backupCheckpoint && currentLeaf) {
400
+ pi.setLabel(currentLeaf, params.backupCheckpoint);
393
401
  }
394
402
  const currentLabel = currentLeaf ? sm.getLabel(currentLeaf) : undefined;
395
- const origin = currentLabel ? `tag: ${currentLabel}` : (currentLeaf || "unknown");
403
+ const origin = currentLabel ? `checkpoint: ${currentLabel}` : (currentLeaf || "unknown");
396
404
 
397
405
  const enrichedMessage = `(summary from ${origin})\n${params.message}`;
398
406
 
399
407
  const nid = await sm.branchWithSummary(tid, enrichedMessage);
400
- CheckoutParams = params;
401
- CheckoutParams.nid = nid;
402
- CheckoutParams.tid = tid;
403
- CheckoutParams.enrichedMessage = enrichedMessage;
408
+ RewindParams = params;
409
+ RewindParams.nid = nid;
410
+ RewindParams.tid = tid;
411
+ RewindParams.enrichedMessage = enrichedMessage;
404
412
 
405
- return { content: [{ type: "text", text: "checkout start" }], details: {} };
413
+ return { content: [{ type: "text", text: "rewind start" }], details: {} };
406
414
  },
407
415
  });
408
416
 
409
- pi.on("turn_end", async (event, ctx) => {
410
- if (!CheckoutParams) {
417
+ pi.on("turn_end", async (_event, ctx) => {
418
+ if (!RewindParams) {
411
419
  return
412
420
  }
413
421
  ctx.abort()
414
422
  });
415
423
 
416
424
  pi.on("agent_end", async (_event, ctx) => {
417
- if (!CheckoutParams) {
425
+ if (!RewindParams) {
418
426
  return
419
427
  }
420
428
  if (!CommandCtx) {
421
429
  return
422
430
  }
423
431
 
424
- await CommandCtx.navigateTree(CheckoutParams.nid, {
432
+ await CommandCtx.navigateTree(RewindParams.nid, {
425
433
  summarize: false,
426
434
  });
427
435
 
428
- ctx.ui.notify(`Checked out ${CheckoutParams.target}${CheckoutParams.target === CheckoutParams.tid ? "" : `(${CheckoutParams.tid})`}\nBackup tag created: ${CheckoutParams.backupTag || "none"}\nmessage: ${CheckoutParams.enrichedMessage}`, "info");
429
- 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;
430
438
 
431
439
  pi.sendMessage({
432
440
  customType: "pi-context",
433
- 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",
434
442
  display: false,
435
443
  }, {
436
444
  triggerTurn: true,