@remnic/core 9.3.645 → 9.3.646

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.
Files changed (95) hide show
  1. package/dist/access-cli.js +18 -17
  2. package/dist/access-cli.js.map +1 -1
  3. package/dist/access-http.js +13 -13
  4. package/dist/access-mcp.js +10 -10
  5. package/dist/access-schema.js +3 -3
  6. package/dist/access-service.js +8 -8
  7. package/dist/adapters/index.js +4 -4
  8. package/dist/adapters/registry.js +2 -2
  9. package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
  10. package/dist/{chunk-6YLJZ2EU.js → chunk-3JSWINVD.js} +92 -29
  11. package/dist/chunk-3JSWINVD.js.map +1 -0
  12. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  13. package/dist/{chunk-HRXCPKN4.js → chunk-APWJRJFW.js} +2 -2
  14. package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
  15. package/dist/{chunk-T22TZJWJ.js → chunk-DC66QVL2.js} +2 -2
  16. package/dist/chunk-EVWIEEKZ.js +315 -0
  17. package/dist/chunk-EVWIEEKZ.js.map +1 -0
  18. package/dist/{chunk-IZXOO6NP.js → chunk-FAYDM5WD.js} +2 -2
  19. package/dist/{chunk-MBT3ASYV.js → chunk-H67ZTMTL.js} +12 -12
  20. package/dist/{chunk-3SF7QNKF.js → chunk-I3IWTRYB.js} +2 -2
  21. package/dist/{chunk-Y2XKJJEI.js → chunk-IMWFHBG2.js} +10 -33
  22. package/dist/chunk-IMWFHBG2.js.map +1 -0
  23. package/dist/{chunk-IHG27THY.js → chunk-JLOJ5RJ7.js} +68 -59
  24. package/dist/chunk-JLOJ5RJ7.js.map +1 -0
  25. package/dist/{chunk-W5ISYSIL.js → chunk-L7W5YW6Y.js} +4 -4
  26. package/dist/{chunk-DGNQRNLL.js → chunk-NT5TINK5.js} +2 -2
  27. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  28. package/dist/{chunk-CDHRUNBX.js → chunk-QKE4LHNR.js} +2 -2
  29. package/dist/{chunk-BTRL6GG7.js → chunk-RAELB5NX.js} +5 -5
  30. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  31. package/dist/chunk-S4DDLTPX.js +140 -0
  32. package/dist/chunk-S4DDLTPX.js.map +1 -0
  33. package/dist/{chunk-KVUG7FF7.js → chunk-U55D5UD5.js} +7 -7
  34. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  35. package/dist/{chunk-D2B22JDF.js → chunk-WEPMT6SC.js} +7 -7
  36. package/dist/{chunk-YOXCJP3A.js → chunk-ZPPFKVSD.js} +3 -3
  37. package/dist/cli.js +30 -28
  38. package/dist/{first-start-migration-FF7YFGRP.js → first-start-migration-PG5HBC3K.js} +4 -4
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.js +77 -63
  41. package/dist/index.js.map +1 -1
  42. package/dist/lcm/engine.js +3 -3
  43. package/dist/lcm/index.js +11 -11
  44. package/dist/namespaces/migrate.js +5 -5
  45. package/dist/namespaces/search.js +4 -4
  46. package/dist/operator-toolkit.js +6 -6
  47. package/dist/orchestrator.js +14 -13
  48. package/dist/resume-bundles.js +3 -2
  49. package/dist/search/factory.js +3 -3
  50. package/dist/search/index.js +7 -7
  51. package/dist/session-identity.d.ts +118 -0
  52. package/dist/session-identity.js +21 -0
  53. package/dist/session-identity.js.map +1 -0
  54. package/dist/session-transcript-migration.d.ts +65 -0
  55. package/dist/session-transcript-migration.js +16 -0
  56. package/dist/session-transcript-migration.js.map +1 -0
  57. package/dist/summarizer.js +2 -1
  58. package/dist/transcript.d.ts +12 -6
  59. package/dist/transcript.js +2 -1
  60. package/dist/transfer/backup.js +2 -2
  61. package/dist/transfer/capsule-export.js +2 -2
  62. package/dist/transfer/capsule-import.js +2 -2
  63. package/dist/transfer/import-sqlite.js +2 -2
  64. package/package.json +1 -1
  65. package/src/cli.ts +79 -0
  66. package/src/index.ts +18 -0
  67. package/src/session-identity.test.ts +130 -0
  68. package/src/session-identity.ts +281 -0
  69. package/src/session-transcript-migration.test.ts +350 -0
  70. package/src/session-transcript-migration.ts +527 -0
  71. package/src/summarizer.ts +15 -35
  72. package/src/transcript-session-identity.test.ts +424 -0
  73. package/src/transcript.ts +100 -70
  74. package/dist/chunk-6YLJZ2EU.js.map +0 -1
  75. package/dist/chunk-IHG27THY.js.map +0 -1
  76. package/dist/chunk-Y2XKJJEI.js.map +0 -1
  77. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  78. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  79. /package/dist/{chunk-HRXCPKN4.js.map → chunk-APWJRJFW.js.map} +0 -0
  80. /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  81. /package/dist/{chunk-T22TZJWJ.js.map → chunk-DC66QVL2.js.map} +0 -0
  82. /package/dist/{chunk-IZXOO6NP.js.map → chunk-FAYDM5WD.js.map} +0 -0
  83. /package/dist/{chunk-MBT3ASYV.js.map → chunk-H67ZTMTL.js.map} +0 -0
  84. /package/dist/{chunk-3SF7QNKF.js.map → chunk-I3IWTRYB.js.map} +0 -0
  85. /package/dist/{chunk-W5ISYSIL.js.map → chunk-L7W5YW6Y.js.map} +0 -0
  86. /package/dist/{chunk-DGNQRNLL.js.map → chunk-NT5TINK5.js.map} +0 -0
  87. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  88. /package/dist/{chunk-CDHRUNBX.js.map → chunk-QKE4LHNR.js.map} +0 -0
  89. /package/dist/{chunk-BTRL6GG7.js.map → chunk-RAELB5NX.js.map} +0 -0
  90. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  91. /package/dist/{chunk-KVUG7FF7.js.map → chunk-U55D5UD5.js.map} +0 -0
  92. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  93. /package/dist/{chunk-D2B22JDF.js.map → chunk-WEPMT6SC.js.map} +0 -0
  94. /package/dist/{chunk-YOXCJP3A.js.map → chunk-ZPPFKVSD.js.map} +0 -0
  95. /package/dist/{first-start-migration-FF7YFGRP.js.map → first-start-migration-PG5HBC3K.js.map} +0 -0
