memhook 0.4.0 → 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.
Files changed (99) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +20 -20
  3. package/dist/bin/memhook.d.ts +0 -1
  4. package/dist/bin/memhook.js +36 -2
  5. package/dist/src/adapters/claudeCode.d.ts +15 -0
  6. package/dist/src/adapters/claudeCode.js +49 -0
  7. package/dist/src/adapters/types.d.ts +45 -0
  8. package/dist/src/adapters/types.js +13 -0
  9. package/dist/src/ansi.d.ts +0 -1
  10. package/dist/src/ansi.js +0 -1
  11. package/dist/src/backup.d.ts +0 -1
  12. package/dist/src/backup.js +0 -1
  13. package/dist/src/cache.d.ts +0 -1
  14. package/dist/src/cache.js +0 -1
  15. package/dist/src/catalog.d.ts +16 -3
  16. package/dist/src/catalog.js +50 -5
  17. package/dist/src/config.d.ts +46 -1
  18. package/dist/src/config.js +8 -1
  19. package/dist/src/configFile.d.ts +9 -1
  20. package/dist/src/configFile.js +0 -1
  21. package/dist/src/index.d.ts +4 -2
  22. package/dist/src/index.js +8 -2
  23. package/dist/src/init.d.ts +0 -1
  24. package/dist/src/init.js +0 -1
  25. package/dist/src/install.d.ts +0 -1
  26. package/dist/src/install.js +0 -1
  27. package/dist/src/preFilter.d.ts +0 -1
  28. package/dist/src/preFilter.js +0 -1
  29. package/dist/src/presetsCmd.d.ts +28 -0
  30. package/dist/src/presetsCmd.js +94 -0
  31. package/dist/src/providers/anthropic.d.ts +0 -1
  32. package/dist/src/providers/anthropic.js +0 -1
  33. package/dist/src/providers/factory.d.ts +0 -1
  34. package/dist/src/providers/factory.js +0 -1
  35. package/dist/src/providers/http.d.ts +0 -1
  36. package/dist/src/providers/http.js +0 -1
  37. package/dist/src/providers/ollama.d.ts +0 -1
  38. package/dist/src/providers/ollama.js +0 -1
  39. package/dist/src/providers/openai.d.ts +0 -1
  40. package/dist/src/providers/openai.js +0 -1
  41. package/dist/src/providers/types.d.ts +0 -1
  42. package/dist/src/providers/types.js +0 -1
  43. package/dist/src/router.d.ts +33 -1
  44. package/dist/src/router.js +141 -45
  45. package/dist/src/skills.d.ts +0 -1
  46. package/dist/src/skills.js +0 -1
  47. package/dist/src/skillsCmd.d.ts +0 -1
  48. package/dist/src/skillsCmd.js +0 -1
  49. package/dist/src/sources.d.ts +138 -0
  50. package/dist/src/sources.js +251 -0
  51. package/dist/src/tail.d.ts +0 -1
  52. package/dist/src/tail.js +7 -4
  53. package/dist/src/version.d.ts +1 -2
  54. package/dist/src/version.js +1 -2
  55. package/package.json +1 -1
  56. package/dist/bin/memhook.d.ts.map +0 -1
  57. package/dist/bin/memhook.js.map +0 -1
  58. package/dist/src/ansi.d.ts.map +0 -1
  59. package/dist/src/ansi.js.map +0 -1
  60. package/dist/src/backup.d.ts.map +0 -1
  61. package/dist/src/backup.js.map +0 -1
  62. package/dist/src/cache.d.ts.map +0 -1
  63. package/dist/src/cache.js.map +0 -1
  64. package/dist/src/catalog.d.ts.map +0 -1
  65. package/dist/src/catalog.js.map +0 -1
  66. package/dist/src/config.d.ts.map +0 -1
  67. package/dist/src/config.js.map +0 -1
  68. package/dist/src/configFile.d.ts.map +0 -1
  69. package/dist/src/configFile.js.map +0 -1
  70. package/dist/src/index.d.ts.map +0 -1
  71. package/dist/src/index.js.map +0 -1
  72. package/dist/src/init.d.ts.map +0 -1
  73. package/dist/src/init.js.map +0 -1
  74. package/dist/src/install.d.ts.map +0 -1
  75. package/dist/src/install.js.map +0 -1
  76. package/dist/src/preFilter.d.ts.map +0 -1
  77. package/dist/src/preFilter.js.map +0 -1
  78. package/dist/src/providers/anthropic.d.ts.map +0 -1
  79. package/dist/src/providers/anthropic.js.map +0 -1
  80. package/dist/src/providers/factory.d.ts.map +0 -1
  81. package/dist/src/providers/factory.js.map +0 -1
  82. package/dist/src/providers/http.d.ts.map +0 -1
  83. package/dist/src/providers/http.js.map +0 -1
  84. package/dist/src/providers/ollama.d.ts.map +0 -1
  85. package/dist/src/providers/ollama.js.map +0 -1
  86. package/dist/src/providers/openai.d.ts.map +0 -1
  87. package/dist/src/providers/openai.js.map +0 -1
  88. package/dist/src/providers/types.d.ts.map +0 -1
  89. package/dist/src/providers/types.js.map +0 -1
  90. package/dist/src/router.d.ts.map +0 -1
  91. package/dist/src/router.js.map +0 -1
  92. package/dist/src/skills.d.ts.map +0 -1
  93. package/dist/src/skills.js.map +0 -1
  94. package/dist/src/skillsCmd.d.ts.map +0 -1
  95. package/dist/src/skillsCmd.js.map +0 -1
  96. package/dist/src/tail.d.ts.map +0 -1
  97. package/dist/src/tail.js.map +0 -1
  98. package/dist/src/version.d.ts.map +0 -1
  99. package/dist/src/version.js.map +0 -1
