diff --git a/CLAUDE.md b/CLAUDE.md index 85a12ca..8db4f25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ The consequence that matters most: **nothing here executes from the repo.** Edit **Run the suite twice when touching state handling** — CI does (`.github/workflows/ci.yml`). The gate's barrier is scoped by a monotonic turn counter rather than wall-clock time. -Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. +Node 22.6+ runs `.mts` files directly with `--experimental-strip-types --disable-warning=ExperimentalWarning`. `.mts` keeps the module format independent of the target project's `package.json`. Development dependencies are only for typechecking; hooks import Node built-ins and `hooks/shared.mts`. MCS installs that library as `hooks/memory/shared.mts` beside the entry points. Every registered hook is an `.mjs` launcher (`hooks/kb-gate.mjs`, `hooks/sync-memories.mjs`) that calls `module.enableCompileCache()` where it exists (Node 22.8+, through a namespace import so 22.6 still links) and then imports its `.mts`, which is installed as a plain file component. Type stripping costs about 30 ms per start whatever the file's size, so a registered `.mts`, however small, pays it every time; from Node 24 the cache keeps the stripped module until its content changes, while on Node 22 it saves only the compile step. A contract test asserts every node registration is `.mjs`. `hooks/memory-loop-activator.sh` stays in shell because it only prints a static reminder on every prompt; its output is covered by the same contract fixtures. Two harness details are load-bearing rather than incidental: diff --git a/README.md b/README.md index adb6395..ef615c2 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,10 @@ memory/ ├── techpack.yaml # Manifest — defines all components ├── config/settings.json # Disables built-in auto-memory ├── hooks/ +│ ├── sync-memories.mjs # Launcher: enables Node's compile cache, then loads the .mts │ ├── sync-memories.mts # Memory indexing/reindexing │ ├── memory-loop-activator.sh # Knowledge extraction reminder +│ ├── kb-gate.mjs # Launcher: enables Node's compile cache, then loads the .mts │ ├── kb-gate.mts # Keeps KB lookups ahead of delegated discovery │ └── shared.mts # Shared filesystem and project-path helpers ├── scripts/ # Maintainer checks diff --git a/biome.json b/biome.json index 0e58158..721ab91 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,7 @@ "files": { "includes": [ "hooks/**/*.mts", + "hooks/**/*.mjs", "scripts/**/*.mts", "tests/**/*.mts", "tests/fixtures/*.json", diff --git a/hooks/kb-gate.mjs b/hooks/kb-gate.mjs new file mode 100644 index 0000000..912c9c1 --- /dev/null +++ b/hooks/kb-gate.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning +import * as nodeModule from "node:module"; + +// Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so +// this launcher stays .mjs. From Node 24 the cache also skips the stripping; on Node 22 it saves only the compile. +// enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. +nodeModule.enableCompileCache?.(); +await import("./kb-gate.mts"); diff --git a/hooks/sync-memories.mjs b/hooks/sync-memories.mjs new file mode 100644 index 0000000..a106400 --- /dev/null +++ b/hooks/sync-memories.mjs @@ -0,0 +1,8 @@ +#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning +import * as nodeModule from "node:module"; + +// Stripping types costs about 30 ms on every start, and only plain JS runs before the cache is on, so +// this launcher stays .mjs. From Node 24 the cache also skips the stripping; on Node 22 it saves only the compile. +// enableCompileCache arrived in Node 22.8; a named import would fail to link on 22.6 and break fail-open. +nodeModule.enableCompileCache?.(); +await import("./sync-memories.mts"); diff --git a/techpack.yaml b/techpack.yaml index 43335e6..2310839 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -218,6 +218,22 @@ components: source: hooks/shared.mts destination: shared.mts + # Registered hooks run a small .mjs launcher that turns on Node's compile cache before + # importing these, so the TypeScript is stripped once instead of on every start. + - id: hook-sync-memories-module + displayName: Sync memories module + description: Indexing logic loaded by the sync memories launcher + hook: + source: hooks/sync-memories.mts + destination: sync-memories.mts + + - id: hook-kb-gate-module + displayName: KB gate module + description: KB gate logic loaded by the KB gate launchers + hook: + source: hooks/kb-gate.mts + destination: kb-gate.mts + - id: hook-continuous-learning displayName: Memory loop activator description: Reminds to evaluate learnings on each prompt @@ -235,8 +251,8 @@ components: hookTimeout: 120 hookStatusMessage: "Indexing memories..." hook: - source: hooks/sync-memories.mts - destination: sync-memories.mts + source: hooks/sync-memories.mjs + destination: sync-memories.mjs - id: hook-reindex-memories displayName: Reindex memories hook @@ -247,8 +263,8 @@ components: hookTimeout: 120 hookStatusMessage: "Reindexing memories..." hook: - source: hooks/sync-memories.mts - destination: sync-memories.mts + source: hooks/sync-memories.mjs + destination: sync-memories.mjs # ── KB-first delegation ───────────────────────────────────────────────── # Several registrations of one dispatcher script, which branches on @@ -267,8 +283,8 @@ components: hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: UserPromptSubmit hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs # Checked because losing this one corrupts the gate rather than disabling it: # with no turn stamp the counter reads 0 forever, so every search ever made in # the session counts as fresh and the barrier silently widens from per-turn to @@ -286,8 +302,8 @@ components: hookEvent: PostToolUse hookMatcher: "mcp__memory-loop__query|Skill" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs # Same reasoning: without this, no search or skill is ever recorded, so the # gate is pure friction until the budget releases it. doctorChecks: @@ -305,8 +321,8 @@ components: # builds. Matching only `Task` silently matches nothing. hookMatcher: "Agent|Task|Write|Edit|NotebookEdit|Bash" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs doctorChecks: - type: hookEventExists name: "PreToolUse hook registered" @@ -320,8 +336,8 @@ components: hookEvent: SessionStart hookMatcher: "compact" hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs - id: hook-kb-gate-retrieval displayName: KB gate — record memory retrievals @@ -332,8 +348,8 @@ components: # Every Read starts this hook, and it never needs to block, so it stays off the turn's critical path. hookAsync: true hook: - source: hooks/kb-gate.mts - destination: kb-usage.mts + source: hooks/kb-gate.mjs + destination: kb-usage.mjs - id: hook-kb-subagent-brief displayName: Sub-agent KB brief @@ -341,8 +357,8 @@ components: hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning hookEvent: SubagentStart hook: - source: hooks/kb-gate.mts - destination: kb-gate.mts + source: hooks/kb-gate.mjs + destination: kb-gate.mjs doctorChecks: - type: hookEventExists name: "SubagentStart hook registered" diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index d40c09f..d2ebc30 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -40,14 +40,24 @@ for (const type of ["commonjs", "module"]) { assert.match(manifest, /hook:\n {6}source: hooks\/shared.mts\n {6}destination: shared.mts/); const installed = `${f.project}/.claude/hooks/memory`; mkdirSync(installed, { recursive: true }); - cpSync(`${root}/hooks/shared.mts`, `${installed}/shared.mts`); + for (const module of ["shared", "kb-gate", "sync-memories"]) { + assert.match(manifest, new RegExp(`hook:\\n {6}source: hooks/${module}.mts\\n {6}destination: ${module}.mts`)); + writeFileSync( + `${installed}/${module}.mts`, + readFileSync(`${root}/hooks/${module}.mts`, "utf8").replace("__KB_GATE_MODE__", "enforce"), + ); + } for (const component of hooks) { const source = field(component, "source"); const destination = field(component, "destination"); const event = field(component, "hookEvent"); if (destination === "memory-loop-activator.sh") assert.doesNotMatch(component, /hookInterpreter:/); - else assert.match(component, /hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning/); - writeFileSync(`${installed}/${destination}`, readFileSync(`${root}/${source}`, "utf8").replace("__KB_GATE_MODE__", "enforce")); + else { + assert.match(component, /hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning/); + // A registered .mts would strip its types on every start, before any launcher could enable the cache. + assert.match(destination, /\.mjs$/); + } + cpSync(`${root}/${source}`, `${installed}/${destination}`); const output = f.run( `${installed}/${destination}`, { @@ -62,7 +72,7 @@ for (const type of ["commonjs", "module"]) { if (event === "SubagentStart") assert.equal(JSON.parse(output).hookSpecificOutput.hookEventName, event); } assert.match(manifest, /hookMatcher: "Agent\|Task\|Write\|Edit\|NotebookEdit\|Bash"/); - assert.match(manifest, /hookEvent: SessionStart\n {4}hookMatcher: "compact"\n {4}hook:\n {6}source: hooks\/kb-gate.mts/); + assert.match(manifest, /hookEvent: SessionStart\n {4}hookMatcher: "compact"\n {4}hook:\n {6}source: hooks\/kb-gate.mjs/); assert.match(manifest, /hookMatcher: "mcp__memory-loop__query\|Skill"/); assert.match(manifest, /hookMatcher: "mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Read"/); assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 3);