@remnic/core 9.3.645 → 9.3.647

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 (98) 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-3D6L7CEP.js} +92 -29
  11. package/dist/chunk-3D6L7CEP.js.map +1 -0
  12. package/dist/{chunk-3SF7QNKF.js → chunk-6BNFVP7Y.js} +2 -2
  13. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  14. package/dist/{chunk-HRXCPKN4.js → chunk-APWJRJFW.js} +2 -2
  15. package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
  16. package/dist/{chunk-T22TZJWJ.js → chunk-DC66QVL2.js} +2 -2
  17. package/dist/chunk-EVWIEEKZ.js +315 -0
  18. package/dist/chunk-EVWIEEKZ.js.map +1 -0
  19. package/dist/{chunk-IZXOO6NP.js → chunk-FAYDM5WD.js} +2 -2
  20. package/dist/{chunk-Y2XKJJEI.js → chunk-IMWFHBG2.js} +10 -33
  21. package/dist/chunk-IMWFHBG2.js.map +1 -0
  22. package/dist/{chunk-W5ISYSIL.js → chunk-L7W5YW6Y.js} +4 -4
  23. package/dist/{chunk-DGNQRNLL.js → chunk-NT5TINK5.js} +2 -2
  24. package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
  25. package/dist/{chunk-CDHRUNBX.js → chunk-QKE4LHNR.js} +2 -2
  26. package/dist/{chunk-BTRL6GG7.js → chunk-RAELB5NX.js} +5 -5
  27. package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
  28. package/dist/chunk-S4DDLTPX.js +140 -0
  29. package/dist/chunk-S4DDLTPX.js.map +1 -0
  30. package/dist/{chunk-MBT3ASYV.js → chunk-TA4LQ5SR.js} +12 -12
  31. package/dist/{chunk-KVUG7FF7.js → chunk-U55D5UD5.js} +7 -7
  32. package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
  33. package/dist/{chunk-D2B22JDF.js → chunk-WEPMT6SC.js} +7 -7
  34. package/dist/{chunk-IHG27THY.js → chunk-XUGQQPGO.js} +77 -61
  35. package/dist/chunk-XUGQQPGO.js.map +1 -0
  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/schemas.d.ts +22 -22
  50. package/dist/search/factory.js +3 -3
  51. package/dist/search/index.js +7 -7
  52. package/dist/session-identity.d.ts +118 -0
  53. package/dist/session-identity.js +21 -0
  54. package/dist/session-identity.js.map +1 -0
  55. package/dist/session-transcript-migration.d.ts +65 -0
  56. package/dist/session-transcript-migration.js +16 -0
  57. package/dist/session-transcript-migration.js.map +1 -0
  58. package/dist/summarizer.js +2 -1
  59. package/dist/transcript.d.ts +18 -6
  60. package/dist/transcript.js +2 -1
  61. package/dist/transfer/backup.js +2 -2
  62. package/dist/transfer/capsule-export.js +2 -2
  63. package/dist/transfer/capsule-import.js +2 -2
  64. package/dist/transfer/import-sqlite.js +2 -2
  65. package/dist/transfer/types.d.ts +12 -12
  66. package/package.json +1 -1
  67. package/src/cli.ts +79 -0
  68. package/src/index.ts +18 -0
  69. package/src/session-identity.test.ts +130 -0
  70. package/src/session-identity.ts +281 -0
  71. package/src/session-transcript-migration.test.ts +350 -0
  72. package/src/session-transcript-migration.ts +527 -0
  73. package/src/summarizer.ts +15 -35
  74. package/src/transcript-session-identity.test.ts +424 -0
  75. package/src/transcript.test.ts +110 -0
  76. package/src/transcript.ts +121 -73
  77. package/dist/chunk-6YLJZ2EU.js.map +0 -1
  78. package/dist/chunk-IHG27THY.js.map +0 -1
  79. package/dist/chunk-Y2XKJJEI.js.map +0 -1
  80. /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
  81. /package/dist/{chunk-3SF7QNKF.js.map → chunk-6BNFVP7Y.js.map} +0 -0
  82. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  83. /package/dist/{chunk-HRXCPKN4.js.map → chunk-APWJRJFW.js.map} +0 -0
  84. /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
  85. /package/dist/{chunk-T22TZJWJ.js.map → chunk-DC66QVL2.js.map} +0 -0
  86. /package/dist/{chunk-IZXOO6NP.js.map → chunk-FAYDM5WD.js.map} +0 -0
  87. /package/dist/{chunk-W5ISYSIL.js.map → chunk-L7W5YW6Y.js.map} +0 -0
  88. /package/dist/{chunk-DGNQRNLL.js.map → chunk-NT5TINK5.js.map} +0 -0
  89. /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
  90. /package/dist/{chunk-CDHRUNBX.js.map → chunk-QKE4LHNR.js.map} +0 -0
  91. /package/dist/{chunk-BTRL6GG7.js.map → chunk-RAELB5NX.js.map} +0 -0
  92. /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
  93. /package/dist/{chunk-MBT3ASYV.js.map → chunk-TA4LQ5SR.js.map} +0 -0
  94. /package/dist/{chunk-KVUG7FF7.js.map → chunk-U55D5UD5.js.map} +0 -0
  95. /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
  96. /package/dist/{chunk-D2B22JDF.js.map → chunk-WEPMT6SC.js.map} +0 -0
  97. /package/dist/{chunk-YOXCJP3A.js.map → chunk-ZPPFKVSD.js.map} +0 -0
  98. /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
