memhook 0.4.1 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to memhook are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0](https://github.com/utilia-ai-wox/memhook/compare/v0.4.1...v0.5.0) (2026-06-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * **catalog:** add built-in host presets ([#41](https://github.com/utilia-ai-wox/memhook/issues/41)) ([596aa11](https://github.com/utilia-ai-wox/memhook/commit/596aa112e691bd94348e0883f12f09950bbe1f99))
14
+ * **catalog:** add custom memory sources ([#40](https://github.com/utilia-ai-wox/memhook/issues/40)) ([fde6507](https://github.com/utilia-ai-wox/memhook/commit/fde6507741f89286c20c31d143cc78eb0f5aff0a))
15
+ * **catalog:** add preset discovery command (presets list/detect) ([#42](https://github.com/utilia-ai-wox/memhook/issues/42)) ([9ce7c1c](https://github.com/utilia-ai-wox/memhook/commit/9ce7c1cb7903c5a7cce204dfdec5809bd1f98b09))
16
+ * **catalog:** add presets:[auto] zero-config preset routing ([#44](https://github.com/utilia-ai-wox/memhook/issues/44)) ([9b6f50a](https://github.com/utilia-ai-wox/memhook/commit/9b6f50ad6eeb1576b4e4da3c0542aeac6589dbd1))
17
+ * **catalog:** omit host-autoloaded rule zones by default ([#39](https://github.com/utilia-ai-wox/memhook/issues/39)) ([0b6dee0](https://github.com/utilia-ai-wox/memhook/commit/0b6dee0a274e497c20070cdea22ecee87e6187ba))
18
+ * **router:** add presets nudge for unrouted host memory ([#43](https://github.com/utilia-ai-wox/memhook/issues/43)) ([f76d159](https://github.com/utilia-ai-wox/memhook/commit/f76d159411e0b65d4c7fcfcfb40bad064700df6a))
19
+ * **router:** introduce a harness-adapter seam with Claude Code as adapter [#1](https://github.com/utilia-ai-wox/memhook/issues/1) ([#38](https://github.com/utilia-ai-wox/memhook/issues/38)) ([cebe646](https://github.com/utilia-ai-wox/memhook/commit/cebe646868ca2f502ffeabe9abf9f9ddc97753a3))
20
+
21
+
22
+ ### Documentation
23
+
24
+ * clarify the README value proposition ([#36](https://github.com/utilia-ai-wox/memhook/issues/36)) ([f663237](https://github.com/utilia-ai-wox/memhook/commit/f663237de7cd3727db7715faf36309fc83a77fcd))
25
+
8
26
  ## [0.4.1](https://github.com/utilia-ai-wox/memhook/compare/v0.4.0...v0.4.1) (2026-06-02)
9
27
 
10
28
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # memhook
4
4
 
5
- **Stop loading every memory file on every prompt. memhook routes only the relevant ones.**
5
+ **Stop telling Claude to check its memory. memhook auto-injects the notes relevant to each prompt — so it already knows what you told it.**
6
6
 
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/memhook"><img src="https://img.shields.io/npm/v/memhook?color=cb3837&logo=npm" alt="npm version"></a>
@@ -14,17 +14,19 @@
14
14
  </p>
15
15
 
16
16
  A semantic memory router for [Claude Code](https://claude.com/claude-code) — a
17
- `UserPromptSubmit` hook that picks the relevant `feedback_*.md` & `rule_*.md`
18
- files for each prompt and injects them as `additionalContext`.
17
+ `UserPromptSubmit` hook that picks the `feedback_*.md` & `rule_*.md` notes relevant
18
+ to _this_ prompt and injects them as `additionalContext`. Your memory gets consulted
19
+ automatically — you stop saying _"go read your memory."_
19
20
 
20
21
  </div>
21
22
 
22
23
  ## ✨ Features
23
24
 
24
- - 🎯 **Relevant-only injection** — a cheap model picks the 0–5 memory files that matter for _this_ prompt.
25
- - 💸 **Token-frugal** — skips the 10–14k-token catalog dump; injects ~2k tokens of signal.
25
+ - 🎯 **Right note, right moment** — auto-selects the 0–5 memory files relevant to _this_ prompt and injects them. No more "go read your memory."
26
+ - 🧠 **Gets better as your memory grows** — relevance is picked per prompt, so a large memory helps instead of drowning the model.
26
27
  - 🛡️ **Fail-soft** — never blocks Claude Code; every error path falls back to empty context.
27
28
  - 🔌 **Multi-provider** — Anthropic (default), OpenAI, or local Ollama. Your key, your endpoint.
29
+ - 💸 **Light on context** — injects ~2k tokens of signal instead of a 10–14k-token catalog dump.
28
30
  - 🤫 **Zero telemetry** — the only outbound call is the LLM endpoint _you_ chose.
29
31
  - 🪶 **One dependency** — `yaml`, with zero sub-deps.
30
32
  - ⚡ **Cached & pre-filtered** — an LRU cache + a trivial-prompt skip keep latency near zero.
@@ -35,17 +37,19 @@ files for each prompt and injects them as `additionalContext`.
35
37
 
36
38
  Claude Code's `~/.claude/` directory accumulates a growing set of
37
39
  `feedback_*.md` (behavioural corrections) and `rule_*.md` (project doctrine)
38
- files. Loading all of them on every prompt is wasteful most of it is
39
- irrelevant to the question at hand.
40
+ files. The problem isn't their size it's that Claude doesn't know what's in
41
+ there: it misses notes that apply, so you keep telling it _"you wrote that
42
+ down, go read it."_
40
43
 
41
- memhook uses a cheap router model (**Haiku 4.5** by default) to match each
42
- prompt against a one-line catalog of all your memory files, and injects only
43
- the most relevant ones. The rest sit on disk, invisible until they matter.
44
+ memhook removes that chore. A cheap router model (**Haiku 4.5** by default)
45
+ matches each prompt against a one-line catalog of all your memory files and
46
+ injects just the relevant ones so the right note is already in context,
47
+ automatically. The rest sit on disk, invisible until they matter.
44
48
 
45
- | Approach | Tokens / prompt | Relevance |
46
- | --------------------- | --------------- | ----------------- |
47
- | Load all memory files | 10–14k | mostly irrelevant |
48
- | **memhook** | ~2k | only what matches |
49
+ | Approach | What Claude sees | Tokens / prompt |
50
+ | --------------------- | ------------------------------- | --------------- |
51
+ | Load all memory files | mostly irrelevant noise | 10–14k |
52
+ | **memhook** | only what matches _this_ prompt | ~2k |
49
53
 
50
54
  ## 🚀 Quick start
51
55
 
@@ -282,7 +286,7 @@ non-negotiable; the [`failsoft-auditor`](.claude/agents/failsoft-auditor.md)
282
286
  agent guards it on every PR.
283
287
 
284
288
  > [!TIP]
285
- > ⭐ If memhook saves you tokens, **star the repo** — it helps other Claude Code users find it.
289
+ > ⭐ If memhook keeps Claude on-context without the "go read your memory" nudges, **star the repo** — it helps other Claude Code users find it.
286
290
 
287
291
  ## License
288
292
 
@@ -17,16 +17,21 @@
17
17
  * "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "memhook run" }] }]
18
18
  * "SessionStart": [{ "hooks": [{ "type": "command", "command": "memhook build-catalog" }] }]
19
19
  */
20
+ import { homedir } from "node:os";
21
+ import { readdirSync } from "node:fs";
20
22
  import { route } from "../src/router.js";
21
23
  import { buildCatalog } from "../src/catalog.js";
22
24
  import { loadConfig } from "../src/config.js";
25
+ import { resolveSources } from "../src/sources.js";
23
26
  import { runInit, runUninstall } from "../src/init.js";
24
27
  import { runTail } from "../src/tail.js";
25
28
  import { runSkills } from "../src/skillsCmd.js";
29
+ import { runPresets } from "../src/presetsCmd.js";
26
30
  import { isCompanionSkill } from "../src/skills.js";
27
31
  import { MEMHOOK_VERSION as VERSION } from "../src/version.js";
28
32
  const PROVIDERS = ["anthropic", "openai", "ollama"];
29
33
  const SKILLS_SUBCOMMANDS = ["install", "uninstall", "list"];
34
+ const PRESETS_SUBCOMMANDS = ["list", "detect"];
30
35
  async function main() {
31
36
  const cmd = process.argv[2] ?? "help";
32
37
  const args = process.argv.slice(3);
@@ -49,6 +54,9 @@ async function main() {
49
54
  case "skills":
50
55
  process.exitCode = await cmdSkills(args);
51
56
  break;
57
+ case "presets":
58
+ process.exitCode = cmdPresets(args);
59
+ break;
52
60
  case "version":
53
61
  case "--version":
54
62
  case "-v":
@@ -88,9 +96,12 @@ async function cmdRun() {
88
96
  }
89
97
  function cmdBuildCatalog() {
90
98
  const config = loadConfig();
99
+ const cwd = process.cwd();
91
100
  const result = buildCatalog({
92
- cwd: process.cwd(),
101
+ cwd,
93
102
  outputPath: config.catalog.path,
103
+ resurfaceHostLoaded: config.resurfaceHostLoaded,
104
+ customSources: resolveSources(config.customSources, config.presets, cwd, homedir(), readdirSync),
94
105
  });
95
106
  process.stderr.write(`[memhook build-catalog] ${config.catalog.path} — ${result.lines}L, ${result.bytes}B\n`);
96
107
  }
@@ -144,6 +155,15 @@ async function cmdSkills(args) {
144
155
  force: flags["force"] === true,
145
156
  });
146
157
  }
158
+ function cmdPresets(args) {
159
+ const { positionals } = parseArgs(args, BOOL_PRESETS);
160
+ const sub = positionals[0] ?? "detect";
161
+ if (!PRESETS_SUBCOMMANDS.includes(sub)) {
162
+ process.stderr.write(`memhook presets: unknown subcommand "${sub}" (list | detect)\n`);
163
+ return 1;
164
+ }
165
+ return runPresets({ subcommand: sub });
166
+ }
147
167
  async function cmdUninstall(args) {
148
168
  const { flags } = parseArgs(args, BOOL_UNINSTALL);
149
169
  return runUninstall({
@@ -176,6 +196,7 @@ const BOOL_INIT = new Set(["yes", "dry-run", "no-catalog", "skills", "no-skills"
176
196
  const BOOL_UNINSTALL = new Set(["yes", "dry-run", "purge"]);
177
197
  const BOOL_TAIL = new Set(["no-follow"]);
178
198
  const BOOL_SKILLS = new Set(["yes", "dry-run", "force"]);
199
+ const BOOL_PRESETS = new Set([]);
179
200
  const SHORT = { "-y": "--yes", "-n": "--lines" };
180
201
  function strFlag(v) {
181
202
  return typeof v === "string" ? v : undefined;
@@ -240,6 +261,7 @@ COMMANDS
240
261
  uninstall Remove memhook's hooks from ~/.claude/settings.json
241
262
  tail Pretty live view of the routing log (status, latency, memories)
242
263
  skills Install/uninstall/list companion skills (/wrap /curate /relay)
264
+ presets List/detect built-in per-host source presets (experimental)
243
265
  version Print version
244
266
  help Show this message
245
267
 
@@ -275,6 +297,11 @@ skills SUBCOMMANDS
275
297
  --dry-run print the plan, write nothing
276
298
  -y, --yes non-interactive (accept defaults)
277
299
 
300
+ presets SUBCOMMANDS
301
+ detect scan this project + home for host memory, print the
302
+ 'presets: […]' snippet to enable what's found (default)
303
+ list show every built-in preset (cline | continue | copilot | windsurf)
304
+
278
305
  ENV VARS
279
306
  MEMHOOK_ENABLED toggle (default: true)
280
307
  MEMHOOK_PROVIDER anthropic | openai | ollama (default: anthropic)
@@ -288,10 +315,18 @@ ENV VARS
288
315
  MEMHOOK_TIMEOUT_MS request timeout (default: 8000; ollama: 30000)
289
316
  MEMHOOK_DISABLE_CACHE=true skip local LRU cache
290
317
  MEMHOOK_DISABLE_PREFILTER=true skip trivial-prompt skip
318
+ MEMHOOK_RESURFACE_HOST_LOADED route rules the host already auto-loads at
319
+ launch (default: false). Enable for long
320
+ sessions / no-drift projects (re-surfaces
321
+ launch-loaded rules near the prompt).
291
322
  MEMHOOK_CURATE_NUDGE /curate-nudge toggle (default: true)
292
323
  MEMHOOK_CURATE_NUDGE_TOKENS catalog-token threshold to nudge (default: 15000)
293
324
  MEMHOOK_CURATE_NUDGE_FILES memory-file-count threshold to nudge (default: 250)
294
325
  MEMHOOK_CURATE_NUDGE_COOLDOWN_DAYS min days between nudges (default: 7)
326
+ MEMHOOK_PRESETS_NUDGE presets-nudge toggle (default: true) — suggests
327
+ 'memhook presets detect' when a known host's
328
+ memory exists in the project but isn't routed
329
+ MEMHOOK_PRESETS_NUDGE_COOLDOWN_DAYS min days between presets nudges (default: 7)
295
330
  NO_COLOR / MEMHOOK_NO_COLOR disable colour in init/tail output
296
331
  MEMHOOK_DEBUG=true print errors to stderr (default: silent fail-soft)
297
332
 
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Claude Code adapter — memhook's first and reference harness adapter.
3
+ *
4
+ * Input (stdin): the `UserPromptSubmit` hook JSON; only `prompt` + `cwd` are
5
+ * used (docs/SPECIFICATION.md §10.1).
6
+ * Output (stdout): `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
7
+ * additionalContext }, systemMessage? }` (§10.2). `systemMessage` is emitted
8
+ * only when the result carries one (the `/curate` nudge); absent otherwise, so
9
+ * the serialised shape is byte-identical to memhook's pre-adapter output.
10
+ *
11
+ * Contract source: https://code.claude.com/docs/en/hooks (mirrored in §10).
12
+ */
13
+ import type { HookOutput } from "../router.js";
14
+ import type { HarnessAdapter } from "./types.js";
15
+ export declare const claudeCodeAdapter: HarnessAdapter<HookOutput>;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Claude Code adapter — memhook's first and reference harness adapter.
3
+ *
4
+ * Input (stdin): the `UserPromptSubmit` hook JSON; only `prompt` + `cwd` are
5
+ * used (docs/SPECIFICATION.md §10.1).
6
+ * Output (stdout): `{ hookSpecificOutput: { hookEventName: "UserPromptSubmit",
7
+ * additionalContext }, systemMessage? }` (§10.2). `systemMessage` is emitted
8
+ * only when the result carries one (the `/curate` nudge); absent otherwise, so
9
+ * the serialised shape is byte-identical to memhook's pre-adapter output.
10
+ *
11
+ * Contract source: https://code.claude.com/docs/en/hooks (mirrored in §10).
12
+ */
13
+ export const claudeCodeAdapter = {
14
+ id: "claude-code",
15
+ parseInput(stdinJson) {
16
+ let parsed;
17
+ try {
18
+ parsed = JSON.parse(stdinJson);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ // Guard against non-object payloads (`"null"`, a bare number/string): they
24
+ // are not usable hook inputs. This keeps `route()` from throwing on them —
25
+ // a strict tightening of fail-soft, with the same empty output as before.
26
+ if (typeof parsed !== "object" || parsed === null)
27
+ return null;
28
+ const obj = parsed;
29
+ if (typeof obj.prompt !== "string")
30
+ return null;
31
+ const input = { prompt: obj.prompt };
32
+ // exactOptionalPropertyTypes: only set cwd when the host actually sent one.
33
+ if (typeof obj.cwd === "string")
34
+ input.cwd = obj.cwd;
35
+ return input;
36
+ },
37
+ formatOutput(result) {
38
+ const output = {
39
+ hookSpecificOutput: {
40
+ hookEventName: "UserPromptSubmit",
41
+ additionalContext: result.additionalContext,
42
+ },
43
+ };
44
+ if (result.systemMessage !== undefined) {
45
+ output.systemMessage = result.systemMessage;
46
+ }
47
+ return output;
48
+ },
49
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Harness adapter contract.
3
+ *
4
+ * memhook's selection pipeline is harness-agnostic: it consumes a normalised
5
+ * `{prompt, cwd}` and produces a `RouteResult`. The ONLY harness-specific
6
+ * surface is (a) parsing the host's hook stdin into that normalised input and
7
+ * (b) serialising the result into the host's stdout envelope. A `HarnessAdapter`
8
+ * captures exactly those two ends, so a new host (Codex, Gemini, …) is an
9
+ * adapter, not a fork of the router.
10
+ *
11
+ * See docs/SPECIFICATION.md §5 (architecture) and §10 (hook contract).
12
+ */
13
+ /** Normalised hook input. Every adapter maps its host's stdin to this shape. */
14
+ export interface HarnessInput {
15
+ /** The verbatim user prompt. */
16
+ prompt: string;
17
+ /** The project working directory, when the host provides one. */
18
+ cwd?: string;
19
+ }
20
+ /**
21
+ * Harness-agnostic outcome of one routing pass. Adapters serialise this into
22
+ * their host's stdout envelope.
23
+ */
24
+ export interface RouteResult {
25
+ /** Context to inject ahead of the user prompt. Empty string = inject nothing. */
26
+ additionalContext: string;
27
+ /**
28
+ * Optional one-line notice to the user (the `/curate` nudge). Absent on every
29
+ * normal turn, so a host whose envelope has no equivalent simply ignores it.
30
+ */
31
+ systemMessage?: string;
32
+ }
33
+ /**
34
+ * The host-specific surface of the hook. `parseInput` reads the host's stdin
35
+ * JSON into a `HarnessInput` (or `null` when it isn't a usable hook input);
36
+ * `formatOutput` serialises a `RouteResult` into the host's stdout shape.
37
+ */
38
+ export interface HarnessAdapter<TOutput = unknown> {
39
+ /** Stable identifier, e.g. `"claude-code"`. */
40
+ readonly id: string;
41
+ /** Parse the host's hook stdin JSON into `{prompt, cwd}`, or `null` if unusable. */
42
+ parseInput(stdinJson: string): HarnessInput | null;
43
+ /** Serialise a routing result into the host's stdout envelope. */
44
+ formatOutput(result: RouteResult): TOutput;
45
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Harness adapter contract.
3
+ *
4
+ * memhook's selection pipeline is harness-agnostic: it consumes a normalised
5
+ * `{prompt, cwd}` and produces a `RouteResult`. The ONLY harness-specific
6
+ * surface is (a) parsing the host's hook stdin into that normalised input and
7
+ * (b) serialising the result into the host's stdout envelope. A `HarnessAdapter`
8
+ * captures exactly those two ends, so a new host (Codex, Gemini, …) is an
9
+ * adapter, not a fork of the router.
10
+ *
11
+ * See docs/SPECIFICATION.md §5 (architecture) and §10 (hook contract).
12
+ */
13
+ export {};
@@ -1,17 +1,31 @@
1
1
  /**
2
2
  * Memhook catalog builder — TS port of build-memory-catalog.sh.
3
3
  *
4
- * Discovers feedbacks & projects in `~/.claude/projects/* /memory/`, global
5
- * rules in `~/.claude/rules/`, and project rules in `<cwd>/.claude/rules/`.
4
+ * Discovers feedbacks & projects in `~/.claude/projects/* /memory/`. Global
5
+ * rules (`~/.claude/rules/`) and project rules (`<cwd>/.claude/rules/`) are
6
+ * host-autoloaded and included ONLY when `resurfaceHostLoaded` is set (default
7
+ * off), so memhook does not re-catalogue what Claude Code already loads at
8
+ * launch.
6
9
  *
7
10
  * Phase 0.5 Q4: title-only for non-CWD zones (~50% catalog size reduction).
8
11
  * The CWD zone gets full `basename: description`; others list just basenames.
9
12
  */
13
+ import { type CustomSource } from "./sources.js";
10
14
  export interface CatalogBuildOptions {
11
15
  cwd: string;
12
16
  projectsRoot?: string;
13
17
  globalRulesDir?: string;
14
18
  outputPath: string;
19
+ /**
20
+ * Include the host-autoloaded rule zones (`~/.claude/rules`,
21
+ * `<cwd>/.claude/rules`) in the catalog. Default `false`: those files are
22
+ * loaded in full by Claude Code at launch, so cataloguing them would let the
23
+ * router re-inject what the host already has (double-injection). See
24
+ * `MemhookConfig.resurfaceHostLoaded`.
25
+ */
26
+ resurfaceHostLoaded?: boolean;
27
+ /** Extra `.md` source dirs to catalog alongside the built-in zones. */
28
+ customSources?: CustomSource[];
15
29
  }
16
30
  export declare function buildCatalog(opts: CatalogBuildOptions): {
17
31
  lines: number;
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Memhook catalog builder — TS port of build-memory-catalog.sh.
3
3
  *
4
- * Discovers feedbacks & projects in `~/.claude/projects/* /memory/`, global
5
- * rules in `~/.claude/rules/`, and project rules in `<cwd>/.claude/rules/`.
4
+ * Discovers feedbacks & projects in `~/.claude/projects/* /memory/`. Global
5
+ * rules (`~/.claude/rules/`) and project rules (`<cwd>/.claude/rules/`) are
6
+ * host-autoloaded and included ONLY when `resurfaceHostLoaded` is set (default
7
+ * off), so memhook does not re-catalogue what Claude Code already loads at
8
+ * launch.
6
9
  *
7
10
  * Phase 0.5 Q4: title-only for non-CWD zones (~50% catalog size reduction).
8
11
  * The CWD zone gets full `basename: description`; others list just basenames.
@@ -10,6 +13,7 @@
10
13
  import { existsSync, readFileSync, readdirSync, writeFileSync, statSync, renameSync, } from "node:fs";
11
14
  import { join, basename as pathBasename } from "node:path";
12
15
  import { homedir } from "node:os";
16
+ import { activeCustomSources, listMatchingMdFiles } from "./sources.js";
13
17
  export function buildCatalog(opts) {
14
18
  const home = homedir();
15
19
  const projectsRoot = opts.projectsRoot ?? join(home, ".claude", "projects");
@@ -18,8 +22,22 @@ export function buildCatalog(opts) {
18
22
  const sections = [];
19
23
  sections.push(emitMemorySection("feedback", "MEMORY FEEDBACKS", memoryDirs));
20
24
  sections.push(emitMemorySection("project", "MEMORY PROJECTS", memoryDirs));
21
- sections.push(emitRulesSection("GLOBAL RULES", globalRulesDir, true));
22
- sections.push(emitRulesSection(`PROJECT RULES (${pathBasename(opts.cwd)})`, join(opts.cwd, ".claude", "rules"), true));
25
+ // Global + project rules are host-autoloaded: Claude Code reads
26
+ // `~/.claude/rules/*.md` and `<cwd>/.claude/rules/*.md` in full at launch. By
27
+ // default memhook leaves them OUT of the catalog so the router never re-routes
28
+ // what the host already loaded (no double-injection — it routes only the
29
+ // not-autoloaded `feedback_*/project_*` memory). `resurfaceHostLoaded` adds
30
+ // them back for positional re-surfacing (long sessions / no-drift projects).
31
+ if (opts.resurfaceHostLoaded) {
32
+ sections.push(emitRulesSection("GLOBAL RULES", globalRulesDir, true));
33
+ sections.push(emitRulesSection(`PROJECT RULES (${pathBasename(opts.cwd)})`, join(opts.cwd, ".claude", "rules"), true));
34
+ }
35
+ // User-declared extra sources (cable onto existing project memory). Skipped
36
+ // when host-autoloaded unless resurfaceHostLoaded — same gate as the rules.
37
+ const custom = activeCustomSources(opts.customSources ?? [], opts.resurfaceHostLoaded ?? false);
38
+ if (custom.length > 0) {
39
+ sections.push(emitCustomSourcesSection(custom));
40
+ }
23
41
  const content = sections.join("\n");
24
42
  const tmp = `${opts.outputPath}.tmp.${process.pid}`;
25
43
  writeFileSync(tmp, content, "utf8");
@@ -106,6 +124,34 @@ function emitRulesSection(label, dir, isCwdZone) {
106
124
  lines.push("");
107
125
  return lines.join("\n");
108
126
  }
127
+ function emitCustomSourcesSection(sources) {
128
+ const lines = ["=== CUSTOM SOURCES ==="];
129
+ let total = 0;
130
+ for (const src of sources) {
131
+ let entries;
132
+ try {
133
+ entries = readdirSync(src.dir);
134
+ }
135
+ catch {
136
+ lines.push(`--- ${src.dir} (directory not found) ---`);
137
+ continue;
138
+ }
139
+ // Only `.md` files can be injected (router SAFE_BASENAME_RE), so the catalog
140
+ // lists only those — a glob like `*.txt` simply yields nothing. Shared with
141
+ // preset detection via listMatchingMdFiles so the two never disagree.
142
+ const files = listMatchingMdFiles(entries, src.glob);
143
+ if (files.length === 0)
144
+ continue;
145
+ lines.push(`--- ${src.dir} ---`);
146
+ for (const f of files) {
147
+ lines.push(`${f}: ${extractDescription(join(src.dir, f))}`);
148
+ }
149
+ total += files.length;
150
+ }
151
+ lines.push(`(${total} entries)`);
152
+ lines.push("");
153
+ return lines.join("\n");
154
+ }
109
155
  function listMemoryFiles(dir, prefix) {
110
156
  let entries = [];
111
157
  try {
@@ -11,6 +11,7 @@
11
11
  * try-boundaries on some paths). All YAML I/O is isolated in `loadYamlConfig`,
12
12
  * which swallows every error to null.
13
13
  */
14
+ import { type CustomSource } from "./sources.js";
14
15
  export type ProviderType = "anthropic" | "openai" | "ollama";
15
16
  export interface MemhookConfig {
16
17
  enabled: boolean;
@@ -51,6 +52,38 @@ export interface MemhookConfig {
51
52
  path: string;
52
53
  };
53
54
  searchDirs: string[];
55
+ /**
56
+ * Whether to route memory the HOST already auto-loads at launch. Claude Code
57
+ * loads `~/.claude/rules/*.md` and `<cwd>/.claude/rules/*.md` in full at
58
+ * startup, so routing them again is positional re-surfacing, not new recall.
59
+ *
60
+ * OFF by default (the clean public behaviour): the catalog omits those
61
+ * host-autoloaded rule zones, so memhook routes only memory the host does NOT
62
+ * load (the `feedback_` / `project_` zones) — no double-injection. Measured: on
63
+ * a real 4.8k-prompt corpus, ~20% of injecting prompts re-injected a rule the
64
+ * host had already loaded in full (docs/private POC, 2026-06-02).
65
+ *
66
+ * Turn ON (`MEMHOOK_RESURFACE_HOST_LOADED=true`) for **long sessions / long
67
+ * context** — launch-loaded rules drift far from the current prompt, so
68
+ * re-surfacing the relevant ones near it restores their salience — or a
69
+ * **complex project that tolerates no drift**, where the redundant re-injection
70
+ * is a deliberate guard-rail.
71
+ */
72
+ resurfaceHostLoaded: boolean;
73
+ /**
74
+ * Extra memory sources beyond the built-in `~/.claude` zones — directories of
75
+ * `.md` files (any naming, via a glob) that memhook catalogs + routes like its
76
+ * own zones. This is how memhook cables onto memory that already exists in a
77
+ * project. YAML-only (`customSources:`), default empty. See `src/sources.ts`.
78
+ */
79
+ customSources: CustomSource[];
80
+ /**
81
+ * Enabled built-in host presets (e.g. `continue`, `cline`) — named bundles of
82
+ * sources for a known tool's `.md` convention, expanded against cwd/home at
83
+ * catalog/router time. YAML-only (`presets:`), default empty. All presets are
84
+ * experimental (doc-verified, not live-tested). See `src/sources.ts`.
85
+ */
86
+ presets: string[];
54
87
  logging: {
55
88
  jsonlPath: string;
56
89
  };
@@ -65,6 +98,19 @@ export interface MemhookConfig {
65
98
  thresholdFiles: number;
66
99
  cooldownDays: number;
67
100
  };
101
+ /**
102
+ * Optional proactive nudge: when a known host's memory directory exists in the
103
+ * project but no matching `presets:` entry routes it yet, the router attaches a
104
+ * one-line `systemMessage` suggesting `memhook presets detect`. Local-only (a
105
+ * readdir per preset dir, gated behind the cooldown), best-effort (never
106
+ * affects fail-soft), and always opt-in — it only points at the explicit
107
+ * `presets:` config, never auto-routes. See `maybePresetsNudge` in
108
+ * `src/router.ts`.
109
+ */
110
+ presetsNudge: {
111
+ enabled: boolean;
112
+ cooldownDays: number;
113
+ };
68
114
  scriptVersion: string;
69
115
  }
70
116
  export declare function loadConfig(env?: NodeJS.ProcessEnv): MemhookConfig;
@@ -14,6 +14,7 @@
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
16
  import { loadYamlConfig } from "./configFile.js";
17
+ import { resolveCustomSources, resolvePresetNames } from "./sources.js";
17
18
  import { MEMHOOK_VERSION } from "./version.js";
18
19
  /** Per-provider defaults applied once the provider type is known. */
19
20
  const PROVIDER_DEFAULTS = {
@@ -163,6 +164,9 @@ export function loadConfig(env = process.env) {
163
164
  str("MEMHOOK_PROJECTS_ROOT", yaml?.searchDirs?.projectsRoot, join(home, ".claude", "projects")),
164
165
  str("MEMHOOK_GLOBAL_RULES_DIR", yaml?.searchDirs?.globalRulesDir, join(home, ".claude", "rules")),
165
166
  ],
167
+ resurfaceHostLoaded: bool("MEMHOOK_RESURFACE_HOST_LOADED", yaml?.resurfaceHostLoaded, false),
168
+ customSources: resolveCustomSources(yaml?.customSources, home),
169
+ presets: resolvePresetNames(yaml?.presets),
166
170
  logging: {
167
171
  jsonlPath: str("MEMHOOK_LOG_PATH", yaml?.logging?.jsonlPath, join(home, ".claude", "logs", "memhook.log")),
168
172
  },
@@ -172,6 +176,10 @@ export function loadConfig(env = process.env) {
172
176
  thresholdFiles: num("MEMHOOK_CURATE_NUDGE_FILES", yaml?.curateNudge?.thresholdFiles, 250),
173
177
  cooldownDays: num("MEMHOOK_CURATE_NUDGE_COOLDOWN_DAYS", yaml?.curateNudge?.cooldownDays, 7),
174
178
  },
179
+ presetsNudge: {
180
+ enabled: bool("MEMHOOK_PRESETS_NUDGE", yaml?.presetsNudge?.enabled, true),
181
+ cooldownDays: num("MEMHOOK_PRESETS_NUDGE_COOLDOWN_DAYS", yaml?.presetsNudge?.cooldownDays, 7),
182
+ },
175
183
  scriptVersion: MEMHOOK_VERSION,
176
184
  };
177
185
  }
@@ -45,6 +45,11 @@ export interface RawConfigFile {
45
45
  projectsRoot?: string;
46
46
  globalRulesDir?: string;
47
47
  };
48
+ resurfaceHostLoaded?: boolean;
49
+ /** Extra `.md` source dirs; validated + narrowed by `resolveCustomSources`. */
50
+ customSources?: unknown;
51
+ /** Built-in host preset names; validated by `resolvePresetNames`. */
52
+ presets?: unknown;
48
53
  logging?: {
49
54
  jsonlPath?: string;
50
55
  };
@@ -54,6 +59,10 @@ export interface RawConfigFile {
54
59
  thresholdFiles?: number;
55
60
  cooldownDays?: number;
56
61
  };
62
+ presetsNudge?: {
63
+ enabled?: boolean;
64
+ cooldownDays?: number;
65
+ };
57
66
  }
58
67
  export declare function resolveConfigPath(env: NodeJS.ProcessEnv): string;
59
68
  export declare function loadYamlConfig(env: NodeJS.ProcessEnv): RawConfigFile | null;
@@ -7,8 +7,11 @@
7
7
  */
8
8
  export { loadConfig, type MemhookConfig, type ProviderType } from "./config.js";
9
9
  export { loadYamlConfig, resolveConfigPath, type RawConfigFile } from "./configFile.js";
10
- export { route, type HookInput, type HookOutput } from "./router.js";
10
+ export { route, runHarness, type HookInput, type HookOutput } from "./router.js";
11
+ export { claudeCodeAdapter } from "./adapters/claudeCode.js";
12
+ export type { HarnessAdapter, HarnessInput, RouteResult } from "./adapters/types.js";
11
13
  export { buildCatalog, type CatalogBuildOptions } from "./catalog.js";
14
+ export { resolveCustomSources, activeCustomSources, resolveSources, expandPresets, resolveActivePresetNames, resolvePresetNames, isPresetName, globToRegExp, expandHome, HOST_PRESETS, PRESET_NAMES, PRESET_AUTO, type CustomSource, type SourceScope, type PresetDef, } from "./sources.js";
12
15
  export { LocalCache, type CacheKeyInput } from "./cache.js";
13
16
  export { PreFilter } from "./preFilter.js";
14
17
  export { MEMHOOK_VERSION } from "./version.js";
package/dist/src/index.js CHANGED
@@ -7,8 +7,10 @@
7
7
  */
8
8
  export { loadConfig } from "./config.js";
9
9
  export { loadYamlConfig, resolveConfigPath } from "./configFile.js";
10
- export { route } from "./router.js";
10
+ export { route, runHarness } from "./router.js";
11
+ export { claudeCodeAdapter } from "./adapters/claudeCode.js";
11
12
  export { buildCatalog } from "./catalog.js";
13
+ export { resolveCustomSources, activeCustomSources, resolveSources, expandPresets, resolveActivePresetNames, resolvePresetNames, isPresetName, globToRegExp, expandHome, HOST_PRESETS, PRESET_NAMES, PRESET_AUTO, } from "./sources.js";
12
14
  export { LocalCache } from "./cache.js";
13
15
  export { PreFilter } from "./preFilter.js";
14
16
  export { MEMHOOK_VERSION } from "./version.js";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `memhook presets list|detect` — discover the built-in per-host source presets
3
+ * so the user never has to know a preset's name or hand-write its paths.
4
+ *
5
+ * list Show every built-in preset (name, summary, the dirs/globs it points
6
+ * at). Static — no disk access.
7
+ * detect Scan this project (cwd) + the home dir for the preset directories
8
+ * that actually hold matching `.md` files, then print the YAML snippet
9
+ * (`presets: [...]`) that enables the ones found.
10
+ *
11
+ * This is the I/O shell around the pure detector in `src/sources.ts`
12
+ * (`detectPresets`), the same functional-core / imperative-shell split as
13
+ * init.ts ↔ install.ts and skillsCmd.ts ↔ skills.ts. `presets` is NOT on the
14
+ * hook path, so it may exit non-zero on user error and use the TTY
15
+ * (docs/SPECIFICATION.md §9). The detector never throws (a missing/denied dir is
16
+ * recorded as absent), so detect/list always exit 0.
17
+ */
18
+ export type PresetsSubcommand = "list" | "detect";
19
+ export interface RunPresetsOptions {
20
+ subcommand: PresetsSubcommand;
21
+ /** Test seams. */
22
+ cwd?: string | undefined;
23
+ home?: string | undefined;
24
+ readDir?: ((dir: string) => string[]) | undefined;
25
+ env?: NodeJS.ProcessEnv | undefined;
26
+ out?: ((s: string) => void) | undefined;
27
+ }
28
+ export declare function runPresets(opts: RunPresetsOptions): number;