@askjo/pi-reflect 1.0.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.
@@ -0,0 +1,484 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import {
6
+ extractTranscript,
7
+ formatSessionTranscript,
8
+ collectTranscripts,
9
+ type SessionExchange,
10
+ } from "../extensions/reflect.js";
11
+ import { makeTempDir, cleanup, buildSessionJsonl, createSessionFixture } from "./helpers.js";
12
+
13
+ let tmpDir: string;
14
+
15
+ beforeEach(() => {
16
+ tmpDir = makeTempDir();
17
+ });
18
+
19
+ afterEach(() => {
20
+ cleanup(tmpDir);
21
+ });
22
+
23
+ describe("extractTranscript", () => {
24
+ it("extracts user and assistant text from JSONL", async () => {
25
+ const jsonl = buildSessionJsonl([
26
+ { role: "user", text: "Hello" },
27
+ { role: "assistant", text: "Hi there" },
28
+ ]);
29
+ const fp = path.join(tmpDir, "session.jsonl");
30
+ fs.writeFileSync(fp, jsonl);
31
+
32
+ const result = await extractTranscript(fp);
33
+ assert.equal(result.length, 2);
34
+ assert.equal(result[0].role, "user");
35
+ assert.equal(result[0].text, "Hello");
36
+ assert.equal(result[1].role, "assistant");
37
+ assert.equal(result[1].text, "Hi there");
38
+ });
39
+
40
+ it("extracts thinking tokens from assistant messages", async () => {
41
+ const jsonl = buildSessionJsonl([
42
+ { role: "user", text: "Explain X" },
43
+ { role: "assistant", text: "Here is the answer", thinking: "Let me think about this..." },
44
+ ]);
45
+ const fp = path.join(tmpDir, "session.jsonl");
46
+ fs.writeFileSync(fp, jsonl);
47
+
48
+ const result = await extractTranscript(fp);
49
+ assert.equal(result.length, 2);
50
+ assert.equal(result[1].thinking, "Let me think about this...");
51
+ assert.equal(result[1].text, "Here is the answer");
52
+ });
53
+
54
+ it("skips non-message entries", async () => {
55
+ const lines = [
56
+ JSON.stringify({ type: "system", data: "ignored" }),
57
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "Hello" }] } }),
58
+ JSON.stringify({ type: "tool_call", tool: "bash" }),
59
+ JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "Hi" }] } }),
60
+ ];
61
+ const fp = path.join(tmpDir, "session.jsonl");
62
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
63
+
64
+ const result = await extractTranscript(fp);
65
+ assert.equal(result.length, 2);
66
+ });
67
+
68
+ it("skips system and tool roles", async () => {
69
+ const lines = [
70
+ JSON.stringify({ type: "message", message: { role: "system", content: [{ type: "text", text: "System prompt" }] } }),
71
+ JSON.stringify({ type: "message", message: { role: "tool", content: [{ type: "text", text: "Tool result" }] } }),
72
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "User msg" }] } }),
73
+ ];
74
+ const fp = path.join(tmpDir, "session.jsonl");
75
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
76
+
77
+ const result = await extractTranscript(fp);
78
+ assert.equal(result.length, 1);
79
+ assert.equal(result[0].role, "user");
80
+ });
81
+
82
+ it("skips messages with empty/whitespace-only text", async () => {
83
+ const lines = [
84
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: " " }] } }),
85
+ JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text: "" }] } }),
86
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "Real message" }] } }),
87
+ ];
88
+ const fp = path.join(tmpDir, "session.jsonl");
89
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
90
+
91
+ const result = await extractTranscript(fp);
92
+ assert.equal(result.length, 1);
93
+ assert.equal(result[0].text, "Real message");
94
+ });
95
+
96
+ it("handles malformed JSON lines gracefully", async () => {
97
+ const lines = [
98
+ "not json at all",
99
+ "{invalid: json}",
100
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "Valid" }] } }),
101
+ "",
102
+ "{{{{",
103
+ ];
104
+ const fp = path.join(tmpDir, "session.jsonl");
105
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
106
+
107
+ const result = await extractTranscript(fp);
108
+ assert.equal(result.length, 1);
109
+ assert.equal(result[0].text, "Valid");
110
+ });
111
+
112
+ it("handles messages with non-array content", async () => {
113
+ const lines = [
114
+ JSON.stringify({ type: "message", message: { role: "user", content: "just a string" } }),
115
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "Array content" }] } }),
116
+ ];
117
+ const fp = path.join(tmpDir, "session.jsonl");
118
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
119
+
120
+ const result = await extractTranscript(fp);
121
+ assert.equal(result.length, 1);
122
+ assert.equal(result[0].text, "Array content");
123
+ });
124
+
125
+ it("handles missing message field", async () => {
126
+ const lines = [
127
+ JSON.stringify({ type: "message" }), // no message field
128
+ JSON.stringify({ type: "message", message: null }),
129
+ JSON.stringify({ type: "message", message: { role: "user", content: [{ type: "text", text: "OK" }] } }),
130
+ ];
131
+ const fp = path.join(tmpDir, "session.jsonl");
132
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
133
+
134
+ const result = await extractTranscript(fp);
135
+ assert.equal(result.length, 1);
136
+ });
137
+
138
+ it("returns empty array for nonexistent file", async () => {
139
+ const result = await extractTranscript("/nonexistent/path/file.jsonl");
140
+ assert.deepEqual(result, []);
141
+ });
142
+
143
+ it("joins multiple text parts in same message", async () => {
144
+ const lines = [
145
+ JSON.stringify({
146
+ type: "message",
147
+ message: {
148
+ role: "user",
149
+ content: [
150
+ { type: "text", text: "Part one" },
151
+ { type: "text", text: "Part two" },
152
+ ],
153
+ },
154
+ }),
155
+ ];
156
+ const fp = path.join(tmpDir, "session.jsonl");
157
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
158
+
159
+ const result = await extractTranscript(fp);
160
+ assert.equal(result.length, 1);
161
+ assert.equal(result[0].text, "Part one\nPart two");
162
+ });
163
+
164
+ it("handles thinking-only assistant messages (no text)", async () => {
165
+ const jsonl = buildSessionJsonl([
166
+ { role: "assistant", thinking: "Just thinking, no text output" },
167
+ ]);
168
+ const fp = path.join(tmpDir, "session.jsonl");
169
+ fs.writeFileSync(fp, jsonl);
170
+
171
+ const result = await extractTranscript(fp);
172
+ assert.equal(result.length, 1);
173
+ assert.equal(result[0].text, null);
174
+ assert.equal(result[0].thinking, "Just thinking, no text output");
175
+ });
176
+
177
+ it("skips content parts that are null or non-objects", async () => {
178
+ const lines = [
179
+ JSON.stringify({
180
+ type: "message",
181
+ message: {
182
+ role: "user",
183
+ content: [
184
+ null,
185
+ 42,
186
+ "string",
187
+ { type: "text", text: "Valid part" },
188
+ ],
189
+ },
190
+ }),
191
+ ];
192
+ const fp = path.join(tmpDir, "session.jsonl");
193
+ fs.writeFileSync(fp, lines.join("\n") + "\n");
194
+
195
+ const result = await extractTranscript(fp);
196
+ assert.equal(result.length, 1);
197
+ assert.equal(result[0].text, "Valid part");
198
+ });
199
+ });
200
+
201
+ describe("formatSessionTranscript", () => {
202
+ it("formats user and assistant exchanges", () => {
203
+ const exchanges: SessionExchange[] = [
204
+ { role: "user", text: "Hello", thinking: null },
205
+ { role: "assistant", text: "Hi there", thinking: null },
206
+ ];
207
+ const result = formatSessionTranscript(exchanges, "2026-02-12 03:00", "myproject");
208
+ assert.ok(result.includes("### Session: myproject [2026-02-12 03:00]"));
209
+ assert.ok(result.includes("**USER:** Hello"));
210
+ assert.ok(result.includes("**AGENT:** Hi there"));
211
+ });
212
+
213
+ it("includes thinking tokens", () => {
214
+ const exchanges: SessionExchange[] = [
215
+ { role: "assistant", text: "Answer", thinking: "Deep thought" },
216
+ ];
217
+ const result = formatSessionTranscript(exchanges, "test", "proj");
218
+ assert.ok(result.includes("**THINKING:** Deep thought"));
219
+ assert.ok(result.includes("**AGENT:** Answer"));
220
+ });
221
+
222
+ it("truncates long assistant text", () => {
223
+ const longText = "x".repeat(3000);
224
+ const exchanges: SessionExchange[] = [
225
+ { role: "assistant", text: longText, thinking: null },
226
+ ];
227
+ const result = formatSessionTranscript(exchanges, "test", "proj");
228
+ assert.ok(result.includes("[...truncated"));
229
+ assert.ok(result.length < longText.length);
230
+ });
231
+
232
+ it("truncates long thinking text", () => {
233
+ const longThinking = "t".repeat(2000);
234
+ const exchanges: SessionExchange[] = [
235
+ { role: "assistant", text: "Short answer", thinking: longThinking },
236
+ ];
237
+ const result = formatSessionTranscript(exchanges, "test", "proj");
238
+ assert.ok(result.includes("[...truncated"));
239
+ });
240
+
241
+ it("handles empty exchanges array", () => {
242
+ const result = formatSessionTranscript([], "test", "proj");
243
+ assert.ok(result.includes("### Session: proj [test]"));
244
+ });
245
+ });
246
+
247
+ describe("collectTranscripts", () => {
248
+ it("finds sessions from yesterday in a sessions directory", async () => {
249
+ const yesterday = new Date();
250
+ yesterday.setDate(yesterday.getDate() - 1);
251
+ const dateStr = yesterday.toISOString().slice(0, 10);
252
+ const fileName = `${dateStr}T03:00:00.000Z.jsonl`;
253
+
254
+ const sessionsDir = createSessionFixture(tmpDir, [{
255
+ projectDirName: "test-project",
256
+ fileName,
257
+ exchanges: [
258
+ { role: "user", text: "Fix the bug" },
259
+ { role: "assistant", text: "Looking at it now" },
260
+ { role: "user", text: "No, wrong file" },
261
+ { role: "assistant", text: "Let me check again" },
262
+ ],
263
+ }]);
264
+
265
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
266
+ assert.equal(result.sessionCount, 1);
267
+ assert.equal(result.includedCount, 1);
268
+ assert.ok(result.transcripts.includes("Fix the bug"));
269
+ assert.ok(result.transcripts.includes("wrong file"));
270
+ });
271
+
272
+ it("ignores sessions from wrong dates", async () => {
273
+ // Create a session from 10 days ago — should be ignored with lookbackDays=1
274
+ const oldDate = new Date();
275
+ oldDate.setDate(oldDate.getDate() - 10);
276
+ const dateStr = oldDate.toISOString().slice(0, 10);
277
+ const fileName = `${dateStr}T03:00:00.000Z.jsonl`;
278
+
279
+ const sessionsDir = createSessionFixture(tmpDir, [{
280
+ projectDirName: "test-project",
281
+ fileName,
282
+ exchanges: [
283
+ { role: "user", text: "Old message" },
284
+ { role: "assistant", text: "Old reply" },
285
+ { role: "user", text: "Old followup" },
286
+ { role: "assistant", text: "Old reply 2" },
287
+ ],
288
+ }]);
289
+
290
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
291
+ assert.equal(result.includedCount, 0);
292
+ });
293
+
294
+ it("skips sessions with fewer than 3 exchanges", async () => {
295
+ const yesterday = new Date();
296
+ yesterday.setDate(yesterday.getDate() - 1);
297
+ const dateStr = yesterday.toISOString().slice(0, 10);
298
+
299
+ const sessionsDir = createSessionFixture(tmpDir, [{
300
+ projectDirName: "test-project",
301
+ fileName: `${dateStr}T03:00:00.000Z.jsonl`,
302
+ exchanges: [
303
+ { role: "user", text: "Hi" },
304
+ { role: "assistant", text: "Hello" },
305
+ ],
306
+ }]);
307
+
308
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
309
+ // Scanned but not included
310
+ assert.equal(result.sessionCount, 1);
311
+ assert.equal(result.includedCount, 0);
312
+ });
313
+
314
+ it("skips sessions with 0 user messages", async () => {
315
+ const yesterday = new Date();
316
+ yesterday.setDate(yesterday.getDate() - 1);
317
+ const dateStr = yesterday.toISOString().slice(0, 10);
318
+
319
+ const sessionsDir = createSessionFixture(tmpDir, [{
320
+ projectDirName: "test-project",
321
+ fileName: `${dateStr}T03:00:00.000Z.jsonl`,
322
+ exchanges: [
323
+ { role: "assistant", text: "I am talking to myself" },
324
+ { role: "assistant", text: "Still talking" },
325
+ { role: "assistant", text: "Echo echo" },
326
+ { role: "assistant", text: "Four messages, no user" },
327
+ ],
328
+ }]);
329
+
330
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
331
+ assert.equal(result.includedCount, 0);
332
+ });
333
+
334
+ it("respects maxBytes budget", async () => {
335
+ const yesterday = new Date();
336
+ yesterday.setDate(yesterday.getDate() - 1);
337
+ const dateStr = yesterday.toISOString().slice(0, 10);
338
+
339
+ // Create two sessions
340
+ const sessionsDir = createSessionFixture(tmpDir, [
341
+ {
342
+ projectDirName: "project-a",
343
+ fileName: `${dateStr}T03:00:00.000Z.jsonl`,
344
+ exchanges: [
345
+ { role: "user", text: "A".repeat(500) },
346
+ { role: "assistant", text: "B".repeat(500) },
347
+ { role: "user", text: "C".repeat(500) },
348
+ { role: "assistant", text: "D".repeat(500) },
349
+ ],
350
+ },
351
+ {
352
+ projectDirName: "project-b",
353
+ fileName: `${dateStr}T04:00:00.000Z.jsonl`,
354
+ exchanges: [
355
+ { role: "user", text: "E".repeat(500) },
356
+ { role: "assistant", text: "F".repeat(500) },
357
+ { role: "user", text: "G".repeat(500) },
358
+ { role: "assistant", text: "H".repeat(500) },
359
+ ],
360
+ },
361
+ ]);
362
+
363
+ // Very small budget — should only include one session
364
+ const result = await collectTranscripts(1, 500, sessionsDir);
365
+ assert.ok(result.includedCount <= 1, `Expected at most 1 included, got ${result.includedCount}`);
366
+ });
367
+
368
+ it("skips var-folders directories", async () => {
369
+ const yesterday = new Date();
370
+ yesterday.setDate(yesterday.getDate() - 1);
371
+ const dateStr = yesterday.toISOString().slice(0, 10);
372
+
373
+ const sessionsDir = path.join(tmpDir, "sessions");
374
+ const varDir = path.join(sessionsDir, "var-folders-something");
375
+ fs.mkdirSync(varDir, { recursive: true });
376
+ fs.writeFileSync(
377
+ path.join(varDir, `${dateStr}T03:00:00.000Z.jsonl`),
378
+ buildSessionJsonl([
379
+ { role: "user", text: "Should be skipped" },
380
+ { role: "assistant", text: "Yep" },
381
+ { role: "user", text: "Definitely" },
382
+ { role: "assistant", text: "Agreed" },
383
+ ]),
384
+ );
385
+
386
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
387
+ assert.equal(result.includedCount, 0);
388
+ });
389
+
390
+ it("returns empty for nonexistent sessions directory", async () => {
391
+ const result = await collectTranscripts(1, 1024 * 1024, "/nonexistent/sessions");
392
+ assert.equal(result.transcripts, "");
393
+ assert.equal(result.sessionCount, 0);
394
+ assert.equal(result.includedCount, 0);
395
+ });
396
+
397
+ it("prioritizes sessions by interaction density", async () => {
398
+ const yesterday = new Date();
399
+ yesterday.setDate(yesterday.getDate() - 1);
400
+ const dateStr = yesterday.toISOString().slice(0, 10);
401
+
402
+ const sessionsDir = createSessionFixture(tmpDir, [
403
+ {
404
+ // Low density: 1 user message, 5 total exchanges
405
+ projectDirName: "low-density",
406
+ fileName: `${dateStr}T01:00:00.000Z.jsonl`,
407
+ exchanges: [
408
+ { role: "user", text: "LOW_DENSITY_MARKER" },
409
+ { role: "assistant", text: "Response 1" },
410
+ { role: "assistant", text: "Response 2" },
411
+ { role: "assistant", text: "Response 3" },
412
+ { role: "assistant", text: "Response 4" },
413
+ ],
414
+ },
415
+ {
416
+ // High density: 3 user messages, 6 total exchanges (50% user)
417
+ projectDirName: "high-density",
418
+ fileName: `${dateStr}T02:00:00.000Z.jsonl`,
419
+ exchanges: [
420
+ { role: "user", text: "HIGH_DENSITY_MARKER" },
421
+ { role: "assistant", text: "Reply 1" },
422
+ { role: "user", text: "No, wrong" },
423
+ { role: "assistant", text: "Reply 2" },
424
+ { role: "user", text: "Still wrong" },
425
+ { role: "assistant", text: "Reply 3" },
426
+ ],
427
+ },
428
+ ]);
429
+
430
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
431
+ assert.equal(result.includedCount, 2);
432
+ // High density should come first in the output
433
+ const highIdx = result.transcripts.indexOf("HIGH_DENSITY_MARKER");
434
+ const lowIdx = result.transcripts.indexOf("LOW_DENSITY_MARKER");
435
+ assert.ok(highIdx < lowIdx, "High-density session should appear before low-density");
436
+ });
437
+
438
+ it("includes header with stats", async () => {
439
+ const yesterday = new Date();
440
+ yesterday.setDate(yesterday.getDate() - 1);
441
+ const dateStr = yesterday.toISOString().slice(0, 10);
442
+
443
+ const sessionsDir = createSessionFixture(tmpDir, [{
444
+ projectDirName: "test-project",
445
+ fileName: `${dateStr}T03:00:00.000Z.jsonl`,
446
+ exchanges: [
447
+ { role: "user", text: "Q1" },
448
+ { role: "assistant", text: "A1" },
449
+ { role: "user", text: "Q2" },
450
+ { role: "assistant", text: "A2" },
451
+ ],
452
+ }]);
453
+
454
+ const result = await collectTranscripts(1, 1024 * 1024, sessionsDir);
455
+ assert.ok(result.transcripts.startsWith("# Session Transcripts\n"));
456
+ assert.ok(result.transcripts.includes("Sessions scanned: 1"));
457
+ assert.ok(result.transcripts.includes("1 included"));
458
+ });
459
+
460
+ it("handles lookbackDays > 1", async () => {
461
+ const twoDaysAgo = new Date();
462
+ twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
463
+ const dateStr = twoDaysAgo.toISOString().slice(0, 10);
464
+
465
+ const sessionsDir = createSessionFixture(tmpDir, [{
466
+ projectDirName: "test-project",
467
+ fileName: `${dateStr}T03:00:00.000Z.jsonl`,
468
+ exchanges: [
469
+ { role: "user", text: "Two days ago" },
470
+ { role: "assistant", text: "Reply" },
471
+ { role: "user", text: "Follow up" },
472
+ { role: "assistant", text: "Reply 2" },
473
+ ],
474
+ }]);
475
+
476
+ // lookbackDays=1 should miss it
477
+ const result1 = await collectTranscripts(1, 1024 * 1024, sessionsDir);
478
+ assert.equal(result1.includedCount, 0);
479
+
480
+ // lookbackDays=3 should find it
481
+ const result3 = await collectTranscripts(3, 1024 * 1024, sessionsDir);
482
+ assert.equal(result3.includedCount, 1);
483
+ });
484
+ });