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.
- package/.playwright-mcp/console-2026-02-16T19-06-21-167Z.log +31 -0
- package/README.md +2 -1
- package/dashboard-chat.png +0 -0
- package/dashboard-followups.png +0 -0
- package/dashboard-history.png +0 -0
- package/dashboard-integrations.png +0 -0
- package/dashboard-knowledge-ok.png +0 -0
- package/dashboard-knowledge.png +0 -0
- package/dashboard-notes-add.png +0 -0
- package/dashboard-notes-improved.png +0 -0
- package/dashboard-notes.png +0 -0
- package/dashboard-overview.png +0 -0
- package/dashboard-preferences.png +0 -0
- package/dashboard-settings-fixed.png +0 -0
- package/dashboard-settings.png +0 -0
- package/dashboard-skills-ok.png +0 -0
- package/dashboard-skills.png +0 -0
- package/dashboard-todos-add.png +0 -0
- package/dashboard-todos-improved.png +0 -0
- package/dashboard-todos-item.png +0 -0
- package/dashboard-todos-priority-badge.png +0 -0
- package/dashboard-todos.png +0 -0
- package/dashboard-topics.png +0 -0
- package/docs/ROADMAP.md +12 -49
- package/migrations/024_personal_features.sql +96 -0
- package/package.json +1 -1
- package/site/docs/index.html +11 -0
- package/site/docs/skills.html +107 -0
- package/site/index.html +9 -1
- package/src/agent/context.ts +3 -3
- package/src/agent/delegate.ts +7 -2
- package/src/agent/loop.ts +6 -6
- package/src/agent/prompts.ts +49 -1
- package/src/agent/workflow-executor.ts +5 -1
- package/src/channels/dashboard.html.ts +558 -6
- package/src/channels/webchat.ts +305 -27
- package/src/llm/claude-code.ts +58 -17
- package/src/llm/codex.ts +59 -18
- package/src/start.ts +12 -0
- package/src/tools/builtin/diagnose.ts +19 -5
- package/src/tools/builtin/follow-ups.ts +189 -0
- package/src/tools/builtin/notes.ts +207 -0
- package/src/tools/builtin/preferences.ts +173 -0
- package/src/tools/builtin/todos.ts +270 -0
- package/src/tools/builtin/topics.ts +166 -0
- package/src/tools/mcp-client.ts +8 -0
- package/src/tools/permissions.ts +7 -0
- package/tests/agent/session.test.ts +43 -45
- package/tests/mcp-registry.test.ts +32 -35
- package/tests/personal-features.test.ts +1251 -0
- package/tests/skill-registry.test.ts +1 -7
- package/tests/db/export.test.ts +0 -219
- package/tests/session.test.ts +0 -58
- package/tests/tools/executor.test.ts +0 -150
package/src/channels/webchat.ts
CHANGED
|
@@ -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\/[
|
|
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\/[
|
|
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\/[
|
|
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
|
-
|
|
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\/[
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
package/src/llm/claude-code.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
|
|
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(
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
52
|
+
return parts.join("\n\n");
|
|
39
53
|
},
|
|
40
54
|
});
|
|
41
55
|
}
|