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 +18 -0
- package/README.md +19 -15
- package/dist/bin/memhook.js +36 -1
- package/dist/src/adapters/claudeCode.d.ts +15 -0
- package/dist/src/adapters/claudeCode.js +49 -0
- package/dist/src/adapters/types.d.ts +45 -0
- package/dist/src/adapters/types.js +13 -0
- package/dist/src/catalog.d.ts +16 -2
- package/dist/src/catalog.js +50 -4
- package/dist/src/config.d.ts +46 -0
- package/dist/src/config.js +8 -0
- package/dist/src/configFile.d.ts +9 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/presetsCmd.d.ts +28 -0
- package/dist/src/presetsCmd.js +94 -0
- package/dist/src/router.d.ts +33 -0
- package/dist/src/router.js +116 -35
- package/dist/src/sources.d.ts +138 -0
- package/dist/src/sources.js +251 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/src/router.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/router.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
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 {};
|