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,251 @@
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
+ import { join } from "node:path";
14
+ /** Expand a leading `~` / `~/` against the given home directory. */
15
+ export function expandHome(p, home) {
16
+ if (p === "~")
17
+ return home;
18
+ if (p.startsWith("~/") || p.startsWith("~\\"))
19
+ return join(home, p.slice(2));
20
+ return p;
21
+ }
22
+ /**
23
+ * Compile a basename glob into an anchored RegExp. Only `*` (any run) and `?`
24
+ * (one char) are special; every other character is matched literally. Operates
25
+ * on basenames only — never a path separator.
26
+ */
27
+ export function globToRegExp(glob) {
28
+ let body = "";
29
+ for (const ch of glob) {
30
+ if (ch === "*")
31
+ body += ".*";
32
+ else if (ch === "?")
33
+ body += ".";
34
+ else
35
+ body += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
36
+ }
37
+ return new RegExp(`^${body}$`);
38
+ }
39
+ /**
40
+ * From a raw directory listing, the `.md` files matching `glob`, sorted. Pure
41
+ * and total. The `.md` gate is intentional and shared with the router's
42
+ * injection guard (`SAFE_BASENAME_RE`): only `.md` can ever be injected, so a
43
+ * glob like `*.txt` yields nothing. The catalog builder and preset detection
44
+ * both filter through this so they can never disagree on what a source matches.
45
+ */
46
+ export function listMatchingMdFiles(entries, glob) {
47
+ const re = globToRegExp(glob);
48
+ return entries.filter((e) => e.endsWith(".md") && re.test(e)).sort();
49
+ }
50
+ /**
51
+ * Resolve untrusted YAML `customSources` into typed `CustomSource[]`. Anything
52
+ * that isn't a usable entry (not an object, missing/blank `dir`) is dropped.
53
+ * Never throws.
54
+ */
55
+ export function resolveCustomSources(raw, home) {
56
+ if (!Array.isArray(raw))
57
+ return [];
58
+ const out = [];
59
+ for (const entry of raw) {
60
+ if (!entry || typeof entry !== "object")
61
+ continue;
62
+ const e = entry;
63
+ if (typeof e["dir"] !== "string" || e["dir"].trim() === "")
64
+ continue;
65
+ const glob = typeof e["glob"] === "string" && e["glob"].trim() !== "" ? e["glob"] : "*.md";
66
+ const scope = e["scope"] === "rules" ? "rules" : "memory";
67
+ out.push({
68
+ dir: expandHome(e["dir"], home),
69
+ glob,
70
+ scope,
71
+ hostAutoLoaded: e["hostAutoLoaded"] === true,
72
+ });
73
+ }
74
+ return out;
75
+ }
76
+ /**
77
+ * The custom sources that should actually be catalogued + scanned: a
78
+ * host-autoloaded source is active only when re-surfacing is requested. Both the
79
+ * catalog builder and the router filter through this so they never disagree.
80
+ */
81
+ export function activeCustomSources(sources, resurfaceHostLoaded) {
82
+ return sources.filter((s) => !s.hostAutoLoaded || resurfaceHostLoaded);
83
+ }
84
+ /** Built-in presets keyed by name. Atomic `.md` conventions only. */
85
+ export const HOST_PRESETS = {
86
+ cline: {
87
+ experimental: true,
88
+ summary: "Cline — .clinerules/ (project) + ~/Documents/Cline/Rules/ (global)",
89
+ sources: [
90
+ { base: "cwd", rel: ".clinerules", glob: "*.md", scope: "rules", hostAutoLoaded: true },
91
+ {
92
+ base: "home",
93
+ rel: join("Documents", "Cline", "Rules"),
94
+ glob: "*.md",
95
+ scope: "rules",
96
+ hostAutoLoaded: true,
97
+ },
98
+ ],
99
+ },
100
+ continue: {
101
+ experimental: true,
102
+ summary: "Continue.dev — .continue/rules/ (project + ~)",
103
+ sources: [
104
+ {
105
+ base: "cwd",
106
+ rel: join(".continue", "rules"),
107
+ glob: "*.md",
108
+ scope: "rules",
109
+ hostAutoLoaded: false,
110
+ },
111
+ {
112
+ base: "home",
113
+ rel: join(".continue", "rules"),
114
+ glob: "*.md",
115
+ scope: "rules",
116
+ hostAutoLoaded: false,
117
+ },
118
+ ],
119
+ },
120
+ copilot: {
121
+ experimental: true,
122
+ summary: "GitHub Copilot — .github/instructions/*.instructions.md (project)",
123
+ sources: [
124
+ {
125
+ base: "cwd",
126
+ rel: join(".github", "instructions"),
127
+ glob: "*.instructions.md",
128
+ scope: "rules",
129
+ hostAutoLoaded: false,
130
+ },
131
+ ],
132
+ },
133
+ windsurf: {
134
+ experimental: true,
135
+ summary: "Windsurf — .windsurf/rules/ (project)",
136
+ sources: [
137
+ {
138
+ base: "cwd",
139
+ rel: join(".windsurf", "rules"),
140
+ glob: "*.md",
141
+ scope: "rules",
142
+ hostAutoLoaded: false,
143
+ },
144
+ ],
145
+ },
146
+ };
147
+ /** All known preset names. */
148
+ export const PRESET_NAMES = Object.keys(HOST_PRESETS);
149
+ /**
150
+ * Special `presets:` token: instead of naming presets, the user opts in once and
151
+ * memhook routes every preset it detects on disk. Explicit opt-in (never the
152
+ * default), so the cardinal opt-in design (D31/D32) holds; the routed presets are
153
+ * still experimental (§24). See `resolveActivePresetNames`.
154
+ */
155
+ export const PRESET_AUTO = "auto";
156
+ export function isPresetName(name) {
157
+ return Object.prototype.hasOwnProperty.call(HOST_PRESETS, name);
158
+ }
159
+ /**
160
+ * Keep only valid preset entries from untrusted YAML: known names plus the
161
+ * special `auto` token. Never throws.
162
+ */
163
+ export function resolvePresetNames(raw) {
164
+ if (!Array.isArray(raw))
165
+ return [];
166
+ return raw.filter((x) => typeof x === "string" && (isPresetName(x) || x === PRESET_AUTO));
167
+ }
168
+ /**
169
+ * Expand preset names into concrete `CustomSource[]`, resolving each template's
170
+ * `cwd`/`home` base. Unknown names are skipped. Pure + total.
171
+ */
172
+ export function expandPresets(names, cwd, home) {
173
+ const out = [];
174
+ for (const name of names) {
175
+ const def = HOST_PRESETS[name];
176
+ if (!def)
177
+ continue;
178
+ for (const s of def.sources) {
179
+ out.push({
180
+ dir: join(s.base === "cwd" ? cwd : home, s.rel),
181
+ glob: s.glob,
182
+ scope: s.scope,
183
+ hostAutoLoaded: s.hostAutoLoaded,
184
+ });
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+ /**
190
+ * Resolve the effective preset names. Without the `auto` token, this is just the
191
+ * known names as given. With `auto` (explicit opt-in), it expands to every preset
192
+ * detected on disk (via `readDir`), unioned with any explicitly-named presets and
193
+ * de-duplicated. `readDir` is consulted ONLY when `auto` is present, so a config
194
+ * without `auto` pays zero detection I/O. Pure-of-I/O (the reader is a seam) and
195
+ * total (`detectPresets` swallows reader errors).
196
+ */
197
+ export function resolveActivePresetNames(names, cwd, home, readDir) {
198
+ const known = names.filter(isPresetName); // drops `auto` + any unknown token
199
+ if (!names.includes(PRESET_AUTO))
200
+ return known;
201
+ const detected = detectPresets(cwd, home, readDir)
202
+ .filter((d) => d.matched)
203
+ .map((d) => d.name);
204
+ return [...new Set([...known, ...detected])];
205
+ }
206
+ /**
207
+ * The full set of user-declared sources: explicit `customSources` plus the
208
+ * expanded built-in `presets` (with `auto` resolved to the detected presets via
209
+ * `readDir`). The single place catalog + router agree on what "the custom
210
+ * sources" are, so they never diverge — including how `auto` expands.
211
+ */
212
+ export function resolveSources(customSources, presets, cwd, home, readDir) {
213
+ const names = resolveActivePresetNames(presets, cwd, home, readDir);
214
+ return [...customSources, ...expandPresets(names, cwd, home)];
215
+ }
216
+ /**
217
+ * Scan every built-in preset against the filesystem (via the injected `readDir`)
218
+ * and report which ones hold matching memory. Pure of real I/O (the reader is a
219
+ * seam) and total: a `readDir` that throws on a missing/denied directory is
220
+ * caught and recorded as `exists: false`, never propagated. Presets are returned
221
+ * in `PRESET_NAMES` order so the output is deterministic.
222
+ */
223
+ export function detectPresets(cwd, home, readDir) {
224
+ const out = [];
225
+ for (const name of PRESET_NAMES) {
226
+ const def = HOST_PRESETS[name];
227
+ if (!def)
228
+ continue;
229
+ const dirs = [];
230
+ for (const s of def.sources) {
231
+ const dir = join(s.base === "cwd" ? cwd : home, s.rel);
232
+ let entries = null;
233
+ try {
234
+ entries = readDir(dir);
235
+ }
236
+ catch {
237
+ entries = null;
238
+ }
239
+ const files = entries === null ? [] : listMatchingMdFiles(entries, s.glob);
240
+ dirs.push({ dir, glob: s.glob, files, exists: entries !== null });
241
+ }
242
+ out.push({
243
+ name,
244
+ summary: def.summary,
245
+ experimental: def.experimental,
246
+ matched: dirs.some((d) => d.files.length > 0),
247
+ dirs,
248
+ });
249
+ }
250
+ return out;
251
+ }
@@ -9,4 +9,4 @@
9
9
  * the literal in lockstep with `package.json` + `.release-please-manifest.json`.
10
10
  * Do not bump it by hand.
11
11
  */
12
- export declare const MEMHOOK_VERSION = "0.4.1";
12
+ export declare const MEMHOOK_VERSION = "0.5.0";
@@ -9,4 +9,4 @@
9
9
  * the literal in lockstep with `package.json` + `.release-please-manifest.json`.
10
10
  * Do not bump it by hand.
11
11
  */
12
- export const MEMHOOK_VERSION = "0.4.1"; // x-release-please-version
12
+ export const MEMHOOK_VERSION = "0.5.0"; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhook",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Semantic memory router for Claude Code — picks relevant feedbacks & rules per prompt via Haiku, injects them as additionalContext.",
5
5
  "type": "module",
6
6
  "license": "MIT",