mnemos-capture 1.0.6 → 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.
@@ -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
+ }