@smeltjs/core 0.4.0 → 0.6.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.
- package/dist/apply.d.ts.map +1 -1
- package/dist/apply.js +8 -2
- package/dist/apply.js.map +1 -1
- package/dist/cli/bin.d.ts.map +1 -1
- package/dist/cli/bin.js +9 -4
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/doctor.d.ts +66 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/doctor.js +137 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/hooks.d.ts +36 -2
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +108 -64
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +12 -16
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/installed.d.ts +56 -0
- package/dist/cli/installed.d.ts.map +1 -0
- package/dist/cli/installed.js +105 -0
- package/dist/cli/installed.js.map +1 -0
- package/dist/cli/lava.d.ts +31 -0
- package/dist/cli/lava.d.ts.map +1 -0
- package/dist/cli/lava.js +82 -0
- package/dist/cli/lava.js.map +1 -0
- package/dist/cli/report.d.ts +10 -1
- package/dist/cli/report.d.ts.map +1 -1
- package/dist/cli/report.js +16 -1
- package/dist/cli/report.js.map +1 -1
- package/dist/cli/setup.d.ts +87 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +513 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.d.ts +11 -4
- package/dist/cli/shell.d.ts.map +1 -1
- package/dist/cli/shell.js.map +1 -1
- package/dist/cli/subcommands/agents.d.ts.map +1 -1
- package/dist/cli/subcommands/agents.js +2 -1
- package/dist/cli/subcommands/agents.js.map +1 -1
- package/dist/cli/subcommands/doctor.d.ts +15 -0
- package/dist/cli/subcommands/doctor.d.ts.map +1 -0
- package/dist/cli/subcommands/doctor.js +43 -0
- package/dist/cli/subcommands/doctor.js.map +1 -0
- package/dist/cli/subcommands/flags.d.ts +16 -0
- package/dist/cli/subcommands/flags.d.ts.map +1 -1
- package/dist/cli/subcommands/flags.js +43 -4
- package/dist/cli/subcommands/flags.js.map +1 -1
- package/dist/cli/subcommands/hooks.d.ts.map +1 -1
- package/dist/cli/subcommands/hooks.js +10 -2
- package/dist/cli/subcommands/hooks.js.map +1 -1
- package/dist/cli/subcommands/init.d.ts.map +1 -1
- package/dist/cli/subcommands/init.js +2 -1
- package/dist/cli/subcommands/init.js.map +1 -1
- package/dist/cli/subcommands/registry.d.ts +3 -1
- package/dist/cli/subcommands/registry.d.ts.map +1 -1
- package/dist/cli/subcommands/registry.js +6 -0
- package/dist/cli/subcommands/registry.js.map +1 -1
- package/dist/cli/subcommands/setup.d.ts +23 -0
- package/dist/cli/subcommands/setup.d.ts.map +1 -0
- package/dist/cli/subcommands/setup.js +87 -0
- package/dist/cli/subcommands/setup.js.map +1 -0
- package/dist/cli/subcommands/smelt.d.ts +3 -0
- package/dist/cli/subcommands/smelt.d.ts.map +1 -1
- package/dist/cli/subcommands/smelt.js +5 -1
- package/dist/cli/subcommands/smelt.js.map +1 -1
- package/dist/cli/subcommands/stats.d.ts +8 -3
- package/dist/cli/subcommands/stats.d.ts.map +1 -1
- package/dist/cli/subcommands/stats.js +15 -5
- package/dist/cli/subcommands/stats.js.map +1 -1
- package/dist/cli/subcommands/subcommand.d.ts +1 -1
- package/dist/cli/subcommands/subcommand.d.ts.map +1 -1
- package/dist/cli/subcommands/subcommand.js.map +1 -1
- package/dist/cli/wizard.d.ts +72 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +86 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -1
- package/dist/harness/claude-code.d.ts.map +1 -1
- package/dist/harness/claude-code.js +10 -0
- package/dist/harness/claude-code.js.map +1 -1
- package/dist/harness/codex.d.ts.map +1 -1
- package/dist/harness/codex.js +1 -0
- package/dist/harness/codex.js.map +1 -1
- package/dist/harness/grok.d.ts.map +1 -1
- package/dist/harness/grok.js +4 -1
- package/dist/harness/grok.js.map +1 -1
- package/dist/harness/kilocode.d.ts.map +1 -1
- package/dist/harness/kilocode.js +1 -1
- package/dist/harness/kilocode.js.map +1 -1
- package/dist/harness/opencode.d.ts.map +1 -1
- package/dist/harness/opencode.js +10 -0
- package/dist/harness/opencode.js.map +1 -1
- package/dist/harness/profile.d.ts +23 -2
- package/dist/harness/profile.d.ts.map +1 -1
- package/dist/harness/profile.js.map +1 -1
- package/dist/harness/snippet.d.ts +14 -1
- package/dist/harness/snippet.d.ts.map +1 -1
- package/dist/harness/snippet.js +18 -2
- package/dist/harness/snippet.js.map +1 -1
- package/dist/hooks/focus-terms.d.ts +57 -0
- package/dist/hooks/focus-terms.d.ts.map +1 -0
- package/dist/hooks/focus-terms.js +230 -0
- package/dist/hooks/focus-terms.js.map +1 -0
- package/dist/hooks/guard-core.d.ts +8 -16
- package/dist/hooks/guard-core.d.ts.map +1 -1
- package/dist/hooks/guard-core.js +22 -120
- package/dist/hooks/guard-core.js.map +1 -1
- package/dist/index.d.ts +16 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/ops/index.d.ts +3 -3
- package/dist/ops/index.d.ts.map +1 -1
- package/dist/ops/index.js +2 -2
- package/dist/ops/index.js.map +1 -1
- package/dist/ops/verbs.d.ts +57 -1
- package/dist/ops/verbs.d.ts.map +1 -1
- package/dist/ops/verbs.js +49 -1
- package/dist/ops/verbs.js.map +1 -1
- package/dist/plan/auto.d.ts +9 -2
- package/dist/plan/auto.d.ts.map +1 -1
- package/dist/plan/auto.js +14 -2
- package/dist/plan/auto.js.map +1 -1
- package/dist/plan/diff.d.ts +45 -0
- package/dist/plan/diff.d.ts.map +1 -0
- package/dist/plan/diff.js +284 -0
- package/dist/plan/diff.js.map +1 -0
- package/dist/plan/json.d.ts +37 -0
- package/dist/plan/json.d.ts.map +1 -0
- package/dist/plan/json.js +181 -0
- package/dist/plan/json.js.map +1 -0
- package/dist/plan/kind.d.ts +28 -0
- package/dist/plan/kind.d.ts.map +1 -0
- package/dist/plan/kind.js +57 -0
- package/dist/plan/kind.js.map +1 -0
- package/dist/plan/offsets.d.ts +8 -0
- package/dist/plan/offsets.d.ts.map +1 -0
- package/dist/plan/offsets.js +19 -0
- package/dist/plan/offsets.js.map +1 -0
- package/dist/plan/planners.d.ts +15 -5
- package/dist/plan/planners.d.ts.map +1 -1
- package/dist/plan/planners.js +13 -5
- package/dist/plan/planners.js.map +1 -1
- package/dist/plan/structural.d.ts.map +1 -1
- package/dist/plan/structural.js +106 -14
- package/dist/plan/structural.js.map +1 -1
- package/dist/retrieve.d.ts +22 -1
- package/dist/retrieve.d.ts.map +1 -1
- package/dist/retrieve.js +59 -0
- package/dist/retrieve.js.map +1 -1
- package/dist/setup/recipe.d.ts +73 -0
- package/dist/setup/recipe.d.ts.map +1 -0
- package/dist/setup/recipe.js +76 -0
- package/dist/setup/recipe.js.map +1 -0
- package/dist/smelter.d.ts +4 -0
- package/dist/smelter.d.ts.map +1 -1
- package/dist/smelter.js +4 -0
- package/dist/smelter.js.map +1 -1
- package/dist/stats.d.ts +16 -1
- package/dist/stats.d.ts.map +1 -1
- package/dist/stats.js +29 -0
- package/dist/stats.js.map +1 -1
- package/dist/store-dir.d.ts +9 -2
- package/dist/store-dir.d.ts.map +1 -1
- package/dist/store-dir.js +48 -7
- package/dist/store-dir.js.map +1 -1
- package/dist/store.d.ts +4 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +12 -2
- package/dist/store.js.map +1 -1
- package/dist/text/json-edit.d.ts +15 -1
- package/dist/text/json-edit.d.ts.map +1 -1
- package/dist/text/json-edit.js +76 -5
- package/dist/text/json-edit.js.map +1 -1
- package/dist/types.d.ts +116 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SetupRecipe (CONTEXT.md): the one true way to put smelt on a machine — the
|
|
3
|
+
* install commands, the init defaults, the store default, the hooks step, the MCP
|
|
4
|
+
* registration — held as data, because prose is never the source. Every rendering
|
|
5
|
+
* derives from this module or is pinned to it by a guard
|
|
6
|
+
* (`test/guards/setup-recipe.test.ts`); the same facts used to be retyped until the
|
|
7
|
+
* store default existed under three doc spellings, one of them wrong, and the MCP
|
|
8
|
+
* registration command lived in four places at once.
|
|
9
|
+
*
|
|
10
|
+
* This module imports nothing and does nothing: it is the fact layer every setup
|
|
11
|
+
* surface reads, and the seam the `setup` verb, the skill pack, and the site's fact
|
|
12
|
+
* generator hang off.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SETUP_RECIPE: {
|
|
15
|
+
readonly install: {
|
|
16
|
+
/** The library, into a project — npm's spelling, and its siblings beside it. */
|
|
17
|
+
readonly library: 'npm install @smeltjs/core';
|
|
18
|
+
readonly libraryPnpm: 'pnpm add @smeltjs/core';
|
|
19
|
+
readonly libraryBun: 'bun add @smeltjs/core';
|
|
20
|
+
/** The CLI, onto the machine. (Not named `global`: that word is Law 1's.) */
|
|
21
|
+
readonly globalInstall: 'npm install -g @smeltjs/core';
|
|
22
|
+
/** The CLI, without installing it. */
|
|
23
|
+
readonly oneShot: 'npx @smeltjs/core';
|
|
24
|
+
/** The CLI over Homebrew, from smelt's own tap (KOT-248 seeds it). */
|
|
25
|
+
readonly brewInstall: 'brew install smeltjs/tap/smelt';
|
|
26
|
+
/** The Homebrew upgrade — the first half of the update loop doctor completes. */
|
|
27
|
+
readonly brewUpgrade: 'brew upgrade smelt';
|
|
28
|
+
/** The SkillPack, installed by an agent's owner (ADR-0002). */
|
|
29
|
+
readonly skillInstall: 'npx skills add smeltjs/smelt';
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The budget the setup path writes when its caller names none — the number every
|
|
33
|
+
* example already uses. Written loudly and recorded with its provenance; the
|
|
34
|
+
* `smelt` verb's own budget-required refusal is untouched by it.
|
|
35
|
+
*/
|
|
36
|
+
readonly recommendedBudgetBytes: 4000;
|
|
37
|
+
readonly store: {
|
|
38
|
+
/** Where the persistent store lives, relative to smelt.config.json. */
|
|
39
|
+
readonly defaultDir: '.smelt/store';
|
|
40
|
+
};
|
|
41
|
+
readonly mcp: {
|
|
42
|
+
/** The MCP server, run from the project directory. */
|
|
43
|
+
readonly run: 'npx @smeltjs/mcp';
|
|
44
|
+
/**
|
|
45
|
+
* Registration as Claude Code's CLI spells it — the canonical string while the
|
|
46
|
+
* harness profiles cannot yet carry per-harness registration; the
|
|
47
|
+
* mcp-registration step kind generalizes this per harness.
|
|
48
|
+
*/
|
|
49
|
+
readonly register: 'claude mcp add smelt -- npx @smeltjs/mcp';
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export type SetupRecipe = typeof SETUP_RECIPE;
|
|
53
|
+
/**
|
|
54
|
+
* The recipe's run command as a spawn array — derived once, beside the fact it comes
|
|
55
|
+
* from, because two harness profiles need the array and a second `.split(' ')` would
|
|
56
|
+
* be the second spelling this module exists to end.
|
|
57
|
+
*/
|
|
58
|
+
export declare const MCP_RUN_ARGS: readonly string[];
|
|
59
|
+
/** One step of the recipe, in the order a new machine walks it. */
|
|
60
|
+
export interface SetupStep {
|
|
61
|
+
readonly id: 'install' | 'init' | 'hooks' | 'mcp' | 'verify';
|
|
62
|
+
readonly title: string;
|
|
63
|
+
readonly command: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The recipe's steps, in order, ending in the verification that makes "set up" a
|
|
67
|
+
* claim with evidence. The install and MCP commands are named facts above; the init,
|
|
68
|
+
* hooks and verify steps name the verbs and the canonical invocation — the wizard
|
|
69
|
+
* each is, not a fact the recipe owns, except the budget the verify step carries,
|
|
70
|
+
* which is the recipe's.
|
|
71
|
+
*/
|
|
72
|
+
export declare const SETUP_STEPS: readonly SetupStep[];
|
|
73
|
+
//# sourceMappingURL=recipe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recipe.d.ts","sourceRoot":"","sources":["../../src/setup/recipe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY;aACvB,OAAO;QACL,gFAAgF;iBAChF,OAAO,EAAE,2BAA2B;iBACpC,WAAW,EAAE,wBAAwB;iBACrC,UAAU,EAAE,uBAAuB;QACnC,6EAA6E;iBAC7E,aAAa,EAAE,8BAA8B;QAC7C,sCAAsC;iBACtC,OAAO,EAAE,mBAAmB;QAC5B,sEAAsE;iBACtE,WAAW,EAAE,gCAAgC;QAC7C,iFAAiF;iBACjF,WAAW,EAAE,oBAAoB;QACjC,+DAA+D;iBAC/D,YAAY,EAAE,8BAA8B;;IAE9C;;;;OAIG;aACH,sBAAsB,EAAE,IAAI;aAC5B,KAAK;QACH,uEAAuE;iBACvE,UAAU,EAAE,cAAc;;aAE5B,GAAG;QACD,sDAAsD;iBACtD,GAAG,EAAE,kBAAkB;QACvB;;;;WAIG;iBACH,QAAQ,EAAE,0CAA0C;;CAE9C,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,OAAO,YAAY,CAAC;AAE9C;;;;GAIG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAoC,CAAC;AAE/E,mEAAmE;AACnE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,QAAQ,CAAC;IAC7D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,EAAE,SAAS,SAAS,EAU3C,CAAC","sourcesContent":["/**\n * The SetupRecipe (CONTEXT.md): the one true way to put smelt on a machine — the\n * install commands, the init defaults, the store default, the hooks step, the MCP\n * registration — held as data, because prose is never the source. Every rendering\n * derives from this module or is pinned to it by a guard\n * (`test/guards/setup-recipe.test.ts`); the same facts used to be retyped until the\n * store default existed under three doc spellings, one of them wrong, and the MCP\n * registration command lived in four places at once.\n *\n * This module imports nothing and does nothing: it is the fact layer every setup\n * surface reads, and the seam the `setup` verb, the skill pack, and the site's fact\n * generator hang off.\n */\nexport const SETUP_RECIPE = {\n install: {\n /** The library, into a project — npm's spelling, and its siblings beside it. */\n library: 'npm install @smeltjs/core',\n libraryPnpm: 'pnpm add @smeltjs/core',\n libraryBun: 'bun add @smeltjs/core',\n /** The CLI, onto the machine. (Not named `global`: that word is Law 1's.) */\n globalInstall: 'npm install -g @smeltjs/core',\n /** The CLI, without installing it. */\n oneShot: 'npx @smeltjs/core',\n /** The CLI over Homebrew, from smelt's own tap (KOT-248 seeds it). */\n brewInstall: 'brew install smeltjs/tap/smelt',\n /** The Homebrew upgrade — the first half of the update loop doctor completes. */\n brewUpgrade: 'brew upgrade smelt',\n /** The SkillPack, installed by an agent's owner (ADR-0002). */\n skillInstall: 'npx skills add smeltjs/smelt',\n },\n /**\n * The budget the setup path writes when its caller names none — the number every\n * example already uses. Written loudly and recorded with its provenance; the\n * `smelt` verb's own budget-required refusal is untouched by it.\n */\n recommendedBudgetBytes: 4000,\n store: {\n /** Where the persistent store lives, relative to smelt.config.json. */\n defaultDir: '.smelt/store',\n },\n mcp: {\n /** The MCP server, run from the project directory. */\n run: 'npx @smeltjs/mcp',\n /**\n * Registration as Claude Code's CLI spells it — the canonical string while the\n * harness profiles cannot yet carry per-harness registration; the\n * mcp-registration step kind generalizes this per harness.\n */\n register: 'claude mcp add smelt -- npx @smeltjs/mcp',\n },\n} as const;\n\nexport type SetupRecipe = typeof SETUP_RECIPE;\n\n/**\n * The recipe's run command as a spawn array — derived once, beside the fact it comes\n * from, because two harness profiles need the array and a second `.split(' ')` would\n * be the second spelling this module exists to end.\n */\nexport const MCP_RUN_ARGS: readonly string[] = SETUP_RECIPE.mcp.run.split(' ');\n\n/** One step of the recipe, in the order a new machine walks it. */\nexport interface SetupStep {\n readonly id: 'install' | 'init' | 'hooks' | 'mcp' | 'verify';\n readonly title: string;\n readonly command: string;\n}\n\n/**\n * The recipe's steps, in order, ending in the verification that makes \"set up\" a\n * claim with evidence. The install and MCP commands are named facts above; the init,\n * hooks and verify steps name the verbs and the canonical invocation — the wizard\n * each is, not a fact the recipe owns, except the budget the verify step carries,\n * which is the recipe's.\n */\nexport const SETUP_STEPS: readonly SetupStep[] = [\n { id: 'install', title: 'install the CLI', command: SETUP_RECIPE.install.globalInstall },\n { id: 'init', title: 'write smelt.config.json', command: 'smelt init' },\n { id: 'hooks', title: 'wire the hooks preset', command: 'smelt hooks install' },\n { id: 'mcp', title: 'register the MCP server', command: SETUP_RECIPE.mcp.register },\n {\n id: 'verify',\n title: 'prove the round trip on a real file',\n command: `smelt <file> --budget ${SETUP_RECIPE.recommendedBudgetBytes} --focus <focus>`,\n },\n];\n"]}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SetupRecipe (CONTEXT.md): the one true way to put smelt on a machine — the
|
|
3
|
+
* install commands, the init defaults, the store default, the hooks step, the MCP
|
|
4
|
+
* registration — held as data, because prose is never the source. Every rendering
|
|
5
|
+
* derives from this module or is pinned to it by a guard
|
|
6
|
+
* (`test/guards/setup-recipe.test.ts`); the same facts used to be retyped until the
|
|
7
|
+
* store default existed under three doc spellings, one of them wrong, and the MCP
|
|
8
|
+
* registration command lived in four places at once.
|
|
9
|
+
*
|
|
10
|
+
* This module imports nothing and does nothing: it is the fact layer every setup
|
|
11
|
+
* surface reads, and the seam the `setup` verb, the skill pack, and the site's fact
|
|
12
|
+
* generator hang off.
|
|
13
|
+
*/
|
|
14
|
+
export const SETUP_RECIPE = {
|
|
15
|
+
install: {
|
|
16
|
+
/** The library, into a project — npm's spelling, and its siblings beside it. */
|
|
17
|
+
library: 'npm install @smeltjs/core',
|
|
18
|
+
libraryPnpm: 'pnpm add @smeltjs/core',
|
|
19
|
+
libraryBun: 'bun add @smeltjs/core',
|
|
20
|
+
/** The CLI, onto the machine. (Not named `global`: that word is Law 1's.) */
|
|
21
|
+
globalInstall: 'npm install -g @smeltjs/core',
|
|
22
|
+
/** The CLI, without installing it. */
|
|
23
|
+
oneShot: 'npx @smeltjs/core',
|
|
24
|
+
/** The CLI over Homebrew, from smelt's own tap (KOT-248 seeds it). */
|
|
25
|
+
brewInstall: 'brew install smeltjs/tap/smelt',
|
|
26
|
+
/** The Homebrew upgrade — the first half of the update loop doctor completes. */
|
|
27
|
+
brewUpgrade: 'brew upgrade smelt',
|
|
28
|
+
/** The SkillPack, installed by an agent's owner (ADR-0002). */
|
|
29
|
+
skillInstall: 'npx skills add smeltjs/smelt',
|
|
30
|
+
},
|
|
31
|
+
/**
|
|
32
|
+
* The budget the setup path writes when its caller names none — the number every
|
|
33
|
+
* example already uses. Written loudly and recorded with its provenance; the
|
|
34
|
+
* `smelt` verb's own budget-required refusal is untouched by it.
|
|
35
|
+
*/
|
|
36
|
+
recommendedBudgetBytes: 4000,
|
|
37
|
+
store: {
|
|
38
|
+
/** Where the persistent store lives, relative to smelt.config.json. */
|
|
39
|
+
defaultDir: '.smelt/store',
|
|
40
|
+
},
|
|
41
|
+
mcp: {
|
|
42
|
+
/** The MCP server, run from the project directory. */
|
|
43
|
+
run: 'npx @smeltjs/mcp',
|
|
44
|
+
/**
|
|
45
|
+
* Registration as Claude Code's CLI spells it — the canonical string while the
|
|
46
|
+
* harness profiles cannot yet carry per-harness registration; the
|
|
47
|
+
* mcp-registration step kind generalizes this per harness.
|
|
48
|
+
*/
|
|
49
|
+
register: 'claude mcp add smelt -- npx @smeltjs/mcp',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The recipe's run command as a spawn array — derived once, beside the fact it comes
|
|
54
|
+
* from, because two harness profiles need the array and a second `.split(' ')` would
|
|
55
|
+
* be the second spelling this module exists to end.
|
|
56
|
+
*/
|
|
57
|
+
export const MCP_RUN_ARGS = SETUP_RECIPE.mcp.run.split(' ');
|
|
58
|
+
/**
|
|
59
|
+
* The recipe's steps, in order, ending in the verification that makes "set up" a
|
|
60
|
+
* claim with evidence. The install and MCP commands are named facts above; the init,
|
|
61
|
+
* hooks and verify steps name the verbs and the canonical invocation — the wizard
|
|
62
|
+
* each is, not a fact the recipe owns, except the budget the verify step carries,
|
|
63
|
+
* which is the recipe's.
|
|
64
|
+
*/
|
|
65
|
+
export const SETUP_STEPS = [
|
|
66
|
+
{ id: 'install', title: 'install the CLI', command: SETUP_RECIPE.install.globalInstall },
|
|
67
|
+
{ id: 'init', title: 'write smelt.config.json', command: 'smelt init' },
|
|
68
|
+
{ id: 'hooks', title: 'wire the hooks preset', command: 'smelt hooks install' },
|
|
69
|
+
{ id: 'mcp', title: 'register the MCP server', command: SETUP_RECIPE.mcp.register },
|
|
70
|
+
{
|
|
71
|
+
id: 'verify',
|
|
72
|
+
title: 'prove the round trip on a real file',
|
|
73
|
+
command: `smelt <file> --budget ${SETUP_RECIPE.recommendedBudgetBytes} --focus <focus>`,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
//# sourceMappingURL=recipe.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recipe.js","sourceRoot":"","sources":["../../src/setup/recipe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,OAAO,EAAE;QACP,gFAAgF;QAChF,OAAO,EAAE,2BAA2B;QACpC,WAAW,EAAE,wBAAwB;QACrC,UAAU,EAAE,uBAAuB;QACnC,6EAA6E;QAC7E,aAAa,EAAE,8BAA8B;QAC7C,sCAAsC;QACtC,OAAO,EAAE,mBAAmB;QAC5B,sEAAsE;QACtE,WAAW,EAAE,gCAAgC;QAC7C,iFAAiF;QACjF,WAAW,EAAE,oBAAoB;QACjC,+DAA+D;QAC/D,YAAY,EAAE,8BAA8B;KAC7C;IACD;;;;OAIG;IACH,sBAAsB,EAAE,IAAI;IAC5B,KAAK,EAAE;QACL,uEAAuE;QACvE,UAAU,EAAE,cAAc;KAC3B;IACD,GAAG,EAAE;QACH,sDAAsD;QACtD,GAAG,EAAE,kBAAkB;QACvB;;;;WAIG;QACH,QAAQ,EAAE,0CAA0C;KACrD;CACO,CAAC;AAIX;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAsB,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;AAS/E;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,WAAW,GAAyB;IAC/C,EAAE,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,iBAAiB,EAAE,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,aAAa,EAAE;IACxF,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,yBAAyB,EAAE,OAAO,EAAE,YAAY,EAAE;IACvE,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,uBAAuB,EAAE,OAAO,EAAE,qBAAqB,EAAE;IAC/E,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,yBAAyB,EAAE,OAAO,EAAE,YAAY,CAAC,GAAG,CAAC,QAAQ,EAAE;IACnF;QACE,EAAE,EAAE,QAAQ;QACZ,KAAK,EAAE,qCAAqC;QAC5C,OAAO,EAAE,yBAAyB,YAAY,CAAC,sBAAsB,kBAAkB;KACxF;CACF,CAAC","sourcesContent":["/**\n * The SetupRecipe (CONTEXT.md): the one true way to put smelt on a machine — the\n * install commands, the init defaults, the store default, the hooks step, the MCP\n * registration — held as data, because prose is never the source. Every rendering\n * derives from this module or is pinned to it by a guard\n * (`test/guards/setup-recipe.test.ts`); the same facts used to be retyped until the\n * store default existed under three doc spellings, one of them wrong, and the MCP\n * registration command lived in four places at once.\n *\n * This module imports nothing and does nothing: it is the fact layer every setup\n * surface reads, and the seam the `setup` verb, the skill pack, and the site's fact\n * generator hang off.\n */\nexport const SETUP_RECIPE = {\n install: {\n /** The library, into a project — npm's spelling, and its siblings beside it. */\n library: 'npm install @smeltjs/core',\n libraryPnpm: 'pnpm add @smeltjs/core',\n libraryBun: 'bun add @smeltjs/core',\n /** The CLI, onto the machine. (Not named `global`: that word is Law 1's.) */\n globalInstall: 'npm install -g @smeltjs/core',\n /** The CLI, without installing it. */\n oneShot: 'npx @smeltjs/core',\n /** The CLI over Homebrew, from smelt's own tap (KOT-248 seeds it). */\n brewInstall: 'brew install smeltjs/tap/smelt',\n /** The Homebrew upgrade — the first half of the update loop doctor completes. */\n brewUpgrade: 'brew upgrade smelt',\n /** The SkillPack, installed by an agent's owner (ADR-0002). */\n skillInstall: 'npx skills add smeltjs/smelt',\n },\n /**\n * The budget the setup path writes when its caller names none — the number every\n * example already uses. Written loudly and recorded with its provenance; the\n * `smelt` verb's own budget-required refusal is untouched by it.\n */\n recommendedBudgetBytes: 4000,\n store: {\n /** Where the persistent store lives, relative to smelt.config.json. */\n defaultDir: '.smelt/store',\n },\n mcp: {\n /** The MCP server, run from the project directory. */\n run: 'npx @smeltjs/mcp',\n /**\n * Registration as Claude Code's CLI spells it — the canonical string while the\n * harness profiles cannot yet carry per-harness registration; the\n * mcp-registration step kind generalizes this per harness.\n */\n register: 'claude mcp add smelt -- npx @smeltjs/mcp',\n },\n} as const;\n\nexport type SetupRecipe = typeof SETUP_RECIPE;\n\n/**\n * The recipe's run command as a spawn array — derived once, beside the fact it comes\n * from, because two harness profiles need the array and a second `.split(' ')` would\n * be the second spelling this module exists to end.\n */\nexport const MCP_RUN_ARGS: readonly string[] = SETUP_RECIPE.mcp.run.split(' ');\n\n/** One step of the recipe, in the order a new machine walks it. */\nexport interface SetupStep {\n readonly id: 'install' | 'init' | 'hooks' | 'mcp' | 'verify';\n readonly title: string;\n readonly command: string;\n}\n\n/**\n * The recipe's steps, in order, ending in the verification that makes \"set up\" a\n * claim with evidence. The install and MCP commands are named facts above; the init,\n * hooks and verify steps name the verbs and the canonical invocation — the wizard\n * each is, not a fact the recipe owns, except the budget the verify step carries,\n * which is the recipe's.\n */\nexport const SETUP_STEPS: readonly SetupStep[] = [\n { id: 'install', title: 'install the CLI', command: SETUP_RECIPE.install.globalInstall },\n { id: 'init', title: 'write smelt.config.json', command: 'smelt init' },\n { id: 'hooks', title: 'wire the hooks preset', command: 'smelt hooks install' },\n { id: 'mcp', title: 'register the MCP server', command: SETUP_RECIPE.mcp.register },\n {\n id: 'verify',\n title: 'prove the round trip on a real file',\n command: `smelt <file> --budget ${SETUP_RECIPE.recommendedBudgetBytes} --focus <focus>`,\n },\n];\n"]}
|
package/dist/smelter.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { MarkerBuilder } from './apply.ts';
|
|
2
|
+
import type { DiffPlannerOptions } from './plan/diff.ts';
|
|
3
|
+
import type { JsonPlannerOptions } from './plan/json.ts';
|
|
2
4
|
import type { LexicalPlannerOptions } from './plan/lexical.ts';
|
|
3
5
|
import type { Strategy } from './plan/planners.ts';
|
|
4
6
|
import type { StructuralPlannerOptions } from './plan/structural.ts';
|
|
@@ -33,6 +35,8 @@ export interface SmelterConfig {
|
|
|
33
35
|
readonly measure?: Measure;
|
|
34
36
|
readonly lexical?: LexicalPlannerOptions;
|
|
35
37
|
readonly structural?: StructuralPlannerOptions;
|
|
38
|
+
readonly json?: JsonPlannerOptions;
|
|
39
|
+
readonly diff?: DiffPlannerOptions;
|
|
36
40
|
}
|
|
37
41
|
/** Options for one `smelt()` call. `budgetBytes` may come from the smelter instead. */
|
|
38
42
|
export interface SmeltCallOptions {
|
package/dist/smelter.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AACzD,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;IAC/C,QAAQ,CAAC,IAAI,CAAC,EAAE,kBAAkB,CAAC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,kBAAkB,CAAC;CACpC;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,CAkDjE","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 { DiffPlannerOptions } from './plan/diff.ts';\nimport type { JsonPlannerOptions } from './plan/json.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 readonly json?: JsonPlannerOptions;\n readonly diff?: DiffPlannerOptions;\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 // The ledger, the same way: read off the store this smelter cuts into, here and\n // nowhere else, when the store keeps one. Opt-in data for a planner that wants\n // the feedback loop; the shipped planners leave it unread (Decision 4).\n ...(store.ledger === undefined ? {} : { ruleHistory: store.ledger() }),\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"]}
|
package/dist/smelter.js
CHANGED
|
@@ -51,6 +51,10 @@ export function createSmelter(config = {}) {
|
|
|
51
51
|
// see that), otherwise the language's leader-wrapped default.
|
|
52
52
|
pricing: markerPricing(language, config.marker),
|
|
53
53
|
...(options.focus === undefined ? {} : { focus: options.focus }),
|
|
54
|
+
// The ledger, the same way: read off the store this smelter cuts into, here and
|
|
55
|
+
// nowhere else, when the store keeps one. Opt-in data for a planner that wants
|
|
56
|
+
// the feedback loop; the shipped planners leave it unread (Decision 4).
|
|
57
|
+
...(store.ledger === undefined ? {} : { ruleHistory: store.ledger() }),
|
|
54
58
|
};
|
|
55
59
|
const plan = await planner.plan(input);
|
|
56
60
|
// The marker follows the *result's* language: it lands behind the language's
|
package/dist/smelter.js.map
CHANGED
|
@@ -1 +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;
|
|
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;AAIzC,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;AA4EhD;;;;;;;;;;;;;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;gBAChE,gFAAgF;gBAChF,+EAA+E;gBAC/E,wEAAwE;gBACxE,GAAG,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,MAAM,EAAE,EAAE,CAAC;aACvE,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 { DiffPlannerOptions } from './plan/diff.ts';\nimport type { JsonPlannerOptions } from './plan/json.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 readonly json?: JsonPlannerOptions;\n readonly diff?: DiffPlannerOptions;\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 // The ledger, the same way: read off the store this smelter cuts into, here and\n // nowhere else, when the store keeps one. Opt-in data for a planner that wants\n // the feedback loop; the shipped planners leave it unread (Decision 4).\n ...(store.ledger === undefined ? {} : { ruleHistory: store.ledger() }),\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"]}
|
package/dist/stats.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RetrieveStats } from './types.ts';
|
|
1
|
+
import type { RetrieveStats, RuleLedgerEntry } from './types.ts';
|
|
2
2
|
/**
|
|
3
3
|
* The directly-observed half of {@link RetrieveStats}: the five counts a store reads
|
|
4
4
|
* off its own records — a map size, a directory scan, a journal fold. Nothing in here
|
|
@@ -38,4 +38,19 @@ export type RawRetrieveCounters = Omit<RetrieveStats, 'expansionRate' | 'allElis
|
|
|
38
38
|
* still call `stats()` and never see this seam.
|
|
39
39
|
*/
|
|
40
40
|
export declare function retrieveStats(raw: RawRetrieveCounters): RetrieveStats;
|
|
41
|
+
/**
|
|
42
|
+
* The one derivation of the per-rule ledger, shared by every store — the same
|
|
43
|
+
* discipline as {@link retrieveStats}: a store supplies the facts it witnessed (which
|
|
44
|
+
* hash was put under which rule, which hashes were ever hit) and never derives the
|
|
45
|
+
* counts itself, so two stores cannot disagree about what "retrieved" means per rule.
|
|
46
|
+
*
|
|
47
|
+
* `puts` may repeat a hash under a rule (a directory store journals every put); the
|
|
48
|
+
* ledger counts distinct hashes. A hash put under two rules counts under both — each
|
|
49
|
+
* rule did make that cut. Rows come back sorted by rule id, so the rendering is
|
|
50
|
+
* stable across processes and platforms.
|
|
51
|
+
*/
|
|
52
|
+
export declare function ruleLedger(puts: Iterable<{
|
|
53
|
+
readonly hash: string;
|
|
54
|
+
readonly rule: string;
|
|
55
|
+
}>, retrieved: ReadonlySet<string>): readonly RuleLedgerEntry[];
|
|
41
56
|
//# sourceMappingURL=stats.d.ts.map
|
package/dist/stats.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../src/stats.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAEjE;;;;;;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;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,QAAQ,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,EAChE,SAAS,EAAE,WAAW,CAAC,MAAM,CAAC,GAC7B,SAAS,eAAe,EAAE,CAiB5B","sourcesContent":["import type { RetrieveStats, RuleLedgerEntry } 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\n/**\n * The one derivation of the per-rule ledger, shared by every store — the same\n * discipline as {@link retrieveStats}: a store supplies the facts it witnessed (which\n * hash was put under which rule, which hashes were ever hit) and never derives the\n * counts itself, so two stores cannot disagree about what \"retrieved\" means per rule.\n *\n * `puts` may repeat a hash under a rule (a directory store journals every put); the\n * ledger counts distinct hashes. A hash put under two rules counts under both — each\n * rule did make that cut. Rows come back sorted by rule id, so the rendering is\n * stable across processes and platforms.\n */\nexport function ruleLedger(\n puts: Iterable<{ readonly hash: string; readonly rule: string }>,\n retrieved: ReadonlySet<string>,\n): readonly RuleLedgerEntry[] {\n const byRule = new Map<string, Set<string>>();\n for (const { hash, rule } of puts) {\n let hashes = byRule.get(rule);\n if (hashes === undefined) {\n hashes = new Set();\n byRule.set(rule, hashes);\n }\n hashes.add(hash);\n }\n return [...byRule.entries()]\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([rule, hashes]) => ({\n rule,\n stored: hashes.size,\n retrieved: [...hashes].filter((hash) => retrieved.has(hash)).length,\n }));\n}\n"]}
|
package/dist/stats.js
CHANGED
|
@@ -35,4 +35,33 @@ export function retrieveStats(raw) {
|
|
|
35
35
|
allElisionsRetrieved: raw.elisionsStored > 0 && raw.uniqueRetrieved === raw.elisionsStored,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* The one derivation of the per-rule ledger, shared by every store — the same
|
|
40
|
+
* discipline as {@link retrieveStats}: a store supplies the facts it witnessed (which
|
|
41
|
+
* hash was put under which rule, which hashes were ever hit) and never derives the
|
|
42
|
+
* counts itself, so two stores cannot disagree about what "retrieved" means per rule.
|
|
43
|
+
*
|
|
44
|
+
* `puts` may repeat a hash under a rule (a directory store journals every put); the
|
|
45
|
+
* ledger counts distinct hashes. A hash put under two rules counts under both — each
|
|
46
|
+
* rule did make that cut. Rows come back sorted by rule id, so the rendering is
|
|
47
|
+
* stable across processes and platforms.
|
|
48
|
+
*/
|
|
49
|
+
export function ruleLedger(puts, retrieved) {
|
|
50
|
+
const byRule = new Map();
|
|
51
|
+
for (const { hash, rule } of puts) {
|
|
52
|
+
let hashes = byRule.get(rule);
|
|
53
|
+
if (hashes === undefined) {
|
|
54
|
+
hashes = new Set();
|
|
55
|
+
byRule.set(rule, hashes);
|
|
56
|
+
}
|
|
57
|
+
hashes.add(hash);
|
|
58
|
+
}
|
|
59
|
+
return [...byRule.entries()]
|
|
60
|
+
.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
61
|
+
.map(([rule, hashes]) => ({
|
|
62
|
+
rule,
|
|
63
|
+
stored: hashes.size,
|
|
64
|
+
retrieved: [...hashes].filter((hash) => retrieved.has(hash)).length,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
38
67
|
//# sourceMappingURL=stats.js.map
|
package/dist/stats.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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"]}
|
|
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;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CACxB,IAAgE,EAChE,SAA8B;IAE9B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,CAAC;QAClC,IAAI,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC9B,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;YACnB,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAC3B,CAAC;QACD,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,OAAO,CAAC,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;SACzB,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;SACpD,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;QACxB,IAAI;QACJ,MAAM,EAAE,MAAM,CAAC,IAAI;QACnB,SAAS,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM;KACpE,CAAC,CAAC,CAAC;AACR,CAAC","sourcesContent":["import type { RetrieveStats, RuleLedgerEntry } 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\n/**\n * The one derivation of the per-rule ledger, shared by every store — the same\n * discipline as {@link retrieveStats}: a store supplies the facts it witnessed (which\n * hash was put under which rule, which hashes were ever hit) and never derives the\n * counts itself, so two stores cannot disagree about what \"retrieved\" means per rule.\n *\n * `puts` may repeat a hash under a rule (a directory store journals every put); the\n * ledger counts distinct hashes. A hash put under two rules counts under both — each\n * rule did make that cut. Rows come back sorted by rule id, so the rendering is\n * stable across processes and platforms.\n */\nexport function ruleLedger(\n puts: Iterable<{ readonly hash: string; readonly rule: string }>,\n retrieved: ReadonlySet<string>,\n): readonly RuleLedgerEntry[] {\n const byRule = new Map<string, Set<string>>();\n for (const { hash, rule } of puts) {\n let hashes = byRule.get(rule);\n if (hashes === undefined) {\n hashes = new Set();\n byRule.set(rule, hashes);\n }\n hashes.add(hash);\n }\n return [...byRule.entries()]\n .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([rule, hashes]) => ({\n rule,\n stored: hashes.size,\n retrieved: [...hashes].filter((hash) => retrieved.has(hash)).length,\n }));\n}\n"]}
|
package/dist/store-dir.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RawRetrieveCounters } from './stats.ts';
|
|
2
|
-
import type { ElisionStore, RetrieveStats } from './types.ts';
|
|
2
|
+
import type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } from './types.ts';
|
|
3
3
|
/**
|
|
4
4
|
* The format marker every store directory carries, and the one version this code
|
|
5
5
|
* understands. A future layout is a new version, refused loudly by old code — never a
|
|
@@ -28,6 +28,7 @@ export interface DirectoryElisionStoreOptions {
|
|
|
28
28
|
* blobs/<hash> one file per elision: the exact UTF-8 bytes, named by their content hash
|
|
29
29
|
* tmp/ staging for atomic writes; never read, safe to sweep
|
|
30
30
|
* retrievals.log append-only journal: `hit "<hash>"` | `miss "<hash>"` | `corrupt "<hash>"`
|
|
31
|
+
* | `put "<hash>" "<rule>"` (the ledger: which rule cut what)
|
|
31
32
|
* ```
|
|
32
33
|
*
|
|
33
34
|
* **Nothing lives in memory.** Every read — `stats()` included — comes off the disk, so
|
|
@@ -103,7 +104,7 @@ export interface DirectoryElisionStoreOptions {
|
|
|
103
104
|
export declare class DirectoryElisionStore implements ElisionStore {
|
|
104
105
|
#private;
|
|
105
106
|
constructor(root: string, options?: DirectoryElisionStoreOptions);
|
|
106
|
-
put(content: string): string;
|
|
107
|
+
put(content: string, reason?: ElisionReason): string;
|
|
107
108
|
peek(hash: string): string | undefined;
|
|
108
109
|
retrieve(hash: string): string;
|
|
109
110
|
/**
|
|
@@ -133,5 +134,11 @@ export declare class DirectoryElisionStore implements ElisionStore {
|
|
|
133
134
|
*/
|
|
134
135
|
rawCounters(): RawRetrieveCounters;
|
|
135
136
|
stats(): RetrieveStats;
|
|
137
|
+
/**
|
|
138
|
+
* The per-rule ledger: a fold over the journal's `put` lines against its `hit`
|
|
139
|
+
* lines, derived by the shared `ruleLedger()`. Uncounted, and read off the disk
|
|
140
|
+
* like everything else here, so two processes agree.
|
|
141
|
+
*/
|
|
142
|
+
ledger(): readonly RuleLedgerEntry[];
|
|
136
143
|
}
|
|
137
144
|
//# sourceMappingURL=store-dir.d.ts.map
|
package/dist/store-dir.d.ts.map
CHANGED
|
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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"]}
|
|
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,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE9F;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,wBAAwB,CAAC;AAC5D,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAqBzC,4FAA4F;AAC5F,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CAC7C;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqFG;AACH,qBAAa,qBAAsB,YAAW,YAAY;;IAMxD,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,4BAAiC,EAiBnE;IAED,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAMnD;IAyCD,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;IAED;;;;OAIG;IACH,MAAM,IAAI,SAAS,eAAe,EAAE,CAanC;CAiJF","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, ruleLedger } from './stats.ts';\nimport type { RawRetrieveCounters } from './stats.ts';\nimport type { ElisionReason, ElisionStore, RetrieveStats, RuleLedgerEntry } 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 counter line: a kind, a space, and the hash as a JSON string literal. */\nconst LOG_LINE = /^(hit|miss|corrupt) (\"(?:[^\"\\\\]|\\\\.)*\")$/;\n\n/**\n * One ledger line: `put`, the hash, and the rule id — both JSON string literals. A\n * separate pattern from {@link LOG_LINE} on purpose: the counter fold matches only\n * counter lines and skips these, exactly as a reader that predates the ledger skips a\n * line it does not know, so a directory written by this version reads as the same\n * counters under the previous one (`test/ledger.test.ts` pins that).\n */\nconst PUT_LINE = /^put (\"(?:[^\"\\\\]|\\\\.)*\") (\"(?:[^\"\\\\]|\\\\.)*\")$/;\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 * | `put \"<hash>\" \"<rule>\"` (the ledger: which rule cut what)\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, reason?: ElisionReason): string {\n const hash = this.#putBlob(content);\n // The ledger line, after the bytes are safe: attribution is bookkeeping, and a\n // failure to write it is surfaced as a warning rather than a failed put.\n if (reason !== undefined) this.#appendLogCounting('put', hash, reason.rule);\n return hash;\n }\n\n /** The publish itself: verify or write the blob, return its hash. */\n #putBlob(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 /**\n * The per-rule ledger: a fold over the journal's `put` lines against its `hit`\n * lines, derived by the shared `ruleLedger()`. Uncounted, and read off the disk\n * like everything else here, so two processes agree.\n */\n ledger(): readonly RuleLedgerEntry[] {\n const puts: { hash: string; rule: string }[] = [];\n const hits = new Set<string>();\n for (const line of this.#readLog().split('\\n')) {\n const put = PUT_LINE.exec(line);\n if (put !== null) {\n puts.push({ hash: JSON.parse(put[1]!) as string, rule: JSON.parse(put[2]!) as string });\n continue;\n }\n const counter = LOG_LINE.exec(line);\n if (counter !== null && counter[1] === 'hit') hits.add(JSON.parse(counter[2]!) as string);\n }\n return ruleLedger(puts, hits);\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' | 'put', hash: string, rule?: string): void {\n try {\n this.#appendLog(kind, hash, rule);\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' | 'put', hash: string, rule?: string): void {\n const fd = openSync(this.#logPath, 'a');\n try {\n const fields =\n rule === undefined\n ? [kind, JSON.stringify(hash)]\n : [kind, JSON.stringify(hash), JSON.stringify(rule)];\n const record = Buffer.from(`\\n${fields.join(' ')}\\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"]}
|