@smeltjs/core 0.2.0 → 0.3.0

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 (319) hide show
  1. package/README.md +3 -3
  2. package/dist/apply.d.ts.map +1 -1
  3. package/dist/apply.js.map +1 -1
  4. package/dist/cache/prefix.d.ts.map +1 -1
  5. package/dist/cache/prefix.js.map +1 -1
  6. package/dist/cli/args.d.ts +16 -70
  7. package/dist/cli/args.d.ts.map +1 -1
  8. package/dist/cli/args.js +18 -338
  9. package/dist/cli/args.js.map +1 -1
  10. package/dist/cli/bin.d.ts.map +1 -1
  11. package/dist/cli/bin.js.map +1 -1
  12. package/dist/cli/config.d.ts +40 -0
  13. package/dist/cli/config.d.ts.map +1 -1
  14. package/dist/cli/config.js +55 -1
  15. package/dist/cli/config.js.map +1 -1
  16. package/dist/cli/hooks.d.ts +33 -38
  17. package/dist/cli/hooks.d.ts.map +1 -1
  18. package/dist/cli/hooks.js +140 -485
  19. package/dist/cli/hooks.js.map +1 -1
  20. package/dist/cli/init.d.ts +30 -15
  21. package/dist/cli/init.d.ts.map +1 -1
  22. package/dist/cli/init.js +167 -38
  23. package/dist/cli/init.js.map +1 -1
  24. package/dist/cli/report.d.ts.map +1 -1
  25. package/dist/cli/report.js +2 -2
  26. package/dist/cli/report.js.map +1 -1
  27. package/dist/cli/run.d.ts +25 -88
  28. package/dist/cli/run.d.ts.map +1 -1
  29. package/dist/cli/run.js +32 -341
  30. package/dist/cli/run.js.map +1 -1
  31. package/dist/cli/shell.d.ts +106 -0
  32. package/dist/cli/shell.d.ts.map +1 -0
  33. package/dist/cli/shell.js +100 -0
  34. package/dist/cli/shell.js.map +1 -0
  35. package/dist/cli/subcommands/flags.d.ts +141 -0
  36. package/dist/cli/subcommands/flags.d.ts.map +1 -0
  37. package/dist/cli/subcommands/flags.js +208 -0
  38. package/dist/cli/subcommands/flags.js.map +1 -0
  39. package/dist/cli/subcommands/hooks.d.ts +18 -0
  40. package/dist/cli/subcommands/hooks.d.ts.map +1 -0
  41. package/dist/cli/subcommands/hooks.js +64 -0
  42. package/dist/cli/subcommands/hooks.js.map +1 -0
  43. package/dist/cli/subcommands/init.d.ts +19 -0
  44. package/dist/cli/subcommands/init.d.ts.map +1 -0
  45. package/dist/cli/subcommands/init.js +44 -0
  46. package/dist/cli/subcommands/init.js.map +1 -0
  47. package/dist/cli/subcommands/map.d.ts +75 -0
  48. package/dist/cli/subcommands/map.d.ts.map +1 -0
  49. package/dist/cli/subcommands/map.js +132 -0
  50. package/dist/cli/subcommands/map.js.map +1 -0
  51. package/dist/cli/subcommands/registry.d.ts +80 -0
  52. package/dist/cli/subcommands/registry.d.ts.map +1 -0
  53. package/dist/cli/subcommands/registry.js +138 -0
  54. package/dist/cli/subcommands/registry.js.map +1 -0
  55. package/dist/cli/subcommands/retrieve.d.ts +74 -0
  56. package/dist/cli/subcommands/retrieve.d.ts.map +1 -0
  57. package/dist/cli/subcommands/retrieve.js +84 -0
  58. package/dist/cli/subcommands/retrieve.js.map +1 -0
  59. package/dist/cli/subcommands/smelt.d.ts +107 -0
  60. package/dist/cli/subcommands/smelt.d.ts.map +1 -0
  61. package/dist/cli/subcommands/smelt.js +304 -0
  62. package/dist/cli/subcommands/smelt.js.map +1 -0
  63. package/dist/cli/subcommands/stats.d.ts +43 -0
  64. package/dist/cli/subcommands/stats.d.ts.map +1 -0
  65. package/dist/cli/subcommands/stats.js +62 -0
  66. package/dist/cli/subcommands/stats.js.map +1 -0
  67. package/dist/cli/subcommands/subcommand.d.ts +113 -0
  68. package/dist/cli/subcommands/subcommand.d.ts.map +1 -0
  69. package/dist/cli/subcommands/subcommand.js +3 -0
  70. package/dist/cli/subcommands/subcommand.js.map +1 -0
  71. package/dist/cli/usage.d.ts +3 -0
  72. package/dist/cli/usage.d.ts.map +1 -0
  73. package/dist/cli/usage.js +95 -0
  74. package/dist/cli/usage.js.map +1 -0
  75. package/dist/detect.d.ts.map +1 -1
  76. package/dist/detect.js.map +1 -1
  77. package/dist/errors.d.ts +21 -1
  78. package/dist/errors.d.ts.map +1 -1
  79. package/dist/errors.js +33 -1
  80. package/dist/errors.js.map +1 -1
  81. package/dist/harness/aider.d.ts +10 -0
  82. package/dist/harness/aider.d.ts.map +1 -0
  83. package/dist/harness/aider.js +21 -0
  84. package/dist/harness/aider.js.map +1 -0
  85. package/dist/harness/claude-code.d.ts +3 -0
  86. package/dist/harness/claude-code.d.ts.map +1 -0
  87. package/dist/harness/claude-code.js +68 -0
  88. package/dist/harness/claude-code.js.map +1 -0
  89. package/dist/harness/cline.d.ts +3 -0
  90. package/dist/harness/cline.d.ts.map +1 -0
  91. package/dist/harness/cline.js +54 -0
  92. package/dist/harness/cline.js.map +1 -0
  93. package/dist/harness/codex.d.ts +3 -0
  94. package/dist/harness/codex.d.ts.map +1 -0
  95. package/dist/harness/codex.js +91 -0
  96. package/dist/harness/codex.js.map +1 -0
  97. package/dist/harness/cursor.d.ts +3 -0
  98. package/dist/harness/cursor.d.ts.map +1 -0
  99. package/dist/harness/cursor.js +54 -0
  100. package/dist/harness/cursor.js.map +1 -0
  101. package/dist/harness/gemini.d.ts +3 -0
  102. package/dist/harness/gemini.d.ts.map +1 -0
  103. package/dist/harness/gemini.js +59 -0
  104. package/dist/harness/gemini.js.map +1 -0
  105. package/dist/harness/grok.d.ts +3 -0
  106. package/dist/harness/grok.d.ts.map +1 -0
  107. package/dist/harness/grok.js +46 -0
  108. package/dist/harness/grok.js.map +1 -0
  109. package/dist/harness/hermes.d.ts +3 -0
  110. package/dist/harness/hermes.d.ts.map +1 -0
  111. package/dist/harness/hermes.js +71 -0
  112. package/dist/harness/hermes.js.map +1 -0
  113. package/dist/harness/kilocode.d.ts +3 -0
  114. package/dist/harness/kilocode.d.ts.map +1 -0
  115. package/dist/harness/kilocode.js +35 -0
  116. package/dist/harness/kilocode.js.map +1 -0
  117. package/dist/harness/opencode.d.ts +3 -0
  118. package/dist/harness/opencode.d.ts.map +1 -0
  119. package/dist/harness/opencode.js +97 -0
  120. package/dist/harness/opencode.js.map +1 -0
  121. package/dist/harness/paths.d.ts +17 -0
  122. package/dist/harness/paths.d.ts.map +1 -0
  123. package/dist/harness/paths.js +45 -0
  124. package/dist/harness/paths.js.map +1 -0
  125. package/dist/harness/profile.d.ts +184 -0
  126. package/dist/harness/profile.d.ts.map +1 -0
  127. package/dist/harness/profile.js +26 -0
  128. package/dist/harness/profile.js.map +1 -0
  129. package/dist/harness/registry.d.ts +51 -0
  130. package/dist/harness/registry.d.ts.map +1 -0
  131. package/dist/harness/registry.js +87 -0
  132. package/dist/harness/registry.js.map +1 -0
  133. package/dist/harness/snippet.d.ts +19 -0
  134. package/dist/harness/snippet.d.ts.map +1 -0
  135. package/dist/harness/snippet.js +41 -0
  136. package/dist/harness/snippet.js.map +1 -0
  137. package/dist/hash.d.ts.map +1 -1
  138. package/dist/hash.js.map +1 -1
  139. package/dist/hooks/guard-core.d.ts +14 -14
  140. package/dist/hooks/guard-core.d.ts.map +1 -1
  141. package/dist/hooks/guard-core.js +15 -37
  142. package/dist/hooks/guard-core.js.map +1 -1
  143. package/dist/hooks/shim.d.ts +95 -4
  144. package/dist/hooks/shim.d.ts.map +1 -1
  145. package/dist/hooks/shim.js +87 -2
  146. package/dist/hooks/shim.js.map +1 -1
  147. package/dist/hooks/shims/claude-code.d.ts +5 -18
  148. package/dist/hooks/shims/claude-code.d.ts.map +1 -1
  149. package/dist/hooks/shims/claude-code.js +9 -56
  150. package/dist/hooks/shims/claude-code.js.map +1 -1
  151. package/dist/hooks/shims/cline.d.ts +5 -12
  152. package/dist/hooks/shims/cline.d.ts.map +1 -1
  153. package/dist/hooks/shims/cline.js +9 -34
  154. package/dist/hooks/shims/cline.js.map +1 -1
  155. package/dist/hooks/shims/codex.d.ts +5 -18
  156. package/dist/hooks/shims/codex.d.ts.map +1 -1
  157. package/dist/hooks/shims/codex.js +9 -51
  158. package/dist/hooks/shims/codex.js.map +1 -1
  159. package/dist/hooks/shims/cursor.d.ts +5 -14
  160. package/dist/hooks/shims/cursor.d.ts.map +1 -1
  161. package/dist/hooks/shims/cursor.js +9 -42
  162. package/dist/hooks/shims/cursor.js.map +1 -1
  163. package/dist/hooks/shims/gemini.d.ts +5 -18
  164. package/dist/hooks/shims/gemini.d.ts.map +1 -1
  165. package/dist/hooks/shims/gemini.js +9 -48
  166. package/dist/hooks/shims/gemini.js.map +1 -1
  167. package/dist/hooks/shims/grok.d.ts +5 -13
  168. package/dist/hooks/shims/grok.d.ts.map +1 -1
  169. package/dist/hooks/shims/grok.js +9 -32
  170. package/dist/hooks/shims/grok.js.map +1 -1
  171. package/dist/hooks/shims/hermes.d.ts +5 -17
  172. package/dist/hooks/shims/hermes.d.ts.map +1 -1
  173. package/dist/hooks/shims/hermes.js +9 -45
  174. package/dist/hooks/shims/hermes.js.map +1 -1
  175. package/dist/index.d.ts +33 -76
  176. package/dist/index.d.ts.map +1 -1
  177. package/dist/index.js +13 -67
  178. package/dist/index.js.map +1 -1
  179. package/dist/lang/bash.d.ts.map +1 -1
  180. package/dist/lang/bash.js.map +1 -1
  181. package/dist/lang/c.d.ts.map +1 -1
  182. package/dist/lang/c.js.map +1 -1
  183. package/dist/lang/c_sharp.d.ts.map +1 -1
  184. package/dist/lang/c_sharp.js.map +1 -1
  185. package/dist/lang/cpp.d.ts.map +1 -1
  186. package/dist/lang/cpp.js.map +1 -1
  187. package/dist/lang/go.d.ts.map +1 -1
  188. package/dist/lang/go.js.map +1 -1
  189. package/dist/lang/java.d.ts.map +1 -1
  190. package/dist/lang/java.js.map +1 -1
  191. package/dist/lang/javascript.d.ts.map +1 -1
  192. package/dist/lang/javascript.js.map +1 -1
  193. package/dist/lang/kotlin.d.ts.map +1 -1
  194. package/dist/lang/kotlin.js.map +1 -1
  195. package/dist/lang/php.d.ts.map +1 -1
  196. package/dist/lang/php.js.map +1 -1
  197. package/dist/lang/profile.d.ts.map +1 -1
  198. package/dist/lang/profile.js.map +1 -1
  199. package/dist/lang/python.d.ts.map +1 -1
  200. package/dist/lang/python.js.map +1 -1
  201. package/dist/lang/registry.d.ts.map +1 -1
  202. package/dist/lang/registry.js.map +1 -1
  203. package/dist/lang/ruby.d.ts.map +1 -1
  204. package/dist/lang/ruby.js.map +1 -1
  205. package/dist/lang/rust.d.ts.map +1 -1
  206. package/dist/lang/rust.js.map +1 -1
  207. package/dist/lang/swift.d.ts.map +1 -1
  208. package/dist/lang/swift.js.map +1 -1
  209. package/dist/lang/tsx.d.ts.map +1 -1
  210. package/dist/lang/tsx.js.map +1 -1
  211. package/dist/lang/typescript.d.ts.map +1 -1
  212. package/dist/lang/typescript.js.map +1 -1
  213. package/dist/net/policy.d.ts +18 -1
  214. package/dist/net/policy.d.ts.map +1 -1
  215. package/dist/net/policy.js +6 -4
  216. package/dist/net/policy.js.map +1 -1
  217. package/dist/ops/index.d.ts +39 -0
  218. package/dist/ops/index.d.ts.map +1 -0
  219. package/dist/ops/index.js +37 -0
  220. package/dist/ops/index.js.map +1 -0
  221. package/dist/ops/inputs.d.ts +170 -0
  222. package/dist/ops/inputs.d.ts.map +1 -0
  223. package/dist/ops/inputs.js +142 -0
  224. package/dist/ops/inputs.js.map +1 -0
  225. package/dist/ops/verbs.d.ts +136 -0
  226. package/dist/ops/verbs.d.ts.map +1 -0
  227. package/dist/ops/verbs.js +76 -0
  228. package/dist/ops/verbs.js.map +1 -0
  229. package/dist/plan/auto.d.ts +69 -0
  230. package/dist/plan/auto.d.ts.map +1 -0
  231. package/dist/plan/auto.js +70 -0
  232. package/dist/plan/auto.js.map +1 -0
  233. package/dist/plan/budget.d.ts +21 -0
  234. package/dist/plan/budget.d.ts.map +1 -0
  235. package/dist/plan/budget.js +26 -0
  236. package/dist/plan/budget.js.map +1 -0
  237. package/dist/plan/grammar.d.ts +14 -0
  238. package/dist/plan/grammar.d.ts.map +1 -1
  239. package/dist/plan/grammar.js +47 -6
  240. package/dist/plan/grammar.js.map +1 -1
  241. package/dist/plan/lexical.d.ts.map +1 -1
  242. package/dist/plan/lexical.js +1 -7
  243. package/dist/plan/lexical.js.map +1 -1
  244. package/dist/plan/planners.d.ts +35 -2
  245. package/dist/plan/planners.d.ts.map +1 -1
  246. package/dist/plan/planners.js +35 -2
  247. package/dist/plan/planners.js.map +1 -1
  248. package/dist/plan/structural.d.ts +19 -5
  249. package/dist/plan/structural.d.ts.map +1 -1
  250. package/dist/plan/structural.js +180 -33
  251. package/dist/plan/structural.js.map +1 -1
  252. package/dist/repomap/cache.d.ts +48 -0
  253. package/dist/repomap/cache.d.ts.map +1 -1
  254. package/dist/repomap/cache.js +112 -13
  255. package/dist/repomap/cache.js.map +1 -1
  256. package/dist/repomap/io.d.ts +24 -0
  257. package/dist/repomap/io.d.ts.map +1 -0
  258. package/dist/repomap/io.js +34 -0
  259. package/dist/repomap/io.js.map +1 -0
  260. package/dist/repomap/map.d.ts +69 -6
  261. package/dist/repomap/map.d.ts.map +1 -1
  262. package/dist/repomap/map.js +104 -22
  263. package/dist/repomap/map.js.map +1 -1
  264. package/dist/repomap/rank.d.ts +26 -2
  265. package/dist/repomap/rank.d.ts.map +1 -1
  266. package/dist/repomap/rank.js +20 -0
  267. package/dist/repomap/rank.js.map +1 -1
  268. package/dist/repomap/reader.d.ts +73 -0
  269. package/dist/repomap/reader.d.ts.map +1 -0
  270. package/dist/repomap/reader.js +30 -0
  271. package/dist/repomap/reader.js.map +1 -0
  272. package/dist/repomap/tags.d.ts.map +1 -1
  273. package/dist/repomap/tags.js.map +1 -1
  274. package/dist/retrieve.d.ts +8 -0
  275. package/dist/retrieve.d.ts.map +1 -1
  276. package/dist/retrieve.js +12 -1
  277. package/dist/retrieve.js.map +1 -1
  278. package/dist/smelter.d.ts +79 -0
  279. package/dist/smelter.d.ts.map +1 -0
  280. package/dist/smelter.js +65 -0
  281. package/dist/smelter.js.map +1 -0
  282. package/dist/stages.d.ts.map +1 -1
  283. package/dist/stages.js +2 -2
  284. package/dist/stages.js.map +1 -1
  285. package/dist/stats.d.ts +2 -1
  286. package/dist/stats.d.ts.map +1 -1
  287. package/dist/stats.js +2 -1
  288. package/dist/stats.js.map +1 -1
  289. package/dist/store-dir.d.ts +43 -4
  290. package/dist/store-dir.d.ts.map +1 -1
  291. package/dist/store-dir.js +48 -5
  292. package/dist/store-dir.js.map +1 -1
  293. package/dist/store.d.ts +6 -0
  294. package/dist/store.d.ts.map +1 -1
  295. package/dist/store.js +6 -0
  296. package/dist/store.js.map +1 -1
  297. package/dist/types.d.ts +35 -3
  298. package/dist/types.d.ts.map +1 -1
  299. package/dist/types.js.map +1 -1
  300. package/grammars/tree-sitter-bash.wasm +0 -0
  301. package/grammars/tree-sitter-c.wasm +0 -0
  302. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  303. package/grammars/tree-sitter-cpp.wasm +0 -0
  304. package/grammars/tree-sitter-go.wasm +0 -0
  305. package/grammars/tree-sitter-java.wasm +0 -0
  306. package/grammars/tree-sitter-javascript.wasm +0 -0
  307. package/grammars/tree-sitter-kotlin.wasm +0 -0
  308. package/grammars/tree-sitter-php.wasm +0 -0
  309. package/grammars/tree-sitter-python.wasm +0 -0
  310. package/grammars/tree-sitter-ruby.wasm +0 -0
  311. package/grammars/tree-sitter-rust.wasm +0 -0
  312. package/grammars/tree-sitter-swift.wasm +0 -0
  313. package/grammars/tree-sitter-tsx.wasm +0 -0
  314. package/grammars/tree-sitter-typescript.wasm +0 -0
  315. package/package.json +11 -11
  316. package/dist/cli/resolve.d.ts +0 -103
  317. package/dist/cli/resolve.d.ts.map +0 -1
  318. package/dist/cli/resolve.js +0 -106
  319. package/dist/cli/resolve.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../../src/repomap/tags.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;;GAeG;AAEH,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,4DAA4D;AAC5D,MAAM,WAAW,QAAQ;IACvB,yBAAyB;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IACxC,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;CACxC;AAiCD;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CA8DvF"}
