@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
package/src/cli.ts
CHANGED
|
@@ -202,6 +202,10 @@ import {
|
|
|
202
202
|
type SessionRepairApplyResult,
|
|
203
203
|
type SessionRepairPlan,
|
|
204
204
|
} from "./session-integrity.js";
|
|
205
|
+
import {
|
|
206
|
+
migrateSessionTranscripts,
|
|
207
|
+
planSessionTranscriptMigration,
|
|
208
|
+
} from "./session-transcript-migration.js";
|
|
205
209
|
import type { TierMigrationCycleSummary, TierMigrationStatusSnapshot } from "./recall-state.js";
|
|
206
210
|
import {
|
|
207
211
|
readRuntimePolicySnapshot as readPolicyRuntimeSnapshot,
|
|
@@ -8650,6 +8654,81 @@ export function registerCli(
|
|
|
8650
8654
|
}
|
|
8651
8655
|
});
|
|
8652
8656
|
|
|
8657
|
+
// ── Sessions subcommand (issue #1496) ───────────────────────────────
|
|
8658
|
+
const sessionsCmd = cmd
|
|
8659
|
+
.command("sessions")
|
|
8660
|
+
.description("Inspect and migrate session transcript storage");
|
|
8661
|
+
|
|
8662
|
+
sessionsCmd
|
|
8663
|
+
.command("migrate-transcripts")
|
|
8664
|
+
.description(
|
|
8665
|
+
"Split conflated other/default transcripts into first-class session/<hash> dirs",
|
|
8666
|
+
)
|
|
8667
|
+
.option("--dry-run", "Report the migration plan without moving files (default)")
|
|
8668
|
+
.option("--apply", "Apply the migration (move files; default is dry-run)")
|
|
8669
|
+
.action(async (...args: unknown[]) => {
|
|
8670
|
+
const options = (args[0] ?? {}) as Record<string, unknown>;
|
|
8671
|
+
const dryRunFlag = options.dryRun === true;
|
|
8672
|
+
const applyFlag = options.apply === true;
|
|
8673
|
+
|
|
8674
|
+
// Rule #51: reject contradictory flags rather than silently picking one.
|
|
8675
|
+
if (dryRunFlag && applyFlag) {
|
|
8676
|
+
console.error("Cannot pass both --dry-run and --apply. Choose one.");
|
|
8677
|
+
process.exit(1);
|
|
8678
|
+
}
|
|
8679
|
+
|
|
8680
|
+
// Safe default: dry-run when neither flag is given.
|
|
8681
|
+
const apply = applyFlag;
|
|
8682
|
+
const memoryDir = orchestrator.config.memoryDir;
|
|
8683
|
+
|
|
8684
|
+
if (!apply) {
|
|
8685
|
+
const plan = await planSessionTranscriptMigration({ memoryDir });
|
|
8686
|
+
console.log("=== Session Transcript Migration (DRY RUN) ===\n");
|
|
8687
|
+
if (!dryRunFlag && !applyFlag) {
|
|
8688
|
+
console.log("No flag given — defaulting to --dry-run. Pass --apply to migrate.\n");
|
|
8689
|
+
}
|
|
8690
|
+
console.log(`transcripts dir: ${plan.transcriptsDir}`);
|
|
8691
|
+
console.log(`files to migrate: ${plan.files.length}`);
|
|
8692
|
+
console.log(`distinct sessions: ${plan.distinctSessions}`);
|
|
8693
|
+
console.log(`entries to move: ${plan.movedEntries}`);
|
|
8694
|
+
if (plan.files.length > 0) {
|
|
8695
|
+
console.log("\nPlanned splits:");
|
|
8696
|
+
for (const file of plan.files) {
|
|
8697
|
+
console.log(`- ${file.sourceRelPath}`);
|
|
8698
|
+
for (const group of file.groups) {
|
|
8699
|
+
console.log(
|
|
8700
|
+
` ${group.entryCount} entr${group.entryCount === 1 ? "y" : "ies"} → ${group.destDir} (${group.legacy ? "legacy" : "session"})`,
|
|
8701
|
+
);
|
|
8702
|
+
}
|
|
8703
|
+
if (file.unmovableLines > 0) {
|
|
8704
|
+
console.log(` ${file.unmovableLines} unmovable line(s) retained in source`);
|
|
8705
|
+
}
|
|
8706
|
+
}
|
|
8707
|
+
}
|
|
8708
|
+
console.log("\nDRY RUN — no files changed.");
|
|
8709
|
+
return;
|
|
8710
|
+
}
|
|
8711
|
+
|
|
8712
|
+
const result = await migrateSessionTranscripts({ memoryDir, apply: true });
|
|
8713
|
+
console.log("=== Session Transcript Migration (APPLY) ===\n");
|
|
8714
|
+
console.log(`files rewritten: ${result.filesRewritten}`);
|
|
8715
|
+
console.log(`files removed: ${result.filesRemoved}`);
|
|
8716
|
+
console.log(`distinct sessions: ${result.plan.distinctSessions}`);
|
|
8717
|
+
console.log(`entries moved: ${result.plan.movedEntries}`);
|
|
8718
|
+
if (result.manifestPath) {
|
|
8719
|
+
console.log(`manifest: ${result.manifestPath}`);
|
|
8720
|
+
}
|
|
8721
|
+
if (result.errors.length > 0) {
|
|
8722
|
+
console.log("\nErrors:");
|
|
8723
|
+
for (const err of result.errors) {
|
|
8724
|
+
console.log(`- ${err}`);
|
|
8725
|
+
}
|
|
8726
|
+
process.exitCode = 1;
|
|
8727
|
+
return;
|
|
8728
|
+
}
|
|
8729
|
+
console.log("\nOK");
|
|
8730
|
+
});
|
|
8731
|
+
|
|
8653
8732
|
// Checkpoint command
|
|
8654
8733
|
cmd
|
|
8655
8734
|
.command("checkpoint")
|
package/src/index.ts
CHANGED
|
@@ -324,6 +324,24 @@ export { MeilisearchBackend } from "./search/meilisearch-backend.js";
|
|
|
324
324
|
export { buildEntityRecallSection } from "./entity-retrieval.js";
|
|
325
325
|
export { resolvePrincipal } from "./namespaces/principal.js";
|
|
326
326
|
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Session identity / transcript pathing (issue #1496)
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
// Shared, deterministic session-key → storage-path resolution. Exported so
|
|
332
|
+
// downstream consumers (the @remnic/bench adapter + its tests, host adapters)
|
|
333
|
+
// resolve transcript directories through the SAME layer the core writer uses
|
|
334
|
+
// instead of hard-coding `other/default` (rule #20, #22, #26).
|
|
335
|
+
export {
|
|
336
|
+
SESSION_CHANNEL_TYPE,
|
|
337
|
+
parseSessionIdentity,
|
|
338
|
+
sessionStoragePaths,
|
|
339
|
+
legacyParserReadbackDir,
|
|
340
|
+
type SessionIdentity,
|
|
341
|
+
type SessionStoragePaths,
|
|
342
|
+
} from "./session-identity.js";
|
|
343
|
+
export { storagePathHash } from "./storage-paths.js";
|
|
344
|
+
|
|
327
345
|
// ---------------------------------------------------------------------------
|
|
328
346
|
// Trust zones
|
|
329
347
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
LEGACY_FALLBACK_CHANNEL_ID,
|
|
6
|
+
LEGACY_FALLBACK_CHANNEL_TYPE,
|
|
7
|
+
SESSION_CHANNEL_TYPE,
|
|
8
|
+
legacyParserReadbackDir,
|
|
9
|
+
parseSessionIdentity,
|
|
10
|
+
sessionStoragePaths,
|
|
11
|
+
} from "./session-identity.js";
|
|
12
|
+
|
|
13
|
+
test("legacy agent:<id>:main keeps main/default channel identity", () => {
|
|
14
|
+
const id = parseSessionIdentity("agent:generalist:main");
|
|
15
|
+
assert.equal(id.legacy, true);
|
|
16
|
+
assert.equal(id.channelType, "main");
|
|
17
|
+
assert.equal(id.channelId, "default");
|
|
18
|
+
assert.equal(id.canonicalSessionKey, "agent:generalist:main");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("legacy discord channel keeps discord/<channelId> identity", () => {
|
|
22
|
+
const id = parseSessionIdentity("agent:generalist:discord:channel:998877");
|
|
23
|
+
assert.equal(id.legacy, true);
|
|
24
|
+
assert.equal(id.channelType, "discord");
|
|
25
|
+
assert.equal(id.channelId, "998877");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("legacy slack channel keeps slack/<channelId> identity", () => {
|
|
29
|
+
const id = parseSessionIdentity("agent:generalist:slack:channel:C12345");
|
|
30
|
+
assert.equal(id.legacy, true);
|
|
31
|
+
assert.equal(id.channelType, "slack");
|
|
32
|
+
assert.equal(id.channelId, "C12345");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("legacy cron job keeps cron/<jobId> identity", () => {
|
|
36
|
+
const id = parseSessionIdentity("agent:generalist:cron:nightly-sync");
|
|
37
|
+
assert.equal(id.legacy, true);
|
|
38
|
+
assert.equal(id.channelType, "cron");
|
|
39
|
+
assert.equal(id.channelId, "nightly-sync");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("arbitrary key becomes a first-class session/<hash> identity, never other/default", () => {
|
|
43
|
+
const id = parseSessionIdentity("pi-geek:abc123");
|
|
44
|
+
assert.equal(id.legacy, false);
|
|
45
|
+
assert.equal(id.channelType, SESSION_CHANNEL_TYPE);
|
|
46
|
+
assert.notEqual(id.channelType, LEGACY_FALLBACK_CHANNEL_TYPE);
|
|
47
|
+
assert.notEqual(id.channelId, LEGACY_FALLBACK_CHANNEL_ID);
|
|
48
|
+
assert.match(id.channelId, /^[0-9a-f]{16}$/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("distinct arbitrary keys get distinct, collision-resistant hashes", () => {
|
|
52
|
+
const geek = parseSessionIdentity("pi-geek:abc123");
|
|
53
|
+
const friend = parseSessionIdentity("pi-friend:def456");
|
|
54
|
+
assert.notEqual(geek.channelId, friend.channelId);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("identity is deterministic for the same key", () => {
|
|
58
|
+
const a = parseSessionIdentity("pi-geek:abc123");
|
|
59
|
+
const b = parseSessionIdentity("pi-geek:abc123");
|
|
60
|
+
assert.deepEqual(a, b);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a bare token without the agent prefix is treated as arbitrary, not legacy", () => {
|
|
64
|
+
// "foo:bar:baz" must NOT be misread as channelType="baz".
|
|
65
|
+
const id = parseSessionIdentity("foo:bar:baz");
|
|
66
|
+
assert.equal(id.legacy, false);
|
|
67
|
+
assert.equal(id.channelType, SESSION_CHANNEL_TYPE);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("empty session key resolves without throwing", () => {
|
|
71
|
+
const id = parseSessionIdentity("");
|
|
72
|
+
assert.equal(id.legacy, false);
|
|
73
|
+
assert.equal(id.channelType, SESSION_CHANNEL_TYPE);
|
|
74
|
+
assert.equal(typeof id.channelId, "string");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("sessionStoragePaths routes arbitrary keys to session/<hash>", () => {
|
|
78
|
+
const paths = sessionStoragePaths("pi-geek:abc123");
|
|
79
|
+
assert.equal(paths.channelType, SESSION_CHANNEL_TYPE);
|
|
80
|
+
assert.match(paths.dir, /^session\/[0-9a-f]{16}$/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("sessionStoragePaths keeps legacy main at main/default", () => {
|
|
84
|
+
const paths = sessionStoragePaths("agent:generalist:main");
|
|
85
|
+
assert.equal(paths.dir, "main/default");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("two arbitrary keys never share a storage dir", () => {
|
|
89
|
+
const a = sessionStoragePaths("pi-geek:abc123");
|
|
90
|
+
const b = sessionStoragePaths("pi-friend:def456");
|
|
91
|
+
assert.notEqual(a.dir, b.dir);
|
|
92
|
+
assert.notEqual(a.dir, "other/default");
|
|
93
|
+
assert.notEqual(b.dir, "other/default");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("display label strips control characters and separators", () => {
|
|
97
|
+
const id = parseSessionIdentity("pi-geek:abc/123");
|
|
98
|
+
assert.ok(!id.displayLabel.includes("/"));
|
|
99
|
+
assert.ok(id.displayLabel.length > 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("legacyParserReadbackDir reconstructs the OLD parser dir for >=3-part keys", () => {
|
|
103
|
+
// foo:bar:baz → channelType=baz, channelId=default
|
|
104
|
+
assert.equal(legacyParserReadbackDir("foo:bar:baz"), "baz/default");
|
|
105
|
+
// foo:bar:baz:qux → channelType=baz, channelId=qux
|
|
106
|
+
assert.equal(legacyParserReadbackDir("foo:bar:baz:qux"), "baz/qux");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("legacyParserReadbackDir returns undefined for <3-part keys (old build used other/default)", () => {
|
|
110
|
+
assert.equal(legacyParserReadbackDir("pi-geek:abc123"), undefined);
|
|
111
|
+
assert.equal(legacyParserReadbackDir("bare"), undefined);
|
|
112
|
+
assert.equal(legacyParserReadbackDir(""), undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("readbackDirs for a >=3-part arbitrary key include other/default AND the old parser dir", () => {
|
|
116
|
+
const paths = sessionStoragePaths("foo:bar:baz");
|
|
117
|
+
assert.match(paths.dir, /^session\/[0-9a-f]{16}$/);
|
|
118
|
+
assert.ok(paths.readbackDirs.includes("other/default"));
|
|
119
|
+
assert.ok(paths.readbackDirs.includes("baz/default"));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("readbackDirs for a 2-part arbitrary key include only other/default", () => {
|
|
123
|
+
const paths = sessionStoragePaths("pi-geek:abc123");
|
|
124
|
+
assert.deepEqual(paths.readbackDirs, ["other/default"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("legacy agent:<id>:... keys expose no read-back dirs (their location never moved)", () => {
|
|
128
|
+
assert.deepEqual(sessionStoragePaths("agent:generalist:main").readbackDirs, []);
|
|
129
|
+
assert.deepEqual(sessionStoragePaths("agent:generalist:discord:channel:998877").readbackDirs, []);
|
|
130
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
encodeStoragePathSegment,
|
|
4
|
+
encodeStoragePathSegmentWithHash,
|
|
5
|
+
isSafeLegacyPathSegment,
|
|
6
|
+
storagePathHash,
|
|
7
|
+
} from "./storage-paths.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared session-identity / transcript-pathing layer (issue #1496).
|
|
11
|
+
*
|
|
12
|
+
* This module is the SINGLE source of truth for turning a `sessionKey` into a
|
|
13
|
+
* channel identity and a set of deterministic, collision-resistant storage
|
|
14
|
+
* paths. Every subsystem that previously re-parsed session keys (transcript
|
|
15
|
+
* pathing, tool-usage pathing, hourly summaries, session auditing, conversation
|
|
16
|
+
* indexing) MUST route through `parseSessionIdentity()` / `sessionStoragePaths()`
|
|
17
|
+
* so behavior stays identical across paths (rule #22, #39).
|
|
18
|
+
*
|
|
19
|
+
* Two shapes are recognized:
|
|
20
|
+
*
|
|
21
|
+
* 1. Legacy `agent:<agentId>:<channelType>:...` keys keep their existing
|
|
22
|
+
* readable channel identity:
|
|
23
|
+
* - agent:<id>:main → type="main", id="default"
|
|
24
|
+
* - agent:<id>:discord:channel:<chanId> → type="discord", id="<chanId>"
|
|
25
|
+
* - agent:<id>:slack:channel:<chanId> → type="slack", id="<chanId>"
|
|
26
|
+
* - agent:<id>:cron:<jobId> → type="cron", id="<jobId>"
|
|
27
|
+
* - agent:<id>:<other>[:<id>] → type="<other>", id="<id?>"
|
|
28
|
+
*
|
|
29
|
+
* 2. Arbitrary / non-legacy keys (e.g. `pi-geek:abc123`) become first-class
|
|
30
|
+
* from the FIRST write — they NEVER start life under `other/default`:
|
|
31
|
+
* channelType = "session"
|
|
32
|
+
* channelId = storagePathHash(sessionKey) (collision-resistant)
|
|
33
|
+
* yielding `transcripts/session/<hash>/YYYY-MM-DD.jsonl`.
|
|
34
|
+
*
|
|
35
|
+
* The reserved `"session"` channel type is what makes arbitrary keys isolated
|
|
36
|
+
* and auditable. Legacy data under `other/default` (and any legacy channel
|
|
37
|
+
* directory) remains READABLE via `legacyDir` / `alternateDir` candidates.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Channel type reserved for first-class arbitrary (non-legacy) session keys. */
|
|
41
|
+
export const SESSION_CHANNEL_TYPE = "session";
|
|
42
|
+
|
|
43
|
+
/** Legacy fallback channel identity for un-parseable keys (read-back only). */
|
|
44
|
+
export const LEGACY_FALLBACK_CHANNEL_TYPE = "other";
|
|
45
|
+
export const LEGACY_FALLBACK_CHANNEL_ID = "default";
|
|
46
|
+
|
|
47
|
+
/** Max characters of the raw session key kept in a human-readable display label. */
|
|
48
|
+
const DISPLAY_LABEL_MAX_LENGTH = 64;
|
|
49
|
+
|
|
50
|
+
export interface SessionIdentity {
|
|
51
|
+
/** Channel type — legacy channel type for known shapes, else `"session"`. */
|
|
52
|
+
channelType: string;
|
|
53
|
+
/** Channel id — legacy channel id for known shapes, else the key hash. */
|
|
54
|
+
channelId: string;
|
|
55
|
+
/** Human-readable, non-authoritative label derived from the raw key. */
|
|
56
|
+
displayLabel: string;
|
|
57
|
+
/** The original session key, unchanged. */
|
|
58
|
+
canonicalSessionKey: string;
|
|
59
|
+
/** True when the key matched a known legacy `agent:<id>:...` shape. */
|
|
60
|
+
legacy: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SessionStoragePaths {
|
|
64
|
+
/** Channel type (mirrors {@link SessionIdentity.channelType}). */
|
|
65
|
+
channelType: string;
|
|
66
|
+
/** Channel id (mirrors {@link SessionIdentity.channelId}). */
|
|
67
|
+
channelId: string;
|
|
68
|
+
/** Primary encoded storage subdirectory: `<encodedType>/<encodedId>`. */
|
|
69
|
+
dir: string;
|
|
70
|
+
/**
|
|
71
|
+
* Collision-resistant alternate subdirectory that always embeds the full
|
|
72
|
+
* session-key hash. Used both as a write-time collision escape hatch and as a
|
|
73
|
+
* read-back candidate for data written before this layer existed.
|
|
74
|
+
*/
|
|
75
|
+
alternateDir: string;
|
|
76
|
+
/**
|
|
77
|
+
* Un-encoded `<type>/<id>` directory for reading data written by older
|
|
78
|
+
* builds that did not URL-encode path segments. Undefined when it would be
|
|
79
|
+
* identical to `dir` or when either segment is path-unsafe.
|
|
80
|
+
*/
|
|
81
|
+
legacyDir?: string;
|
|
82
|
+
/**
|
|
83
|
+
* Ordered, de-duplicated list of READ-BACK-ONLY directories where an older
|
|
84
|
+
* build may have stranded this key's data. NEW writes never target these —
|
|
85
|
+
* they exist purely so pre-#1496 transcripts/tool-usage stay discoverable
|
|
86
|
+
* (and migratable). Populated only for non-legacy keys. Includes:
|
|
87
|
+
* 1. the shared `other/default` fallback (every arbitrary key landed here);
|
|
88
|
+
* 2. the directory the OLD `parts.length >= 3` parser would have chosen
|
|
89
|
+
* (e.g. `foo:bar:baz` → `baz/default`, `foo:bar:baz:qux` → `baz/qux`).
|
|
90
|
+
* See {@link legacyParserReadbackDir}.
|
|
91
|
+
*/
|
|
92
|
+
readbackDirs: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse a session key into a stable channel identity.
|
|
97
|
+
*
|
|
98
|
+
* Pure and deterministic — no filesystem access. Same input always yields the
|
|
99
|
+
* same identity, which is what makes arbitrary keys collision-resistant from
|
|
100
|
+
* the first write.
|
|
101
|
+
*/
|
|
102
|
+
export function parseSessionIdentity(sessionKey: string): SessionIdentity {
|
|
103
|
+
const canonicalSessionKey = typeof sessionKey === "string" ? sessionKey : "";
|
|
104
|
+
const legacy = parseLegacyChannelIdentity(canonicalSessionKey);
|
|
105
|
+
if (legacy) {
|
|
106
|
+
return {
|
|
107
|
+
channelType: legacy.channelType,
|
|
108
|
+
channelId: legacy.channelId,
|
|
109
|
+
displayLabel: safeDisplayLabel(canonicalSessionKey),
|
|
110
|
+
canonicalSessionKey,
|
|
111
|
+
legacy: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
channelType: SESSION_CHANNEL_TYPE,
|
|
117
|
+
channelId: storagePathHash(canonicalSessionKey),
|
|
118
|
+
displayLabel: safeDisplayLabel(canonicalSessionKey),
|
|
119
|
+
canonicalSessionKey,
|
|
120
|
+
legacy: false,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the storage path pieces for a session key. Wraps
|
|
126
|
+
* {@link parseSessionIdentity} so transcript, tool-usage, and summary writers
|
|
127
|
+
* share one implementation (rule #22).
|
|
128
|
+
*/
|
|
129
|
+
export function sessionStoragePaths(sessionKey: string): SessionStoragePaths {
|
|
130
|
+
const identity = parseSessionIdentity(sessionKey);
|
|
131
|
+
const { channelType, channelId, canonicalSessionKey } = identity;
|
|
132
|
+
|
|
133
|
+
const dir = path.join(encodeStoragePathSegment(channelType), encodeStoragePathSegment(channelId));
|
|
134
|
+
|
|
135
|
+
const alternateDir = path.join(
|
|
136
|
+
encodeStoragePathSegmentWithHash(channelType),
|
|
137
|
+
`${encodeStoragePathSegmentWithHash(channelId)}--session-${storagePathHash(canonicalSessionKey)}`
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
let legacyDir: string | undefined;
|
|
141
|
+
if (isSafeLegacyPathSegment(channelType) && isSafeLegacyPathSegment(channelId)) {
|
|
142
|
+
const candidate = path.join(channelType, channelId);
|
|
143
|
+
if (candidate !== dir) {
|
|
144
|
+
legacyDir = candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Read-back-only candidates are only relevant for non-legacy keys: legacy
|
|
149
|
+
// `agent:<id>:...` keys still resolve to their original channel directory, so
|
|
150
|
+
// nothing about their on-disk location moved.
|
|
151
|
+
const readbackDirs: string[] = [];
|
|
152
|
+
if (!identity.legacy) {
|
|
153
|
+
const seen = new Set<string>([dir]);
|
|
154
|
+
if (alternateDir) seen.add(alternateDir);
|
|
155
|
+
if (legacyDir) seen.add(legacyDir);
|
|
156
|
+
for (const candidate of [OTHER_DEFAULT_READBACK_DIR, legacyParserReadbackDir(canonicalSessionKey)]) {
|
|
157
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
158
|
+
seen.add(candidate);
|
|
159
|
+
readbackDirs.push(candidate);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { channelType, channelId, dir, alternateDir, legacyDir, readbackDirs };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The shared `other/default` directory every arbitrary key was routed into by
|
|
168
|
+
* builds predating issue #1496. Exposed so transcript/tool-usage/summary read
|
|
169
|
+
* paths and the migration scanner all agree on the same fallback location.
|
|
170
|
+
*/
|
|
171
|
+
export const OTHER_DEFAULT_READBACK_DIR = path.join(
|
|
172
|
+
LEGACY_FALLBACK_CHANNEL_TYPE,
|
|
173
|
+
LEGACY_FALLBACK_CHANNEL_ID
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Reconstruct the transcript/tool-usage directory the OLD `getTranscriptPath`
|
|
178
|
+
* parser (pre-#1496) would have produced for a key the NEW parser reclassifies
|
|
179
|
+
* as a first-class `session/<hash>` identity.
|
|
180
|
+
*
|
|
181
|
+
* The OLD parser treated ANY key with `parts.length >= 3` as legacy — it did
|
|
182
|
+
* NOT require a leading `agent` segment — so an arbitrary key like
|
|
183
|
+
* `foo:bar:baz` was stored under `baz/default` and `foo:bar:baz:qux` under
|
|
184
|
+
* `baz/qux`. Those directories must stay readable (and migratable) for existing
|
|
185
|
+
* installs even though the key now writes to `session/<hash>` (Thread B / codex
|
|
186
|
+
* review on PR #1504). Path segments are encoded exactly as the old `dir` was
|
|
187
|
+
* built (`encodeStoragePathSegment`) so the candidate matches the bytes on disk.
|
|
188
|
+
*
|
|
189
|
+
* Returns `undefined` when the key has fewer than three colon parts (the old
|
|
190
|
+
* parser would have used the `other/default` fallback, already covered), when
|
|
191
|
+
* the channel type is empty, or when the result is unsafe.
|
|
192
|
+
*/
|
|
193
|
+
export function legacyParserReadbackDir(sessionKey: string): string | undefined {
|
|
194
|
+
if (typeof sessionKey !== "string" || sessionKey.length === 0) return undefined;
|
|
195
|
+
const parts = sessionKey.split(":");
|
|
196
|
+
if (parts.length < 3) return undefined;
|
|
197
|
+
|
|
198
|
+
const channelType = parts[2];
|
|
199
|
+
if (!channelType || channelType.length === 0) return undefined;
|
|
200
|
+
|
|
201
|
+
// Mirror the OLD parser's channelId derivation for parts.length >= 3 keys.
|
|
202
|
+
let channelId = LEGACY_FALLBACK_CHANNEL_ID;
|
|
203
|
+
if (channelType === "main") {
|
|
204
|
+
channelId = "default";
|
|
205
|
+
} else if (channelType === "discord" && parts.length >= 5 && parts[3] === "channel") {
|
|
206
|
+
channelId = parts[4];
|
|
207
|
+
} else if (channelType === "slack" && parts.length >= 5 && parts[3] === "channel") {
|
|
208
|
+
channelId = parts[4];
|
|
209
|
+
} else if (channelType === "cron" && parts.length >= 4) {
|
|
210
|
+
channelId = parts[3];
|
|
211
|
+
} else if (parts.length >= 4) {
|
|
212
|
+
channelId = parts[3];
|
|
213
|
+
}
|
|
214
|
+
if (!channelId || channelId.length === 0) {
|
|
215
|
+
channelId = LEGACY_FALLBACK_CHANNEL_ID;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return path.join(encodeStoragePathSegment(channelType), encodeStoragePathSegment(channelId));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse the legacy `agent:<agentId>:<channelType>:...` shape. Returns
|
|
223
|
+
* `undefined` for anything that is not a recognized legacy key, so the caller
|
|
224
|
+
* can route it to a first-class `session/<hash>` identity instead.
|
|
225
|
+
*
|
|
226
|
+
* A legacy key must start with the literal `agent` segment and a non-empty
|
|
227
|
+
* agent id; otherwise an arbitrary key like `pi-geek:abc123` would be
|
|
228
|
+
* misread as `channelType = "abc123"`.
|
|
229
|
+
*/
|
|
230
|
+
function parseLegacyChannelIdentity(sessionKey: string): { channelType: string; channelId: string } | undefined {
|
|
231
|
+
if (sessionKey.length === 0) return undefined;
|
|
232
|
+
const parts = sessionKey.split(":");
|
|
233
|
+
if (parts.length < 3) return undefined;
|
|
234
|
+
if (parts[0] !== "agent") return undefined;
|
|
235
|
+
if (!parts[1] || parts[1].length === 0) return undefined;
|
|
236
|
+
|
|
237
|
+
const channelType = parts[2];
|
|
238
|
+
if (!channelType || channelType.length === 0) return undefined;
|
|
239
|
+
|
|
240
|
+
let channelId = LEGACY_FALLBACK_CHANNEL_ID;
|
|
241
|
+
if (channelType === "main") {
|
|
242
|
+
channelId = "default";
|
|
243
|
+
} else if (channelType === "discord" && parts.length >= 5 && parts[3] === "channel") {
|
|
244
|
+
channelId = parts[4];
|
|
245
|
+
} else if (channelType === "slack" && parts.length >= 5 && parts[3] === "channel") {
|
|
246
|
+
channelId = parts[4];
|
|
247
|
+
} else if (channelType === "cron" && parts.length >= 4) {
|
|
248
|
+
channelId = parts[3];
|
|
249
|
+
} else if (parts.length >= 4) {
|
|
250
|
+
channelId = parts[3];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!channelId || channelId.length === 0) {
|
|
254
|
+
channelId = LEGACY_FALLBACK_CHANNEL_ID;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { channelType, channelId };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A short, human-readable label for dashboards/audits. Never used for storage
|
|
262
|
+
* routing — `channelId` (the hash) owns isolation. Control characters and path
|
|
263
|
+
* separators are stripped so the label is safe to render.
|
|
264
|
+
*/
|
|
265
|
+
function safeDisplayLabel(sessionKey: string): string {
|
|
266
|
+
if (sessionKey.length === 0) return "(empty)";
|
|
267
|
+
let out = "";
|
|
268
|
+
for (const char of sessionKey) {
|
|
269
|
+
const code = char.codePointAt(0) ?? 0;
|
|
270
|
+
// Drop ASCII control characters (0x00-0x1F) and DEL (0x7F).
|
|
271
|
+
if (code <= 0x1f || code === 0x7f) continue;
|
|
272
|
+
if (char === "/" || char === "\\") {
|
|
273
|
+
out += "_";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
out += char;
|
|
277
|
+
}
|
|
278
|
+
const trimmed = out.trim();
|
|
279
|
+
if (trimmed.length === 0) return "(empty)";
|
|
280
|
+
return trimmed.length > DISPLAY_LABEL_MAX_LENGTH ? `${trimmed.slice(0, DISPLAY_LABEL_MAX_LENGTH - 1)}…` : trimmed;
|
|
281
|
+
}
|