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,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
|
+
}
|
package/dist/src/version.d.ts
CHANGED
package/dist/src/version.js
CHANGED
package/package.json
CHANGED