@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.
Files changed (249) hide show
  1. package/dist/access-admin-ops-surface.js +27 -27
  2. package/dist/access-authorization-probe.js +28 -28
  3. package/dist/access-boundary.js +27 -27
  4. package/dist/access-cli.js +78 -77
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-extraction-force-flush.js +27 -27
  7. package/dist/access-http-briefing.js +28 -28
  8. package/dist/access-http-query.js +29 -29
  9. package/dist/access-http.js +45 -45
  10. package/dist/access-identity-continuity-surface.js +27 -27
  11. package/dist/access-lcm-surface.js +27 -27
  12. package/dist/access-mcp.js +37 -37
  13. package/dist/access-namespace-preflight.js +27 -27
  14. package/dist/access-observe-helpers.d.ts.map +1 -1
  15. package/dist/access-observe-helpers.js +1 -1
  16. package/dist/access-observe-write-category.js +5 -5
  17. package/dist/access-observe-write-surface.js +27 -27
  18. package/dist/access-operations-batch.js +29 -29
  19. package/dist/access-operations.js +36 -36
  20. package/dist/access-recall-concurrency.js +27 -27
  21. package/dist/access-recall-response.js +27 -27
  22. package/dist/access-recall-surface.js +27 -27
  23. package/dist/access-schema.js +4 -4
  24. package/dist/access-service.js +27 -27
  25. package/dist/access-surface-catalog.js +29 -29
  26. package/dist/active-recall.js +7 -7
  27. package/dist/briefing.js +8 -8
  28. package/dist/calibration.js +1 -1
  29. package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
  30. package/dist/causal-consolidation.js +9 -9
  31. package/dist/{chunk-Z3CKK5RR.js → chunk-2XG7QECM.js} +3 -3
  32. package/dist/{chunk-BZTA7DR4.js → chunk-35RSL5B4.js} +3 -3
  33. package/dist/{chunk-3GGCMRO7.js → chunk-3RYP4OTZ.js} +2 -2
  34. package/dist/{chunk-VEQVXNWR.js → chunk-446H5UUO.js} +4 -4
  35. package/dist/{chunk-XC7OYOQC.js → chunk-4BCX6YLW.js} +2 -2
  36. package/dist/{chunk-RXGFITWS.js → chunk-4HLPFYF2.js} +7 -7
  37. package/dist/{chunk-WIAXVBQH.js → chunk-65SKM4BF.js} +2 -2
  38. package/dist/{chunk-VEWTHVX2.js → chunk-6HERQLF4.js} +2 -2
  39. package/dist/{chunk-4DLZ2ITE.js → chunk-6OGFQ5GH.js} +2 -2
  40. package/dist/{chunk-C44DRDZM.js → chunk-6WULHFNJ.js} +2 -2
  41. package/dist/{chunk-IVXOYE43.js → chunk-7IQ5HYBJ.js} +2 -2
  42. package/dist/{chunk-SZBZOUHR.js → chunk-A45ZOJV4.js} +2 -2
  43. package/dist/chunk-ADJXJKU6.js +74 -0
  44. package/dist/chunk-ADJXJKU6.js.map +1 -0
  45. package/dist/{chunk-DANFOVQG.js → chunk-B7PLF3HW.js} +2 -2
  46. package/dist/{chunk-PBE7NFB5.js → chunk-BNQJKWWZ.js} +4 -4
  47. package/dist/{chunk-AY2UK5XK.js → chunk-BSBGI4BD.js} +2 -2
  48. package/dist/{chunk-AY2UK5XK.js.map → chunk-BSBGI4BD.js.map} +1 -1
  49. package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
  50. package/dist/{chunk-IPHIIRDN.js → chunk-CYTTY4M2.js} +5 -7
  51. package/dist/chunk-CYTTY4M2.js.map +1 -0
  52. package/dist/{chunk-R24RYSQX.js → chunk-CZD654DQ.js} +22 -22
  53. package/dist/{chunk-MBENRPMY.js → chunk-D3WNJF7D.js} +12 -12
  54. package/dist/{chunk-5IQL4N37.js → chunk-D4WBMLPD.js} +2 -2
  55. package/dist/{chunk-FYQUUIPI.js → chunk-DLZGSZVC.js} +4 -4
  56. package/dist/{chunk-YK6SFDP2.js → chunk-DOJBV6WR.js} +4 -4
  57. package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
  58. package/dist/{chunk-FETPTCQZ.js → chunk-E33UJHY3.js} +2 -2
  59. package/dist/{chunk-LJUQPT5G.js → chunk-EFRMEOZQ.js} +3 -3
  60. package/dist/{chunk-HTDXNIO6.js → chunk-HK3CUTAG.js} +2 -2
  61. package/dist/{chunk-FGUWCE5I.js → chunk-HUF4S4MY.js} +2 -2
  62. package/dist/{chunk-6Q6AQ36B.js → chunk-HWHNQVRV.js} +2 -2
  63. package/dist/{chunk-SYDPJUFR.js → chunk-IDGCL5OP.js} +2 -2
  64. package/dist/{chunk-F3FNXE7C.js → chunk-JHJP2IAU.js} +3 -3
  65. package/dist/{chunk-IBQ7LGUJ.js → chunk-JIGHVQF6.js} +6 -6
  66. package/dist/{chunk-P7HNMNN2.js → chunk-K4SVTZWP.js} +7 -7
  67. package/dist/{chunk-KAED7IBB.js → chunk-KDWO22ZK.js} +2 -2
  68. package/dist/{chunk-N6GEGRCT.js → chunk-KYVBGDGA.js} +2 -2
  69. package/dist/{chunk-HB5TNQMM.js → chunk-L7BOS7KV.js} +2 -2
  70. package/dist/{chunk-3MBSD53O.js → chunk-LE4MQ2WF.js} +2 -2
  71. package/dist/{chunk-I4LJJ44E.js → chunk-LNHBYT3F.js} +2 -2
  72. package/dist/{chunk-SHVLNI4L.js → chunk-LU66I56Q.js} +2 -2
  73. package/dist/{chunk-TD5VTVPT.js → chunk-N2H6VC65.js} +3 -3
  74. package/dist/{chunk-L4QYW2HO.js → chunk-NBFHOQIO.js} +2 -2
  75. package/dist/{chunk-KNZDGZSY.js → chunk-NVYY2TJG.js} +2 -2
  76. package/dist/{chunk-7FFGWIBN.js → chunk-PCSQRQPJ.js} +2 -2
  77. package/dist/{chunk-P3AQ2YSX.js → chunk-PFUXPTBT.js} +4 -4
  78. package/dist/{chunk-KQAFEZQX.js → chunk-R45L4TV6.js} +6 -6
  79. package/dist/{chunk-E22CQZ3H.js → chunk-R63X5BQA.js} +12 -12
  80. package/dist/{chunk-PLGBPFEO.js → chunk-RF7QJ736.js} +2 -2
  81. package/dist/{chunk-ZZSFRWKD.js → chunk-RIBCP4DJ.js} +2 -2
  82. package/dist/{chunk-HDN7IVIO.js → chunk-RIZ5NUNN.js} +16 -62
  83. package/dist/chunk-RIZ5NUNN.js.map +1 -0
  84. package/dist/{chunk-HR6NTVYZ.js → chunk-S34XZUTY.js} +172 -39
  85. package/dist/chunk-S34XZUTY.js.map +1 -0
  86. package/dist/{chunk-DEGE32C3.js → chunk-SLORPQZT.js} +2 -2
  87. package/dist/{chunk-6RPPZL7W.js → chunk-SMSJOWVE.js} +2 -2
  88. package/dist/{chunk-X53HJENJ.js → chunk-SPYFXSFZ.js} +2 -2
  89. package/dist/{chunk-IRFQKR4Y.js → chunk-SW6OAWK7.js} +2 -2
  90. package/dist/{chunk-VPEMGBTI.js → chunk-SZEPOUW2.js} +2 -2
  91. package/dist/{chunk-COIQ5N4J.js → chunk-TSITK6QK.js} +2 -2
  92. package/dist/{chunk-HCFNZWFB.js → chunk-VBNXDC7P.js} +2 -2
  93. package/dist/{chunk-FP2AT4WL.js → chunk-VJ3HFCG2.js} +2 -2
  94. package/dist/{chunk-OBQ2NZAR.js → chunk-VKDAJJQP.js} +4 -4
  95. package/dist/{chunk-HZNURRDS.js → chunk-VUROWXFL.js} +2 -2
  96. package/dist/{chunk-G5NH3SSX.js → chunk-X3KFSJLQ.js} +2 -2
  97. package/dist/{chunk-GCJA5KKT.js → chunk-XBNO56HC.js} +2 -2
  98. package/dist/{chunk-GWOFCHPN.js → chunk-YGECOOTF.js} +2 -2
  99. package/dist/{chunk-2R7OCBRR.js → chunk-YK7GOPIE.js} +2 -2
  100. package/dist/{chunk-ELUG6NKQ.js → chunk-YSA4CYTK.js} +2 -2
  101. package/dist/{chunk-233S3MXC.js → chunk-YXUNSOHD.js} +3 -3
  102. package/dist/{chunk-SY4AFWUZ.js → chunk-Z3O7ZOCC.js} +30 -30
  103. package/dist/{chunk-ZG57BEUS.js → chunk-ZAU2OPDW.js} +2 -2
  104. package/dist/cli.js +63 -62
  105. package/dist/coding/export-okf-codegraph.js +7 -7
  106. package/dist/compounding/engine.js +9 -9
  107. package/dist/config.js +7 -7
  108. package/dist/connectors/codex-materialize-runner.js +8 -8
  109. package/dist/connectors/index.js +8 -8
  110. package/dist/convergence-refresh.js +10 -10
  111. package/dist/corpus-watermark.js +10 -10
  112. package/dist/deep-recall-http-glue.js +5 -5
  113. package/dist/entity-retrieval.js +8 -8
  114. package/dist/external-wiki-access.js +28 -28
  115. package/dist/extraction.js +10 -10
  116. package/dist/harmonic-retrieval.js +8 -8
  117. package/dist/index.js +86 -85
  118. package/dist/lcm/engine.js +2 -2
  119. package/dist/lcm/index.js +5 -5
  120. package/dist/local-llm.js +1 -1
  121. package/dist/location/index.js +10 -10
  122. package/dist/maintenance/memory-governance.js +8 -8
  123. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
  124. package/dist/maintenance/rebuild-memory-projection-runner.js +10 -10
  125. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  126. package/dist/memory-browse-operations.js +28 -28
  127. package/dist/namespaces/migrate.js +9 -9
  128. package/dist/namespaces/search.js +6 -6
  129. package/dist/namespaces/storage.js +8 -8
  130. package/dist/operator-doctor-corpus.js +11 -11
  131. package/dist/operator-toolkit.js +16 -16
  132. package/dist/orchestration/backlog-scheduler.js +79 -78
  133. package/dist/orchestration/backlog-scheduler.js.map +1 -1
  134. package/dist/orchestration/buffer-drain-runner.js +79 -78
  135. package/dist/orchestration/extraction-queue-snapshot-sharing.d.ts +113 -0
  136. package/dist/orchestration/extraction-queue-snapshot-sharing.d.ts.map +1 -0
  137. package/dist/orchestration/maintenance.js +19 -19
  138. package/dist/orchestration/turn-ingestion.d.ts +3 -0
  139. package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
  140. package/dist/orchestrator.js +78 -77
  141. package/dist/recall-navigation-operations.js +28 -28
  142. package/dist/recall-navigation.js +27 -27
  143. package/dist/resume-bundles.js +10 -9
  144. package/dist/search/factory.js +5 -5
  145. package/dist/search/index.js +5 -5
  146. package/dist/search/lancedb-backend.js +2 -2
  147. package/dist/search/meilisearch-backend.js +2 -2
  148. package/dist/search/orama-backend.js +2 -2
  149. package/dist/semantic-consolidation.js +9 -9
  150. package/dist/semantic-rule-promotion.js +8 -8
  151. package/dist/semantic-rule-verifier.js +8 -8
  152. package/dist/source-agent-qualifier.js +8 -8
  153. package/dist/standup/build.js +8 -8
  154. package/dist/storage.js +7 -7
  155. package/dist/summarizer.js +2 -2
  156. package/dist/support-passport/index.js +30 -30
  157. package/dist/transcript-ownership-digest.d.ts +31 -0
  158. package/dist/transcript-ownership-digest.d.ts.map +1 -0
  159. package/dist/transcript-ownership-digest.js +10 -0
  160. package/dist/transcript-ownership-digest.js.map +1 -0
  161. package/dist/transcript.d.ts +2 -5
  162. package/dist/transcript.d.ts.map +1 -1
  163. package/dist/transcript.js +2 -1
  164. package/dist/transfer/autodetect.js +1 -1
  165. package/dist/transfer/backup.js +2 -2
  166. package/dist/transfer/capsule-export.js +3 -3
  167. package/dist/transfer/capsule-import.js +2 -2
  168. package/dist/transfer/export-okf.js +8 -8
  169. package/dist/verified-recall.js +8 -8
  170. package/dist/who-knows-cli.js +9 -9
  171. package/dist/who-knows.js +8 -8
  172. package/package.json +2 -2
  173. package/src/access-observe-helpers.test.ts +156 -0
  174. package/src/access-observe-helpers.ts +6 -6
  175. package/src/local-llm-qos.test.ts +59 -0
  176. package/src/local-llm.ts +1 -1
  177. package/src/orchestration/extraction-queue-bounded-retention.test.ts +548 -0
  178. package/src/orchestration/extraction-queue-snapshot-sharing.ts +257 -0
  179. package/src/orchestration/turn-ingestion.ts +19 -6
  180. package/src/transcript-ownership-digest.test.ts +403 -0
  181. package/src/transcript-ownership-digest.ts +99 -0
  182. package/src/transcript.ts +14 -76
  183. package/dist/chunk-HDN7IVIO.js.map +0 -1
  184. package/dist/chunk-HR6NTVYZ.js.map +0 -1
  185. package/dist/chunk-IPHIIRDN.js.map +0 -1
  186. /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
  187. /package/dist/{chunk-Z3CKK5RR.js.map → chunk-2XG7QECM.js.map} +0 -0
  188. /package/dist/{chunk-BZTA7DR4.js.map → chunk-35RSL5B4.js.map} +0 -0
  189. /package/dist/{chunk-3GGCMRO7.js.map → chunk-3RYP4OTZ.js.map} +0 -0
  190. /package/dist/{chunk-VEQVXNWR.js.map → chunk-446H5UUO.js.map} +0 -0
  191. /package/dist/{chunk-XC7OYOQC.js.map → chunk-4BCX6YLW.js.map} +0 -0
  192. /package/dist/{chunk-RXGFITWS.js.map → chunk-4HLPFYF2.js.map} +0 -0
  193. /package/dist/{chunk-WIAXVBQH.js.map → chunk-65SKM4BF.js.map} +0 -0
  194. /package/dist/{chunk-VEWTHVX2.js.map → chunk-6HERQLF4.js.map} +0 -0
  195. /package/dist/{chunk-4DLZ2ITE.js.map → chunk-6OGFQ5GH.js.map} +0 -0
  196. /package/dist/{chunk-C44DRDZM.js.map → chunk-6WULHFNJ.js.map} +0 -0
  197. /package/dist/{chunk-IVXOYE43.js.map → chunk-7IQ5HYBJ.js.map} +0 -0
  198. /package/dist/{chunk-SZBZOUHR.js.map → chunk-A45ZOJV4.js.map} +0 -0
  199. /package/dist/{chunk-DANFOVQG.js.map → chunk-B7PLF3HW.js.map} +0 -0
  200. /package/dist/{chunk-PBE7NFB5.js.map → chunk-BNQJKWWZ.js.map} +0 -0
  201. /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
  202. /package/dist/{chunk-R24RYSQX.js.map → chunk-CZD654DQ.js.map} +0 -0
  203. /package/dist/{chunk-MBENRPMY.js.map → chunk-D3WNJF7D.js.map} +0 -0
  204. /package/dist/{chunk-5IQL4N37.js.map → chunk-D4WBMLPD.js.map} +0 -0
  205. /package/dist/{chunk-FYQUUIPI.js.map → chunk-DLZGSZVC.js.map} +0 -0
  206. /package/dist/{chunk-YK6SFDP2.js.map → chunk-DOJBV6WR.js.map} +0 -0
  207. /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
  208. /package/dist/{chunk-FETPTCQZ.js.map → chunk-E33UJHY3.js.map} +0 -0
  209. /package/dist/{chunk-LJUQPT5G.js.map → chunk-EFRMEOZQ.js.map} +0 -0
  210. /package/dist/{chunk-HTDXNIO6.js.map → chunk-HK3CUTAG.js.map} +0 -0
  211. /package/dist/{chunk-FGUWCE5I.js.map → chunk-HUF4S4MY.js.map} +0 -0
  212. /package/dist/{chunk-6Q6AQ36B.js.map → chunk-HWHNQVRV.js.map} +0 -0
  213. /package/dist/{chunk-SYDPJUFR.js.map → chunk-IDGCL5OP.js.map} +0 -0
  214. /package/dist/{chunk-F3FNXE7C.js.map → chunk-JHJP2IAU.js.map} +0 -0
  215. /package/dist/{chunk-IBQ7LGUJ.js.map → chunk-JIGHVQF6.js.map} +0 -0
  216. /package/dist/{chunk-P7HNMNN2.js.map → chunk-K4SVTZWP.js.map} +0 -0
  217. /package/dist/{chunk-KAED7IBB.js.map → chunk-KDWO22ZK.js.map} +0 -0
  218. /package/dist/{chunk-N6GEGRCT.js.map → chunk-KYVBGDGA.js.map} +0 -0
  219. /package/dist/{chunk-HB5TNQMM.js.map → chunk-L7BOS7KV.js.map} +0 -0
  220. /package/dist/{chunk-3MBSD53O.js.map → chunk-LE4MQ2WF.js.map} +0 -0
  221. /package/dist/{chunk-I4LJJ44E.js.map → chunk-LNHBYT3F.js.map} +0 -0
  222. /package/dist/{chunk-SHVLNI4L.js.map → chunk-LU66I56Q.js.map} +0 -0
  223. /package/dist/{chunk-TD5VTVPT.js.map → chunk-N2H6VC65.js.map} +0 -0
  224. /package/dist/{chunk-L4QYW2HO.js.map → chunk-NBFHOQIO.js.map} +0 -0
  225. /package/dist/{chunk-KNZDGZSY.js.map → chunk-NVYY2TJG.js.map} +0 -0
  226. /package/dist/{chunk-7FFGWIBN.js.map → chunk-PCSQRQPJ.js.map} +0 -0
  227. /package/dist/{chunk-P3AQ2YSX.js.map → chunk-PFUXPTBT.js.map} +0 -0
  228. /package/dist/{chunk-KQAFEZQX.js.map → chunk-R45L4TV6.js.map} +0 -0
  229. /package/dist/{chunk-E22CQZ3H.js.map → chunk-R63X5BQA.js.map} +0 -0
  230. /package/dist/{chunk-PLGBPFEO.js.map → chunk-RF7QJ736.js.map} +0 -0
  231. /package/dist/{chunk-ZZSFRWKD.js.map → chunk-RIBCP4DJ.js.map} +0 -0
  232. /package/dist/{chunk-DEGE32C3.js.map → chunk-SLORPQZT.js.map} +0 -0
  233. /package/dist/{chunk-6RPPZL7W.js.map → chunk-SMSJOWVE.js.map} +0 -0
  234. /package/dist/{chunk-X53HJENJ.js.map → chunk-SPYFXSFZ.js.map} +0 -0
  235. /package/dist/{chunk-IRFQKR4Y.js.map → chunk-SW6OAWK7.js.map} +0 -0
  236. /package/dist/{chunk-VPEMGBTI.js.map → chunk-SZEPOUW2.js.map} +0 -0
  237. /package/dist/{chunk-COIQ5N4J.js.map → chunk-TSITK6QK.js.map} +0 -0
  238. /package/dist/{chunk-HCFNZWFB.js.map → chunk-VBNXDC7P.js.map} +0 -0
  239. /package/dist/{chunk-FP2AT4WL.js.map → chunk-VJ3HFCG2.js.map} +0 -0
  240. /package/dist/{chunk-OBQ2NZAR.js.map → chunk-VKDAJJQP.js.map} +0 -0
  241. /package/dist/{chunk-HZNURRDS.js.map → chunk-VUROWXFL.js.map} +0 -0
  242. /package/dist/{chunk-G5NH3SSX.js.map → chunk-X3KFSJLQ.js.map} +0 -0
  243. /package/dist/{chunk-GCJA5KKT.js.map → chunk-XBNO56HC.js.map} +0 -0
  244. /package/dist/{chunk-GWOFCHPN.js.map → chunk-YGECOOTF.js.map} +0 -0
  245. /package/dist/{chunk-2R7OCBRR.js.map → chunk-YK7GOPIE.js.map} +0 -0
  246. /package/dist/{chunk-ELUG6NKQ.js.map → chunk-YSA4CYTK.js.map} +0 -0
  247. /package/dist/{chunk-233S3MXC.js.map → chunk-YXUNSOHD.js.map} +0 -0
  248. /package/dist/{chunk-SY4AFWUZ.js.map → chunk-Z3O7ZOCC.js.map} +0 -0
  249. /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 directoryOwnershipCache = new Map<string, DirectoryOwnershipCacheEntry>();
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 fileSizes = await this.directoryJsonlFileSizes(root, dir, names);
271
- if (!fileSizes) return "occupied";
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
- let hasMatchingEntry = false;
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 raw = await readFile(filePath, "utf-8");
288
- for (const line of raw.split("\n")) {
289
- if (!line.trim()) continue;
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 directoryJsonlFileSizes(
285
+ private async directoryJsonlFilePaths(
327
286
  root: string,
328
287
  dir: string,
329
288
  names: string[],
330
- ): Promise<Map<string, number> | null> {
331
- const fileSizes = new Map<string, number>();
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
- fileSizes.set(name, Math.max(0, fileInfo.size));
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);