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.
@@ -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
+ }
@@ -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,3 +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;
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.
@@ -271,11 +295,15 @@ function extractJsonArray(text) {
271
295
  return result;
272
296
  }
273
297
  function readSelected(basenames, cwd, config) {
274
- // 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).
275
301
  const projectDirs = listProjectsMemoryDirs(config.searchDirs[0]);
276
302
  const rulesDir = config.searchDirs[1];
277
303
  const cwdRulesDir = join(cwd, ".claude", "rules");
278
- 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);
279
307
  let additional = "";
280
308
  let injected = 0;
281
309
  const seen = [];
@@ -382,6 +410,59 @@ export function maybeCurateNudge(config, catalogContent, now) {
382
410
  return undefined; // a nudge must never break fail-soft
383
411
  }
384
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
+ }
385
466
  /** Count top-level `*.md` memory files (excludes MEMORY.md and the journal/ subdir). */
386
467
  function countMemoryFiles(projectsRoot) {
387
468
  let total = 0;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Custom memory sources — let memhook cable onto memory that already exists in a
3
+ * project, wherever it lives and however its files are named, instead of only
4
+ * the built-in `~/.claude` zones.
5
+ *
6
+ * A user declares extra sources in the YAML config (`customSources:`); each is a
7
+ * directory of `.md` files plus a filename glob, a scope, and whether the host
8
+ * already auto-loads them at launch. The router catalogs + injects from these
9
+ * exactly like the built-in zones. Everything here is pure (no I/O) and total
10
+ * (never throws): malformed entries are dropped, not fatal — the hook stays
11
+ * fail-soft.
12
+ */
13
+ export type SourceScope = "memory" | "rules";
14
+ /** A resolved, fully-typed custom source (every field present). */
15
+ export interface CustomSource {
16
+ /** Absolute directory to scan (`~` already expanded). */
17
+ dir: string;
18
+ /** Basename glob (`*`, `?`); defaults to `*.md`. */
19
+ glob: string;
20
+ /** `memory` (default) or `rules` — drives the catalog section + display. */
21
+ scope: SourceScope;
22
+ /**
23
+ * Whether the host loads these files in full at launch. When true they are
24
+ * skipped unless `resurfaceHostLoaded` is on (same contract as the built-in
25
+ * `~/.claude/rules` zones — see `MemhookConfig.resurfaceHostLoaded`).
26
+ */
27
+ hostAutoLoaded: boolean;
28
+ }
29
+ /** Expand a leading `~` / `~/` against the given home directory. */
30
+ export declare function expandHome(p: string, home: string): string;
31
+ /**
32
+ * Compile a basename glob into an anchored RegExp. Only `*` (any run) and `?`
33
+ * (one char) are special; every other character is matched literally. Operates
34
+ * on basenames only — never a path separator.
35
+ */
36
+ export declare function globToRegExp(glob: string): RegExp;
37
+ /**
38
+ * From a raw directory listing, the `.md` files matching `glob`, sorted. Pure
39
+ * and total. The `.md` gate is intentional and shared with the router's
40
+ * injection guard (`SAFE_BASENAME_RE`): only `.md` can ever be injected, so a
41
+ * glob like `*.txt` yields nothing. The catalog builder and preset detection
42
+ * both filter through this so they can never disagree on what a source matches.
43
+ */
44
+ export declare function listMatchingMdFiles(entries: readonly string[], glob: string): string[];
45
+ /**
46
+ * Resolve untrusted YAML `customSources` into typed `CustomSource[]`. Anything
47
+ * that isn't a usable entry (not an object, missing/blank `dir`) is dropped.
48
+ * Never throws.
49
+ */
50
+ export declare function resolveCustomSources(raw: unknown, home: string): CustomSource[];
51
+ /**
52
+ * The custom sources that should actually be catalogued + scanned: a
53
+ * host-autoloaded source is active only when re-surfacing is requested. Both the
54
+ * catalog builder and the router filter through this so they never disagree.
55
+ */
56
+ export declare function activeCustomSources(sources: readonly CustomSource[], resurfaceHostLoaded: boolean): CustomSource[];
57
+ interface PresetSourceDef {
58
+ /** Resolve `rel` against the project cwd or the home directory. */
59
+ readonly base: "cwd" | "home";
60
+ readonly rel: string;
61
+ readonly glob: string;
62
+ readonly scope: SourceScope;
63
+ readonly hostAutoLoaded: boolean;
64
+ }
65
+ export interface PresetDef {
66
+ /** Doc-verified, not live-tested → always experimental until an echo-test. */
67
+ readonly experimental: true;
68
+ readonly summary: string;
69
+ readonly sources: readonly PresetSourceDef[];
70
+ }
71
+ /** Built-in presets keyed by name. Atomic `.md` conventions only. */
72
+ export declare const HOST_PRESETS: Record<string, PresetDef>;
73
+ /** All known preset names. */
74
+ export declare const PRESET_NAMES: string[];
75
+ /**
76
+ * Special `presets:` token: instead of naming presets, the user opts in once and
77
+ * memhook routes every preset it detects on disk. Explicit opt-in (never the
78
+ * default), so the cardinal opt-in design (D31/D32) holds; the routed presets are
79
+ * still experimental (§24). See `resolveActivePresetNames`.
80
+ */
81
+ export declare const PRESET_AUTO = "auto";
82
+ export declare function isPresetName(name: string): boolean;
83
+ /**
84
+ * Keep only valid preset entries from untrusted YAML: known names plus the
85
+ * special `auto` token. Never throws.
86
+ */
87
+ export declare function resolvePresetNames(raw: unknown): string[];
88
+ /**
89
+ * Expand preset names into concrete `CustomSource[]`, resolving each template's
90
+ * `cwd`/`home` base. Unknown names are skipped. Pure + total.
91
+ */
92
+ export declare function expandPresets(names: readonly string[], cwd: string, home: string): CustomSource[];
93
+ /**
94
+ * Resolve the effective preset names. Without the `auto` token, this is just the
95
+ * known names as given. With `auto` (explicit opt-in), it expands to every preset
96
+ * detected on disk (via `readDir`), unioned with any explicitly-named presets and
97
+ * de-duplicated. `readDir` is consulted ONLY when `auto` is present, so a config
98
+ * without `auto` pays zero detection I/O. Pure-of-I/O (the reader is a seam) and
99
+ * total (`detectPresets` swallows reader errors).
100
+ */
101
+ export declare function resolveActivePresetNames(names: readonly string[], cwd: string, home: string, readDir: (dir: string) => string[]): string[];
102
+ /**
103
+ * The full set of user-declared sources: explicit `customSources` plus the
104
+ * expanded built-in `presets` (with `auto` resolved to the detected presets via
105
+ * `readDir`). The single place catalog + router agree on what "the custom
106
+ * sources" are, so they never diverge — including how `auto` expands.
107
+ */
108
+ export declare function resolveSources(customSources: readonly CustomSource[], presets: readonly string[], cwd: string, home: string, readDir: (dir: string) => string[]): CustomSource[];
109
+ /** One resolved preset directory and what it matched. */
110
+ export interface PresetDirMatch {
111
+ /** Absolute directory the preset points at (cwd/home already resolved). */
112
+ dir: string;
113
+ /** The basename glob applied to that directory. */
114
+ glob: string;
115
+ /** Matching `.md` basenames (sorted); empty if the dir is absent or has none. */
116
+ files: string[];
117
+ /** Whether the directory was readable at all (distinguishes empty from missing). */
118
+ exists: boolean;
119
+ }
120
+ /** Detection result for one built-in preset. */
121
+ export interface PresetDetection {
122
+ name: string;
123
+ summary: string;
124
+ /** Mirrors `PresetDef.experimental` — every built-in preset is experimental. */
125
+ experimental: true;
126
+ /** True when at least one of the preset's directories matched ≥1 `.md` file. */
127
+ matched: boolean;
128
+ dirs: PresetDirMatch[];
129
+ }
130
+ /**
131
+ * Scan every built-in preset against the filesystem (via the injected `readDir`)
132
+ * and report which ones hold matching memory. Pure of real I/O (the reader is a
133
+ * seam) and total: a `readDir` that throws on a missing/denied directory is
134
+ * caught and recorded as `exists: false`, never propagated. Presets are returned
135
+ * in `PRESET_NAMES` order so the output is deterministic.
136
+ */
137
+ export declare function detectPresets(cwd: string, home: string, readDir: (dir: string) => string[]): PresetDetection[];
138
+ export {};