zubo 0.1.21 → 0.1.23

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.
Files changed (54) hide show
  1. package/.playwright-mcp/console-2026-02-16T19-06-21-167Z.log +31 -0
  2. package/README.md +2 -1
  3. package/dashboard-chat.png +0 -0
  4. package/dashboard-followups.png +0 -0
  5. package/dashboard-history.png +0 -0
  6. package/dashboard-integrations.png +0 -0
  7. package/dashboard-knowledge-ok.png +0 -0
  8. package/dashboard-knowledge.png +0 -0
  9. package/dashboard-notes-add.png +0 -0
  10. package/dashboard-notes-improved.png +0 -0
  11. package/dashboard-notes.png +0 -0
  12. package/dashboard-overview.png +0 -0
  13. package/dashboard-preferences.png +0 -0
  14. package/dashboard-settings-fixed.png +0 -0
  15. package/dashboard-settings.png +0 -0
  16. package/dashboard-skills-ok.png +0 -0
  17. package/dashboard-skills.png +0 -0
  18. package/dashboard-todos-add.png +0 -0
  19. package/dashboard-todos-improved.png +0 -0
  20. package/dashboard-todos-item.png +0 -0
  21. package/dashboard-todos-priority-badge.png +0 -0
  22. package/dashboard-todos.png +0 -0
  23. package/dashboard-topics.png +0 -0
  24. package/docs/ROADMAP.md +12 -49
  25. package/migrations/024_personal_features.sql +96 -0
  26. package/package.json +1 -1
  27. package/site/docs/index.html +11 -0
  28. package/site/docs/skills.html +107 -0
  29. package/site/index.html +9 -1
  30. package/src/agent/context.ts +3 -3
  31. package/src/agent/delegate.ts +7 -2
  32. package/src/agent/loop.ts +6 -6
  33. package/src/agent/prompts.ts +49 -1
  34. package/src/agent/workflow-executor.ts +5 -1
  35. package/src/channels/dashboard.html.ts +558 -6
  36. package/src/channels/webchat.ts +305 -27
  37. package/src/llm/claude-code.ts +58 -17
  38. package/src/llm/codex.ts +59 -18
  39. package/src/start.ts +12 -0
  40. package/src/tools/builtin/diagnose.ts +19 -5
  41. package/src/tools/builtin/follow-ups.ts +189 -0
  42. package/src/tools/builtin/notes.ts +207 -0
  43. package/src/tools/builtin/preferences.ts +173 -0
  44. package/src/tools/builtin/todos.ts +270 -0
  45. package/src/tools/builtin/topics.ts +166 -0
  46. package/src/tools/mcp-client.ts +8 -0
  47. package/src/tools/permissions.ts +7 -0
  48. package/tests/agent/session.test.ts +43 -45
  49. package/tests/mcp-registry.test.ts +32 -35
  50. package/tests/personal-features.test.ts +1251 -0
  51. package/tests/skill-registry.test.ts +1 -7
  52. package/tests/db/export.test.ts +0 -219
  53. package/tests/session.test.ts +0 -58
  54. package/tests/tools/executor.test.ts +0 -150