@@ -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";
@@ -23,4 +26,3 @@ export { AnthropicProvider, type AnthropicProviderOptions } from "./providers/an
23
26
  export { OpenAIProvider } from "./providers/openai.js";
24
27
  export { OllamaProvider } from "./providers/ollama.js";
25
28
  export type { Provider, ProviderConfig, SelectionRequest, SelectionResponse, UsageBreakdown, } from "./providers/types.js";
26
- //# sourceMappingURL=index.d.ts.map
package/dist/src/index.js CHANGED
@@ -7,12 +7,19 @@
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";
15
17
  export { createProvider } from "./providers/factory.js";
18
+ // ── Internal building blocks (NOT part of the semver-stable surface) ─────────
19
+ // The install/init/skills/tail/ansi re-exports below back the CLI and tests.
20
+ // They are exposed for power users but may change between 0.x minor releases;
21
+ // the stable embedding API is the core above (route, loadConfig, buildCatalog,
22
+ // LocalCache, PreFilter, MEMHOOK_VERSION) plus the provider exports.
16
23
  export { addHooks, removeHooks, memhookSubcommand, MEMHOOK_HOOKS, } from "./install.js";
17
24
  export { runInit, runUninstall, buildConfigObject, backupPath, } from "./init.js";
18
25
  export { COMPANION_SKILLS, SKILL_FILES, isCompanionSkill, diffSkill, planInstall, planUninstall, } from "./skills.js";
@@ -22,4 +29,3 @@ export { makeAnsi, colorEnabled, visibleWidth } from "./ansi.js";
22
29
  export { AnthropicProvider } from "./providers/anthropic.js";
23
30
  export { OpenAIProvider } from "./providers/openai.js";
24
31
  export { OllamaProvider } from "./providers/ollama.js";
25
- //# sourceMappingURL=index.js.map
@@ -45,4 +45,3 @@ export declare function buildConfigObject(opts: {
45
45
  }): Record<string, unknown> | null;
46
46
  export declare function runInit(opts: InitOptions, env?: NodeJS.ProcessEnv): Promise<number>;
47
47
  export declare function runUninstall(opts: UninstallOptions, env?: NodeJS.ProcessEnv): Promise<number>;
48
- //# sourceMappingURL=init.d.ts.map
package/dist/src/init.js CHANGED
@@ -324,4 +324,3 @@ export async function runUninstall(opts, env = process.env) {
324
324
  io.out(`\n${ansi.green("Done.")} Restart Claude Code to drop the hooks.`);
325
325
  return 0;
326
326
  }
327
- //# sourceMappingURL=init.js.map
@@ -84,4 +84,3 @@ export declare function addHooks(input: unknown, bin?: string): AddResult;
84
84
  * group list becomes empty — are pruned so no dangling shells are left behind.
85
85
  */
86
86
  export declare function removeHooks(input: unknown): RemoveResult;
87
- //# sourceMappingURL=install.d.ts.map
@@ -121,4 +121,3 @@ export function removeHooks(input) {
121
121
  }
122
122
  return { settings, removed, removedEvents };
123
123
  }
124
- //# sourceMappingURL=install.js.map
@@ -13,4 +13,3 @@ export declare class PreFilter {
13
13
  constructor(filePath: string | undefined, defaults: string[]);
14
14
  isTrivial(prompt: string): boolean;
15
15
  }
