@gswangg/duncan-cc 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/src/tree.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * CC Session Tree Operations
3
+ *
4
+ * Implements tree walk, preserved segment relinking, leaf detection,
5
+ * and post-processing.
6
+ *
7
+ * Equivalent to CC's relink + walk + post-process + strip + slice pipeline
8
+ */
9
+
10
+ import type { CCMessage, ParsedSession } from "./parser.js";
11
+ import { isCompactBoundary } from "./parser.js";
12
+
13
+ // ============================================================================
14
+ // Preserved Segment Relinking — CC's wHY()
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Relink preserved segments after compaction.
19
+ *
20
+ * When compaction preserves messages (messagesToKeep), the boundary marker
21
+ * stores a preservedSegment with headUuid/tailUuid/anchorUuid. This function
22
+ * relinks the tree so preserved messages are accessible via parentUuid walk.
23
+ *
24
+ * Mutates the messages Map in place.
25
+ */
26
+ export function relinkPreservedSegments(messages: Map<string, CCMessage>): void {
27
+ // Find the last compact boundary and its index in insertion order
28
+ let lastBoundary: CCMessage | undefined;
29
+ let lastBoundaryIndex = -1;
30
+ let preservedSegment: { headUuid: string; tailUuid: string; anchorUuid: string } | undefined;
31
+ let index = 0;
32
+
33
+ for (const entry of messages.values()) {
34
+ if (isCompactBoundary(entry)) {
35
+ lastBoundaryIndex = index;
36
+ const seg = entry.compactMetadata?.preservedSegment;
37
+ if (seg) {
38
+ preservedSegment = seg;
39
+ lastBoundary = entry;
40
+ }
41
+ }
42
+ index++;
43
+ }
44
+
45
+ if (!preservedSegment || !lastBoundary) return;
46
+
47
+ const totalEntries = messages.size;
48
+ const isLastBoundary = lastBoundaryIndex === totalEntries - 1 ||
49
+ // Check if this is the last boundary (no later boundaries exist)
50
+ (() => {
51
+ let idx = 0;
52
+ let lastBIdx = -1;
53
+ for (const entry of messages.values()) {
54
+ if (isCompactBoundary(entry)) lastBIdx = idx;
55
+ idx++;
56
+ }
57
+ return lastBIdx === lastBoundaryIndex;
58
+ })();
59
+
60
+ // Identify preserved segment by walking from tail to head
61
+ const preservedUuids = new Set<string>();
62
+ if (isLastBoundary) {
63
+ const visited = new Set<string>();
64
+ let current = messages.get(preservedSegment.tailUuid);
65
+ let foundHead = false;
66
+
67
+ while (current && !visited.has(current.uuid)) {
68
+ visited.add(current.uuid);
69
+ preservedUuids.add(current.uuid);
70
+ if (current.uuid === preservedSegment.headUuid) {
71
+ foundHead = true;
72
+ break;
73
+ }
74
+ current = current.parentUuid ? messages.get(current.parentUuid) : undefined;
75
+ }
76
+
77
+ if (!foundHead) {
78
+ // Walk broken — can't relink
79
+ return;
80
+ }
81
+ }
82
+
83
+ if (isLastBoundary) {
84
+ // Relink head: head.parentUuid = anchorUuid
85
+ const head = messages.get(preservedSegment.headUuid);
86
+ if (head) {
87
+ messages.set(preservedSegment.headUuid, {
88
+ ...head,
89
+ parentUuid: preservedSegment.anchorUuid,
90
+ });
91
+ }
92
+
93
+ // Relink followers: messages with parentUuid === anchorUuid (except head) → parentUuid = tailUuid
94
+ for (const [uuid, msg] of messages) {
95
+ if (msg.parentUuid === preservedSegment.anchorUuid && uuid !== preservedSegment.headUuid) {
96
+ messages.set(uuid, {
97
+ ...msg,
98
+ parentUuid: preservedSegment.tailUuid,
99
+ });
100
+ }
101
+ }
102
+
103
+ // Zero out usage for assistant messages in preserved segment
104
+ for (const uuid of preservedUuids) {
105
+ const msg = messages.get(uuid);
106
+ if (msg?.type !== "assistant") continue;
107
+ messages.set(uuid, {
108
+ ...msg,
109
+ message: {
110
+ ...msg.message,
111
+ usage: {
112
+ ...msg.message.usage,
113
+ input_tokens: 0,
114
+ output_tokens: 0,
115
+ cache_creation_input_tokens: 0,
116
+ cache_read_input_tokens: 0,
117
+ },
118
+ },
119
+ });
120
+ }
121
+ }
122
+
123
+ // Delete pre-boundary messages not in preserved segment
124
+ const toDelete: string[] = [];
125
+ let idx = 0;
126
+ for (const [uuid] of messages) {
127
+ if (idx < lastBoundaryIndex && !preservedUuids.has(uuid)) {
128
+ toDelete.push(uuid);
129
+ }
130
+ idx++;
131
+ }
132
+ for (const uuid of toDelete) {
133
+ messages.delete(uuid);
134
+ }
135
+ }
136
+
137
+ // ============================================================================
138
+ // Leaf Detection
139
+ // ============================================================================
140
+
141
+ /** Find all leaf messages (messages not referenced as parentUuid by any other) */
142
+ export function findLeaves(messages: Map<string, CCMessage>): CCMessage[] {
143
+ const referenced = new Set<string>();
144
+ for (const msg of messages.values()) {
145
+ if (msg.parentUuid) referenced.add(msg.parentUuid);
146
+ }
147
+ return [...messages.values()].filter((msg) => !referenced.has(msg.uuid));
148
+ }
149
+
150
+ /** Find the "best" leaf — the latest user or assistant message that's a leaf */
151
+ export function findBestLeaf(messages: Map<string, CCMessage>): CCMessage | undefined {
152
+ const leaves = findLeaves(messages);
153
+ let best: CCMessage | undefined;
154
+ let bestTime = -Infinity;
155
+
156
+ for (const leaf of leaves) {
157
+ // Walk up to find the first user/assistant message
158
+ const visited = new Set<string>();
159
+ let current: CCMessage | undefined = leaf;
160
+ while (current) {
161
+ if (visited.has(current.uuid)) break;
162
+ visited.add(current.uuid);
163
+ if (current.type === "user" || current.type === "assistant") {
164
+ const time = Date.parse(current.timestamp);
165
+ if (time > bestTime) {
166
+ bestTime = time;
167
+ best = current;
168
+ }
169
+ break;
170
+ }
171
+ current = current.parentUuid ? messages.get(current.parentUuid) : undefined;
172
+ }
173
+ }
174
+
175
+ return best;
176
+ }
177
+
178
+ // ============================================================================
179
+ // Tree Walk
180
+ // ============================================================================
181
+
182
+ /**
183
+ * Walk parentUuid chain from leaf to root, return root→leaf order.
184
+ * Walks the parentUuid chain from leaf to root with cycle detection.
185
+ */
186
+ export function walkChain(messages: Map<string, CCMessage>, leaf: CCMessage): CCMessage[] {
187
+ const chain: CCMessage[] = [];
188
+ const visited = new Set<string>();
189
+ let current: CCMessage | undefined = leaf;
190
+
191
+ while (current) {
192
+ if (visited.has(current.uuid)) {
193
+ // Cycle detected
194
+ break;
195
+ }
196
+ visited.add(current.uuid);
197
+ chain.push(current);
198
+ current = current.parentUuid ? messages.get(current.parentUuid) : undefined;
199
+ }
200
+
201
+ chain.reverse();
202
+ return postProcessChain(messages, chain, visited);
203
+ }
204
+
205
+ // ============================================================================
206
+ // Post-processing
207
+ // ============================================================================
208
+
209
+ /**
210
+ * Post-process the chain: handle split assistant messages and orphan tool results.
211
+ * Post-process: handle orphan tool results, deduplicate split assistant messages.
212
+ */
213
+ function postProcessChain(
214
+ messages: Map<string, CCMessage>,
215
+ chain: CCMessage[],
216
+ visited: Set<string>,
217
+ ): CCMessage[] {
218
+ // Find assistant messages with API response IDs on the chain
219
+ const assistants = chain.filter((m) => m.type === "assistant");
220
+ if (assistants.length === 0) return chain;
221
+
222
+ const byResponseId = new Map<string, CCMessage>();
223
+ for (const a of assistants) {
224
+ if (a.message.id) byResponseId.set(a.message.id, a);
225
+ }
226
+
227
+ // Find all assistant messages with same response IDs (potential splits)
228
+ const allByResponseId = new Map<string, CCMessage[]>();
229
+ const toolResultsByParent = new Map<string, CCMessage[]>();
230
+
231
+ for (const msg of messages.values()) {
232
+ if (msg.type === "assistant" && msg.message.id) {
233
+ const existing = allByResponseId.get(msg.message.id);
234
+ if (existing) existing.push(msg);
235
+ else allByResponseId.set(msg.message.id, [msg]);
236
+ } else if (
237
+ msg.type === "user" &&
238
+ msg.parentUuid &&
239
+ Array.isArray(msg.message.content) &&
240
+ msg.message.content.some((c: any) => c.type === "tool_result")
241
+ ) {
242
+ const existing = toolResultsByParent.get(msg.parentUuid);
243
+ if (existing) existing.push(msg);
244
+ else toolResultsByParent.set(msg.parentUuid, [msg]);
245
+ }
246
+ }
247
+
248
+ // For now, return chain as-is. Split merging and orphan tool result
249
+ // reattachment are edge cases we'll handle when we have test data for them.
250
+ // The core tree walk is correct.
251
+ return chain;
252
+ }
253
+
254
+ // ============================================================================
255
+ // Field Stripping — remove internal-only fields
256
+ // ============================================================================
257
+
258
+ /** Strip internal fields not needed by the API */
259
+ export function stripInternalFields(messages: CCMessage[]): CCMessage[] {
260
+ return messages.map((msg) => {
261
+ const { isSidechain, parentUuid, ...rest } = msg;
262
+ return rest as CCMessage;
263
+ });
264
+ }
265
+
266
+ // ============================================================================
267
+ // Boundary Slicing
268
+ // ============================================================================
269
+
270
+ /** Find last compact boundary index in array */
271
+ function findLastBoundaryIndex(messages: CCMessage[]): number {
272
+ for (let i = messages.length - 1; i >= 0; i--) {
273
+ if (isCompactBoundary(messages[i])) return i;
274
+ }
275
+ return -1;
276
+ }
277
+
278
+ /** Slice from last compact boundary onward */
279
+ export function sliceFromBoundary(messages: CCMessage[]): CCMessage[] {
280
+ const idx = findLastBoundaryIndex(messages);
281
+ if (idx === -1) return messages;
282
+ return messages.slice(idx);
283
+ }
284
+
285
+ // ============================================================================
286
+ // Compaction Windows
287
+ // ============================================================================
288
+
289
+ export interface CompactionWindow {
290
+ windowIndex: number;
291
+ messages: CCMessage[];
292
+ modelInfo?: { provider: string; modelId: string };
293
+ }
294
+
295
+ /**
296
+ * Split a session's message chain into compaction windows.
297
+ * Each window is independently queryable.
298
+ *
299
+ * For sessions with no compaction: single window with all messages.
300
+ * For sessions with N boundaries: N+1 windows.
301
+ */
302
+ export function getCompactionWindows(chain: CCMessage[]): CompactionWindow[] {
303
+ // Find all boundary indices
304
+ const boundaryIndices: number[] = [];
305
+ for (let i = 0; i < chain.length; i++) {
306
+ if (isCompactBoundary(chain[i])) {
307
+ boundaryIndices.push(i);
308
+ }
309
+ }
310
+
311
+ const resolveModel = (start: number, end: number): { provider: string; modelId: string } | undefined => {
312
+ let info: { provider: string; modelId: string } | undefined;
313
+ for (let i = start; i < end; i++) {
314
+ const msg = chain[i];
315
+ if (msg.type === "assistant" && msg.message?.model) {
316
+ info = { provider: "anthropic", modelId: msg.message.model };
317
+ }
318
+ }
319
+ return info;
320
+ };
321
+
322
+ // No boundaries — single window
323
+ if (boundaryIndices.length === 0) {
324
+ const modelInfo = resolveModel(0, chain.length);
325
+ return chain.length > 0 ? [{ windowIndex: 0, messages: chain, modelInfo }] : [];
326
+ }
327
+
328
+ const windows: CompactionWindow[] = [];
329
+
330
+ // Window 0: messages before first boundary
331
+ const w0 = chain.slice(0, boundaryIndices[0]);
332
+ if (w0.length > 0) {
333
+ windows.push({ windowIndex: 0, messages: w0, modelInfo: resolveModel(0, boundaryIndices[0]) });
334
+ }
335
+
336
+ // Windows 1..N: boundary + messages until next boundary
337
+ for (let k = 0; k < boundaryIndices.length; k++) {
338
+ const start = boundaryIndices[k];
339
+ const end = k + 1 < boundaryIndices.length ? boundaryIndices[k + 1] : chain.length;
340
+ const windowMessages = chain.slice(start, end);
341
+ if (windowMessages.length > 0) {
342
+ windows.push({
343
+ windowIndex: k + 1,
344
+ messages: windowMessages,
345
+ modelInfo: resolveModel(0, end),
346
+ });
347
+ }
348
+ }
349
+
350
+ return windows;
351
+ }
352
+
353
+ // ============================================================================
354
+ // High-level: Build session context from file content
355
+ // ============================================================================
356
+
357
+ /**
358
+ * Full pipeline: parse → relink → find leaf → walk → return chain.
359
+ * Returns the raw chain (before normalization).
360
+ */
361
+ export function buildRawChain(parsed: ParsedSession): CCMessage[] {
362
+ // Step 1: Relink preserved segments (mutates the map)
363
+ relinkPreservedSegments(parsed.messages);
364
+
365
+ // Step 2: Find the best leaf
366
+ const leaf = findBestLeaf(parsed.messages);
367
+ if (!leaf) return [];
368
+
369
+ // Step 3: Walk the chain
370
+ return walkChain(parsed.messages, leaf);
371
+ }
@@ -0,0 +1,12 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const TESTDATA = join(import.meta.dirname, "..", "testdata", "projects");
5
+
6
+ export function requireCorpus(): string {
7
+ if (!existsSync(TESTDATA)) {
8
+ console.log("⊘ skipped (no testdata/ — corpus tests require session data)");
9
+ process.exit(0);
10
+ }
11
+ return TESTDATA;
12
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Synthetic tests for compaction boundaries, preserved segments, and windowing.
3
+ * Tests wHY relinking, vk slicing, and getCompactionWindows.
4
+ */
5
+
6
+ import { parseSession } from "../src/parser.js";
7
+ import { relinkPreservedSegments, findBestLeaf, walkChain, sliceFromBoundary, getCompactionWindows } from "../src/tree.js";
8
+
9
+ let passed = 0;
10
+ let failed = 0;
11
+ function assert(c: boolean, m: string) { if (c) passed++; else { failed++; console.error(` ✗ ${m}`); } }
12
+ function ok(m: string) { passed++; console.log(` ✓ ${m}`); }
13
+
14
+ // Helper: build a JSONL session string
15
+ function buildSession(entries: any[]): string {
16
+ return entries.map(e => JSON.stringify(e)).join("\n");
17
+ }
18
+
19
+ let uuid = 0;
20
+ function id() { return `uuid-${++uuid}`; }
21
+
22
+ function userMsg(uid: string, parent: string | null, text: string) {
23
+ return { type: "user", uuid: uid, parentUuid: parent, timestamp: new Date().toISOString(), message: { role: "user", content: text } };
24
+ }
25
+
26
+ function assistantMsg(uid: string, parent: string, text: string) {
27
+ return { type: "assistant", uuid: uid, parentUuid: parent, timestamp: new Date().toISOString(), message: { role: "assistant", content: [{ type: "text", text }], model: "claude-sonnet-4" } };
28
+ }
29
+
30
+ function compactBoundary(uid: string, parent: string, preserved?: { headUuid: string; tailUuid: string; anchorUuid: string }) {
31
+ return {
32
+ type: "system", subtype: "compact_boundary", uuid: uid, parentUuid: parent,
33
+ timestamp: new Date().toISOString(),
34
+ message: { role: "system", content: "Compact boundary" },
35
+ ...(preserved ? { compactMetadata: { preservedSegment: preserved } } : {}),
36
+ };
37
+ }
38
+
39
+ function summaryEntry(leafUuid: string, text: string) {
40
+ return { type: "summary", leafUuid, summary: text };
41
+ }
42
+
43
+ // ============================================================================
44
+
45
+ console.log("\n--- No compaction: single window ---");
46
+ {
47
+ const u1 = id(), a1 = id(), u2 = id(), a2 = id();
48
+ const content = buildSession([
49
+ userMsg(u1, null, "hello"),
50
+ assistantMsg(a1, u1, "hi"),
51
+ userMsg(u2, a1, "how are you"),
52
+ assistantMsg(a2, u2, "good"),
53
+ ]);
54
+ const parsed = parseSession(content);
55
+ const leaf = findBestLeaf(parsed.messages)!;
56
+ const chain = walkChain(parsed.messages, leaf);
57
+ const windows = getCompactionWindows(chain);
58
+
59
+ assert(windows.length === 1, "1 window");
60
+ assert(windows[0].messages.length === 4, "4 messages");
61
+ ok("no compaction: single window with all messages");
62
+ }
63
+
64
+ console.log("\n--- One boundary, no preserved segment ---");
65
+ {
66
+ const u1 = id(), a1 = id(), b = id(), u2 = id(), a2 = id();
67
+ const content = buildSession([
68
+ userMsg(u1, null, "old message"),
69
+ assistantMsg(a1, u1, "old reply"),
70
+ compactBoundary(b, a1),
71
+ summaryEntry(b, "Summary of old conversation"),
72
+ userMsg(u2, b, "new message"),
73
+ assistantMsg(a2, u2, "new reply"),
74
+ ]);
75
+ const parsed = parseSession(content);
76
+ const leaf = findBestLeaf(parsed.messages)!;
77
+ const chain = walkChain(parsed.messages, leaf);
78
+
79
+ // vk slicing: should start from boundary
80
+ const sliced = sliceFromBoundary(chain);
81
+ assert(sliced[0].uuid === b, "sliced starts at boundary");
82
+ assert(sliced.length === 3, "boundary + 2 new messages");
83
+
84
+ const windows = getCompactionWindows(chain);
85
+ assert(windows.length === 2, "2 windows");
86
+ assert(windows[0].messages.length === 2, "window 0: old messages");
87
+ assert(windows[1].messages.length === 3, "window 1: boundary + new messages");
88
+ ok("one boundary: correct windowing");
89
+ }
90
+
91
+ console.log("\n--- One boundary with preserved segment ---");
92
+ {
93
+ const u1 = id(), a1 = id(), u2 = id(), a2 = id();
94
+ const b = id(), u3 = id(), a3 = id();
95
+
96
+ // u1 → a1 → u2 → a2 → boundary(preserves u2,a2) → u3 → a3
97
+ const content = buildSession([
98
+ userMsg(u1, null, "very old"),
99
+ assistantMsg(a1, u1, "very old reply"),
100
+ userMsg(u2, a1, "kept message"),
101
+ assistantMsg(a2, u2, "kept reply"),
102
+ compactBoundary(b, a2, { headUuid: u2, tailUuid: a2, anchorUuid: b }),
103
+ userMsg(u3, b, "new message"),
104
+ assistantMsg(a3, u3, "new reply"),
105
+ ]);
106
+ const parsed = parseSession(content);
107
+
108
+ // Before relinking
109
+ assert(parsed.messages.has(u1), "u1 exists before relink");
110
+
111
+ // Relink
112
+ relinkPreservedSegments(parsed.messages);
113
+
114
+ // After relinking: u1 and a1 should be deleted (pre-boundary, not preserved)
115
+ assert(!parsed.messages.has(u1), "u1 deleted after relink");
116
+ assert(!parsed.messages.has(a1), "a1 deleted after relink");
117
+
118
+ // Preserved messages should still exist
119
+ assert(parsed.messages.has(u2), "u2 preserved");
120
+ assert(parsed.messages.has(a2), "a2 preserved");
121
+
122
+ // u2 should now point to boundary as parent (relinked)
123
+ const u2msg = parsed.messages.get(u2)!;
124
+ assert(u2msg.parentUuid === b, "u2.parentUuid = boundary (relinked)");
125
+
126
+ // u3 should point to a2 (tail of preserved segment)
127
+ const u3msg = parsed.messages.get(u3)!;
128
+ assert(u3msg.parentUuid === a2, "u3.parentUuid = a2 (relinked to tail)");
129
+
130
+ // Walk should work: boundary → u2 → a2 → u3 → a3
131
+ // Wait, the walk goes from leaf (a3) up: a3 → u3 → a2 → u2 → boundary
132
+ const leaf = findBestLeaf(parsed.messages)!;
133
+ const chain = walkChain(parsed.messages, leaf);
134
+
135
+ assert(chain.length === 5, `chain length 5 (got ${chain.length})`);
136
+ assert(chain[0].uuid === b, "chain starts at boundary");
137
+ assert(chain[1].uuid === u2, "chain[1] = preserved u2");
138
+ assert(chain[2].uuid === a2, "chain[2] = preserved a2");
139
+ assert(chain[3].uuid === u3, "chain[3] = new u3");
140
+ assert(chain[4].uuid === a3, "chain[4] = new a3");
141
+
142
+ ok("preserved segment: relinked correctly, walk correct");
143
+ }
144
+
145
+ console.log("\n--- Two boundaries: three windows ---");
146
+ {
147
+ const u1 = id(), a1 = id(), b1 = id();
148
+ const u2 = id(), a2 = id(), b2 = id();
149
+ const u3 = id(), a3 = id();
150
+
151
+ const content = buildSession([
152
+ userMsg(u1, null, "first"),
153
+ assistantMsg(a1, u1, "first reply"),
154
+ compactBoundary(b1, a1),
155
+ userMsg(u2, b1, "second"),
156
+ assistantMsg(a2, u2, "second reply"),
157
+ compactBoundary(b2, a2),
158
+ userMsg(u3, b2, "third"),
159
+ assistantMsg(a3, u3, "third reply"),
160
+ ]);
161
+ const parsed = parseSession(content);
162
+ const leaf = findBestLeaf(parsed.messages)!;
163
+ const chain = walkChain(parsed.messages, leaf);
164
+
165
+ const windows = getCompactionWindows(chain);
166
+ assert(windows.length === 3, `3 windows (got ${windows.length})`);
167
+ assert(windows[0].messages.length === 2, "window 0: 2 msgs (u1, a1)");
168
+ assert(windows[1].messages.length === 3, "window 1: 3 msgs (b1, u2, a2)");
169
+ assert(windows[2].messages.length === 3, "window 2: 3 msgs (b2, u3, a3)");
170
+
171
+ // vk should slice from last boundary
172
+ const sliced = sliceFromBoundary(chain);
173
+ assert(sliced[0].uuid === b2, "sliced from last boundary");
174
+ assert(sliced.length === 3, "3 messages after last boundary");
175
+
176
+ ok("two boundaries: three windows, correct slicing");
177
+ }
178
+
179
+ console.log("\n--- Model extraction per window ---");
180
+ {
181
+ const u1 = id(), a1 = id(), b1 = id(), u2 = id(), a2 = id();
182
+
183
+ // a1 uses opus, a2 uses sonnet
184
+ const content = buildSession([
185
+ userMsg(u1, null, "hello"),
186
+ { ...assistantMsg(a1, u1, "hi"), message: { role: "assistant", content: [{ type: "text", text: "hi" }], model: "claude-opus-4-6" } },
187
+ compactBoundary(b1, a1),
188
+ userMsg(u2, b1, "hello again"),
189
+ { ...assistantMsg(a2, u2, "hi again"), message: { role: "assistant", content: [{ type: "text", text: "hi again" }], model: "claude-sonnet-4-20250514" } },
190
+ ]);
191
+ const parsed = parseSession(content);
192
+ const leaf = findBestLeaf(parsed.messages)!;
193
+ const chain = walkChain(parsed.messages, leaf);
194
+ const windows = getCompactionWindows(chain);
195
+
196
+ assert(windows[0].modelInfo?.modelId === "claude-opus-4-6", "window 0: opus");
197
+ assert(windows[1].modelInfo?.modelId === "claude-sonnet-4-20250514", "window 1: sonnet");
198
+ ok("model extraction per window");
199
+ }
200
+
201
+ // ============================================================================
202
+
203
+ console.log(`\n${passed} passed, ${failed} failed`);
204
+ if (failed > 0) { console.log("❌ Some tests failed"); process.exit(1); }
205
+ else console.log("✅ All tests passed");