@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
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gswangg/pi-duncan",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session memory for pi — query dormant sessions, hand off context across session boundaries",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/gswangg/pi-duncan.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
14
|
+
"@mariozechner/pi-ai": "*",
|
|
15
|
+
"@sinclair/typebox": "*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
19
|
+
"@mariozechner/pi-ai": "*",
|
|
20
|
+
"@sinclair/typebox": "*"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": ["extensions"],
|
|
24
|
+
"skills": ["skills"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: duncan
|
|
3
|
+
description: Query dormant sessions to recover context from previous work. Use when the user asks about earlier sessions, what was decided before, what happened in a previous conversation, or needs information that was lost to compaction. Also use when the current context references work done in prior sessions.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Duncan — Session Query
|
|
7
|
+
|
|
8
|
+
Query dormant sessions using the `duncan` tool. Each past session retains its full conversation context and can be queried independently.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Call the `duncan` tool with:
|
|
13
|
+
- `question` — specific, self-contained question (the dormant session has no knowledge of this conversation)
|
|
14
|
+
- `sessions` — which sessions to search
|
|
15
|
+
- `limit` — (optional) max windows to query. defaults to 50 for multi-session modes, unlimited for parent/filename.
|
|
16
|
+
- `offset` — (optional) skip N windows for pagination. use when a previous query didn't find what you needed.
|
|
17
|
+
|
|
18
|
+
## Session Modes
|
|
19
|
+
|
|
20
|
+
| Mode | When to use |
|
|
21
|
+
|------|------------|
|
|
22
|
+
| `ancestors` | Default. Walks up the parent chain. Start here when unsure. |
|
|
23
|
+
| `parent` | Only the immediate parent session. |
|
|
24
|
+
| `descendants` | Sessions spawned from the current one (children, BFS). |
|
|
25
|
+
| `project` | All sessions in the same working directory, newest first. Use when the info might be in a sibling/unrelated session. |
|
|
26
|
+
| `global` | All sessions across all working directories, newest first. Last resort when info might be in another project entirely. |
|
|
27
|
+
| `<filename>` | A specific session file when you know exactly which one. |
|
|
28
|
+
|
|
29
|
+
## Guidelines
|
|
30
|
+
|
|
31
|
+
- Start with `ancestors` unless there's a reason to use another mode.
|
|
32
|
+
- Keep questions **specific and self-contained** — the dormant session sees only its own conversation plus your question.
|
|
33
|
+
- One question per call. If you need multiple things, make multiple calls.
|
|
34
|
+
- Results include a `hasContext` signal — if no session has context, say so rather than guessing.
|
|
35
|
+
- If results show pagination info (e.g. "50 of 200 windows"), call again with a higher `offset` to search further.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: compaction windowing for duncan fan-out.
|
|
3
|
+
*
|
|
4
|
+
* Creates synthetic sessions with compaction entries using pi's SessionManager,
|
|
5
|
+
* then validates that getCompactionWindows() correctly splits sessions into
|
|
6
|
+
* independently queryable windows. Cross-validates the last window against
|
|
7
|
+
* buildSessionContext().
|
|
8
|
+
*
|
|
9
|
+
* Run: tsx tests/compaction-windows.test.mjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCompactionWindows } from "../extensions/duncan.ts";
|
|
13
|
+
|
|
14
|
+
const { SessionManager, buildSessionContext, parseSessionEntries } = await import("@mariozechner/pi-coding-agent");
|
|
15
|
+
|
|
16
|
+
import { readFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Test helpers
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
const TEST_DIR = join("/tmp", "duncan-compaction-test");
|
|
24
|
+
const TEST_CWD = "/workspace";
|
|
25
|
+
|
|
26
|
+
function setup() {
|
|
27
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
|
|
28
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function teardown() {
|
|
32
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeUser(t) {
|
|
36
|
+
return {
|
|
37
|
+
role: "user",
|
|
38
|
+
content: [{ type: "text", text: t }],
|
|
39
|
+
timestamp: Date.now(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeAssistant(t) {
|
|
44
|
+
return {
|
|
45
|
+
role: "assistant",
|
|
46
|
+
content: [{ type: "text", text: t }],
|
|
47
|
+
provider: "test",
|
|
48
|
+
model: "test-model",
|
|
49
|
+
stopReason: "endTurn",
|
|
50
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150 },
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function text(msg) {
|
|
56
|
+
if (msg.role === "compactionSummary") return `[SUMMARY] ${msg.summary}`;
|
|
57
|
+
if (typeof msg.content === "string") return msg.content;
|
|
58
|
+
if (Array.isArray(msg.content)) {
|
|
59
|
+
return msg.content.filter(c => c.type === "text").map(c => c.text).join("");
|
|
60
|
+
}
|
|
61
|
+
return JSON.stringify(msg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let passed = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
|
|
67
|
+
function assert(condition, msg) {
|
|
68
|
+
if (!condition) {
|
|
69
|
+
console.error(` ✗ ${msg}`);
|
|
70
|
+
failed++;
|
|
71
|
+
} else {
|
|
72
|
+
console.log(` ✓ ${msg}`);
|
|
73
|
+
passed++;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findEntryByText(sm, targetText) {
|
|
78
|
+
return sm.getEntries().find(e =>
|
|
79
|
+
e.type === "message" && text(e.message) === targetText
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Tests
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
function test1() {
|
|
88
|
+
console.log("\n--- Test 1: No compactions → single window ---");
|
|
89
|
+
const sm = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
90
|
+
sm.appendMessage(makeUser("hello"));
|
|
91
|
+
sm.appendMessage(makeAssistant("world"));
|
|
92
|
+
sm.appendMessage(makeUser("ping"));
|
|
93
|
+
sm.appendMessage(makeAssistant("pong"));
|
|
94
|
+
|
|
95
|
+
const entries = parseSessionEntries(readFileSync(sm.getSessionFile(), "utf-8"));
|
|
96
|
+
const windows = getCompactionWindows(entries);
|
|
97
|
+
|
|
98
|
+
assert(windows.length === 1, "1 window");
|
|
99
|
+
assert(windows[0].messages.length === 4, `4 messages (got ${windows[0].messages.length})`);
|
|
100
|
+
|
|
101
|
+
const ctx = buildSessionContext(entries.filter(e => e.type !== "session"));
|
|
102
|
+
assert(ctx.messages.length === 4, `buildSessionContext also 4`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function test2() {
|
|
106
|
+
console.log("\n--- Test 2: One compaction → 2 windows ---");
|
|
107
|
+
const sm = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
108
|
+
sm.appendMessage(makeUser("W0-A"));
|
|
109
|
+
sm.appendMessage(makeAssistant("W0-A resp"));
|
|
110
|
+
sm.appendMessage(makeUser("W0-B"));
|
|
111
|
+
sm.appendMessage(makeAssistant("W0-B resp"));
|
|
112
|
+
sm.appendMessage(makeUser("W0-C (kept)"));
|
|
113
|
+
sm.appendMessage(makeAssistant("W0-C resp (kept)"));
|
|
114
|
+
|
|
115
|
+
const keptEntry = findEntryByText(sm, "W0-C (kept)");
|
|
116
|
+
assert(!!keptEntry, "found kept entry");
|
|
117
|
+
sm.appendCompaction("Summary: W0 discussed A, B, C", keptEntry.id, 50000, { readFiles: [], modifiedFiles: [] });
|
|
118
|
+
|
|
119
|
+
sm.appendMessage(makeUser("W1-A"));
|
|
120
|
+
sm.appendMessage(makeAssistant("W1-A resp"));
|
|
121
|
+
|
|
122
|
+
const entries = parseSessionEntries(readFileSync(sm.getSessionFile(), "utf-8"));
|
|
123
|
+
const windows = getCompactionWindows(entries);
|
|
124
|
+
|
|
125
|
+
assert(windows.length === 2, `2 windows (got ${windows.length})`);
|
|
126
|
+
assert(windows[0].messages.length === 6, `w0: 6 msgs (got ${windows[0].messages.length})`);
|
|
127
|
+
assert(windows[1].messages.length === 5, `w1: 5 msgs (got ${windows[1].messages.length})`);
|
|
128
|
+
assert(text(windows[1].messages[0]).includes("[SUMMARY]"), "w1 starts with summary");
|
|
129
|
+
|
|
130
|
+
// Cross-validate last window against buildSessionContext
|
|
131
|
+
const ctx = buildSessionContext(entries.filter(e => e.type !== "session"));
|
|
132
|
+
assert(ctx.messages.length === windows[1].messages.length,
|
|
133
|
+
`w1 matches buildSessionContext (${windows[1].messages.length} vs ${ctx.messages.length})`);
|
|
134
|
+
for (let i = 0; i < ctx.messages.length; i++) {
|
|
135
|
+
assert(text(ctx.messages[i]) === text(windows[1].messages[i]),
|
|
136
|
+
` msg[${i}] matches`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function test3() {
|
|
141
|
+
console.log("\n--- Test 3: Two compactions → 3 windows ---");
|
|
142
|
+
const sm = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
143
|
+
sm.appendMessage(makeUser("W0-A"));
|
|
144
|
+
sm.appendMessage(makeAssistant("W0-A resp"));
|
|
145
|
+
sm.appendMessage(makeUser("W0-B"));
|
|
146
|
+
sm.appendMessage(makeAssistant("W0-B resp"));
|
|
147
|
+
|
|
148
|
+
const w0Kept = findEntryByText(sm, "W0-B");
|
|
149
|
+
sm.appendCompaction("C1: covers W0", w0Kept.id, 40000, { readFiles: [], modifiedFiles: [] });
|
|
150
|
+
|
|
151
|
+
sm.appendMessage(makeUser("W1-A"));
|
|
152
|
+
sm.appendMessage(makeAssistant("W1-A resp"));
|
|
153
|
+
sm.appendMessage(makeUser("W1-B"));
|
|
154
|
+
sm.appendMessage(makeAssistant("W1-B resp"));
|
|
155
|
+
|
|
156
|
+
const w1Kept = findEntryByText(sm, "W1-B");
|
|
157
|
+
sm.appendCompaction("C2: covers C1+W1", w1Kept.id, 45000, { readFiles: [], modifiedFiles: [] });
|
|
158
|
+
|
|
159
|
+
sm.appendMessage(makeUser("W2-A"));
|
|
160
|
+
sm.appendMessage(makeAssistant("W2-A resp"));
|
|
161
|
+
|
|
162
|
+
const entries = parseSessionEntries(readFileSync(sm.getSessionFile(), "utf-8"));
|
|
163
|
+
const windows = getCompactionWindows(entries);
|
|
164
|
+
|
|
165
|
+
assert(windows.length === 3, `3 windows (got ${windows.length})`);
|
|
166
|
+
assert(windows[0].messages.length === 4, `w0: 4 msgs (got ${windows[0].messages.length})`);
|
|
167
|
+
assert(windows[1].messages.length === 7, `w1: 7 msgs (got ${windows[1].messages.length})`);
|
|
168
|
+
assert(windows[2].messages.length === 5, `w2: 5 msgs (got ${windows[2].messages.length})`);
|
|
169
|
+
|
|
170
|
+
// Cross-validate last window
|
|
171
|
+
const ctx = buildSessionContext(entries.filter(e => e.type !== "session"));
|
|
172
|
+
assert(ctx.messages.length === windows[2].messages.length,
|
|
173
|
+
`w2 matches buildSessionContext (${windows[2].messages.length} vs ${ctx.messages.length})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function test4() {
|
|
177
|
+
console.log("\n--- Test 4: Content isolation ---");
|
|
178
|
+
const sm = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
179
|
+
|
|
180
|
+
sm.appendMessage(makeUser("ALPHA-unique"));
|
|
181
|
+
sm.appendMessage(makeAssistant("ALPHA-resp"));
|
|
182
|
+
const alphaKept = findEntryByText(sm, "ALPHA-unique");
|
|
183
|
+
sm.appendCompaction("Summary: alpha", alphaKept.id, 30000);
|
|
184
|
+
|
|
185
|
+
sm.appendMessage(makeUser("BETA-unique"));
|
|
186
|
+
sm.appendMessage(makeAssistant("BETA-resp"));
|
|
187
|
+
const betaKept = findEntryByText(sm, "BETA-unique");
|
|
188
|
+
sm.appendCompaction("Summary: alpha+beta", betaKept.id, 35000);
|
|
189
|
+
|
|
190
|
+
sm.appendMessage(makeUser("GAMMA-unique"));
|
|
191
|
+
sm.appendMessage(makeAssistant("GAMMA-resp"));
|
|
192
|
+
|
|
193
|
+
const entries = parseSessionEntries(readFileSync(sm.getSessionFile(), "utf-8"));
|
|
194
|
+
const windows = getCompactionWindows(entries);
|
|
195
|
+
|
|
196
|
+
const w0 = windows[0].messages.map(text).join(" ");
|
|
197
|
+
const w1 = windows[1].messages.map(text).join(" ");
|
|
198
|
+
const w2 = windows[2].messages.map(text).join(" ");
|
|
199
|
+
|
|
200
|
+
assert(w0.includes("ALPHA") && !w0.includes("BETA") && !w0.includes("GAMMA"),
|
|
201
|
+
"w0: only ALPHA");
|
|
202
|
+
assert(w1.includes("BETA") && !w1.includes("GAMMA"),
|
|
203
|
+
"w1: BETA (+ alpha summary), no GAMMA");
|
|
204
|
+
assert(w2.includes("GAMMA") && w2.includes("BETA-unique"),
|
|
205
|
+
"w2: GAMMA + kept BETA (via firstKeptEntryId) + summary");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function test5() {
|
|
209
|
+
console.log("\n--- Test 5: JSONL structure dump ---");
|
|
210
|
+
const sm = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
211
|
+
sm.appendMessage(makeUser("A"));
|
|
212
|
+
sm.appendMessage(makeAssistant("A-r"));
|
|
213
|
+
const a = findEntryByText(sm, "A");
|
|
214
|
+
sm.appendCompaction("C1", a.id, 10000);
|
|
215
|
+
sm.appendMessage(makeUser("B"));
|
|
216
|
+
sm.appendMessage(makeAssistant("B-r"));
|
|
217
|
+
const b = findEntryByText(sm, "B");
|
|
218
|
+
sm.appendCompaction("C2", b.id, 15000);
|
|
219
|
+
sm.appendMessage(makeUser("C"));
|
|
220
|
+
sm.appendMessage(makeAssistant("C-r"));
|
|
221
|
+
|
|
222
|
+
const raw = readFileSync(sm.getSessionFile(), "utf-8").trim().split("\n");
|
|
223
|
+
console.log("\n Raw JSONL:");
|
|
224
|
+
for (const line of raw) {
|
|
225
|
+
const e = JSON.parse(line);
|
|
226
|
+
if (e.type === "session") console.log(` [session] id=${e.id.slice(0,8)}`);
|
|
227
|
+
else if (e.type === "message") console.log(` [msg] id=${e.id} "${text(e.message)}"`);
|
|
228
|
+
else if (e.type === "compaction") console.log(` [compact] id=${e.id} firstKept=${e.firstKeptEntryId} "${e.summary}"`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entries = parseSessionEntries(readFileSync(sm.getSessionFile(), "utf-8"));
|
|
232
|
+
const windows = getCompactionWindows(entries);
|
|
233
|
+
|
|
234
|
+
console.log(`\n Windows: ${windows.length}`);
|
|
235
|
+
for (const w of windows) {
|
|
236
|
+
console.log(` W${w.windowIndex}: ${w.messages.map(m => `${m.role}:"${text(m).slice(0,30)}"`).join(", ")}`);
|
|
237
|
+
}
|
|
238
|
+
assert(windows.length === 3, "3 windows");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function test6() {
|
|
242
|
+
console.log("\n--- Test 6: getDuncanTargets ---");
|
|
243
|
+
|
|
244
|
+
function getDuncanTargets(sessionFile) {
|
|
245
|
+
const content = readFileSync(sessionFile, "utf-8");
|
|
246
|
+
const entries = parseSessionEntries(content);
|
|
247
|
+
const windows = getCompactionWindows(entries);
|
|
248
|
+
return windows.map(w => ({ sessionFile, windowIndex: w.windowIndex, messages: w.messages }));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sm1 = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
252
|
+
sm1.appendMessage(makeUser("simple"));
|
|
253
|
+
sm1.appendMessage(makeAssistant("reply"));
|
|
254
|
+
const t1 = getDuncanTargets(sm1.getSessionFile());
|
|
255
|
+
assert(t1.length === 1, "uncompacted: 1 target");
|
|
256
|
+
|
|
257
|
+
const sm2 = new SessionManager(TEST_CWD, TEST_DIR, undefined, true);
|
|
258
|
+
sm2.appendMessage(makeUser("X"));
|
|
259
|
+
sm2.appendMessage(makeAssistant("X-r"));
|
|
260
|
+
sm2.appendCompaction("C1", findEntryByText(sm2, "X").id, 20000);
|
|
261
|
+
sm2.appendMessage(makeUser("Y"));
|
|
262
|
+
sm2.appendMessage(makeAssistant("Y-r"));
|
|
263
|
+
const t2 = getDuncanTargets(sm2.getSessionFile());
|
|
264
|
+
assert(t2.length === 2, "compacted: 2 targets");
|
|
265
|
+
assert(t2[0].windowIndex === 0 && t2[1].windowIndex === 1, "correct indices");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============================================================================
|
|
269
|
+
// Run
|
|
270
|
+
// ============================================================================
|
|
271
|
+
|
|
272
|
+
setup();
|
|
273
|
+
try {
|
|
274
|
+
test1();
|
|
275
|
+
test2();
|
|
276
|
+
test3();
|
|
277
|
+
test4();
|
|
278
|
+
test5();
|
|
279
|
+
test6();
|
|
280
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
281
|
+
if (failed > 0) process.exit(1);
|
|
282
|
+
console.log("✅ All tests passed\n");
|
|
283
|
+
} finally {
|
|
284
|
+
teardown();
|
|
285
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: lineage, session discovery, and pagination helpers.
|
|
3
|
+
*
|
|
4
|
+
* Run: tsx tests/lineage.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getAncestorChain,
|
|
9
|
+
getDescendantChain,
|
|
10
|
+
getProjectSessions,
|
|
11
|
+
getGlobalSessions,
|
|
12
|
+
getSessionPreview,
|
|
13
|
+
getDuncanTargets,
|
|
14
|
+
readSessionHeader,
|
|
15
|
+
buildSessionTree,
|
|
16
|
+
resolveGeneration,
|
|
17
|
+
} from "../extensions/duncan.ts";
|
|
18
|
+
|
|
19
|
+
const { SessionManager, parseSessionEntries } = await import("@mariozechner/pi-coding-agent");
|
|
20
|
+
|
|
21
|
+
import { readFileSync, mkdirSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join, basename, dirname } from "node:path";
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Test helpers
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
const TEST_ROOT = join("/tmp", "duncan-lineage-test");
|
|
29
|
+
|
|
30
|
+
let passed = 0;
|
|
31
|
+
let failed = 0;
|
|
32
|
+
|
|
33
|
+
function assert(condition, msg) {
|
|
34
|
+
if (!condition) {
|
|
35
|
+
console.error(` ✗ ${msg}`);
|
|
36
|
+
failed++;
|
|
37
|
+
} else {
|
|
38
|
+
console.log(` ✓ ${msg}`);
|
|
39
|
+
passed++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function setup() {
|
|
44
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
45
|
+
mkdirSync(TEST_ROOT, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function teardown() {
|
|
49
|
+
if (existsSync(TEST_ROOT)) rmSync(TEST_ROOT, { recursive: true });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeUser(t) {
|
|
53
|
+
return { role: "user", content: [{ type: "text", text: t }], timestamp: Date.now() };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeAssistant(t) {
|
|
57
|
+
return {
|
|
58
|
+
role: "assistant", content: [{ type: "text", text: t }],
|
|
59
|
+
provider: "test", model: "test-model", stopReason: "endTurn",
|
|
60
|
+
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150 },
|
|
61
|
+
timestamp: Date.now(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a session with messages, optionally as a child of another session file.
|
|
67
|
+
* Returns the session file path.
|
|
68
|
+
*/
|
|
69
|
+
function createSession(sessionDir, messages, parentSessionFile) {
|
|
70
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
71
|
+
const sm = new SessionManager("/workspace", sessionDir, undefined, true);
|
|
72
|
+
// If parentSessionFile specified, we need to manually write the header with parentSession
|
|
73
|
+
// since SessionManager doesn't expose this in constructor.
|
|
74
|
+
if (parentSessionFile) {
|
|
75
|
+
// Write messages first to get the file created
|
|
76
|
+
for (const msg of messages) sm.appendMessage(msg);
|
|
77
|
+
const file = sm.getSessionFile();
|
|
78
|
+
// Rewrite the header line to include parentSession
|
|
79
|
+
const content = readFileSync(file, "utf-8");
|
|
80
|
+
const lines = content.split("\n");
|
|
81
|
+
const header = JSON.parse(lines[0]);
|
|
82
|
+
header.parentSession = parentSessionFile;
|
|
83
|
+
lines[0] = JSON.stringify(header);
|
|
84
|
+
writeFileSync(file, lines.join("\n"));
|
|
85
|
+
return file;
|
|
86
|
+
}
|
|
87
|
+
for (const msg of messages) sm.appendMessage(msg);
|
|
88
|
+
return sm.getSessionFile();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Tests
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
function testReadSessionHeader() {
|
|
96
|
+
console.log("\n--- readSessionHeader ---");
|
|
97
|
+
const dir = join(TEST_ROOT, "header-test");
|
|
98
|
+
const file = createSession(dir, [makeUser("hello"), makeAssistant("world")]);
|
|
99
|
+
|
|
100
|
+
const header = readSessionHeader(file);
|
|
101
|
+
assert(header !== null, "header is not null");
|
|
102
|
+
assert(typeof header.id === "string" && header.id.length > 0, `has id: ${header.id.slice(0, 8)}`);
|
|
103
|
+
assert(typeof header.timestamp === "string", `has timestamp`);
|
|
104
|
+
assert(header.parentSession === undefined, "no parentSession on root session");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function testReadSessionHeaderWithParent() {
|
|
108
|
+
console.log("\n--- readSessionHeader with parentSession ---");
|
|
109
|
+
const dir = join(TEST_ROOT, "parent-header-test");
|
|
110
|
+
const parent = createSession(dir, [makeUser("parent msg"), makeAssistant("parent resp")]);
|
|
111
|
+
const child = createSession(dir, [makeUser("child msg"), makeAssistant("child resp")], parent);
|
|
112
|
+
|
|
113
|
+
const header = readSessionHeader(child);
|
|
114
|
+
assert(header !== null, "header is not null");
|
|
115
|
+
assert(header.parentSession === parent, `parentSession points to parent file`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function testBuildSessionTree() {
|
|
119
|
+
console.log("\n--- buildSessionTree ---");
|
|
120
|
+
const dir = join(TEST_ROOT, "tree-test");
|
|
121
|
+
|
|
122
|
+
const root = createSession(dir, [makeUser("root"), makeAssistant("root-r")]);
|
|
123
|
+
const child1 = createSession(dir, [makeUser("child1"), makeAssistant("child1-r")], root);
|
|
124
|
+
const child2 = createSession(dir, [makeUser("child2"), makeAssistant("child2-r")], root);
|
|
125
|
+
const grandchild = createSession(dir, [makeUser("grandchild"), makeAssistant("grandchild-r")], child1);
|
|
126
|
+
|
|
127
|
+
const nodes = buildSessionTree(dir);
|
|
128
|
+
assert(nodes.size === 4, `4 nodes (got ${nodes.size})`);
|
|
129
|
+
|
|
130
|
+
const rootNode = nodes.get(root);
|
|
131
|
+
assert(rootNode.children.length === 2, `root has 2 children`);
|
|
132
|
+
assert(rootNode.generation === 0, `root is gen 0`);
|
|
133
|
+
|
|
134
|
+
const child1Node = nodes.get(child1);
|
|
135
|
+
assert(child1Node.generation === 1, `child1 is gen 1`);
|
|
136
|
+
assert(child1Node.children.length === 1, `child1 has 1 child`);
|
|
137
|
+
|
|
138
|
+
const grandchildNode = nodes.get(grandchild);
|
|
139
|
+
assert(grandchildNode.generation === 2, `grandchild is gen 2`);
|
|
140
|
+
assert(grandchildNode.children.length === 0, `grandchild has 0 children`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function testAncestorChain() {
|
|
144
|
+
console.log("\n--- getAncestorChain ---");
|
|
145
|
+
const dir = join(TEST_ROOT, "ancestor-test");
|
|
146
|
+
|
|
147
|
+
const gen0 = createSession(dir, [makeUser("gen0"), makeAssistant("gen0-r")]);
|
|
148
|
+
const gen1 = createSession(dir, [makeUser("gen1"), makeAssistant("gen1-r")], gen0);
|
|
149
|
+
const gen2 = createSession(dir, [makeUser("gen2"), makeAssistant("gen2-r")], gen1);
|
|
150
|
+
|
|
151
|
+
const chain = getAncestorChain(gen2, dir);
|
|
152
|
+
assert(chain.length === 3, `3 ancestors (got ${chain.length})`);
|
|
153
|
+
assert(chain[0] === gen2, `chain[0] is self`);
|
|
154
|
+
assert(chain[1] === gen1, `chain[1] is parent`);
|
|
155
|
+
assert(chain[2] === gen0, `chain[2] is grandparent`);
|
|
156
|
+
|
|
157
|
+
// Root has only itself
|
|
158
|
+
const rootChain = getAncestorChain(gen0, dir);
|
|
159
|
+
assert(rootChain.length === 1, `root chain is 1 (self)`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function testDescendantChain() {
|
|
163
|
+
console.log("\n--- getDescendantChain ---");
|
|
164
|
+
const dir = join(TEST_ROOT, "descendant-test");
|
|
165
|
+
|
|
166
|
+
const root = createSession(dir, [makeUser("root"), makeAssistant("root-r")]);
|
|
167
|
+
const child1 = createSession(dir, [makeUser("child1"), makeAssistant("child1-r")], root);
|
|
168
|
+
const child2 = createSession(dir, [makeUser("child2"), makeAssistant("child2-r")], root);
|
|
169
|
+
const grandchild = createSession(dir, [makeUser("gc"), makeAssistant("gc-r")], child1);
|
|
170
|
+
|
|
171
|
+
const chain = getDescendantChain(root, dir);
|
|
172
|
+
assert(chain.length === 3, `3 descendants (got ${chain.length})`);
|
|
173
|
+
assert(!chain.includes(root), `excludes self`);
|
|
174
|
+
assert(chain.includes(child1), `includes child1`);
|
|
175
|
+
assert(chain.includes(child2), `includes child2`);
|
|
176
|
+
assert(chain.includes(grandchild), `includes grandchild`);
|
|
177
|
+
|
|
178
|
+
// BFS order: children before grandchildren
|
|
179
|
+
const child1Idx = chain.indexOf(child1);
|
|
180
|
+
const gcIdx = chain.indexOf(grandchild);
|
|
181
|
+
assert(child1Idx < gcIdx, `child1 before grandchild (BFS)`);
|
|
182
|
+
|
|
183
|
+
// Leaf has no descendants
|
|
184
|
+
const leafChain = getDescendantChain(grandchild, dir);
|
|
185
|
+
assert(leafChain.length === 0, `leaf has 0 descendants`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function testGetProjectSessions() {
|
|
189
|
+
console.log("\n--- getProjectSessions ---");
|
|
190
|
+
const dir = join(TEST_ROOT, "project-test");
|
|
191
|
+
|
|
192
|
+
const s1 = createSession(dir, [makeUser("first"), makeAssistant("first-r")]);
|
|
193
|
+
const s2 = createSession(dir, [makeUser("second"), makeAssistant("second-r")]);
|
|
194
|
+
const s3 = createSession(dir, [makeUser("third"), makeAssistant("third-r")]);
|
|
195
|
+
|
|
196
|
+
const sessions = getProjectSessions(dir);
|
|
197
|
+
assert(sessions.length === 3, `3 sessions (got ${sessions.length})`);
|
|
198
|
+
|
|
199
|
+
// All files present
|
|
200
|
+
const basenames = new Set(sessions.map(f => basename(f)));
|
|
201
|
+
assert(basenames.has(basename(s1)) && basenames.has(basename(s2)) && basenames.has(basename(s3)), `all sessions present`);
|
|
202
|
+
|
|
203
|
+
// Sorted by filename descending (newest first)
|
|
204
|
+
const sorted = [...sessions].sort((a, b) => basename(b).localeCompare(basename(a)));
|
|
205
|
+
assert(sessions.every((f, i) => f === sorted[i]), `sorted newest first`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function testGetGlobalSessions() {
|
|
209
|
+
console.log("\n--- getGlobalSessions ---");
|
|
210
|
+
// Global scans sibling directories under the parent of sessionDir
|
|
211
|
+
const sessionsRoot = join(TEST_ROOT, "sessions");
|
|
212
|
+
const projA = join(sessionsRoot, "--project-a--");
|
|
213
|
+
const projB = join(sessionsRoot, "--project-b--");
|
|
214
|
+
|
|
215
|
+
const a1 = createSession(projA, [makeUser("a1"), makeAssistant("a1-r")]);
|
|
216
|
+
const a2 = createSession(projA, [makeUser("a2"), makeAssistant("a2-r")]);
|
|
217
|
+
const b1 = createSession(projB, [makeUser("b1"), makeAssistant("b1-r")]);
|
|
218
|
+
|
|
219
|
+
// getGlobalSessions takes a sessionDir (one project) and scans its parent
|
|
220
|
+
const global = getGlobalSessions(projA);
|
|
221
|
+
assert(global.length === 3, `3 global sessions (got ${global.length})`);
|
|
222
|
+
|
|
223
|
+
// Should include sessions from both projects
|
|
224
|
+
const basenames = new Set(global.map(f => basename(f)));
|
|
225
|
+
assert(basenames.has(basename(a1)), `includes a1 from project A`);
|
|
226
|
+
assert(basenames.has(basename(b1)), `includes b1 from project B`);
|
|
227
|
+
|
|
228
|
+
// All sorted by filename descending (newest first)
|
|
229
|
+
const sorted = [...global].sort((a, b) => basename(b).localeCompare(basename(a)));
|
|
230
|
+
assert(global.every((f, i) => f === sorted[i]), `sorted newest first`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function testGetSessionPreview() {
|
|
234
|
+
console.log("\n--- getSessionPreview ---");
|
|
235
|
+
const dir = join(TEST_ROOT, "preview-test");
|
|
236
|
+
|
|
237
|
+
const file = createSession(dir, [
|
|
238
|
+
makeUser("What is the airspeed velocity of an unladen swallow?"),
|
|
239
|
+
makeAssistant("African or European?"),
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
const preview = getSessionPreview(file);
|
|
243
|
+
assert(preview.includes("airspeed velocity"), `preview has first user message: "${preview}"`);
|
|
244
|
+
|
|
245
|
+
// Long messages get truncated
|
|
246
|
+
const longFile = createSession(dir, [
|
|
247
|
+
makeUser("A".repeat(200)),
|
|
248
|
+
makeAssistant("ok"),
|
|
249
|
+
]);
|
|
250
|
+
const longPreview = getSessionPreview(longFile);
|
|
251
|
+
assert(longPreview.length <= 80, `long preview truncated (${longPreview.length} chars)`);
|
|
252
|
+
assert(longPreview.endsWith("..."), `truncated preview ends with ...`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function testResolveGeneration() {
|
|
256
|
+
console.log("\n--- resolveGeneration ---");
|
|
257
|
+
const dir = join(TEST_ROOT, "gen-test");
|
|
258
|
+
|
|
259
|
+
const gen0 = createSession(dir, [makeUser("gen0"), makeAssistant("gen0-r")]);
|
|
260
|
+
const gen1 = createSession(dir, [makeUser("gen1"), makeAssistant("gen1-r")], gen0);
|
|
261
|
+
const gen2 = createSession(dir, [makeUser("gen2"), makeAssistant("gen2-r")], gen1);
|
|
262
|
+
|
|
263
|
+
assert(resolveGeneration(gen0, dir) === 0, `gen0 = 0`);
|
|
264
|
+
assert(resolveGeneration(gen1, dir) === 1, `gen1 = 1`);
|
|
265
|
+
assert(resolveGeneration(gen2, dir) === 2, `gen2 = 2`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function testGetDuncanTargetsWithParent() {
|
|
269
|
+
console.log("\n--- getDuncanTargets across lineage ---");
|
|
270
|
+
const dir = join(TEST_ROOT, "targets-lineage");
|
|
271
|
+
|
|
272
|
+
const parent = createSession(dir, [
|
|
273
|
+
makeUser("parent-work"), makeAssistant("parent-done"),
|
|
274
|
+
]);
|
|
275
|
+
const child = createSession(dir, [
|
|
276
|
+
makeUser("child-work"), makeAssistant("child-done"),
|
|
277
|
+
], parent);
|
|
278
|
+
|
|
279
|
+
const parentTargets = getDuncanTargets(parent);
|
|
280
|
+
assert(parentTargets.length === 1, `parent: 1 window`);
|
|
281
|
+
assert(parentTargets[0].messages.length === 2, `parent window has 2 messages`);
|
|
282
|
+
|
|
283
|
+
const childTargets = getDuncanTargets(child);
|
|
284
|
+
assert(childTargets.length === 1, `child: 1 window`);
|
|
285
|
+
|
|
286
|
+
// Ancestor chain from child includes both
|
|
287
|
+
const chain = getAncestorChain(child, dir);
|
|
288
|
+
assert(chain.length === 2, `chain has 2 sessions`);
|
|
289
|
+
let totalWindows = 0;
|
|
290
|
+
for (const file of chain) totalWindows += getDuncanTargets(file).length;
|
|
291
|
+
assert(totalWindows === 2, `2 total windows across lineage`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============================================================================
|
|
295
|
+
// Run
|
|
296
|
+
// ============================================================================
|
|
297
|
+
|
|
298
|
+
setup();
|
|
299
|
+
try {
|
|
300
|
+
testReadSessionHeader();
|
|
301
|
+
testReadSessionHeaderWithParent();
|
|
302
|
+
testBuildSessionTree();
|
|
303
|
+
testAncestorChain();
|
|
304
|
+
testDescendantChain();
|
|
305
|
+
testGetProjectSessions();
|
|
306
|
+
testGetGlobalSessions();
|
|
307
|
+
testGetSessionPreview();
|
|
308
|
+
testResolveGeneration();
|
|
309
|
+
testGetDuncanTargetsWithParent();
|
|
310
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
311
|
+
if (failed > 0) process.exit(1);
|
|
312
|
+
console.log("✅ All tests passed\n");
|
|
313
|
+
} finally {
|
|
314
|
+
teardown();
|
|
315
|
+
}
|