16
- //# sourceMappingURL=preFilter.d.ts.map
@@ -37,4 +37,3 @@ export class PreFilter {
37
37
  function normalise(input) {
38
38
  return input.replace(/[\s\p{P}]/gu, "").toLowerCase();
39
39
  }
40
- //# sourceMappingURL=preFilter.js.map
@@ -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;
@@ -0,0 +1,94 @@
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
+ import { readdirSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { makeAnsi } from "./ansi.js";
22
+ import { HOST_PRESETS, PRESET_NAMES, detectPresets } from "./sources.js";
23
+ function makeIo(env, out) {
24
+ const ansi = makeAnsi({ isTTY: Boolean(process.stdout.isTTY), env });
25
+ return { out: out ?? ((s) => process.stdout.write(s + "\n")), ansi };
26
+ }
27
+ /** Display a source dir + glob with a portable separator (path.join, never "/"). */
28
+ function dirGlob(dir, glob) {
29
+ return join(dir, glob);
30
+ }
31
+ export function runPresets(opts) {
32
+ const env = opts.env ?? process.env;
33
+ const cwd = opts.cwd ?? process.cwd();
34
+ const home = opts.home ?? homedir();
35
+ const readDir = opts.readDir ?? ((dir) => readdirSync(dir));
36
+ const io = makeIo(env, opts.out);
37
+ if (opts.subcommand === "list")
38
+ return runList(io, cwd, home);
39
+ return runDetect(io, cwd, home, readDir);
40
+ }
41
+ function runList(io, cwd, home) {
42
+ const { ansi } = io;
43
+ io.out(ansi.bold("memhook host presets") + ansi.dim(" — built-in source bundles (all experimental)\n"));
44
+ for (const name of PRESET_NAMES) {
45
+ const def = HOST_PRESETS[name];
46
+ if (!def)
47
+ continue;
48
+ io.out(` ${ansi.cyan(name)} ${ansi.dim("(experimental)")}`);
49
+ io.out(` ${ansi.dim(def.summary)}`);
50
+ for (const s of def.sources) {
51
+ const dir = join(s.base === "cwd" ? cwd : home, s.rel);
52
+ io.out(` ${ansi.dim(dirGlob(dir, s.glob))}`);
53
+ }
54
+ }
55
+ io.out(ansi.dim("\nAll presets are doc-verified but not live-tested → experimental " +
56
+ "until an echo-test.\nEnable in ~/.config/memhook/config.yaml: " +
57
+ "presets: [name, …]\nFind which apply here with `memhook presets detect`."));
58
+ return 0;
59
+ }
60
+ function runDetect(io, cwd, home, readDir) {
61
+ const { ansi } = io;
62
+ const detections = detectPresets(cwd, home, readDir);
63
+ const matched = detections.filter((d) => d.matched);
64
+ io.out(ansi.bold("memhook presets detect") +
65
+ ansi.dim(" — scan this project + home for known host memory\n"));
66
+ for (const d of detections) {
67
+ const total = fileCount(d);
68
+ if (d.matched) {
69
+ io.out(` ${ansi.green("●")} ${ansi.cyan(d.name)} ${ansi.dim(`— ${total} file(s)`)}`);
70
+ for (const dir of d.dirs) {
71
+ if (dir.files.length === 0)
72
+ continue;
73
+ io.out(` ${ansi.dim(`${dirGlob(dir.dir, dir.glob)} (${dir.files.length})`)}`);
74
+ }
75
+ }
76
+ else {
77
+ io.out(` ${ansi.dim("○")} ${ansi.dim(`${d.name} — no matching files`)}`);
78
+ }
79
+ }
80
+ if (matched.length === 0) {
81
+ io.out(ansi.dim("\nNo known host memory found under this project or home. Nothing to enable."));
82
+ return 0;
83
+ }
84
+ const names = matched.map((d) => d.name).join(", ");
85
+ io.out("\n" +
86
+ ansi.bold(`Found ${matched.length} host preset(s) with memory.`) +
87
+ ansi.dim(" Enable (experimental) by adding to\n~/.config/memhook/config.yaml:\n"));
88
+ io.out(` ${ansi.cyan(`presets: [${names}]`)}`);
89
+ io.out(ansi.dim("\nThen run `memhook build-catalog` (or restart Claude Code) to catalog them."));
90
+ return 0;
91
+ }
92
+ function fileCount(d) {
93
+ return d.dirs.reduce((n, dir) => n + dir.files.length, 0);
94
+ }
@@ -30,4 +30,3 @@ export declare class AnthropicProvider implements Provider {
30
30
  constructor(config: ProviderConfig, options?: AnthropicProviderOptions);
31
31
  select(req: SelectionRequest): Promise<SelectionResponse>;
32
32
  }
33
- //# sourceMappingURL=anthropic.d.ts.map
@@ -95,4 +95,3 @@ function extractUsage(json) {
95
95
  cacheReadTokens: num("cache_read_input_tokens"),
96
96
  };
97
97
  }
98
- //# sourceMappingURL=anthropic.js.map
@@ -12,4 +12,3 @@
12
12
  import type { Provider } from "./types.js";
13
13
  import type { MemhookConfig } from "../config.js";
14
14
  export declare function createProvider(cfg: MemhookConfig, apiKey: string | undefined): Provider;
15
- //# sourceMappingURL=factory.d.ts.map
@@ -34,4 +34,3 @@ export function createProvider(cfg, apiKey) {
34
34
  }
35
35
  }
