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
@@ -18,22 +18,22 @@ function escapeHtml(s: string): string {
18
18
  }
19
19
 
20
20
  /** Convert raw error messages to user-friendly messages. Prevents leaking internal details. */
21
- function friendlyError(err: any): string {
22
- const msg = err?.message ?? String(err);
23
- if (msg.includes("401") || msg.includes("Unauthorized") || msg.includes("invalid"))
24
- return "Authentication failed. Check your API key in Settings > API Keys.";
25
- if (msg.includes("429") || msg.includes("rate limit"))
26
- return "Too many messages too quickly. Wait a moment and try again.";
27
- if (msg.includes("404") || msg.includes("not found"))
28
- return "The AI model wasn't found. Check your model name in Settings > AI Model.";
29
- if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("Connection refused"))
30
- return "Can't reach the AI service. Check your internet connection or try again.";
31
- if (msg.includes("timed out") || msg.includes("timeout"))
32
- return "The request took too long. The AI service may be busy — try again in a moment.";
33
- if (msg.includes("context") || msg.includes("too long"))
34
- return "Your message is too long. Try splitting it into shorter questions.";
35
- return "Something went wrong. Try again, or check Settings if this keeps happening.";
36
- }
21
+ function friendlyError(err: any): string {
22
+ const msg = err?.message ?? String(err);
23
+ if (msg.includes("401") || msg.includes("Unauthorized") || msg.includes("invalid"))
24
+ return "Authentication failed. Check your API key in Settings > API Keys.";
25
+ if (msg.includes("429") || msg.includes("rate limit"))
26
+ return "Too many messages too quickly. Wait a moment and try again.";
27
+ if (msg.includes("404") || msg.includes("not found"))
28
+ return "The AI model wasn't found. Check your model name in Settings > AI Model.";
29
+ if (msg.includes("ECONNREFUSED") || msg.includes("fetch failed") || msg.includes("Connection refused"))
30
+ return "Can't reach the AI service. Check your internet connection or try again.";
31
+ if (msg.includes("timed out") || msg.includes("timeout"))
32
+ return "The request took too long. The AI service may be busy — try again in a moment.";
33
+ if (msg.includes("context") || msg.includes("too long"))
34
+ return "Your message is too long. Try splitting it into shorter questions.";
35
+ return "Something went wrong. Try again, or check Settings if this keeps happening.";
36
+ }
37
37
 
38
38
  /** Add security headers to all HTTP responses */
39
39
  function addSecurityHeaders(res: Response): Response {
@@ -331,6 +331,260 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
331
331
  return Response.json({ skills: getSkillsData() });
332
332
  }
333
333
 
