Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"files": {
"includes": [
"hooks/**/*.mts",
"hooks/**/*.mjs",
"scripts/**/*.mts",
"tests/**/*.mts",
"tests/fixtures/*.json",
Expand Down
8 changes: 8 additions & 0 deletions hooks/kb-gate.mjs
Original file line number Diff line number Diff line change
@@ -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");
8 changes: 8 additions & 0 deletions hooks/sync-memories.mjs
Original file line number Diff line number Diff line change
@@ -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");
48 changes: 32 additions & 16 deletions techpack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -332,17 +348,17 @@ 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
description: Tells discovery sub-agents that the KB exists, so they search it instead of sweeping files
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"
Expand Down
18 changes: 14 additions & 4 deletions tests/contracts.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
{
Expand All @@ -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);
Expand Down
Loading