36
36
  }
37
- //# sourceMappingURL=factory.js.map
@@ -31,4 +31,3 @@ export interface RawHttpResult {
31
31
  latencyMs: number;
32
32
  }
33
33
  export declare function postJsonWithRetry(opts: PostJsonOptions): Promise<RawHttpResult>;
34
- //# sourceMappingURL=http.d.ts.map
@@ -57,4 +57,3 @@ export async function postJsonWithRetry(opts) {
57
57
  function sleep(ms) {
58
58
  return new Promise((resolve) => setTimeout(resolve, ms));
59
59
  }
60
- //# sourceMappingURL=http.js.map
@@ -27,4 +27,3 @@ export declare class OllamaProvider implements Provider {
27
27
  constructor(config: ProviderConfig);
28
28
  select(req: SelectionRequest): Promise<SelectionResponse>;
29
29
  }
30
- //# sourceMappingURL=ollama.d.ts.map
@@ -86,4 +86,3 @@ function extractUsage(json) {
86
86
  cacheReadTokens: 0,
87
87
  };
88
88
  }
89
- //# sourceMappingURL=ollama.js.map
@@ -28,4 +28,3 @@ export declare class OpenAIProvider implements Provider {
28
28
  constructor(config: ProviderConfig);
29
29
  select(req: SelectionRequest): Promise<SelectionResponse>;
30
30
  }
31
- //# sourceMappingURL=openai.d.ts.map
@@ -91,4 +91,3 @@ function extractUsage(json) {
91
91
  cacheReadTokens: cachedTokens,
92
92
  };
93
93
  }
94
- //# sourceMappingURL=openai.js.map
@@ -45,4 +45,3 @@ export interface Provider {
45
45
  readonly name: string;
46
46
  select(req: SelectionRequest): Promise<SelectionResponse>;
47
47
  }
48
- //# sourceMappingURL=types.d.ts.map
@@ -15,4 +15,3 @@
15
15
  * `createProvider()` in `factory.ts`.
16
16
  */
17
17
  export {};
18
- //# sourceMappingURL=types.js.map
@@ -19,6 +19,7 @@
19
19
  * Never blocks Claude Code.
20
20
  */
21
21
  import { type MemhookConfig } from "./config.js";
