memhook 0.2.2 → 0.4.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 +30 -72
- package/README.md +158 -69
- package/dist/bin/memhook.d.ts +11 -6
- package/dist/bin/memhook.d.ts.map +1 -1
- package/dist/bin/memhook.js +208 -21
- package/dist/bin/memhook.js.map +1 -1
- package/dist/src/ansi.d.ts +71 -0
- package/dist/src/ansi.d.ts.map +1 -0
- package/dist/src/ansi.js +100 -0
- package/dist/src/ansi.js.map +1 -0
- package/dist/src/backup.d.ts +10 -0
- package/dist/src/backup.d.ts.map +1 -0
- package/dist/src/backup.js +17 -0
- package/dist/src/backup.js.map +1 -0
- package/dist/src/cache.d.ts.map +1 -1
- package/dist/src/cache.js +14 -7
- package/dist/src/cache.js.map +1 -1
- package/dist/src/config.d.ts +11 -0
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +6 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/configFile.d.ts +6 -0
- package/dist/src/configFile.d.ts.map +1 -1
- package/dist/src/configFile.js.map +1 -1
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +6 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/init.d.ts +48 -0
- package/dist/src/init.d.ts.map +1 -0
- package/dist/src/init.js +327 -0
- package/dist/src/init.js.map +1 -0
- package/dist/src/install.d.ts +87 -0
- package/dist/src/install.d.ts.map +1 -0
- package/dist/src/install.js +124 -0
- package/dist/src/install.js.map +1 -0
- package/dist/src/router.d.ts +22 -0
- package/dist/src/router.d.ts.map +1 -1
- package/dist/src/router.js +113 -8
- package/dist/src/router.js.map +1 -1
- package/dist/src/skills.d.ts +68 -0
- package/dist/src/skills.d.ts.map +1 -0
- package/dist/src/skills.js +73 -0
- package/dist/src/skills.js.map +1 -0
- package/dist/src/skillsCmd.d.ts +51 -0
- package/dist/src/skillsCmd.d.ts.map +1 -0
- package/dist/src/skillsCmd.js +273 -0
- package/dist/src/skillsCmd.js.map +1 -0
- package/dist/src/tail.d.ts +76 -0
- package/dist/src/tail.d.ts.map +1 -0
- package/dist/src/tail.js +280 -0
- package/dist/src/tail.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +9 -2
- package/skills/curate/SKILL.md +181 -0
- package/skills/curate/reference.md +105 -0
- package/skills/relay/SKILL.md +162 -0
- package/skills/wrap/SKILL.md +173 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memhook skills install|uninstall|list` — copy the bundled companion skills
|
|
3
|
+
* (/wrap, /curate, /relay) into `~/.claude/skills/<name>/`.
|
|
4
|
+
*
|
|
5
|
+
* This is the I/O shell around the pure planner in src/skills.ts (same split as
|
|
6
|
+
* init.ts ↔ install.ts). All reads/writes/backups live here; the plan logic is
|
|
7
|
+
* pure and unit-tested. These are INTERACTIVE, user-invoked commands — not the
|
|
8
|
+
* hook path — so they may use the TTY and exit non-zero on user error
|
|
9
|
+
* (docs/SPECIFICATION.md §9). The one safety rule: never clobber a skill the
|
|
10
|
+
* user has edited without `--force`, and always back up before overwriting.
|
|
11
|
+
*/
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { createInterface } from "node:readline/promises";
|
|
17
|
+
import { makeAnsi } from "./ansi.js";
|
|
18
|
+
import { backupPath, stampNow } from "./backup.js";
|
|
19
|
+
import { COMPANION_SKILLS, SKILL_FILES, diffSkill, planInstall, planUninstall, } from "./skills.js";
|
|
20
|
+
/**
|
|
21
|
+
* Directory holding the bundled skill sources. The module lives at `src/` in
|
|
22
|
+
* dev/tests and `dist/src/` once published, so the relative depth to the
|
|
23
|
+
* package-root `skills/` differs — walk up from the module dir until we find a
|
|
24
|
+
* `skills/wrap/SKILL.md`, which is unambiguous in either layout.
|
|
25
|
+
*/
|
|
26
|
+
export function bundledSkillsDir() {
|
|
27
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
for (let i = 0; i < 6; i++) {
|
|
29
|
+
const candidate = join(dir, "skills");
|
|
30
|
+
if (existsSync(join(candidate, "wrap", "SKILL.md")))
|
|
31
|
+
return candidate;
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
// Fallback: best guess relative to the published layout (dist/src → root).
|
|
38
|
+
return fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
39
|
+
}
|
|
40
|
+
function skillDir(home, name) {
|
|
41
|
+
return join(home, ".claude", "skills", name);
|
|
42
|
+
}
|
|
43
|
+
/** Read a skill's bundled files. Throws if a shipped file is missing (a packaging bug). */
|
|
44
|
+
function readSources(sourceDir, name) {
|
|
45
|
+
const dir = join(sourceDir, name);
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const rel of SKILL_FILES[name])
|
|
48
|
+
out[rel] = readFileSync(join(dir, rel), "utf8");
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Read what's installed for a skill; a missing/unreadable file maps to `null`. */
|
|
52
|
+
function readInstalled(home, name) {
|
|
53
|
+
const dir = skillDir(home, name);
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const rel of SKILL_FILES[name]) {
|
|
56
|
+
try {
|
|
57
|
+
out[rel] = readFileSync(join(dir, rel), "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
out[rel] = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Install (copy) the requested skills, backing up any file an `--force`
|
|
67
|
+
* overwrite would replace. Pure-of-prompts: callers handle confirmation + I/O
|
|
68
|
+
* reporting. Returns one result per requested skill.
|
|
69
|
+
*/
|
|
70
|
+
export function installCompanionSkills(opts = {}) {
|
|
71
|
+
const home = opts.home ?? homedir();
|
|
72
|
+
const sourceDir = opts.sourceDir ?? bundledSkillsDir();
|
|
73
|
+
const names = opts.names ?? [...COMPANION_SKILLS];
|
|
74
|
+
const force = opts.force ?? false;
|
|
75
|
+
const dryRun = opts.dryRun ?? false;
|
|
76
|
+
const stamp = stampNow();
|
|
77
|
+
const results = [];
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const source = readSources(sourceDir, name);
|
|
80
|
+
const installed = readInstalled(home, name);
|
|
81
|
+
const plan = planInstall(name, source, installed, { force });
|
|
82
|
+
const backedUp = [];
|
|
83
|
+
let applied = false;
|
|
84
|
+
if (!dryRun && (plan.action === "install" || plan.action === "overwrite")) {
|
|
85
|
+
const dir = skillDir(home, name);
|
|
86
|
+
for (const rel of plan.writes) {
|
|
87
|
+
const dest = join(dir, rel);
|
|
88
|
+
if (plan.action === "overwrite" && installed[rel] != null) {
|
|
89
|
+
const bak = backupPath(dest, stamp);
|
|
90
|
+
try {
|
|
91
|
+
copyFileSync(dest, bak);
|
|
92
|
+
backedUp.push(bak);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* best-effort backup; never abort the install on a backup miss */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
99
|
+
writeFileSync(dest, source[rel], "utf8");
|
|
100
|
+
}
|
|
101
|
+
applied = true;
|
|
102
|
+
}
|
|
103
|
+
results.push({ plan, applied, backedUp });
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
function makeIo(env) {
|
|
108
|
+
const ansi = makeAnsi({ isTTY: Boolean(process.stdout.isTTY), env });
|
|
109
|
+
return { out: (s) => process.stdout.write(s + "\n"), ansi };
|
|
110
|
+
}
|
|
111
|
+
const STATUS_LABEL = {
|
|
112
|
+
absent: "not installed",
|
|
113
|
+
identical: "installed (up to date)",
|
|
114
|
+
differs: "installed (differs from shipped)",
|
|
115
|
+
};
|
|
116
|
+
export async function runSkills(opts) {
|
|
117
|
+
const env = opts.env ?? process.env;
|
|
118
|
+
const io = makeIo(env);
|
|
119
|
+
const { ansi } = io;
|
|
120
|
+
const home = opts.home ?? homedir();
|
|
121
|
+
const sourceDir = opts.sourceDir ?? bundledSkillsDir();
|
|
122
|
+
const names = opts.names && opts.names.length > 0 ? opts.names : [...COMPANION_SKILLS];
|
|
123
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY) && !opts.dryRun;
|
|
124
|
+
if (opts.subcommand === "list") {
|
|
125
|
+
io.out(ansi.bold("memhook companion skills"));
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const status = diffSkill(readSources(sourceDir, name), readInstalled(home, name));
|
|
128
|
+
const dot = status === "identical"
|
|
129
|
+
? ansi.green("●")
|
|
130
|
+
: status === "differs"
|
|
131
|
+
? ansi.yellow("●")
|
|
132
|
+
: ansi.dim("○");
|
|
133
|
+
io.out(` ${dot} /${name} ${ansi.dim(`— ${STATUS_LABEL[status]}`)}`);
|
|
134
|
+
io.out(` ${ansi.dim(skillDir(home, name))}`);
|
|
135
|
+
}
|
|
136
|
+
io.out(ansi.dim("\nInstall with `memhook skills install`. Invoke as /wrap, /curate, /relay."));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (opts.subcommand === "uninstall") {
|
|
140
|
+
const plans = names.map((name) => planUninstall(name, readSources(sourceDir, name), readInstalled(home, name)));
|
|
141
|
+
const toRemove = plans.filter((p) => p.present);
|
|
142
|
+
io.out(ansi.bold("memhook skills uninstall") + ansi.dim(" — remove bundled companion skills\n"));
|
|
143
|
+
if (toRemove.length === 0) {
|
|
144
|
+
io.out(ansi.dim("No memhook companion skills found. Nothing to do."));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
io.out(ansi.bold("Plan"));
|
|
148
|
+
for (const p of plans) {
|
|
149
|
+
if (p.present) {
|
|
150
|
+
io.out(` ${ansi.red("-")} /${p.name} ${ansi.dim(`(${p.removes.length} file(s))`)}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
io.out(` ${ansi.dim("·")} /${p.name} ${ansi.dim("(not installed — skip)")}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (opts.dryRun) {
|
|
157
|
+
io.out(ansi.dim("\n(dry run — nothing removed)"));
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (interactive && !(await confirm(ansi, "[y/N]", false))) {
|
|
161
|
+
io.out(ansi.dim("Aborted. Nothing removed."));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
const stamp = stampNow();
|
|
165
|
+
const skillsRoot = join(home, ".claude", "skills");
|
|
166
|
+
let removed = 0;
|
|
167
|
+
for (const p of toRemove) {
|
|
168
|
+
const dir = skillDir(home, p.name);
|
|
169
|
+
const source = readSources(sourceDir, p.name);
|
|
170
|
+
for (const rel of p.removes) {
|
|
171
|
+
const target = join(dir, rel);
|
|
172
|
+
// Back up only a file the user edited (differs from shipped), and place
|
|
173
|
+
// the backup OUTSIDE the skill dir so the dir can be cleanly removed. A
|
|
174
|
+
// pristine shipped file is just deleted — it's recoverable by reinstall.
|
|
175
|
+
let content = null;
|
|
176
|
+
try {
|
|
177
|
+
content = readFileSync(target, "utf8");
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* already gone */
|
|
181
|
+
}
|
|
182
|
+
if (content !== null && content !== source[rel]) {
|
|
183
|
+
const bak = join(skillsRoot, `${p.name}.${rel.replace(/[\\/]/g, "_")}.bak-${stamp}`);
|
|
184
|
+
try {
|
|
185
|
+
copyFileSync(target, bak);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* best-effort backup */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(target);
|
|
193
|
+
removed++;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* already gone */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
rmdirSync(dir); // only succeeds if the dir is now empty — leaves user files alone
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* non-empty or missing — fine */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
io.out(`${ansi.green("✓")} removed ${removed} file(s) from ${toRemove.length} skill(s)`);
|
|
207
|
+
io.out(ansi.dim("Restart Claude Code to drop the skills from the menu."));
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
// install
|
|
211
|
+
io.out(ansi.bold("memhook skills install") + ansi.dim(" — copy /wrap, /curate, /relay\n"));
|
|
212
|
+
const preview = installCompanionSkills({
|
|
213
|
+
home,
|
|
214
|
+
sourceDir,
|
|
215
|
+
names,
|
|
216
|
+
force: opts.force,
|
|
217
|
+
dryRun: true,
|
|
218
|
+
});
|
|
219
|
+
const willWrite = preview.filter((r) => r.plan.action === "install" || r.plan.action === "overwrite");
|
|
220
|
+
const blocked = preview.filter((r) => r.plan.action === "blocked");
|
|
221
|
+
io.out(ansi.bold("Plan"));
|
|
222
|
+
for (const r of preview) {
|
|
223
|
+
const { name, action, status } = r.plan;
|
|
224
|
+
if (action === "install")
|
|
225
|
+
io.out(` ${ansi.green("+")} /${name} ${ansi.dim("(new)")}`);
|
|
226
|
+
else if (action === "overwrite")
|
|
227
|
+
io.out(` ${ansi.yellow("~")} /${name} ${ansi.dim("(overwrite — backup first)")}`);
|
|
228
|
+
else if (action === "skip")
|
|
229
|
+
io.out(` ${ansi.dim("·")} /${name} ${ansi.dim("(up to date — skip)")}`);
|
|
230
|
+
else
|
|
231
|
+
io.out(` ${ansi.yellow("!")} /${name} ${ansi.dim(`(${STATUS_LABEL[status]} — use --force to overwrite)`)}`);
|
|
232
|
+
}
|
|
233
|
+
if (willWrite.length === 0) {
|
|
234
|
+
if (blocked.length > 0) {
|
|
235
|
+
io.out(ansi.dim(`\n${blocked.length} skill(s) differ from shipped. Re-run with --force to overwrite (a backup is made first).`));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
io.out(ansi.dim("\nAll skills are already up to date. Nothing to do."));
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (opts.dryRun) {
|
|
243
|
+
io.out(ansi.dim("\n(dry run — nothing written)"));
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
if (interactive && !(await confirm(ansi, "[Y/n]", true))) {
|
|
247
|
+
io.out(ansi.dim("Aborted. Nothing written."));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
const results = installCompanionSkills({ home, sourceDir, names, force: opts.force });
|
|
251
|
+
const applied = results.filter((r) => r.applied);
|
|
252
|
+
io.out(`${ansi.green("✓")} installed ${applied.length} skill(s) into ${skillDir(home, "<name>")}`);
|
|
253
|
+
if (blocked.length > 0) {
|
|
254
|
+
io.out(ansi.dim(` ${blocked.length} skill(s) left untouched (differ from shipped — use --force).`));
|
|
255
|
+
}
|
|
256
|
+
io.out(ansi.dim("Restart Claude Code, then use /wrap, /curate, /relay."));
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
async function confirm(ansi, hint, defaultYes) {
|
|
260
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
261
|
+
try {
|
|
262
|
+
const a = (await rl.question(`\n${ansi.bold("Proceed?")} ${ansi.dim(hint)} `))
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase();
|
|
265
|
+
if (a === "")
|
|
266
|
+
return defaultYes;
|
|
267
|
+
return a === "y" || a === "yes";
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
rl.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=skillsCmd.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skillsCmd.js","sourceRoot":"","sources":["../../src/skillsCmd.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memhook tail` — a pretty, live view of the JSONL routing log.
|
|
3
|
+
*
|
|
4
|
+
* This is the human-facing twin of `tail -f ~/.claude/logs/memhook.log | jq`:
|
|
5
|
+
* it reads the SAME frozen log the router writes (docs/SPECIFICATION.md §14 —
|
|
6
|
+
* "the memhook tail TUI parses it"), and renders each routing decision as a
|
|
7
|
+
* colourised row. It only ever READS the log, so it can never affect the hook.
|
|
8
|
+
*
|
|
9
|
+
* No TUI framework, no dependency: the layout is plain column padding + the
|
|
10
|
+
* `src/ansi.ts` styler, which degrades to clean text when stdout is not a TTY
|
|
11
|
+
* (so `memhook tail --no-follow > report.txt` is well-behaved).
|
|
12
|
+
*
|
|
13
|
+
* Pure rendering (`parseLogLine`, `formatRow`, the stats reducer) is split from
|
|
14
|
+
* the follow-loop shell so the formatting is unit-tested without a terminal.
|
|
15
|
+
*/
|
|
16
|
+
import { type Ansi } from "./ansi.js";
|
|
17
|
+
export interface LogRow {
|
|
18
|
+
ts: string;
|
|
19
|
+
promptPreview: string;
|
|
20
|
+
selected: string[];
|
|
21
|
+
latencyMs: number;
|
|
22
|
+
tokensIn: number;
|
|
23
|
+
tokensOut: number;
|
|
24
|
+
status: string;
|
|
25
|
+
/** Provider model recorded for the entry (added v0.3); null on older lines. */
|
|
26
|
+
model: string | null;
|
|
27
|
+
}
|
|
28
|
+
/** Parse one JSONL line into a `LogRow`, or null if it isn't a usable entry. */
|
|
29
|
+
export declare function parseLogLine(line: string): LogRow | null;
|
|
30
|
+
/** HH:MM:SS from an ISO timestamp, or the raw value clipped if unparseable. */
|
|
31
|
+
export declare function formatTime(ts: string): string;
|
|
32
|
+
/** Compact latency: "142ms" under a second, "2.0s" above. */
|
|
33
|
+
export declare function formatLatency(ms: number): string;
|
|
34
|
+
/** Right-most column: which model handled the entry (mirrors the mockup). */
|
|
35
|
+
export declare function modelCell(row: LogRow): string;
|
|
36
|
+
/**
|
|
37
|
+
* Render one log row to one or two lines: the primary row (time · status ·
|
|
38
|
+
* prompt · latency · model) plus, when memories were injected, a dim secondary
|
|
39
|
+
* line listing them — the whole reason memhook exists is to see WHICH memories
|
|
40
|
+
* were picked.
|
|
41
|
+
*/
|
|
42
|
+
export declare function formatRow(row: LogRow, ansi: Ansi, columns?: number): string;
|
|
43
|
+
export interface Stats {
|
|
44
|
+
count: number;
|
|
45
|
+
byStatus: Record<string, number>;
|
|
46
|
+
latencies: number[];
|
|
47
|
+
}
|
|
48
|
+
export declare function emptyStats(): Stats;
|
|
49
|
+
export declare function accumulate(stats: Stats, row: LogRow): void;
|
|
50
|
+
export interface StatsSummary {
|
|
51
|
+
count: number;
|
|
52
|
+
cachePct: number;
|
|
53
|
+
okPct: number;
|
|
54
|
+
p50: number;
|
|
55
|
+
p95: number;
|
|
56
|
+
}
|
|
57
|
+
export declare function summarize(stats: Stats): StatsSummary;
|
|
58
|
+
export interface HeaderInfo {
|
|
59
|
+
version: string;
|
|
60
|
+
provider: string;
|
|
61
|
+
model: string;
|
|
62
|
+
logPath: string;
|
|
63
|
+
follow: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare function formatHeader(info: HeaderInfo, ansi: Ansi, columns?: number): string;
|
|
66
|
+
export declare function formatFooter(stats: Stats, ansi: Ansi, columns?: number): string;
|
|
67
|
+
/** Last `n` non-empty lines of `text`. */
|
|
68
|
+
export declare function tailLines(text: string, n: number): string[];
|
|
69
|
+
export interface TailOptions {
|
|
70
|
+
file?: string | undefined;
|
|
71
|
+
lines: number;
|
|
72
|
+
noFollow: boolean;
|
|
73
|
+
status?: string[] | undefined;
|
|
74
|
+
}
|
|
75
|
+
export declare function runTail(opts: TailOptions, env?: NodeJS.ProcessEnv): Promise<number>;
|
|
76
|
+
//# sourceMappingURL=tail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tail.d.ts","sourceRoot":"","sources":["../../src/tail.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,EAAwC,KAAK,IAAI,EAAkB,MAAM,WAAW,CAAC;AAI5F,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAMD,gFAAgF;AAChF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuBxD;AA6BD,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAG7C;AAED,6DAA6D;AAC7D,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAIhD;AAED,6EAA6E;AAC7E,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM7C;AAKD;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,SAAK,GAAG,MAAM,CAuBvE;AAID,MAAM,WAAW,KAAK;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAED,wBAAgB,UAAU,IAAI,KAAK,CAElC;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAM1D;AAQD,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,KAAK,GAAG,YAAY,CAYpD;AAQD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;CACjB;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,SAAK,GAAG,MAAM,CAY/E;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,SAAK,GAAG,MAAM,CAW3E;AAID,0CAA0C;AAC1C,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAG3D;AAQD,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAC;CAC/B;AAoBD,wBAAsB,OAAO,CAC3B,IAAI,EAAE,WAAW,EACjB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,OAAO,CAAC,MAAM,CAAC,CA4EjB"}
|
package/dist/src/tail.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memhook tail` — a pretty, live view of the JSONL routing log.
|
|
3
|
+
*
|
|
4
|
+
* This is the human-facing twin of `tail -f ~/.claude/logs/memhook.log | jq`:
|
|
5
|
+
* it reads the SAME frozen log the router writes (docs/SPECIFICATION.md §14 —
|
|
6
|
+
* "the memhook tail TUI parses it"), and renders each routing decision as a
|
|
7
|
+
* colourised row. It only ever READS the log, so it can never affect the hook.
|
|
8
|
+
*
|
|
9
|
+
* No TUI framework, no dependency: the layout is plain column padding + the
|
|
10
|
+
* `src/ansi.ts` styler, which degrades to clean text when stdout is not a TTY
|
|
11
|
+
* (so `memhook tail --no-follow > report.txt` is well-behaved).
|
|
12
|
+
*
|
|
13
|
+
* Pure rendering (`parseLogLine`, `formatRow`, the stats reducer) is split from
|
|
14
|
+
* the follow-loop shell so the formatting is unit-tested without a terminal.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, openSync, readSync, closeSync, readFileSync, statSync } from "node:fs";
|
|
17
|
+
import { loadConfig } from "./config.js";
|
|
18
|
+
import { MEMHOOK_VERSION } from "./version.js";
|
|
19
|
+
import { makeAnsi, padStart, padEnd, truncate } from "./ansi.js";
|
|
20
|
+
function asInt(v) {
|
|
21
|
+
return typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : 0;
|
|
22
|
+
}
|
|
23
|
+
/** Parse one JSONL line into a `LogRow`, or null if it isn't a usable entry. */
|
|
24
|
+
export function parseLogLine(line) {
|
|
25
|
+
let obj;
|
|
26
|
+
try {
|
|
27
|
+
obj = JSON.parse(line);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj))
|
|
33
|
+
return null;
|
|
34
|
+
const o = obj;
|
|
35
|
+
if (typeof o["status"] !== "string")
|
|
36
|
+
return null;
|
|
37
|
+
const selected = Array.isArray(o["selected"])
|
|
38
|
+
? o["selected"].filter((x) => typeof x === "string")
|
|
39
|
+
: [];
|
|
40
|
+
return {
|
|
41
|
+
ts: typeof o["ts"] === "string" ? o["ts"] : "",
|
|
42
|
+
promptPreview: typeof o["prompt_preview"] === "string" ? o["prompt_preview"] : "",
|
|
43
|
+
selected,
|
|
44
|
+
latencyMs: asInt(o["latency_ms"]),
|
|
45
|
+
tokensIn: asInt(o["tokens_in"]),
|
|
46
|
+
tokensOut: asInt(o["tokens_out"]),
|
|
47
|
+
status: o["status"],
|
|
48
|
+
model: typeof o["model"] === "string" ? o["model"] : null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const STATUS_STYLE = {
|
|
52
|
+
ok: { label: "ok", color: "green" },
|
|
53
|
+
cache_hit: { label: "cache", color: "cyan" },
|
|
54
|
+
pre_filter_skip: { label: "skip", color: "gray" },
|
|
55
|
+
empty_selection: { label: "empty", color: "yellow" },
|
|
56
|
+
all_unfound: { label: "unfound", color: "yellow" },
|
|
57
|
+
no_catalog: { label: "no catalog", color: "red" },
|
|
58
|
+
no_api_key: { label: "no key", color: "red" },
|
|
59
|
+
api_no_response: { label: "api err", color: "red" },
|
|
60
|
+
api_no_content: { label: "api err", color: "red" },
|
|
61
|
+
parse_invalid: { label: "bad json", color: "red" },
|
|
62
|
+
provider_init_failed: { label: "init err", color: "red" },
|
|
63
|
+
};
|
|
64
|
+
function styleFor(status) {
|
|
65
|
+
return STATUS_STYLE[status] ?? { label: status, color: "gray" };
|
|
66
|
+
}
|
|
67
|
+
const STATUS_W = Math.max(...Object.values(STATUS_STYLE).map((s) => s.label.length));
|
|
68
|
+
/** HH:MM:SS from an ISO timestamp, or the raw value clipped if unparseable. */
|
|
69
|
+
export function formatTime(ts) {
|
|
70
|
+
const m = ts.match(/T(\d{2}:\d{2}:\d{2})/);
|
|
71
|
+
return m?.[1] ?? truncate(ts, 8).padEnd(8);
|
|
72
|
+
}
|
|
73
|
+
/** Compact latency: "142ms" under a second, "2.0s" above. */
|
|
74
|
+
export function formatLatency(ms) {
|
|
75
|
+
if (ms <= 0)
|
|
76
|
+
return "0ms";
|
|
77
|
+
if (ms < 1000)
|
|
78
|
+
return `${ms}ms`;
|
|
79
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
80
|
+
}
|
|
81
|
+
/** Right-most column: which model handled the entry (mirrors the mockup). */
|
|
82
|
+
export function modelCell(row) {
|
|
83
|
+
if (row.status === "pre_filter_skip")
|
|
84
|
+
return "dropped";
|
|
85
|
+
if (row.status === "cache_hit")
|
|
86
|
+
return "—";
|
|
87
|
+
// Statuses that never reached the provider have no model to show.
|
|
88
|
+
if (row.status === "no_catalog" || row.status === "no_api_key")
|
|
89
|
+
return "—";
|
|
90
|
+
return row.model ?? "—";
|
|
91
|
+
}
|
|
92
|
+
const LAT_W = 6;
|
|
93
|
+
const MODEL_W = 18;
|
|
94
|
+
/**
|
|
95
|
+
* Render one log row to one or two lines: the primary row (time · status ·
|
|
96
|
+
* prompt · latency · model) plus, when memories were injected, a dim secondary
|
|
97
|
+
* line listing them — the whole reason memhook exists is to see WHICH memories
|
|
98
|
+
* were picked.
|
|
99
|
+
*/
|
|
100
|
+
export function formatRow(row, ansi, columns = 80) {
|
|
101
|
+
const st = styleFor(row.status);
|
|
102
|
+
const time = ansi.dim(formatTime(row.ts));
|
|
103
|
+
const status = ansi.style(st.color, padEnd(st.label, STATUS_W));
|
|
104
|
+
const latency = ansi.dim(padStart(formatLatency(row.latencyMs), LAT_W));
|
|
105
|
+
const model = ansi.dim(truncate(modelCell(row), MODEL_W));
|
|
106
|
+
// Prompt preview fills the space left between the fixed columns. The budget
|
|
107
|
+
// is constant per row (5 two-col gaps + time + status + latency + a reserved
|
|
108
|
+
// model column) so latency and model line up vertically across every row —
|
|
109
|
+
// the preview never borrows from the model column's width.
|
|
110
|
+
const fixed = 2 * 5 + 8 + STATUS_W + LAT_W + MODEL_W;
|
|
111
|
+
const previewW = Math.max(12, columns - fixed);
|
|
112
|
+
const preview = row.promptPreview
|
|
113
|
+
? ansi.style("white", `"${truncate(row.promptPreview, previewW - 2)}"`)
|
|
114
|
+
: ansi.dim('""');
|
|
115
|
+
const main = ` ${time} ${status} ${padEnd(preview, previewW)} ${latency} ${model}`;
|
|
116
|
+
if (row.selected.length === 0)
|
|
117
|
+
return main;
|
|
118
|
+
const trimmed = row.selected.map((f) => f.replace(/\.md$/, ""));
|
|
119
|
+
const files = ansi.dim(` ↳ ${truncate(trimmed.join(" · "), Math.max(20, columns - 8))}`);
|
|
120
|
+
return `${main}\n${files}`;
|
|
121
|
+
}
|
|
122
|
+
export function emptyStats() {
|
|
123
|
+
return { count: 0, byStatus: {}, latencies: [] };
|
|
124
|
+
}
|
|
125
|
+
export function accumulate(stats, row) {
|
|
126
|
+
stats.count++;
|
|
127
|
+
stats.byStatus[row.status] = (stats.byStatus[row.status] ?? 0) + 1;
|
|
128
|
+
// Percentiles only over rows that actually called the provider (skips/cache
|
|
129
|
+
// hits sit near 0ms and would flatten the distribution).
|
|
130
|
+
if (row.latencyMs > 0)
|
|
131
|
+
stats.latencies.push(row.latencyMs);
|
|
132
|
+
}
|
|
133
|
+
function percentile(sorted, p) {
|
|
134
|
+
if (sorted.length === 0)
|
|
135
|
+
return 0;
|
|
136
|
+
const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
|
137
|
+
return sorted[Math.max(0, idx)] ?? 0;
|
|
138
|
+
}
|
|
139
|
+
export function summarize(stats) {
|
|
140
|
+
const ok = stats.byStatus["ok"] ?? 0;
|
|
141
|
+
const cache = stats.byStatus["cache_hit"] ?? 0;
|
|
142
|
+
const delivered = ok + cache;
|
|
143
|
+
const sorted = [...stats.latencies].sort((a, b) => a - b);
|
|
144
|
+
return {
|
|
145
|
+
count: stats.count,
|
|
146
|
+
cachePct: delivered > 0 ? Math.round((cache / delivered) * 100) : 0,
|
|
147
|
+
okPct: stats.count > 0 ? Math.round((delivered / stats.count) * 100) : 0,
|
|
148
|
+
p50: percentile(sorted, 50),
|
|
149
|
+
p95: percentile(sorted, 95),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// ── header / footer ──────────────────────────────────────────────────────────
|
|
153
|
+
function divider(ansi, columns) {
|
|
154
|
+
return ansi.dim(" " + "─".repeat(Math.max(10, Math.min(columns - 2, 72))));
|
|
155
|
+
}
|
|
156
|
+
export function formatHeader(info, ansi, columns = 80) {
|
|
157
|
+
const live = info.follow ? ansi.green("● live") : ansi.dim("○ snapshot");
|
|
158
|
+
const title = ansi.bold("memhook");
|
|
159
|
+
const who = ansi.dim(`${info.provider} · ${info.model}`);
|
|
160
|
+
const ver = ansi.dim(`v${info.version}`);
|
|
161
|
+
const lines = [
|
|
162
|
+
` ${title} ${live} ${who} ${ver}`,
|
|
163
|
+
ansi.dim(` ${info.logPath}`),
|
|
164
|
+
divider(ansi, columns),
|
|
165
|
+
];
|
|
166
|
+
if (info.follow)
|
|
167
|
+
lines.push(ansi.dim(" ⌃C to quit"));
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
export function formatFooter(stats, ansi, columns = 80) {
|
|
171
|
+
const s = summarize(stats);
|
|
172
|
+
const noun = s.count === 1 ? "prompt" : "prompts";
|
|
173
|
+
const parts = [
|
|
174
|
+
ansi.bold(`${s.count} ${noun}`),
|
|
175
|
+
`${s.cachePct}% cache`,
|
|
176
|
+
`${s.okPct}% delivered`,
|
|
177
|
+
`p50 ${s.p50}ms`,
|
|
178
|
+
`p95 ${s.p95}ms`,
|
|
179
|
+
];
|
|
180
|
+
return `${divider(ansi, columns)}\n ${parts.join(ansi.dim(" · "))}`;
|
|
181
|
+
}
|
|
182
|
+
// ── pure helpers shared with the follow loop ────────────────────────────────
|
|
183
|
+
/** Last `n` non-empty lines of `text`. */
|
|
184
|
+
export function tailLines(text, n) {
|
|
185
|
+
const lines = text.split("\n").filter((l) => l.trim() !== "");
|
|
186
|
+
return n >= lines.length ? lines : lines.slice(lines.length - n);
|
|
187
|
+
}
|
|
188
|
+
function passesFilter(row, filter) {
|
|
189
|
+
return filter === null || filter.has(row.status);
|
|
190
|
+
}
|
|
191
|
+
const POLL_MS = 300;
|
|
192
|
+
function sleep(ms) {
|
|
193
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
194
|
+
}
|
|
195
|
+
/** Read `len` bytes at `offset` from `path` as utf8 (best-effort). */
|
|
196
|
+
function readChunk(path, offset, len) {
|
|
197
|
+
const fd = openSync(path, "r");
|
|
198
|
+
try {
|
|
199
|
+
const buf = Buffer.alloc(len);
|
|
200
|
+
const read = readSync(fd, buf, 0, len, offset);
|
|
201
|
+
return buf.toString("utf8", 0, read);
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
closeSync(fd);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export async function runTail(opts, env = process.env) {
|
|
208
|
+
const config = loadConfig(env);
|
|
209
|
+
const logPath = opts.file ?? config.logging.jsonlPath;
|
|
210
|
+
const ansi = makeAnsi({ isTTY: Boolean(process.stdout.isTTY), env });
|
|
211
|
+
const columns = process.stdout.columns ?? 80;
|
|
212
|
+
const filter = opts.status && opts.status.length > 0 ? new Set(opts.status) : null;
|
|
213
|
+
const out = (s) => void process.stdout.write(s + "\n");
|
|
214
|
+
out(formatHeader({
|
|
215
|
+
version: MEMHOOK_VERSION,
|
|
216
|
+
provider: config.provider.type,
|
|
217
|
+
model: config.provider.model,
|
|
218
|
+
logPath,
|
|
219
|
+
follow: !opts.noFollow,
|
|
220
|
+
}, ansi, columns));
|
|
221
|
+
const stats = emptyStats();
|
|
222
|
+
const render = (line) => {
|
|
223
|
+
const row = parseLogLine(line);
|
|
224
|
+
if (!row)
|
|
225
|
+
return;
|
|
226
|
+
accumulate(stats, row);
|
|
227
|
+
if (passesFilter(row, filter))
|
|
228
|
+
out(formatRow(row, ansi, columns));
|
|
229
|
+
};
|
|
230
|
+
// Initial tail of the existing log.
|
|
231
|
+
if (existsSync(logPath)) {
|
|
232
|
+
for (const line of tailLines(readFileSync(logPath, "utf8"), opts.lines))
|
|
233
|
+
render(line);
|
|
234
|
+
}
|
|
235
|
+
if (opts.noFollow) {
|
|
236
|
+
out(formatFooter(stats, ansi, columns));
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
// Follow by polling (robust + cross-platform; fs.watch semantics vary by OS).
|
|
240
|
+
let stop = false;
|
|
241
|
+
const stopped = new Promise((resolve) => {
|
|
242
|
+
process.once("SIGINT", () => {
|
|
243
|
+
stop = true;
|
|
244
|
+
resolve();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
if (!existsSync(logPath))
|
|
248
|
+
out(ansi.dim(" waiting for the first prompt…"));
|
|
249
|
+
let offset = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
250
|
+
let buffer = "";
|
|
251
|
+
while (!stop) {
|
|
252
|
+
await Promise.race([sleep(POLL_MS), stopped]);
|
|
253
|
+
if (stop)
|
|
254
|
+
break;
|
|
255
|
+
try {
|
|
256
|
+
if (!existsSync(logPath))
|
|
257
|
+
continue;
|
|
258
|
+
const size = statSync(logPath).size;
|
|
259
|
+
if (size < offset) {
|
|
260
|
+
offset = 0; // truncated / rotated
|
|
261
|
+
buffer = "";
|
|
262
|
+
}
|
|
263
|
+
if (size > offset) {
|
|
264
|
+
buffer += readChunk(logPath, offset, size - offset);
|
|
265
|
+
offset = size;
|
|
266
|
+
const parts = buffer.split("\n");
|
|
267
|
+
buffer = parts.pop() ?? "";
|
|
268
|
+
for (const line of parts)
|
|
269
|
+
if (line.trim() !== "")
|
|
270
|
+
render(line);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Transient (file rotated out from under us) — retry next tick.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
out("\n" + formatFooter(stats, ansi, columns));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=tail.js.map
|