pi-context 1.0.5 → 1.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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Pi Context Extension
1
+ # Pi Context: Agentic Context Management for the Pi
2
2
 
3
3
  A Git-like context management tool that allows AI agents to proactively manage their context.
4
4
 
@@ -17,10 +17,10 @@ pi install npm:pi-context
17
17
 
18
18
  ### For Humans
19
19
 
20
- Load the skill to enable the workflow:
20
+ Run the command to enable ACM (**A**gentic **C**ontext **M**anagement) for the current session.
21
21
 
22
22
  ```bash
23
- /skill:context-management
23
+ /acm
24
24
  ```
25
25
 
26
26
  View detailed context window usage and token distribution with a visual dashboard. (like `claude code /context`)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-context",
3
- "version": "1.0.5",
4
- "description": "agent-driven context management tools",
3
+ "version": "1.1.0",
4
+ "description": "Agentic Context Management for the Pi",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "pi": {
31
31
  "extensions": [
32
- "./src/tools.ts",
33
- "./src/commands.ts"
32
+ "./src/index.ts",
33
+ "./src/context.ts"
34
34
  ],
35
35
  "skills": [
36
36
  "./skills"
package/src/index.ts ADDED
@@ -0,0 +1,409 @@
1
+ import {
2
+ type ExtensionAPI,
3
+ type SessionManager,
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";
13
+ import { formatTokens } from "./utils.js";
14
+
15
+ // Define missing types locally as they are not exported from the main entry point
16
+ interface SessionTreeNode {
17
+ entry: SessionEntry;
18
+ children: SessionTreeNode[];
19
+ label?: string;
20
+ }
21
+
22
+ const InternalTools = ["context_tag", "context_log", "context_checkout"];
23
+ let CommandCtx: ExtensionCommandContext | null = null;
24
+ let CheckoutParams: any = null;
25
+
26
+ const isInternal = (name: string) => InternalTools.includes(name);
27
+
28
+ const resolveTargetId = (sm: SessionManager, target: string): string => {
29
+ if (target.toLowerCase() === "root") {
30
+ const tree = sm.getTree();
31
+ return tree.length > 0 ? tree[0].entry.id : target;
32
+ }
33
+ if (/^[0-9a-f]{8,}$/i.test(target)) return target;
34
+ const find = (nodes: SessionTreeNode[]): string | null => {
35
+ for (const n of nodes) {
36
+ if (sm.getLabel(n.entry.id) === target) return n.entry.id;
37
+ const r = find(n.children);
38
+ if (r) return r;
39
+ }
40
+ return null;
41
+ };
42
+ // sm.getTree() returns the SDK's SessionTreeNode[], which is structurally compatible
43
+ return find(sm.getTree()) || target;
44
+ };
45
+
46
+ const ContextLogParams = Type.Object({
47
+ limit: Type.Optional(Type.Number({ description: "History limit for visible entries (default: 50)." })),
48
+ 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." })),
49
+ });
50
+
51
+ const ContextCheckoutParams = Type.Object({
52
+ 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." }),
53
+ 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]'" }),
54
+ 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
+ });
56
+
57
+ const ContextTagParams = Type.Object({
58
+ name: Type.String({ description: "The tag/milestone name. Use meaningful names." }),
59
+ target: Type.Optional(Type.String({ description: "The commit ID to tag. Defaults to HEAD (current state)." })),
60
+ });
61
+
62
+ export default function (pi: ExtensionAPI) {
63
+ pi.registerCommand("acm", {
64
+ description: "Enable agentic context management for the current session",
65
+ handler: async (args, ctx) => {
66
+ CommandCtx = ctx;
67
+ pi.setActiveTools(pi.getActiveTools().concat(InternalTools));
68
+ ctx.ui.notify("Agentic Context Management enabled.", "info");
69
+ pi.sendMessage({
70
+ customType: "pi-context",
71
+ content: "read context-management skill",
72
+ display: false,
73
+ }, {
74
+ deliverAs: "followUp"
75
+ });
76
+ }
77
+ });
78
+
79
+ pi.registerTool({
80
+ name: "context_tag",
81
+ label: "Context Tag",
82
+ 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'.",
83
+ parameters: ContextTagParams,
84
+ async execute(_id, params: Static<typeof ContextTagParams>, _signal, _onUpdate, ctx) {
85
+ const sm = ctx.sessionManager as SessionManager;
86
+ let id = params.target ? resolveTargetId(sm, params.target) : undefined;
87
+
88
+ if (!id) {
89
+ // Auto-resolve: Find the last "interesting" node to tag.
90
+ // We skip ToolResults (which look ugly tagged) and internal-only Assistant messages (which look empty).
91
+ const branch = sm.getBranch();
92
+ for (let i = branch.length - 1; i >= 0; i--) {
93
+ const entry = branch[i];
94
+
95
+ // 1. Check ToolResults
96
+ if (entry.type === 'message' && entry.message.role === 'toolResult') {
97
+ const tr = entry.message as any;
98
+ if (isInternal(tr.toolName)) continue;
99
+
100
+ // Public tool result is a valid target
101
+ id = entry.id;
102
+ break;
103
+ }
104
+
105
+ // 2. Check Assistant messages for visibility
106
+ if (entry.type === 'message' && entry.message.role === 'assistant') {
107
+ const m = entry.message;
108
+ const hasInternalTool = m.content.some(c => c.type === 'toolCall' && isInternal(c.name));
109
+
110
+ if (!hasInternalTool) {
111
+ id = entry.id;
112
+ break;
113
+ }
114
+ }
115
+
116
+ id = entry.id;
117
+ break;
118
+ }
119
+ // Fallback to leaf if search failed
120
+ if (!id) id = sm.getLeafId() ?? "";
121
+ }
122
+
123
+ pi.setLabel(id, params.name);
124
+ return { content: [{ type: "text", text: `Created tag '${params.name}' at ${id}` }], details: {} };
125
+ },
126
+ });
127
+
128
+ pi.registerTool({
129
+ name: "context_log",
130
+ label: "Context Log",
131
+ description: "Show the entire history structure (status, message, tags, milestones). Analogous to 'git log --graph --oneline --decorate'",
132
+ parameters: ContextLogParams,
133
+ async execute(_id, params: Static<typeof ContextLogParams>, _signal, _onUpdate, ctx) {
134
+ const sm = ctx.sessionManager as SessionManager;
135
+ const branch = sm.getBranch();
136
+ const currentLeafId = sm.getLeafId();
137
+ const verbose = params.verbose ?? false;
138
+ const limit = params.limit ?? 50;
139
+
140
+ const backboneIds = new Set(branch.map((e) => e.id));
141
+ const sequence: SessionEntry[] = [];
142
+
143
+ branch.forEach((entry) => {
144
+ sequence.push(entry);
145
+
146
+ // Preserve side-summary logic: Show branch summaries/compactions that are off-path
147
+ const children = sm.getChildren(entry.id);
148
+ children.forEach((child) => {
149
+ if ((child.type === "branch_summary" || child.type === "compaction") && !backboneIds.has(child.id)) {
150
+ sequence.push(child);
151
+ }
152
+ });
153
+ });
154
+
155
+ const getMsgContent = (entry: SessionEntry): string => {
156
+ if (entry.type === "branch_summary" || entry.type === "compaction") {
157
+ const e = entry;
158
+ return e.summary || "[No summary provided]";
159
+ }
160
+ if (entry.type === "label") {
161
+ return `tag: ${entry.label}`;
162
+ }
163
+
164
+ if (entry.type === "message") {
165
+ const msg = entry.message;
166
+
167
+ if (msg.role === "toolResult") {
168
+ const tr = msg;
169
+ if (!verbose && isInternal(tr.toolName)) return "";
170
+
171
+ const extractText = (content: (TextContent | ImageContent)[]): string => {
172
+ return content
173
+ .map((p) => (p.type === "text" ? p.text : ""))
174
+ .join(" ")
175
+ .trim();
176
+ };
177
+
178
+ let resText = extractText(tr.content);
179
+ const details = tr.details as Record<string, unknown> | undefined;
180
+ if ((tr.toolName === "read" || tr.toolName === "edit") && details && "path" in details && typeof details.path === "string") {
181
+ resText = `${details.path}: ${resText}`;
182
+ }
183
+ return `(${tr.toolName}) ${resText}`;
184
+ }
185
+
186
+ if (msg.role === "bashExecution") {
187
+ return `[Bash] ${msg.command}`;
188
+ }
189
+
190
+ if (msg.role === "user" || msg.role === "assistant") {
191
+ let text = "";
192
+ if (typeof msg.content === "string") {
193
+ text = msg.content;
194
+ } else if (Array.isArray(msg.content)) {
195
+ text = msg.content
196
+ .map((p: any) => {
197
+ if (typeof p === "object" && p !== null && "text" in p) return (p as TextContent).text;
198
+ return "";
199
+ })
200
+ .join(" ")
201
+ .trim();
202
+ }
203
+
204
+ let toolCallsText = "";
205
+ if (msg.role === "assistant") {
206
+ const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
207
+
208
+ toolCallsText = toolCalls
209
+ .filter((tc) => verbose || !isInternal(tc.name))
210
+ .map((tc) => `call: ${tc.name}(${JSON.stringify(tc.arguments)})`)
211
+ .join("; ");
212
+ }
213
+
214
+ return [text, toolCallsText].filter(Boolean).join(" ");
215
+ }
216
+ }
217
+ return "";
218
+ };
219
+
220
+ const isInteresting = (entry: SessionEntry): boolean => {
221
+ // 1. HEAD and Root
222
+ if (entry.id === currentLeafId) return true;
223
+ if (branch.length > 0 && entry.id === branch[0].id) return true;
224
+
225
+ // 2. Explicit Tags (Labels) - Only show the TAGGED node, not the label node itself
226
+ if (sm.getLabel(entry.id)) return true;
227
+ if (entry.type === 'label') return false; // Hide label nodes, they are redundant
228
+
229
+ // 3. Structural Milestones (Summaries)
230
+ if (entry.type === 'branch_summary' || entry.type === 'compaction') return true;
231
+
232
+ // 4. Branch Points (Forks)
233
+ if (sm.getChildren(entry.id).length > 1) return true;
234
+
235
+ // 5. Natural Milestones (User Messages) - This is the key auto-tagging mechanism
236
+ if (entry.type === 'message' && entry.message.role === 'user') return true;
237
+
238
+ return false;
239
+ };
240
+
241
+ const visibleSequenceIds = new Set<string>();
242
+ sequence.forEach(e => {
243
+ if (verbose || isInteresting(e)) {
244
+ visibleSequenceIds.add(e.id);
245
+ }
246
+ });
247
+
248
+ let visibleEntries = sequence.filter(e => visibleSequenceIds.has(e.id));
249
+ if (visibleEntries.length > limit) {
250
+ const allowedIds = new Set(visibleEntries.slice(-limit).map(e => e.id));
251
+ visibleSequenceIds.clear();
252
+ allowedIds.forEach(id => visibleSequenceIds.add(id));
253
+ }
254
+
255
+ const lines: string[] = [];
256
+ let hiddenCount = 0;
257
+
258
+ sequence.forEach((entry) => {
259
+ if (!visibleSequenceIds.has(entry.id)) {
260
+ hiddenCount++;
261
+ return;
262
+ }
263
+
264
+ if (hiddenCount > 0) {
265
+ lines.push(` : ... (${hiddenCount} hidden messages) ...`);
266
+ hiddenCount = 0;
267
+ }
268
+
269
+ const isHead = entry.id === currentLeafId;
270
+ const label = sm.getLabel(entry.id);
271
+ const content = getMsgContent(entry).replace(/\s+/g, " ");
272
+
273
+ let role = entry.type.toUpperCase();
274
+ if (entry.type === "message") {
275
+ const m = entry.message;
276
+ role =
277
+ m.role === "assistant"
278
+ ? "AI"
279
+ : m.role === "user"
280
+ ? "USER"
281
+ : m.role === "bashExecution"
282
+ ? "BASH"
283
+ : "TOOL";
284
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
285
+ role = "SUMMARY";
286
+ }
287
+
288
+ // hide custom messages
289
+ if (role === "CUSTOM_MESSAGE") {
290
+ return
291
+ }
292
+
293
+ const id = entry.id;
294
+ const isRoot = branch.length > 0 && entry.id === branch[0].id;
295
+ const meta = [isRoot ? "ROOT" : null, isHead ? "HEAD" : null, label ? `tag: ${label}` : null].filter(Boolean).join(", ");
296
+
297
+ const body = content.length > 100 ? content.slice(0, 100) + "..." : content;
298
+
299
+ const marker = isHead ? "*" : (role === "USER" ? "•" : "|");
300
+
301
+ lines.push(`${marker} ${id}${meta ? ` (${meta})` : ""} [${role}] ${body}`);
302
+ });
303
+
304
+ if (hiddenCount > 0) {
305
+ lines.push(` : ... (${hiddenCount} hidden messages) ...`);
306
+ }
307
+
308
+ // --- Context Dashboard (HUD) ---
309
+ const usage = await ctx.getContextUsage();
310
+ let usageStr = "Unknown";
311
+ if (usage) {
312
+ usageStr = `${usage.percent.toFixed(1)}% (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
313
+ }
314
+
315
+ // Find the distance to the nearest tag
316
+ let stepsSinceTag = 0;
317
+ let nearestTagName = "None";
318
+ for (let i = branch.length - 1; i >= 0; i--) {
319
+ const id = branch[i].id;
320
+ const label = sm.getLabel(id);
321
+ if (label) {
322
+ nearestTagName = label;
323
+ break;
324
+ }
325
+ stepsSinceTag++;
326
+ }
327
+
328
+ const hud = [
329
+ `[Context Dashboard]`,
330
+ `• Context Usage: ${usageStr}`,
331
+ `• Segment Size: ${stepsSinceTag} steps since last tag '${nearestTagName}'`,
332
+ `---------------------------------------------------`
333
+ ].join("\n");
334
+
335
+ return { content: [{ type: "text", text: hud + "\n" + (lines.join("\n") || "(Root Path Only)") }], details: {} };
336
+ },
337
+ });
338
+
339
+ pi.registerTool({
340
+ name: "context_checkout",
341
+ label: "Context Checkout",
342
+ 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.",
343
+ parameters: ContextCheckoutParams,
344
+ async execute(_id, params: Static<typeof ContextCheckoutParams>, _signal, _onUpdate, ctx) {
345
+ if (!CommandCtx) {
346
+ return { content: [{ type: "text", text: "Command context not available, require /acm command first." }], details: {} };
347
+ }
348
+ const sm = ctx.sessionManager as SessionManager;
349
+
350
+ const tid = resolveTargetId(sm, params.target);
351
+
352
+ const currentLeaf = sm.getLeafId();
353
+ if (currentLeaf === tid) {
354
+ return { content: [{ type: "text", text: `Already at target ${tid}` }], details: {} };
355
+ }
356
+ if (params.backupTag && currentLeaf) {
357
+ pi.setLabel(currentLeaf, params.backupTag);
358
+ }
359
+ const currentLabel = currentLeaf ? sm.getLabel(currentLeaf) : undefined;
360
+ const origin = currentLabel ? `tag: ${currentLabel}` : (currentLeaf || "unknown");
361
+
362
+ const enrichedMessage = `(summary from ${origin})\n${params.message}`;
363
+
364
+ const nid = await sm.branchWithSummary(tid, enrichedMessage);
365
+ CheckoutParams = params;
366
+ CheckoutParams.nid = nid;
367
+ CheckoutParams.tid = tid;
368
+ CheckoutParams.enrichedMessage = enrichedMessage;
369
+
370
+ return { content: [{ type: "text", text: "checkout start" }], details: {} };
371
+ },
372
+ });
373
+
374
+ pi.on("turn_end", async (event, ctx) => {
375
+ if (!CheckoutParams) {
376
+ return
377
+ }
378
+ ctx.abort()
379
+ });
380
+
381
+ pi.on("agent_end", async (_event, ctx) => {
382
+ if (!CheckoutParams) {
383
+ return
384
+ }
385
+ if (!CommandCtx) {
386
+ return
387
+ }
388
+
389
+ await CommandCtx.navigateTree(CheckoutParams.nid, {
390
+ summarize: false,
391
+ });
392
+
393
+ ctx.ui.notify(`Checked out ${CheckoutParams.target}${CheckoutParams.target === CheckoutParams.tid ? "" : `(${CheckoutParams.tid})`}\nBackup tag created: ${CheckoutParams.backupTag || "none"}\nmessage: ${CheckoutParams.enrichedMessage}`, "info");
394
+ CheckoutParams = null;
395
+
396
+ pi.sendMessage({
397
+ customType: "pi-context",
398
+ content: "context_checkout done, continue",
399
+ display: false,
400
+ }, {
401
+ triggerTurn: true,
402
+ });
403
+ });
404
+
405
+ pi.on("session_start", () => {
406
+ // hide tools by default
407
+ pi.setActiveTools(pi.getActiveTools().filter(n => !InternalTools.includes(n)));
408
+ });
409
+ }
package/src/utils.ts CHANGED
@@ -1,35 +1,3 @@
1
- import {
2
- type SessionEntry,
3
- type SessionManager,
4
- } from "@mariozechner/pi-coding-agent";
5
-
6
- // Define missing types locally as they are not exported from the main entry point
7
- export interface SessionTreeNode {
8
- entry: SessionEntry;
9
- children: SessionTreeNode[];
10
- label?: string;
11
- }
12
-
13
- export const isInternal = (name: string) => ["context_tag", "context_log", "context_checkout"].includes(name);
14
-
15
- export const resolveTargetId = (sm: SessionManager, target: string): string => {
16
- if (target.toLowerCase() === "root") {
17
- const tree = sm.getTree();
18
- return tree.length > 0 ? tree[0].entry.id : target;
19
- }
20
- if (/^[0-9a-f]{8,}$/i.test(target)) return target;
21
- const find = (nodes: SessionTreeNode[]): string | null => {
22
- for (const n of nodes) {
23
- if (sm.getLabel(n.entry.id) === target) return n.entry.id;
24
- const r = find(n.children);
25
- if (r) return r;
26
- }
27
- return null;
28
- };
29
- // sm.getTree() returns the SDK's SessionTreeNode[], which is structurally compatible
30
- return find(sm.getTree()) || target;
31
- };
32
-
33
1
  export const formatTokens = (n: number) => {
34
2
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
35
3
  if (n >= 1_000) return Math.round(n / 1_000) + "k";
package/src/tools.ts DELETED
@@ -1,312 +0,0 @@
1
- import {
2
- type ExtensionAPI,
3
- type SessionManager,
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 { isInternal, resolveTargetId, formatTokens } from "./utils.js";
13
-
14
- const ContextLogParams = Type.Object({
15
- limit: Type.Optional(Type.Number({ description: "History limit for visible entries (default: 50)." })),
16
- 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." })),
17
- });
18
-
19
- const ContextCheckoutParams = Type.Object({
20
- 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." }),
21
- 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]'" }),
22
- 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." })),
23
- });
24
-
25
- const ContextTagParams = Type.Object({
26
- name: Type.String({ description: "The tag/milestone name. Use meaningful names." }),
27
- target: Type.Optional(Type.String({ description: "The commit ID to tag. Defaults to HEAD (current state)." })),
28
- });
29
-
30
- export default function (pi: ExtensionAPI) {
31
- pi.registerTool({
32
- name: "context_tag",
33
- label: "Context Tag",
34
- 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'.",
35
- parameters: ContextTagParams,
36
- async execute(_id, params: Static<typeof ContextTagParams>, _signal, _onUpdate, ctx) {
37
- const sm = ctx.sessionManager as SessionManager;
38
- let id = params.target ? resolveTargetId(sm, params.target) : undefined;
39
-
40
- if (!id) {
41
- // Auto-resolve: Find the last "interesting" node to tag.
42
- // We skip ToolResults (which look ugly tagged) and internal-only Assistant messages (which look empty).
43
- const branch = sm.getBranch();
44
- for (let i = branch.length - 1; i >= 0; i--) {
45
- const entry = branch[i];
46
-
47
- // 1. Check ToolResults
48
- if (entry.type === 'message' && entry.message.role === 'toolResult') {
49
- const tr = entry.message as any;
50
- if (isInternal(tr.toolName)) continue;
51
-
52
- // Public tool result is a valid target
53
- id = entry.id;
54
- break;
55
- }
56
-
57
- // 2. Check Assistant messages for visibility
58
- if (entry.type === 'message' && entry.message.role === 'assistant') {
59
- const m = entry.message;
60
- const hasInternalTool = m.content.some(c => c.type === 'toolCall' && isInternal(c.name));
61
-
62
- if (!hasInternalTool) {
63
- id = entry.id;
64
- break;
65
- }
66
- }
67
-
68
- id = entry.id;
69
- break;
70
- }
71
- // Fallback to leaf if search failed
72
- if (!id) id = sm.getLeafId() ?? "";
73
- }
74
-
75
- pi.setLabel(id, params.name);
76
- return { content: [{ type: "text", text: `Created tag '${params.name}' at ${id}` }], details: {} };
77
- },
78
- });
79
-
80
- pi.registerTool({
81
- name: "context_log",
82
- label: "Context Log",
83
- description: "Show the entire history structure (status, message, tags, milestones). Analogous to 'git log --graph --oneline --decorate'",
84
- parameters: ContextLogParams,
85
- async execute(_id, params: Static<typeof ContextLogParams>, _signal, _onUpdate, ctx) {
86
- const sm = ctx.sessionManager as SessionManager;
87
- const branch = sm.getBranch();
88
- const currentLeafId = sm.getLeafId();
89
- const verbose = params.verbose ?? false;
90
- const limit = params.limit ?? 50;
91
-
92
- const backboneIds = new Set(branch.map((e) => e.id));
93
- const sequence: SessionEntry[] = [];
94
-
95
- branch.forEach((entry) => {
96
- sequence.push(entry);
97
-
98
- // Preserve side-summary logic: Show branch summaries/compactions that are off-path
99
- const children = sm.getChildren(entry.id);
100
- children.forEach((child) => {
101
- if ((child.type === "branch_summary" || child.type === "compaction") && !backboneIds.has(child.id)) {
102
- sequence.push(child);
103
- }
104
- });
105
- });
106
-
107
- const getMsgContent = (entry: SessionEntry): string => {
108
- if (entry.type === "branch_summary" || entry.type === "compaction") {
109
- const e = entry;
110
- return e.summary || "[No summary provided]";
111
- }
112
- if (entry.type === "label") {
113
- return `tag: ${entry.label}`;
114
- }
115
-
116
- if (entry.type === "message") {
117
- const msg = entry.message;
118
-
119
- if (msg.role === "toolResult") {
120
- const tr = msg;
121
- if (!verbose && isInternal(tr.toolName)) return "";
122
-
123
- const extractText = (content: (TextContent | ImageContent)[]): string => {
124
- return content
125
- .map((p) => (p.type === "text" ? p.text : ""))
126
- .join(" ")
127
- .trim();
128
- };
129
-
130
- let resText = extractText(tr.content);
131
- const details = tr.details as Record<string, unknown> | undefined;
132
- if ((tr.toolName === "read" || tr.toolName === "edit") && details && "path" in details && typeof details.path === "string") {
133
- resText = `${details.path}: ${resText}`;
134
- }
135
- return `(${tr.toolName}) ${resText}`;
136
- }
137
-
138
- if (msg.role === "bashExecution") {
139
- return `[Bash] ${msg.command}`;
140
- }
141
-
142
- if (msg.role === "user" || msg.role === "assistant") {
143
- let text = "";
144
- if (typeof msg.content === "string") {
145
- text = msg.content;
146
- } else if (Array.isArray(msg.content)) {
147
- text = msg.content
148
- .map((p: any) => {
149
- if (typeof p === "object" && p !== null && "text" in p) return (p as TextContent).text;
150
- return "";
151
- })
152
- .join(" ")
153
- .trim();
154
- }
155
-
156
- let toolCallsText = "";
157
- if (msg.role === "assistant") {
158
- const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
159
-
160
- toolCallsText = toolCalls
161
- .filter((tc) => verbose || !isInternal(tc.name))
162
- .map((tc) => `call: ${tc.name}(${JSON.stringify(tc.arguments)})`)
163
- .join("; ");
164
- }
165
-
166
- return [text, toolCallsText].filter(Boolean).join(" ");
167
- }
168
- }
169
- return "";
170
- };
171
-
172
- const isInteresting = (entry: SessionEntry): boolean => {
173
- // 1. HEAD and Root
174
- if (entry.id === currentLeafId) return true;
175
- if (branch.length > 0 && entry.id === branch[0].id) return true;
176
-
177
- // 2. Explicit Tags (Labels) - Only show the TAGGED node, not the label node itself
178
- if (sm.getLabel(entry.id)) return true;
179
- if (entry.type === 'label') return false; // Hide label nodes, they are redundant
180
-
181
- // 3. Structural Milestones (Summaries)
182
- if (entry.type === 'branch_summary' || entry.type === 'compaction') return true;
183
-
184
- // 4. Branch Points (Forks)
185
- if (sm.getChildren(entry.id).length > 1) return true;
186
-
187
- // 5. Natural Milestones (User Messages) - This is the key auto-tagging mechanism
188
- if (entry.type === 'message' && entry.message.role === 'user') return true;
189
-
190
- return false;
191
- };
192
-
193
- const visibleSequenceIds = new Set<string>();
194
- sequence.forEach(e => {
195
- if (verbose || isInteresting(e)) {
196
- visibleSequenceIds.add(e.id);
197
- }
198
- });
199
-
200
- let visibleEntries = sequence.filter(e => visibleSequenceIds.has(e.id));
201
- if (visibleEntries.length > limit) {
202
- const allowedIds = new Set(visibleEntries.slice(-limit).map(e => e.id));
203
- visibleSequenceIds.clear();
204
- allowedIds.forEach(id => visibleSequenceIds.add(id));
205
- }
206
-
207
- const lines: string[] = [];
208
- let hiddenCount = 0;
209
-
210
- sequence.forEach((entry) => {
211
- if (!visibleSequenceIds.has(entry.id)) {
212
- hiddenCount++;
213
- return;
214
- }
215
-
216
- if (hiddenCount > 0) {
217
- lines.push(` : ... (${hiddenCount} hidden messages) ...`);
218
- hiddenCount = 0;
219
- }
220
-
221
- const isHead = entry.id === currentLeafId;
222
- const label = sm.getLabel(entry.id);
223
- const content = getMsgContent(entry).replace(/\s+/g, " ");
224
-
225
- let role = entry.type.toUpperCase();
226
- if (entry.type === "message") {
227
- const m = entry.message;
228
- role =
229
- m.role === "assistant"
230
- ? "AI"
231
- : m.role === "user"
232
- ? "USER"
233
- : m.role === "bashExecution"
234
- ? "BASH"
235
- : "TOOL";
236
- } else if (entry.type === "branch_summary" || entry.type === "compaction") {
237
- role = "SUMMARY";
238
- }
239
-
240
- const id = entry.id;
241
- const isRoot = branch.length > 0 && entry.id === branch[0].id;
242
- const meta = [isRoot ? "ROOT" : null, isHead ? "HEAD" : null, label ? `tag: ${label}` : null].filter(Boolean).join(", ");
243
-
244
- const body = content.length > 100 ? content.slice(0, 100) + "..." : content;
245
-
246
- const marker = isHead ? "*" : (role === "USER" ? "•" : "|");
247
-
248
- lines.push(`${marker} ${id}${meta ? ` (${meta})` : ""} [${role}] ${body}`);
249
- });
250
-
251
- if (hiddenCount > 0) {
252
- lines.push(` : ... (${hiddenCount} hidden messages) ...`);
253
- }
254
-
255
- // --- Context Dashboard (HUD) ---
256
- const usage = await ctx.getContextUsage();
257
- let usageStr = "Unknown";
258
- if (usage) {
259
- usageStr = `${usage.percent.toFixed(1)}% (${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)})`;
260
- }
261
-
262
- // Find the distance to the nearest tag
263
- let stepsSinceTag = 0;
264
- let nearestTagName = "None";
265
- for (let i = branch.length - 1; i >= 0; i--) {
266
- const id = branch[i].id;
267
- const label = sm.getLabel(id);
268
- if (label) {
269
- nearestTagName = label;
270
- break;
271
- }
272
- stepsSinceTag++;
273
- }
274
-
275
- const hud = [
276
- `[Context Dashboard]`,
277
- `• Context Usage: ${usageStr}`,
278
- `• Segment Size: ${stepsSinceTag} steps since last tag '${nearestTagName}'`,
279
- `---------------------------------------------------`
280
- ].join("\n");
281
-
282
- return { content: [{ type: "text", text: hud + "\n" + (lines.join("\n") || "(Root Path Only)") }], details: {} };
283
- },
284
- });
285
-
286
- pi.registerTool({
287
- name: "context_checkout",
288
- label: "Context Checkout",
289
- 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.",
290
- parameters: ContextCheckoutParams,
291
- async execute(_id, params: Static<typeof ContextCheckoutParams>, _signal, _onUpdate, ctx) {
292
- const sm = ctx.sessionManager as SessionManager;
293
-
294
- const tid = resolveTargetId(sm, params.target);
295
-
296
- const currentLeaf = sm.getLeafId();
297
- if (currentLeaf === tid) {
298
- return { content: [{ type: "text", text: `Already at target ${tid}` }], details: {} };
299
- }
300
- if (params.backupTag && currentLeaf) {
301
- pi.setLabel(currentLeaf, params.backupTag);
302
- }
303
- const currentLabel = currentLeaf ? sm.getLabel(currentLeaf) : undefined;
304
- const origin = currentLabel ? `tag: ${currentLabel}` : (currentLeaf || "unknown");
305
-
306
- const enrichedMessage = `(summary from ${origin})\n${params.message}`;
307
- await sm.branchWithSummary(tid, enrichedMessage);
308
-
309
- return { content: [{ type: "text", text: `Checked out ${tid}\nBackup tag created: ${params.backupTag || "none"}\nmessage: ${enrichedMessage}` }], details: {} };
310
- },
311
- });
312
- }
File without changes