22
+ import type { HarnessAdapter } from "./adapters/types.js";
22
23
  export interface HookInput {
23
24
  prompt: string;
24
25
  cwd?: string;
@@ -36,7 +37,19 @@ export interface HookOutput {
36
37
  */
37
38
  systemMessage?: string;
38
39
  }
40
+ /**
41
+ * Claude Code hook entry point. Unchanged signature and output shape — a thin
42
+ * wrapper that drives the harness-agnostic pipeline through the Claude Code
43
+ * adapter (src/adapters/claudeCode.ts). Byte-identical to the pre-adapter hook.
44
+ */
39
45
  export declare function route(stdinJson: string, env?: NodeJS.ProcessEnv): Promise<HookOutput>;
46
+ /**
47
+ * Generic harness entry point: parse the host's stdin into `{prompt, cwd}` with
48
+ * the adapter, run the selection pipeline, then serialise the result into the
49
+ * host's stdout envelope with the same adapter. The pipeline (`selectMemory`) is
50
+ * identical for every host; only `parseInput` / `formatOutput` differ.
51
+ */
52
+ export declare function runHarness<T>(adapter: HarnessAdapter<T>, stdinJson: string, env?: NodeJS.ProcessEnv): Promise<T>;
40
53
  /**
41
54
  * Proactive `/curate` nudge. Returns a one-line `systemMessage` when the memory
42
55
  * catalog has grown past a threshold and the cooldown has elapsed, else
@@ -51,4 +64,23 @@ export declare function route(stdinJson: string, env?: NodeJS.ProcessEnv): Promi
51
64
  * Exported for direct unit testing.
52
65
  */
53
66
  export declare function maybeCurateNudge(config: MemhookConfig, catalogContent: string, now: number): string | undefined;
54
- //# sourceMappingURL=router.d.ts.map
67
+ /**
68
+ * Proactive presets nudge. Returns a one-line `systemMessage` when a known
69
+ * host's memory directory exists in this project (or home) but no `presets:`
70
+ * entry routes it yet, else undefined. It makes `memhook presets detect` (the
71
+ * #42 discovery command) self-announcing instead of something the user must know
72
+ * to run.
73
+ *
74
+ * Opt-in is preserved: the nudge only *suggests* the explicit `presets:` config;
75
+ * it never auto-routes anything (every preset is experimental — see
76
+ * `docs/SPECIFICATION.md` §24). Local-only and fully wrapped so any failure
77
+ * yields no nudge (fail-soft is never affected). The cooldown stamp is keyed by
78
+ * cwd because the presets signal is per-project (unlike the catalog-size signal
79
+ * behind the `/curate` nudge), so one project firing must not silence another's.
80
+ * Cost: like the curate nudge, the stamp is written only on a fire, so until one
81
+ * fires the detection runs per prompt — a few `readdir`s on mostly-absent dirs,
82
+ * the same order of I/O `readSelected` already does.
83
+ *
84
+ * Exported for direct unit testing.
85
+ */
86
+ export declare function maybePresetsNudge(config: MemhookConfig, cwd: string, home: string, now: number): string | undefined;
@@ -20,37 +20,55 @@
20
20
  */
21
21
  import { existsSync, readFileSync, writeFileSync, openSync, fstatSync, closeSync, appendFileSync, mkdirSync, readdirSync, } from "node:fs";
22
22
  import { join, dirname, basename } from "node:path";
23
+ import { homedir } from "node:os";
24
+ import { createHash } from "node:crypto";
23
25
  import { LocalCache } from "./cache.js";
24
26
  import { loadConfig } from "./config.js";
25
27
  import { PreFilter } from "./preFilter.js";
26
28
  import { createProvider } from "./providers/factory.js";
29
+ import { activeCustomSources, resolveSources, resolveActivePresetNames, detectPresets, } from "./sources.js";
30
+ import { claudeCodeAdapter } from "./adapters/claudeCode.js";
27
31
  const SAFE_BASENAME_RE = /^[A-Za-z0-9._-]+\.md$/;
28
- const EMPTY = {
29
- hookSpecificOutput: {
30
- hookEventName: "UserPromptSubmit",
31
- additionalContext: "",
32
- },
33
- };
32
+ /** The harness-agnostic empty outcome: inject nothing, no nudge (fail-soft). */
33
+ const EMPTY_RESULT = { additionalContext: "" };
34
+ /**
35
+ * Claude Code hook entry point. Unchanged signature and output shape — a thin
36
+ * wrapper that drives the harness-agnostic pipeline through the Claude Code
37
+ * adapter (src/adapters/claudeCode.ts). Byte-identical to the pre-adapter hook.
38
+ */
34
39
  export async function route(stdinJson, env = process.env) {
40
+ return runHarness(claudeCodeAdapter, stdinJson, env);
41
+ }
42
+ /**
43
+ * Generic harness entry point: parse the host's stdin into `{prompt, cwd}` with
44
+ * the adapter, run the selection pipeline, then serialise the result into the
45
+ * host's stdout envelope with the same adapter. The pipeline (`selectMemory`) is
46
+ * identical for every host; only `parseInput` / `formatOutput` differ.
47
+ */
48
+ export async function runHarness(adapter, stdinJson, env = process.env) {
49
+ const result = await selectMemory(stdinJson, adapter.parseInput, env);
50
+ return adapter.formatOutput(result);
51
+ }
52
+ /**
53
+ * Harness-agnostic selection pipeline. Owns the config gate, prefilter, catalog
54
+ * + API-key checks, the local cache, the provider call, file injection, the
55
+ * JSONL log, and the `/curate` nudge. Every error path returns `EMPTY_RESULT`
56
+ * (fail-soft) — the hook never throws out of here.
57
+ */
58
+ async function selectMemory(stdinJson, parseInput, env) {
35
59
  const config = loadConfig(env);
36
60
  ensureDirs(config);
37
61
  evictStale(config);
38
62
  if (!config.enabled)
39
- return EMPTY;
40
- let input;
41
- try {
42
- input = JSON.parse(stdinJson);
43
- }
44
- catch {
45
- return EMPTY;
46
- }
47
- if (!input.prompt || typeof input.prompt !== "string")
48
- return EMPTY;
63
+ return EMPTY_RESULT;
64
+ const input = parseInput(stdinJson);
65
+ if (!input || !input.prompt || typeof input.prompt !== "string")
66
+ return EMPTY_RESULT;
49
67
  const cwd = input.cwd ?? process.cwd();
50
68
  const preFilter = new PreFilter(config.preFilter.trivialWordsFile, config.preFilter.defaultWords);
51
69
  if (config.preFilter.enabled && preFilter.isTrivial(input.prompt)) {
52
70
  logEntry(config, baseLog(input.prompt, "pre_filter_skip"));
53
- return EMPTY;
71
+ return EMPTY_RESULT;
54
72
  }
55
73
  // Read the catalog through a single fd: `fstat` gives the cache-key mtime and
56
74
  // we read the content from the same handle. Using one open fd — instead of
@@ -73,14 +91,14 @@ export async function route(stdinJson, env = process.env) {
73
91
  }
74
92
  catch {
75
93
  logEntry(config, baseLog(input.prompt, "no_catalog"));
76
- return EMPTY;
94
+ return EMPTY_RESULT;
77
95
  }
78
96
  // Provider-aware key gate: local providers (Ollama) need no API key.
79
97
  const needsKey = config.provider.type !== "ollama";
80
98
  const apiKey = config.provider.apiKeyEnv ? env[config.provider.apiKeyEnv] : undefined;
81
99
  if (needsKey && !apiKey) {
82
100
  logEntry(config, baseLog(input.prompt, "no_api_key"));
83
- return EMPTY;
101
+ return EMPTY_RESULT;
84
102
  }
85
103
  const cache = new LocalCache(config.cache.dir, config.cache.ttlMin, config.cache.evictionDays);
86
104
  const cacheKey = config.cache.enabled
@@ -120,7 +138,7 @@ export async function route(stdinJson, env = process.env) {
120
138
  }
121
139
  catch {
122
140
  logEntry(config, baseLog(input.prompt, "provider_init_failed"));
123
- return EMPTY;
141
+ return EMPTY_RESULT;
124
142
  }
125
143
  let resp;
126
144
  try {
@@ -133,7 +151,7 @@ export async function route(stdinJson, env = process.env) {
133
151
  }
134
152
  catch {
135
153
  logEntry(config, baseLog(input.prompt, "api_no_response"));
136
- return EMPTY;
154
+ return EMPTY_RESULT;
137
155
  }
138
156
  latencyMs = resp.latencyMs;
139
157
  usage = resp.usage;
@@ -146,7 +164,7 @@ export async function route(stdinJson, env = process.env) {
146
164
  cacheCreate: usage.cacheCreateTokens,
147
165
  cacheRead: usage.cacheReadTokens,
148
166
  });
149
- return EMPTY;
167
+ return EMPTY_RESULT;
150
168
  }
151
169
  const extracted = extractJsonArray(resp.rawText);
152
170
  if (extracted === null) {
@@ -158,7 +176,7 @@ export async function route(stdinJson, env = process.env) {
158
176
  cacheCreate: usage.cacheCreateTokens,
159
177
  cacheRead: usage.cacheReadTokens,
160
178
  });
161
- return EMPTY;
179
+ return EMPTY_RESULT;
162
180
  }
