@gswangg/pi-duncan 0.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 +92 -0
- package/extensions/duncan.ts +931 -0
- package/package.json +26 -0
- package/skills/duncan/SKILL.md +35 -0
- package/tests/compaction-windows.test.mjs +285 -0
- package/tests/lineage.test.mjs +315 -0
- package/tests/resolve-targets.test.mjs +291 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { buildSessionContext, convertToLlm, parseSessionEntries } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { completeSimple } from "@mariozechner/pi-ai";
|
|
4
|
+
import type { Model, Api } from "@mariozechner/pi-ai";
|
|
5
|
+
import { Type } from "@sinclair/typebox";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import { existsSync, openSync, readSync, readFileSync, closeSync, readdirSync, appendFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Compaction windowing — split a session into independently queryable windows
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A compaction window: a slice of session context that can be queried independently.
|
|
18
|
+
* For a session with N compactions, there are N+1 windows:
|
|
19
|
+
* - Window 0: raw messages from start to first compaction
|
|
20
|
+
* - Window k: compaction[k-1] summary + kept messages through compaction[k]
|
|
21
|
+
* - Window N: compaction[N-1] summary + messages to leaf (= buildSessionContext default)
|
|
22
|
+
*/
|
|
23
|
+
export interface CompactionWindow {
|
|
24
|
+
windowIndex: number;
|
|
25
|
+
messages: any[]; // AgentMessage[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract message from an entry (mirrors compaction.js getMessageFromEntry).
|
|
30
|
+
*/
|
|
31
|
+
export function getMessageFromEntry(entry: any): any | undefined {
|
|
32
|
+
if (entry.type === "message") return entry.message;
|
|
33
|
+
if (entry.type === "custom_message") {
|
|
34
|
+
// Inline minimal createCustomMessage to avoid import issues
|
|
35
|
+
return {
|
|
36
|
+
role: "custom",
|
|
37
|
+
customType: entry.customType,
|
|
38
|
+
content: entry.content,
|
|
39
|
+
display: entry.display,
|
|
40
|
+
details: entry.details,
|
|
41
|
+
timestamp: new Date(entry.timestamp).getTime(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (entry.type === "branch_summary" && entry.summary) {
|
|
45
|
+
return {
|
|
46
|
+
role: "branchSummary",
|
|
47
|
+
summary: entry.summary,
|
|
48
|
+
fromId: entry.fromId,
|
|
49
|
+
timestamp: new Date(entry.timestamp).getTime(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (entry.type === "compaction") {
|
|
53
|
+
return {
|
|
54
|
+
role: "compactionSummary",
|
|
55
|
+
summary: entry.summary,
|
|
56
|
+
tokensBefore: entry.tokensBefore ?? 0,
|
|
57
|
+
timestamp: new Date(entry.timestamp).getTime(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Split session entries into compaction windows. Each window is an independent
|
|
65
|
+
* message array suitable for duncan queries.
|
|
66
|
+
*
|
|
67
|
+
* For sessions with no compactions, returns a single window with all messages
|
|
68
|
+
* (identical to current buildSessionContext behavior).
|
|
69
|
+
*
|
|
70
|
+
* For sessions with compactions, returns N+1 windows where N = number of compactions.
|
|
71
|
+
* Each window contains the raw messages for that segment, plus the preceding
|
|
72
|
+
* compaction summary if applicable.
|
|
73
|
+
*/
|
|
74
|
+
export function getCompactionWindows(entries: any[]): CompactionWindow[] {
|
|
75
|
+
// Filter out session header
|
|
76
|
+
const nonHeader = entries.filter((e: any) => e.type !== "session");
|
|
77
|
+
if (nonHeader.length === 0) return [];
|
|
78
|
+
|
|
79
|
+
// Build id index for tree traversal
|
|
80
|
+
const byId = new Map<string, any>();
|
|
81
|
+
for (const entry of nonHeader) {
|
|
82
|
+
byId.set(entry.id, entry);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Find leaf (last entry)
|
|
86
|
+
const leaf = nonHeader[nonHeader.length - 1];
|
|
87
|
+
if (!leaf) return [];
|
|
88
|
+
|
|
89
|
+
// Walk from leaf to root to get the path
|
|
90
|
+
const pathEntries: any[] = [];
|
|
91
|
+
let current: any = leaf;
|
|
92
|
+
while (current) {
|
|
93
|
+
pathEntries.unshift(current);
|
|
94
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Find all compaction entries on the path, in order
|
|
98
|
+
const compactionIndices: number[] = [];
|
|
99
|
+
for (let i = 0; i < pathEntries.length; i++) {
|
|
100
|
+
if (pathEntries[i].type === "compaction") {
|
|
101
|
+
compactionIndices.push(i);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Helper: collect messages from a slice of pathEntries
|
|
106
|
+
const collectMessages = (start: number, end: number): any[] =>
|
|
107
|
+
pathEntries.slice(start, end).map(getMessageFromEntry).filter(Boolean);
|
|
108
|
+
|
|
109
|
+
// No compactions — single window with all messages
|
|
110
|
+
if (compactionIndices.length === 0) {
|
|
111
|
+
const messages = collectMessages(0, pathEntries.length);
|
|
112
|
+
return messages.length > 0 ? [{ windowIndex: 0, messages }] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const windows: CompactionWindow[] = [];
|
|
116
|
+
|
|
117
|
+
// Window 0: raw messages before first compaction
|
|
118
|
+
const w0 = collectMessages(0, compactionIndices[0]);
|
|
119
|
+
if (w0.length > 0) windows.push({ windowIndex: 0, messages: w0 });
|
|
120
|
+
|
|
121
|
+
// Windows 1..N: compaction summary + kept messages + new messages until next boundary
|
|
122
|
+
for (let k = 0; k < compactionIndices.length; k++) {
|
|
123
|
+
const compIdx = compactionIndices[k];
|
|
124
|
+
const comp = pathEntries[compIdx];
|
|
125
|
+
const nextBoundary = k + 1 < compactionIndices.length ? compactionIndices[k + 1] : pathEntries.length;
|
|
126
|
+
const messages: any[] = [];
|
|
127
|
+
|
|
128
|
+
// Compaction summary
|
|
129
|
+
const compMsg = getMessageFromEntry(comp);
|
|
130
|
+
if (compMsg) messages.push(compMsg);
|
|
131
|
+
|
|
132
|
+
// Kept messages: entries before the compaction, from firstKeptEntryId onward
|
|
133
|
+
if (comp.firstKeptEntryId) {
|
|
134
|
+
let found = false;
|
|
135
|
+
for (let i = 0; i < compIdx; i++) {
|
|
136
|
+
if (pathEntries[i].id === comp.firstKeptEntryId) found = true;
|
|
137
|
+
if (found) {
|
|
138
|
+
const msg = getMessageFromEntry(pathEntries[i]);
|
|
139
|
+
if (msg) messages.push(msg);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// New messages after compaction until next boundary
|
|
145
|
+
messages.push(...collectMessages(compIdx + 1, nextBoundary));
|
|
146
|
+
|
|
147
|
+
if (messages.length > 0) windows.push({ windowIndex: k + 1, messages });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return windows;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ============================================================================
|
|
154
|
+
// Query metadata
|
|
155
|
+
// ============================================================================
|
|
156
|
+
|
|
157
|
+
// Query log location. Defaults to duncan.jsonl next to this extension file.
|
|
158
|
+
// Override via DUNCAN_LOG env var.
|
|
159
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
160
|
+
const DUNCAN_LOG = process.env.DUNCAN_LOG ?? path.join(__dirname, "duncan.jsonl");
|
|
161
|
+
|
|
162
|
+
interface DuncanRecord {
|
|
163
|
+
question: string;
|
|
164
|
+
answer: string;
|
|
165
|
+
hasContext: boolean;
|
|
166
|
+
targetSession: string;
|
|
167
|
+
windowIndex: number;
|
|
168
|
+
sourceSession: string;
|
|
169
|
+
timestamp: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function recordQuery(record: DuncanRecord): void {
|
|
173
|
+
const dir = path.dirname(DUNCAN_LOG);
|
|
174
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
175
|
+
appendFileSync(DUNCAN_LOG, JSON.stringify(record) + "\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================================================================
|
|
179
|
+
// Duncan query — structured output via tool call
|
|
180
|
+
// ============================================================================
|
|
181
|
+
|
|
182
|
+
const DUNCAN_RESPONSE_TOOL = {
|
|
183
|
+
name: "duncan_response",
|
|
184
|
+
description: "Provide your answer to the query.",
|
|
185
|
+
parameters: Type.Object({
|
|
186
|
+
hasContext: Type.Boolean({ description: "true if the conversation contained specific information to answer the question, false if it did not" }),
|
|
187
|
+
answer: Type.String({ description: "Your answer based on the conversation context, or a brief explanation of why you lack context" }),
|
|
188
|
+
}),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const DUNCAN_PREFIX = `Answer solely based on the conversation above. If you don't explicitly have context from the conversation on this topic, say so. Use the duncan_response tool to provide your answer.\n\n`;
|
|
192
|
+
|
|
193
|
+
interface DuncanResult {
|
|
194
|
+
answer: string;
|
|
195
|
+
hasContext: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateDuncanResponse(response: any): DuncanResult | null {
|
|
199
|
+
const toolCall = response.content.find((c: any) => c.type === "toolCall" && c.name === "duncan_response");
|
|
200
|
+
if (!toolCall || toolCall.type !== "toolCall") return null;
|
|
201
|
+
const { answer, hasContext } = toolCall.arguments;
|
|
202
|
+
if (typeof answer !== "string" || typeof hasContext !== "boolean") return null;
|
|
203
|
+
return { answer, hasContext };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const MAX_RETRIES = 3;
|
|
207
|
+
|
|
208
|
+
interface DuncanTarget {
|
|
209
|
+
sessionFile: string;
|
|
210
|
+
windowIndex: number;
|
|
211
|
+
messages: any[]; // AgentMessage[]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getDuncanTargets(sessionFile: string): DuncanTarget[] {
|
|
215
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
216
|
+
const entries = parseSessionEntries(content);
|
|
217
|
+
const windows = getCompactionWindows(entries);
|
|
218
|
+
return windows.map(w => ({
|
|
219
|
+
sessionFile,
|
|
220
|
+
windowIndex: w.windowIndex,
|
|
221
|
+
messages: w.messages,
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function duncanQuery(
|
|
226
|
+
messages: any[],
|
|
227
|
+
question: string,
|
|
228
|
+
model: Model<Api>,
|
|
229
|
+
apiKey: string,
|
|
230
|
+
opts: { systemPrompt: string; signal?: AbortSignal },
|
|
231
|
+
): Promise<DuncanResult> {
|
|
232
|
+
const llmMessages = convertToLlm([
|
|
233
|
+
...messages,
|
|
234
|
+
{ role: "user", content: [{ type: "text", text: DUNCAN_PREFIX + question }], timestamp: Date.now() },
|
|
235
|
+
]);
|
|
236
|
+
const tools = [DUNCAN_RESPONSE_TOOL];
|
|
237
|
+
const complete = () => completeSimple(model, { systemPrompt: opts.systemPrompt, messages: llmMessages, tools }, { apiKey, signal: opts.signal, maxTokens: 16384 });
|
|
238
|
+
|
|
239
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
240
|
+
const response = await complete();
|
|
241
|
+
|
|
242
|
+
if (response.stopReason === "error") {
|
|
243
|
+
throw new Error(`Query failed (attempt ${attempt}): ${(response as any).errorMessage || "unknown error"}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = validateDuncanResponse(response);
|
|
247
|
+
if (result) return result;
|
|
248
|
+
|
|
249
|
+
llmMessages.push(
|
|
250
|
+
{ role: "assistant" as const, content: response.content, stopReason: response.stopReason, model: response.model, usage: response.usage },
|
|
251
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: "You must respond by calling the duncan_response tool with { hasContext: boolean, answer: string }. Do not respond with plain text." }], timestamp: Date.now() },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
throw new Error(`Duncan query failed after ${MAX_RETRIES} retries: model did not produce a valid duncan_response tool call`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Query a dormant session with a plain text prompt (for handoff summaries).
|
|
260
|
+
* No structured output — just returns the response text.
|
|
261
|
+
*/
|
|
262
|
+
async function sessionQuery(
|
|
263
|
+
sessionFile: string,
|
|
264
|
+
prompt: string,
|
|
265
|
+
model: Model<Api>,
|
|
266
|
+
apiKey: string,
|
|
267
|
+
opts?: { systemPrompt?: string; signal?: AbortSignal },
|
|
268
|
+
): Promise<string> {
|
|
269
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
270
|
+
const entries = parseSessionEntries(content);
|
|
271
|
+
const sessionEntries = entries.filter((e: any) => e.type !== "session");
|
|
272
|
+
const { messages } = buildSessionContext(sessionEntries);
|
|
273
|
+
|
|
274
|
+
messages.push({
|
|
275
|
+
role: "user",
|
|
276
|
+
content: [{ type: "text", text: prompt }],
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const llmMessages = convertToLlm(messages);
|
|
281
|
+
|
|
282
|
+
const response = await completeSimple(model, {
|
|
283
|
+
systemPrompt: opts?.systemPrompt ?? "",
|
|
284
|
+
messages: llmMessages,
|
|
285
|
+
}, {
|
|
286
|
+
apiKey,
|
|
287
|
+
signal: opts?.signal,
|
|
288
|
+
maxTokens: 16384,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (response.stopReason === "error") {
|
|
292
|
+
throw new Error(`Query failed: ${(response as any).errorMessage || "unknown error"}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return response.content
|
|
296
|
+
.filter((c: any) => c.type === "text")
|
|
297
|
+
.map((c: any) => c.text)
|
|
298
|
+
.join("\n");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// Lineage helpers — built on pi's native parentSession header
|
|
303
|
+
// ============================================================================
|
|
304
|
+
|
|
305
|
+
export function readSessionHeader(sessionFile: string): { id: string; parentSession?: string; timestamp: string } | null {
|
|
306
|
+
if (!existsSync(sessionFile)) return null;
|
|
307
|
+
try {
|
|
308
|
+
const fd = openSync(sessionFile, "r");
|
|
309
|
+
const buf = Buffer.alloc(2048);
|
|
310
|
+
const bytesRead = readSync(fd, buf, 0, 2048, 0);
|
|
311
|
+
closeSync(fd);
|
|
312
|
+
const firstLine = buf.toString("utf-8", 0, bytesRead).split("\n")[0];
|
|
313
|
+
return JSON.parse(firstLine);
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
interface SessionNode {
|
|
320
|
+
file: string;
|
|
321
|
+
id: string;
|
|
322
|
+
parent?: string;
|
|
323
|
+
children: string[];
|
|
324
|
+
generation: number;
|
|
325
|
+
timestamp: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function buildSessionTree(sessionDir: string): Map<string, SessionNode> {
|
|
329
|
+
const nodes = new Map<string, SessionNode>();
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const files = readdirSync(sessionDir);
|
|
333
|
+
for (const f of files) {
|
|
334
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
335
|
+
const fullPath = path.join(sessionDir, f);
|
|
336
|
+
const header = readSessionHeader(fullPath);
|
|
337
|
+
if (!header) continue;
|
|
338
|
+
nodes.set(fullPath, {
|
|
339
|
+
file: fullPath,
|
|
340
|
+
id: header.id,
|
|
341
|
+
parent: header.parentSession,
|
|
342
|
+
children: [],
|
|
343
|
+
generation: 0,
|
|
344
|
+
timestamp: header.timestamp,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
} catch { return nodes; }
|
|
348
|
+
|
|
349
|
+
for (const [_file, node] of nodes) {
|
|
350
|
+
if (node.parent && nodes.has(node.parent)) {
|
|
351
|
+
nodes.get(node.parent)!.children.push(node.file);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const node of nodes.values()) {
|
|
356
|
+
node.children.sort((a, b) => {
|
|
357
|
+
const ta = nodes.get(a)?.timestamp ?? "";
|
|
358
|
+
const tb = nodes.get(b)?.timestamp ?? "";
|
|
359
|
+
return ta.localeCompare(tb);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const roots = [...nodes.values()].filter(n => !n.parent || !nodes.has(n.parent));
|
|
364
|
+
const queue = [...roots];
|
|
365
|
+
while (queue.length > 0) {
|
|
366
|
+
const node = queue.shift()!;
|
|
367
|
+
for (const childFile of node.children) {
|
|
368
|
+
const child = nodes.get(childFile);
|
|
369
|
+
if (child) {
|
|
370
|
+
child.generation = node.generation + 1;
|
|
371
|
+
queue.push(child);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return nodes;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function findLineageRoot(nodes: Map<string, SessionNode>, sessionFile: string): string {
|
|
380
|
+
let current = sessionFile;
|
|
381
|
+
while (true) {
|
|
382
|
+
const node = nodes.get(current);
|
|
383
|
+
if (!node?.parent || !nodes.has(node.parent)) return current;
|
|
384
|
+
current = node.parent;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export function collectLineage(nodes: Map<string, SessionNode>, rootFile: string): Set<string> {
|
|
389
|
+
const lineage = new Set<string>();
|
|
390
|
+
const queue = [rootFile];
|
|
391
|
+
while (queue.length > 0) {
|
|
392
|
+
const file = queue.shift()!;
|
|
393
|
+
if (lineage.has(file)) continue;
|
|
394
|
+
lineage.add(file);
|
|
395
|
+
const node = nodes.get(file);
|
|
396
|
+
if (node) queue.push(...node.children);
|
|
397
|
+
}
|
|
398
|
+
return lineage;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function resolveGeneration(sessionFile: string, sessionDir: string): number {
|
|
402
|
+
const nodes = buildSessionTree(sessionDir);
|
|
403
|
+
return nodes.get(sessionFile)?.generation ?? 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function getSessionPreview(sessionFile: string): string {
|
|
407
|
+
if (!existsSync(sessionFile)) return "";
|
|
408
|
+
try {
|
|
409
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
410
|
+
const lines = content.split("\n");
|
|
411
|
+
for (const line of lines) {
|
|
412
|
+
if (!line.includes('"role":"user"')) continue;
|
|
413
|
+
try {
|
|
414
|
+
const entry = JSON.parse(line);
|
|
415
|
+
if (entry?.type !== "message" || entry?.message?.role !== "user") continue;
|
|
416
|
+
const msg = entry.message.content;
|
|
417
|
+
let text = "";
|
|
418
|
+
if (typeof msg === "string") {
|
|
419
|
+
text = msg;
|
|
420
|
+
} else if (Array.isArray(msg)) {
|
|
421
|
+
for (const c of msg) {
|
|
422
|
+
if (c?.type === "text") { text = c.text; break; }
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
text = text.replace(/[\n\r\t]+/g, " ").trim();
|
|
426
|
+
if (text.length > 80) text = text.slice(0, 77) + "...";
|
|
427
|
+
return text;
|
|
428
|
+
} catch { continue; }
|
|
429
|
+
}
|
|
430
|
+
return "(no messages)";
|
|
431
|
+
} catch {
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Get all session files in a directory, ordered by recency (newest first).
|
|
438
|
+
*/
|
|
439
|
+
export function getProjectSessions(sessionDir: string): string[] {
|
|
440
|
+
try {
|
|
441
|
+
return readdirSync(sessionDir)
|
|
442
|
+
.filter(f => f.endsWith(".jsonl"))
|
|
443
|
+
.sort((a, b) => b.localeCompare(a)) // newest first (timestamp prefix)
|
|
444
|
+
.map(f => path.join(sessionDir, f));
|
|
445
|
+
} catch {
|
|
446
|
+
return [];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Get all session files across all project directories, ordered by recency (newest first).
|
|
452
|
+
*/
|
|
453
|
+
export function getGlobalSessions(sessionDir: string): string[] {
|
|
454
|
+
const sessionsRoot = path.dirname(sessionDir); // ~/.pi/agent/sessions/
|
|
455
|
+
try {
|
|
456
|
+
const dirs = readdirSync(sessionsRoot, { withFileTypes: true })
|
|
457
|
+
.filter(d => d.isDirectory())
|
|
458
|
+
.map(d => path.join(sessionsRoot, d.name));
|
|
459
|
+
const allFiles: string[] = [];
|
|
460
|
+
for (const dir of dirs) {
|
|
461
|
+
try {
|
|
462
|
+
const files = readdirSync(dir)
|
|
463
|
+
.filter(f => f.endsWith(".jsonl"))
|
|
464
|
+
.map(f => path.join(dir, f));
|
|
465
|
+
allFiles.push(...files);
|
|
466
|
+
} catch { /* skip unreadable dirs */ }
|
|
467
|
+
}
|
|
468
|
+
return allFiles.sort((a, b) => path.basename(b).localeCompare(path.basename(a)));
|
|
469
|
+
} catch {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Get descendant chain from current session (BFS, children only — excludes self).
|
|
476
|
+
*/
|
|
477
|
+
export function getDescendantChain(sessionFile: string, sessionDir: string): string[] {
|
|
478
|
+
const nodes = buildSessionTree(sessionDir);
|
|
479
|
+
const chain: string[] = [];
|
|
480
|
+
const current = nodes.get(sessionFile);
|
|
481
|
+
if (!current) return chain;
|
|
482
|
+
const queue = [...current.children];
|
|
483
|
+
while (queue.length > 0) {
|
|
484
|
+
const childFile = queue.shift()!;
|
|
485
|
+
const child = nodes.get(childFile);
|
|
486
|
+
if (child) {
|
|
487
|
+
chain.push(childFile);
|
|
488
|
+
queue.push(...child.children);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return chain;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Get ancestor chain from current session to root (parent first).
|
|
496
|
+
*/
|
|
497
|
+
export function getAncestorChain(sessionFile: string, sessionDir: string): string[] {
|
|
498
|
+
const nodes = buildSessionTree(sessionDir);
|
|
499
|
+
const chain: string[] = [];
|
|
500
|
+
// Include current session — its earlier compaction windows are ancestors too.
|
|
501
|
+
// The last window (active context) gets filtered out downstream.
|
|
502
|
+
let current = nodes.get(sessionFile);
|
|
503
|
+
if (current) chain.push(sessionFile);
|
|
504
|
+
while (current?.parent && nodes.has(current.parent)) {
|
|
505
|
+
chain.push(current.parent);
|
|
506
|
+
current = nodes.get(current.parent);
|
|
507
|
+
}
|
|
508
|
+
return chain;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ============================================================================
|
|
512
|
+
// Target resolution + pagination — pure, testable
|
|
513
|
+
// ============================================================================
|
|
514
|
+
|
|
515
|
+
const LIMITED_MODES = new Set(["ancestors", "descendants", "project", "global"]);
|
|
516
|
+
const DEFAULT_LIMIT = 50;
|
|
517
|
+
|
|
518
|
+
export interface ResolveParams {
|
|
519
|
+
sessions: string;
|
|
520
|
+
limit?: number;
|
|
521
|
+
offset?: number;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface ResolveResult {
|
|
525
|
+
targets: DuncanTarget[];
|
|
526
|
+
totalWindows: number;
|
|
527
|
+
hasMore: boolean;
|
|
528
|
+
offset: number;
|
|
529
|
+
limit: number;
|
|
530
|
+
error?: string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Resolve session mode + pagination into a page of DuncanTargets.
|
|
535
|
+
* Pure function — no ctx, no model, no API calls.
|
|
536
|
+
*/
|
|
537
|
+
export function resolveTargets(
|
|
538
|
+
params: ResolveParams,
|
|
539
|
+
sessionFile: string,
|
|
540
|
+
sessionDir: string,
|
|
541
|
+
): ResolveResult {
|
|
542
|
+
const limit = params.limit ?? (LIMITED_MODES.has(params.sessions) ? DEFAULT_LIMIT : Infinity);
|
|
543
|
+
const offset = params.offset ?? 0;
|
|
544
|
+
|
|
545
|
+
let sessionFiles: string[] = [];
|
|
546
|
+
let error: string | undefined;
|
|
547
|
+
|
|
548
|
+
if (params.sessions === "parent") {
|
|
549
|
+
const chain = getAncestorChain(sessionFile, sessionDir);
|
|
550
|
+
if (chain.length < 2) error = "No parent session found.";
|
|
551
|
+
else sessionFiles = [chain[1]];
|
|
552
|
+
} else if (params.sessions === "ancestors") {
|
|
553
|
+
sessionFiles = getAncestorChain(sessionFile, sessionDir);
|
|
554
|
+
if (sessionFiles.length === 0) error = "No ancestor sessions found.";
|
|
555
|
+
} else if (params.sessions === "descendants") {
|
|
556
|
+
sessionFiles = getDescendantChain(sessionFile, sessionDir);
|
|
557
|
+
if (sessionFiles.length === 0) error = "No descendant sessions found.";
|
|
558
|
+
} else if (params.sessions === "project") {
|
|
559
|
+
sessionFiles = getProjectSessions(sessionDir);
|
|
560
|
+
if (sessionFiles.length === 0) error = "No sessions found in this project directory.";
|
|
561
|
+
} else if (params.sessions === "global") {
|
|
562
|
+
sessionFiles = getGlobalSessions(sessionDir);
|
|
563
|
+
if (sessionFiles.length === 0) error = "No sessions found.";
|
|
564
|
+
} else {
|
|
565
|
+
const target = path.resolve(sessionDir, params.sessions);
|
|
566
|
+
if (!existsSync(target)) error = `Session not found: ${params.sessions}`;
|
|
567
|
+
else sessionFiles = [target];
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (error) {
|
|
571
|
+
return { targets: [], totalWindows: 0, hasMore: false, offset, limit, error };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Deduplicate
|
|
575
|
+
sessionFiles = [...new Set(sessionFiles)];
|
|
576
|
+
|
|
577
|
+
// Expand to windows
|
|
578
|
+
const allTargets: DuncanTarget[] = [];
|
|
579
|
+
for (const file of sessionFiles) {
|
|
580
|
+
try {
|
|
581
|
+
const windows = getDuncanTargets(file);
|
|
582
|
+
if (file === sessionFile) {
|
|
583
|
+
windows.pop(); // drop active context window
|
|
584
|
+
}
|
|
585
|
+
allTargets.push(...windows);
|
|
586
|
+
} catch {
|
|
587
|
+
// skip unparseable files
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (allTargets.length === 0) {
|
|
592
|
+
return { targets: [], totalWindows: 0, hasMore: false, offset, limit, error: "No queryable context found in target sessions." };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const totalWindows = allTargets.length;
|
|
596
|
+
const page = allTargets.slice(offset, offset + limit);
|
|
597
|
+
|
|
598
|
+
if (page.length === 0) {
|
|
599
|
+
return { targets: [], totalWindows, hasMore: false, offset, limit, error: `No windows in range (offset ${offset}, total ${totalWindows}).` };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
targets: page,
|
|
604
|
+
totalWindows,
|
|
605
|
+
hasMore: offset + limit < totalWindows,
|
|
606
|
+
offset,
|
|
607
|
+
limit,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Resolve model and API key from extension context.
|
|
613
|
+
*/
|
|
614
|
+
async function getModelAndKey(ctx: ExtensionContext): Promise<{ model: Model<Api>; apiKey: string }> {
|
|
615
|
+
const model = ctx.model;
|
|
616
|
+
if (!model) throw new Error("No model selected");
|
|
617
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
|
|
618
|
+
if (!apiKey) throw new Error(`No API key for provider "${model.provider}"`);
|
|
619
|
+
return { model, apiKey };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ============================================================================
|
|
623
|
+
// Extension
|
|
624
|
+
// ============================================================================
|
|
625
|
+
|
|
626
|
+
export default function (pi: ExtensionAPI) {
|
|
627
|
+
const THRESHOLD = 80;
|
|
628
|
+
let warned = false;
|
|
629
|
+
|
|
630
|
+
pi.on("session_start", async () => {
|
|
631
|
+
warned = false;
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
635
|
+
const usage = ctx.getContextUsage();
|
|
636
|
+
if (!usage || usage.percent === null) return;
|
|
637
|
+
if (usage.percent > THRESHOLD && !warned) {
|
|
638
|
+
warned = true;
|
|
639
|
+
ctx.ui.notify(
|
|
640
|
+
`Context at ${Math.round(usage.percent)}% — run /dfork to hand off`,
|
|
641
|
+
"warning"
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ---- duncan tool — query dormant sessions in-process ----
|
|
647
|
+
pi.registerTool({
|
|
648
|
+
name: "duncan",
|
|
649
|
+
label: "Duncan",
|
|
650
|
+
description: "Query a dormant session from a previous context handoff. Loads the session's full conversation context and asks a question against it using the same model. Use this when you need information from a previous session that isn't in your current context.",
|
|
651
|
+
promptSnippet: "duncan — query dormant sessions from previous handoffs for information not in current context",
|
|
652
|
+
promptGuidelines: [
|
|
653
|
+
"Use duncan when the current context references a previous session or when the user asks about work done in earlier sessions.",
|
|
654
|
+
"The 'sessions' parameter accepts 'parent' (immediate parent only), 'ancestors' (walk up lineage, parent first), 'descendants' (walk down to children, BFS), 'project' (all sessions from same working directory, newest first), 'global' (all sessions across all projects, newest first), or a specific session filename.",
|
|
655
|
+
"For 'ancestors' mode, sessions are queried parent-first — the answer comes from the first session that has relevant information.",
|
|
656
|
+
"For 'descendants' mode, child sessions are queried breadth-first. Use when you spawned work via /dfork and need to know what happened in those sessions.",
|
|
657
|
+
"For 'project' mode, all sessions started from the same working directory are queried (newest first). Use when the information might be in a non-ancestor session from the same project.",
|
|
658
|
+
"For 'global' mode, all sessions across all working directories are queried (newest first). Use as a last resort when the information might be in an unrelated project.",
|
|
659
|
+
"Keep questions specific and self-contained — the dormant session has no knowledge of the current conversation.",
|
|
660
|
+
"Results include pagination info when not all windows were queried. Call again with a higher offset to continue searching.",
|
|
661
|
+
],
|
|
662
|
+
parameters: Type.Object({
|
|
663
|
+
question: Type.String({ description: "The question to ask the dormant session. Should be specific and self-contained." }),
|
|
664
|
+
sessions: Type.String({ description: "Which sessions to query: 'parent' (immediate parent only), 'ancestors' (walk up lineage, parent first), 'descendants' (walk down to children, BFS), 'project' (all sessions from same working directory, newest first), 'global' (all sessions across all projects, newest first), or a session filename." }),
|
|
665
|
+
limit: Type.Optional(Type.Number({ description: "Max windows to query. Defaults: 50 for ancestors/descendants/project/global, unlimited for parent and explicit filename." })),
|
|
666
|
+
offset: Type.Optional(Type.Number({ description: "Skip this many windows before querying. Use for pagination when a previous query didn't find what you needed. Default: 0." })),
|
|
667
|
+
}),
|
|
668
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
669
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
670
|
+
if (!sessionFile) {
|
|
671
|
+
return { content: [{ type: "text", text: "Error: no active session" }], isError: true };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const sessionDir = path.dirname(sessionFile);
|
|
675
|
+
const resolved = resolveTargets(params, sessionFile, sessionDir);
|
|
676
|
+
|
|
677
|
+
if (resolved.error) {
|
|
678
|
+
return { content: [{ type: "text", text: resolved.error }], isError: true };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const { targets: duncanTargets, totalWindows, hasMore, offset } = resolved;
|
|
682
|
+
const sessionCount = new Set(duncanTargets.map(t => t.sessionFile)).size;
|
|
683
|
+
const windowCount = duncanTargets.length;
|
|
684
|
+
|
|
685
|
+
const update = (text: string) => onUpdate?.({ content: [{ type: "text", text }] });
|
|
686
|
+
const rangeLabel = hasMore || offset > 0 ? ` (${offset}–${offset + windowCount} of ${totalWindows})` : "";
|
|
687
|
+
update(`**${params.question}**\n\nquerying ${windowCount} window${windowCount === 1 ? "" : "s"} from ${sessionCount} session${sessionCount === 1 ? "" : "s"}${rangeLabel} (${params.sessions})…`);
|
|
688
|
+
|
|
689
|
+
let { model, apiKey } = await getModelAndKey(ctx);
|
|
690
|
+
const systemPrompt = ctx.getSystemPrompt();
|
|
691
|
+
const sourceSession = path.basename(sessionFile);
|
|
692
|
+
const BATCH_SIZE = 10;
|
|
693
|
+
let completed = 0;
|
|
694
|
+
|
|
695
|
+
const queryTarget = async (target: DuncanTarget): Promise<{ session: string; window: number; answer: string; hasContext: boolean }> => {
|
|
696
|
+
const targetSession = path.basename(target.sessionFile);
|
|
697
|
+
try {
|
|
698
|
+
const result = await duncanQuery(target.messages, params.question, model, apiKey, { systemPrompt, signal });
|
|
699
|
+
recordQuery({
|
|
700
|
+
question: params.question, answer: result.answer, hasContext: result.hasContext,
|
|
701
|
+
targetSession, windowIndex: target.windowIndex, sourceSession, timestamp: new Date().toISOString(),
|
|
702
|
+
});
|
|
703
|
+
completed++;
|
|
704
|
+
update(`**${params.question}**\n\n${completed}/${windowCount} windows queried…`);
|
|
705
|
+
return { session: targetSession, window: target.windowIndex, ...result };
|
|
706
|
+
} catch (err: any) {
|
|
707
|
+
completed++;
|
|
708
|
+
update(`**${params.question}**\n\n${completed}/${windowCount} windows queried…`);
|
|
709
|
+
return { session: targetSession, window: target.windowIndex, answer: `Error: ${err.message}`, hasContext: false };
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const results: Array<{ session: string; window: number; answer: string; hasContext: boolean }> = [];
|
|
714
|
+
|
|
715
|
+
for (let i = 0; i < duncanTargets.length; i += BATCH_SIZE) {
|
|
716
|
+
if (signal?.aborted) break;
|
|
717
|
+
const batch = duncanTargets.slice(i, i + BATCH_SIZE);
|
|
718
|
+
const batchResults = await Promise.all(batch.map(queryTarget));
|
|
719
|
+
results.push(...batchResults);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Filter: only return answers that have context, unless none do
|
|
723
|
+
const withContext = results.filter(r => r.hasContext);
|
|
724
|
+
const relevant = withContext.length > 0 ? withContext : results;
|
|
725
|
+
|
|
726
|
+
const answers = relevant.map(r => {
|
|
727
|
+
const windowLabel = windowCount > sessionCount ? ` (window ${r.window})` : "";
|
|
728
|
+
return relevant.length === 1
|
|
729
|
+
? r.answer
|
|
730
|
+
: `### ${r.session}${windowLabel}\n${r.answer}`;
|
|
731
|
+
}).join("\n\n---\n\n");
|
|
732
|
+
|
|
733
|
+
const parts = [`**${params.question}**\n\n${answers}`];
|
|
734
|
+
|
|
735
|
+
if (hasMore) {
|
|
736
|
+
const nextOffset = offset + limit;
|
|
737
|
+
const remaining = totalWindows - nextOffset;
|
|
738
|
+
parts.push(`\n\n---\n*Queried ${windowCount} of ${totalWindows} windows (offset ${offset}). ${remaining} more available — call again with offset: ${nextOffset} to continue.*`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return { content: [{ type: "text", text: parts.join("") }] };
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// ---- /dfork — handoff to new session ----
|
|
746
|
+
pi.registerCommand("dfork", {
|
|
747
|
+
description: "Hand off to a new session with context summary",
|
|
748
|
+
handler: async (_args, ctx) => {
|
|
749
|
+
await ctx.waitForIdle();
|
|
750
|
+
|
|
751
|
+
const oldSessionFile = ctx.sessionManager.getSessionFile();
|
|
752
|
+
if (!oldSessionFile) {
|
|
753
|
+
ctx.ui.notify("No active session", "error");
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const oldSessionId = path.basename(oldSessionFile, ".jsonl").split("_").pop();
|
|
757
|
+
const sessionDir = path.dirname(oldSessionFile);
|
|
758
|
+
const oldGen = resolveGeneration(oldSessionFile, sessionDir);
|
|
759
|
+
|
|
760
|
+
ctx.ui.notify("Generating dfork summary...", "info");
|
|
761
|
+
|
|
762
|
+
// Adapted from pi's compaction SUMMARIZATION_PROMPT
|
|
763
|
+
const summaryPrompt = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
|
764
|
+
|
|
765
|
+
Use this EXACT format:
|
|
766
|
+
|
|
767
|
+
## Goal
|
|
768
|
+
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
|
769
|
+
|
|
770
|
+
## Constraints & Preferences
|
|
771
|
+
- [Any constraints, preferences, or requirements mentioned by user]
|
|
772
|
+
- [Or "(none)" if none were mentioned]
|
|
773
|
+
|
|
774
|
+
## Progress
|
|
775
|
+
### Done
|
|
776
|
+
- [x] [Completed tasks/changes]
|
|
777
|
+
|
|
778
|
+
### In Progress
|
|
779
|
+
- [ ] [Current work]
|
|
780
|
+
|
|
781
|
+
### Blocked
|
|
782
|
+
- [Issues preventing progress, if any]
|
|
783
|
+
|
|
784
|
+
## Key Decisions
|
|
785
|
+
- **[Decision]**: [Brief rationale]
|
|
786
|
+
|
|
787
|
+
## Next Steps
|
|
788
|
+
1. [Ordered list of what should happen next]
|
|
789
|
+
|
|
790
|
+
## Critical Context
|
|
791
|
+
- [Any data, examples, or references needed to continue]
|
|
792
|
+
- [Or "(none)" if not applicable]
|
|
793
|
+
|
|
794
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
const { model, apiKey } = await getModelAndKey(ctx);
|
|
798
|
+
const summary = await sessionQuery(oldSessionFile, summaryPrompt, model, apiKey, {
|
|
799
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
if (!summary.trim()) {
|
|
803
|
+
ctx.ui.notify("Summary generation returned empty response", "error");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const newGen = oldGen + 1;
|
|
808
|
+
|
|
809
|
+
const newSession = await ctx.newSession({
|
|
810
|
+
parentSession: oldSessionFile,
|
|
811
|
+
setup: async (sm) => {
|
|
812
|
+
sm.appendMessage({
|
|
813
|
+
role: "user",
|
|
814
|
+
content: [{
|
|
815
|
+
type: "text",
|
|
816
|
+
text: `# Duncan Handoff (gen ${newGen})\n\nContinuing from previous session \`${oldSessionId}\`.\n\n${summary}`,
|
|
817
|
+
}],
|
|
818
|
+
timestamp: Date.now(),
|
|
819
|
+
});
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (newSession.cancelled) {
|
|
824
|
+
ctx.ui.notify("Handoff cancelled", "info");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
warned = false;
|
|
829
|
+
ctx.ui.notify(`dfork complete (gen ${newGen}) ✓`, "success");
|
|
830
|
+
} catch (err: any) {
|
|
831
|
+
ctx.ui.notify(`Handoff failed: ${err.message?.slice(0, 200)}`, "error");
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// ---- /lineage — show session tree, optionally switch ----
|
|
837
|
+
pi.registerCommand("lineage", {
|
|
838
|
+
description: "Show session lineage tree and switch sessions. Use /lineage all to include unrelated sessions.",
|
|
839
|
+
handler: async (args, ctx) => {
|
|
840
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
841
|
+
if (!sessionFile) {
|
|
842
|
+
ctx.ui.notify("No active session", "error");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const sessionDir = path.dirname(sessionFile);
|
|
846
|
+
const nodes = buildSessionTree(sessionDir);
|
|
847
|
+
|
|
848
|
+
if (nodes.size === 0) {
|
|
849
|
+
ctx.ui.notify("No sessions found", "error");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const showAll = args.trim() === "all";
|
|
854
|
+
const rootFile = findLineageRoot(nodes, sessionFile);
|
|
855
|
+
const lineage = collectLineage(nodes, rootFile);
|
|
856
|
+
|
|
857
|
+
const previews = new Map<string, string>();
|
|
858
|
+
const windowCounts = new Map<string, number>();
|
|
859
|
+
const sessionFiles = showAll ? [...nodes.keys()] : [...lineage];
|
|
860
|
+
for (const file of sessionFiles) {
|
|
861
|
+
previews.set(file, getSessionPreview(file));
|
|
862
|
+
// Count compaction windows (compactions + 1) — cheap line scan
|
|
863
|
+
try {
|
|
864
|
+
const raw = readFileSync(file, "utf-8");
|
|
865
|
+
const compactions = (raw.match(/"type":"compaction"/g) || []).length;
|
|
866
|
+
windowCounts.set(file, compactions + 1);
|
|
867
|
+
} catch {
|
|
868
|
+
windowCounts.set(file, 1);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const options: string[] = [];
|
|
873
|
+
const fileByOption = new Map<string, string>();
|
|
874
|
+
|
|
875
|
+
function addOption(file: string, node: SessionNode, depth: number) {
|
|
876
|
+
const indent = " ".repeat(depth);
|
|
877
|
+
const shortId = node.id.split("-")[0];
|
|
878
|
+
const date = node.timestamp.replace("T", " ").slice(0, 16);
|
|
879
|
+
const marker = file === sessionFile ? " ◀" : "";
|
|
880
|
+
const wc = windowCounts.get(file) ?? 1;
|
|
881
|
+
const windowBadge = wc > 1 ? ` [${wc}w]` : "";
|
|
882
|
+
const preview = previews.get(file) ?? "";
|
|
883
|
+
const label = `${indent}${shortId} (${date})${windowBadge}${marker} ${preview}`;
|
|
884
|
+
options.push(label);
|
|
885
|
+
fileByOption.set(label, file);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const collectTree = (file: string, depth: number, filter?: Set<string>) => {
|
|
889
|
+
const node = nodes.get(file);
|
|
890
|
+
if (!node) return;
|
|
891
|
+
addOption(file, node, depth);
|
|
892
|
+
const children = filter ? node.children.filter(c => filter.has(c)) : node.children;
|
|
893
|
+
for (const child of children) {
|
|
894
|
+
collectTree(child, depth + 1, filter);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
collectTree(rootFile, 0, lineage);
|
|
899
|
+
|
|
900
|
+
if (showAll) {
|
|
901
|
+
const others = [...nodes.keys()].filter(f => !lineage.has(f));
|
|
902
|
+
if (others.length > 0) {
|
|
903
|
+
options.push("── other sessions ──");
|
|
904
|
+
fileByOption.set("── other sessions ──", "");
|
|
905
|
+
for (const file of others) {
|
|
906
|
+
addOption(file, nodes.get(file)!, 0);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (options.length === 1) {
|
|
912
|
+
ctx.ui.notify("Only one session in lineage (this one)", "info");
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const choice = await ctx.ui.select(
|
|
917
|
+
showAll ? "All sessions" : "Lineage",
|
|
918
|
+
options
|
|
919
|
+
);
|
|
920
|
+
if (!choice) return;
|
|
921
|
+
|
|
922
|
+
const targetFile = fileByOption.get(choice);
|
|
923
|
+
if (!targetFile || targetFile === sessionFile) {
|
|
924
|
+
if (targetFile === sessionFile) ctx.ui.notify("Already on this session", "info");
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
await ctx.switchSession(targetFile);
|
|
929
|
+
},
|
|
930
|
+
});
|
|
931
|
+
}
|