334
+ // ── TODOS ──
335
+
336
+ // GET /api/dashboard/todos?filter=pending|done|all
337
+ if (path === "/todos" && req.method === "GET") {
338
+ const db = getDb();
339
+ const filter = url.searchParams.get("filter") || "pending";
340
+ let query = "SELECT * FROM todos";
341
+ if (filter === "pending") query += " WHERE status != 'done'";
342
+ else if (filter === "done") query += " WHERE status = 'done'";
343
+ query += " ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 END, due_date ASC NULLS LAST";
344
+ return Response.json({ todos: db.query(query).all() });
345
+ }
346
+
347
+ // POST /api/dashboard/todos
348
+ if (path === "/todos" && req.method === "POST") {
349
+ return (async () => {
350
+ const body = (await req.json()) as any;
351
+ if (!body.title) return Response.json({ error: "title required" }, { status: 400 });
352
+ const db = getDb();
353
+ const tags = body.tags ? JSON.stringify(body.tags.split(",").map((t: string) => t.trim()).filter(Boolean)) : "[]";
354
+ const result = db.prepare(
355
+ "INSERT INTO todos (title, description, priority, due_date, tags) VALUES (?, ?, ?, ?, ?)"
356
+ ).run(body.title, body.description || null, body.priority || "medium", body.due_date || null, tags);
357
+ return Response.json({ ok: true, id: result.lastInsertRowid });
358
+ })() as any;
359
+ }
360
+
361
+ // PUT /api/dashboard/todos/:id
362
+ if (path.match(/^\/todos\/\d+$/) && req.method === "PUT") {
363
+ return (async () => {
364
+ const id = path.split("/").pop()!;
365
+ const body = (await req.json()) as any;
366
+ const db = getDb();
367
+ const sets: string[] = [];
368
+ const vals: any[] = [];
369
+ if (body.title !== undefined) { sets.push("title = ?"); vals.push(body.title); }
370
+ if (body.description !== undefined) { sets.push("description = ?"); vals.push(body.description || null); }
371
+ if (body.priority !== undefined) { sets.push("priority = ?"); vals.push(body.priority); }
372
+ if (body.due_date !== undefined) { sets.push("due_date = ?"); vals.push(body.due_date || null); }
373
+ if (body.status !== undefined) {
374
+ sets.push("status = ?"); vals.push(body.status);
375
+ if (body.status === "done") { sets.push("completed_at = datetime('now')"); }
376
+ }
377
+ if (body.tags !== undefined) {
378
+ sets.push("tags = ?");
379
+ vals.push(JSON.stringify(body.tags.split(",").map((t: string) => t.trim()).filter(Boolean)));
380
+ }
381
+ if (sets.length === 0) return Response.json({ error: "nothing to update" }, { status: 400 });
382
+ vals.push(id);
383
+ db.prepare("UPDATE todos SET " + sets.join(", ") + " WHERE id = ?").run(...vals);
384
+ return Response.json({ ok: true });
385
+ })() as any;
386
+ }
387
+
388
+ // DELETE /api/dashboard/todos/:id
389
+ if (path.match(/^\/todos\/\d+$/) && req.method === "DELETE") {
390
+ const id = path.split("/").pop()!;
391
+ const db = getDb();
392
+ db.prepare("DELETE FROM todos WHERE id = ?").run(id);
393
+ return Response.json({ ok: true });
394
+ }
395
+
396
+ // ── NOTES ──
397
+
398
+ // GET /api/dashboard/notes?q=search
399
+ if (path === "/notes" && req.method === "GET") {
400
+ const db = getDb();
401
+ const q = url.searchParams.get("q");
402
+ let rows;
403
+ if (q) {
404
+ try {
405
+ // Sanitize FTS5 special characters to prevent query syntax errors
406
+ const sanitized = q.replace(/["*(){}[\]:^~!@#$%&]/g, " ").trim();
407
+ if (sanitized) {
408
+ rows = db.prepare("SELECT n.* FROM notes n JOIN notes_fts f ON n.id = f.rowid WHERE notes_fts MATCH ? ORDER BY n.pinned DESC, rank").all(sanitized);
409
+ } else {
410
+ rows = db.query("SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC").all();
411
+ }
412
+ } catch {
413
+ // Fall back to LIKE query if FTS fails
414
+ rows = db.prepare("SELECT * FROM notes WHERE title LIKE ? OR content LIKE ? ORDER BY pinned DESC, updated_at DESC").all(`%${q}%`, `%${q}%`);
415
+ }
416
+ } else {
417
+ rows = db.query("SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC").all();
418
+ }
419
+ return Response.json({ notes: rows });
420
+ }
421
+
422
+ // POST /api/dashboard/notes
423
+ if (path === "/notes" && req.method === "POST") {
424
+ return (async () => {
425
+ const body = (await req.json()) as any;
426
+ if (!body.title || !body.content) return Response.json({ error: "title and content required" }, { status: 400 });
427
+ const db = getDb();
428
+ const tags = body.tags ? JSON.stringify(body.tags.split(",").map((t: string) => t.trim()).filter(Boolean)) : "[]";
429
+ const result = db.prepare("INSERT INTO notes (title, content, tags) VALUES (?, ?, ?)").run(body.title, body.content, tags);
430
+ return Response.json({ ok: true, id: result.lastInsertRowid });
431
+ })() as any;
432
+ }
433
+
434
+ // PUT /api/dashboard/notes/:id
435
+ if (path.match(/^\/notes\/\d+$/) && req.method === "PUT") {
436
+ return (async () => {
437
+ const id = path.split("/").pop()!;
438
+ const body = (await req.json()) as any;
439
+ const db = getDb();
440
+ const sets: string[] = [];
441
+ const vals: any[] = [];
442
+ if (body.title !== undefined) { sets.push("title = ?"); vals.push(body.title); }
443
+ if (body.content !== undefined) { sets.push("content = ?"); vals.push(body.content); }
444
+ if (body.pinned !== undefined) { sets.push("pinned = ?"); vals.push(body.pinned ? 1 : 0); }
445
+ if (body.tags !== undefined) {
446
+ sets.push("tags = ?");
447
+ vals.push(JSON.stringify(body.tags.split(",").map((t: string) => t.trim()).filter(Boolean)));
448
+ }
449
+ sets.push("updated_at = datetime('now')");
450
+ if (sets.length <= 1) return Response.json({ error: "nothing to update" }, { status: 400 });
451
+ vals.push(id);
452
+ db.prepare("UPDATE notes SET " + sets.join(", ") + " WHERE id = ?").run(...vals);
453
+ return Response.json({ ok: true });
454
+ })() as any;
455
+ }
456
+
457
+ // DELETE /api/dashboard/notes/:id
458
+ if (path.match(/^\/notes\/\d+$/) && req.method === "DELETE") {
459
+ const id = path.split("/").pop()!;
460
+ const db = getDb();
461
+ db.prepare("DELETE FROM notes WHERE id = ?").run(id);
462
+ return Response.json({ ok: true });
463
+ }
464
+
465
+ // ── PREFERENCES ──
466
+
467
+ // GET /api/dashboard/preferences
468
+ if (path === "/preferences" && req.method === "GET") {
469
+ const db = getDb();
470
+ const rows = db.query("SELECT * FROM user_preferences ORDER BY category, key").all();
471
+ return Response.json({ preferences: rows });
472
+ }
473
+
474
+ // POST /api/dashboard/preferences
475
+ if (path === "/preferences" && req.method === "POST") {
476
+ return (async () => {
477
+ const body = (await req.json()) as any;
478
+ if (!body.key || !body.value) return Response.json({ error: "key and value required" }, { status: 400 });
479
+ const db = getDb();
480
+ db.prepare(
481
+ "INSERT INTO user_preferences (category, key, value, source) VALUES (?, ?, ?, 'explicit') ON CONFLICT(category, key) DO UPDATE SET value = excluded.value, source = 'explicit', updated_at = datetime('now')"
482
+ ).run(body.category || "general", body.key, body.value);
483
+ return Response.json({ ok: true });
484
+ })() as any;
485
+ }
486
+
487
+ // DELETE /api/dashboard/preferences/:id
488
+ if (path.match(/^\/preferences\/\d+$/) && req.method === "DELETE") {
489
+ const id = path.split("/").pop()!;
490
+ const db = getDb();
491
+ db.prepare("DELETE FROM user_preferences WHERE id = ?").run(id);
492
+ return Response.json({ ok: true });
493
+ }
494
+
495
+ // ── TOPICS ──
496
+
497
+ // GET /api/dashboard/topics
498
+ if (path === "/topics" && req.method === "GET") {
499
+ const db = getDb();
500
+ const rows = db.query("SELECT * FROM conversation_topics ORDER BY last_message_at DESC").all();
501
+ return Response.json({ topics: rows });
502
+ }
503
+
504
+ // POST /api/dashboard/topics
505
+ if (path === "/topics" && req.method === "POST") {
506
+ return (async () => {
507
+ const body = (await req.json()) as any;
508
+ if (!body.name) return Response.json({ error: "name required" }, { status: 400 });
509
+ const db = getDb();
510
+ const result = db.prepare("INSERT INTO conversation_topics (name, description) VALUES (?, ?)").run(body.name, body.description || null);
511
+ return Response.json({ ok: true, id: result.lastInsertRowid });
512
+ })() as any;
513
+ }
514
+
515
+ // PUT /api/dashboard/topics/:id
516
+ if (path.match(/^\/topics\/\d+$/) && req.method === "PUT") {
517
+ return (async () => {
518
+ const id = path.split("/").pop()!;
519
+ const body = (await req.json()) as any;
520
+ const db = getDb();
521
+ const sets: string[] = [];
522
+ const vals: any[] = [];
523
+ if (body.status) { sets.push("status = ?"); vals.push(body.status); }
524
+ if (body.name) { sets.push("name = ?"); vals.push(body.name); }
525
+ if (body.description !== undefined) { sets.push("description = ?"); vals.push(body.description || null); }
526
+ if (sets.length > 0) {
527
+ sets.push("updated_at = datetime('now')");
528
+ vals.push(id);
529
+ db.prepare(`UPDATE conversation_topics SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
530
+ }
531
+ return Response.json({ ok: true });
532
+ })() as any;
533
+ }
534
+
535
+ // DELETE /api/dashboard/topics/:id
536
+ if (path.match(/^\/topics\/\d+$/) && req.method === "DELETE") {
537
+ const id = path.split("/").pop()!;
538
+ const db = getDb();
539
+ db.prepare("DELETE FROM conversation_topics WHERE id = ?").run(id);
540
+ return Response.json({ ok: true });
541
+ }
542
+
543
+ // ── FOLLOW-UPS ──
544
+
545
+ // GET /api/dashboard/followups
546
+ if (path === "/followups" && req.method === "GET") {
547
+ const db = getDb();
548
+ const rows = db.query("SELECT * FROM follow_ups ORDER BY follow_up_at ASC").all();
549
+ return Response.json({ followups: rows });
550
+ }
551
+
552
+ // POST /api/dashboard/followups
553
+ if (path === "/followups" && req.method === "POST") {
554
+ return (async () => {
555
+ const body = (await req.json()) as any;
556
+ if (!body.context || !body.message || !body.follow_up_at) return Response.json({ error: "context, message, and follow_up_at required" }, { status: 400 });
557
+ const db = getDb();
558
+ const result = db.prepare("INSERT INTO follow_ups (context, message, follow_up_at) VALUES (?, ?, ?)").run(body.context, body.message, body.follow_up_at);
559
+ return Response.json({ ok: true, id: result.lastInsertRowid });
560
+ })() as any;
561
+ }
562
+
563
+ // PUT /api/dashboard/followups/:id
564
+ if (path.match(/^\/followups\/\d+$/) && req.method === "PUT") {
565
+ return (async () => {
566
+ const id = path.split("/").pop()!;
567
+ const body = (await req.json()) as any;
568
+ const db = getDb();
569
+ const sets: string[] = [];
570
+ const vals: any[] = [];
571
+ if (body.status) { sets.push("status = ?"); vals.push(body.status); }
572
+ if (sets.length > 0) {
573
+ vals.push(id);
574
+ db.prepare(`UPDATE follow_ups SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
575
+ }
576
+ return Response.json({ ok: true });
577
+ })() as any;
578
+ }
579
+
580
+ // DELETE /api/dashboard/followups/:id
581
+ if (path.match(/^\/followups\/\d+$/) && req.method === "DELETE") {
582
+ const id = path.split("/").pop()!;
583
+ const db = getDb();
584
+ db.prepare("DELETE FROM follow_ups WHERE id = ?").run(id);
585
+ return Response.json({ ok: true });
586
+ }
587
+
334
588
  // GET /api/dashboard/config
335
589
  if (path === "/config" && req.method === "GET") {
336
590
  return Response.json(getConfigInfo());
@@ -1262,9 +1516,9 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
1262
1516
  }
1263
1517
 
1264
1518
  // PUT /api/dashboard/threads/:id — rename thread
1265
- if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "PUT") {
1519
+ if (path.match(/^\/threads\/[^/]+$/) && req.method === "PUT") {
1266
1520
  return (async () => {
1267
- const threadId = path.split("/").pop()!;
1521
+ const threadId = decodeURIComponent(path.split("/").pop()!);
1268
1522
  const { title } = await req.json();
1269
1523
  const db = getDb();
1270
1524
  db.prepare(
@@ -1275,8 +1529,8 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
1275
1529
  }
1276
1530
 
1277
1531
  // DELETE /api/dashboard/threads/:id — delete thread and session file
1278
- if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "DELETE") {
1279
- const threadId = path.split("/").pop()!;
1532
+ if (path.match(/^\/threads\/[^/]+$/) && req.method === "DELETE") {
1533
+ const threadId = decodeURIComponent(path.split("/").pop()!);
1280
1534
  try {
1281
1535
  const db = getDb();
1282
1536
  db.prepare("DELETE FROM threads WHERE id = ?").run(threadId);
@@ -1289,21 +1543,45 @@ async function handleDashboardApi(url: URL, req: Request): Promise<Response | nu
1289
1543
  }
1290
1544
 
1291
1545
  // GET /api/dashboard/threads/:id/messages — get thread messages
1292
- if (path.match(/^\/threads\/[a-f0-9-]+\/messages$/) && req.method === "GET") {
1546
+ if (path.match(/^\/threads\/[^/]+\/messages$/) && req.method === "GET") {
1293
1547
  return (async () => {
1294
- const threadId = path.split("/")[2];
1548
+ const threadId = decodeURIComponent(path.split("/")[2]);
1295
1549
  const { loadSession } = await import("../agent/session");
1296
- const messages = loadSession(threadId, 100);
1550
+ let messages = loadSession(threadId, 100);
1551
+
1552
+ // Fallback: if no session file, load from conversation_messages DB
1553
+ if (messages.length === 0) {
1554
+ try {
1555
+ const db = getDb();
1556
+ const rows = db.query(
1557
+ "SELECT role, content FROM conversation_messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT 100"
1558
+ ).all(threadId) as Array<{ role: string; content: string }>;
1559
+ messages = rows.map(r => ({ role: r.role as "user" | "assistant", content: r.content }));
1560
+ } catch {}
1561
+ }
1562
+
1297
1563
  return Response.json({ messages });
1298
1564
  })() as any;
1299
1565
  }
1300
1566
 
1301
1567
  // GET /api/dashboard/threads/:id/export — export thread as markdown
1302
- if (path.match(/^\/threads\/[a-f0-9-]+\/export$/) && req.method === "GET") {
1568
+ if (path.match(/^\/threads\/[^/]+\/export$/) && req.method === "GET") {
1303
1569
  return (async () => {
1304
- const threadId = path.split("/")[2];
1570
+ const threadId = decodeURIComponent(path.split("/")[2]);
1305
1571
  const { loadSession } = await import("../agent/session");
1306
- const messages = loadSession(threadId, 1000);
1572
+ let messages = loadSession(threadId, 1000);
1573
+
1574
+ // Fallback: if no session file, load from conversation_messages DB
1575
+ if (messages.length === 0) {
1576
+ try {
1577
+ const db2 = getDb();
1578
+ const rows = db2.query(
1579
+ "SELECT role, content FROM conversation_messages WHERE thread_id = ? ORDER BY timestamp ASC LIMIT 1000"
1580
+ ).all(threadId) as Array<{ role: string; content: string }>;
1581
+ messages = rows.map(r => ({ role: r.role as "user" | "assistant", content: r.content }));
1582
+ } catch {}
1583
+ }
1584
+
1307
1585
  const db = getDb();
1308
1586
  const thread = db.query(
1309
1587
  "SELECT title FROM threads WHERE id = ?"
@@ -2925,7 +3203,7 @@ async function handleRequest(
2925
3203
  }
2926
3204
 
2927
3205
  // Validate threadId format to prevent path traversal
2928
- if (body.threadId && !/^[a-f0-9-]{36}$/.test(body.threadId)) {
3206
+ if (body.threadId && (/[\/\\\0]/.test(body.threadId) || body.threadId.includes(".."))) {
2929
3207
  return Response.json({ error: "Invalid thread ID" }, { status: 400 });
2930
3208
  }
2931
3209
 
@@ -2,6 +2,35 @@ import { randomUUID } from "crypto";
2
2
  import type { LlmProvider, LlmRequest, LlmResponse, LlmContentBlock } from "./provider";
3
3
  import { logger } from "../util/logger";
4
4
 
5
+ /** Try to extract a tool_use JSON from the response, handling markdown fences and surrounding text. */
6
+ function extractToolUse(text: string): { name: string; input: Record<string, unknown> } | null {
7
+ // 1. Try exact parse
8
+ try {
9
+ const parsed = JSON.parse(text);
10
+ if (parsed.tool_use?.name) return parsed.tool_use;
11
+ } catch {}
12
+
13
+ // 2. Try extracting from markdown code fences
14
+ const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
15
+ if (fenceMatch) {
16
+ try {
17
+ const parsed = JSON.parse(fenceMatch[1].trim());
18
+ if (parsed.tool_use?.name) return parsed.tool_use;
19
+ } catch {}
20
+ }
21
+
22
+ // 3. Try finding a JSON object with "tool_use" anywhere in the text
23
+ const braceMatch = text.match(/\{[\s\S]*"tool_use"[\s\S]*\}/);
24
+ if (braceMatch) {
25
+ try {
26
+ const parsed = JSON.parse(braceMatch[0]);
27
+ if (parsed.tool_use?.name) return parsed.tool_use;
28
+ } catch {}
29
+ }
30
+
31
+ return null;
32
+ }
33
+
5
34
  /**
6
35
  * LLM provider that uses Claude Code CLI as the backend.
7
36
  * Spawns `claude -p <prompt> --output-format json` for each request.
@@ -49,7 +78,21 @@ export class ClaudeCodeProvider implements LlmProvider {
49
78
  const toolHint = request.tools.map(t =>
50
79
  `Tool: ${t.name} - ${t.description}\nParameters: ${JSON.stringify(t.input_schema)}`
51
80
  ).join("\n\n");
52
- parts.push(`\nAvailable tools:\n${toolHint}\n\nTo use a tool, respond with JSON: {"tool_use": {"name": "tool_name", "input": {...}}}`);
81
+ parts.push([
82
+ `\n## TOOLS`,
83
+ `You have the following tools available. When the user's request can be fulfilled by a tool, you MUST use it.`,
84
+ `Do NOT say you can't do something if a matching tool exists — call it instead.\n`,
85
+ toolHint,
86
+ `\n## HOW TO CALL A TOOL`,
87
+ `Respond with ONLY this JSON (no markdown, no explanation, no wrapping):`,
88
+ `{"tool_use": {"name": "TOOL_NAME", "input": {PARAMETERS}}}`,
89
+ ``,
90
+ `Example — user says "add buy milk to my todos":`,
91
+ `{"tool_use": {"name": "todos", "input": {"action": "add", "title": "Buy milk"}}}`,
92
+ ``,
93
+ `CRITICAL: Output raw JSON only. No \`\`\`json blocks. No text before or after. Just the JSON object.`,
94
+ `If you don't need a tool, respond normally with text.`,
95
+ ].join("\n"));
53
96
  }
54
97
 
55
98
  const prompt = parts.join("\n\n");
@@ -97,22 +140,20 @@ export class ClaudeCodeProvider implements LlmProvider {
97
140
 
98
141
  // Check if the response contains tool_use JSON
99
142
  const content: LlmContentBlock[] = [];
100
- try {
101
- const maybeToolUse = JSON.parse(responseText);
102
- if (maybeToolUse.tool_use) {
103
- content.push({
104
- type: "tool_use",
105
- id: `cc_${randomUUID()}`,
106
- name: maybeToolUse.tool_use.name,
107
- input: maybeToolUse.tool_use.input ?? {},
108
- });
109
- return {
110
- content,
111
- stopReason: "tool_use",
112
- usage: { inputTokens: 0, outputTokens: 0 },
113
- };
114
- }
115
- } catch {}
143
+ const toolUse = extractToolUse(responseText);
144
+ if (toolUse) {
145
+ content.push({
146
+ type: "tool_use",
147
+ id: `cc_${randomUUID()}`,
148
+ name: toolUse.name,
149
+ input: toolUse.input ?? {},
150
+ });
151
+ return {
152
+ content,
153
+ stopReason: "tool_use",
154
+ usage: { inputTokens: 0, outputTokens: 0 },
155
+ };
156
+ }
116
157
 
117
158
  content.push({ type: "text", text: responseText });
118
159
 
package/src/llm/codex.ts CHANGED
@@ -2,6 +2,35 @@ import { randomUUID } from "crypto";
2
2
  import type { LlmProvider, LlmRequest, LlmResponse, LlmContentBlock } from "./provider";
3
3
  import { logger } from "../util/logger";
4
4
 
5
+ /** Try to extract a tool_use JSON from the response, handling markdown fences and surrounding text. */
6
+ function extractToolUse(text: string): { name: string; input: Record<string, unknown> } | null {
7
+ // 1. Try exact parse
8
+ try {
9
+ const parsed = JSON.parse(text);
10
+ if (parsed.tool_use?.name) return parsed.tool_use;
11
+ } catch {}
12
+
13
+ // 2. Try extracting from markdown code fences: ```json ... ``` or ``` ... ```
14
+ const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
15
+ if (fenceMatch) {
16
+ try {
17
+ const parsed = JSON.parse(fenceMatch[1].trim());
18
+ if (parsed.tool_use?.name) return parsed.tool_use;
19
+ } catch {}
20
+ }
21
+
22
+ // 3. Try finding a JSON object with "tool_use" anywhere in the text
23
+ const braceMatch = text.match(/\{[\s\S]*"tool_use"[\s\S]*\}/);
24
+ if (braceMatch) {
25
+ try {
26
+ const parsed = JSON.parse(braceMatch[0]);
27
+ if (parsed.tool_use?.name) return parsed.tool_use;
28
+ } catch {}
29
+ }
30
+
31
+ return null;
32
+ }
33
+
5
34
  /**
6
35
  * LLM provider that uses OpenAI Codex CLI as the backend.
7
36
  * Spawns `codex -q <prompt>` for each request.
@@ -46,7 +75,21 @@ export class CodexProvider implements LlmProvider {
46
75
  const toolHint = request.tools.map(t =>
47
76
  `Tool: ${t.name} - ${t.description}\nParameters: ${JSON.stringify(t.input_schema)}`
48
77
  ).join("\n\n");
49
- parts.push(`\nAvailable tools:\n${toolHint}\n\nTo use a tool, respond with JSON: {"tool_use": {"name": "tool_name", "input": {...}}}`);
78
+ parts.push([
79
+ `\n## TOOLS`,
80
+ `You have the following tools available. When the user's request can be fulfilled by a tool, you MUST use it.`,
81
+ `Do NOT say you can't do something if a matching tool exists — call it instead.\n`,
82
+ toolHint,
83
+ `\n## HOW TO CALL A TOOL`,
84
+ `Respond with ONLY this JSON (no markdown, no explanation, no wrapping):`,
85
+ `{"tool_use": {"name": "TOOL_NAME", "input": {PARAMETERS}}}`,
86
+ ``,
87
+ `Example — user says "add buy milk to my todos":`,
88
+ `{"tool_use": {"name": "todos", "input": {"action": "add", "title": "Buy milk"}}}`,
89
+ ``,
90
+ `CRITICAL: Output raw JSON only. No \`\`\`json blocks. No text before or after. Just the JSON object.`,
91
+ `If you don't need a tool, respond normally with text.`,
92
+ ].join("\n"));
50
93
  }
51
94
 
52
95
  const prompt = parts.join("\n\n");
@@ -83,23 +126,21 @@ export class CodexProvider implements LlmProvider {
83
126
 
84
127
  const content: LlmContentBlock[] = [];
85
128
 
86
- // Check for tool_use response
87
- try {
88
- const maybeToolUse = JSON.parse(responseText);
89
- if (maybeToolUse.tool_use) {
90
- content.push({
91
- type: "tool_use",
92
- id: `cx_${randomUUID()}`,
93
- name: maybeToolUse.tool_use.name,
94
- input: maybeToolUse.tool_use.input ?? {},
95
- });
96
- return {
97
- content,
98
- stopReason: "tool_use",
99
- usage: { inputTokens: 0, outputTokens: 0 },
100
- };
101
- }
102
- } catch {}
129
+ // Check for tool_use response — try exact JSON first, then extract from text
130
+ const toolUse = extractToolUse(responseText);
131
+ if (toolUse) {
132
+ content.push({
133
+ type: "tool_use",
134
+ id: `cx_${randomUUID()}`,
135
+ name: toolUse.name,
136
+ input: toolUse.input ?? {},
137
+ });
138
+ return {
139
+ content,
140
+ stopReason: "tool_use",
141
+ usage: { inputTokens: 0, outputTokens: 0 },
142
+ };
143
+ }
103
144
 
104
145
  content.push({ type: "text", text: responseText });
105
146
 
package/src/start.ts CHANGED
@@ -26,6 +26,10 @@ import { createRouter, type MessageRouter } from "./channels/router";
26
26
  import { startHeartbeat } from "./scheduler/heartbeat";
27
27
  import { initCronScheduler } from "./scheduler/cron";
28
28
  import { initMemory } from "./memory/engine";
29
+ import { registerTodosTool } from "./tools/builtin/todos";
30
+ import { registerNotesTool } from "./tools/builtin/notes";
31
+ import { registerPreferencesTool } from "./tools/builtin/preferences";
32
+ import { registerTopicsTool } from "./tools/builtin/topics";
29
33
  import { logger, enableFileLogging } from "./util/logger";
30
34
 
31
35
  function openBrowser(url: string) {
@@ -227,6 +231,14 @@ export async function startZubo(isDaemon = false) {
227
231
  const { registerManageTriggersTool } = await import("./tools/builtin/manage-triggers");
228
232
  registerManageTriggersTool();
229
233
 
234
+ // Register personal agent tools
235
+ registerTodosTool();
236
+ registerNotesTool();
237
+ registerPreferencesTool();
238
+ registerTopicsTool();
239
+ const { registerFollowUpsTool } = await import("./tools/builtin/follow-ups");
240
+ registerFollowUpsTool(db, router, config, llm);
241
+
230
242
  // Register code interpreter tool
231
243
  if (config.codeInterpreter?.enabled !== false) {
232
244
  const codeInterpreterHandler = (await import("./tools/builtin-skills/code-interpreter/handler")).default;
@@ -27,15 +27,29 @@ export function registerDiagnoseTool(): void {
27
27
  ? errors.filter((e) => e.source.includes(category))
28
28
  : errors;
29
29
 
30
- if (filtered.length === 0) {
30
+ const parts: string[] = [];
31
+
32
+ // Check for failed MCP servers
33
+ try {
34
+ const { getFailedMcpServers } = await import("../mcp-client");
35
+ const failed = getFailedMcpServers();
36
+ if (failed.length > 0) {
37
+ parts.push(`MCP servers failed to start (${failed.length}):\n${failed.map(f => ` - ${f.name}: ${f.error}`).join("\n")}`);
38
+ }
39
+ } catch {}
40
+
41
+ if (filtered.length === 0 && parts.length === 0) {
31
42
  return "No recent errors found. System appears healthy.";
32
43
  }
33
44
 
34
- const summary = filtered
35
- .map((e) => `[${e.timestamp}] ${e.source}: ${e.message}`)
36
- .join("\n");
45
+ if (filtered.length > 0) {
46
+ const summary = filtered
47
+ .map((e) => `[${e.timestamp}] ${e.source}: ${e.message}`)
48
+ .join("\n");
49
+ parts.push(`Recent errors (${filtered.length}):\n${summary}`);
50
+ }
37
51
 
38
- return `Found ${filtered.length} recent error(s):\n${summary}`;
52
+ return parts.join("\n\n");
39
53
  },
40
54
  });
41
55
  }