@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.
- package/dist/access-cli.js +18 -17
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.js +13 -13
- package/dist/access-mcp.js +10 -10
- package/dist/access-schema.js +3 -3
- package/dist/access-service.js +8 -8
- package/dist/adapters/index.js +4 -4
- package/dist/adapters/registry.js +2 -2
- package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-GWVG7LGC.js} +2 -2
- package/dist/{chunk-6YLJZ2EU.js → chunk-3JSWINVD.js} +92 -29
- package/dist/chunk-3JSWINVD.js.map +1 -0
- package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
- package/dist/{chunk-HRXCPKN4.js → chunk-APWJRJFW.js} +2 -2
- package/dist/{chunk-VDX2J7OX.js → chunk-BUKK5SWA.js} +2 -2
- package/dist/{chunk-T22TZJWJ.js → chunk-DC66QVL2.js} +2 -2
- package/dist/chunk-EVWIEEKZ.js +315 -0
- package/dist/chunk-EVWIEEKZ.js.map +1 -0
- package/dist/{chunk-IZXOO6NP.js → chunk-FAYDM5WD.js} +2 -2
- package/dist/{chunk-MBT3ASYV.js → chunk-H67ZTMTL.js} +12 -12
- package/dist/{chunk-3SF7QNKF.js → chunk-I3IWTRYB.js} +2 -2
- package/dist/{chunk-Y2XKJJEI.js → chunk-IMWFHBG2.js} +10 -33
- package/dist/chunk-IMWFHBG2.js.map +1 -0
- package/dist/{chunk-IHG27THY.js → chunk-JLOJ5RJ7.js} +68 -59
- package/dist/chunk-JLOJ5RJ7.js.map +1 -0
- package/dist/{chunk-W5ISYSIL.js → chunk-L7W5YW6Y.js} +4 -4
- package/dist/{chunk-DGNQRNLL.js → chunk-NT5TINK5.js} +2 -2
- package/dist/{chunk-BXLOS5AJ.js → chunk-OWHERGF2.js} +2 -2
- package/dist/{chunk-CDHRUNBX.js → chunk-QKE4LHNR.js} +2 -2
- package/dist/{chunk-BTRL6GG7.js → chunk-RAELB5NX.js} +5 -5
- package/dist/{chunk-2QSZNTDO.js → chunk-RKNJBZ55.js} +4 -4
- package/dist/chunk-S4DDLTPX.js +140 -0
- package/dist/chunk-S4DDLTPX.js.map +1 -0
- package/dist/{chunk-KVUG7FF7.js → chunk-U55D5UD5.js} +7 -7
- package/dist/{chunk-DQEMWVMT.js → chunk-UVYI6VIX.js} +1 -1
- package/dist/{chunk-D2B22JDF.js → chunk-WEPMT6SC.js} +7 -7
- package/dist/{chunk-YOXCJP3A.js → chunk-ZPPFKVSD.js} +3 -3
- package/dist/cli.js +30 -28
- package/dist/{first-start-migration-FF7YFGRP.js → first-start-migration-PG5HBC3K.js} +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +77 -63
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.js +11 -11
- package/dist/namespaces/migrate.js +5 -5
- package/dist/namespaces/search.js +4 -4
- package/dist/operator-toolkit.js +6 -6
- package/dist/orchestrator.js +14 -13
- package/dist/resume-bundles.js +3 -2
- package/dist/search/factory.js +3 -3
- package/dist/search/index.js +7 -7
- package/dist/session-identity.d.ts +118 -0
- package/dist/session-identity.js +21 -0
- package/dist/session-identity.js.map +1 -0
- package/dist/session-transcript-migration.d.ts +65 -0
- package/dist/session-transcript-migration.js +16 -0
- package/dist/session-transcript-migration.js.map +1 -0
- package/dist/summarizer.js +2 -1
- package/dist/transcript.d.ts +12 -6
- package/dist/transcript.js +2 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/import-sqlite.js +2 -2
- package/package.json +1 -1
- package/src/cli.ts +79 -0
- package/src/index.ts +18 -0
- package/src/session-identity.test.ts +130 -0
- package/src/session-identity.ts +281 -0
- package/src/session-transcript-migration.test.ts +350 -0
- package/src/session-transcript-migration.ts +527 -0
- package/src/summarizer.ts +15 -35
- package/src/transcript-session-identity.test.ts +424 -0
- package/src/transcript.ts +100 -70
- package/dist/chunk-6YLJZ2EU.js.map +0 -1
- package/dist/chunk-IHG27THY.js.map +0 -1
- package/dist/chunk-Y2XKJJEI.js.map +0 -1
- /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-GWVG7LGC.js.map} +0 -0
- /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
- /package/dist/{chunk-HRXCPKN4.js.map → chunk-APWJRJFW.js.map} +0 -0
- /package/dist/{chunk-VDX2J7OX.js.map → chunk-BUKK5SWA.js.map} +0 -0
- /package/dist/{chunk-T22TZJWJ.js.map → chunk-DC66QVL2.js.map} +0 -0
- /package/dist/{chunk-IZXOO6NP.js.map → chunk-FAYDM5WD.js.map} +0 -0
- /package/dist/{chunk-MBT3ASYV.js.map → chunk-H67ZTMTL.js.map} +0 -0
- /package/dist/{chunk-3SF7QNKF.js.map → chunk-I3IWTRYB.js.map} +0 -0
- /package/dist/{chunk-W5ISYSIL.js.map → chunk-L7W5YW6Y.js.map} +0 -0
- /package/dist/{chunk-DGNQRNLL.js.map → chunk-NT5TINK5.js.map} +0 -0
- /package/dist/{chunk-BXLOS5AJ.js.map → chunk-OWHERGF2.js.map} +0 -0
- /package/dist/{chunk-CDHRUNBX.js.map → chunk-QKE4LHNR.js.map} +0 -0
- /package/dist/{chunk-BTRL6GG7.js.map → chunk-RAELB5NX.js.map} +0 -0
- /package/dist/{chunk-2QSZNTDO.js.map → chunk-RKNJBZ55.js.map} +0 -0
- /package/dist/{chunk-KVUG7FF7.js.map → chunk-U55D5UD5.js.map} +0 -0
- /package/dist/{chunk-DQEMWVMT.js.map → chunk-UVYI6VIX.js.map} +0 -0
- /package/dist/{chunk-D2B22JDF.js.map → chunk-WEPMT6SC.js.map} +0 -0
- /package/dist/{chunk-YOXCJP3A.js.map → chunk-ZPPFKVSD.js.map} +0 -0
- /package/dist/{first-start-migration-FF7YFGRP.js.map → first-start-migration-PG5HBC3K.js.map} +0 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } 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 { sessionStoragePaths } from "./session-identity.js";
|
|
8
|
+
import { migrateSessionTranscripts, planSessionTranscriptMigration } from "./session-transcript-migration.js";
|
|
9
|
+
|
|
10
|
+
async function makeMemoryDir(): Promise<string> {
|
|
11
|
+
return mkdtemp(path.join(os.tmpdir(), "remnic-sess-mig-"));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function entryLine(sessionKey: string, turnId: string, role: "user" | "assistant"): string {
|
|
15
|
+
return JSON.stringify({
|
|
16
|
+
sessionKey,
|
|
17
|
+
turnId,
|
|
18
|
+
role,
|
|
19
|
+
content: `content-${turnId}`,
|
|
20
|
+
timestamp: `2026-06-29T10:0${turnId}:00.000Z`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function seedMixedOtherDefault(memoryDir: string, fileName: string, lines: string[]): Promise<string> {
|
|
25
|
+
const dir = path.join(memoryDir, "transcripts", "other", "default");
|
|
26
|
+
await mkdir(dir, { recursive: true });
|
|
27
|
+
const filePath = path.join(dir, fileName);
|
|
28
|
+
await writeFile(filePath, `${lines.join("\n")}\n`, "utf-8");
|
|
29
|
+
return filePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("dry-run reports expected splits for mixed other/default file and changes nothing", async () => {
|
|
33
|
+
const memoryDir = await makeMemoryDir();
|
|
34
|
+
try {
|
|
35
|
+
const fileName = "2026-06-29.jsonl";
|
|
36
|
+
const lines = [
|
|
37
|
+
entryLine("pi-geek:abc123", "1", "user"),
|
|
38
|
+
entryLine("pi-friend:def456", "2", "user"),
|
|
39
|
+
entryLine("pi-geek:abc123", "3", "assistant"),
|
|
40
|
+
];
|
|
41
|
+
const sourcePath = await seedMixedOtherDefault(memoryDir, fileName, lines);
|
|
42
|
+
|
|
43
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
44
|
+
assert.equal(plan.dryRun, true);
|
|
45
|
+
assert.equal(plan.files.length, 1);
|
|
46
|
+
assert.equal(plan.distinctSessions, 2);
|
|
47
|
+
assert.equal(plan.movedEntries, 3);
|
|
48
|
+
|
|
49
|
+
const groupKeys = plan.files[0].groups.map((g) => g.sessionKey).sort();
|
|
50
|
+
assert.deepEqual(groupKeys, ["pi-friend:def456", "pi-geek:abc123"]);
|
|
51
|
+
|
|
52
|
+
// Nothing moved.
|
|
53
|
+
const before = await readFile(sourcePath, "utf-8");
|
|
54
|
+
assert.equal(before.split("\n").filter(Boolean).length, 3);
|
|
55
|
+
const sessionDir = path.join(memoryDir, "transcripts", "session");
|
|
56
|
+
await assert.rejects(() => readdir(sessionDir));
|
|
57
|
+
} finally {
|
|
58
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("apply is lossless: every entry lands in its session dir, source is emptied", async () => {
|
|
63
|
+
const memoryDir = await makeMemoryDir();
|
|
64
|
+
try {
|
|
65
|
+
const fileName = "2026-06-29.jsonl";
|
|
66
|
+
const lines = [
|
|
67
|
+
entryLine("pi-geek:abc123", "1", "user"),
|
|
68
|
+
entryLine("pi-friend:def456", "2", "user"),
|
|
69
|
+
entryLine("pi-geek:abc123", "3", "assistant"),
|
|
70
|
+
];
|
|
71
|
+
await seedMixedOtherDefault(memoryDir, fileName, lines);
|
|
72
|
+
|
|
73
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
74
|
+
assert.equal(result.applied, true);
|
|
75
|
+
assert.equal(result.errors.length, 0);
|
|
76
|
+
|
|
77
|
+
const geekDir = sessionStoragePaths("pi-geek:abc123").dir;
|
|
78
|
+
const friendDir = sessionStoragePaths("pi-friend:def456").dir;
|
|
79
|
+
|
|
80
|
+
const geekContent = await readFile(path.join(memoryDir, "transcripts", geekDir, fileName), "utf-8");
|
|
81
|
+
const friendContent = await readFile(path.join(memoryDir, "transcripts", friendDir, fileName), "utf-8");
|
|
82
|
+
|
|
83
|
+
const geekLines = geekContent.split("\n").filter(Boolean);
|
|
84
|
+
const friendLines = friendContent.split("\n").filter(Boolean);
|
|
85
|
+
assert.equal(geekLines.length, 2);
|
|
86
|
+
assert.equal(friendLines.length, 1);
|
|
87
|
+
|
|
88
|
+
// Ordering preserved within a session (turn 1 before turn 3).
|
|
89
|
+
assert.ok(geekLines[0].includes('"turnId":"1"'));
|
|
90
|
+
assert.ok(geekLines[1].includes('"turnId":"3"'));
|
|
91
|
+
|
|
92
|
+
// Source other/default file removed (no entries retained).
|
|
93
|
+
await assert.rejects(() => readFile(path.join(memoryDir, "transcripts", "other", "default", fileName), "utf-8"));
|
|
94
|
+
|
|
95
|
+
// No entries lost: 3 in, 3 out.
|
|
96
|
+
assert.equal(geekLines.length + friendLines.length, 3);
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("apply is idempotent: a second run finds nothing to migrate and does not duplicate", async () => {
|
|
103
|
+
const memoryDir = await makeMemoryDir();
|
|
104
|
+
try {
|
|
105
|
+
const fileName = "2026-06-29.jsonl";
|
|
106
|
+
const lines = [entryLine("pi-geek:abc123", "1", "user"), entryLine("pi-geek:abc123", "2", "assistant")];
|
|
107
|
+
await seedMixedOtherDefault(memoryDir, fileName, lines);
|
|
108
|
+
|
|
109
|
+
const first = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
110
|
+
assert.equal(first.errors.length, 0);
|
|
111
|
+
|
|
112
|
+
const second = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
113
|
+
assert.equal(second.errors.length, 0);
|
|
114
|
+
assert.equal(second.plan.files.length, 0);
|
|
115
|
+
assert.equal(second.plan.movedEntries, 0);
|
|
116
|
+
|
|
117
|
+
const geekDir = sessionStoragePaths("pi-geek:abc123").dir;
|
|
118
|
+
const geekContent = await readFile(path.join(memoryDir, "transcripts", geekDir, fileName), "utf-8");
|
|
119
|
+
// Still exactly 2 lines — no duplication.
|
|
120
|
+
assert.equal(geekContent.split("\n").filter(Boolean).length, 2);
|
|
121
|
+
} finally {
|
|
122
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("unparseable and legacy-homed lines are retained in the source file", async () => {
|
|
127
|
+
const memoryDir = await makeMemoryDir();
|
|
128
|
+
try {
|
|
129
|
+
const fileName = "2026-06-29.jsonl";
|
|
130
|
+
// One arbitrary key (movable), one malformed line (retained).
|
|
131
|
+
const lines = [entryLine("pi-geek:abc123", "1", "user"), "{not valid json"];
|
|
132
|
+
await seedMixedOtherDefault(memoryDir, fileName, lines);
|
|
133
|
+
|
|
134
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
135
|
+
assert.equal(result.errors.length, 0);
|
|
136
|
+
|
|
137
|
+
// Source retains the malformed line (not deleted).
|
|
138
|
+
const sourceContent = await readFile(path.join(memoryDir, "transcripts", "other", "default", fileName), "utf-8");
|
|
139
|
+
assert.ok(sourceContent.includes("{not valid json"));
|
|
140
|
+
|
|
141
|
+
// The movable entry landed in its session dir.
|
|
142
|
+
const geekDir = sessionStoragePaths("pi-geek:abc123").dir;
|
|
143
|
+
const geekContent = await readFile(path.join(memoryDir, "transcripts", geekDir, fileName), "utf-8");
|
|
144
|
+
assert.equal(geekContent.split("\n").filter(Boolean).length, 1);
|
|
145
|
+
} finally {
|
|
146
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
async function seedLegacyParserDir(
|
|
151
|
+
memoryDir: string,
|
|
152
|
+
channelType: string,
|
|
153
|
+
channelId: string,
|
|
154
|
+
fileName: string,
|
|
155
|
+
lines: string[]
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
const dir = path.join(memoryDir, "transcripts", channelType, channelId);
|
|
158
|
+
await mkdir(dir, { recursive: true });
|
|
159
|
+
const filePath = path.join(dir, fileName);
|
|
160
|
+
await writeFile(filePath, `${lines.join("\n")}\n`, "utf-8");
|
|
161
|
+
return filePath;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
test("migration scan picks up pre-existing foo:bar:baz data under old baz/default", async () => {
|
|
165
|
+
const memoryDir = await makeMemoryDir();
|
|
166
|
+
try {
|
|
167
|
+
const fileName = "2026-06-29.jsonl";
|
|
168
|
+
// OLD build stored foo:bar:baz under baz/default (parts.length >= 3 parser).
|
|
169
|
+
const sourcePath = await seedLegacyParserDir(memoryDir, "baz", "default", fileName, [
|
|
170
|
+
entryLine("foo:bar:baz", "1", "user"),
|
|
171
|
+
entryLine("foo:bar:baz", "2", "assistant"),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
175
|
+
assert.equal(plan.files.length, 1, "baz/default must be a migration candidate");
|
|
176
|
+
assert.equal(plan.distinctSessions, 1);
|
|
177
|
+
assert.equal(plan.movedEntries, 2);
|
|
178
|
+
|
|
179
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
180
|
+
assert.equal(result.errors.length, 0);
|
|
181
|
+
|
|
182
|
+
const destDir = sessionStoragePaths("foo:bar:baz").dir;
|
|
183
|
+
const destContent = await readFile(path.join(memoryDir, "transcripts", destDir, fileName), "utf-8");
|
|
184
|
+
assert.equal(destContent.split("\n").filter(Boolean).length, 2);
|
|
185
|
+
|
|
186
|
+
// Source emptied/removed.
|
|
187
|
+
await assert.rejects(() => readFile(sourcePath, "utf-8"));
|
|
188
|
+
|
|
189
|
+
// Idempotent: a second run finds nothing.
|
|
190
|
+
const second = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
191
|
+
assert.equal(second.plan.files.length, 0);
|
|
192
|
+
assert.equal(second.plan.movedEntries, 0);
|
|
193
|
+
} finally {
|
|
194
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("migration leaves legitimate legacy agent:<id>:main data in place (dest === source)", async () => {
|
|
199
|
+
const memoryDir = await makeMemoryDir();
|
|
200
|
+
try {
|
|
201
|
+
const fileName = "2026-06-29.jsonl";
|
|
202
|
+
// agent:generalist:main legitimately lives in main/default and must NOT move.
|
|
203
|
+
await seedLegacyParserDir(memoryDir, "main", "default", fileName, [
|
|
204
|
+
entryLine("agent:generalist:main", "1", "user"),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
208
|
+
assert.equal(plan.files.length, 0, "legacy agent data must not be a migration source");
|
|
209
|
+
assert.equal(plan.movedEntries, 0);
|
|
210
|
+
|
|
211
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
212
|
+
assert.equal(result.errors.length, 0);
|
|
213
|
+
const stillThere = await readFile(path.join(memoryDir, "transcripts", "main", "default", fileName), "utf-8");
|
|
214
|
+
assert.equal(stillThere.split("\n").filter(Boolean).length, 1);
|
|
215
|
+
} finally {
|
|
216
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("migration never treats session/<hash> dirs as sources", async () => {
|
|
221
|
+
const memoryDir = await makeMemoryDir();
|
|
222
|
+
try {
|
|
223
|
+
const fileName = "2026-06-29.jsonl";
|
|
224
|
+
const sessionKey = "pi-geek:abc123";
|
|
225
|
+
const destDir = sessionStoragePaths(sessionKey).dir; // session/<hash>
|
|
226
|
+
const [type, id] = destDir.split("/");
|
|
227
|
+
await seedLegacyParserDir(memoryDir, type, id, fileName, [entryLine(sessionKey, "1", "user")]);
|
|
228
|
+
|
|
229
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
230
|
+
assert.equal(plan.files.length, 0, "already-homed session/<hash> must never be scanned");
|
|
231
|
+
} finally {
|
|
232
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("migration splits a LEGACY session/<name> dir while leaving real session/<hash> untouched", async () => {
|
|
237
|
+
// Thread 3 (codex review on PR #1504): pre-#1496 the OLD parser stored a key
|
|
238
|
+
// whose 3rd colon segment was literally `session` (e.g. foo:bar:session:baz)
|
|
239
|
+
// under transcripts/session/baz — a NON-hash id. The new layer maps that key
|
|
240
|
+
// to session/<hash>, but the migration previously BLANKET-SKIPPED everything
|
|
241
|
+
// under session/, so the legacy session/baz dir was never scanned. Only the
|
|
242
|
+
// canonical 16-hex session/<hash> dirs must be skipped.
|
|
243
|
+
const memoryDir = await makeMemoryDir();
|
|
244
|
+
try {
|
|
245
|
+
const fileName = "2026-06-29.jsonl";
|
|
246
|
+
const legacyKey = "foo:bar:session:baz";
|
|
247
|
+
|
|
248
|
+
// LEGACY: OLD parser homed foo:bar:session:baz under session/baz (non-hash).
|
|
249
|
+
const legacySourcePath = await seedLegacyParserDir(memoryDir, "session", "baz", fileName, [
|
|
250
|
+
entryLine(legacyKey, "1", "user"),
|
|
251
|
+
entryLine(legacyKey, "2", "assistant"),
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
// CANONICAL: a genuinely-homed session/<hash> dir for a different key.
|
|
255
|
+
const homedKey = "pi-geek:abc123";
|
|
256
|
+
const homedDir = sessionStoragePaths(homedKey).dir; // session/<16hex>
|
|
257
|
+
const [homedType, homedId] = homedDir.split("/");
|
|
258
|
+
assert.match(homedId, /^[0-9a-f]{16}$/);
|
|
259
|
+
await seedLegacyParserDir(memoryDir, homedType, homedId, fileName, [
|
|
260
|
+
entryLine(homedKey, "1", "user"),
|
|
261
|
+
]);
|
|
262
|
+
const homedContentBefore = await readFile(path.join(memoryDir, "transcripts", homedDir, fileName), "utf-8");
|
|
263
|
+
|
|
264
|
+
// Plan: only the legacy session/baz dir is a source; session/<hash> is left.
|
|
265
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
266
|
+
assert.equal(plan.files.length, 1, "only the legacy session/<name> dir must be a source");
|
|
267
|
+
assert.equal(plan.distinctSessions, 1);
|
|
268
|
+
assert.equal(plan.movedEntries, 2);
|
|
269
|
+
assert.equal(plan.files[0].sourceRelPath, path.join("session", "baz", fileName));
|
|
270
|
+
|
|
271
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
272
|
+
assert.equal(result.errors.length, 0);
|
|
273
|
+
|
|
274
|
+
// Legacy data re-homed to its canonical session/<hash> dir, order preserved.
|
|
275
|
+
const legacyDestDir = sessionStoragePaths(legacyKey).dir;
|
|
276
|
+
assert.notEqual(legacyDestDir, path.join("session", "baz"));
|
|
277
|
+
const legacyDestContent = await readFile(
|
|
278
|
+
path.join(memoryDir, "transcripts", legacyDestDir, fileName),
|
|
279
|
+
"utf-8",
|
|
280
|
+
);
|
|
281
|
+
const legacyDestLines = legacyDestContent.split("\n").filter(Boolean);
|
|
282
|
+
assert.equal(legacyDestLines.length, 2);
|
|
283
|
+
assert.ok(legacyDestLines[0].includes('"turnId":"1"'));
|
|
284
|
+
assert.ok(legacyDestLines[1].includes('"turnId":"2"'));
|
|
285
|
+
|
|
286
|
+
// Legacy source emptied/removed (lossless move).
|
|
287
|
+
await assert.rejects(() => readFile(legacySourcePath, "utf-8"));
|
|
288
|
+
|
|
289
|
+
// The real session/<hash> dir is byte-for-byte untouched.
|
|
290
|
+
const homedContentAfter = await readFile(path.join(memoryDir, "transcripts", homedDir, fileName), "utf-8");
|
|
291
|
+
assert.equal(homedContentAfter, homedContentBefore);
|
|
292
|
+
|
|
293
|
+
// Idempotent: a second run finds nothing and does not duplicate.
|
|
294
|
+
const second = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
295
|
+
assert.equal(second.plan.files.length, 0);
|
|
296
|
+
assert.equal(second.plan.movedEntries, 0);
|
|
297
|
+
const reread = await readFile(path.join(memoryDir, "transcripts", legacyDestDir, fileName), "utf-8");
|
|
298
|
+
assert.equal(reread.split("\n").filter(Boolean).length, 2);
|
|
299
|
+
} finally {
|
|
300
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("migration error detail is sanitized (no raw filesystem/stack message leak)", async () => {
|
|
305
|
+
// Thread 2 (cursor review on PR #1504): the per-file catch must route
|
|
306
|
+
// operator-facing strings (CLI output + audit manifest) through the shared
|
|
307
|
+
// displayErrorDetail() sanitizer so a raw fs path or stack detail cannot leak.
|
|
308
|
+
const memoryDir = await makeMemoryDir();
|
|
309
|
+
try {
|
|
310
|
+
const fileName = "2026-06-29.jsonl";
|
|
311
|
+
// Arbitrary key routes to transcripts/session/<hash>. Planting a FILE at
|
|
312
|
+
// transcripts/session makes the destination `mkdir` fail with ENOTDIR — a
|
|
313
|
+
// realistic write-time failure whose raw OS message embeds an absolute path.
|
|
314
|
+
await seedMixedOtherDefault(memoryDir, fileName, [entryLine("pi-geek:abc123", "1", "user")]);
|
|
315
|
+
await writeFile(path.join(memoryDir, "transcripts", "session"), "blocker", "utf-8");
|
|
316
|
+
|
|
317
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
318
|
+
|
|
319
|
+
assert.equal(result.errors.length, 1);
|
|
320
|
+
const message = result.errors[0];
|
|
321
|
+
// Sanitized: includes the relative source path + the error name/code only.
|
|
322
|
+
assert.ok(
|
|
323
|
+
message.startsWith(`Failed to migrate ${path.join("other", "default", fileName)}`),
|
|
324
|
+
`unexpected error prefix: ${message}`,
|
|
325
|
+
);
|
|
326
|
+
assert.match(message, /\(ENOTDIR\)/);
|
|
327
|
+
// Must NOT leak the raw OS message or any absolute filesystem path.
|
|
328
|
+
assert.ok(!message.includes(memoryDir), "sanitized error must not leak an absolute fs path");
|
|
329
|
+
assert.ok(
|
|
330
|
+
!/not a directory|open '|write '/i.test(message),
|
|
331
|
+
`sanitized error must not leak the raw OS message: ${message}`,
|
|
332
|
+
);
|
|
333
|
+
} finally {
|
|
334
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("apply writes an audit manifest", async () => {
|
|
339
|
+
const memoryDir = await makeMemoryDir();
|
|
340
|
+
try {
|
|
341
|
+
await seedMixedOtherDefault(memoryDir, "2026-06-29.jsonl", [entryLine("pi-geek:abc123", "1", "user")]);
|
|
342
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
343
|
+
const manifestPath = result.manifestPath;
|
|
344
|
+
assert.ok(manifestPath);
|
|
345
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf-8"));
|
|
346
|
+
assert.equal(manifest.applied, true);
|
|
347
|
+
} finally {
|
|
348
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
});
|