163
181
  selectedJson = JSON.stringify(extracted);
164
182
  if (config.cache.enabled && selectedJson !== "[]") {
@@ -192,19 +210,25 @@ export async function route(stdinJson, env = process.env) {
192
210
  additionalSizeTokensEst: Math.floor(additional.length / 4),
193
211
  status,
194
212
  });
195
- const output = {
196
- hookSpecificOutput: {
197
- hookEventName: "UserPromptSubmit",
198
- additionalContext: additional,
199
- },
200
- };
213
+ const result = { additionalContext: additional };
201
214
  // Proactive `/curate` nudge — best-effort, local-only, never affects the
202
215
  // additionalContext contract or fail-soft. `catalogContent` is already in hand
203
- // (read above), so the catalog-size signal is free.
216
+ // (read above), so the catalog-size signal is free. The adapter serialises it
217
+ // into the host's notice channel (Claude Code: `systemMessage`); a host with
218
+ // no equivalent simply drops it.
204
219
  const nudge = maybeCurateNudge(config, catalogContent, Date.now());
205
- if (nudge)
206
- output.systemMessage = nudge;
207
- return output;
220
+ if (nudge) {
221
+ result.systemMessage = nudge;
222
+ }
223
+ else {
224
+ // Only one notice channel (`systemMessage`) exists, so the presets nudge is
225
+ // a fallback: it fires only on a turn the curate nudge did not. Each has its
226
+ // own long cooldown, so the collision is rare and never produces two notices.
227
+ const pnudge = maybePresetsNudge(config, cwd, homedir(), Date.now());
228
+ if (pnudge)
229
+ result.systemMessage = pnudge;
230
+ }
231
+ return result;
208
232
  }