@@ -0,0 +1,424 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readdir, realpath, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { TranscriptManager } from "./transcript.js";
8
+ import type { PluginConfig, TranscriptEntry } from "./types.js";
9
+
10
+ function makeConfig(memoryDir: string): PluginConfig {
11
+ // TranscriptManager only reads memoryDir + transcriptSkipChannelTypes.
12
+ return {
13
+ memoryDir,
14
+ transcriptSkipChannelTypes: [],
15
+ } as unknown as PluginConfig;
16
+ }
17
+
18
+ // On macOS `os.tmpdir()` is a `/var/folders/...` symlink to `/private/var/...`.
19
+ // `resolveSafeStoragePath` canonicalizes via `fs.realpath`, so we canonicalize
20
+ // the test root upfront to match (issue #691 symlink convention) and to avoid a
21
+ // race where one test's recursive cleanup interleaves with another test's
22
+ // realpath traversal of the shared tmp parent.
23
+ async function makeMemoryDir(): Promise<string> {
24
+ return realpath(await mkdtemp(path.join(os.tmpdir(), "remnic-tx-id-")));
25
+ }
26
+
27
+ function makeEntry(sessionKey: string, turnId: string, role: "user" | "assistant"): TranscriptEntry {
28
+ return {
29
+ sessionKey,
30
+ turnId,
31
+ role,
32
+ content: `content-${turnId}`,
33
+ timestamp: new Date().toISOString(),
34
+ } as TranscriptEntry;
35
+ }
36
+
37
+ // A timestamp comfortably INSIDE a `readRecent(48, …)` window. Seeding with the
38
+ // exact wall-clock "now" is racy: the read's upper bound is captured a moment
39
+ // after the write, and at millisecond resolution the entry's `ts` can equal the
40
+ // read's exclusive `end`, excluding a just-written row. Using a fixed offset in
41
+ // the recent past keeps these read-back assertions deterministic. The current
42
+ // day's date stamp is still used for the file name so date-window selection
43
+ // matches the directory scan.
44
+ const SEED_TS = new Date(Date.now() - 60 * 60 * 1000).toISOString();
45
+ const TODAY = new Date().toISOString().slice(0, 10);
46
+
47
+ async function listDirs(root: string): Promise<string[]> {
48
+ try {
49
+ const entries = await readdir(root, { withFileTypes: true });
50
+ return entries
51
+ .filter((e) => e.isDirectory())
52
+ .map((e) => e.name)
53
+ .sort();
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ test("arbitrary session keys use DIFFERENT transcript dirs on first write, never other/default", async () => {
60
+ const memoryDir = await makeMemoryDir();
61
+ try {
62
+ const tm = new TranscriptManager(makeConfig(memoryDir));
63
+ await tm.initialize();
64
+
65
+ await tm.append(makeEntry("pi-geek:abc123", "1", "user"));
66
+ await tm.append(makeEntry("pi-friend:def456", "2", "user"));
67
+
68
+ const transcriptsDir = path.join(memoryDir, "transcripts");
69
+ const typeDirs = await listDirs(transcriptsDir);
70
+
71
+ // First-class "session" channel type, NOT "other".
72
+ assert.ok(typeDirs.includes("session"), `expected a session dir, got: ${typeDirs.join(",")}`);
73
+ assert.ok(!typeDirs.includes("other"), `should not create other/, got: ${typeDirs.join(",")}`);
74
+
75
+ // Two distinct hashed dirs under session/.
76
+ const sessionHashes = await listDirs(path.join(transcriptsDir, "session"));
77
+ assert.equal(sessionHashes.length, 2);
78
+ assert.notEqual(sessionHashes[0], sessionHashes[1]);
79
+ } finally {
80
+ await rm(memoryDir, { recursive: true, force: true });
81
+ }
82
+ });
83
+
84
+ test("distinct arbitrary keys never share other/default by default", async () => {
85
+ const memoryDir = await makeMemoryDir();
86
+ try {
87
+ const tm = new TranscriptManager(makeConfig(memoryDir));
88
+ await tm.initialize();
89
+
90
+ await tm.append(makeEntry("pi-geek:abc123", "1", "user"));
91
+ await tm.append(makeEntry("pi-friend:def456", "2", "user"));
92
+
93
+ // Each key only reads back its OWN entries.
94
+ const geek = await tm.readRecent(48, "pi-geek:abc123");
95
+ const friend = await tm.readRecent(48, "pi-friend:def456");
96
+ assert.equal(geek.length, 1);
97
+ assert.equal(friend.length, 1);
98
+ assert.equal(geek[0].sessionKey, "pi-geek:abc123");
99
+ assert.equal(friend[0].sessionKey, "pi-friend:def456");
100
+ } finally {
101
+ await rm(memoryDir, { recursive: true, force: true });
102
+ }
103
+ });
104
+
105
+ test("legacy agent:<id>:main keeps its readable main/default path", async () => {
106
+ const memoryDir = await makeMemoryDir();
107
+ try {
108
+ const tm = new TranscriptManager(makeConfig(memoryDir));
109
+ await tm.initialize();
110
+
111
+ await tm.append(makeEntry("agent:generalist:main", "1", "user"));
112
+
113
+ const transcriptsDir = path.join(memoryDir, "transcripts");
114
+ const typeDirs = await listDirs(transcriptsDir);
115
+ assert.ok(typeDirs.includes("main"), `expected main dir, got: ${typeDirs.join(",")}`);
116
+
117
+ const idDirs = await listDirs(path.join(transcriptsDir, "main"));
118
+ assert.deepEqual(idDirs, ["default"]);
119
+ } finally {
120
+ await rm(memoryDir, { recursive: true, force: true });
121
+ }
122
+ });
123
+
124
+ test("tool usage for arbitrary keys routes to state/tool-usage/session/<hash>", async () => {
125
+ const memoryDir = await makeMemoryDir();
126
+ try {
127
+ const tm = new TranscriptManager(makeConfig(memoryDir));
128
+ await tm.initialize();
129
+
130
+ await tm.appendToolUse({
131
+ timestamp: new Date().toISOString(),
132
+ sessionKey: "pi-geek:abc123",
133
+ tool: "search",
134
+ });
135
+
136
+ const toolUsageDir = path.join(memoryDir, "state", "tool-usage");
137
+ const typeDirs = await listDirs(toolUsageDir);
138
+ assert.ok(typeDirs.includes("session"), `expected session tool-usage dir, got: ${typeDirs.join(",")}`);
139
+ assert.ok(!typeDirs.includes("other"));
140
+ } finally {
141
+ await rm(memoryDir, { recursive: true, force: true });
142
+ }
143
+ });
144
+
145
+ test("listSessionKeys discovers BOTH legacy and hashed arbitrary sessions", async () => {
146
+ const memoryDir = await makeMemoryDir();
147
+ try {
148
+ const tm = new TranscriptManager(makeConfig(memoryDir));
149
+ await tm.initialize();
150
+
151
+ await tm.append(makeEntry("agent:generalist:main", "1", "user"));
152
+ await tm.append(makeEntry("pi-geek:abc123", "2", "user"));
153
+ await tm.append(makeEntry("pi-friend:def456", "3", "user"));
154
+
155
+ const keys = (await tm.listSessionKeys()).sort();
156
+ assert.deepEqual(keys, ["agent:generalist:main", "pi-friend:def456", "pi-geek:abc123"]);
157
+ } finally {
158
+ await rm(memoryDir, { recursive: true, force: true });
159
+ }
160
+ });
161
+
162
+ async function writeJsonl(absDir: string, fileName: string, lines: string[]): Promise<void> {
163
+ const { mkdir, writeFile } = await import("node:fs/promises");
164
+ await mkdir(absDir, { recursive: true });
165
+ await writeFile(path.join(absDir, fileName), `${lines.join("\n")}\n`, "utf-8");
166
+ }
167
+
168
+ test("partial migration (copied-but-not-trimmed) yields each transcript row exactly once", async () => {
169
+ const memoryDir = await makeMemoryDir();
170
+ try {
171
+ const sessionKey = "pi-geek:abc123";
172
+ // Same byte-identical row present in BOTH the primary session/<hash> dir and
173
+ // the legacy other/default dir — the state left by a migration that copied
174
+ // to the destination but crashed before trimming the source.
175
+ const row = JSON.stringify({
176
+ sessionKey,
177
+ turnId: "1",
178
+ role: "user",
179
+ content: "duplicated row",
180
+ timestamp: SEED_TS,
181
+ });
182
+
183
+ const { sessionStoragePaths } = await import("./session-identity.js");
184
+ const primaryDir = path.join(memoryDir, "transcripts", sessionStoragePaths(sessionKey).dir);
185
+ const otherDefaultDir = path.join(memoryDir, "transcripts", "other", "default");
186
+ await writeJsonl(primaryDir, `${TODAY}.jsonl`, [row]);
187
+ await writeJsonl(otherDefaultDir, `${TODAY}.jsonl`, [row]);
188
+
189
+ const tm = new TranscriptManager(makeConfig(memoryDir));
190
+ await tm.initialize();
191
+
192
+ const entries = await tm.readRecent(48, sessionKey);
193
+ assert.equal(entries.length, 1, "duplicated row must be returned exactly once");
194
+ assert.equal(entries[0].content, "duplicated row");
195
+ } finally {
196
+ await rm(memoryDir, { recursive: true, force: true });
197
+ }
198
+ });
199
+
200
+ test("partial migration yields each tool-usage row exactly once", async () => {
201
+ const memoryDir = await makeMemoryDir();
202
+ try {
203
+ const sessionKey = "pi-geek:abc123";
204
+ const row = JSON.stringify({ sessionKey, tool: "search", timestamp: SEED_TS });
205
+
206
+ const { sessionStoragePaths } = await import("./session-identity.js");
207
+ const primaryDir = path.join(memoryDir, "state", "tool-usage", sessionStoragePaths(sessionKey).dir);
208
+ const otherDefaultDir = path.join(memoryDir, "state", "tool-usage", "other", "default");
209
+ await writeJsonl(primaryDir, `${TODAY}.jsonl`, [row]);
210
+ await writeJsonl(otherDefaultDir, `${TODAY}.jsonl`, [row]);
211
+
212
+ const tm = new TranscriptManager(makeConfig(memoryDir));
213
+ await tm.initialize();
214
+
215
+ const start = new Date(Date.now() - 48 * 60 * 60 * 1000);
216
+ const end = new Date(Date.now() + 60 * 60 * 1000);
217
+ const used = await tm.readToolUse(sessionKey, start, end);
218
+ assert.equal(used.length, 1, "duplicated tool-usage row must be returned exactly once");
219
+ assert.equal(used[0].tool, "search");
220
+ } finally {
221
+ await rm(memoryDir, { recursive: true, force: true });
222
+ }
223
+ });
224
+
225
+ test("pre-existing foo:bar:baz data under old baz/default stays readable", async () => {
226
+ const memoryDir = await makeMemoryDir();
227
+ try {
228
+ // An OLD build (parts.length >= 3, no leading `agent`) stored foo:bar:baz
229
+ // under baz/default. The NEW parser reclassifies it as session/<hash>, but
230
+ // the old data must remain visible via the legacy-parser read-back dir.
231
+ const sessionKey = "foo:bar:baz";
232
+ const oldDir = path.join(memoryDir, "transcripts", "baz", "default");
233
+ await writeJsonl(oldDir, `${TODAY}.jsonl`, [
234
+ JSON.stringify({ sessionKey, turnId: "1", role: "user", content: "old baz entry", timestamp: SEED_TS }),
235
+ ]);
236
+ // Tool-usage equivalent.
237
+ const oldToolDir = path.join(memoryDir, "state", "tool-usage", "baz", "default");
238
+ await writeJsonl(oldToolDir, `${TODAY}.jsonl`, [
239
+ JSON.stringify({ sessionKey, tool: "grep", timestamp: SEED_TS }),
240
+ ]);
241
+
242
+ const tm = new TranscriptManager(makeConfig(memoryDir));
243
+ await tm.initialize();
244
+
245
+ const entries = await tm.readRecent(48, sessionKey);
246
+ assert.equal(entries.length, 1);
247
+ assert.equal(entries[0].content, "old baz entry");
248
+
249
+ const start = new Date(Date.now() - 48 * 60 * 60 * 1000);
250
+ const end = new Date(Date.now() + 60 * 60 * 1000);
251
+ const used = await tm.readToolUse(sessionKey, start, end);
252
+ assert.equal(used.length, 1);
253
+ assert.equal(used[0].tool, "grep");
254
+ } finally {
255
+ await rm(memoryDir, { recursive: true, force: true });
256
+ }
257
+ });
258
+
259
+ test("pre-existing foo:bar:baz:qux data under old baz/qux stays readable", async () => {
260
+ const memoryDir = await makeMemoryDir();
261
+ try {
262
+ const sessionKey = "foo:bar:baz:qux";
263
+ const oldDir = path.join(memoryDir, "transcripts", "baz", "qux");
264
+ await writeJsonl(oldDir, `${TODAY}.jsonl`, [
265
+ JSON.stringify({ sessionKey, turnId: "1", role: "user", content: "old baz/qux entry", timestamp: SEED_TS }),
266
+ ]);
267
+
268
+ const tm = new TranscriptManager(makeConfig(memoryDir));
269
+ await tm.initialize();
270
+
271
+ const entries = await tm.readRecent(48, sessionKey);
272
+ assert.equal(entries.length, 1);
273
+ assert.equal(entries[0].content, "old baz/qux entry");
274
+ } finally {
275
+ await rm(memoryDir, { recursive: true, force: true });
276
+ }
277
+ });
278
+
279
+ test("partial migration across legacy baz/default and session/<hash> dedupes to one row", async () => {
280
+ const memoryDir = await makeMemoryDir();
281
+ try {
282
+ const sessionKey = "foo:bar:baz";
283
+ const row = JSON.stringify({
284
+ sessionKey,
285
+ turnId: "1",
286
+ role: "user",
287
+ content: "shared legacy row",
288
+ timestamp: SEED_TS,
289
+ });
290
+ const { sessionStoragePaths } = await import("./session-identity.js");
291
+ const primaryDir = path.join(memoryDir, "transcripts", sessionStoragePaths(sessionKey).dir);
292
+ const legacyDir = path.join(memoryDir, "transcripts", "baz", "default");
293
+ await writeJsonl(primaryDir, `${TODAY}.jsonl`, [row]);
294
+ await writeJsonl(legacyDir, `${TODAY}.jsonl`, [row]);
295
+
296
+ const tm = new TranscriptManager(makeConfig(memoryDir));
297
+ await tm.initialize();
298
+
299
+ const entries = await tm.readRecent(48, sessionKey);
300
+ assert.equal(entries.length, 1, "row shared across legacy and primary dir must dedupe to one");
301
+ } finally {
302
+ await rm(memoryDir, { recursive: true, force: true });
303
+ }
304
+ });
305
+
306
+ test("legacy other/default data written by older builds remains readable", async () => {
307
+ const memoryDir = await makeMemoryDir();
308
+ try {
309
+ // Simulate an older build that wrote an arbitrary key under other/default.
310
+ const { mkdir, writeFile } = await import("node:fs/promises");
311
+ const legacyDir = path.join(memoryDir, "transcripts", "other", "default");
312
+ await mkdir(legacyDir, { recursive: true });
313
+ await writeFile(
314
+ path.join(legacyDir, `${TODAY}.jsonl`),
315
+ `${JSON.stringify({
316
+ sessionKey: "pi-legacy:zzz999",
317
+ turnId: "1",
318
+ role: "user",
319
+ content: "old entry",
320
+ timestamp: SEED_TS,
321
+ })}\n`,
322
+ "utf-8"
323
+ );
324
+
325
+ const tm = new TranscriptManager(makeConfig(memoryDir));
326
+ await tm.initialize();
327
+
328
+ // listSessionKeys must still surface the legacy-stored key.
329
+ const keys = await tm.listSessionKeys();
330
+ assert.ok(keys.includes("pi-legacy:zzz999"));
331
+
332
+ // readRecent must still read it (alternateDir/legacy read-back path).
333
+ const entries = await tm.readRecent(48, "pi-legacy:zzz999");
334
+ assert.equal(entries.length, 1);
335
+ assert.equal(entries[0].content, "old entry");
336
+ } finally {
337
+ await rm(memoryDir, { recursive: true, force: true });
338
+ }
339
+ });
340
+
341
+ test("readRange with a session key dedupes a copied-but-not-trimmed partial migration", async () => {
342
+ // Thread 1 (cursor review on PR #1504): `readRange` scans EVERY transcript
343
+ // file. Round 1 added raw-line dedup to readRecent/readToolUse/summarizer but
344
+ // not to readRange, so a partially-applied migration that copied a row into
345
+ // session/<hash> without trimming the legacy other/default source would yield
346
+ // the same turn twice through bootstrap / the transcript CLI.
347
+ const memoryDir = await makeMemoryDir();
348
+ try {
349
+ const sessionKey = "pi-geek:abc123";
350
+ const row = JSON.stringify({
351
+ sessionKey,
352
+ turnId: "1",
353
+ role: "user",
354
+ content: "duplicated range row",
355
+ timestamp: SEED_TS,
356
+ });
357
+
358
+ const { sessionStoragePaths } = await import("./session-identity.js");
359
+ const primaryDir = path.join(memoryDir, "transcripts", sessionStoragePaths(sessionKey).dir);
360
+ const otherDefaultDir = path.join(memoryDir, "transcripts", "other", "default");
361
+ // Byte-identical row in BOTH the primary dir and the legacy read-back dir.
362
+ await writeJsonl(primaryDir, `${TODAY}.jsonl`, [row]);
363
+ await writeJsonl(otherDefaultDir, `${TODAY}.jsonl`, [row]);
364
+
365
+ const tm = new TranscriptManager(makeConfig(memoryDir));
366
+ await tm.initialize();
367
+
368
+ const start = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
369
+ const end = new Date(Date.now() + 60 * 60 * 1000).toISOString();
370
+ const scoped = await tm.readRange(start, end, sessionKey);
371
+ assert.equal(scoped.length, 1, "session-scoped readRange must return the shared row exactly once");
372
+ assert.equal(scoped[0].content, "duplicated range row");
373
+ } finally {
374
+ await rm(memoryDir, { recursive: true, force: true });
375
+ }
376
+ });
377
+
378
+ test("readRange without a session key preserves distinct rows from every directory", async () => {
379
+ // The dedup is session-scoped only — an unfiltered range scan over the whole
380
+ // tree must NOT collapse legitimately distinct rows that happen to share a
381
+ // date. Guards against the deduper changing unrelated behavior.
382
+ const memoryDir = await makeMemoryDir();
383
+ try {
384
+ const { sessionStoragePaths } = await import("./session-identity.js");
385
+ const rowA = JSON.stringify({
386
+ sessionKey: "pi-geek:abc123",
387
+ turnId: "1",
388
+ role: "user",
389
+ content: "row A",
390
+ timestamp: SEED_TS,
391
+ });
392
+ const rowB = JSON.stringify({
393
+ sessionKey: "pi-friend:def456",
394
+ turnId: "1",
395
+ role: "user",
396
+ content: "row B",
397
+ timestamp: SEED_TS,
398
+ });
399
+ await writeJsonl(
400
+ path.join(memoryDir, "transcripts", sessionStoragePaths("pi-geek:abc123").dir),
401
+ `${TODAY}.jsonl`,
402
+ [rowA],
403
+ );
404
+ await writeJsonl(
405
+ path.join(memoryDir, "transcripts", sessionStoragePaths("pi-friend:def456").dir),
406
+ `${TODAY}.jsonl`,
407
+ [rowB],
408
+ );
409
+
410
+ const tm = new TranscriptManager(makeConfig(memoryDir));
411
+ await tm.initialize();
412
+
413
+ const start = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
414
+ const end = new Date(Date.now() + 60 * 60 * 1000).toISOString();
415
+ const all = await tm.readRange(start, end);
416
+ assert.equal(all.length, 2, "unfiltered readRange must keep both distinct rows");
417
+ assert.deepEqual(
418
+ all.map((e) => e.content).sort(),
419
+ ["row A", "row B"],
420
+ );
421
+ } finally {
422
+ await rm(memoryDir, { recursive: true, force: true });
423
+ }
424
+ });