@@ -0,0 +1,1251 @@
1
+ import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { createTestDb } from "./helpers/test-db";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Test database
7
+ // ---------------------------------------------------------------------------
8
+
9
+ let testDb: Database;
10
+
11
+ /**
12
+ * Create the test database with all migrations applied, then fix schema
13
+ * conflicts. Migration 008 creates a minimal `user_preferences` table.
14
+ * Migration 024 tries to create the expanded version but `IF NOT EXISTS`
15
+ * makes it a no-op. We drop and recreate with the schema that the
16
+ * preferences tool actually expects.
17
+ */
18
+ function createPersonalFeaturesDb(): Database {
19
+ const db = createTestDb();
20
+
21
+ // Upgrade user_preferences to the 024 schema the tool expects
22
+ db.run("DROP TABLE IF EXISTS user_preferences");
23
+ db.run(`
24
+ CREATE TABLE user_preferences (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ category TEXT NOT NULL,
27
+ key TEXT NOT NULL,
28
+ value TEXT NOT NULL,
29
+ confidence REAL NOT NULL DEFAULT 0.8,
30
+ source TEXT NOT NULL DEFAULT 'inferred',
31
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
32
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
33
+ UNIQUE(category, key)
34
+ )
35
+ `);
36
+ db.run(
37
+ "CREATE INDEX IF NOT EXISTS idx_prefs_category ON user_preferences(category)"
38
+ );
39
+
40
+ return db;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Mocks — must be declared BEFORE importing any module under test
45
+ // ---------------------------------------------------------------------------
46
+
47
+ mock.module("../src/db/connection", () => ({
48
+ getDb: () => testDb,
49
+ closeDb: () => {},
50
+ }));
51
+
52
+ mock.module("../src/util/logger", () => ({
53
+ logger: {
54
+ debug: () => {},
55
+ info: () => {},
56
+ warn: () => {},
57
+ error: () => {},
58
+ },
59
+ }));
60
+
61
+ mock.module("../src/util/error-buffer", () => ({
62
+ recordError: () => {},
63
+ }));
64
+
65
+ // Personal-feature tools are now registered as "auto" in the permissions map,
66
+ // so no mock needed here.
67
+
68
+ // Sandbox should never kick in for built-in tools
69
+ mock.module("../src/tools/sandbox", () => ({
70
+ executeSandboxed: async () => "sandboxed-noop",
71
+ }));
72
+
73
+ // Follow-ups scheduler mocks
74
+ const cronJobsAdded: any[] = [];
75
+ const cronJobsRemoved: any[] = [];
76
+
77
+ mock.module("../src/scheduler/cron", () => ({
78
+ addCronJob: (...args: any[]) => {
79
+ cronJobsAdded.push(args);
80
+ },
81
+ removeCronJob: (...args: any[]) => {
82
+ cronJobsRemoved.push(args);
83
+ },
84
+ }));
85
+
86
+ mock.module("../src/scheduler/natural-cron", () => ({
87
+ parseNaturalSchedule: (input: string) => {
88
+ if (input.includes("invalid")) return "Could not parse schedule";
89
+ return { cron: new Date(Date.now() + 3_600_000).toISOString(), once: true };
90
+ },
91
+ }));
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Import modules under test AFTER mocks are set up
95
+ // ---------------------------------------------------------------------------
96
+
97
+ const { registerTodosTool } = await import("../src/tools/builtin/todos");
98
+ const { registerNotesTool } = await import("../src/tools/builtin/notes");
99
+ const {
100
+ registerPreferencesTool,
101
+ loadPreferencesContext,
102
+ extractPreferences,
103
+ } = await import("../src/tools/builtin/preferences");
104
+ const {
105
+ registerTopicsTool,
106
+ getActiveTopic,
107
+ getTopicSessionId,
108
+ } = await import("../src/tools/builtin/topics");
109
+ const { registerFollowUpsTool } = await import(
110
+ "../src/tools/builtin/follow-ups"
111
+ );
112
+ const { executeTool } = await import("../src/tools/executor");
113
+ const { getTool, unregisterTool } = await import("../src/tools/registry");
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Helper: call a tool's execute fn directly (faster, no permission layer)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ async function callTool(
120
+ name: string,
121
+ input: Record<string, unknown>
122
+ ): Promise<string> {
123
+ const tool = getTool(name);
124
+ if (!tool) throw new Error(`Tool "${name}" not registered`);
125
+ return tool.execute(input);
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Register all tools once — the DB reference is swapped in beforeEach.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ registerTodosTool();
133
+ registerNotesTool();
134
+ registerPreferencesTool();
135
+ registerTopicsTool();
136
+
137
+ // follow-ups needs the db instance + stubs for router/config/llm
138
+ const fakeRouter = {} as any;
139
+ const fakeConfig = {} as any;
140
+
141
+ // We'll register follow-ups after first DB creation (it captures the db ref)
142
+ let followUpsRegistered = false;
143
+
144
+ function ensureFollowUps() {
145
+ if (!followUpsRegistered) {
146
+ registerFollowUpsTool(testDb, fakeRouter, fakeConfig);
147
+ followUpsRegistered = true;
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Reset state before every test
153
+ // ---------------------------------------------------------------------------
154
+
155
+ beforeEach(() => {
156
+ testDb = createPersonalFeaturesDb();
157
+ cronJobsAdded.length = 0;
158
+ cronJobsRemoved.length = 0;
159
+ });
160
+
161
+ afterAll(() => {
162
+ // Clean up registered tools to avoid polluting other test files
163
+ unregisterTool("todos");
164
+ unregisterTool("notes");
165
+ unregisterTool("preferences");
166
+ unregisterTool("topics");
167
+ unregisterTool("follow_ups");
168
+ });
169
+
170
+ // ===========================================================================
171
+ // TODOS
172
+ // ===========================================================================
173
+
174
+ describe("todos tool", () => {
175
+ // --- add ---
176
+
177
+ test("add with title only returns success with ID", async () => {
178
+ const result = await callTool("todos", { action: "add", title: "Buy milk" });
179
+ expect(result).toContain("Added:");
180
+ expect(result).toContain("#1");
181
+ expect(result).toContain("Buy milk");
182
+ expect(result).toContain("[medium]"); // default priority
183
+ });
184
+
185
+ test("add with priority, due_date, and tags returns full details", async () => {
186
+ const result = await callTool("todos", {
187
+ action: "add",
188
+ title: "Ship feature",
189
+ priority: "high",
190
+ due_date: "2099-12-31",
191
+ tags: "work, release",
192
+ });
193
+ expect(result).toContain("Added:");
194
+ expect(result).toContain("Ship feature");
195
+ expect(result).toContain("[high]");
196
+ expect(result).toContain("due:");
197
+ // 2099-12-31 is far in the future — should show the raw date
198
+ expect(result).toContain("2099-12-31");
199
+ });
200
+
201
+ test("add without title returns error", async () => {
202
+ const result = await callTool("todos", { action: "add" });
203
+ expect(result).toContain("Error");
204
+ expect(result).toContain("title is required");
205
+ });
206
+
207
+ // --- list ---
208
+
209
+ test("list pending returns correct items", async () => {
210
+ await callTool("todos", { action: "add", title: "Task A", priority: "high" });
211
+ await callTool("todos", { action: "add", title: "Task B", priority: "low" });
212
+
213
+ const result = await callTool("todos", { action: "list" });
214
+ expect(result).toContain("Todos (pending");
215
+ expect(result).toContain("2 items");
216
+ expect(result).toContain("Task A");
217
+ expect(result).toContain("Task B");
218
+ });
219
+
220
+ test("list pending excludes completed items", async () => {
221
+ await callTool("todos", { action: "add", title: "Done task" });
222
+ await callTool("todos", { action: "complete", id: 1 });
223
+ await callTool("todos", { action: "add", title: "Pending task" });
224
+
225
+ const result = await callTool("todos", { action: "list", filter: "pending" });
226
+ expect(result).toContain("Pending task");
227
+ expect(result).not.toContain("Done task");
228
+ });
229
+
230
+ test("list all shows both pending and done", async () => {
231
+ await callTool("todos", { action: "add", title: "Item 1" });
232
+ await callTool("todos", { action: "complete", id: 1 });
233
+ await callTool("todos", { action: "add", title: "Item 2" });
234
+
235
+ const result = await callTool("todos", { action: "list", filter: "all" });
236
+ expect(result).toContain("Item 1");
237
+ expect(result).toContain("Item 2");
238
+ expect(result).toContain("[done]");
239
+ });
240
+
241
+ test("list overdue filters correctly", async () => {
242
+ // Insert a todo with a past due date directly
243
+ testDb
244
+ .prepare(
245
+ "INSERT INTO todos (title, priority, due_date) VALUES (?, ?, ?)"
246
+ )
247
+ .run("Overdue task", "medium", "2020-01-01");
248
+ // And one with a future due date
249
+ testDb
250
+ .prepare(
251
+ "INSERT INTO todos (title, priority, due_date) VALUES (?, ?, ?)"
252
+ )
253
+ .run("Future task", "medium", "2099-12-31");
254
+
255
+ const result = await callTool("todos", { action: "list", filter: "overdue" });
256
+ expect(result).toContain("Overdue task");
257
+ expect(result).not.toContain("Future task");
258
+ });
259
+
260
+ test("list returns empty message when no todos match", async () => {
261
+ const result = await callTool("todos", { action: "list" });
262
+ expect(result).toContain("No pending todos found");
263
+ });
264
+
265
+ // --- complete ---
266
+
267
+ test("complete marks todo as done", async () => {
268
+ await callTool("todos", { action: "add", title: "Finish report" });
269
+ const result = await callTool("todos", { action: "complete", id: 1 });
270
+ expect(result).toBe("Completed todo #1.");
271
+
272
+ // Verify in DB
273
+ const row = testDb
274
+ .query("SELECT status, completed_at FROM todos WHERE id = 1")
275
+ .get() as any;
276
+ expect(row.status).toBe("done");
277
+ expect(row.completed_at).toBeTruthy();
278
+ });
279
+
280
+ test("complete with invalid id returns not found", async () => {
281
+ const result = await callTool("todos", { action: "complete", id: 999 });
282
+ expect(result).toContain("No todo found with id #999");
283
+ });
284
+
285
+ test("complete without id returns error", async () => {
286
+ const result = await callTool("todos", { action: "complete" });
287
+ expect(result).toContain("Error");
288
+ expect(result).toContain("id is required");
289
+ });
290
+
291
+ // --- remove ---
292
+
293
+ test("remove deletes todo", async () => {
294
+ await callTool("todos", { action: "add", title: "Temp task" });
295
+ const result = await callTool("todos", { action: "remove", id: 1 });
296
+ expect(result).toBe("Removed todo #1.");
297
+
298
+ const count = testDb
299
+ .query("SELECT COUNT(*) as c FROM todos")
300
+ .get() as any;
301
+ expect(count.c).toBe(0);
302
+ });
303
+
304
+ test("remove with invalid id returns not found", async () => {
305
+ const result = await callTool("todos", { action: "remove", id: 999 });
306
+ expect(result).toContain("No todo found with id #999");
307
+ });
308
+
309
+ // --- update ---
310
+
311
+ test("update priority changes the priority", async () => {
312
+ await callTool("todos", { action: "add", title: "Update me", priority: "low" });
313
+ const result = await callTool("todos", {
314
+ action: "update",
315
+ id: 1,
316
+ priority: "urgent",
317
+ });
318
+ expect(result).toBe("Updated todo #1.");
319
+
320
+ const row = testDb
321
+ .query("SELECT priority FROM todos WHERE id = 1")
322
+ .get() as any;
323
+ expect(row.priority).toBe("urgent");
324
+ });
325
+
326
+ test("update multiple fields at once", async () => {
327
+ await callTool("todos", { action: "add", title: "Old title" });
328
+ await callTool("todos", {
329
+ action: "update",
330
+ id: 1,
331
+ title: "New title",
332
+ tags: "updated, multi",
333
+ });
334
+
335
+ const row = testDb
336
+ .query("SELECT title, tags FROM todos WHERE id = 1")
337
+ .get() as any;
338
+ expect(row.title).toBe("New title");
339
+ const tags = JSON.parse(row.tags);
340
+ expect(tags).toContain("updated");
341
+ expect(tags).toContain("multi");
342
+ });
343
+
344
+ test("update without fields returns error", async () => {
345
+ await callTool("todos", { action: "add", title: "No change" });
346
+ const result = await callTool("todos", { action: "update", id: 1 });
347
+ expect(result).toContain("Error");
348
+ expect(result).toContain("at least one field");
349
+ });
350
+
351
+ test("update without id returns error", async () => {
352
+ const result = await callTool("todos", {
353
+ action: "update",
354
+ title: "No id",
355
+ });
356
+ expect(result).toContain("Error");
357
+ expect(result).toContain("id is required");
358
+ });
359
+
360
+ // --- unknown action ---
361
+
362
+ test("unknown action returns error message", async () => {
363
+ const result = await callTool("todos", { action: "explode" });
364
+ expect(result).toContain("Unknown action: explode");
365
+ });
366
+
367
+ // --- executeTool integration ---
368
+
369
+ test("executeTool returns proper ToolResult shape", async () => {
370
+ const result = await executeTool("todos", "tu-1", {
371
+ action: "add",
372
+ title: "Via executor",
373
+ });
374
+ expect(result.tool_use_id).toBe("tu-1");
375
+ expect(result.is_error).toBe(false);
376
+ expect(result.content).toContain("Added:");
377
+ expect(result.content).toContain("Via executor");
378
+ });
379
+ });
380
+
381
+ // ===========================================================================
382
+ // NOTES
383
+ // ===========================================================================
384
+
385
+ describe("notes tool", () => {
386
+ // --- save ---
387
+
388
+ test("save creates a note and returns its ID", async () => {
389
+ const result = await callTool("notes", {
390
+ action: "save",
391
+ title: "Meeting notes",
392
+ content: "Discussed roadmap for Q3.",
393
+ });
394
+ expect(result).toContain("Note saved:");
395
+ expect(result).toContain("#1");
396
+ expect(result).toContain("Meeting notes");
397
+ });
398
+
399
+ test("save with tags stores tags correctly", async () => {
400
+ await callTool("notes", {
401
+ action: "save",
402
+ title: "Tagged note",
403
+ content: "Content here",
404
+ tags: "work, ideas",
405
+ });
406
+
407
+ const row = testDb
408
+ .query("SELECT tags FROM notes WHERE id = 1")
409
+ .get() as any;
410
+ const tags = JSON.parse(row.tags);
411
+ expect(tags).toContain("work");
412
+ expect(tags).toContain("ideas");
413
+ });
414
+
415
+ test("save without title returns error", async () => {
416
+ const result = await callTool("notes", {
417
+ action: "save",
418
+ content: "No title",
419
+ });
420
+ expect(result).toContain("Error");
421
+ expect(result).toContain("title is required");
422
+ });
423
+
424
+ test("save without content returns error", async () => {
425
+ const result = await callTool("notes", {
426
+ action: "save",
427
+ title: "No content",
428
+ });
429
+ expect(result).toContain("Error");
430
+ expect(result).toContain("content is required");
431
+ });
432
+
433
+ // --- list ---
434
+
435
+ test("list returns notes sorted by pinned then updated_at", async () => {
436
+ await callTool("notes", {
437
+ action: "save",
438
+ title: "Note A",
439
+ content: "First note",
440
+ });
441
+ await callTool("notes", {
442
+ action: "save",
443
+ title: "Note B",
444
+ content: "Second note",
445
+ });
446
+ // Pin Note A
447
+ await callTool("notes", { action: "pin", id: 1 });
448
+
449
+ const result = await callTool("notes", { action: "list" });
450
+ expect(result).toContain("Notes (2 total)");
451
+ // Pinned note should appear first
452
+ const indexA = result.indexOf("Note A");
453
+ const indexB = result.indexOf("Note B");
454
+ expect(indexA).toBeLessThan(indexB);
455
+ });
456
+
457
+ test("list returns empty message when no notes exist", async () => {
458
+ const result = await callTool("notes", { action: "list" });
459
+ expect(result).toBe("No notes found.");
460
+ });
461
+
462
+ test("list respects limit parameter", async () => {
463
+ for (let i = 1; i <= 5; i++) {
464
+ await callTool("notes", {
465
+ action: "save",
466
+ title: `Note ${i}`,
467
+ content: `Content ${i}`,
468
+ });
469
+ }
470
+
471
+ const result = await callTool("notes", { action: "list", limit: 2 });
472
+ expect(result).toContain("2 total");
473
+ });
474
+
475
+ // --- search ---
476
+
477
+ test("search via FTS finds matching notes", async () => {
478
+ await callTool("notes", {
479
+ action: "save",
480
+ title: "TypeScript tips",
481
+ content: "Use strict mode for better type safety.",
482
+ });
483
+ await callTool("notes", {
484
+ action: "save",
485
+ title: "Grocery list",
486
+ content: "Eggs, milk, bread.",
487
+ });
488
+
489
+ const result = await callTool("notes", {
490
+ action: "search",
491
+ query: "TypeScript",
492
+ });
493
+ expect(result).toContain("TypeScript tips");
494
+ expect(result).not.toContain("Grocery list");
495
+ });
496
+
497
+ test("search with no results returns appropriate message", async () => {
498
+ await callTool("notes", {
499
+ action: "save",
500
+ title: "Unrelated",
501
+ content: "Nothing relevant here.",
502
+ });
503
+
504
+ const result = await callTool("notes", {
505
+ action: "search",
506
+ query: "quantum",
507
+ });
508
+ expect(result).toContain('No notes found matching "quantum"');
509
+ });
510
+
511
+ test("search without query returns error", async () => {
512
+ const result = await callTool("notes", { action: "search" });
513
+ expect(result).toContain("Error");
514
+ expect(result).toContain("query is required");
515
+ });
516
+
517
+ // --- update ---
518
+
519
+ test("update modifies note fields", async () => {
520
+ await callTool("notes", {
521
+ action: "save",
522
+ title: "Original",
523
+ content: "Original content",
524
+ });
525
+
526
+ const result = await callTool("notes", {
527
+ action: "update",
528
+ id: 1,
529
+ title: "Updated title",
530
+ content: "Updated content",
531
+ });
532
+ expect(result).toBe("Updated note #1.");
533
+
534
+ const row = testDb
535
+ .query("SELECT title, content FROM notes WHERE id = 1")
536
+ .get() as any;
537
+ expect(row.title).toBe("Updated title");
538
+ expect(row.content).toBe("Updated content");
539
+ });
540
+
541
+ test("update with invalid id returns not found", async () => {
542
+ const result = await callTool("notes", {
543
+ action: "update",
544
+ id: 999,
545
+ title: "Nope",
546
+ });
547
+ expect(result).toContain("No note found with id #999");
548
+ });
549
+
550
+ test("update without any fields returns error", async () => {
551
+ await callTool("notes", {
552
+ action: "save",
553
+ title: "Test",
554
+ content: "Content",
555
+ });
556
+ const result = await callTool("notes", { action: "update", id: 1 });
557
+ expect(result).toContain("Error");
558
+ expect(result).toContain("at least one field");
559
+ });
560
+
561
+ // --- delete ---
562
+
563
+ test("delete removes the note", async () => {
564
+ await callTool("notes", {
565
+ action: "save",
566
+ title: "To delete",
567
+ content: "Gone soon",
568
+ });
569
+ const result = await callTool("notes", { action: "delete", id: 1 });
570
+ expect(result).toBe("Deleted note #1.");
571
+
572
+ const count = testDb
573
+ .query("SELECT COUNT(*) as c FROM notes")
574
+ .get() as any;
575
+ expect(count.c).toBe(0);
576
+ });
577
+
578
+ test("delete with invalid id returns not found", async () => {
579
+ const result = await callTool("notes", { action: "delete", id: 999 });
580
+ expect(result).toContain("No note found with id #999");
581
+ });
582
+
583
+ // --- pin ---
584
+
585
+ test("pin toggles pin state on", async () => {
586
+ await callTool("notes", {
587
+ action: "save",
588
+ title: "Pin me",
589
+ content: "Important note",
590
+ });
591
+ const result = await callTool("notes", { action: "pin", id: 1 });
592
+ expect(result).toContain("Note #1 is now pinned");
593
+ });
594
+
595
+ test("pin toggles pin state off (unpin)", async () => {
596
+ await callTool("notes", {
597
+ action: "save",
598
+ title: "Unpin me",
599
+ content: "Less important",
600
+ });
601
+ // Pin then unpin
602
+ await callTool("notes", { action: "pin", id: 1 });
603
+ const result = await callTool("notes", { action: "pin", id: 1 });
604
+ expect(result).toContain("Note #1 is now unpinned");
605
+ });
606
+
607
+ test("pin with invalid id returns not found", async () => {
608
+ const result = await callTool("notes", { action: "pin", id: 999 });
609
+ expect(result).toContain("No note found with id #999");
610
+ });
611
+
612
+ // --- unknown action ---
613
+
614
+ test("unknown action returns error message", async () => {
615
+ const result = await callTool("notes", { action: "archive" });
616
+ expect(result).toContain("Unknown action: archive");
617
+ });
618
+ });
619
+
620
+ // ===========================================================================
621
+ // PREFERENCES
622
+ // ===========================================================================
623
+
624
+ describe("preferences tool", () => {
625
+ // --- set ---
626
+
627
+ test("set stores a preference", async () => {
628
+ const result = await callTool("preferences", {
629
+ action: "set",
630
+ category: "food",
631
+ key: "coffee_order",
632
+ value: "oat milk latte",
633
+ });
634
+ expect(result).toBe("Preference saved: food/coffee_order = oat milk latte");
635
+
636
+ const row = testDb
637
+ .query(
638
+ "SELECT value, confidence, source FROM user_preferences WHERE category = ? AND key = ?"
639
+ )
640
+ .get("food", "coffee_order") as any;
641
+ expect(row.value).toBe("oat milk latte");
642
+ expect(row.confidence).toBe(0.9); // default
643
+ expect(row.source).toBe("explicit");
644
+ });
645
+
646
+ test("set with custom confidence stores it", async () => {
647
+ await callTool("preferences", {
648
+ action: "set",
649
+ category: "work",
650
+ key: "editor",
651
+ value: "VSCode",
652
+ confidence: 0.7,
653
+ });
654
+
655
+ const row = testDb
656
+ .query(
657
+ "SELECT confidence FROM user_preferences WHERE category = ? AND key = ?"
658
+ )
659
+ .get("work", "editor") as any;
660
+ expect(row.confidence).toBe(0.7);
661
+ });
662
+
663
+ test("set overwrites existing preference (upsert)", async () => {
664
+ await callTool("preferences", {
665
+ action: "set",
666
+ category: "food",
667
+ key: "coffee_order",
668
+ value: "black coffee",
669
+ });
670
+ await callTool("preferences", {
671
+ action: "set",
672
+ category: "food",
673
+ key: "coffee_order",
674
+ value: "espresso",
675
+ });
676
+
677
+ const row = testDb
678
+ .query(
679
+ "SELECT value FROM user_preferences WHERE category = ? AND key = ?"
680
+ )
681
+ .get("food", "coffee_order") as any;
682
+ expect(row.value).toBe("espresso");
683
+
684
+ // Ensure only one row exists
685
+ const count = testDb
686
+ .query("SELECT COUNT(*) as c FROM user_preferences")
687
+ .get() as any;
688
+ expect(count.c).toBe(1);
689
+ });
690
+
691
+ test("set without required fields returns error", async () => {
692
+ const result = await callTool("preferences", { action: "set" });
693
+ expect(result).toContain("Error");
694
+ expect(result).toContain("category, key, and value are required");
695
+ });
696
+
697
+ // --- get ---
698
+
699
+ test("get retrieves a stored preference", async () => {
700
+ await callTool("preferences", {
701
+ action: "set",
702
+ category: "coding",
703
+ key: "language",
704
+ value: "TypeScript",
705
+ });
706
+ const result = await callTool("preferences", {
707
+ action: "get",
708
+ category: "coding",
709
+ key: "language",
710
+ });
711
+ expect(result).toBe("TypeScript");
712
+ });
713
+
714
+ test("get with nonexistent key returns not found", async () => {
715
+ const result = await callTool("preferences", {
716
+ action: "get",
717
+ category: "nope",
718
+ key: "nada",
719
+ });
720
+ expect(result).toBe("No preference found.");
721
+ });
722
+
723
+ test("get without category or key returns error", async () => {
724
+ const result = await callTool("preferences", { action: "get" });
725
+ expect(result).toContain("Error");
726
+ expect(result).toContain("category and key are required");
727
+ });
728
+
729
+ // --- list ---
730
+
731
+ test("list shows all preferences grouped by category", async () => {
732
+ await callTool("preferences", {
733
+ action: "set",
734
+ category: "coding",
735
+ key: "language",
736
+ value: "TypeScript",
737
+ });
738
+ await callTool("preferences", {
739
+ action: "set",
740
+ category: "coding",
741
+ key: "editor",
742
+ value: "Neovim",
743
+ });
744
+ await callTool("preferences", {
745
+ action: "set",
746
+ category: "food",
747
+ key: "cuisine",
748
+ value: "Japanese",
749
+ });
750
+
751
+ const result = await callTool("preferences", { action: "list" });
752
+ expect(result).toContain("## Coding");
753
+ expect(result).toContain("## Food");
754
+ expect(result).toContain("language: TypeScript");
755
+ expect(result).toContain("editor: Neovim");
756
+ expect(result).toContain("cuisine: Japanese");
757
+ });
758
+
759
+ test("list with no preferences returns empty message", async () => {
760
+ const result = await callTool("preferences", { action: "list" });
761
+ expect(result).toBe("No preferences saved yet.");
762
+ });
763
+
764
+ // --- remove ---
765
+
766
+ test("remove deletes a preference", async () => {
767
+ await callTool("preferences", {
768
+ action: "set",
769
+ category: "food",
770
+ key: "drink",
771
+ value: "water",
772
+ });
773
+ const result = await callTool("preferences", {
774
+ action: "remove",
775
+ category: "food",
776
+ key: "drink",
777
+ });
778
+ expect(result).toBe("Preference removed: food/drink");
779
+
780
+ const count = testDb
781
+ .query("SELECT COUNT(*) as c FROM user_preferences")
782
+ .get() as any;
783
+ expect(count.c).toBe(0);
784
+ });
785
+
786
+ test("remove nonexistent preference returns not found", async () => {
787
+ const result = await callTool("preferences", {
788
+ action: "remove",
789
+ category: "nope",
790
+ key: "nada",
791
+ });
792
+ expect(result).toContain("No preference found for nope/nada");
793
+ });
794
+
795
+ test("remove without category/key returns error", async () => {
796
+ const result = await callTool("preferences", { action: "remove" });
797
+ expect(result).toContain("Error");
798
+ expect(result).toContain("category and key are required");
799
+ });
800
+
801
+ // --- unknown action ---
802
+
803
+ test("unknown action returns error message", async () => {
804
+ const result = await callTool("preferences", { action: "reset" });
805
+ expect(result).toContain("Unknown action: reset");
806
+ });
807
+
808
+ // --- loadPreferencesContext ---
809
+
810
+ test("loadPreferencesContext formats preferences for system prompt", async () => {
811
+ await callTool("preferences", {
812
+ action: "set",
813
+ category: "communication",
814
+ key: "tone",
815
+ value: "casual",
816
+ });
817
+ await callTool("preferences", {
818
+ action: "set",
819
+ category: "communication",
820
+ key: "language",
821
+ value: "English",
822
+ });
823
+ await callTool("preferences", {
824
+ action: "set",
825
+ category: "work",
826
+ key: "role",
827
+ value: "engineer",
828
+ });
829
+
830
+ const context = await loadPreferencesContext();
831
+ expect(context).toContain("## User preferences");
832
+ expect(context).toContain("**communication:**");
833
+ expect(context).toContain("- tone: casual");
834
+ expect(context).toContain("- language: English");
835
+ expect(context).toContain("**work:**");
836
+ expect(context).toContain("- role: engineer");
837
+ });
838
+
839
+ test("loadPreferencesContext returns empty string when no prefs exist", async () => {
840
+ const context = await loadPreferencesContext();
841
+ expect(context).toBe("");
842
+ });
843
+
844
+ // --- extractPreferences ---
845
+
846
+ test("extractPreferences returns empty array (conservative design)", () => {
847
+ // The current implementation is intentionally conservative and returns []
848
+ const result = extractPreferences("I prefer TypeScript over JavaScript");
849
+ expect(Array.isArray(result)).toBe(true);
850
+ expect(result.length).toBe(0);
851
+ });
852
+ });
853
+
854
+ // ===========================================================================
855
+ // TOPICS
856
+ // ===========================================================================
857
+
858
+ describe("topics tool", () => {
859
+ // Reset the module-level activeTopic between describes by switching to
860
+ // "current" and checking state. We cannot directly reset the module var,
861
+ // but we can archive any active topic.
862
+
863
+ // --- create ---
864
+
865
+ test("create inserts a new topic", async () => {
866
+ const result = await callTool("topics", {
867
+ action: "create",
868
+ name: "trip planning",
869
+ description: "Summer vacation",
870
+ });
871
+ expect(result).toContain("Topic 'trip planning' created");
872
+
873
+ const row = testDb
874
+ .query("SELECT * FROM conversation_topics WHERE name = ?")
875
+ .get("trip planning") as any;
876
+ expect(row).toBeTruthy();
877
+ expect(row.description).toBe("Summer vacation");
878
+ expect(row.status).toBe("active");
879
+ });
880
+
881
+ test("create without name returns error", async () => {
882
+ const result = await callTool("topics", { action: "create" });
883
+ expect(result).toContain("Error");
884
+ expect(result).toContain("'name' is required");
885
+ });
886
+
887
+ // --- switch ---
888
+
889
+ test("switch sets the active topic", async () => {
890
+ await callTool("topics", {
891
+ action: "create",
892
+ name: "work project",
893
+ });
894
+ const result = await callTool("topics", {
895
+ action: "switch",
896
+ name: "work project",
897
+ });
898
+ expect(result).toContain("Switched to topic: work project");
899
+
900
+ // Verify the module-level state
901
+ expect(getActiveTopic()).toBe("work project");
902
+ });
903
+
904
+ test("switch to nonexistent topic auto-creates it", async () => {
905
+ const result = await callTool("topics", {
906
+ action: "switch",
907
+ name: "new topic",
908
+ });
909
+ expect(result).toContain("Switched to topic: new topic");
910
+
911
+ const row = testDb
912
+ .query("SELECT * FROM conversation_topics WHERE name = ?")
913
+ .get("new topic") as any;
914
+ expect(row).toBeTruthy();
915
+ });
916
+
917
+ test("switch without name returns error", async () => {
918
+ const result = await callTool("topics", { action: "switch" });
919
+ expect(result).toContain("Error");
920
+ expect(result).toContain("'name' is required");
921
+ });
922
+
923
+ // --- list ---
924
+
925
+ test("list shows active topics", async () => {
926
+ await callTool("topics", { action: "create", name: "alpha" });
927
+ await callTool("topics", { action: "create", name: "beta" });
928
+
929
+ const result = await callTool("topics", { action: "list" });
930
+ expect(result).toContain("Active topics:");
931
+ expect(result).toContain("alpha");
932
+ expect(result).toContain("beta");
933
+ });
934
+
935
+ test("list shows current topic", async () => {
936
+ await callTool("topics", { action: "create", name: "focus" });
937
+ await callTool("topics", { action: "switch", name: "focus" });
938
+
939
+ const result = await callTool("topics", { action: "list" });
940
+ expect(result).toContain("Current: focus");
941
+ });
942
+
943
+ test("list with no topics returns helpful message", async () => {
944
+ // Reset active topic by creating + archiving it in the fresh DB
945
+ const current = getActiveTopic();
946
+ if (current) {
947
+ await callTool("topics", { action: "create", name: current });
948
+ await callTool("topics", { action: "archive", name: current });
949
+ }
950
+
951
+ const result = await callTool("topics", { action: "list" });
952
+ expect(result).toContain("No active topics yet");
953
+ expect(result).toContain("Create one");
954
+ });
955
+
956
+ // --- archive ---
957
+
958
+ test("archive hides a topic", async () => {
959
+ await callTool("topics", { action: "create", name: "old project" });
960
+ const result = await callTool("topics", {
961
+ action: "archive",
962
+ name: "old project",
963
+ });
964
+ expect(result).toContain("Topic 'old project' archived");
965
+
966
+ const row = testDb
967
+ .query("SELECT status FROM conversation_topics WHERE name = ?")
968
+ .get("old project") as any;
969
+ expect(row.status).toBe("archived");
970
+ });
971
+
972
+ test("archive resets activeTopic if it was the active one", async () => {
973
+ await callTool("topics", { action: "create", name: "temp" });
974
+ await callTool("topics", { action: "switch", name: "temp" });
975
+ expect(getActiveTopic()).toBe("temp");
976
+
977
+ await callTool("topics", { action: "archive", name: "temp" });
978
+ expect(getActiveTopic()).toBeNull();
979
+ });
980
+
981
+ test("archive nonexistent topic returns not found", async () => {
982
+ const result = await callTool("topics", {
983
+ action: "archive",
984
+ name: "ghost",
985
+ });
986
+ expect(result).toContain("No active topic found with name 'ghost'");
987
+ });
988
+
989
+ test("archive without name returns error", async () => {
990
+ const result = await callTool("topics", { action: "archive" });
991
+ expect(result).toContain("Error");
992
+ expect(result).toContain("'name' is required");
993
+ });
994
+
995
+ // --- current ---
996
+
997
+ test("current returns active topic name", async () => {
998
+ await callTool("topics", { action: "create", name: "my topic" });
999
+ await callTool("topics", { action: "switch", name: "my topic" });
1000
+
1001
+ const result = await callTool("topics", { action: "current" });
1002
+ expect(result).toBe("my topic");
1003
+ });
1004
+
1005
+ test("current returns default when no topic selected", async () => {
1006
+ // Ensure any stale activeTopic from previous tests is cleared.
1007
+ // We must create the topic in the fresh DB before we can archive it.
1008
+ const current = getActiveTopic();
1009
+ if (current) {
1010
+ await callTool("topics", { action: "create", name: current });
1011
+ await callTool("topics", { action: "archive", name: current });
1012
+ }
1013
+
1014
+ const result = await callTool("topics", { action: "current" });
1015
+ expect(result).toContain("default");
1016
+ expect(result).toContain("no topic selected");
1017
+ });
1018
+
1019
+ // --- unknown action ---
1020
+
1021
+ test("unknown action returns error message", async () => {
1022
+ const result = await callTool("topics", { action: "rename" });
1023
+ expect(result).toContain("Unknown action: rename");
1024
+ });
1025
+
1026
+ // --- getActiveTopic / getTopicSessionId ---
1027
+
1028
+ test("getActiveTopic returns null when no topic set", async () => {
1029
+ const current = getActiveTopic();
1030
+ if (current) {
1031
+ await callTool("topics", { action: "create", name: current });
1032
+ await callTool("topics", { action: "archive", name: current });
1033
+ }
1034
+ expect(getActiveTopic()).toBeNull();
1035
+ });
1036
+
1037
+ test("getActiveTopic returns topic name after switch", async () => {
1038
+ await callTool("topics", { action: "create", name: "session test" });
1039
+ await callTool("topics", { action: "switch", name: "session test" });
1040
+ expect(getActiveTopic()).toBe("session test");
1041
+ });
1042
+
1043
+ test("getTopicSessionId returns 'owner' when no topic", async () => {
1044
+ const current = getActiveTopic();
1045
+ if (current) {
1046
+ await callTool("topics", { action: "create", name: current });
1047
+ await callTool("topics", { action: "archive", name: current });
1048
+ }
1049
+ expect(getTopicSessionId()).toBe("owner");
1050
+ });
1051
+
1052
+ test("getTopicSessionId returns sanitized ID for active topic", async () => {
1053
+ await callTool("topics", { action: "create", name: "Work Project!" });
1054
+ await callTool("topics", { action: "switch", name: "Work Project!" });
1055
+ const sessionId = getTopicSessionId();
1056
+ expect(sessionId).toBe("topic-work-project");
1057
+ // Should not contain special characters
1058
+ expect(sessionId).not.toContain("!");
1059
+ expect(sessionId).not.toContain(" ");
1060
+ });
1061
+
1062
+ test("getTopicSessionId handles leading/trailing special chars", async () => {
1063
+ await callTool("topics", { action: "create", name: "---test---" });
1064
+ await callTool("topics", { action: "switch", name: "---test---" });
1065
+ const sessionId = getTopicSessionId();
1066
+ expect(sessionId).toBe("topic-test");
1067
+ });
1068
+ });
1069
+
1070
+ // ===========================================================================
1071
+ // FOLLOW-UPS
1072
+ // ===========================================================================
1073
+
1074
+ describe("follow_ups tool", () => {
1075
+ // follow-ups captures the db reference at registration time, so we need
1076
+ // to re-register for each test with the fresh testDb. We'll use the
1077
+ // callTool helper which goes through the registry.
1078
+
1079
+ beforeEach(() => {
1080
+ // Re-register follow-ups with the new testDb
1081
+ unregisterTool("follow_ups");
1082
+ registerFollowUpsTool(testDb, fakeRouter, fakeConfig);
1083
+ followUpsRegistered = true;
1084
+ });
1085
+
1086
+ // --- schedule ---
1087
+
1088
+ test("schedule creates a follow-up and cron job", async () => {
1089
+ const result = await callTool("follow_ups", {
1090
+ action: "schedule",
1091
+ context: "dentist appointment",
1092
+ message: "How did the dentist go?",
1093
+ delay: "in 2 hours",
1094
+ });
1095
+ expect(result).toContain("Follow-up scheduled");
1096
+ expect(result).toContain("How did the dentist go?");
1097
+
1098
+ // Verify DB record
1099
+ const row = testDb
1100
+ .query("SELECT * FROM follow_ups WHERE id = 1")
1101
+ .get() as any;
1102
+ expect(row).toBeTruthy();
1103
+ expect(row.context).toBe("dentist appointment");
1104
+ expect(row.message).toBe("How did the dentist go?");
1105
+ expect(row.status).toBe("pending");
1106
+
1107
+ // Verify cron job was added
1108
+ expect(cronJobsAdded.length).toBe(1);
1109
+ expect(cronJobsAdded[0][1]).toBe("followup-1"); // cronName
1110
+ });
1111
+
1112
+ test("schedule adds 'in' prefix if missing from delay", async () => {
1113
+ await callTool("follow_ups", {
1114
+ action: "schedule",
1115
+ context: "interview",
1116
+ message: "How did the interview go?",
1117
+ delay: "2 hours",
1118
+ });
1119
+
1120
+ // Should still succeed (code prepends "in " if missing)
1121
+ expect(cronJobsAdded.length).toBe(1);
1122
+ });
1123
+
1124
+ test("schedule without required fields returns error", async () => {
1125
+ const result = await callTool("follow_ups", { action: "schedule" });
1126
+ expect(result).toContain("Error");
1127
+ expect(result).toContain("context, message, and delay are required");
1128
+ });
1129
+
1130
+ test("schedule with unparseable delay returns error", async () => {
1131
+ const result = await callTool("follow_ups", {
1132
+ action: "schedule",
1133
+ context: "test",
1134
+ message: "test message",
1135
+ delay: "invalid nonsense",
1136
+ });
1137
+ expect(result).toContain("Error");
1138
+ expect(result).toContain("does not look like a one-shot delay");
1139
+ });
1140
+
1141
+ // --- list ---
1142
+
1143
+ test("list shows pending follow-ups", async () => {
1144
+ await callTool("follow_ups", {
1145
+ action: "schedule",
1146
+ context: "doctor visit",
1147
+ message: "How was the doctor?",
1148
+ delay: "in 1 hour",
1149
+ });
1150
+ await callTool("follow_ups", {
1151
+ action: "schedule",
1152
+ context: "meeting",
1153
+ message: "How was the meeting?",
1154
+ delay: "in 3 hours",
1155
+ });
1156
+
1157
+ const result = await callTool("follow_ups", { action: "list" });
1158
+ expect(result).toContain("doctor visit");
1159
+ expect(result).toContain("How was the doctor?");
1160
+ expect(result).toContain("meeting");
1161
+ expect(result).toContain("How was the meeting?");
1162
+ });
1163
+
1164
+ test("list with no pending follow-ups returns message", async () => {
1165
+ const result = await callTool("follow_ups", { action: "list" });
1166
+ expect(result).toBe("No pending follow-ups.");
1167
+ });
1168
+
1169
+ test("list excludes cancelled follow-ups", async () => {
1170
+ await callTool("follow_ups", {
1171
+ action: "schedule",
1172
+ context: "cancelled item",
1173
+ message: "Should not appear",
1174
+ delay: "in 1 hour",
1175
+ });
1176
+ await callTool("follow_ups", { action: "cancel", id: 1 });
1177
+
1178
+ const result = await callTool("follow_ups", { action: "list" });
1179
+ expect(result).toBe("No pending follow-ups.");
1180
+ });
1181
+
1182
+ // --- cancel ---
1183
+
1184
+ test("cancel marks follow-up as cancelled and removes cron", async () => {
1185
+ await callTool("follow_ups", {
1186
+ action: "schedule",
1187
+ context: "something",
1188
+ message: "Ping",
1189
+ delay: "in 30 minutes",
1190
+ });
1191
+
1192
+ cronJobsRemoved.length = 0;
1193
+ const result = await callTool("follow_ups", { action: "cancel", id: 1 });
1194
+ expect(result).toBe("Follow-up #1 cancelled.");
1195
+
1196
+ // Verify DB status
1197
+ const row = testDb
1198
+ .query("SELECT status FROM follow_ups WHERE id = 1")
1199
+ .get() as any;
1200
+ expect(row.status).toBe("cancelled");
1201
+
1202
+ // Verify cron removal
1203
+ expect(cronJobsRemoved.length).toBe(1);
1204
+ expect(cronJobsRemoved[0][1]).toBe("followup-1");
1205
+ });
1206
+
1207
+ test("cancel without id returns error", async () => {
1208
+ const result = await callTool("follow_ups", { action: "cancel" });
1209
+ expect(result).toContain("Error");
1210
+ expect(result).toContain("id is required");
1211
+ });
1212
+
1213
+ test("cancel with nonexistent id returns not found", async () => {
1214
+ const result = await callTool("follow_ups", { action: "cancel", id: 999 });
1215
+ expect(result).toContain("No pending follow-up found with ID 999");
1216
+ });
1217
+
1218
+ test("cancel an already-cancelled follow-up returns not found", async () => {
1219
+ await callTool("follow_ups", {
1220
+ action: "schedule",
1221
+ context: "double cancel",
1222
+ message: "test",
1223
+ delay: "in 1 hour",
1224
+ });
1225
+ await callTool("follow_ups", { action: "cancel", id: 1 });
1226
+
1227
+ const result = await callTool("follow_ups", { action: "cancel", id: 1 });
1228
+ expect(result).toContain("No pending follow-up found with ID 1");
1229
+ });
1230
+
1231
+ // --- unknown action ---
1232
+
1233
+ test("unknown action returns error message", async () => {
1234
+ const result = await callTool("follow_ups", { action: "snooze" });
1235
+ expect(result).toContain("Unknown action: snooze");
1236
+ });
1237
+
1238
+ // --- executeTool integration ---
1239
+
1240
+ test("executeTool works for follow_ups schedule", async () => {
1241
+ const result = await executeTool("follow_ups", "fu-1", {
1242
+ action: "schedule",
1243
+ context: "gym session",
1244
+ message: "Did you work out?",
1245
+ delay: "in 4 hours",
1246
+ });
1247
+ expect(result.tool_use_id).toBe("fu-1");
1248
+ expect(result.is_error).toBe(false);
1249
+ expect(result.content).toContain("Follow-up scheduled");
1250
+ });
1251
+ });