@remnic/core 10.0.76 → 10.0.78
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-admin-ops-surface.js +27 -27
- package/dist/access-authorization-probe.js +28 -28
- package/dist/access-boundary.js +27 -27
- package/dist/access-cli.js +78 -77
- package/dist/access-cli.js.map +1 -1
- package/dist/access-extraction-force-flush.js +27 -27
- package/dist/access-http-briefing.js +28 -28
- package/dist/access-http-query.js +29 -29
- package/dist/access-http.js +45 -45
- package/dist/access-identity-continuity-surface.js +27 -27
- package/dist/access-lcm-surface.js +27 -27
- package/dist/access-mcp.js +37 -37
- package/dist/access-namespace-preflight.js +27 -27
- package/dist/access-observe-helpers.d.ts.map +1 -1
- package/dist/access-observe-helpers.js +1 -1
- package/dist/access-observe-write-category.js +5 -5
- package/dist/access-observe-write-surface.js +27 -27
- package/dist/access-operations-batch.js +29 -29
- package/dist/access-operations.js +36 -36
- package/dist/access-recall-concurrency.js +27 -27
- package/dist/access-recall-response.js +27 -27
- package/dist/access-recall-surface.js +27 -27
- package/dist/access-schema.js +4 -4
- package/dist/access-service.js +27 -27
- package/dist/access-surface-catalog.js +29 -29
- package/dist/active-recall.js +7 -7
- package/dist/briefing.js +8 -8
- package/dist/calibration.js +1 -1
- package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
- package/dist/causal-consolidation.js +9 -9
- package/dist/{chunk-Z3CKK5RR.js → chunk-2XG7QECM.js} +3 -3
- package/dist/{chunk-BZTA7DR4.js → chunk-35RSL5B4.js} +3 -3
- package/dist/{chunk-3GGCMRO7.js → chunk-3RYP4OTZ.js} +2 -2
- package/dist/{chunk-VEQVXNWR.js → chunk-446H5UUO.js} +4 -4
- package/dist/{chunk-XC7OYOQC.js → chunk-4BCX6YLW.js} +2 -2
- package/dist/{chunk-RXGFITWS.js → chunk-4HLPFYF2.js} +7 -7
- package/dist/{chunk-WIAXVBQH.js → chunk-65SKM4BF.js} +2 -2
- package/dist/{chunk-VEWTHVX2.js → chunk-6HERQLF4.js} +2 -2
- package/dist/{chunk-4DLZ2ITE.js → chunk-6OGFQ5GH.js} +2 -2
- package/dist/{chunk-C44DRDZM.js → chunk-6WULHFNJ.js} +2 -2
- package/dist/{chunk-IVXOYE43.js → chunk-7IQ5HYBJ.js} +2 -2
- package/dist/{chunk-SZBZOUHR.js → chunk-A45ZOJV4.js} +2 -2
- package/dist/chunk-ADJXJKU6.js +74 -0
- package/dist/chunk-ADJXJKU6.js.map +1 -0
- package/dist/{chunk-DANFOVQG.js → chunk-B7PLF3HW.js} +2 -2
- package/dist/{chunk-PBE7NFB5.js → chunk-BNQJKWWZ.js} +4 -4
- package/dist/{chunk-AY2UK5XK.js → chunk-BSBGI4BD.js} +2 -2
- package/dist/{chunk-AY2UK5XK.js.map → chunk-BSBGI4BD.js.map} +1 -1
- package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
- package/dist/{chunk-IPHIIRDN.js → chunk-CYTTY4M2.js} +5 -7
- package/dist/chunk-CYTTY4M2.js.map +1 -0
- package/dist/{chunk-R24RYSQX.js → chunk-CZD654DQ.js} +22 -22
- package/dist/{chunk-MBENRPMY.js → chunk-D3WNJF7D.js} +12 -12
- package/dist/{chunk-5IQL4N37.js → chunk-D4WBMLPD.js} +2 -2
- package/dist/{chunk-FYQUUIPI.js → chunk-DLZGSZVC.js} +4 -4
- package/dist/{chunk-YK6SFDP2.js → chunk-DOJBV6WR.js} +4 -4
- package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
- package/dist/{chunk-FETPTCQZ.js → chunk-E33UJHY3.js} +2 -2
- package/dist/{chunk-LJUQPT5G.js → chunk-EFRMEOZQ.js} +3 -3
- package/dist/{chunk-HTDXNIO6.js → chunk-HK3CUTAG.js} +2 -2
- package/dist/{chunk-FGUWCE5I.js → chunk-HUF4S4MY.js} +2 -2
- package/dist/{chunk-6Q6AQ36B.js → chunk-HWHNQVRV.js} +2 -2
- package/dist/{chunk-SYDPJUFR.js → chunk-IDGCL5OP.js} +2 -2
- package/dist/{chunk-F3FNXE7C.js → chunk-JHJP2IAU.js} +3 -3
- package/dist/{chunk-IBQ7LGUJ.js → chunk-JIGHVQF6.js} +6 -6
- package/dist/{chunk-P7HNMNN2.js → chunk-K4SVTZWP.js} +7 -7
- package/dist/{chunk-KAED7IBB.js → chunk-KDWO22ZK.js} +2 -2
- package/dist/{chunk-N6GEGRCT.js → chunk-KYVBGDGA.js} +2 -2
- package/dist/{chunk-HB5TNQMM.js → chunk-L7BOS7KV.js} +2 -2
- package/dist/{chunk-3MBSD53O.js → chunk-LE4MQ2WF.js} +2 -2
- package/dist/{chunk-I4LJJ44E.js → chunk-LNHBYT3F.js} +2 -2
- package/dist/{chunk-SHVLNI4L.js → chunk-LU66I56Q.js} +2 -2
- package/dist/{chunk-TD5VTVPT.js → chunk-N2H6VC65.js} +3 -3
- package/dist/{chunk-L4QYW2HO.js → chunk-NBFHOQIO.js} +2 -2
- package/dist/{chunk-KNZDGZSY.js → chunk-NVYY2TJG.js} +2 -2
- package/dist/{chunk-7FFGWIBN.js → chunk-PCSQRQPJ.js} +2 -2
- package/dist/{chunk-P3AQ2YSX.js → chunk-PFUXPTBT.js} +4 -4
- package/dist/{chunk-KQAFEZQX.js → chunk-R45L4TV6.js} +6 -6
- package/dist/{chunk-E22CQZ3H.js → chunk-R63X5BQA.js} +12 -12
- package/dist/{chunk-PLGBPFEO.js → chunk-RF7QJ736.js} +2 -2
- package/dist/{chunk-ZZSFRWKD.js → chunk-RIBCP4DJ.js} +2 -2
- package/dist/{chunk-HDN7IVIO.js → chunk-RIZ5NUNN.js} +16 -62
- package/dist/chunk-RIZ5NUNN.js.map +1 -0
- package/dist/{chunk-HR6NTVYZ.js → chunk-S34XZUTY.js} +172 -39
- package/dist/chunk-S34XZUTY.js.map +1 -0
- package/dist/{chunk-DEGE32C3.js → chunk-SLORPQZT.js} +2 -2
- package/dist/{chunk-6RPPZL7W.js → chunk-SMSJOWVE.js} +2 -2
- package/dist/{chunk-X53HJENJ.js → chunk-SPYFXSFZ.js} +2 -2
- package/dist/{chunk-IRFQKR4Y.js → chunk-SW6OAWK7.js} +2 -2
- package/dist/{chunk-VPEMGBTI.js → chunk-SZEPOUW2.js} +2 -2
- package/dist/{chunk-COIQ5N4J.js → chunk-TSITK6QK.js} +2 -2
- package/dist/{chunk-HCFNZWFB.js → chunk-VBNXDC7P.js} +2 -2
- package/dist/{chunk-FP2AT4WL.js → chunk-VJ3HFCG2.js} +2 -2
- package/dist/{chunk-OBQ2NZAR.js → chunk-VKDAJJQP.js} +4 -4
- package/dist/{chunk-HZNURRDS.js → chunk-VUROWXFL.js} +2 -2
- package/dist/{chunk-G5NH3SSX.js → chunk-X3KFSJLQ.js} +2 -2
- package/dist/{chunk-GCJA5KKT.js → chunk-XBNO56HC.js} +2 -2
- package/dist/{chunk-GWOFCHPN.js → chunk-YGECOOTF.js} +2 -2
- package/dist/{chunk-2R7OCBRR.js → chunk-YK7GOPIE.js} +2 -2
- package/dist/{chunk-ELUG6NKQ.js → chunk-YSA4CYTK.js} +2 -2
- package/dist/{chunk-233S3MXC.js → chunk-YXUNSOHD.js} +3 -3
- package/dist/{chunk-SY4AFWUZ.js → chunk-Z3O7ZOCC.js} +30 -30
- package/dist/{chunk-ZG57BEUS.js → chunk-ZAU2OPDW.js} +2 -2
- package/dist/cli.js +63 -62
- package/dist/coding/export-okf-codegraph.js +7 -7
- package/dist/compounding/engine.js +9 -9
- package/dist/config.js +7 -7
- package/dist/connectors/codex-materialize-runner.js +8 -8
- package/dist/connectors/index.js +8 -8
- package/dist/convergence-refresh.js +10 -10
- package/dist/corpus-watermark.js +10 -10
- package/dist/deep-recall-http-glue.js +5 -5
- package/dist/entity-retrieval.js +8 -8
- package/dist/external-wiki-access.js +28 -28
- package/dist/extraction.js +10 -10
- package/dist/harmonic-retrieval.js +8 -8
- package/dist/index.js +86 -85
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +5 -5
- package/dist/local-llm.js +1 -1
- package/dist/location/index.js +10 -10
- package/dist/maintenance/memory-governance.js +8 -8
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
- package/dist/maintenance/rebuild-memory-projection-runner.js +10 -10
- package/dist/maintenance/rebuild-memory-projection.js +9 -9
- package/dist/memory-browse-operations.js +28 -28
- package/dist/namespaces/migrate.js +9 -9
- package/dist/namespaces/search.js +6 -6
- package/dist/namespaces/storage.js +8 -8
- package/dist/operator-doctor-corpus.js +11 -11
- package/dist/operator-toolkit.js +16 -16
- package/dist/orchestration/backlog-scheduler.js +79 -78
- package/dist/orchestration/backlog-scheduler.js.map +1 -1
- package/dist/orchestration/buffer-drain-runner.js +79 -78
- package/dist/orchestration/extraction-queue-snapshot-sharing.d.ts +113 -0
- package/dist/orchestration/extraction-queue-snapshot-sharing.d.ts.map +1 -0
- package/dist/orchestration/maintenance.js +19 -19
- package/dist/orchestration/turn-ingestion.d.ts +3 -0
- package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
- package/dist/orchestrator.js +78 -77
- package/dist/recall-navigation-operations.js +28 -28
- package/dist/recall-navigation.js +27 -27
- package/dist/resume-bundles.js +10 -9
- package/dist/search/factory.js +5 -5
- package/dist/search/index.js +5 -5
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/semantic-consolidation.js +9 -9
- package/dist/semantic-rule-promotion.js +8 -8
- package/dist/semantic-rule-verifier.js +8 -8
- package/dist/source-agent-qualifier.js +8 -8
- package/dist/standup/build.js +8 -8
- package/dist/storage.js +7 -7
- package/dist/summarizer.js +2 -2
- package/dist/support-passport/index.js +30 -30
- package/dist/transcript-ownership-digest.d.ts +31 -0
- package/dist/transcript-ownership-digest.d.ts.map +1 -0
- package/dist/transcript-ownership-digest.js +10 -0
- package/dist/transcript-ownership-digest.js.map +1 -0
- package/dist/transcript.d.ts +2 -5
- package/dist/transcript.d.ts.map +1 -1
- package/dist/transcript.js +2 -1
- package/dist/transfer/autodetect.js +1 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +3 -3
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/export-okf.js +8 -8
- package/dist/verified-recall.js +8 -8
- package/dist/who-knows-cli.js +9 -9
- package/dist/who-knows.js +8 -8
- package/package.json +2 -2
- package/src/access-observe-helpers.test.ts +156 -0
- package/src/access-observe-helpers.ts +6 -6
- package/src/local-llm-qos.test.ts +59 -0
- package/src/local-llm.ts +1 -1
- package/src/orchestration/extraction-queue-bounded-retention.test.ts +548 -0
- package/src/orchestration/extraction-queue-snapshot-sharing.ts +257 -0
- package/src/orchestration/turn-ingestion.ts +19 -6
- package/src/transcript-ownership-digest.test.ts +403 -0
- package/src/transcript-ownership-digest.ts +99 -0
- package/src/transcript.ts +14 -76
- package/dist/chunk-HDN7IVIO.js.map +0 -1
- package/dist/chunk-HR6NTVYZ.js.map +0 -1
- package/dist/chunk-IPHIIRDN.js.map +0 -1
- /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
- /package/dist/{chunk-Z3CKK5RR.js.map → chunk-2XG7QECM.js.map} +0 -0
- /package/dist/{chunk-BZTA7DR4.js.map → chunk-35RSL5B4.js.map} +0 -0
- /package/dist/{chunk-3GGCMRO7.js.map → chunk-3RYP4OTZ.js.map} +0 -0
- /package/dist/{chunk-VEQVXNWR.js.map → chunk-446H5UUO.js.map} +0 -0
- /package/dist/{chunk-XC7OYOQC.js.map → chunk-4BCX6YLW.js.map} +0 -0
- /package/dist/{chunk-RXGFITWS.js.map → chunk-4HLPFYF2.js.map} +0 -0
- /package/dist/{chunk-WIAXVBQH.js.map → chunk-65SKM4BF.js.map} +0 -0
- /package/dist/{chunk-VEWTHVX2.js.map → chunk-6HERQLF4.js.map} +0 -0
- /package/dist/{chunk-4DLZ2ITE.js.map → chunk-6OGFQ5GH.js.map} +0 -0
- /package/dist/{chunk-C44DRDZM.js.map → chunk-6WULHFNJ.js.map} +0 -0
- /package/dist/{chunk-IVXOYE43.js.map → chunk-7IQ5HYBJ.js.map} +0 -0
- /package/dist/{chunk-SZBZOUHR.js.map → chunk-A45ZOJV4.js.map} +0 -0
- /package/dist/{chunk-DANFOVQG.js.map → chunk-B7PLF3HW.js.map} +0 -0
- /package/dist/{chunk-PBE7NFB5.js.map → chunk-BNQJKWWZ.js.map} +0 -0
- /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
- /package/dist/{chunk-R24RYSQX.js.map → chunk-CZD654DQ.js.map} +0 -0
- /package/dist/{chunk-MBENRPMY.js.map → chunk-D3WNJF7D.js.map} +0 -0
- /package/dist/{chunk-5IQL4N37.js.map → chunk-D4WBMLPD.js.map} +0 -0
- /package/dist/{chunk-FYQUUIPI.js.map → chunk-DLZGSZVC.js.map} +0 -0
- /package/dist/{chunk-YK6SFDP2.js.map → chunk-DOJBV6WR.js.map} +0 -0
- /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
- /package/dist/{chunk-FETPTCQZ.js.map → chunk-E33UJHY3.js.map} +0 -0
- /package/dist/{chunk-LJUQPT5G.js.map → chunk-EFRMEOZQ.js.map} +0 -0
- /package/dist/{chunk-HTDXNIO6.js.map → chunk-HK3CUTAG.js.map} +0 -0
- /package/dist/{chunk-FGUWCE5I.js.map → chunk-HUF4S4MY.js.map} +0 -0
- /package/dist/{chunk-6Q6AQ36B.js.map → chunk-HWHNQVRV.js.map} +0 -0
- /package/dist/{chunk-SYDPJUFR.js.map → chunk-IDGCL5OP.js.map} +0 -0
- /package/dist/{chunk-F3FNXE7C.js.map → chunk-JHJP2IAU.js.map} +0 -0
- /package/dist/{chunk-IBQ7LGUJ.js.map → chunk-JIGHVQF6.js.map} +0 -0
- /package/dist/{chunk-P7HNMNN2.js.map → chunk-K4SVTZWP.js.map} +0 -0
- /package/dist/{chunk-KAED7IBB.js.map → chunk-KDWO22ZK.js.map} +0 -0
- /package/dist/{chunk-N6GEGRCT.js.map → chunk-KYVBGDGA.js.map} +0 -0
- /package/dist/{chunk-HB5TNQMM.js.map → chunk-L7BOS7KV.js.map} +0 -0
- /package/dist/{chunk-3MBSD53O.js.map → chunk-LE4MQ2WF.js.map} +0 -0
- /package/dist/{chunk-I4LJJ44E.js.map → chunk-LNHBYT3F.js.map} +0 -0
- /package/dist/{chunk-SHVLNI4L.js.map → chunk-LU66I56Q.js.map} +0 -0
- /package/dist/{chunk-TD5VTVPT.js.map → chunk-N2H6VC65.js.map} +0 -0
- /package/dist/{chunk-L4QYW2HO.js.map → chunk-NBFHOQIO.js.map} +0 -0
- /package/dist/{chunk-KNZDGZSY.js.map → chunk-NVYY2TJG.js.map} +0 -0
- /package/dist/{chunk-7FFGWIBN.js.map → chunk-PCSQRQPJ.js.map} +0 -0
- /package/dist/{chunk-P3AQ2YSX.js.map → chunk-PFUXPTBT.js.map} +0 -0
- /package/dist/{chunk-KQAFEZQX.js.map → chunk-R45L4TV6.js.map} +0 -0
- /package/dist/{chunk-E22CQZ3H.js.map → chunk-R63X5BQA.js.map} +0 -0
- /package/dist/{chunk-PLGBPFEO.js.map → chunk-RF7QJ736.js.map} +0 -0
- /package/dist/{chunk-ZZSFRWKD.js.map → chunk-RIBCP4DJ.js.map} +0 -0
- /package/dist/{chunk-DEGE32C3.js.map → chunk-SLORPQZT.js.map} +0 -0
- /package/dist/{chunk-6RPPZL7W.js.map → chunk-SMSJOWVE.js.map} +0 -0
- /package/dist/{chunk-X53HJENJ.js.map → chunk-SPYFXSFZ.js.map} +0 -0
- /package/dist/{chunk-IRFQKR4Y.js.map → chunk-SW6OAWK7.js.map} +0 -0
- /package/dist/{chunk-VPEMGBTI.js.map → chunk-SZEPOUW2.js.map} +0 -0
- /package/dist/{chunk-COIQ5N4J.js.map → chunk-TSITK6QK.js.map} +0 -0
- /package/dist/{chunk-HCFNZWFB.js.map → chunk-VBNXDC7P.js.map} +0 -0
- /package/dist/{chunk-FP2AT4WL.js.map → chunk-VJ3HFCG2.js.map} +0 -0
- /package/dist/{chunk-OBQ2NZAR.js.map → chunk-VKDAJJQP.js.map} +0 -0
- /package/dist/{chunk-HZNURRDS.js.map → chunk-VUROWXFL.js.map} +0 -0
- /package/dist/{chunk-G5NH3SSX.js.map → chunk-X3KFSJLQ.js.map} +0 -0
- /package/dist/{chunk-GCJA5KKT.js.map → chunk-XBNO56HC.js.map} +0 -0
- /package/dist/{chunk-GWOFCHPN.js.map → chunk-YGECOOTF.js.map} +0 -0
- /package/dist/{chunk-2R7OCBRR.js.map → chunk-YK7GOPIE.js.map} +0 -0
- /package/dist/{chunk-ELUG6NKQ.js.map → chunk-YSA4CYTK.js.map} +0 -0
- /package/dist/{chunk-233S3MXC.js.map → chunk-YXUNSOHD.js.map} +0 -0
- /package/dist/{chunk-SY4AFWUZ.js.map → chunk-Z3O7ZOCC.js.map} +0 -0
- /package/dist/{chunk-ZG57BEUS.js.map → chunk-ZAU2OPDW.js.map} +0 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test, { mock } from "node:test";
|
|
6
|
+
import { parseConfig } from "./config.js";
|
|
7
|
+
import { JsonlOwnershipDigestCache } from "./transcript-ownership-digest.js";
|
|
8
|
+
import { TranscriptManager } from "./transcript.js";
|
|
9
|
+
import type { TranscriptEntry } from "./types.js";
|
|
10
|
+
|
|
11
|
+
function makeConfig(memoryDir: string) {
|
|
12
|
+
return parseConfig({ memoryDir });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function withManager(
|
|
16
|
+
fn: (ctx: { manager: TranscriptManager; memoryDir: string; root: string }) => Promise<void>,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-transcript-digest-"));
|
|
19
|
+
try {
|
|
20
|
+
const manager = new TranscriptManager(makeConfig(memoryDir));
|
|
21
|
+
await manager.initialize();
|
|
22
|
+
await fn({ manager, memoryDir, root: path.join(memoryDir, "transcripts") });
|
|
23
|
+
} finally {
|
|
24
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function entry(sessionKey: string, turnId: string): TranscriptEntry {
|
|
29
|
+
return {
|
|
30
|
+
timestamp: new Date().toISOString(),
|
|
31
|
+
role: "user",
|
|
32
|
+
content: `turn ${turnId}`,
|
|
33
|
+
sessionKey,
|
|
34
|
+
turnId,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function jsonlContents(dir: string): Promise<string> {
|
|
39
|
+
const names = (await readdir(dir).catch(() => [])).filter((n) => n.endsWith(".jsonl")).sort();
|
|
40
|
+
const parts = await Promise.all(names.map((n) => readFile(path.join(dir, n), "utf-8")));
|
|
41
|
+
return parts.join("");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Overwrite a file in place with the same byte length and the same mtime. */
|
|
45
|
+
async function rewriteSameSize(filePath: string, transform: (text: string) => string): Promise<void> {
|
|
46
|
+
const before = await stat(filePath);
|
|
47
|
+
const original = await readFile(filePath, "utf-8");
|
|
48
|
+
const rewritten = transform(original);
|
|
49
|
+
assert.equal(Buffer.byteLength(rewritten), Buffer.byteLength(original), "test must keep byte length");
|
|
50
|
+
assert.notEqual(rewritten, original);
|
|
51
|
+
await writeFile(filePath, rewritten, "utf-8");
|
|
52
|
+
await utimes(filePath, before.atime, before.mtime);
|
|
53
|
+
assert.equal((await stat(filePath)).size, before.size);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("same-size foreign rewrite with restored mtime is never served a cached owner verdict", async () => {
|
|
57
|
+
await withManager(async ({ manager, root }) => {
|
|
58
|
+
const owner = "pi-geek:digest-same-a";
|
|
59
|
+
const intruder = "pi-geek:digest-same-b";
|
|
60
|
+
assert.equal(owner.length, intruder.length);
|
|
61
|
+
const { dir, alternateDir, file } = manager.getTranscriptPath(owner);
|
|
62
|
+
await manager.append(entry(owner, "1"));
|
|
63
|
+
await manager.append(entry(owner, "2"));
|
|
64
|
+
const primary = path.join(root, dir, file);
|
|
65
|
+
await rewriteSameSize(primary, (text) => text.replaceAll(owner, intruder));
|
|
66
|
+
|
|
67
|
+
await manager.append(entry(owner, "3"));
|
|
68
|
+
|
|
69
|
+
const primaryText = await jsonlContents(path.join(root, dir));
|
|
70
|
+
assert.equal(primaryText.includes('"turnId":"3"'), false, "owner turn must not join foreign-content directory");
|
|
71
|
+
assert.equal(primaryText.includes(owner), false);
|
|
72
|
+
const alternateText = await jsonlContents(path.join(root, alternateDir));
|
|
73
|
+
assert.equal(alternateText.includes('"turnId":"3"'), true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("same-size malformed rewrite with restored mtime is never served a cached owner verdict", async () => {
|
|
78
|
+
await withManager(async ({ manager, root }) => {
|
|
79
|
+
const owner = "pi-geek:digest-malformed";
|
|
80
|
+
const { dir, alternateDir, file } = manager.getTranscriptPath(owner);
|
|
81
|
+
await manager.append(entry(owner, "1"));
|
|
82
|
+
await manager.append(entry(owner, "2"));
|
|
83
|
+
const primary = path.join(root, dir, file);
|
|
84
|
+
await rewriteSameSize(primary, (text) => text.replace('{"timestamp"', '{"timestamp:'));
|
|
85
|
+
|
|
86
|
+
await manager.append(entry(owner, "3"));
|
|
87
|
+
|
|
88
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"3"'), false);
|
|
89
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"3"'), true);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a read error after a cached owner verdict makes the directory occupied", async (t) => {
|
|
94
|
+
if (process.getuid?.() === 0) {
|
|
95
|
+
t.skip("root bypasses file permissions");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await withManager(async ({ manager, root }) => {
|
|
99
|
+
const owner = "pi-geek:digest-readerr";
|
|
100
|
+
const { dir, alternateDir, file } = manager.getTranscriptPath(owner);
|
|
101
|
+
await manager.append(entry(owner, "1"));
|
|
102
|
+
await manager.append(entry(owner, "2"));
|
|
103
|
+
const primary = path.join(root, dir, file);
|
|
104
|
+
await chmod(primary, 0o000);
|
|
105
|
+
try {
|
|
106
|
+
await manager.append(entry(owner, "3"));
|
|
107
|
+
} finally {
|
|
108
|
+
await chmod(primary, 0o600);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"3"'), false);
|
|
112
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"3"'), true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("editing a cached prefix while the file grows is detected by content, not size", async () => {
|
|
117
|
+
await withManager(async ({ manager, root }) => {
|
|
118
|
+
const owner = "pi-geek:digest-prefix-a";
|
|
119
|
+
const intruder = "pi-geek:digest-prefix-b";
|
|
120
|
+
const { dir, alternateDir, file } = manager.getTranscriptPath(owner);
|
|
121
|
+
for (const id of ["1", "2", "3"]) await manager.append(entry(owner, id));
|
|
122
|
+
const primary = path.join(root, dir, file);
|
|
123
|
+
const text = await readFile(primary, "utf-8");
|
|
124
|
+
const [first, ...rest] = text.split("\n");
|
|
125
|
+
await writeFile(primary, [first.replace(owner, intruder), ...rest].join("\n"), "utf-8");
|
|
126
|
+
const grown = (await readFile(primary, "utf-8")) + JSON.stringify(entry(owner, "x")) + "\n";
|
|
127
|
+
await writeFile(primary, grown, "utf-8");
|
|
128
|
+
|
|
129
|
+
await manager.append(entry(owner, "4"));
|
|
130
|
+
|
|
131
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"4"'), false);
|
|
132
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"4"'), true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an owner's cached verdict is never shared with another session on the same directory", async () => {
|
|
137
|
+
await withManager(async ({ manager, root }) => {
|
|
138
|
+
const a = "agent:alpha:discord:channel:777";
|
|
139
|
+
const b = "agent:beta:discord:channel:777";
|
|
140
|
+
const pa = manager.getTranscriptPath(a);
|
|
141
|
+
const pb = manager.getTranscriptPath(b);
|
|
142
|
+
assert.equal(pa.dir, pb.dir, "legacy keys must share a channel directory for this test");
|
|
143
|
+
await manager.append(entry(a, "1"));
|
|
144
|
+
await manager.append(entry(a, "2"));
|
|
145
|
+
|
|
146
|
+
await manager.append(entry(b, "1"));
|
|
147
|
+
|
|
148
|
+
const shared = await jsonlContents(path.join(root, pa.dir));
|
|
149
|
+
assert.equal(shared.includes(b), false, "foreign session must not write into the owner's directory");
|
|
150
|
+
assert.equal((await jsonlContents(path.join(root, pb.alternateDir))).includes(b), true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("steady-state appends parse only new lines, not the accumulated history", async () => {
|
|
155
|
+
await withManager(async ({ manager, root }) => {
|
|
156
|
+
const owner = "pi-geek:digest-steady";
|
|
157
|
+
const { dir } = manager.getTranscriptPath(owner);
|
|
158
|
+
const channelDir = path.join(root, dir);
|
|
159
|
+
await mkdir(channelDir, { recursive: true });
|
|
160
|
+
const linesPerDay = 100;
|
|
161
|
+
for (let day = 1; day <= 20; day++) {
|
|
162
|
+
const date = new Date(Date.UTC(2026, 8, day)).toISOString().slice(0, 10);
|
|
163
|
+
const lines = Array.from({ length: linesPerDay }, (_, i) =>
|
|
164
|
+
JSON.stringify({ ...entry(owner, `h${day}-${i}`), timestamp: `${date}T12:00:00.000Z` }),
|
|
165
|
+
);
|
|
166
|
+
await writeFile(path.join(channelDir, `${date}.jsonl`), lines.join("\n") + "\n", "utf-8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const parseSpy = mock.method(JSON, "parse");
|
|
170
|
+
try {
|
|
171
|
+
await manager.append(entry(owner, "a1"));
|
|
172
|
+
assert.ok(parseSpy.mock.callCount() >= 20 * linesPerDay, "first append must verify the full history");
|
|
173
|
+
for (const id of ["a2", "a3", "a4"]) {
|
|
174
|
+
parseSpy.mock.resetCalls();
|
|
175
|
+
await manager.append(entry(owner, id));
|
|
176
|
+
const parses = parseSpy.mock.callCount();
|
|
177
|
+
assert.ok(parses <= 2, `append ${id} re-parsed ${parses} lines`);
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
parseSpy.mock.restore();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("digest cache: bounded size, exact verdicts across eviction", async () => {
|
|
186
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-digest-cache-"));
|
|
187
|
+
try {
|
|
188
|
+
const key = "pi-geek:cache-bound";
|
|
189
|
+
const line = JSON.stringify({ sessionKey: key, content: "x" }) + "\n";
|
|
190
|
+
const cache = new JsonlOwnershipDigestCache(2);
|
|
191
|
+
const files = ["a", "b", "c"].map((n) => path.join(dir, `${n}.jsonl`));
|
|
192
|
+
for (const f of files) await writeFile(f, line, "utf-8");
|
|
193
|
+
for (const f of files) assert.equal(await cache.verify(f, key), "matches");
|
|
194
|
+
assert.ok(cache.size <= 2, `cache grew to ${cache.size}`);
|
|
195
|
+
for (const f of files) assert.equal(await cache.verify(f, key), "matches");
|
|
196
|
+
assert.ok(cache.size <= 2);
|
|
197
|
+
} finally {
|
|
198
|
+
await rm(dir, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("digest cache: verdicts are keyed by session and re-derived from bytes", async () => {
|
|
203
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-digest-cache-"));
|
|
204
|
+
try {
|
|
205
|
+
const cache = new JsonlOwnershipDigestCache();
|
|
206
|
+
const file = path.join(dir, "d.jsonl");
|
|
207
|
+
const mine = "pi-geek:cache-mine";
|
|
208
|
+
const other = "pi-geek:cache-othr";
|
|
209
|
+
await writeFile(file, JSON.stringify({ sessionKey: mine }) + "\n", "utf-8");
|
|
210
|
+
assert.equal(await cache.verify(file, mine), "matches");
|
|
211
|
+
assert.equal(await cache.verify(file, other), "foreign");
|
|
212
|
+
assert.equal(await cache.verify(file, mine), "matches");
|
|
213
|
+
|
|
214
|
+
await writeFile(file, "\n \n", "utf-8");
|
|
215
|
+
assert.equal(await cache.verify(file, mine), "empty");
|
|
216
|
+
|
|
217
|
+
await writeFile(file, "null\n", "utf-8");
|
|
218
|
+
assert.equal(await cache.verify(file, mine), "foreign", "JSON null has no owner");
|
|
219
|
+
await writeFile(file, "42\n", "utf-8");
|
|
220
|
+
assert.equal(await cache.verify(file, mine), "foreign");
|
|
221
|
+
} finally {
|
|
222
|
+
await rm(dir, { recursive: true, force: true });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("digest cache: unterminated trailing line is judged but never cached as complete", async () => {
|
|
227
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-digest-cache-"));
|
|
228
|
+
try {
|
|
229
|
+
const cache = new JsonlOwnershipDigestCache();
|
|
230
|
+
const file = path.join(dir, "p.jsonl");
|
|
231
|
+
const key = "pi-geek:cache-partial";
|
|
232
|
+
const good = JSON.stringify({ sessionKey: key, n: 1 });
|
|
233
|
+
await writeFile(file, `${good}\n${good.slice(0, -4)}`, "utf-8");
|
|
234
|
+
assert.equal(await cache.verify(file, key), "foreign", "torn trailing line is malformed");
|
|
235
|
+
|
|
236
|
+
await writeFile(file, `${good}\n${good}`, "utf-8");
|
|
237
|
+
assert.equal(await cache.verify(file, key), "matches", "valid unterminated last line counts");
|
|
238
|
+
|
|
239
|
+
await writeFile(file, `${good}\n${good}\n`, "utf-8");
|
|
240
|
+
assert.equal(await cache.verify(file, key), "matches");
|
|
241
|
+
|
|
242
|
+
await writeFile(file, `${good}\n`, "utf-8");
|
|
243
|
+
assert.equal(await cache.verify(file, key), "matches", "truncation re-derives from bytes");
|
|
244
|
+
|
|
245
|
+
await writeFile(file, `${good.replace(key, "pi-geek:cache-other")}\n`, "utf-8");
|
|
246
|
+
assert.equal(await cache.verify(file, key), "foreign", "same-length foreign replacement");
|
|
247
|
+
} finally {
|
|
248
|
+
await rm(dir, { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("digest cache: read errors surface to the caller instead of a cached verdict", async () => {
|
|
253
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-digest-cache-"));
|
|
254
|
+
try {
|
|
255
|
+
const cache = new JsonlOwnershipDigestCache();
|
|
256
|
+
const file = path.join(dir, "gone.jsonl");
|
|
257
|
+
const key = "pi-geek:cache-gone";
|
|
258
|
+
await writeFile(file, JSON.stringify({ sessionKey: key }) + "\n", "utf-8");
|
|
259
|
+
assert.equal(await cache.verify(file, key), "matches");
|
|
260
|
+
await rm(file);
|
|
261
|
+
await assert.rejects(cache.verify(file, key));
|
|
262
|
+
} finally {
|
|
263
|
+
await rm(dir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
async function seedFiles(root: string, dir: string, files: Record<string, string>): Promise<void> {
|
|
268
|
+
const channelDir = path.join(root, dir);
|
|
269
|
+
await mkdir(channelDir, { recursive: true });
|
|
270
|
+
for (const [name, text] of Object.entries(files)) await writeFile(path.join(channelDir, name), text, "utf-8");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function line(sessionKey: string, id: string): string {
|
|
274
|
+
return `${JSON.stringify(entry(sessionKey, id))}\n`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const MIXED_CASES: Array<{
|
|
278
|
+
name: string;
|
|
279
|
+
owned: boolean;
|
|
280
|
+
files: (owner: string, other: string) => Record<string, string>;
|
|
281
|
+
}> = [
|
|
282
|
+
{
|
|
283
|
+
name: "every file owned",
|
|
284
|
+
owned: true,
|
|
285
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a") + line(o, "b"), "2026-01-02.jsonl": line(o, "c"), "2026-01-03.jsonl": line(o, "d") }),
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: "owned files around a blank-only file",
|
|
289
|
+
owned: true,
|
|
290
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": "\n \n", "2026-01-03.jsonl": line(o, "b") }),
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: "only blank files",
|
|
294
|
+
owned: true,
|
|
295
|
+
files: () => ({ "2026-01-01.jsonl": "\n", "2026-01-02.jsonl": " \n" }),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: "foreign file after owned files",
|
|
299
|
+
owned: false,
|
|
300
|
+
files: (o, x) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": line(o, "b"), "2026-01-03.jsonl": line(x, "f") }),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: "foreign file before owned files",
|
|
304
|
+
owned: false,
|
|
305
|
+
files: (o, x) => ({ "2026-01-01.jsonl": line(x, "f"), "2026-01-02.jsonl": line(o, "a") }),
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: "foreign line inside an otherwise owned file",
|
|
309
|
+
owned: false,
|
|
310
|
+
files: (o, x) => ({ "2026-01-01.jsonl": line(o, "a") + line(x, "f") + line(o, "b"), "2026-01-02.jsonl": line(o, "c") }),
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "malformed line in a middle file",
|
|
314
|
+
owned: false,
|
|
315
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": line(o, "b") + "{not json\n", "2026-01-03.jsonl": line(o, "c") }),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "torn unterminated final line",
|
|
319
|
+
owned: false,
|
|
320
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": line(o, "b") + line(o, "c").slice(0, -6) }),
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
name: "JSON null line",
|
|
324
|
+
owned: false,
|
|
325
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a") + "null\n" }),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "JSON scalar line",
|
|
329
|
+
owned: false,
|
|
330
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": "42\n" }),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "JSON array line",
|
|
334
|
+
owned: false,
|
|
335
|
+
files: (o) => ({ "2026-01-01.jsonl": line(o, "a"), "2026-01-02.jsonl": "[1]\n" }),
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
for (const mixed of MIXED_CASES) {
|
|
340
|
+
test(`multi-file directory verdict is the conjunction of every file: ${mixed.name}`, async () => {
|
|
341
|
+
await withManager(async ({ manager, root }) => {
|
|
342
|
+
const owner = "pi-geek:digest-mixed-own";
|
|
343
|
+
const other = "pi-geek:digest-mixed-oth";
|
|
344
|
+
const { dir, alternateDir } = manager.getTranscriptPath(owner);
|
|
345
|
+
await seedFiles(root, dir, mixed.files(owner, other));
|
|
346
|
+
|
|
347
|
+
await manager.append(entry(owner, "new"));
|
|
348
|
+
|
|
349
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"new"'), mixed.owned);
|
|
350
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"new"'), !mixed.owned);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const HISTORY = ["2026-01-01.jsonl", "2026-01-02.jsonl", "2026-01-03.jsonl", "2026-01-04.jsonl", "2026-01-05.jsonl"];
|
|
356
|
+
|
|
357
|
+
async function warmedHistory(manager: TranscriptManager, root: string, owner: string): Promise<string> {
|
|
358
|
+
const { dir } = manager.getTranscriptPath(owner);
|
|
359
|
+
await seedFiles(
|
|
360
|
+
root,
|
|
361
|
+
dir,
|
|
362
|
+
Object.fromEntries(HISTORY.map((name, i) => [name, line(owner, `h${i}a`) + line(owner, `h${i}b`)])),
|
|
363
|
+
);
|
|
364
|
+
await manager.append(entry(owner, "warm-1"));
|
|
365
|
+
await manager.append(entry(owner, "warm-2"));
|
|
366
|
+
return path.join(root, dir, HISTORY[2]);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
test("warm multi-file directory: equal-size restored-mtime foreign rewrite of an older file is re-derived, and restoring owned bytes restores ownership", async () => {
|
|
370
|
+
await withManager(async ({ manager, root }) => {
|
|
371
|
+
const owner = "pi-geek:digest-multi-a";
|
|
372
|
+
const intruder = "pi-geek:digest-multi-b";
|
|
373
|
+
const { dir, alternateDir } = manager.getTranscriptPath(owner);
|
|
374
|
+
const target = await warmedHistory(manager, root, owner);
|
|
375
|
+
const original = await readFile(target, "utf-8");
|
|
376
|
+
await rewriteSameSize(target, (text) => text.replaceAll(owner, intruder));
|
|
377
|
+
|
|
378
|
+
await manager.append(entry(owner, "after-foreign"));
|
|
379
|
+
|
|
380
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"after-foreign"'), false);
|
|
381
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"after-foreign"'), true);
|
|
382
|
+
|
|
383
|
+
await writeFile(target, original, "utf-8");
|
|
384
|
+
await manager.append(entry(owner, "after-restore"));
|
|
385
|
+
|
|
386
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"after-restore"'), true);
|
|
387
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"after-restore"'), false);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("warm multi-file directory: equal-size restored-mtime malformed rewrite of an older file is re-derived", async () => {
|
|
392
|
+
await withManager(async ({ manager, root }) => {
|
|
393
|
+
const owner = "pi-geek:digest-multi-m";
|
|
394
|
+
const { dir, alternateDir } = manager.getTranscriptPath(owner);
|
|
395
|
+
const target = await warmedHistory(manager, root, owner);
|
|
396
|
+
await rewriteSameSize(target, (text) => text.replace('{"timestamp"', '{"timestamp:'));
|
|
397
|
+
|
|
398
|
+
await manager.append(entry(owner, "after-malformed"));
|
|
399
|
+
|
|
400
|
+
assert.equal((await jsonlContents(path.join(root, dir))).includes('"turnId":"after-malformed"'), false);
|
|
401
|
+
assert.equal((await jsonlContents(path.join(root, alternateDir))).includes('"turnId":"after-malformed"'), true);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
/** Ownership of one JSONL file for one session key, derived from its bytes. */
|
|
5
|
+
export type JsonlOwnership = "empty" | "matches" | "foreign";
|
|
6
|
+
|
|
7
|
+
type VerifiedPrefix = { length: number; digest: string; matches: boolean };
|
|
8
|
+
|
|
9
|
+
const NEWLINE = 0x0a;
|
|
10
|
+
|
|
11
|
+
/** One entry per (session, daily file); ~250 B each, so well under 2 MiB. */
|
|
12
|
+
export const DEFAULT_OWNERSHIP_CACHE_ENTRIES = 4096;
|
|
13
|
+
|
|
14
|
+
function classify(bytes: Uint8Array, sessionKey: string): JsonlOwnership {
|
|
15
|
+
const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf-8");
|
|
16
|
+
let matches = false;
|
|
17
|
+
for (const line of text.split("\n")) {
|
|
18
|
+
if (!line.trim()) continue;
|
|
19
|
+
try {
|
|
20
|
+
if (JSON.parse(line).sessionKey !== sessionKey) return "foreign";
|
|
21
|
+
matches = true;
|
|
22
|
+
} catch {
|
|
23
|
+
return "foreign";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return matches ? "matches" : "empty";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse cache for transcript directory ownership checks.
|
|
31
|
+
*
|
|
32
|
+
* Every {@link verify} call reads the file's exact bytes and hashes them; it
|
|
33
|
+
* trusts no size, mtime, or earlier verdict that those bytes do not reproduce.
|
|
34
|
+
* A cached entry records that the first `length` bytes (always ending on a
|
|
35
|
+
* newline) hashed to `digest` and were all lines owned by the session. A call
|
|
36
|
+
* reuses it only when the current bytes' prefix of that length hashes to the
|
|
37
|
+
* same digest, then parses just the bytes after it. Anything else re-parses
|
|
38
|
+
* from byte 0, so rewrites at equal size, prefix edits, and truncation all
|
|
39
|
+
* fall back to a full, independent parse. Concurrent callers never share
|
|
40
|
+
* verdicts; each reads and hashes for itself.
|
|
41
|
+
*
|
|
42
|
+
* Only owned prefixes are cached, never foreign or unreadable results, and the
|
|
43
|
+
* map is an LRU capped at `maxEntries`. Keys hold the session key as a digest
|
|
44
|
+
* so caller-chosen keys cannot inflate memory.
|
|
45
|
+
*/
|
|
46
|
+
export class JsonlOwnershipDigestCache {
|
|
47
|
+
private readonly entries = new Map<string, VerifiedPrefix>();
|
|
48
|
+
|
|
49
|
+
constructor(private readonly maxEntries: number = DEFAULT_OWNERSHIP_CACHE_ENTRIES) {
|
|
50
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 1) {
|
|
51
|
+
throw new RangeError("maxEntries must be a positive integer");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get size(): number {
|
|
56
|
+
return this.entries.size;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Rejects when the file cannot be read; callers must treat that as occupied. */
|
|
60
|
+
async verify(filePath: string, sessionKey: string): Promise<JsonlOwnership> {
|
|
61
|
+
const bytes = await readFile(filePath);
|
|
62
|
+
const key = `${createHash("sha256").update(sessionKey).digest("hex")}\0${filePath}`;
|
|
63
|
+
const hit = this.entries.get(key);
|
|
64
|
+
|
|
65
|
+
let hash = createHash("sha256");
|
|
66
|
+
let hashed = 0;
|
|
67
|
+
let verified = 0;
|
|
68
|
+
let matches = false;
|
|
69
|
+
if (hit && hit.length <= bytes.length) {
|
|
70
|
+
const probe = createHash("sha256").update(bytes.subarray(0, hit.length));
|
|
71
|
+
if (probe.copy().digest("hex") === hit.digest) {
|
|
72
|
+
hash = probe;
|
|
73
|
+
hashed = verified = hit.length;
|
|
74
|
+
matches = hit.matches;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const completeEnd = bytes.lastIndexOf(NEWLINE) + 1;
|
|
79
|
+
const complete = classify(bytes.subarray(verified, completeEnd), sessionKey);
|
|
80
|
+
const partial = classify(bytes.subarray(completeEnd), sessionKey);
|
|
81
|
+
if (complete === "foreign" || partial === "foreign") return "foreign";
|
|
82
|
+
|
|
83
|
+
matches ||= complete === "matches";
|
|
84
|
+
if (completeEnd > 0) {
|
|
85
|
+
hash.update(bytes.subarray(hashed, completeEnd));
|
|
86
|
+
this.remember(key, { length: completeEnd, digest: hash.digest("hex"), matches });
|
|
87
|
+
}
|
|
88
|
+
return matches || partial === "matches" ? "matches" : "empty";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private remember(key: string, prefix: VerifiedPrefix): void {
|
|
92
|
+
this.entries.delete(key);
|
|
93
|
+
this.entries.set(key, prefix);
|
|
94
|
+
if (this.entries.size > this.maxEntries) {
|
|
95
|
+
const oldest = this.entries.keys().next();
|
|
96
|
+
if (!oldest.done) this.entries.delete(oldest.value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -7,12 +7,9 @@ import { isDailyFileDatedBefore, resolveSafeStoragePath } from "./storage-paths.
|
|
|
7
7
|
import { sessionStoragePaths } from "./session-identity.js";
|
|
8
8
|
import { estimateTokenCount } from "./token-estimate.js";
|
|
9
9
|
import { isMacOsReplicationJunkBasename } from "./utils/macos-replication-junk.js";
|
|
10
|
+
import { JsonlOwnershipDigestCache } from "./transcript-ownership-digest.js";
|
|
10
11
|
|
|
11
12
|
type DirectorySessionStatus = "missing" | "empty" | "matches" | "occupied";
|
|
12
|
-
type DirectoryOwnershipCacheEntry = {
|
|
13
|
-
status: "empty" | "matches";
|
|
14
|
-
fileSizes: Map<string, number>;
|
|
15
|
-
};
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
15
|
* De-duplicate JSONL lines collected across multiple read-back candidate
|
|
@@ -86,7 +83,7 @@ export class TranscriptManager {
|
|
|
86
83
|
fileTokens: Map<string, number>;
|
|
87
84
|
}
|
|
88
85
|
>();
|
|
89
|
-
private
|
|
86
|
+
private ownershipDigests = new JsonlOwnershipDigestCache();
|
|
90
87
|
|
|
91
88
|
/** Default checkpoint TTL in hours */
|
|
92
89
|
private static readonly DEFAULT_CHECKPOINT_TTL_HOURS = 24;
|
|
@@ -267,95 +264,38 @@ export class TranscriptManager {
|
|
|
267
264
|
return "occupied";
|
|
268
265
|
}
|
|
269
266
|
|
|
270
|
-
const
|
|
271
|
-
if (!
|
|
267
|
+
const filePaths = await this.directoryJsonlFilePaths(root, dir, names);
|
|
268
|
+
if (!filePaths) return "occupied";
|
|
272
269
|
|
|
273
270
|
if (!sessionKey) return "matches";
|
|
274
|
-
const cacheKey = this.directoryOwnershipCacheKey(root, dir, sessionKey);
|
|
275
|
-
const cached = this.directoryOwnershipCache.get(cacheKey);
|
|
276
|
-
if (cached && this.sameFileSizes(cached.fileSizes, fileSizes)) {
|
|
277
|
-
return cached.status;
|
|
278
|
-
}
|
|
279
271
|
|
|
280
272
|
let hasEntries = false;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
for (const name of names) {
|
|
284
|
-
const filePath = await resolveSafeStoragePath(root, dir, name).catch(() => null);
|
|
285
|
-
if (filePath === null) return "occupied";
|
|
273
|
+
for (const filePath of filePaths) {
|
|
286
274
|
try {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
hasEntries = true;
|
|
291
|
-
try {
|
|
292
|
-
const obj = JSON.parse(line) as { sessionKey?: string };
|
|
293
|
-
if (obj.sessionKey === sessionKey) {
|
|
294
|
-
hasMatchingEntry = true;
|
|
295
|
-
} else {
|
|
296
|
-
return "occupied";
|
|
297
|
-
}
|
|
298
|
-
} catch {
|
|
299
|
-
return "occupied";
|
|
300
|
-
}
|
|
301
|
-
}
|
|
275
|
+
const ownership = await this.ownershipDigests.verify(filePath, sessionKey);
|
|
276
|
+
if (ownership === "foreign") return "occupied";
|
|
277
|
+
if (ownership === "matches") hasEntries = true;
|
|
302
278
|
} catch {
|
|
303
279
|
return "occupied";
|
|
304
280
|
}
|
|
305
281
|
}
|
|
306
|
-
|
|
307
|
-
const status = hasMatchingEntry ? "matches" : hasEntries ? "occupied" : "empty";
|
|
308
|
-
if (status === "matches" || status === "empty") {
|
|
309
|
-
this.directoryOwnershipCache.set(cacheKey, { status, fileSizes });
|
|
310
|
-
}
|
|
311
|
-
return status;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
private directoryOwnershipCacheKey(root: string, dir: string, sessionKey: string): string {
|
|
315
|
-
return `${path.resolve(root)}\0${dir}\0${sessionKey}`;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
private sameFileSizes(left: Map<string, number>, right: Map<string, number>): boolean {
|
|
319
|
-
if (left.size !== right.size) return false;
|
|
320
|
-
for (const [name, size] of left) {
|
|
321
|
-
if (right.get(name) !== size) return false;
|
|
322
|
-
}
|
|
323
|
-
return true;
|
|
282
|
+
return hasEntries ? "matches" : "empty";
|
|
324
283
|
}
|
|
325
284
|
|
|
326
|
-
private async
|
|
285
|
+
private async directoryJsonlFilePaths(
|
|
327
286
|
root: string,
|
|
328
287
|
dir: string,
|
|
329
288
|
names: string[],
|
|
330
|
-
): Promise<
|
|
331
|
-
const
|
|
289
|
+
): Promise<string[] | null> {
|
|
290
|
+
const filePaths: string[] = [];
|
|
332
291
|
for (const name of names) {
|
|
333
292
|
const filePath = await resolveSafeStoragePath(root, dir, name).catch(() => null);
|
|
334
293
|
if (filePath === null) return null;
|
|
335
294
|
const fileInfo = await stat(filePath).catch(() => null);
|
|
336
295
|
if (!fileInfo?.isFile()) return null;
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
return fileSizes;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
private async rememberDirectoryOwnership(
|
|
343
|
-
root: string,
|
|
344
|
-
dir: string,
|
|
345
|
-
sessionKey: string,
|
|
346
|
-
): Promise<void> {
|
|
347
|
-
try {
|
|
348
|
-
const channelDir = await resolveSafeStoragePath(root, dir);
|
|
349
|
-
const names = (await readdir(channelDir)).filter((file) => file.endsWith(".jsonl") && !isMacOsReplicationJunkBasename(file));
|
|
350
|
-
const fileSizes = await this.directoryJsonlFileSizes(root, dir, names);
|
|
351
|
-
if (!fileSizes) return;
|
|
352
|
-
this.directoryOwnershipCache.set(
|
|
353
|
-
this.directoryOwnershipCacheKey(root, dir, sessionKey),
|
|
354
|
-
{ status: "matches", fileSizes },
|
|
355
|
-
);
|
|
356
|
-
} catch {
|
|
357
|
-
// Cache refresh is best-effort; write path correctness does not depend on it.
|
|
296
|
+
filePaths.push(filePath);
|
|
358
297
|
}
|
|
298
|
+
return filePaths;
|
|
359
299
|
}
|
|
360
300
|
|
|
361
301
|
private async getSessionStorageFiles(
|
|
@@ -412,7 +352,6 @@ export class TranscriptManager {
|
|
|
412
352
|
await mkdir(channelDir, { recursive: true });
|
|
413
353
|
const filePath = await resolveSafeStoragePath(this.toolUsageDir, writeDir, file);
|
|
414
354
|
await appendFile(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
|
415
|
-
await this.rememberDirectoryOwnership(this.toolUsageDir, writeDir, entry.sessionKey);
|
|
416
355
|
}
|
|
417
356
|
|
|
418
357
|
async readToolUse(
|
|
@@ -610,7 +549,6 @@ export class TranscriptManager {
|
|
|
610
549
|
|
|
611
550
|
const line = JSON.stringify(entry) + "\n";
|
|
612
551
|
await appendFile(filePath, line, "utf-8");
|
|
613
|
-
await this.rememberDirectoryOwnership(this.transcriptsDir, writeDir, entry.sessionKey);
|
|
614
552
|
log.debug(`appended transcript entry for ${entry.sessionKey}: ${entry.turnId}`);
|
|
615
553
|
} catch (err) {
|
|
616
554
|
log.error("failed to append transcript entry:", err);
|