1
+ {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../../src/repomap/tags.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;;;;;;;;;;;;;GAeG;AAEH,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,uFAAuF;IACvF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,4DAA4D;AAC5D,MAAM,WAAW,QAAQ;IACvB,yBAAyB;IACzB,QAAQ,CAAC,IAAI,EAAE,SAAS,aAAa,EAAE,CAAC;IACxC,qEAAqE;IACrE,QAAQ,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;CACxC;AAiCD;;;;;;;;GAQG;AACH,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,UAAU,GAAG,OAAO,CAAC,QAAQ,CAAC,CA8DvF","sourcesContent":["import { Parser } from 'web-tree-sitter';\nimport type { Node } from 'web-tree-sitter';\n\nimport { GrammarUnavailableError } from '../errors.ts';\nimport { profileFor } from '../lang/registry.ts';\nimport { loadGrammar } from '../plan/grammar.ts';\nimport type { LanguageId } from '../types.ts';\n\n/**\n * Definition/reference tag extraction for the repo map — one file at a time, through\n * the same bundled tree-sitter grammars the structural planner uses.\n *\n * Modelled on Aider's repo-map tags (https://aider.chat/docs/repomap.html): a *def* is\n * a named declaration read off the parse tree, a *ref* is an identifier occurrence.\n * Aider extracts both with per-language `.scm` query files; here the same facts come\n * from a manual tree walk over each language's `repomap` profile section\n * (`src/lang/`), so no query assets are added. The design is Aider's, not this\n * project's.\n *\n * Honest scope, stated rather than implied: refs count `identifier`/`type_identifier`\n * nodes only, so a member access like `config.load()` contributes `config`, not\n * `load` — method references travel through the object they are called on. A def whose\n * name is a destructuring pattern is skipped rather than guessed at.\n */\n\n/** One named definition, read off the parse tree. `line` is 1-based. */\nexport interface DefinitionTag {\n readonly name: string;\n /** Human word for the node kind, e.g. `'function'` — same register as the planners. */\n readonly kind: string;\n readonly line: number;\n}\n\n/** One referenced identifier and how many times this file references it. */\nexport interface ReferenceTag {\n readonly name: string;\n readonly count: number;\n}\n\n/** Everything the repo map needs to know about one file. */\nexport interface FileTags {\n /** In document order. */\n readonly defs: readonly DefinitionTag[];\n /** Sorted by name, so the extraction is deterministic end to end. */\n readonly refs: readonly ReferenceTag[];\n}\n\n/**\n * C/C++ specifier node types that name a *usage site* as readily as a definition:\n * `struct point p;` parses as a `struct_specifier` with a `name` and no `body`, same\n * as the `struct point { … };` that actually defines it. Only the bodied form is a\n * definition. Without this check a mere mention earns a `defined at` receipt, *and*\n * its name node lands in `defNameStarts` — so the real definition's cross-file\n * references silently vanish from the map.\n */\nconst BODY_REQUIRED_TYPES: ReadonlySet<string> = new Set([\n 'struct_specifier',\n 'union_specifier',\n 'enum_specifier',\n 'class_specifier',\n]);\n\n/**\n * A local `const x = …` inside a function body is not a map-worthy symbol. A\n * `variable_declarator` counts as a definition only at module top level — directly\n * under the program node, or under an `export` statement that is.\n */\nfunction isDefinitionSite(node: Node): boolean {\n if (BODY_REQUIRED_TYPES.has(node.type) && node.childForFieldName('body') === null) {\n return false; // a bodiless specifier is a usage or forward declaration, not a definition\n }\n if (node.type !== 'variable_declarator') return true;\n const container = node.parent?.parent;\n if (container === null || container === undefined) return false;\n if (container.type === 'program') return true;\n return container.type === 'export_statement' && container.parent?.type === 'program';\n}\n\n/**\n * Extract definition and reference tags from one file.\n *\n * Deterministic: same text, same language, identical tags — defs in document order,\n * refs sorted by name. A def's own name node is not counted as a reference to itself.\n *\n * @throws {GrammarUnavailableError} when the grammar cannot load or produces no tree.\n * Never a lexical guess — same no-fallback rule as the structural planner.\n */\nexport async function extractTags(text: string, language: LanguageId): Promise<FileTags> {\n const grammar = await loadGrammar(language);\n // The per-language facts live on the language's profile (`src/lang/`). A profile\n // without a repomap section contributes nothing — no symbols, never wrong ones.\n const repomap = profileFor(language).repomap;\n const defKinds = repomap?.defKinds ?? {};\n const refTypes = repomap?.refTypes ?? [];\n\n const parser = new Parser();\n let tree = null;\n try {\n parser.setLanguage(grammar);\n tree = parser.parse(text);\n if (tree === null) {\n throw new GrammarUnavailableError(\n `smelt: the ${language} parser returned no tree, so no tags can be extracted. ` +\n `Refusing to guess at definitions that were never parsed.`,\n );\n }\n\n const defs: DefinitionTag[] = [];\n const refCounts = new Map<string, number>();\n /** Start indices of def-name nodes, so a definition never references itself. */\n const defNameStarts = new Set<number>();\n\n // Depth-first, document order, via an explicit stack — recursion depth on a real\n // file is the tree's depth, which nothing bounds.\n const stack: Node[] = [tree.rootNode];\n while (stack.length > 0) {\n const node = stack.pop()!;\n\n const kind = defKinds[node.type];\n if (kind !== undefined && isDefinitionSite(node)) {\n const nameNode = node.childForFieldName('name');\n // Only a plain identifier names a def; a destructuring pattern is skipped, not\n // guessed at.\n if (nameNode !== null && nameNode.type.endsWith('identifier')) {\n defs.push({ name: nameNode.text, kind, line: node.startPosition.row + 1 });\n defNameStarts.add(nameNode.startIndex);\n }\n }\n\n if (refTypes.includes(node.type) && !defNameStarts.has(node.startIndex)) {\n refCounts.set(node.text, (refCounts.get(node.text) ?? 0) + 1);\n }\n\n const children = node.namedChildren;\n for (let i = children.length - 1; i >= 0; i -= 1) {\n const child = children[i];\n if (child !== null && child !== undefined) stack.push(child);\n }\n }\n\n const refs = [...refCounts.entries()]\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([name, count]) => ({ name, count }));\n\n return { defs, refs };\n } finally {\n tree?.delete();\n parser.delete();\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"tags.js","sourceRoot":"","sources":["../../src/repomap/tags.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tags.js","sourceRoot":"","sources":["../../src/repomap/tags.ts"],"names":[],"mappings":"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","sourcesContent":["import { Parser } from 'web-tree-sitter';\nimport type { Node } from 'web-tree-sitter';\n\nimport { GrammarUnavailableError } from '../errors.ts';\nimport { profileFor } from '../lang/registry.ts';\nimport { loadGrammar } from '../plan/grammar.ts';\nimport type { LanguageId } from '../types.ts';\n\n/**\n * Definition/reference tag extraction for the repo map — one file at a time, through\n * the same bundled tree-sitter grammars the structural planner uses.\n *\n * Modelled on Aider's repo-map tags (https://aider.chat/docs/repomap.html): a *def* is\n * a named declaration read off the parse tree, a *ref* is an identifier occurrence.\n * Aider extracts both with per-language `.scm` query files; here the same facts come\n * from a manual tree walk over each language's `repomap` profile section\n * (`src/lang/`), so no query assets are added. The design is Aider's, not this\n * project's.\n *\n * Honest scope, stated rather than implied: refs count `identifier`/`type_identifier`\n * nodes only, so a member access like `config.load()` contributes `config`, not\n * `load` — method references travel through the object they are called on. A def whose\n * name is a destructuring pattern is skipped rather than guessed at.\n */\n\n/** One named definition, read off the parse tree. `line` is 1-based. */\nexport interface DefinitionTag {\n readonly name: string;\n /** Human word for the node kind, e.g. `'function'` — same register as the planners. */\n readonly kind: string;\n readonly line: number;\n}\n\n/** One referenced identifier and how many times this file references it. */\nexport interface ReferenceTag {\n readonly name: string;\n readonly count: number;\n}\n\n/** Everything the repo map needs to know about one file. */\nexport interface FileTags {\n /** In document order. */\n readonly defs: readonly DefinitionTag[];\n /** Sorted by name, so the extraction is deterministic end to end. */\n readonly refs: readonly ReferenceTag[];\n}\n\n/**\n * C/C++ specifier node types that name a *usage site* as readily as a definition:\n * `struct point p;` parses as a `struct_specifier` with a `name` and no `body`, same\n * as the `struct point { … };` that actually defines it. Only the bodied form is a\n * definition. Without this check a mere mention earns a `defined at` receipt, *and*\n * its name node lands in `defNameStarts` — so the real definition's cross-file\n * references silently vanish from the map.\n */\nconst BODY_REQUIRED_TYPES: ReadonlySet<string> = new Set([\n 'struct_specifier',\n 'union_specifier',\n 'enum_specifier',\n 'class_specifier',\n]);\n\n/**\n * A local `const x = …` inside a function body is not a map-worthy symbol. A\n * `variable_declarator` counts as a definition only at module top level — directly\n * under the program node, or under an `export` statement that is.\n */\nfunction isDefinitionSite(node: Node): boolean {\n if (BODY_REQUIRED_TYPES.has(node.type) && node.childForFieldName('body') === null) {\n return false; // a bodiless specifier is a usage or forward declaration, not a definition\n }\n if (node.type !== 'variable_declarator') return true;\n const container = node.parent?.parent;\n if (container === null || container === undefined) return false;\n if (container.type === 'program') return true;\n return container.type === 'export_statement' && container.parent?.type === 'program';\n}\n\n/**\n * Extract definition and reference tags from one file.\n *\n * Deterministic: same text, same language, identical tags — defs in document order,\n * refs sorted by name. A def's own name node is not counted as a reference to itself.\n *\n * @throws {GrammarUnavailableError} when the grammar cannot load or produces no tree.\n * Never a lexical guess — same no-fallback rule as the structural planner.\n */\nexport async function extractTags(text: string, language: LanguageId): Promise<FileTags> {\n const grammar = await loadGrammar(language);\n // The per-language facts live on the language's profile (`src/lang/`). A profile\n // without a repomap section contributes nothing — no symbols, never wrong ones.\n const repomap = profileFor(language).repomap;\n const defKinds = repomap?.defKinds ?? {};\n const refTypes = repomap?.refTypes ?? [];\n\n const parser = new Parser();\n let tree = null;\n try {\n parser.setLanguage(grammar);\n tree = parser.parse(text);\n if (tree === null) {\n throw new GrammarUnavailableError(\n `smelt: the ${language} parser returned no tree, so no tags can be extracted. ` +\n `Refusing to guess at definitions that were never parsed.`,\n );\n }\n\n const defs: DefinitionTag[] = [];\n const refCounts = new Map<string, number>();\n /** Start indices of def-name nodes, so a definition never references itself. */\n const defNameStarts = new Set<number>();\n\n // Depth-first, document order, via an explicit stack — recursion depth on a real\n // file is the tree's depth, which nothing bounds.\n const stack: Node[] = [tree.rootNode];\n while (stack.length > 0) {\n const node = stack.pop()!;\n\n const kind = defKinds[node.type];\n if (kind !== undefined && isDefinitionSite(node)) {\n const nameNode = node.childForFieldName('name');\n // Only a plain identifier names a def; a destructuring pattern is skipped, not\n // guessed at.\n if (nameNode !== null && nameNode.type.endsWith('identifier')) {\n defs.push({ name: nameNode.text, kind, line: node.startPosition.row + 1 });\n defNameStarts.add(nameNode.startIndex);\n }\n }\n\n if (refTypes.includes(node.type) && !defNameStarts.has(node.startIndex)) {\n refCounts.set(node.text, (refCounts.get(node.text) ?? 0) + 1);\n }\n\n const children = node.namedChildren;\n for (let i = children.length - 1; i >= 0; i -= 1) {\n const child = children[i];\n if (child !== null && child !== undefined) stack.push(child);\n }\n }\n\n const refs = [...refCounts.entries()]\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([name, count]) => ({ name, count }));\n\n return { defs, refs };\n } finally {\n tree?.delete();\n parser.delete();\n }\n}\n"]}
@@ -9,6 +9,14 @@ export declare const RETRIEVE_TOOL_NAME = "smelt_retrieve";
9
9
  * confident answer built on material it never saw, and the retrieve rate reads 0% —
10
10
  * which is indistinguishable from perfect pruning. Encouraging retrieval keeps the
11
11
  * signal in {@link ElisionStore.stats} honest.
12
+ *
13
+ * **The schema is strict-mode shaped on purpose.** `additionalProperties: false`, and
14
+ * `required` naming every property — the two rules OpenAI's structured-outputs strict
15
+ * mode enforces before it will register a function at all. Without them a whole class
16
+ * of consumer simply cannot expose this tool. It says nothing new: `hash` was always
17
+ * the only key `invoke` reads, and an extra key was always ignored. A schema that is
18
+ * strictly more precise about the same shape is not a change to the wire surface the
19
+ * tool name and behaviour guarantee covers.
12
20
  */
13
21
  export declare function createRetrieveTool(store: ElisionStore): RetrieveTool;
14
22
  //# sourceMappingURL=retrieve.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"retrieve.d.ts","sourceRoot":"","sources":["../src/retrieve.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAwBnD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,YAAY,CAWpE"}
1
+ {"version":3,"file":"retrieve.d.ts","sourceRoot":"","sources":["../src/retrieve.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE7D,yFAAyF;AACzF,eAAO,MAAM,kBAAkB,mBAAmB,CAAC;AAwBnD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,YAAY,GAAG,YAAY,CAcpE","sourcesContent":["import { defaultMarker } from './apply.ts';\nimport type { ElisionStore, RetrieveTool } from './types.ts';\n\n/** The tool name smelt's markers reference. Consumers hard-code it; do not rename it. */\nexport const RETRIEVE_TOOL_NAME = 'smelt_retrieve';\n\n/**\n * The example marker inside the tool description is *rendered by the real marker\n * builder*, never hand-written — the description is the one string a model reads to\n * recognize markers, and an example whose shape drifted from the wire format\n * (`<<smelt: …>>` when real markers say `<<smelt/v1: …>>`) would teach the model to\n * miss every marker it actually receives. `test/guards/marker-format.test.ts` pins\n * this to `MARKER_FORMAT_VERSION`.\n */\nconst EXAMPLE_MARKER = defaultMarker({\n hash: 'a1b2c3d4e5f60718',\n bytes: 412,\n rule: 'sibling-collapse',\n explanation: 'collapsed 3 sibling functions',\n});\n\nconst DESCRIPTION =\n 'Return the exact original text that was elided from a previous tool result. ' +\n `Context you were given may contain markers like \\`${EXAMPLE_MARKER}\\`. ` +\n 'Call this with that hash to get those bytes ' +\n 'back verbatim. Nothing was deleted — it is all still here. Ask whenever the elided ' +\n 'material might matter; guessing at what a marker hid is never correct.';\n\n/**\n * Wrap a store as the tool a model calls.\n *\n * Why the description says \"ask whenever it might matter\": under-retrieval is the\n * failure mode that looks like success. A model that never calls this produces a\n * confident answer built on material it never saw, and the retrieve rate reads 0% —\n * which is indistinguishable from perfect pruning. Encouraging retrieval keeps the\n * signal in {@link ElisionStore.stats} honest.\n *\n * **The schema is strict-mode shaped on purpose.** `additionalProperties: false`, and\n * `required` naming every property — the two rules OpenAI's structured-outputs strict\n * mode enforces before it will register a function at all. Without them a whole class\n * of consumer simply cannot expose this tool. It says nothing new: `hash` was always\n * the only key `invoke` reads, and an extra key was always ignored. A schema that is\n * strictly more precise about the same shape is not a change to the wire surface the\n * tool name and behaviour guarantee covers.\n */\nexport function createRetrieveTool(store: ElisionStore): RetrieveTool {\n return {\n name: RETRIEVE_TOOL_NAME,\n description: DESCRIPTION,\n inputSchema: {\n type: 'object',\n properties: {\n hash: { type: 'string', description: 'The hash from a marker\\'s retrieve(\"hash\").' },\n },\n required: ['hash'],\n additionalProperties: false,\n },\n invoke: ({ hash }) => store.retrieve(hash),\n };\n}\n"]}
package/dist/retrieve.js CHANGED
@@ -28,6 +28,14 @@ const DESCRIPTION = 'Return the exact original text that was elided from a previ
28
28
  * confident answer built on material it never saw, and the retrieve rate reads 0% —
29
29
  * which is indistinguishable from perfect pruning. Encouraging retrieval keeps the
30
30
  * signal in {@link ElisionStore.stats} honest.
31
+ *
32
+ * **The schema is strict-mode shaped on purpose.** `additionalProperties: false`, and
33
+ * `required` naming every property — the two rules OpenAI's structured-outputs strict
34
+ * mode enforces before it will register a function at all. Without them a whole class
35
+ * of consumer simply cannot expose this tool. It says nothing new: `hash` was always
36
+ * the only key `invoke` reads, and an extra key was always ignored. A schema that is
37
+ * strictly more precise about the same shape is not a change to the wire surface the
38
+ * tool name and behaviour guarantee covers.
31
39
  */
32
40
  export function createRetrieveTool(store) {
33
41
  return {
@@ -35,8 +43,11 @@ export function createRetrieveTool(store) {
35
43
  description: DESCRIPTION,
36
44
  inputSchema: {
37
45
  type: 'object',
38
- properties: { hash: { type: 'string' } },
46
+ properties: {
47
+ hash: { type: 'string', description: 'The hash from a marker\'s retrieve("hash").' },
48
+ },
39
49
  required: ['hash'],
50
+ additionalProperties: false,
40
51
  },
41
52
  invoke: ({ hash }) => store.retrieve(hash),
42
53
  };
@@ -1 +1 @@
1
- {"version":3,"file":"retrieve.js","sourceRoot":"","sources":["../src/retrieve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAG3C,yFAAyF;AACzF,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,cAAc,GAAG,aAAa,CAAC;IACnC,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,GAAG;IACV,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,+BAA+B;CAC7C,CAAC,CAAC;AAEH,MAAM,WAAW,GACf,8EAA8E;IAC9E,qDAAqD,cAAc,MAAM;IACzE,8CAA8C;IAC9C,qFAAqF;IACrF,wEAAwE,CAAC;AAE3E;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAmB;IACpD,OAAO;QACL,IAAI,EAAE,kBAAkB;QACxB,WAAW,EAAE,WAAW;QACxB,WAAW,EAAE;YACX,IAAI,EAAE,QAAQ;YACd,UAAU,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE;YACxC,QAAQ,EAAE,CAAC,MAAM,CAAC;SACnB;QACD,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;KAC3C,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"retrieve.js","sourceRoot":"","sources":["../src/retrieve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAG3C,yFAAyF;AACzF,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC;AAEnD;;;;;;;GAOG;AACH,MAAM,cAAc,GAAG,aAAa,CAAC;IACnC,IAAI,EAAE,kBAAkB;IACxB,KAAK,EAAE,GAAG;IACV,IAAI,EAAE,kBAAkB;IACxB,WAAW,EAAE,+BAA+B;CAC7C,CAAC,CAAC;AAEH,MAAM,WAAW,GACf,8EAA8E;IAC9E,qDAAqD,cAAc,MAAM;IACzE,8CAA8C;IAC9C,qFAAqF;IACrF,wEAAwE,CAAC;AAE3E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,kBAAkB,CAAC,KAAmB;IACpD,OAAO;QACL,IAAI,EAAE,kBAAkB;QACxB,WAAW,EAAE,WAAW;QACxB,WAAW,EAAE;YACX,IAAI,EAAE,QAAQ;YACd,UAAU,EAAE;gBACV,IAAI,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,6CAA6C,EAAE;aACrF;YACD,QAAQ,EAAE,CAAC,MAAM,CAAC;YAClB,oBAAoB,EAAE,KAAK;SAC5B;QACD,MAAM,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;KAC3C,CAAC;AACJ,CAAC","sourcesContent":["import { defaultMarker } from './apply.ts';\nimport type { ElisionStore, RetrieveTool } from './types.ts';\n\n/** The tool name smelt's markers reference. Consumers hard-code it; do not rename it. */\nexport const RETRIEVE_TOOL_NAME = 'smelt_retrieve';\n\n/**\n * The example marker inside the tool description is *rendered by the real marker\n * builder*, never hand-written — the description is the one string a model reads to\n * recognize markers, and an example whose shape drifted from the wire format\n * (`<<smelt: …>>` when real markers say `<<smelt/v1: …>>`) would teach the model to\n * miss every marker it actually receives. `test/guards/marker-format.test.ts` pins\n * this to `MARKER_FORMAT_VERSION`.\n */\nconst EXAMPLE_MARKER = defaultMarker({\n hash: 'a1b2c3d4e5f60718',\n bytes: 412,\n rule: 'sibling-collapse',\n explanation: 'collapsed 3 sibling functions',\n});\n\nconst DESCRIPTION =\n 'Return the exact original text that was elided from a previous tool result. ' +\n `Context you were given may contain markers like \\`${EXAMPLE_MARKER}\\`. ` +\n 'Call this with that hash to get those bytes ' +\n 'back verbatim. Nothing was deleted — it is all still here. Ask whenever the elided ' +\n 'material might matter; guessing at what a marker hid is never correct.';\n\n/**\n * Wrap a store as the tool a model calls.\n *\n * Why the description says \"ask whenever it might matter\": under-retrieval is the\n * failure mode that looks like success. A model that never calls this produces a\n * confident answer built on material it never saw, and the retrieve rate reads 0% —\n * which is indistinguishable from perfect pruning. Encouraging retrieval keeps the\n * signal in {@link ElisionStore.stats} honest.\n *\n * **The schema is strict-mode shaped on purpose.** `additionalProperties: false`, and\n * `required` naming every property — the two rules OpenAI's structured-outputs strict\n * mode enforces before it will register a function at all. Without them a whole class\n * of consumer simply cannot expose this tool. It says nothing new: `hash` was always\n * the only key `invoke` reads, and an extra key was always ignored. A schema that is\n * strictly more precise about the same shape is not a change to the wire surface the\n * tool name and behaviour guarantee covers.\n */\nexport function createRetrieveTool(store: ElisionStore): RetrieveTool {\n return {\n name: RETRIEVE_TOOL_NAME,\n description: DESCRIPTION,\n inputSchema: {\n type: 'object',\n properties: {\n hash: { type: 'string', description: 'The hash from a marker\\'s retrieve(\"hash\").' },\n },\n required: ['hash'],\n additionalProperties: false,\n },\n invoke: ({ hash }) => store.retrieve(hash),\n };\n}\n"]}
@@ -0,0 +1,79 @@
1
+ import type { MarkerBuilder } from './apply.ts';
2
+ import type { LexicalPlannerOptions } from './plan/lexical.ts';
3
+ import type { Strategy } from './plan/planners.ts';
4
+ import type { StructuralPlannerOptions } from './plan/structural.ts';
5
+ import type { DetectedLanguage, ElisionStore, Measure, Planner, RetrieveStats, RetrieveTool, SmeltResult } from './types.ts';
6
+ /**
7
+ * `createSmelter()` and the three types it is spelled in.
8
+ *
9
+ * It lives here rather than in `index.ts` so that nothing under `src/` has to import
10
+ * the package barrel to build a smelter. `cli/subcommands/smelt.ts` does exactly that,
11
+ * and while `index.ts` re-exports the CLI, a barrel that imports the CLI which imports
12
+ * the barrel is a cycle whose only symptom is a registry that evaluates to `undefined`
13
+ * in whichever module the loader happens to enter first. `index.ts` re-exports every
14
+ * name below, so the published surface is unchanged.
15
+ */
16
+ export interface SmelterConfig {
17
+ /** Where elided bytes live. Defaults to a fresh {@link MemoryElisionStore}. */
18
+ readonly store?: ElisionStore;
19
+ /** Used when a `smelt()` call omits `budgetBytes`. No global default is assumed. */
20
+ readonly defaultBudgetBytes?: number;
21
+ /**
22
+ * A constructed planner instance. Wins over `strategy`: the registry is a
23
+ * convenience for the shipped planners, and an instance you built yourself is
24
+ * always more specific than a name.
25
+ */
26
+ readonly planner?: Planner;
27
+ readonly strategy?: Strategy;
28
+ readonly marker?: MarkerBuilder;
29
+ /**
30
+ * Your own counter, so results carry a number in your unit as well as in bytes.
31
+ * The budget stays in bytes — see {@link Measure} and `docs/ARCHITECTURE.md` § "Decision 1".
32
+ */
33
+ readonly measure?: Measure;
34
+ readonly lexical?: LexicalPlannerOptions;
35
+ readonly structural?: StructuralPlannerOptions;
36
+ }
37
+ /** Options for one `smelt()` call. `budgetBytes` may come from the smelter instead. */
38
+ export interface SmeltCallOptions {
39
+ readonly budgetBytes?: number;
40
+ readonly path?: string;
41
+ readonly language?: DetectedLanguage;
42
+ readonly focus?: readonly string[];
43
+ }
44
+ /**
45
+ * One smelter, one store, one set of counters. The store is the reason this is an
46
+ * object rather than a free function: elisions are only reversible for as long as
47
+ * something holds them, so the thing that cuts and the thing that remembers have the
48
+ * same lifetime by construction.
49
+ */
50
+ export interface Smelter {
51
+ /** Shrink one blob of text. Never mutates its input. */
52
+ smelt(text: string, options?: SmeltCallOptions): Promise<SmeltResult>;
53
+ /** The exact original text of a previous result. @throws {UnknownHashError} */
54
+ reconstruct(result: SmeltResult): string;
55
+ /** One elided run, counted as a retrieval. @throws {UnknownHashError} */
56
+ retrieve(hash: string): string;
57
+ /** The tool to expose to your model. See {@link RetrieveTool}. */
58
+ readonly tool: RetrieveTool;
59
+ /** Live counters, including the expansion rate. See {@link RetrieveStats}. */
60
+ stats(): RetrieveStats;
61
+ /** The underlying store, for consumers that persist or inspect it. */
62
+ readonly store: ElisionStore;
63
+ }
64
+ /**
65
+ * Build a smelter.
66
+ *
67
+ * ```ts
68
+ * const smelter = createSmelter();
69
+ * const result = await smelter.smelt(toolOutput, {
70
+ * path: 'src/server.ts',
71
+ * budgetBytes: 4_000,
72
+ * focus: ['handleRequest'],
73
+ * });
74
+ * // result.text goes to the model; smelter.tool lets it ask for the rest back.
75
+ * // smelter.stats().expansionRate tells you whether you cut too much.
76
+ * ```
77
+ */
78
+ export declare function createSmelter(config?: SmelterConfig): Smelter;
79
+ //# sourceMappingURL=smelter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smelter.d.ts","sourceRoot":"","sources":["../src/smelter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAgB,aAAa,EAAE,MAAM,YAAY,CAAC;AAG9D,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAGrE,OAAO,KAAK,EACV,gBAAgB,EAChB,YAAY,EACZ,OAAO,EAEP,OAAO,EACP,aAAa,EACb,YAAY,EACZ,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB;;;;;;;;;GASG;AAEH,MAAM,WAAW,aAAa;IAC5B,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,CAAC;IAC9B,oFAAoF;IACpF,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,QAAQ,CAAC,MAAM,CAAC,EAAE,aAAa,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,CAAC,EAAE,qBAAqB,CAAC;IACzC,QAAQ,CAAC,UAAU,CAAC,EAAE,wBAAwB,CAAC;CAChD;AAED,uFAAuF;AACvF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,wDAAwD;IACxD,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IACtE,+EAA+E;IAC/E,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC;IACzC,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,8EAA8E;IAC9E,KAAK,IAAI,aAAa,CAAC;IACvB,sEAAsE;IACtE,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAC;CAC9B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,aAAa,CAAC,MAAM,GAAE,aAAkB,GAAG,OAAO,CA8CjE","sourcesContent":["import { applyPlan, markerForLanguage, markerPricing, reconstruct } from './apply.ts';\nimport type { ApplyOptions, MarkerBuilder } from './apply.ts';\nimport { detectLanguage } from './detect.ts';\nimport { SmeltError } from './errors.ts';\nimport type { LexicalPlannerOptions } from './plan/lexical.ts';\nimport { DEFAULT_STRATEGY, PLANNERS } from './plan/planners.ts';\nimport type { Strategy } from './plan/planners.ts';\nimport type { StructuralPlannerOptions } from './plan/structural.ts';\nimport { createRetrieveTool } from './retrieve.ts';\nimport { MemoryElisionStore } from './store.ts';\nimport type {\n DetectedLanguage,\n ElisionStore,\n Measure,\n PlanInput,\n Planner,\n RetrieveStats,\n RetrieveTool,\n SmeltResult,\n} from './types.ts';\n\n/**\n * `createSmelter()` and the three types it is spelled in.\n *\n * It lives here rather than in `index.ts` so that nothing under `src/` has to import\n * the package barrel to build a smelter. `cli/subcommands/smelt.ts` does exactly that,\n * and while `index.ts` re-exports the CLI, a barrel that imports the CLI which imports\n * the barrel is a cycle whose only symptom is a registry that evaluates to `undefined`\n * in whichever module the loader happens to enter first. `index.ts` re-exports every\n * name below, so the published surface is unchanged.\n */\n\nexport interface SmelterConfig {\n /** Where elided bytes live. Defaults to a fresh {@link MemoryElisionStore}. */\n readonly store?: ElisionStore;\n /** Used when a `smelt()` call omits `budgetBytes`. No global default is assumed. */\n readonly defaultBudgetBytes?: number;\n /**\n * A constructed planner instance. Wins over `strategy`: the registry is a\n * convenience for the shipped planners, and an instance you built yourself is\n * always more specific than a name.\n */\n readonly planner?: Planner;\n readonly strategy?: Strategy;\n readonly marker?: MarkerBuilder;\n /**\n * Your own counter, so results carry a number in your unit as well as in bytes.\n * The budget stays in bytes — see {@link Measure} and `docs/ARCHITECTURE.md` § \"Decision 1\".\n */\n readonly measure?: Measure;\n readonly lexical?: LexicalPlannerOptions;\n readonly structural?: StructuralPlannerOptions;\n}\n\n/** Options for one `smelt()` call. `budgetBytes` may come from the smelter instead. */\nexport interface SmeltCallOptions {\n readonly budgetBytes?: number;\n readonly path?: string;\n readonly language?: DetectedLanguage;\n readonly focus?: readonly string[];\n}\n\n/**\n * One smelter, one store, one set of counters. The store is the reason this is an\n * object rather than a free function: elisions are only reversible for as long as\n * something holds them, so the thing that cuts and the thing that remembers have the\n * same lifetime by construction.\n */\nexport interface Smelter {\n /** Shrink one blob of text. Never mutates its input. */\n smelt(text: string, options?: SmeltCallOptions): Promise<SmeltResult>;\n /** The exact original text of a previous result. @throws {UnknownHashError} */\n reconstruct(result: SmeltResult): string;\n /** One elided run, counted as a retrieval. @throws {UnknownHashError} */\n retrieve(hash: string): string;\n /** The tool to expose to your model. See {@link RetrieveTool}. */\n readonly tool: RetrieveTool;\n /** Live counters, including the expansion rate. See {@link RetrieveStats}. */\n stats(): RetrieveStats;\n /** The underlying store, for consumers that persist or inspect it. */\n readonly store: ElisionStore;\n}\n\n/**\n * Build a smelter.\n *\n * ```ts\n * const smelter = createSmelter();\n * const result = await smelter.smelt(toolOutput, {\n * path: 'src/server.ts',\n * budgetBytes: 4_000,\n * focus: ['handleRequest'],\n * });\n * // result.text goes to the model; smelter.tool lets it ask for the rest back.\n * // smelter.stats().expansionRate tells you whether you cut too much.\n * ```\n */\nexport function createSmelter(config: SmelterConfig = {}): Smelter {\n const store = config.store ?? new MemoryElisionStore();\n // A constructed instance wins over a strategy name; the registry serves the names.\n const planner: Planner = config.planner ?? PLANNERS[config.strategy ?? DEFAULT_STRATEGY](config);\n const applyOptions: ApplyOptions = {\n ...(config.marker === undefined ? {} : { marker: config.marker }),\n ...(config.measure === undefined ? {} : { measure: config.measure }),\n };\n\n return {\n store,\n tool: createRetrieveTool(store),\n stats: () => store.stats(),\n retrieve: (hash) => store.retrieve(hash),\n reconstruct: (result) => reconstruct(result, store),\n async smelt(text, options = {}) {\n const budgetBytes = options.budgetBytes ?? config.defaultBudgetBytes;\n if (budgetBytes === undefined) {\n throw new SmeltError(\n 'smelt: no budget. Pass `budgetBytes` to smelt() or `defaultBudgetBytes` to ' +\n 'createSmelter(). There is no built-in default, because a budget smelt ' +\n 'invented would silently decide how much of your context to throw away.',\n );\n }\n const language = options.language ?? detectLanguage(options.path);\n const input: PlanInput = {\n text,\n language,\n budgetBytes,\n // The MarkerPricing seam, constructed centrally — here, and nowhere else in\n // the shipped pipeline — from the exact builder the applyPlan call below will\n // use: a caller-supplied `config.marker` prices with its own rendering (a\n // longer custom marker makes small cuts unprofitable, and the planner must\n // see that), otherwise the language's leader-wrapped default.\n pricing: markerPricing(language, config.marker),\n ...(options.focus === undefined ? {} : { focus: options.focus }),\n };\n const plan = await planner.plan(input);\n // The marker follows the *result's* language: it lands behind the language's\n // line-comment leader (see MARKER_LINE_COMMENT_LEADERS), because a bare marker\n // line breaks the survivor's syntax in every grammar tested. A caller-supplied\n // marker builder always wins.\n const marker = config.marker ?? markerForLanguage(plan.language);\n return applyPlan(text, plan, store, { ...applyOptions, marker });\n },\n };\n}\n"]}
@@ -0,0 +1,65 @@
1
+ import { applyPlan, markerForLanguage, markerPricing, reconstruct } from './apply.js';
2
+ import { detectLanguage } from './detect.js';
3
+ import { SmeltError } from './errors.js';
4
+ import { DEFAULT_STRATEGY, PLANNERS } from './plan/planners.js';
5
+ import { createRetrieveTool } from './retrieve.js';
6
+ import { MemoryElisionStore } from './store.js';
7
+ /**
8
+ * Build a smelter.
9
+ *
10
+ * ```ts
11
+ * const smelter = createSmelter();
12
+ * const result = await smelter.smelt(toolOutput, {
13
+ * path: 'src/server.ts',
14
+ * budgetBytes: 4_000,
15
+ * focus: ['handleRequest'],
16
+ * });
17
+ * // result.text goes to the model; smelter.tool lets it ask for the rest back.
18
+ * // smelter.stats().expansionRate tells you whether you cut too much.
19
+ * ```
20
+ */
21
+ export function createSmelter(config = {}) {
22
+ const store = config.store ?? new MemoryElisionStore();
23
+ // A constructed instance wins over a strategy name; the registry serves the names.
24
+ const planner = config.planner ?? PLANNERS[config.strategy ?? DEFAULT_STRATEGY](config);
25
+ const applyOptions = {
26
+ ...(config.marker === undefined ? {} : { marker: config.marker }),
27
+ ...(config.measure === undefined ? {} : { measure: config.measure }),
28
+ };
29
+ return {
30
+ store,
31
+ tool: createRetrieveTool(store),
32
+ stats: () => store.stats(),
33
+ retrieve: (hash) => store.retrieve(hash),
34
+ reconstruct: (result) => reconstruct(result, store),
35
+ async smelt(text, options = {}) {
36
+ const budgetBytes = options.budgetBytes ?? config.defaultBudgetBytes;
37
+ if (budgetBytes === undefined) {
38
+ throw new SmeltError('smelt: no budget. Pass `budgetBytes` to smelt() or `defaultBudgetBytes` to ' +
39
+ 'createSmelter(). There is no built-in default, because a budget smelt ' +
40
+ 'invented would silently decide how much of your context to throw away.');
41
+ }
42
+ const language = options.language ?? detectLanguage(options.path);
43
+ const input = {
44
+ text,
45
+ language,
46
+ budgetBytes,
47
+ // The MarkerPricing seam, constructed centrally — here, and nowhere else in
48
+ // the shipped pipeline — from the exact builder the applyPlan call below will
49
+ // use: a caller-supplied `config.marker` prices with its own rendering (a
50
+ // longer custom marker makes small cuts unprofitable, and the planner must
51
+ // see that), otherwise the language's leader-wrapped default.
52
+ pricing: markerPricing(language, config.marker),
53
+ ...(options.focus === undefined ? {} : { focus: options.focus }),
54
+ };
55
+ const plan = await planner.plan(input);
56
+ // The marker follows the *result's* language: it lands behind the language's
57
+ // line-comment leader (see MARKER_LINE_COMMENT_LEADERS), because a bare marker
58
+ // line breaks the survivor's syntax in every grammar tested. A caller-supplied
59
+ // marker builder always wins.
60
+ const marker = config.marker ?? markerForLanguage(plan.language);
61
+ return applyPlan(text, plan, store, { ...applyOptions, marker });
62
+ },
63
+ };
64
+ }
65
+ //# sourceMappingURL=smelter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"smelter.js","sourceRoot":"","sources":["../src/smelter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtF,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAGhE,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AA0EhD;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAAC,MAAM,GAAkB,EAAE;IACtD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,IAAI,IAAI,kBAAkB,EAAE,CAAC;IACvD,mFAAmF;IACnF,MAAM,OAAO,GAAY,MAAM,CAAC,OAAO,IAAI,QAAQ,CAAC,MAAM,CAAC,QAAQ,IAAI,gBAAgB,CAAC,CAAC,MAAM,CAAC,CAAC;IACjG,MAAM,YAAY,GAAiB;QACjC,GAAG,CAAC,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC;QACjE,GAAG,CAAC,MAAM,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;KACrE,CAAC;IAEF,OAAO;QACL,KAAK;QACL,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC;QAC/B,KAAK,EAAE,GAAG,EAAE,CAAC,KAAK,CAAC,KAAK,EAAE;QAC1B,QAAQ,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC;QACxC,WAAW,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,WAAW,CAAC,MAAM,EAAE,KAAK,CAAC;QACnD,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,EAAE;YAC5B,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,MAAM,CAAC,kBAAkB,CAAC;YACrE,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;gBAC9B,MAAM,IAAI,UAAU,CAClB,6EAA6E;oBAC3E,wEAAwE;oBACxE,wEAAwE,CAC3E,CAAC;YACJ,CAAC;YACD,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;YAClE,MAAM,KAAK,GAAc;gBACvB,IAAI;gBACJ,QAAQ;gBACR,WAAW;gBACX,4EAA4E;gBAC5E,8EAA8E;gBAC9E,0EAA0E;gBAC1E,2EAA2E;gBAC3E,8DAA8D;gBAC9D,OAAO,EAAE,aAAa,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC;gBAC/C,GAAG,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC;aACjE,CAAC;YACF,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACvC,6EAA6E;YAC7E,+EAA+E;YAC/E,+EAA+E;YAC/E,8BAA8B;YAC9B,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,IAAI,iBAAiB,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACjE,OAAO,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,GAAG,YAAY,EAAE,MAAM,EAAE,CAAC,CAAC;QACnE,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import { applyPlan, markerForLanguage, markerPricing, reconstruct } from './apply.ts';\nimport type { ApplyOptions, MarkerBuilder } from './apply.ts';\nimport { detectLanguage } from './detect.ts';\nimport { SmeltError } from './errors.ts';\nimport type { LexicalPlannerOptions } from './plan/lexical.ts';\nimport { DEFAULT_STRATEGY, PLANNERS } from './plan/planners.ts';\nimport type { Strategy } from './plan/planners.ts';\nimport type { StructuralPlannerOptions } from './plan/structural.ts';\nimport { createRetrieveTool } from './retrieve.ts';\nimport { MemoryElisionStore } from './store.ts';\nimport type {\n DetectedLanguage,\n ElisionStore,\n Measure,\n PlanInput,\n Planner,\n RetrieveStats,\n RetrieveTool,\n SmeltResult,\n} from './types.ts';\n\n/**\n * `createSmelter()` and the three types it is spelled in.\n *\n * It lives here rather than in `index.ts` so that nothing under `src/` has to import\n * the package barrel to build a smelter. `cli/subcommands/smelt.ts` does exactly that,\n * and while `index.ts` re-exports the CLI, a barrel that imports the CLI which imports\n * the barrel is a cycle whose only symptom is a registry that evaluates to `undefined`\n * in whichever module the loader happens to enter first. `index.ts` re-exports every\n * name below, so the published surface is unchanged.\n */\n\nexport interface SmelterConfig {\n /** Where elided bytes live. Defaults to a fresh {@link MemoryElisionStore}. */\n readonly store?: ElisionStore;\n /** Used when a `smelt()` call omits `budgetBytes`. No global default is assumed. */\n readonly defaultBudgetBytes?: number;\n /**\n * A constructed planner instance. Wins over `strategy`: the registry is a\n * convenience for the shipped planners, and an instance you built yourself is\n * always more specific than a name.\n */\n readonly planner?: Planner;\n readonly strategy?: Strategy;\n readonly marker?: MarkerBuilder;\n /**\n * Your own counter, so results carry a number in your unit as well as in bytes.\n * The budget stays in bytes — see {@link Measure} and `docs/ARCHITECTURE.md` § \"Decision 1\".\n */\n readonly measure?: Measure;\n readonly lexical?: LexicalPlannerOptions;\n readonly structural?: StructuralPlannerOptions;\n}\n\n/** Options for one `smelt()` call. `budgetBytes` may come from the smelter instead. */\nexport interface SmeltCallOptions {\n readonly budgetBytes?: number;\n readonly path?: string;\n readonly language?: DetectedLanguage;\n readonly focus?: readonly string[];\n}\n\n/**\n * One smelter, one store, one set of counters. The store is the reason this is an\n * object rather than a free function: elisions are only reversible for as long as\n * something holds them, so the thing that cuts and the thing that remembers have the\n * same lifetime by construction.\n */\nexport interface Smelter {\n /** Shrink one blob of text. Never mutates its input. */\n smelt(text: string, options?: SmeltCallOptions): Promise<SmeltResult>;\n /** The exact original text of a previous result. @throws {UnknownHashError} */\n reconstruct(result: SmeltResult): string;\n /** One elided run, counted as a retrieval. @throws {UnknownHashError} */\n retrieve(hash: string): string;\n /** The tool to expose to your model. See {@link RetrieveTool}. */\n readonly tool: RetrieveTool;\n /** Live counters, including the expansion rate. See {@link RetrieveStats}. */\n stats(): RetrieveStats;\n /** The underlying store, for consumers that persist or inspect it. */\n readonly store: ElisionStore;\n}\n\n/**\n * Build a smelter.\n *\n * ```ts\n * const smelter = createSmelter();\n * const result = await smelter.smelt(toolOutput, {\n * path: 'src/server.ts',\n * budgetBytes: 4_000,\n * focus: ['handleRequest'],\n * });\n * // result.text goes to the model; smelter.tool lets it ask for the rest back.\n * // smelter.stats().expansionRate tells you whether you cut too much.\n * ```\n */\nexport function createSmelter(config: SmelterConfig = {}): Smelter {\n const store = config.store ?? new MemoryElisionStore();\n // A constructed instance wins over a strategy name; the registry serves the names.\n const planner: Planner = config.planner ?? PLANNERS[config.strategy ?? DEFAULT_STRATEGY](config);\n const applyOptions: ApplyOptions = {\n ...(config.marker === undefined ? {} : { marker: config.marker }),\n ...(config.measure === undefined ? {} : { measure: config.measure }),\n };\n\n return {\n store,\n tool: createRetrieveTool(store),\n stats: () => store.stats(),\n retrieve: (hash) => store.retrieve(hash),\n reconstruct: (result) => reconstruct(result, store),\n async smelt(text, options = {}) {\n const budgetBytes = options.budgetBytes ?? config.defaultBudgetBytes;\n if (budgetBytes === undefined) {\n throw new SmeltError(\n 'smelt: no budget. Pass `budgetBytes` to smelt() or `defaultBudgetBytes` to ' +\n 'createSmelter(). There is no built-in default, because a budget smelt ' +\n 'invented would silently decide how much of your context to throw away.',\n );\n }\n const language = options.language ?? detectLanguage(options.path);\n const input: PlanInput = {\n text,\n language,\n budgetBytes,\n // The MarkerPricing seam, constructed centrally — here, and nowhere else in\n // the shipped pipeline — from the exact builder the applyPlan call below will\n // use: a caller-supplied `config.marker` prices with its own rendering (a\n // longer custom marker makes small cuts unprofitable, and the planner must\n // see that), otherwise the language's leader-wrapped default.\n pricing: markerPricing(language, config.marker),\n ...(options.focus === undefined ? {} : { focus: options.focus }),\n };\n const plan = await planner.plan(input);\n // The marker follows the *result's* language: it lands behind the language's\n // line-comment leader (see MARKER_LINE_COMMENT_LEADERS), because a bare marker\n // line breaks the survivor's syntax in every grammar tested. A caller-supplied\n // marker builder always wins.\n const marker = config.marker ?? markerForLanguage(plan.language);\n return applyPlan(text, plan, store, { ...applyOptions, marker });\n },\n };\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"stages.d.ts","sourceRoot":"","sources":["../src/stages.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAmB,WAAW,EAAqB,MAAM,YAAY,CAAC;AAEhG;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,WAYrC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,EAAE,YAQtC,CAAC"}
1
+ {"version":3,"file":"stages.d.ts","sourceRoot":"","sources":["../src/stages.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAmB,WAAW,EAAqB,MAAM,YAAY,CAAC;AAEhG;;;;;;;;GAQG;AACH,eAAO,MAAM,uBAAuB,EAAE,WAYrC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,EAAE,YAQtC,CAAC","sourcesContent":["import { NotImplementedError } from './errors.ts';\nimport type { DistillStage, RerankCandidate, RerankStage, RerankedCandidate } from './types.ts';\n\n/**\n * The reranker that ships with smelt: one that refuses.\n *\n * It exists so that \"reranking is a seam, not a feature\" is enforced rather than\n * promised. Wire this in and you get an exception naming the interface you were\n * supposed to implement. There is no default hosted reranker, no bundled key handling,\n * and no `SMELT_RERANK_API_KEY` — the first of those to appear breaks Law 1 for every\n * consumer at once, including the ones who never read the changelog.\n */\nexport const unconfiguredRerankStage: RerankStage = {\n id: 'rerank/unconfigured',\n rerank(\n _candidates: readonly RerankCandidate[],\n _query: string,\n ): Promise<readonly RerankedCandidate[]> {\n throw new NotImplementedError(\n 'reranking',\n 'docs/ARCHITECTURE.md § \"Explicitly out of scope\" — implement `RerankStage` in your own ' +\n 'code, with your own key, so the network call is visible in your source',\n );\n },\n};\n\n/**\n * Learned distillation, same treatment, for a different reason.\n *\n * Distillation is out of v1 because a model-written summary cannot satisfy Law 2. \"The\n * model condensed this\" does not say what was removed, and once the text is rewritten\n * there is nothing left to store under a hash. If this ever ships it will store the\n * original, explain itself in the same rule-named terms every other elision uses, and\n * be reversible — or it will not ship.\n */\nexport const unconfiguredDistillStage: DistillStage = {\n id: 'distill/unconfigured',\n distill(_text: string, _budgetBytes: number): Promise<string> {\n throw new NotImplementedError(\n 'learned distillation',\n 'docs/ARCHITECTURE.md § \"Explicitly out of scope\"',\n );\n },\n};\n"]}
package/dist/stages.js CHANGED
@@ -11,7 +11,7 @@ import { NotImplementedError } from './errors.js';
11
11
  export const unconfiguredRerankStage = {
12
12
  id: 'rerank/unconfigured',
13
13
  rerank(_candidates, _query) {
14
- throw new NotImplementedError('reranking', 'docs/HANDOFF.md § "Explicitly out of v1" — implement `RerankStage` in your own ' +
14
+ throw new NotImplementedError('reranking', 'docs/ARCHITECTURE.md § "Explicitly out of scope" — implement `RerankStage` in your own ' +
15
15
  'code, with your own key, so the network call is visible in your source');
16
16
  },
17
17
  };
@@ -27,7 +27,7 @@ export const unconfiguredRerankStage = {
27
27
  export const unconfiguredDistillStage = {
28
28
  id: 'distill/unconfigured',
29
29
  distill(_text, _budgetBytes) {
30
- throw new NotImplementedError('learned distillation', 'docs/HANDOFF.md § "Explicitly out of v1"');
30
+ throw new NotImplementedError('learned distillation', 'docs/ARCHITECTURE.md § "Explicitly out of scope"');
31
31
  },
32
32
  };
33
33
  //# sourceMappingURL=stages.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"stages.js","sourceRoot":"","sources":["../src/stages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGlD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAgB;IAClD,EAAE,EAAE,qBAAqB;IACzB,MAAM,CACJ,WAAuC,EACvC,MAAc;QAEd,MAAM,IAAI,mBAAmB,CAC3B,WAAW,EACX,iFAAiF;YAC/E,wEAAwE,CAC3E,CAAC;IACJ,CAAC;CACF,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAiB;IACpD,EAAE,EAAE,sBAAsB;IAC1B,OAAO,CAAC,KAAa,EAAE,YAAoB;QACzC,MAAM,IAAI,mBAAmB,CAC3B,sBAAsB,EACtB,0CAA0C,CAC3C,CAAC;IACJ,CAAC;CACF,CAAC"}
1
+ {"version":3,"file":"stages.js","sourceRoot":"","sources":["../src/stages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAGlD;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAgB;IAClD,EAAE,EAAE,qBAAqB;IACzB,MAAM,CACJ,WAAuC,EACvC,MAAc;QAEd,MAAM,IAAI,mBAAmB,CAC3B,WAAW,EACX,yFAAyF;YACvF,wEAAwE,CAC3E,CAAC;IACJ,CAAC;CACF,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAiB;IACpD,EAAE,EAAE,sBAAsB;IAC1B,OAAO,CAAC,KAAa,EAAE,YAAoB;QACzC,MAAM,IAAI,mBAAmB,CAC3B,sBAAsB,EACtB,kDAAkD,CACnD,CAAC;IACJ,CAAC;CACF,CAAC","sourcesContent":["import { NotImplementedError } from './errors.ts';\nimport type { DistillStage, RerankCandidate, RerankStage, RerankedCandidate } from './types.ts';\n\n/**\n * The reranker that ships with smelt: one that refuses.\n *\n * It exists so that \"reranking is a seam, not a feature\" is enforced rather than\n * promised. Wire this in and you get an exception naming the interface you were\n * supposed to implement. There is no default hosted reranker, no bundled key handling,\n * and no `SMELT_RERANK_API_KEY` — the first of those to appear breaks Law 1 for every\n * consumer at once, including the ones who never read the changelog.\n */\nexport const unconfiguredRerankStage: RerankStage = {\n id: 'rerank/unconfigured',\n rerank(\n _candidates: readonly RerankCandidate[],\n _query: string,\n ): Promise<readonly RerankedCandidate[]> {\n throw new NotImplementedError(\n 'reranking',\n 'docs/ARCHITECTURE.md § \"Explicitly out of scope\" — implement `RerankStage` in your own ' +\n 'code, with your own key, so the network call is visible in your source',\n );\n },\n};\n\n/**\n * Learned distillation, same treatment, for a different reason.\n *\n * Distillation is out of v1 because a model-written summary cannot satisfy Law 2. \"The\n * model condensed this\" does not say what was removed, and once the text is rewritten\n * there is nothing left to store under a hash. If this ever ships it will store the\n * original, explain itself in the same rule-named terms every other elision uses, and\n * be reversible — or it will not ship.\n */\nexport const unconfiguredDistillStage: DistillStage = {\n id: 'distill/unconfigured',\n distill(_text: string, _budgetBytes: number): Promise<string> {\n throw new NotImplementedError(\n 'learned distillation',\n 'docs/ARCHITECTURE.md § \"Explicitly out of scope\"',\n );\n },\n};\n"]}
package/dist/stats.d.ts CHANGED
@@ -11,7 +11,8 @@ export type RawRetrieveCounters = Omit<RetrieveStats, 'expansionRate' | 'allElis
11
11
  * The one derivation of the honesty arithmetic, shared by every store.
12
12
  *
13
13
  * `expansionRate` is the number this project exists to keep honest, and
14
- * `allElisionsRetrieved` is the one degenerate outcome it names (HANDOFF Decision 4).
14
+ * `allElisionsRetrieved` is the one degenerate outcome it names (`docs/ARCHITECTURE.md`
15
+ * § Decision 4).
15
16
  * When each store derived them privately, the two copies could drift — and a store
16
17
  * whose arithmetic drifted flattering-ward would be the exact silent failure Law 3's
17
18
  * counters exist to refuse. So the seam between a store and its stats is narrowed to
@@ -1 +1 @@
1
- {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,aAAa,EAAE,eAAe,GAAG,sBAAsB,CAAC,CAAC;AAEhG;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,mBAAmB,GAAG,aAAa,CAMrE"}
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,aAAa,EAAE,eAAe,GAAG,sBAAsB,CAAC,CAAC;AAEhG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,mBAAmB,GAAG,aAAa,CAMrE","sourcesContent":["import type { RetrieveStats } from './types.ts';\n\n/**\n * The directly-observed half of {@link RetrieveStats}: the five counts a store reads\n * off its own records — a map size, a directory scan, a journal fold. Nothing in here\n * is derived; every field is a fact the store witnessed. The derived half —\n * `expansionRate` and `allElisionsRetrieved`, the honesty arithmetic of Law 3 — is\n * computed from these by {@link retrieveStats}, in exactly one place.\n */\nexport type RawRetrieveCounters = Omit<RetrieveStats, 'expansionRate' | 'allElisionsRetrieved'>;\n\n/**\n * The one derivation of the honesty arithmetic, shared by every store.\n *\n * `expansionRate` is the number this project exists to keep honest, and\n * `allElisionsRetrieved` is the one degenerate outcome it names (`docs/ARCHITECTURE.md`\n * § Decision 4).\n * When each store derived them privately, the two copies could drift — and a store\n * whose arithmetic drifted flattering-ward would be the exact silent failure Law 3's\n * counters exist to refuse. So the seam between a store and its stats is narrowed to\n * raw counters: an {@link ElisionStore} adapter *supplies counts*\n * ({@link RawRetrieveCounters}) and never derives the metric.\n *\n * **Contract for adapter authors.** A custom store implements a\n * `rawCounters(): RawRetrieveCounters` method (or any equivalent that gathers the five\n * counts from its own records) and delegates its public `stats()` to this function:\n *\n * ```ts\n * stats(): RetrieveStats {\n * return retrieveStats(this.rawCounters());\n * }\n * ```\n *\n * This is a free wrapper function rather than an abstract base class, deliberately —\n * it is the least-breaking shape. The stores share no storage machinery (one is a\n * `Map`, one is a directory), so a base class would couple every adapter's inheritance\n * chain to smelt's for the sake of two lines of arithmetic; an existing `ElisionStore`\n * implementation keeps its own hierarchy and adopts this contract by changing only its\n * `stats()` body. The {@link ElisionStore} interface itself is unchanged: consumers\n * still call `stats()` and never see this seam.\n */\nexport function retrieveStats(raw: RawRetrieveCounters): RetrieveStats {\n return {\n ...raw,\n expansionRate: raw.elisionsStored === 0 ? 0 : raw.uniqueRetrieved / raw.elisionsStored,\n allElisionsRetrieved: raw.elisionsStored > 0 && raw.uniqueRetrieved === raw.elisionsStored,\n };\n}\n"]}
package/dist/stats.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * The one derivation of the honesty arithmetic, shared by every store.
3
3
  *
4
4
  * `expansionRate` is the number this project exists to keep honest, and
5
- * `allElisionsRetrieved` is the one degenerate outcome it names (HANDOFF Decision 4).
5
+ * `allElisionsRetrieved` is the one degenerate outcome it names (`docs/ARCHITECTURE.md`
6
+ * § Decision 4).
6
7
  * When each store derived them privately, the two copies could drift — and a store
7
8
  * whose arithmetic drifted flattering-ward would be the exact silent failure Law 3's
8
9
  * counters exist to refuse. So the seam between a store and its stats is narrowed to
package/dist/stats.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"stats.js","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,UAAU,aAAa,CAAC,GAAwB;IACpD,OAAO;QACL,GAAG,GAAG;QACN,aAAa,EAAE,GAAG,CAAC,cAAc,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,eAAe,GAAG,GAAG,CAAC,cAAc;QACtF,oBAAoB,EAAE,GAAG,CAAC,cAAc,GAAG,CAAC,IAAI,GAAG,CAAC,eAAe,KAAK,GAAG,CAAC,cAAc;KAC3F,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"stats.js","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAWA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,UAAU,aAAa,CAAC,GAAwB;IACpD,OAAO;QACL,GAAG,GAAG;QACN,aAAa,EAAE,GAAG,CAAC,cAAc,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,eAAe,GAAG,GAAG,CAAC,cAAc;QACtF,oBAAoB,EAAE,GAAG,CAAC,cAAc,GAAG,CAAC,IAAI,GAAG,CAAC,eAAe,KAAK,GAAG,CAAC,cAAc;KAC3F,CAAC;AACJ,CAAC","sourcesContent":["import type { RetrieveStats } from './types.ts';\n\n/**\n * The directly-observed half of {@link RetrieveStats}: the five counts a store reads\n * off its own records — a map size, a directory scan, a journal fold. Nothing in here\n * is derived; every field is a fact the store witnessed. The derived half —\n * `expansionRate` and `allElisionsRetrieved`, the honesty arithmetic of Law 3 — is\n * computed from these by {@link retrieveStats}, in exactly one place.\n */\nexport type RawRetrieveCounters = Omit<RetrieveStats, 'expansionRate' | 'allElisionsRetrieved'>;\n\n/**\n * The one derivation of the honesty arithmetic, shared by every store.\n *\n * `expansionRate` is the number this project exists to keep honest, and\n * `allElisionsRetrieved` is the one degenerate outcome it names (`docs/ARCHITECTURE.md`\n * § Decision 4).\n * When each store derived them privately, the two copies could drift — and a store\n * whose arithmetic drifted flattering-ward would be the exact silent failure Law 3's\n * counters exist to refuse. So the seam between a store and its stats is narrowed to\n * raw counters: an {@link ElisionStore} adapter *supplies counts*\n * ({@link RawRetrieveCounters}) and never derives the metric.\n *\n * **Contract for adapter authors.** A custom store implements a\n * `rawCounters(): RawRetrieveCounters` method (or any equivalent that gathers the five\n * counts from its own records) and delegates its public `stats()` to this function:\n *\n * ```ts\n * stats(): RetrieveStats {\n * return retrieveStats(this.rawCounters());\n * }\n * ```\n *\n * This is a free wrapper function rather than an abstract base class, deliberately —\n * it is the least-breaking shape. The stores share no storage machinery (one is a\n * `Map`, one is a directory), so a base class would couple every adapter's inheritance\n * chain to smelt's for the sake of two lines of arithmetic; an existing `ElisionStore`\n * implementation keeps its own hierarchy and adopts this contract by changing only its\n * `stats()` body. The {@link ElisionStore} interface itself is unchanged: consumers\n * still call `stats()` and never see this seam.\n */\nexport function retrieveStats(raw: RawRetrieveCounters): RetrieveStats {\n return {\n ...raw,\n expansionRate: raw.elisionsStored === 0 ? 0 : raw.uniqueRetrieved / raw.elisionsStored,\n allElisionsRetrieved: raw.elisionsStored > 0 && raw.uniqueRetrieved === raw.elisionsStored,\n };\n}\n"]}
@@ -40,10 +40,30 @@ export interface DirectoryElisionStoreOptions {
40
40
  * - **Writes are crash-safe.** A blob is written to `tmp/`, `fsync`ed, then `link(2)`ed
41
41
  * into `blobs/` — an atomic, no-clobber publish. A torn write dies in `tmp/`, where
42
42
  * nothing looks; a name in `blobs/` always refers to a fully written file.
43
- * - **Reads verify.** `retrieve()` and `peek()` re-hash the bytes and refuse a mismatch
44
- * with {@link StoreCorruptionError} a damaged blob is never handed back as a
45
- * retrieval, and "we hold damaged bytes" is distinct from {@link UnknownHashError}'s
46
- * "never existed". The guard in `test/guards/persistent-store.test.ts` watches this.
43
+ * - **`fsync` is only as strong as the platform makes it.** Every flush here is Node's
44
+ * `fsyncSync`, which is libuv's `uv_fs_fsync`, and what that reaches the hardware with
45
+ * differs by platform. On Apple it is strong: libuv knows macOS's own `fsync(2)` only
46
+ * hands the write to the drive, so its `__APPLE__` branch issues
47
+ * `fcntl(fd, F_FULLFSYNC)` — a real drive-cache flush — before falling back to
48
+ * `F_BARRIERFSYNC` and then plain `fsync(2)`. Measured here (Node 26, libuv 1.52,
49
+ * internal APFS SSD): an 11-byte append costs 0.01 ms unflushed, 5.5 ms through
50
+ * `fsyncSync`, and 0.10 ms through a raw `fsync(2)` against 4.8 ms through a raw
51
+ * `F_FULLFSYNC` — the cost says which syscall is being made. Everywhere else libuv
52
+ * calls plain `fsync(2)`, which is as durable as the drive's honesty about its own
53
+ * write cache. So a **power loss** can lose a blob or a journal line this code has
54
+ * already `fsync`ed and reported as written on any non-Apple platform whose drive
55
+ * lies, and on Apple only where `F_FULLFSYNC` itself fails and libuv degrades
56
+ * silently — some non-APFS and network mounts. A **process** crash cannot lose one
57
+ * anywhere: the bytes are in the page cache and the publish is still atomic. Nothing
58
+ * is ever handed back unverified either way, so the worst a lost blob can produce is
59
+ * {@link UnknownHashError} — never wrong bytes presented as right ones. "Crash-safe"
60
+ * is the claim, deliberately not "power-loss-proof": the durability is real, it is
61
+ * just not unconditional.
62
+ * - **Reads verify.** `retrieve()`, `peek()` and `has()` re-hash the bytes and refuse a
63
+ * mismatch with {@link StoreCorruptionError} — a damaged blob is never handed back as
64
+ * a retrieval nor reported as present, and "we hold damaged bytes" is distinct from
65
+ * {@link UnknownHashError}'s "never existed". The guard in
66
+ * `test/guards/persistent-store.test.ts` watches this.
47
67
  * - **Counters survive a restart.** Every `retrieve()` appends one `fsync`ed line to
48
68
  * `retrievals.log`, and `stats()` is a fold over it — so `expansionRate` stays
49
69
  * meaningful across a whole session, not just one process. A crash in the middle of
@@ -86,6 +106,25 @@ export declare class DirectoryElisionStore implements ElisionStore {
86
106
  put(content: string): string;
87
107
  peek(hash: string): string | undefined;
88
108
  retrieve(hash: string): string;
109
+ /**
110
+ * Whether this hash can be **retrieved** — verified, exactly as {@link peek} and
111
+ * {@link retrieve} verify, because it is `peek()`.
112
+ *
113
+ * `has()` used to be the one read that skipped verification: a damaged blob answered
114
+ * `true` and then threw {@link StoreCorruptionError} on the very next line, so a
115
+ * consumer that checked before retrieving was told a lie by the cheaper call. The two
116
+ * answers now come from one place and cannot drift: `true` means the bytes are there
117
+ * and hash to their name, `false` means this store never held them, and damage is
118
+ * raised rather than hidden behind a boolean — the same distinction `peek()` draws
119
+ * between "we hold damaged bytes" and "never existed".
120
+ *
121
+ * It stays uncounted: a check is not the model asking for material back, and counting
122
+ * one would inflate `retrieveCalls` and with it the expansion rate, which is the one
123
+ * number this library exists to keep honest. So no journal line is written here, not
124
+ * even for the corrupt case — `retrieve()` journals that when the model asks.
125
+ *
126
+ * @throws {StoreCorruptionError} when the stored bytes do not hash to their name.
127
+ */
89
128
  has(hash: string): boolean;
90
129
  /**
91
130
  * The five directly-observed counts, every one read off the disk — a scan of
@@ -1 +1 @@
1
- {"version":3,"file":"store-dir.d.ts","sourceRoot":"","sources":["../src/store-dir.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE9D;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAC5D,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAYzC,4FAA4F;AAC5F,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AACH,qBAAa,qBAAsB,YAAW,YAAY;;IAMxD,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,4BAAiC,EAiBnE;IAED,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoC3B;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKrC;IAED,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAY7B;IAED,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzB;IAED;;;;OAIG;IACH,WAAW,IAAI,mBAAmB,CAqBjC;IAED,KAAK,IAAI,aAAa,CAErB;CA6IF"}
1
+ {"version":3,"file":"store-dir.d.ts","sourceRoot":"","sources":["../src/store-dir.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE9D;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAC5D,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAYzC,4FAA4F;AAC5F,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoFG;AACH,qBAAa,qBAAsB,YAAW,YAAY;;IAMxD,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,4BAAiC,EAiBnE;IAED,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAoC3B;IAED,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAKrC;IAED,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAY7B;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzB;IAED;;;;OAIG;IACH,WAAW,IAAI,mBAAmB,CAqBjC;IAED,KAAK,IAAI,aAAa,CAErB;CA6IF","sourcesContent":["import {\n closeSync,\n fsyncSync,\n linkSync,\n mkdirSync,\n openSync,\n readdirSync,\n readFileSync,\n statSync,\n unlinkSync,\n writeSync,\n} from 'node:fs';\nimport { join, resolve } from 'node:path';\nimport { randomBytes } from 'node:crypto';\nimport process from 'node:process';\n\nimport {\n HashCollisionError,\n SmeltError,\n StoreCorruptionError,\n StoreFormatError,\n UnknownHashError,\n} from './errors.ts';\nimport { contentHash } from './hash.ts';\nimport { retrieveStats } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionStore, RetrieveStats } from './types.ts';\n\n/**\n * The format marker every store directory carries, and the one version this code\n * understands. A future layout is a new version, refused loudly by old code — never a\n * quiet reinterpretation of someone's stored bytes.\n */\nexport const DIRECTORY_STORE_FORMAT = 'smelt-elision-store';\nexport const DIRECTORY_STORE_VERSION = 1;\n\n/**\n * What a storage key may look like. `contentHash` produces 16 lowercase hex characters;\n * the pattern is wider so an injected test hash still works, and strict enough that a\n * key can never traverse out of `blobs/` or collide with `format.json`.\n */\nconst KEY_PATTERN = /^[0-9a-f]{4,128}$/;\n\n/** One journal line: a kind, a space, and the hash as a JSON string literal. */\nconst LOG_LINE = /^(hit|miss|corrupt) (\"(?:[^\"\\\\]|\\\\.)*\")$/;\n\n/** See {@link MemoryElisionStoreOptions} in `store.ts` — same escape hatch, same reason. */\nexport interface DirectoryElisionStoreOptions {\n /**\n * Override the hash function, so the collision branch — unreachable with sha256 —\n * can be tested. Production has no reason to pass this.\n */\n readonly hash?: (content: string) => string;\n}\n\n/**\n * A persistent {@link ElisionStore} over a content-addressed directory. `node:fs` only —\n * no SQLite, no new dependency, nothing that phones home. Elisions put here outlive the\n * process, so a long-lived agent session can `retrieve()` across restarts.\n *\n * ## Storage layout\n *\n * ```text\n * <root>/\n * format.json { \"format\": \"smelt-elision-store\", \"version\": 1 } — refused if unknown\n * blobs/<hash> one file per elision: the exact UTF-8 bytes, named by their content hash\n * tmp/ staging for atomic writes; never read, safe to sweep\n * retrievals.log append-only journal: `hit \"<hash>\"` | `miss \"<hash>\"` | `corrupt \"<hash>\"`\n * ```\n *\n * **Nothing lives in memory.** Every read — `stats()` included — comes off the disk, so\n * two instances over the same directory (two processes, or one process before and after\n * a restart) always agree. `stats()` is a scan; elision counts per session are small and\n * retrieval is the model asking for material back, which is rare by design.\n *\n * ## Durability\n *\n * - **Writes are crash-safe.** A blob is written to `tmp/`, `fsync`ed, then `link(2)`ed\n * into `blobs/` — an atomic, no-clobber publish. A torn write dies in `tmp/`, where\n * nothing looks; a name in `blobs/` always refers to a fully written file.\n * - **`fsync` is only as strong as the platform makes it.** Every flush here is Node's\n * `fsyncSync`, which is libuv's `uv_fs_fsync`, and what that reaches the hardware with\n * differs by platform. On Apple it is strong: libuv knows macOS's own `fsync(2)` only\n * hands the write to the drive, so its `__APPLE__` branch issues\n * `fcntl(fd, F_FULLFSYNC)` — a real drive-cache flush — before falling back to\n * `F_BARRIERFSYNC` and then plain `fsync(2)`. Measured here (Node 26, libuv 1.52,\n * internal APFS SSD): an 11-byte append costs 0.01 ms unflushed, 5.5 ms through\n * `fsyncSync`, and 0.10 ms through a raw `fsync(2)` against 4.8 ms through a raw\n * `F_FULLFSYNC` — the cost says which syscall is being made. Everywhere else libuv\n * calls plain `fsync(2)`, which is as durable as the drive's honesty about its own\n * write cache. So a **power loss** can lose a blob or a journal line this code has\n * already `fsync`ed and reported as written on any non-Apple platform whose drive\n * lies, and on Apple only where `F_FULLFSYNC` itself fails and libuv degrades\n * silently — some non-APFS and network mounts. A **process** crash cannot lose one\n * anywhere: the bytes are in the page cache and the publish is still atomic. Nothing\n * is ever handed back unverified either way, so the worst a lost blob can produce is\n * {@link UnknownHashError} — never wrong bytes presented as right ones. \"Crash-safe\"\n * is the claim, deliberately not \"power-loss-proof\": the durability is real, it is\n * just not unconditional.\n * - **Reads verify.** `retrieve()`, `peek()` and `has()` re-hash the bytes and refuse a\n * mismatch with {@link StoreCorruptionError} — a damaged blob is never handed back as\n * a retrieval nor reported as present, and \"we hold damaged bytes\" is distinct from\n * {@link UnknownHashError}'s \"never existed\". The guard in\n * `test/guards/persistent-store.test.ts` watches this.\n * - **Counters survive a restart.** Every `retrieve()` appends one `fsync`ed line to\n * `retrievals.log`, and `stats()` is a fold over it — so `expansionRate` stays\n * meaningful across a whole session, not just one process. A crash in the middle of\n * an append can tear at most that one line; a torn tail is skipped, costing at most\n * the single count that was being written when the process died.\n * - **Concurrent writers are safe.** `link(2)` refuses to clobber, so two processes\n * putting at once race to publish and the loser verifies byte-for-byte agreement with\n * the winner — identical content dedupes, different content under one hash is a\n * {@link HashCollisionError}. Journal appends use `O_APPEND`. Tested with two real\n * processes in `test/store-dir.test.ts`.\n *\n * ## No eviction\n *\n * Same rule as {@link MemoryElisionStore}: no cap, no LRU, no `clear()`. A store that\n * can forget turns Law 3 into \"reversible, usually\". Elided text is smaller than the\n * session that produced it; if disk pressure ever forces a cap, retrieval of an evicted\n * hash must throw a distinct \"evicted\" error — never {@link UnknownHashError} — so the\n * model can tell \"we lost it\" from \"never existed\". Today there is no such error because\n * there is no such cap.\n *\n * ## Two deliberate choices around the edges\n *\n * - **The root is resolved to an absolute path at construction.** Every later path is\n * joined from that, so a `process.chdir()` after construction cannot silently\n * re-target the store — the bytes a relative-rooted store put before a chdir would\n * otherwise be unreachable after it, which reads exactly like data loss.\n * - **A failed journal append never withholds intact bytes.** `retrieve()`'s order of\n * business is: read, verify, count, return. When the *count* cannot be written (a\n * read-only journal, a full disk), the bytes are still returned — they are verified\n * and the caller asked for them; refusing would turn a bookkeeping failure into\n * Law 3 breaking. The failure is surfaced distinctly instead: a\n * `process.emitWarning` with name `SmeltCounterWriteFailure`, so \"your retrieval\n * worked\" and \"your counters just went quiet\" stay two separate facts. The same\n * applies to the `miss`/`corrupt` journal lines: the store's own error for the\n * lookup still wins over the journal's I/O error.\n */\nexport class DirectoryElisionStore implements ElisionStore {\n readonly #blobsDir: string;\n readonly #tmpDir: string;\n readonly #logPath: string;\n readonly #hash: (content: string) => string;\n\n constructor(root: string, options: DirectoryElisionStoreOptions = {}) {\n this.#hash = options.hash ?? contentHash;\n // Resolve NOW, against the working directory the caller constructed with — a\n // later chdir must never re-point an already-constructed store. See the class doc.\n const absoluteRoot = resolve(root);\n this.#blobsDir = join(absoluteRoot, 'blobs');\n this.#tmpDir = join(absoluteRoot, 'tmp');\n this.#logPath = join(absoluteRoot, 'retrievals.log');\n const markerPath = join(absoluteRoot, 'format.json');\n // Validate before mutating: a directory carrying a marker this code does not\n // understand is refused with the directory exactly as it was found — no blobs/,\n // no tmp/, no staged temp file created inside someone else's layout.\n const existing = this.#readMarker(markerPath);\n if (existing !== undefined) this.#verifyMarker(markerPath, existing);\n mkdirSync(this.#blobsDir, { recursive: true });\n mkdirSync(this.#tmpDir, { recursive: true });\n this.#claimFormat(markerPath);\n }\n\n put(content: string): string {\n const hash = this.#hash(content);\n if (!KEY_PATTERN.test(hash)) {\n throw new SmeltError(\n `smelt: hash \"${hash}\" is not usable as a storage key — it must match ` +\n `${String(KEY_PATTERN)} so it can name a file inside blobs/ and nothing else.`,\n );\n }\n const existing = this.#readBlob(hash);\n if (existing !== undefined) {\n // Verify the stored bytes before comparing: a damaged blob is corruption, not a\n // collision. Only intact bytes that still differ earn HashCollisionError.\n if (this.#hash(existing) !== hash) throw new StoreCorruptionError(hash);\n if (existing !== content) throw new HashCollisionError(hash);\n return hash;\n }\n const tmpPath = this.#writeTemp(content);\n try {\n // link(2) is the atomic, no-clobber publish: it fails with EEXIST rather than\n // overwrite, so a concurrent writer can never silently replace someone's bytes.\n linkSync(tmpPath, join(this.#blobsDir, hash));\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n // Another writer published this hash between our existence check and our link.\n // Same bytes: idempotent put, done. Damaged or vanished bytes: corruption — the\n // store was torn or edited outside smelt. Intact different bytes: a collision.\n const winner = this.#readBlob(hash);\n if (winner === undefined || this.#hash(winner) !== hash) {\n throw new StoreCorruptionError(hash);\n }\n if (winner !== content) throw new HashCollisionError(hash);\n } finally {\n unlinkSync(tmpPath);\n }\n fsyncDirBestEffort(this.#blobsDir);\n return hash;\n }\n\n peek(hash: string): string | undefined {\n const content = this.#readBlob(hash);\n if (content === undefined) return undefined;\n if (this.#hash(content) !== hash) throw new StoreCorruptionError(hash);\n return content;\n }\n\n retrieve(hash: string): string {\n const content = this.#readBlob(hash);\n if (content === undefined) {\n this.#appendLogCounting('miss', hash);\n throw new UnknownHashError(hash);\n }\n if (this.#hash(content) !== hash) {\n this.#appendLogCounting('corrupt', hash);\n throw new StoreCorruptionError(hash);\n }\n this.#appendLogCounting('hit', hash);\n return content;\n }\n\n /**\n * Whether this hash can be **retrieved** — verified, exactly as {@link peek} and\n * {@link retrieve} verify, because it is `peek()`.\n *\n * `has()` used to be the one read that skipped verification: a damaged blob answered\n * `true` and then threw {@link StoreCorruptionError} on the very next line, so a\n * consumer that checked before retrieving was told a lie by the cheaper call. The two\n * answers now come from one place and cannot drift: `true` means the bytes are there\n * and hash to their name, `false` means this store never held them, and damage is\n * raised rather than hidden behind a boolean — the same distinction `peek()` draws\n * between \"we hold damaged bytes\" and \"never existed\".\n *\n * It stays uncounted: a check is not the model asking for material back, and counting\n * one would inflate `retrieveCalls` and with it the expansion rate, which is the one\n * number this library exists to keep honest. So no journal line is written here, not\n * even for the corrupt case — `retrieve()` journals that when the model asks.\n *\n * @throws {StoreCorruptionError} when the stored bytes do not hash to their name.\n */\n has(hash: string): boolean {\n return this.peek(hash) !== undefined;\n }\n\n /**\n * The five directly-observed counts, every one read off the disk — a scan of\n * `blobs/` plus a fold over `retrievals.log`. See {@link RawRetrieveCounters}; the\n * derived half of the stats comes from the shared `retrieveStats()`, never here.\n */\n rawCounters(): RawRetrieveCounters {\n let elisionsStored = 0;\n let bytesStored = 0;\n for (const entry of readdirSync(this.#blobsDir)) {\n if (!KEY_PATTERN.test(entry)) continue; // `.DS_Store` and friends are not blobs\n elisionsStored += 1;\n bytesStored += statSync(join(this.#blobsDir, entry)).size;\n }\n\n let retrieveCalls = 0;\n let misses = 0;\n const hits = new Set<string>();\n for (const line of this.#readLog().split('\\n')) {\n const match = LOG_LINE.exec(line);\n if (match === null) continue; // a torn tail from a crash mid-append, or blank\n retrieveCalls += 1;\n if (match[1] === 'miss') misses += 1;\n else if (match[1] === 'hit') hits.add(JSON.parse(match[2]!) as string);\n }\n\n return { elisionsStored, bytesStored, retrieveCalls, uniqueRetrieved: hits.size, misses };\n }\n\n stats(): RetrieveStats {\n return retrieveStats(this.rawCounters());\n }\n\n /** The blob's exact content, or `undefined` when no such blob is stored. */\n #readBlob(hash: string): string | undefined {\n if (!KEY_PATTERN.test(hash)) return undefined; // never a path component\n try {\n return readFileSync(join(this.#blobsDir, hash), 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Write content to a unique file in `tmp/`, fsynced, and return its path. */\n #writeTemp(content: string): string {\n const tmpPath = join(this.#tmpDir, `${String(process.pid)}-${randomBytes(8).toString('hex')}`);\n const fd = openSync(tmpPath, 'wx');\n try {\n // writeSync may write fewer bytes than asked; loop, or a short write would be\n // fsynced and published under the full content's hash as a torn blob.\n const bytes = Buffer.from(content, 'utf8');\n let written = 0;\n while (written < bytes.length) {\n written += writeSync(fd, bytes, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n return tmpPath;\n }\n\n /**\n * A journal append on the `retrieve()` path — counting, not custody. A failure here\n * must never decide whether the caller gets its verified bytes (or its true error),\n * so it is caught and surfaced as a distinct `process.emitWarning` — see the class\n * doc, and the read-only-journal case in `test/store-dir.test.ts`.\n */\n #appendLogCounting(kind: 'hit' | 'miss' | 'corrupt', hash: string): void {\n try {\n this.#appendLog(kind, hash);\n } catch (error) {\n process.emitWarning(\n `smelt: could not journal a \"${kind}\" for hash \"${hash}\" in ${this.#logPath} ` +\n `(${error instanceof Error ? error.message : String(error)}). The retrieval ` +\n `itself is unaffected, but this count is lost — retrieveCalls and ` +\n `expansionRate now UNDER-report until the journal is writable again.`,\n 'SmeltCounterWriteFailure',\n );\n }\n }\n\n /**\n * One durable journal line. The hash is JSON-encoded because `retrieve()` takes it\n * from the model verbatim — a hash containing a newline must not forge a second line.\n * The record starts with its own newline so a torn tail from an earlier crash — a\n * partial record with no trailing newline — can never bleed into this one: the tear\n * stays on its own line and is skipped by `stats()`, as blank lines are.\n */\n #appendLog(kind: 'hit' | 'miss' | 'corrupt', hash: string): void {\n const fd = openSync(this.#logPath, 'a');\n try {\n const record = Buffer.from(`\\n${kind} ${JSON.stringify(hash)}\\n`, 'utf8');\n let written = 0;\n while (written < record.length) {\n written += writeSync(fd, record, written);\n }\n fsyncSync(fd);\n } finally {\n closeSync(fd);\n }\n }\n\n #readLog(): string {\n try {\n return readFileSync(this.#logPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return '';\n throw error;\n }\n }\n\n /**\n * Write the format marker if this directory has none, or verify the one it has.\n * Creation is atomic (write to `tmp/`, then `link`), so a concurrent creator never\n * observes a half-written marker. The constructor pre-verified any pre-existing\n * marker; the verify here catches only a concurrent creator's claim.\n */\n #claimFormat(markerPath: string): void {\n const claim = (): string | undefined => {\n const body = `${JSON.stringify({\n format: DIRECTORY_STORE_FORMAT,\n version: DIRECTORY_STORE_VERSION,\n })}\\n`;\n const tmpPath = this.#writeTemp(body);\n try {\n linkSync(tmpPath, markerPath);\n return undefined; // claimed by us; nothing to verify\n } catch (error) {\n if ((error as { code?: string }).code !== 'EEXIST') throw error;\n return readFileSync(markerPath, 'utf8');\n } finally {\n unlinkSync(tmpPath);\n }\n };\n\n const existing = claim();\n if (existing === undefined) return;\n this.#verifyMarker(markerPath, existing);\n }\n\n /** The marker's body, or `undefined` when the directory carries none. */\n #readMarker(markerPath: string): string | undefined {\n try {\n return readFileSync(markerPath, 'utf8');\n } catch (error) {\n if ((error as { code?: string }).code === 'ENOENT') return undefined;\n throw error;\n }\n }\n\n /** Refuse a marker this version of smelt does not understand. */\n #verifyMarker(markerPath: string, existing: string): void {\n let parsed: { format?: unknown; version?: unknown };\n try {\n parsed = JSON.parse(existing) as { format?: unknown; version?: unknown };\n } catch {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" is not parseable JSON, so this directory cannot be ` +\n `trusted as an elision store. Refusing to read or write it.`,\n );\n }\n if (parsed.format !== DIRECTORY_STORE_FORMAT || parsed.version !== DIRECTORY_STORE_VERSION) {\n throw new StoreFormatError(\n `smelt: \"${markerPath}\" declares format ${JSON.stringify(parsed.format)} ` +\n `version ${JSON.stringify(parsed.version)}; this code understands ` +\n `\"${DIRECTORY_STORE_FORMAT}\" version ${String(DIRECTORY_STORE_VERSION)}. ` +\n `Refusing to reinterpret someone else's layout.`,\n );\n }\n }\n}\n\n/**\n * Flush the directory entry after a publish, so the *name* survives a crash as well as\n * the bytes. Where the platform refuses to fsync a directory (Windows does), the publish\n * is still atomic — only the durability of the directory entry falls back to the OS's\n * own schedule. Only that refusal is swallowed: a real I/O failure (`EIO`) propagates,\n * because \"the disk could not flush\" must never be reported as a successful put.\n *\n * Where it does work it is the same `fsyncSync` the blob's own flush uses, and so is\n * exactly as strong as that — see the durability note on {@link DirectoryElisionStore}\n * for what that means per platform.\n */\nfunction fsyncDirBestEffort(path: string): void {\n let fd: number;\n try {\n fd = openSync(path, 'r');\n } catch {\n return; // the platform refuses to even open a directory for reading (Windows)\n }\n try {\n fsyncSync(fd);\n } catch (error) {\n const code = (error as { code?: string }).code;\n // EINVAL/ENOTSUP/EPERM/EBADF: the platform refuses to fsync a directory — see the\n // doc comment. Anything else (EIO above all) is a genuine write failure.\n if (code !== 'EINVAL' && code !== 'ENOTSUP' && code !== 'EPERM' && code !== 'EBADF') {\n throw error;\n }\n } finally {\n closeSync(fd);\n }\n}\n"]}
package/dist/store-dir.js CHANGED
@@ -45,10 +45,30 @@ const LOG_LINE = /^(hit|miss|corrupt) ("(?:[^"\\]|\\.)*")$/;
45
45
  * - **Writes are crash-safe.** A blob is written to `tmp/`, `fsync`ed, then `link(2)`ed
46
46
  * into `blobs/` — an atomic, no-clobber publish. A torn write dies in `tmp/`, where
47
47
  * nothing looks; a name in `blobs/` always refers to a fully written file.
48
- * - **Reads verify.** `retrieve()` and `peek()` re-hash the bytes and refuse a mismatch
49
- * with {@link StoreCorruptionError} a damaged blob is never handed back as a
50
- * retrieval, and "we hold damaged bytes" is distinct from {@link UnknownHashError}'s
51
- * "never existed". The guard in `test/guards/persistent-store.test.ts` watches this.
48
+ * - **`fsync` is only as strong as the platform makes it.** Every flush here is Node's
49
+ * `fsyncSync`, which is libuv's `uv_fs_fsync`, and what that reaches the hardware with
50
+ * differs by platform. On Apple it is strong: libuv knows macOS's own `fsync(2)` only
51
+ * hands the write to the drive, so its `__APPLE__` branch issues
52
+ * `fcntl(fd, F_FULLFSYNC)` — a real drive-cache flush — before falling back to
53
+ * `F_BARRIERFSYNC` and then plain `fsync(2)`. Measured here (Node 26, libuv 1.52,
54
+ * internal APFS SSD): an 11-byte append costs 0.01 ms unflushed, 5.5 ms through
55
+ * `fsyncSync`, and 0.10 ms through a raw `fsync(2)` against 4.8 ms through a raw
56
+ * `F_FULLFSYNC` — the cost says which syscall is being made. Everywhere else libuv
57
+ * calls plain `fsync(2)`, which is as durable as the drive's honesty about its own
58
+ * write cache. So a **power loss** can lose a blob or a journal line this code has
59
+ * already `fsync`ed and reported as written on any non-Apple platform whose drive
60
+ * lies, and on Apple only where `F_FULLFSYNC` itself fails and libuv degrades
61
+ * silently — some non-APFS and network mounts. A **process** crash cannot lose one
62
+ * anywhere: the bytes are in the page cache and the publish is still atomic. Nothing
63
+ * is ever handed back unverified either way, so the worst a lost blob can produce is
64
+ * {@link UnknownHashError} — never wrong bytes presented as right ones. "Crash-safe"
65
+ * is the claim, deliberately not "power-loss-proof": the durability is real, it is
66
+ * just not unconditional.
67
+ * - **Reads verify.** `retrieve()`, `peek()` and `has()` re-hash the bytes and refuse a
68
+ * mismatch with {@link StoreCorruptionError} — a damaged blob is never handed back as
69
+ * a retrieval nor reported as present, and "we hold damaged bytes" is distinct from
70
+ * {@link UnknownHashError}'s "never existed". The guard in
71
+ * `test/guards/persistent-store.test.ts` watches this.
52
72
  * - **Counters survive a restart.** Every `retrieve()` appends one `fsync`ed line to
53
73
  * `retrievals.log`, and `stats()` is a fold over it — so `expansionRate` stays
54
74
  * meaningful across a whole session, not just one process. A crash in the middle of
@@ -171,8 +191,27 @@ export class DirectoryElisionStore {
171
191
  this.#appendLogCounting('hit', hash);
172
192
  return content;
173
193
  }
194
+ /**
195
+ * Whether this hash can be **retrieved** — verified, exactly as {@link peek} and
196
+ * {@link retrieve} verify, because it is `peek()`.
197
+ *
198
+ * `has()` used to be the one read that skipped verification: a damaged blob answered
199
+ * `true` and then threw {@link StoreCorruptionError} on the very next line, so a
200
+ * consumer that checked before retrieving was told a lie by the cheaper call. The two
201
+ * answers now come from one place and cannot drift: `true` means the bytes are there
202
+ * and hash to their name, `false` means this store never held them, and damage is
203
+ * raised rather than hidden behind a boolean — the same distinction `peek()` draws
204
+ * between "we hold damaged bytes" and "never existed".
205
+ *
206
+ * It stays uncounted: a check is not the model asking for material back, and counting
207
+ * one would inflate `retrieveCalls` and with it the expansion rate, which is the one
208
+ * number this library exists to keep honest. So no journal line is written here, not
209
+ * even for the corrupt case — `retrieve()` journals that when the model asks.
210
+ *
211
+ * @throws {StoreCorruptionError} when the stored bytes do not hash to their name.
212
+ */
174
213
  has(hash) {
175
- return this.#readBlob(hash) !== undefined;
214
+ return this.peek(hash) !== undefined;
176
215
  }
177
216
  /**
178
217
  * The five directly-observed counts, every one read off the disk — a scan of
@@ -352,6 +391,10 @@ export class DirectoryElisionStore {
352
391
  * is still atomic — only the durability of the directory entry falls back to the OS's
353
392
  * own schedule. Only that refusal is swallowed: a real I/O failure (`EIO`) propagates,
354
393
  * because "the disk could not flush" must never be reported as a successful put.
394
+ *
395
+ * Where it does work it is the same `fsyncSync` the blob's own flush uses, and so is
396
+ * exactly as strong as that — see the durability note on {@link DirectoryElisionStore}
397
+ * for what that means per platform.
355
398
  */
356
399
  function fsyncDirBestEffort(path) {
357
400
  let fd;