+ });
@@ -0,0 +1,110 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtemp } from "node:fs/promises";
6
+ import { TranscriptManager } from "./transcript.js";
7
+ import type { PluginConfig, TranscriptEntry } from "./types.js";
8
+
9
+ function makeConfig(memoryDir: string): PluginConfig {
10
+ return {
11
+ memoryDir,
12
+ transcriptSkipChannelTypes: [],
13
+ } as unknown as PluginConfig;
14
+ }
15
+
16
+ async function makeManager(): Promise<{ manager: TranscriptManager; memoryDir: string }> {
17
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-transcript-recent-"));
18
+ const manager = new TranscriptManager(makeConfig(memoryDir));
19
+ await manager.initialize();
20
+ return { manager, memoryDir };
21
+ }
22
+
23
+ function entryAt(timestamp: string, sessionKey: string, turnId: string): TranscriptEntry {
24
+ return { timestamp, role: "user", content: `turn ${turnId}`, sessionKey, turnId };
25
+ }
26
+
27
+ /**
28
+ * Freeze `new Date()` / `Date.now()` to a fixed instant for the duration of `fn`.
29
+ *
30
+ * This lets a test seed a transcript entry whose timestamp is *exactly* the
31
+ * instant the read observes as "now", deterministically reproducing the
32
+ * millisecond-collision that the recent-window boundary fix targets.
33
+ * Single-argument constructions (`new Date(isoString)`, `new Date(ms)`,
34
+ * `new Date(otherDate)`) are forwarded unchanged so timestamp parsing still works.
35
+ */
36
+ async function withFrozenNow<T>(now: Date, fn: () => Promise<T>): Promise<T> {
37
+ const RealDate = Date;
38
+ const fixedMs = now.getTime();
39
+ class FrozenDate extends RealDate {
40
+ constructor(...args: any[]) {
41
+ if (args.length === 0) {
42
+ super(fixedMs);
43
+ } else if (args.length === 1) {
44
+ super(args[0] as number);
45
+ } else {
46
+ super(
47
+ args[0] as number,
48
+ args[1] as number,
49
+ args[2] as number,
50
+ args[3] as number,
51
+ args[4] as number,
52
+ args[5] as number,
53
+ args[6] as number,
54
+ );
55
+ }
56
+ }
57
+ static now(): number {
58
+ return fixedMs;
59
+ }
60
+ }
61
+ (globalThis as { Date: DateConstructor }).Date = FrozenDate as unknown as DateConstructor;
62
+ try {
63
+ return await fn();
64
+ } finally {
65
+ (globalThis as { Date: DateConstructor }).Date = RealDate;
66
+ }
67
+ }
68
+
69
+ test("readRecent (session fast path) returns a turn written at the same instant as the read", async () => {
70
+ const { manager } = await makeManager();
71
+ const sessionKey = "agent:test-agent:main";
72
+ const now = new Date("2026-06-29T12:00:00.000Z");
73
+
74
+ const entries = await withFrozenNow(now, async () => {
75
+ // Seed an entry stamped at the exact instant the read observes as "now".
76
+ await manager.append(entryAt(now.toISOString(), sessionKey, "boundary"));
77
+ return manager.readRecent(48, sessionKey);
78
+ });
79
+
80
+ assert.equal(entries.length, 1, "just-written boundary entry should be included in last-N-hours read");
81
+ assert.equal(entries[0].turnId, "boundary");
82
+ });
83
+
84
+ test("readRecent (full scan, no sessionKey) returns a turn written at the same instant as the read", async () => {
85
+ const { manager } = await makeManager();
86
+ const sessionKey = "agent:test-agent:main";
87
+ const now = new Date("2026-06-29T12:00:00.000Z");
88
+
89
+ const entries = await withFrozenNow(now, async () => {
90
+ await manager.append(entryAt(now.toISOString(), sessionKey, "boundary"));
91
+ return manager.readRecent(48);
92
+ });
93
+
94
+ assert.equal(entries.length, 1, "just-written boundary entry should be included in full-scan recent read");
95
+ assert.equal(entries[0].turnId, "boundary");
96
+ });
97
+
98
+ test("readRange keeps an exclusive upper bound for caller-specified ranges", async () => {
99
+ const { manager } = await makeManager();
100
+ const sessionKey = "agent:test-agent:main";
101
+ const start = "2026-06-29T00:00:00.000Z";
102
+ const end = "2026-06-29T12:00:00.000Z";
103
+
104
+ await manager.append(entryAt("2026-06-29T11:59:59.999Z", sessionKey, "inside"));
105
+ await manager.append(entryAt(end, sessionKey, "at-end"));
106
+
107
+ const exclusive = await manager.readRange(start, end, sessionKey);
108
+ const ids = exclusive.map((e) => e.turnId);
109
+ assert.deepEqual(ids, ["inside"], "explicit [start, end) range must exclude the entry at exactly end (rule #35)");
110
+ });