209
233
  function buildSystemPrompt(catalog) {
210
234
  return `Tu es un sélecteur de mémoire pour Claude Code. Tu identifies les feedbacks et règles pertinents pour le prompt utilisateur. Tu réponds UNIQUEMENT avec un JSON array de basenames .md, sans explication, sans markdown code fence.
@@ -246,33 +270,53 @@ function parseBasenames(raw) {
246
270
  }
247
271
  function extractJsonArray(text) {
248
272
  const flat = text.replace(/\n/g, " ");
249
- const match = flat.match(/\[[^\]]*\]/);
250
- if (!match)
273
+ const matches = flat.match(/\[[^\]]*\]/g);
274
+ if (!matches)
251
275
  return null;
252
- try {
253
- const parsed = JSON.parse(match[0]);
276
+ // A compliant response is a single bare array, but a model may wrap it in
277
+ // prose containing a decoy `[...]`. Scan every bracketed candidate and prefer
278
+ // the LAST one that yields usable string basenames; keep an empty array only
279
+ // as a fallback when no non-empty array is found, else null.
280
+ let result = null;
281
+ for (const candidate of matches) {
282
+ let parsed;
283
+ try {
284
+ parsed = JSON.parse(candidate);
285
+ }
286
+ catch {
287
+ continue;
288
+ }
254
289
  if (!Array.isArray(parsed))
255
- return null;
256
- return parsed.filter((x) => typeof x === "string");
257
- }
258
- catch {
259
- return null;
290
+ continue;
291
+ const strings = parsed.filter((x) => typeof x === "string");
292
+ if (strings.length > 0 || result === null)
293
+ result = strings;
260
294
  }
295
+ return result;
261
296
  }
262
297
  function readSelected(basenames, cwd, config) {
263
- // Build search dirs: ~/.claude/projects/*/memory + global rules + cwd rules.
298
+ // Build search dirs: ~/.claude/projects/*/memory + global rules + cwd rules +
299
+ // any user-declared custom sources (host-autoloaded ones only when resurfacing,
300
+ // mirroring the catalog so the router never finds what the catalog omitted).
264
301
  const projectDirs = listProjectsMemoryDirs(config.searchDirs[0]);
265
302
  const rulesDir = config.searchDirs[1];
266
303
  const cwdRulesDir = join(cwd, ".claude", "rules");
267
- const dirs = [...projectDirs, rulesDir, cwdRulesDir].filter((d) => typeof d === "string" && d.length > 0);
304
+ const allSources = resolveSources(config.customSources, config.presets, cwd, homedir(), readdirSync);
305
+ const customDirs = activeCustomSources(allSources, config.resurfaceHostLoaded).map((s) => s.dir);
306
+ const dirs = [...projectDirs, rulesDir, cwdRulesDir, ...customDirs].filter((d) => typeof d === "string" && d.length > 0);
268
307
  let additional = "";
269
308
  let injected = 0;
270
309
  const seen = [];
310
+ const seenNames = new Set();
271
311
  for (const name of basenames) {
272
312
  if (injected >= config.selection.maxFiles)
273
313
  break;
274
314
  if (!SAFE_BASENAME_RE.test(name))
275
315
  continue;
316
+ // De-dup: a basename the model repeats is injected once and uses one slot.
317
+ if (seenNames.has(name))
318
+ continue;
319
+ seenNames.add(name);
276
320
  seen.push(name);
277
321
  for (const dir of dirs) {
278
322
  const file = join(dir, name);
@@ -366,6 +410,59 @@ export function maybeCurateNudge(config, catalogContent, now) {
366
410
  return undefined; // a nudge must never break fail-soft
367
411
  }
368
412
  }
413
+ /**
414
+ * Proactive presets nudge. Returns a one-line `systemMessage` when a known
415
+ * host's memory directory exists in this project (or home) but no `presets:`
416
+ * entry routes it yet, else undefined. It makes `memhook presets detect` (the
417
+ * #42 discovery command) self-announcing instead of something the user must know
418
+ * to run.
419
+ *
420
+ * Opt-in is preserved: the nudge only *suggests* the explicit `presets:` config;
421
+ * it never auto-routes anything (every preset is experimental — see
422
+ * `docs/SPECIFICATION.md` §24). Local-only and fully wrapped so any failure
423
+ * yields no nudge (fail-soft is never affected). The cooldown stamp is keyed by
424
+ * cwd because the presets signal is per-project (unlike the catalog-size signal
425
+ * behind the `/curate` nudge), so one project firing must not silence another's.
426
+ * Cost: like the curate nudge, the stamp is written only on a fire, so until one
427
+ * fires the detection runs per prompt — a few `readdir`s on mostly-absent dirs,
428
+ * the same order of I/O `readSelected` already does.
429
+ *
430
+ * Exported for direct unit testing.
431
+ */
432
+ export function maybePresetsNudge(config, cwd, home, now) {
433
+ try {
434
+ if (!config.presetsNudge.enabled)
435
+ return undefined;
436
+ // Per-project stamp: a global stamp would let the first project hit in a
437
+ // window silence every other project's (per-cwd) presets signal.
438
+ const cwdHash = createHash("sha256").update(cwd).digest("hex").slice(0, 12);
439
+ const stampFile = join(config.cache.dir, `.presets-nudge-${cwdHash}`);
440
+ const last = readNudgeStamp(stampFile);
441
+ if (last !== null && now - last < config.presetsNudge.cooldownDays * 86_400_000) {
442
+ return undefined;
443
+ }
444
+ // Suggest a preset only when it has memory on disk that is NOT already routed
445
+ // — neither by an effective preset name (named entries AND `presets: [auto]`
446
+ // expansion, so an `auto` user is never nudged) nor by a hand-written
447
+ // `customSources` dir pointing at the same place (`presets:` is sugar over
448
+ // `customSources`, D31/D32). `enabled` keys on the EXPANDED names (not the raw
449
+ // config) so it suppresses regardless of the `resurfaceHostLoaded` gate, which
450
+ // would otherwise drop a hostAutoLoaded preset's dirs from `routedDirs`.
451
+ const enabled = new Set(resolveActivePresetNames(config.presets, cwd, home, readdirSync));
452
+ const routedDirs = new Set(activeCustomSources(resolveSources(config.customSources, config.presets, cwd, home, readdirSync), config.resurfaceHostLoaded).map((s) => s.dir));
453
+ const found = detectPresets(cwd, home, readdirSync)
454
+ .filter((d) => d.matched && !enabled.has(d.name))
455
+ .filter((d) => d.dirs.some((dir) => dir.files.length > 0 && !routedDirs.has(dir.dir)))
456
+ .map((d) => d.name);
457
+ if (found.length === 0)
458
+ return undefined;
459
+ writeNudgeStamp(stampFile, now);
460
+ return `🔌 memhook: found ${found.join(", ")} memory not yet routed by memhook. Run \`memhook presets detect\` to enable it.`;
461
+ }
462
+ catch {
463
+ return undefined; // a nudge must never break fail-soft
464
+ }
465
+ }
369
466
  /** Count top-level `*.md` memory files (excludes MEMORY.md and the journal/ subdir). */
