mnemos-capture 1.1.0 → 1.2.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/README.md +182 -113
- package/dist/cli/__tests__/config.test.js +49 -0
- package/dist/cli/__tests__/hooks.test.js +276 -0
- package/dist/cli/__tests__/kos.test.js +65 -0
- package/dist/cli/config.js +70 -0
- package/dist/cli/hooks.js +144 -5
- package/dist/cli/index.js +57 -7
- package/dist/cli/kos.js +123 -0
- package/package.json +7 -4
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
6
|
+
/** Capture stdout writes during a callback. */
|
|
7
|
+
async function captureStdout(fn) {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
const orig = process.stdout.write.bind(process.stdout);
|
|
10
|
+
process.stdout.write = (chunk) => {
|
|
11
|
+
chunks.push(String(chunk));
|
|
12
|
+
return true;
|
|
13
|
+
};
|
|
14
|
+
try {
|
|
15
|
+
await fn();
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
process.stdout.write = orig;
|
|
19
|
+
}
|
|
20
|
+
return chunks.join("");
|
|
21
|
+
}
|
|
22
|
+
// ── setupHooks / setupPreToolHook ────────────────────────────────────────────
|
|
23
|
+
describe("setupHooks", () => {
|
|
24
|
+
let tmpDir;
|
|
25
|
+
let settingsPath;
|
|
26
|
+
let origHome;
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpDir = mkdtempSync(join(tmpdir(), "mnemos-test-"));
|
|
29
|
+
settingsPath = join(tmpDir, ".claude", "settings.json");
|
|
30
|
+
origHome = process.env["HOME"];
|
|
31
|
+
process.env["HOME"] = tmpDir;
|
|
32
|
+
// Clear module cache so CLAUDE_SETTINGS_PATH is recomputed with the new HOME
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
+
process.env["HOME"] = origHome;
|
|
38
|
+
});
|
|
39
|
+
it("installs a SessionStart hook with the provided API key", async () => {
|
|
40
|
+
const { setupHooks } = await import("../hooks.js");
|
|
41
|
+
setupHooks("my-api-key", { silent: true });
|
|
42
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
43
|
+
const cmd = settings.hooks.SessionStart[0]?.hooks[0]?.command ?? "";
|
|
44
|
+
expect(cmd).toContain("inbox-check");
|
|
45
|
+
expect(cmd).toContain("my-api-key");
|
|
46
|
+
});
|
|
47
|
+
it("includes --briefing flag when briefing option is true", async () => {
|
|
48
|
+
const { setupHooks } = await import("../hooks.js");
|
|
49
|
+
setupHooks("key123", { silent: true, briefing: true });
|
|
50
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
51
|
+
const cmd = settings.hooks.SessionStart[0]?.hooks[0]?.command ?? "";
|
|
52
|
+
expect(cmd).toContain("--briefing");
|
|
53
|
+
});
|
|
54
|
+
it("omits --briefing flag when briefing option is false", async () => {
|
|
55
|
+
const { setupHooks } = await import("../hooks.js");
|
|
56
|
+
setupHooks("key123", { silent: true, briefing: false });
|
|
57
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
58
|
+
const cmd = settings.hooks.SessionStart[0]?.hooks[0]?.command ?? "";
|
|
59
|
+
expect(cmd).not.toContain("--briefing");
|
|
60
|
+
});
|
|
61
|
+
it("also installs PreToolCall hook when vault option is true", async () => {
|
|
62
|
+
const { setupHooks } = await import("../hooks.js");
|
|
63
|
+
setupHooks("vault-key", { silent: true, vault: true });
|
|
64
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
65
|
+
expect(settings.hooks.PreToolCall).toBeDefined();
|
|
66
|
+
const cmd = settings.hooks.PreToolCall[0]?.hooks[0]?.command ?? "";
|
|
67
|
+
expect(cmd).toContain("vault-check");
|
|
68
|
+
expect(cmd).toContain("vault-key");
|
|
69
|
+
});
|
|
70
|
+
it("does NOT install PreToolCall hook when vault option is false", async () => {
|
|
71
|
+
const { setupHooks } = await import("../hooks.js");
|
|
72
|
+
setupHooks("no-vault-key", { silent: true, vault: false });
|
|
73
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
74
|
+
expect(settings.hooks["PreToolCall"]).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it("replaces an existing mnemos hook rather than duplicating it", async () => {
|
|
77
|
+
const { setupHooks } = await import("../hooks.js");
|
|
78
|
+
setupHooks("old-key", { silent: true });
|
|
79
|
+
setupHooks("new-key", { silent: true });
|
|
80
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
81
|
+
const cmds = settings.hooks.SessionStart.flatMap((g) => g.hooks.map((h) => h.command));
|
|
82
|
+
const mnemosHooks = cmds.filter((c) => c.includes("inbox-check"));
|
|
83
|
+
expect(mnemosHooks).toHaveLength(1);
|
|
84
|
+
expect(mnemosHooks[0]).toContain("new-key");
|
|
85
|
+
});
|
|
86
|
+
it("skipIfExists: keeps exactly one mnemos hook when called twice", async () => {
|
|
87
|
+
const { setupHooks } = await import("../hooks.js");
|
|
88
|
+
setupHooks("original-key", { silent: true });
|
|
89
|
+
setupHooks("different-key", { silent: true, skipIfExists: true });
|
|
90
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
91
|
+
const cmds = settings.hooks.SessionStart.flatMap((g) => g.hooks.map((h) => h.command));
|
|
92
|
+
expect(cmds.filter((c) => c.includes("inbox-check"))).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
// ── inboxCheck — briefing mode parsing ───────────────────────────────────────
|
|
96
|
+
describe("inboxCheck — briefing mode", () => {
|
|
97
|
+
let fetchSpy;
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
100
|
+
});
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
fetchSpy.mockRestore();
|
|
103
|
+
});
|
|
104
|
+
function makeMcpResponse(text) {
|
|
105
|
+
return Promise.resolve(new Response(JSON.stringify({ result: { content: [{ text }] } }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
106
|
+
}
|
|
107
|
+
it("strips the JSON block from display output", async () => {
|
|
108
|
+
fetchSpy.mockReturnValue(makeMcpResponse(`\`\`\`json
|
|
109
|
+
[{"filename":"inbox/a.md","why":"x","benefit":"y","where":"z.ts","applyNow":true}]
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
## Relevant Rules
|
|
113
|
+
None applicable.`));
|
|
114
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
115
|
+
const output = await captureStdout(() => inboxCheck("key", { briefing: true }));
|
|
116
|
+
expect(output).not.toContain("```json");
|
|
117
|
+
expect(output).toContain("## Relevant Rules");
|
|
118
|
+
});
|
|
119
|
+
it("appends generate_plan prompt when there are applyNow suggestions", async () => {
|
|
120
|
+
fetchSpy.mockReturnValue(makeMcpResponse(`\`\`\`json
|
|
121
|
+
[{"filename":"inbox/a.md","why":"x","benefit":"y","where":"z.ts","applyNow":true}]
|
|
122
|
+
\`\`\`
|
|
123
|
+
|
|
124
|
+
## Apply Now
|
|
125
|
+
One insight.`));
|
|
126
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
127
|
+
const output = await captureStdout(() => inboxCheck("key", { briefing: true }));
|
|
128
|
+
expect(output).toContain("generate_plan");
|
|
129
|
+
expect(output).toContain("inbox/a.md");
|
|
130
|
+
});
|
|
131
|
+
it("does NOT append generate_plan prompt when all suggestions have applyNow=false", async () => {
|
|
132
|
+
fetchSpy.mockReturnValue(makeMcpResponse(`\`\`\`json
|
|
133
|
+
[{"filename":"inbox/b.md","why":"x","benefit":"y","where":"z.ts","applyNow":false}]
|
|
134
|
+
\`\`\`
|
|
135
|
+
|
|
136
|
+
## Knowledge Vault
|
|
137
|
+
One vault item.`));
|
|
138
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
139
|
+
const output = await captureStdout(() => inboxCheck("key", { briefing: true }));
|
|
140
|
+
expect(output).not.toContain("generate_plan");
|
|
141
|
+
});
|
|
142
|
+
it("does NOT append generate_plan prompt when JSON block is missing", async () => {
|
|
143
|
+
fetchSpy.mockReturnValue(makeMcpResponse(`## Relevant Rules
|
|
144
|
+
None applicable.`));
|
|
145
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
146
|
+
const output = await captureStdout(() => inboxCheck("key", { briefing: true }));
|
|
147
|
+
expect(output).not.toContain("generate_plan");
|
|
148
|
+
});
|
|
149
|
+
it("shows inbox count in non-briefing mode when count > 0", async () => {
|
|
150
|
+
fetchSpy.mockReturnValue(makeMcpResponse("3 capture(s) in inbox:\n\n1. file.md"));
|
|
151
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
152
|
+
const output = await captureStdout(() => inboxCheck("key"));
|
|
153
|
+
expect(output).toContain("3");
|
|
154
|
+
expect(output).toContain("inbox");
|
|
155
|
+
});
|
|
156
|
+
it("shows nothing in non-briefing mode when inbox is empty", async () => {
|
|
157
|
+
fetchSpy.mockReturnValue(makeMcpResponse("Inbox is empty."));
|
|
158
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
159
|
+
const output = await captureStdout(() => inboxCheck("key"));
|
|
160
|
+
expect(output).toBe("");
|
|
161
|
+
});
|
|
162
|
+
it("silently fails when fetch throws", async () => {
|
|
163
|
+
fetchSpy.mockRejectedValue(new Error("Network error"));
|
|
164
|
+
const { inboxCheck } = await import("../hooks.js");
|
|
165
|
+
// Should not throw
|
|
166
|
+
await expect(inboxCheck("key", { briefing: true })).resolves.toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
// ── vaultCheck — stdin parsing ────────────────────────────────────────────────
|
|
170
|
+
describe("vaultCheck", () => {
|
|
171
|
+
let fetchSpy;
|
|
172
|
+
let origSessionId;
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
fetchSpy = vi.spyOn(globalThis, "fetch");
|
|
175
|
+
origSessionId = process.env["CLAUDE_SESSION_ID"];
|
|
176
|
+
// Use a unique session ID per test to avoid state pollution
|
|
177
|
+
process.env["CLAUDE_SESSION_ID"] = `test-${Date.now()}-${Math.random()}`;
|
|
178
|
+
});
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
fetchSpy.mockRestore();
|
|
181
|
+
process.env["CLAUDE_SESSION_ID"] = origSessionId;
|
|
182
|
+
});
|
|
183
|
+
function makeMcpResponse(text) {
|
|
184
|
+
return Promise.resolve(new Response(JSON.stringify({ result: { content: [{ text }] } }), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
185
|
+
}
|
|
186
|
+
it("outputs vault match when fetch returns a high-score match", async () => {
|
|
187
|
+
fetchSpy.mockReturnValue(makeMcpResponse("Mnemos vault match (score: 87%):\n\n**2026-05-01-error-ctx**\nCore idea here.\n- Key takeaway\n\nApply it? Run: `apply_capture` with filename: `inbox/2026-05-01-error-ctx.md`"));
|
|
188
|
+
const { vaultCheck } = await import("../hooks.js");
|
|
189
|
+
// Simulate piped stdin with an Edit tool call
|
|
190
|
+
const hookData = JSON.stringify({
|
|
191
|
+
tool_name: "Edit",
|
|
192
|
+
tool_input: { file_path: "src/api/capture/route.ts" },
|
|
193
|
+
});
|
|
194
|
+
// Temporarily replace stdin
|
|
195
|
+
const mockStdin = {
|
|
196
|
+
isTTY: false,
|
|
197
|
+
setEncoding: vi.fn(),
|
|
198
|
+
on: vi.fn((event, cb) => {
|
|
199
|
+
if (event === "data")
|
|
200
|
+
cb(hookData);
|
|
201
|
+
if (event === "end")
|
|
202
|
+
cb();
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
const origStdin = process.stdin;
|
|
206
|
+
Object.defineProperty(process, "stdin", { value: mockStdin, writable: true, configurable: true });
|
|
207
|
+
const output = await captureStdout(() => vaultCheck("key"));
|
|
208
|
+
Object.defineProperty(process, "stdin", { value: origStdin, writable: true, configurable: true });
|
|
209
|
+
expect(output).toContain("vault match");
|
|
210
|
+
});
|
|
211
|
+
it("outputs nothing when vault_scan returns empty", async () => {
|
|
212
|
+
fetchSpy.mockReturnValue(makeMcpResponse(""));
|
|
213
|
+
const { vaultCheck } = await import("../hooks.js");
|
|
214
|
+
const mockStdin = {
|
|
215
|
+
isTTY: false,
|
|
216
|
+
setEncoding: vi.fn(),
|
|
217
|
+
on: vi.fn((event, cb) => {
|
|
218
|
+
if (event === "data")
|
|
219
|
+
cb(JSON.stringify({ tool_name: "Edit", tool_input: { file_path: "src/foo.ts" } }));
|
|
220
|
+
if (event === "end")
|
|
221
|
+
cb();
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
const origStdin = process.stdin;
|
|
225
|
+
Object.defineProperty(process, "stdin", { value: mockStdin, writable: true, configurable: true });
|
|
226
|
+
const output = await captureStdout(() => vaultCheck("key"));
|
|
227
|
+
Object.defineProperty(process, "stdin", { value: origStdin, writable: true, configurable: true });
|
|
228
|
+
expect(output).toBe("");
|
|
229
|
+
});
|
|
230
|
+
it("outputs nothing and does not call fetch when tool is not an editing tool", async () => {
|
|
231
|
+
const { vaultCheck } = await import("../hooks.js");
|
|
232
|
+
const mockStdin = {
|
|
233
|
+
isTTY: false,
|
|
234
|
+
setEncoding: vi.fn(),
|
|
235
|
+
on: vi.fn((event, cb) => {
|
|
236
|
+
if (event === "data")
|
|
237
|
+
cb(JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls" } }));
|
|
238
|
+
if (event === "end")
|
|
239
|
+
cb();
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
const origStdin = process.stdin;
|
|
243
|
+
Object.defineProperty(process, "stdin", { value: mockStdin, writable: true, configurable: true });
|
|
244
|
+
await vaultCheck("key");
|
|
245
|
+
Object.defineProperty(process, "stdin", { value: origStdin, writable: true, configurable: true });
|
|
246
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
it("outputs nothing when stdin is a TTY (not piped)", async () => {
|
|
249
|
+
const { vaultCheck } = await import("../hooks.js");
|
|
250
|
+
const mockStdin = { isTTY: true };
|
|
251
|
+
const origStdin = process.stdin;
|
|
252
|
+
Object.defineProperty(process, "stdin", { value: mockStdin, writable: true, configurable: true });
|
|
253
|
+
const output = await captureStdout(() => vaultCheck("key"));
|
|
254
|
+
Object.defineProperty(process, "stdin", { value: origStdin, writable: true, configurable: true });
|
|
255
|
+
expect(output).toBe("");
|
|
256
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
it("silently fails when fetch throws", async () => {
|
|
259
|
+
fetchSpy.mockRejectedValue(new Error("Network error"));
|
|
260
|
+
const { vaultCheck } = await import("../hooks.js");
|
|
261
|
+
const mockStdin = {
|
|
262
|
+
isTTY: false,
|
|
263
|
+
setEncoding: vi.fn(),
|
|
264
|
+
on: vi.fn((event, cb) => {
|
|
265
|
+
if (event === "data")
|
|
266
|
+
cb(JSON.stringify({ tool_name: "Edit", tool_input: { file_path: "src/foo.ts" } }));
|
|
267
|
+
if (event === "end")
|
|
268
|
+
cb();
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
const origStdin = process.stdin;
|
|
272
|
+
Object.defineProperty(process, "stdin", { value: mockStdin, writable: true, configurable: true });
|
|
273
|
+
await expect(vaultCheck("key")).resolves.toBeUndefined();
|
|
274
|
+
Object.defineProperty(process, "stdin", { value: origStdin, writable: true, configurable: true });
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parsePlanFilenames, latestPlanFilename, parseChecklist, buildKosBranch, } from "../kos.js";
|
|
3
|
+
const LIST_PLANS = `3 plan(s):
|
|
4
|
+
|
|
5
|
+
- **2026-06-03-plan-error-handling.md**: Implementation Plan: Error Handling
|
|
6
|
+
- **2026-06-05-plan-provider-abstraction.md**: Implementation Plan: Provider Abstraction
|
|
7
|
+
- **2026-06-04-plan-vault.md**: Implementation Plan: Vault`;
|
|
8
|
+
describe("parsePlanFilenames", () => {
|
|
9
|
+
it("extracts all bolded plan filenames", () => {
|
|
10
|
+
expect(parsePlanFilenames(LIST_PLANS)).toEqual([
|
|
11
|
+
"2026-06-03-plan-error-handling.md",
|
|
12
|
+
"2026-06-05-plan-provider-abstraction.md",
|
|
13
|
+
"2026-06-04-plan-vault.md",
|
|
14
|
+
]);
|
|
15
|
+
});
|
|
16
|
+
it("returns empty array when there are no plans", () => {
|
|
17
|
+
expect(parsePlanFilenames("No plans saved yet.")).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe("latestPlanFilename", () => {
|
|
21
|
+
it("returns the newest plan by date prefix", () => {
|
|
22
|
+
expect(latestPlanFilename(LIST_PLANS)).toBe("2026-06-05-plan-provider-abstraction.md");
|
|
23
|
+
});
|
|
24
|
+
it("returns null when there are no plans", () => {
|
|
25
|
+
expect(latestPlanFilename("No plans saved yet.")).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("parseChecklist", () => {
|
|
29
|
+
const PLAN = `# Implementation Plan: Foo
|
|
30
|
+
|
|
31
|
+
## Codebase Mapping
|
|
32
|
+
| File | Capture | Change | Complexity | Effort |
|
|
33
|
+
|------|---------|--------|------------|--------|
|
|
34
|
+
| a.ts | x | edit | simple | low |
|
|
35
|
+
|
|
36
|
+
## Verification Checklist
|
|
37
|
+
- [ ] Error messages include a handler prefix
|
|
38
|
+
- [ ] No regressions in the happy path
|
|
39
|
+
- [x] Already-done item still counts
|
|
40
|
+
|
|
41
|
+
## Notes
|
|
42
|
+
Not part of the checklist.`;
|
|
43
|
+
it("extracts checklist items without the checkbox prefix", () => {
|
|
44
|
+
expect(parseChecklist(PLAN)).toEqual([
|
|
45
|
+
"Error messages include a handler prefix",
|
|
46
|
+
"No regressions in the happy path",
|
|
47
|
+
"Already-done item still counts",
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
it("stops at the next section heading", () => {
|
|
51
|
+
expect(parseChecklist(PLAN)).not.toContain("Not part of the checklist.");
|
|
52
|
+
});
|
|
53
|
+
it("returns empty array when there is no checklist section", () => {
|
|
54
|
+
expect(parseChecklist("# Plan\n\n## Context\nNothing here.")).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe("buildKosBranch", () => {
|
|
58
|
+
it("produces a deterministic, sortable branch name from a date", () => {
|
|
59
|
+
const branch = buildKosBranch(new Date("2026-06-05T10:30:45.000Z"));
|
|
60
|
+
expect(branch).toBe("mnemos/kos-2026-06-05-10-30-45");
|
|
61
|
+
});
|
|
62
|
+
it("always starts with the mnemos/kos- prefix", () => {
|
|
63
|
+
expect(buildKosBranch()).toMatch(/^mnemos\/kos-/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mnemos CLI config — stores which AI assistant kos should drive, and an
|
|
3
|
+
* optional API key, in ~/.mnemos/config.json. Provider/agent-agnostic: the
|
|
4
|
+
* user tells Mnemos the exact command to launch their assistant.
|
|
5
|
+
*/
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
9
|
+
const CONFIG_DIR = join(homedir(), ".mnemos");
|
|
10
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
11
|
+
const ALLOWED_FIELDS = ["agent", "key"];
|
|
12
|
+
export function readConfig() {
|
|
13
|
+
if (!existsSync(CONFIG_PATH))
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function writeConfig(cfg) {
|
|
23
|
+
if (!existsSync(CONFIG_DIR))
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
26
|
+
}
|
|
27
|
+
export function setConfigField(field, value) {
|
|
28
|
+
if (!ALLOWED_FIELDS.includes(field)) {
|
|
29
|
+
throw new Error(`Unknown config field: ${field}. Allowed: ${ALLOWED_FIELDS.join(", ")}`);
|
|
30
|
+
}
|
|
31
|
+
const cfg = readConfig();
|
|
32
|
+
cfg[field] = value;
|
|
33
|
+
writeConfig(cfg);
|
|
34
|
+
}
|
|
35
|
+
/** Route `mnemos config <set|get> ...`. */
|
|
36
|
+
export function runConfigCommand(args) {
|
|
37
|
+
const action = args[0];
|
|
38
|
+
if (action === "set") {
|
|
39
|
+
const field = args[1];
|
|
40
|
+
const value = args.slice(2).join(" ");
|
|
41
|
+
if (!field || !value) {
|
|
42
|
+
console.error('Usage: mnemos config set <field> <value>');
|
|
43
|
+
console.error(' e.g. mnemos config set agent "claude -p"');
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
setConfigField(field, value);
|
|
48
|
+
console.log(`Set ${field} = ${value}`);
|
|
49
|
+
console.log(`Saved to ${CONFIG_PATH}`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (action === "get") {
|
|
58
|
+
const cfg = readConfig();
|
|
59
|
+
const field = args[1];
|
|
60
|
+
if (field) {
|
|
61
|
+
console.log(cfg[field] ?? "(not set)");
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(JSON.stringify(cfg, null, 2));
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
console.error("Usage: mnemos config set|get <field> [value]");
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
package/dist/cli/hooks.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { homedir } from "os";
|
|
1
|
+
import { homedir, tmpdir } from "os";
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { execSync } from "child_process";
|
|
@@ -9,6 +9,9 @@ const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
|
9
9
|
function isMnemosHook(cmd) {
|
|
10
10
|
return cmd.includes("mnemos-capture") && cmd.includes("inbox-check");
|
|
11
11
|
}
|
|
12
|
+
function isMnemosPreToolHook(cmd) {
|
|
13
|
+
return cmd.includes("mnemos-capture") && cmd.includes("vault-check");
|
|
14
|
+
}
|
|
12
15
|
function readSettings() {
|
|
13
16
|
if (!existsSync(CLAUDE_SETTINGS_PATH))
|
|
14
17
|
return {};
|
|
@@ -25,6 +28,47 @@ function writeSettings(settings) {
|
|
|
25
28
|
mkdirSync(dir, { recursive: true });
|
|
26
29
|
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
27
30
|
}
|
|
31
|
+
// ── Session state for vault deduplication ──
|
|
32
|
+
function sessionStateFile() {
|
|
33
|
+
const sessionId = process.env["CLAUDE_SESSION_ID"] ?? new Date().toISOString().split("T")[0] ?? "default";
|
|
34
|
+
return join(tmpdir(), `mnemos-vault-${sessionId}.json`);
|
|
35
|
+
}
|
|
36
|
+
function readSessionSurfaced() {
|
|
37
|
+
try {
|
|
38
|
+
const file = sessionStateFile();
|
|
39
|
+
if (!existsSync(file))
|
|
40
|
+
return [];
|
|
41
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function appendSessionSurfaced(filenames) {
|
|
48
|
+
try {
|
|
49
|
+
const file = sessionStateFile();
|
|
50
|
+
const existing = readSessionSurfaced();
|
|
51
|
+
const updated = [...new Set([...existing, ...filenames])];
|
|
52
|
+
writeFileSync(file, JSON.stringify(updated));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// best-effort
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// ── Stdin reader for hook context ──
|
|
59
|
+
async function readStdin() {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
if (process.stdin.isTTY) {
|
|
62
|
+
resolve("");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let data = "";
|
|
66
|
+
process.stdin.setEncoding("utf-8");
|
|
67
|
+
process.stdin.on("data", (chunk) => { data += chunk; });
|
|
68
|
+
process.stdin.on("end", () => resolve(data));
|
|
69
|
+
setTimeout(() => resolve(data), 2000);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
28
72
|
export function setupHooks(apiKey, opts = {}) {
|
|
29
73
|
const settings = readSettings();
|
|
30
74
|
const hooks = settings.hooks ?? {};
|
|
@@ -73,6 +117,43 @@ export function setupHooks(apiKey, opts = {}) {
|
|
|
73
117
|
console.log("At the start of each Claude Code session you'll see your inbox count.");
|
|
74
118
|
}
|
|
75
119
|
}
|
|
120
|
+
if (opts.vault) {
|
|
121
|
+
setupPreToolHook(apiKey, { silent: opts.silent, skipIfExists: opts.skipIfExists });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export function setupPreToolHook(apiKey, opts = {}) {
|
|
125
|
+
const settings = readSettings();
|
|
126
|
+
const hooks = settings.hooks ?? {};
|
|
127
|
+
const preToolCall = hooks.PreToolCall ?? [];
|
|
128
|
+
let existingCmd = null;
|
|
129
|
+
for (const group of preToolCall) {
|
|
130
|
+
for (const h of group.hooks ?? []) {
|
|
131
|
+
if (typeof h.command === "string" && isMnemosPreToolHook(h.command)) {
|
|
132
|
+
existingCmd = h.command;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (existingCmd)
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (existingCmd && opts.skipIfExists) {
|
|
140
|
+
if (existingCmd.includes("mnemos-capture@latest"))
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const command = `npx -y mnemos-capture@latest vault-check --key ${apiKey}`;
|
|
144
|
+
const filtered = preToolCall
|
|
145
|
+
.map((group) => ({
|
|
146
|
+
...group,
|
|
147
|
+
hooks: (group.hooks ?? []).filter((h) => !(typeof h.command === "string" && isMnemosPreToolHook(h.command))),
|
|
148
|
+
}))
|
|
149
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
150
|
+
filtered.push({ hooks: [{ type: "command", command }] });
|
|
151
|
+
settings.hooks = { ...hooks, PreToolCall: filtered };
|
|
152
|
+
writeSettings(settings);
|
|
153
|
+
if (!opts.silent) {
|
|
154
|
+
console.log("Mnemos vault hook installed (PreToolCall).");
|
|
155
|
+
console.log("Your knowledge vault will surface relevant captures as you edit files.");
|
|
156
|
+
}
|
|
76
157
|
}
|
|
77
158
|
function run(cmd) {
|
|
78
159
|
return execSync(cmd, { timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
|
|
@@ -116,7 +197,7 @@ function detectProjectContext() {
|
|
|
116
197
|
catch { }
|
|
117
198
|
return parts.length > 0 ? parts.join("\n\n") : "current project";
|
|
118
199
|
}
|
|
119
|
-
async function callMcp(apiKey, toolName, toolArgs) {
|
|
200
|
+
export async function callMcp(apiKey, toolName, toolArgs, timeoutMs = 15000) {
|
|
120
201
|
const res = await fetch(HOSTED_URL, {
|
|
121
202
|
method: "POST",
|
|
122
203
|
headers: {
|
|
@@ -129,20 +210,78 @@ async function callMcp(apiKey, toolName, toolArgs) {
|
|
|
129
210
|
params: { name: toolName, arguments: toolArgs },
|
|
130
211
|
id: 1,
|
|
131
212
|
}),
|
|
132
|
-
signal: AbortSignal.timeout(
|
|
213
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
214
|
});
|
|
134
215
|
if (!res.ok)
|
|
135
216
|
return null;
|
|
136
217
|
const data = (await res.json());
|
|
137
218
|
return data.result?.content?.[0]?.text ?? null;
|
|
138
219
|
}
|
|
220
|
+
export async function vaultCheck(apiKey) {
|
|
221
|
+
try {
|
|
222
|
+
const inputJson = await readStdin();
|
|
223
|
+
if (!inputJson.trim())
|
|
224
|
+
return;
|
|
225
|
+
let hookData;
|
|
226
|
+
try {
|
|
227
|
+
hookData = JSON.parse(inputJson);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// Only act on file-editing tool calls
|
|
233
|
+
const editingTools = ["Edit", "Write", "MultiEdit"];
|
|
234
|
+
if (!hookData.tool_name || !editingTools.includes(hookData.tool_name))
|
|
235
|
+
return;
|
|
236
|
+
const filePath = hookData.tool_input?.["file_path"] ??
|
|
237
|
+
hookData.tool_input?.["path"];
|
|
238
|
+
if (!filePath)
|
|
239
|
+
return;
|
|
240
|
+
const projectCtx = detectProjectContext();
|
|
241
|
+
const activityContext = `Editing file: ${filePath}\nProject context:\n${projectCtx}`;
|
|
242
|
+
const sessionSurfaced = readSessionSurfaced();
|
|
243
|
+
const text = await callMcp(apiKey, "vault_scan", {
|
|
244
|
+
activity_context: activityContext,
|
|
245
|
+
session_surfaced: sessionSurfaced,
|
|
246
|
+
}, 8000);
|
|
247
|
+
if (!text?.trim())
|
|
248
|
+
return;
|
|
249
|
+
// Track surfaced filenames to avoid repetition within the session
|
|
250
|
+
const filenameMatches = text.match(/(?:inbox|applied|archived)\/[\w-]+\.md/g) ?? [];
|
|
251
|
+
if (filenameMatches.length > 0)
|
|
252
|
+
appendSessionSurfaced(filenameMatches);
|
|
253
|
+
process.stdout.write(text.trim() + "\n");
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Silent fail — never interrupt a tool call
|
|
257
|
+
}
|
|
258
|
+
}
|
|
139
259
|
export async function inboxCheck(apiKey, opts = {}) {
|
|
140
260
|
try {
|
|
141
261
|
if (opts.briefing) {
|
|
142
262
|
const projectContext = detectProjectContext();
|
|
143
263
|
const text = await callMcp(apiKey, "briefing", { project_context: projectContext });
|
|
144
|
-
if (text?.trim())
|
|
145
|
-
|
|
264
|
+
if (!text?.trim())
|
|
265
|
+
return;
|
|
266
|
+
const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/);
|
|
267
|
+
let applyNow = [];
|
|
268
|
+
if (jsonMatch?.[1]) {
|
|
269
|
+
try {
|
|
270
|
+
const all = JSON.parse(jsonMatch[1]);
|
|
271
|
+
applyNow = all.filter((s) => s.applyNow);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
applyNow = [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Strip the JSON block — display only the Markdown narrative
|
|
278
|
+
const displayText = text.replace(/```json[\s\S]*?```\n?/, "").trim();
|
|
279
|
+
process.stdout.write(displayText + "\n");
|
|
280
|
+
if (applyNow.length > 0) {
|
|
281
|
+
const filenames = applyNow.map((s) => `'${s.filename}'`).join(", ");
|
|
282
|
+
process.stdout.write(`\n---\nMnemos: ${applyNow.length} insight${applyNow.length === 1 ? "" : "s"} ready to apply now.\n` +
|
|
283
|
+
`Run generate_plan with selected_captures: [${filenames}] to get a full implementation plan.\n`);
|
|
284
|
+
}
|
|
146
285
|
return;
|
|
147
286
|
}
|
|
148
287
|
// Default: fast count via list_inbox (no LLM call)
|