370
467
  function countMemoryFiles(projectsRoot) {
371
468
  let total = 0;
@@ -444,4 +541,3 @@ function logEntry(config, entry) {
444
541
  function nowIso() {
445
542
  return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
446
543
  }
447
- //# sourceMappingURL=router.js.map
@@ -65,4 +65,3 @@ export interface SkillUninstallPlan {
65
65
  }
66
66
  /** Plan one skill uninstall: remove only the files memhook ships, if present. */
67
67
  export declare function planUninstall(name: CompanionSkill, source: SkillSources, installed: InstalledFiles): SkillUninstallPlan;
68
- //# sourceMappingURL=skills.d.ts.map
@@ -70,4 +70,3 @@ export function planUninstall(name, source, installed) {
70
70
  const present = removes.length > 0;
71
71
  return { name, present, action: present ? "remove" : "skip", removes };
72
72
  }
73
- //# sourceMappingURL=skills.js.map
@@ -48,4 +48,3 @@ export interface RunSkillsOptions {
48
48
  env?: NodeJS.ProcessEnv | undefined;
49
49
  }
50
50
  export declare function runSkills(opts: RunSkillsOptions): Promise<number>;
51
- //# sourceMappingURL=skillsCmd.d.ts.map
@@ -270,4 +270,3 @@ async function confirm(ansi, hint, defaultYes) {
270
270
  rl.close();
271
271
  }
272
272
  }
273
- //# sourceMappingURL=skillsCmd.js.map