funolio-agent 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/backfill.d.ts +71 -0
  2. package/dist/backfill.d.ts.map +1 -0
  3. package/dist/backfill.js +637 -0
  4. package/dist/backfill.js.map +1 -0
  5. package/dist/clerk-model.d.ts +28 -2
  6. package/dist/clerk-model.d.ts.map +1 -1
  7. package/dist/clerk-model.js +90 -29
  8. package/dist/clerk-model.js.map +1 -1
  9. package/dist/cli.js +36 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config-cleanup.d.ts +35 -0
  12. package/dist/config-cleanup.d.ts.map +1 -0
  13. package/dist/config-cleanup.js +167 -0
  14. package/dist/config-cleanup.js.map +1 -0
  15. package/dist/context-window.d.ts +79 -0
  16. package/dist/context-window.d.ts.map +1 -0
  17. package/dist/context-window.js +192 -0
  18. package/dist/context-window.js.map +1 -0
  19. package/dist/local-data.d.ts +110 -0
  20. package/dist/local-data.d.ts.map +1 -1
  21. package/dist/local-data.js +219 -3
  22. package/dist/local-data.js.map +1 -1
  23. package/dist/local-db.d.ts.map +1 -1
  24. package/dist/local-db.js +116 -3
  25. package/dist/local-db.js.map +1 -1
  26. package/dist/local-server.d.ts.map +1 -1
  27. package/dist/local-server.js +567 -29
  28. package/dist/local-server.js.map +1 -1
  29. package/dist/mcp/claude-config-writer.d.ts +28 -0
  30. package/dist/mcp/claude-config-writer.d.ts.map +1 -0
  31. package/dist/mcp/claude-config-writer.js +125 -0
  32. package/dist/mcp/claude-config-writer.js.map +1 -0
  33. package/dist/mcp/local-memory-server.d.ts.map +1 -1
  34. package/dist/mcp/local-memory-server.js +27 -2
  35. package/dist/mcp/local-memory-server.js.map +1 -1
  36. package/dist/mcp/manager.d.ts +4 -0
  37. package/dist/mcp/manager.d.ts.map +1 -1
  38. package/dist/mcp/manager.js +19 -0
  39. package/dist/mcp/manager.js.map +1 -1
  40. package/dist/mcp/marketplace.d.ts +52 -0
  41. package/dist/mcp/marketplace.d.ts.map +1 -0
  42. package/dist/mcp/marketplace.js +144 -0
  43. package/dist/mcp/marketplace.js.map +1 -0
  44. package/dist/orchestration/safeguards.d.ts +29 -0
  45. package/dist/orchestration/safeguards.d.ts.map +1 -0
  46. package/dist/orchestration/safeguards.js +32 -0
  47. package/dist/orchestration/safeguards.js.map +1 -0
  48. package/dist/orchestration/status-parser.d.ts +10 -6
  49. package/dist/orchestration/status-parser.d.ts.map +1 -1
  50. package/dist/orchestration/status-parser.js +35 -19
  51. package/dist/orchestration/status-parser.js.map +1 -1
  52. package/dist/orchestration/topic-normalizer.d.ts +15 -0
  53. package/dist/orchestration/topic-normalizer.d.ts.map +1 -0
  54. package/dist/orchestration/topic-normalizer.js +65 -0
  55. package/dist/orchestration/topic-normalizer.js.map +1 -0
  56. package/dist/providers/claude-cli.d.ts.map +1 -1
  57. package/dist/providers/claude-cli.js +8 -1
  58. package/dist/providers/claude-cli.js.map +1 -1
  59. package/dist/summarization-pipeline.d.ts +39 -0
  60. package/dist/summarization-pipeline.d.ts.map +1 -0
  61. package/dist/summarization-pipeline.js +280 -0
  62. package/dist/summarization-pipeline.js.map +1 -0
  63. package/dist/tools/search-local-memory.d.ts.map +1 -1
  64. package/dist/tools/search-local-memory.js +34 -2
  65. package/dist/tools/search-local-memory.js.map +1 -1
  66. package/dist/workflow-engine.d.ts +6 -0
  67. package/dist/workflow-engine.d.ts.map +1 -1
  68. package/dist/workflow-engine.js +174 -11
  69. package/dist/workflow-engine.js.map +1 -1
  70. package/package.json +1 -1
@@ -54,8 +54,17 @@ const local_funnel_1 = require("./local-funnel");
54
54
  const local_import_worker_1 = require("./local-import-worker");
55
55
  const clerk_model_1 = require("./clerk-model");
56
56
  const workflow_engine_1 = require("./workflow-engine");
57
+ const context_window_1 = require("./context-window");
58
+ const summarization_pipeline_1 = require("./summarization-pipeline");
59
+ const backfill_1 = require("./backfill");
60
+ const config_cleanup_1 = require("./config-cleanup");
57
61
  const status_parser_1 = require("./orchestration/status-parser");
58
62
  const guided_actions_1 = require("./orchestration/guided-actions");
63
+ const topic_normalizer_1 = require("./orchestration/topic-normalizer");
64
+ const manager_1 = require("./mcp/manager");
65
+ const registry_1 = require("./mcp/registry");
66
+ const marketplace_1 = require("./mcp/marketplace");
67
+ const claude_config_writer_1 = require("./mcp/claude-config-writer");
59
68
  const chalk_1 = __importDefault(require("chalk"));
60
69
  const path = __importStar(require("path"));
61
70
  const fs = __importStar(require("fs"));
@@ -128,6 +137,16 @@ function startLocalServer(opts) {
128
137
  catch (err) {
129
138
  console.error(chalk_1.default.yellow(` Failed to seed agent profiles: ${err}`));
130
139
  }
140
+ // Ensure there is always an "unassigned" project and no NULL project_id rows.
141
+ try {
142
+ const moved = data.moveNullConversationsToUnassigned();
143
+ if (moved.moved > 0) {
144
+ console.log(chalk_1.default.gray(` Assigned ${moved.moved} conversation(s) to "${data.UNASSIGNED_PROJECT_NAME}"`));
145
+ }
146
+ }
147
+ catch (err) {
148
+ console.error(chalk_1.default.yellow(` Failed to normalize unassigned conversations: ${err}`));
149
+ }
131
150
  // ─── Health ──────────────────────────────────────────────────
132
151
  app.get('/api/health', (_req, res) => {
133
152
  try {
@@ -294,6 +313,7 @@ function startLocalServer(opts) {
294
313
  // ─── Projects ──────────────────────────────────────────────
295
314
  app.get('/api/projects', (req, res) => {
296
315
  try {
316
+ data.moveNullConversationsToUnassigned();
297
317
  const projects = data.listProjects();
298
318
  if (req.query.include === 'conversations') {
299
319
  const result = projects.map(p => {
@@ -337,6 +357,7 @@ function startLocalServer(opts) {
337
357
  const deleted = data.deleteProject(req.params.id);
338
358
  if (!deleted)
339
359
  return res.status(404).json({ error: 'Not found' });
360
+ data.moveNullConversationsToUnassigned();
340
361
  res.json({ ok: true });
341
362
  }
342
363
  catch (err) {
@@ -481,6 +502,67 @@ function startLocalServer(opts) {
481
502
  res.status(500).json({ error: err.message });
482
503
  }
483
504
  });
505
+ // ─── Topic Operations (Phase 4) ────────────────────────────
506
+ /**
507
+ * GET /api/topics/:id/conversations — conversations linked via topic_segment
508
+ */
509
+ app.get('/api/topics/:id/conversations', (req, res) => {
510
+ try {
511
+ const limit = parseInt(req.query.limit, 10) || 50;
512
+ const offset = parseInt(req.query.offset, 10) || 0;
513
+ const convs = data.listTopicConversations(req.params.id, { limit, offset });
514
+ res.json(convs);
515
+ }
516
+ catch (err) {
517
+ res.status(500).json({ error: err.message });
518
+ }
519
+ });
520
+ /**
521
+ * POST /api/topics/:id/move — move topic to a different project
522
+ */
523
+ app.post('/api/topics/:id/move', (req, res) => {
524
+ try {
525
+ const { toProjectId } = req.body;
526
+ if (!toProjectId)
527
+ return res.status(400).json({ error: 'toProjectId required' });
528
+ const updated = data.moveTopic(req.params.id, toProjectId);
529
+ if (!updated)
530
+ return res.status(404).json({ error: 'Topic not found' });
531
+ res.json(updated);
532
+ }
533
+ catch (err) {
534
+ res.status(500).json({ error: err.message });
535
+ }
536
+ });
537
+ /**
538
+ * POST /api/topics/:id/merge — merge source topic into target
539
+ */
540
+ app.post('/api/topics/:id/merge', (req, res) => {
541
+ try {
542
+ const { targetTopicId } = req.body;
543
+ if (!targetTopicId)
544
+ return res.status(400).json({ error: 'targetTopicId required' });
545
+ data.mergeTopics(req.params.id, targetTopicId);
546
+ res.json({ ok: true });
547
+ }
548
+ catch (err) {
549
+ res.status(500).json({ error: err.message });
550
+ }
551
+ });
552
+ /**
553
+ * GET /api/conversations/:id/messages — paginated messages for a conversation
554
+ */
555
+ app.get('/api/conversations/:id/messages', (req, res) => {
556
+ try {
557
+ const limit = parseInt(req.query.limit, 10) || 25;
558
+ const offset = parseInt(req.query.offset, 10) || 0;
559
+ const msgs = data.getMessages(req.params.id, { limit, offset });
560
+ res.json(msgs);
561
+ }
562
+ catch (err) {
563
+ res.status(500).json({ error: err.message });
564
+ }
565
+ });
484
566
  // ─── File Upload ───────────────────────────────────────────
485
567
  app.post('/api/chat/upload', async (req, res) => {
486
568
  try {
@@ -565,6 +647,7 @@ function startLocalServer(opts) {
565
647
  // Save user message (skip if multi-bot call where first bot already saved it)
566
648
  if (!skipUserMessage) {
567
649
  data.addMessage(convId, 'user', message);
650
+ (0, context_window_1.incrementTurnCount)(convId);
568
651
  }
569
652
  // Load conversation history
570
653
  const history = data.getMessages(convId, { limit: 100 });
@@ -577,9 +660,14 @@ function startLocalServer(opts) {
577
660
  let systemPrompt;
578
661
  const clerk = (0, clerk_model_1.getClerk)();
579
662
  if (clerk) {
580
- const built = clerk.buildPrompt(message, profile.id, profile, { targetModel: profile.model });
663
+ const conv = data.getConversation(convId);
664
+ const built = clerk.buildPrompt(message, profile.id, profile, {
665
+ targetModel: profile.model,
666
+ conversationId: convId,
667
+ projectName: conv?.project_name || undefined,
668
+ });
581
669
  systemPrompt = built.systemPrompt;
582
- console.log(chalk_1.default.gray(` [clerk] Context: ${built.injectedFacts} facts, ${built.injectedDecisions} decisions (${built.contextTokensUsed} tokens)`));
670
+ console.log(chalk_1.default.gray(` [clerk] Context: ${built.injectedFacts} facts, ${built.injectedDecisions} decisions, ${built.injectedSummaries} summaries (${built.contextTokensUsed} tokens)`));
583
671
  }
584
672
  else {
585
673
  // Fallback: manual prompt building
@@ -710,6 +798,10 @@ function startLocalServer(opts) {
710
798
  }
711
799
  }
712
800
  (0, local_funnel_1.scheduleFunnelProcessing)(convId);
801
+ // Summarization: trigger after every 5-turn boundary (fire-and-forget)
802
+ if ((0, context_window_1.shouldSummarize)(convId)) {
803
+ (0, summarization_pipeline_1.runSummarization)(convId).catch(err => console.error(chalk_1.default.yellow(` [summarization] ${err.message}`)));
804
+ }
713
805
  }
714
806
  catch (err) {
715
807
  console.error(chalk_1.default.red(`Chat error: ${err.message}`));
@@ -728,16 +820,49 @@ function startLocalServer(opts) {
728
820
  // ─── Memory Facts ───────────────────────────────────────────
729
821
  app.get('/api/memory/facts', (req, res) => {
730
822
  try {
731
- const { agentId, factType, limit, offset, search } = req.query;
823
+ const { agentId, factType, limit, offset, search, groupByProject } = req.query;
824
+ const limitN = limit ? parseInt(limit) : 100;
825
+ const offsetN = offset ? parseInt(offset) : 0;
826
+ const grouped = groupByProject === '1' || groupByProject === 'true';
827
+ if (grouped) {
828
+ const db = data.getDb();
829
+ const wheres = [];
830
+ const params = [];
831
+ if (agentId) {
832
+ wheres.push('f.agent_id = ?');
833
+ params.push(agentId);
834
+ }
835
+ if (factType) {
836
+ wheres.push('f.fact_type = ?');
837
+ params.push(factType);
838
+ }
839
+ if (search) {
840
+ wheres.push('(f.content LIKE ? OR COALESCE(c.title, \'\') LIKE ?)');
841
+ params.push(`%${search}%`, `%${search}%`);
842
+ }
843
+ let sql = `
844
+ SELECT f.*, c.title as conversation_title, c.project_id,
845
+ COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
846
+ FROM memory_fact f
847
+ LEFT JOIN conversation c ON f.conversation_id = c.id
848
+ LEFT JOIN project p ON c.project_id = p.id
849
+ `;
850
+ if (wheres.length > 0)
851
+ sql += ` WHERE ${wheres.join(' AND ')}`;
852
+ sql += ' ORDER BY f.created_at DESC LIMIT ? OFFSET ?';
853
+ params.push(limitN, offsetN);
854
+ const rows = db.prepare(sql).all(...params);
855
+ return res.json(rows);
856
+ }
732
857
  if (search && agentId) {
733
- const facts = data.searchMemoryFacts(search, agentId, limit ? parseInt(limit) : 50);
858
+ const facts = data.searchMemoryFacts(search, agentId, limitN);
734
859
  return res.json(facts);
735
860
  }
736
861
  const facts = data.listMemoryFacts({
737
862
  agentId: agentId || undefined,
738
863
  factType: factType || undefined,
739
- limit: limit ? parseInt(limit) : undefined,
740
- offset: offset ? parseInt(offset) : undefined,
864
+ limit: limitN,
865
+ offset: offsetN,
741
866
  });
742
867
  res.json(facts);
743
868
  }
@@ -770,7 +895,27 @@ function startLocalServer(opts) {
770
895
  });
771
896
  app.get('/api/memory/decisions', (req, res) => {
772
897
  try {
773
- const { conversationId, limit } = req.query;
898
+ const { conversationId, limit, groupByProject } = req.query;
899
+ const grouped = groupByProject === '1' || groupByProject === 'true';
900
+ if (grouped) {
901
+ const db = data.getDb();
902
+ const params = [];
903
+ let sql = `
904
+ SELECT d.*, c.title as conversation_title, c.project_id,
905
+ COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
906
+ FROM decision d
907
+ LEFT JOIN conversation c ON d.conversation_id = c.id
908
+ LEFT JOIN project p ON c.project_id = p.id
909
+ `;
910
+ if (conversationId) {
911
+ sql += ' WHERE d.conversation_id = ?';
912
+ params.push(conversationId);
913
+ }
914
+ sql += ' ORDER BY d.created_at DESC LIMIT ?';
915
+ params.push(limit ? parseInt(limit) : 200);
916
+ const rows = db.prepare(sql).all(...params);
917
+ return res.json(rows);
918
+ }
774
919
  const decisions = data.listDecisions({
775
920
  conversationId: conversationId || undefined,
776
921
  limit: limit ? parseInt(limit) : undefined,
@@ -781,9 +926,44 @@ function startLocalServer(opts) {
781
926
  res.status(500).json({ error: err.message });
782
927
  }
783
928
  });
929
+ app.delete('/api/memory/decisions/:id', (req, res) => {
930
+ try {
931
+ const deleted = data.deleteDecision(req.params.id);
932
+ if (!deleted)
933
+ return res.status(404).json({ error: 'Not found' });
934
+ res.json({ ok: true });
935
+ }
936
+ catch (err) {
937
+ res.status(500).json({ error: err.message });
938
+ }
939
+ });
784
940
  app.get('/api/memory/action-items', (req, res) => {
785
941
  try {
786
- const { status, limit } = req.query;
942
+ const { status, limit, groupByProject } = req.query;
943
+ const grouped = groupByProject === '1' || groupByProject === 'true';
944
+ if (grouped) {
945
+ const db = data.getDb();
946
+ const params = [];
947
+ const wheres = [];
948
+ if (status) {
949
+ wheres.push('a.status = ?');
950
+ params.push(status);
951
+ }
952
+ let sql = `
953
+ SELECT a.*, c.title as conversation_title, c.project_id,
954
+ COALESCE(p.name, '${data.UNASSIGNED_PROJECT_NAME}') as project_name
955
+ FROM action_item a
956
+ LEFT JOIN conversation c ON a.conversation_id = c.id
957
+ LEFT JOIN project p ON c.project_id = p.id
958
+ `;
959
+ if (wheres.length > 0) {
960
+ sql += ` WHERE ${wheres.join(' AND ')}`;
961
+ }
962
+ sql += ' ORDER BY a.created_at DESC LIMIT ?';
963
+ params.push(limit ? parseInt(limit) : 200);
964
+ const rows = db.prepare(sql).all(...params);
965
+ return res.json(rows);
966
+ }
787
967
  const items = data.listActionItems({
788
968
  status: status || undefined,
789
969
  limit: limit ? parseInt(limit) : undefined,
@@ -794,6 +974,17 @@ function startLocalServer(opts) {
794
974
  res.status(500).json({ error: err.message });
795
975
  }
796
976
  });
977
+ app.delete('/api/memory/action-items/:id', (req, res) => {
978
+ try {
979
+ const deleted = data.deleteActionItem(req.params.id);
980
+ if (!deleted)
981
+ return res.status(404).json({ error: 'Not found' });
982
+ res.json({ ok: true });
983
+ }
984
+ catch (err) {
985
+ res.status(500).json({ error: err.message });
986
+ }
987
+ });
797
988
  // ─── Settings ───────────────────────────────────────────────
798
989
  app.get('/api/settings', (_req, res) => {
799
990
  try {
@@ -986,6 +1177,9 @@ function startLocalServer(opts) {
986
1177
  agentId: s.agentId,
987
1178
  instruction: s.instruction,
988
1179
  onStatus: s.onStatus,
1180
+ isCheckpoint: s.isCheckpoint,
1181
+ onFailStepIndex: s.onFailStepIndex,
1182
+ maxLoops: s.maxLoops,
989
1183
  });
990
1184
  }
991
1185
  }
@@ -1031,7 +1225,7 @@ function startLocalServer(opts) {
1031
1225
  });
1032
1226
  app.post('/api/workflow-templates/:id/steps', (req, res) => {
1033
1227
  try {
1034
- const { agentId, instruction, onStatus, orderIndex } = req.body;
1228
+ const { agentId, instruction, onStatus, orderIndex, isCheckpoint, onFailStepIndex, maxLoops } = req.body;
1035
1229
  if (!agentId)
1036
1230
  return res.status(400).json({ error: 'agentId is required' });
1037
1231
  const step = data.addWorkflowTemplateStep({
@@ -1040,6 +1234,9 @@ function startLocalServer(opts) {
1040
1234
  agentId,
1041
1235
  instruction,
1042
1236
  onStatus,
1237
+ isCheckpoint,
1238
+ onFailStepIndex,
1239
+ maxLoops,
1043
1240
  });
1044
1241
  res.status(201).json(step);
1045
1242
  }
@@ -1082,6 +1279,259 @@ function startLocalServer(opts) {
1082
1279
  res.status(500).json({ error: err.message });
1083
1280
  }
1084
1281
  });
1282
+ // ─── Project Extractions (decisions, facts, action items) ──
1283
+ app.get('/api/projects/:id/extractions', (req, res) => {
1284
+ try {
1285
+ const projectId = req.params.id;
1286
+ const { type } = req.query;
1287
+ // Get all conversations for this project
1288
+ const db = data.getDb();
1289
+ const convIds = db.prepare('SELECT id FROM conversation WHERE project_id = ?').all(projectId);
1290
+ if (convIds.length === 0) {
1291
+ return res.json({ decisions: [], actionItems: [], facts: [], summaries: [] });
1292
+ }
1293
+ const ids = convIds.map(c => c.id);
1294
+ const placeholders = ids.map(() => '?').join(',');
1295
+ const result = {};
1296
+ if (!type || type === 'decisions') {
1297
+ result.decisions = db.prepare(`SELECT d.*, c.title as conversation_title FROM decision d
1298
+ LEFT JOIN conversation c ON d.conversation_id = c.id
1299
+ WHERE d.conversation_id IN (${placeholders})
1300
+ ORDER BY d.created_at DESC`).all(...ids);
1301
+ }
1302
+ if (!type || type === 'actionItems') {
1303
+ result.actionItems = db.prepare(`SELECT a.*, c.title as conversation_title FROM action_item a
1304
+ LEFT JOIN conversation c ON a.conversation_id = c.id
1305
+ WHERE a.conversation_id IN (${placeholders})
1306
+ ORDER BY a.created_at DESC`).all(...ids);
1307
+ }
1308
+ if (!type || type === 'facts') {
1309
+ result.facts = db.prepare(`SELECT f.*, c.title as conversation_title FROM memory_fact f
1310
+ LEFT JOIN conversation c ON f.conversation_id = c.id
1311
+ WHERE f.conversation_id IN (${placeholders})
1312
+ ORDER BY f.created_at DESC`).all(...ids);
1313
+ }
1314
+ if (!type || type === 'summaries') {
1315
+ result.summaries = db.prepare(`SELECT s.*, c.title as conversation_title FROM conversation_summary s
1316
+ LEFT JOIN conversation c ON s.conversation_id = c.id
1317
+ WHERE s.conversation_id IN (${placeholders})
1318
+ ORDER BY s.created_at DESC`).all(...ids);
1319
+ }
1320
+ res.json(result);
1321
+ }
1322
+ catch (err) {
1323
+ res.status(500).json({ error: err.message });
1324
+ }
1325
+ });
1326
+ // ─── Config Cleanup ─────────────────────────────────────────
1327
+ app.post('/api/projects/:id/cleanup', async (req, res) => {
1328
+ try {
1329
+ const project = data.getProject(req.params.id);
1330
+ if (!project)
1331
+ return res.status(404).json({ error: 'Project not found' });
1332
+ if (!project.folder)
1333
+ return res.status(400).json({ error: 'Project has no folder configured' });
1334
+ const result = await (0, config_cleanup_1.runConfigCleanup)(project.id, project.folder, { force: req.body?.force });
1335
+ res.json(result);
1336
+ }
1337
+ catch (err) {
1338
+ res.status(500).json({ error: err.message });
1339
+ }
1340
+ });
1341
+ // ─── Workflow Executions ────────────────────────────────────
1342
+ app.get('/api/workflows/:id', (req, res) => {
1343
+ try {
1344
+ const exec = data.getWorkflowExecution(req.params.id);
1345
+ if (!exec)
1346
+ return res.status(404).json({ error: 'Workflow execution not found' });
1347
+ res.json(exec);
1348
+ }
1349
+ catch (err) {
1350
+ res.status(500).json({ error: err.message });
1351
+ }
1352
+ });
1353
+ app.get('/api/conversations/:id/workflows', (req, res) => {
1354
+ try {
1355
+ const executions = data.listWorkflowExecutions({ conversationId: req.params.id, limit: 20 });
1356
+ res.json(executions);
1357
+ }
1358
+ catch (err) {
1359
+ res.status(500).json({ error: err.message });
1360
+ }
1361
+ });
1362
+ // ─── Backfill ──────────────────────────────────────────────
1363
+ // Backfill runs detached from the HTTP request so it survives page navigation.
1364
+ // The SSE endpoint streams progress while connected, but the backfill job
1365
+ // continues in the background regardless.
1366
+ let backfillJob = {
1367
+ running: false, progress: null, result: null, error: null,
1368
+ };
1369
+ const launchBackfill = (projectHints, options) => {
1370
+ if (backfillJob.running)
1371
+ return;
1372
+ backfillJob = { running: true, progress: null, result: null, error: null };
1373
+ (0, backfill_1.backfillAll)((progress) => {
1374
+ backfillJob.progress = progress;
1375
+ }, projectHints, {
1376
+ includeSummaries: options?.includeSummaries,
1377
+ includeProjectCatalog: options?.includeProjectCatalog,
1378
+ }).then((result) => {
1379
+ backfillJob.result = result;
1380
+ backfillJob.running = false;
1381
+ }).catch((err) => {
1382
+ backfillJob.error = err.message;
1383
+ backfillJob.running = false;
1384
+ });
1385
+ };
1386
+ // JSON status endpoint used by desktop polling fallback (Tauri HTTP can buffer SSE).
1387
+ app.get('/api/backfill/status', (_req, res) => {
1388
+ res.json(backfillJob);
1389
+ });
1390
+ // JSON start endpoint for polling clients.
1391
+ app.post('/api/backfill/start', async (req, res) => {
1392
+ try {
1393
+ if (!(0, summarization_pipeline_1.isClerkConfigured)()) {
1394
+ return res.status(400).json({
1395
+ error: 'Configure a clerk model in Settings → Orchestration before running backfill'
1396
+ });
1397
+ }
1398
+ const projectHints = Array.isArray(req.body?.projectHints) ? req.body.projectHints : undefined;
1399
+ launchBackfill(projectHints, {
1400
+ includeSummaries: req.body?.includeSummaries === true,
1401
+ includeProjectCatalog: req.body?.includeProjectCatalog !== false,
1402
+ });
1403
+ res.status(202).json({ ok: true, running: true });
1404
+ }
1405
+ catch (err) {
1406
+ res.status(500).json({ error: err.message });
1407
+ }
1408
+ });
1409
+ app.post('/api/backfill', async (req, res) => {
1410
+ try {
1411
+ if (!(0, summarization_pipeline_1.isClerkConfigured)()) {
1412
+ return res.status(400).json({
1413
+ error: 'Configure a clerk model in Settings → Orchestration before running backfill'
1414
+ });
1415
+ }
1416
+ if (backfillJob.running) {
1417
+ // Already running — stream existing progress
1418
+ res.writeHead(200, {
1419
+ 'Content-Type': 'text/event-stream',
1420
+ 'Cache-Control': 'no-cache',
1421
+ 'Connection': 'keep-alive',
1422
+ });
1423
+ if (backfillJob.progress) {
1424
+ res.write(`event: progress\ndata: ${JSON.stringify(backfillJob.progress)}\n\n`);
1425
+ }
1426
+ // Don't end — keep SSE open for live updates
1427
+ const interval = setInterval(() => {
1428
+ try {
1429
+ if (!backfillJob.running) {
1430
+ if (backfillJob.result) {
1431
+ res.write(`event: done\ndata: ${JSON.stringify(backfillJob.result)}\n\n`);
1432
+ }
1433
+ else if (backfillJob.error) {
1434
+ res.write(`event: error\ndata: ${JSON.stringify({ error: backfillJob.error })}\n\n`);
1435
+ }
1436
+ clearInterval(interval);
1437
+ res.end();
1438
+ }
1439
+ else if (backfillJob.progress) {
1440
+ res.write(`event: progress\ndata: ${JSON.stringify(backfillJob.progress)}\n\n`);
1441
+ }
1442
+ }
1443
+ catch {
1444
+ clearInterval(interval);
1445
+ }
1446
+ }, 2000);
1447
+ req.on('close', () => clearInterval(interval));
1448
+ return;
1449
+ }
1450
+ const projectHints = Array.isArray(req.body?.projectHints) ? req.body.projectHints : undefined;
1451
+ launchBackfill(projectHints, {
1452
+ includeSummaries: req.body?.includeSummaries === true,
1453
+ includeProjectCatalog: req.body?.includeProjectCatalog !== false,
1454
+ });
1455
+ // SSE for progress
1456
+ res.writeHead(200, {
1457
+ 'Content-Type': 'text/event-stream',
1458
+ 'Cache-Control': 'no-cache',
1459
+ 'Connection': 'keep-alive',
1460
+ });
1461
+ let connected = true;
1462
+ req.on('close', () => { connected = false; });
1463
+ const sendEvent = (event, payload) => {
1464
+ if (connected) {
1465
+ try {
1466
+ res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
1467
+ }
1468
+ catch {
1469
+ connected = false;
1470
+ }
1471
+ }
1472
+ };
1473
+ if (backfillJob.progress) {
1474
+ sendEvent('progress', backfillJob.progress);
1475
+ }
1476
+ const interval = setInterval(() => {
1477
+ try {
1478
+ if (!backfillJob.running) {
1479
+ if (backfillJob.result) {
1480
+ sendEvent('done', backfillJob.result);
1481
+ }
1482
+ else if (backfillJob.error) {
1483
+ sendEvent('error', { error: backfillJob.error });
1484
+ }
1485
+ clearInterval(interval);
1486
+ if (connected) {
1487
+ try {
1488
+ res.end();
1489
+ }
1490
+ catch { }
1491
+ }
1492
+ return;
1493
+ }
1494
+ if (backfillJob.progress) {
1495
+ sendEvent('progress', backfillJob.progress);
1496
+ }
1497
+ }
1498
+ catch {
1499
+ clearInterval(interval);
1500
+ }
1501
+ }, 1000);
1502
+ req.on('close', () => clearInterval(interval));
1503
+ }
1504
+ catch (err) {
1505
+ backfillJob.running = false;
1506
+ if (!res.headersSent) {
1507
+ res.status(500).json({ error: err.message });
1508
+ }
1509
+ }
1510
+ });
1511
+ app.post('/api/backfill/test-one', async (req, res) => {
1512
+ try {
1513
+ if (!(0, summarization_pipeline_1.isClerkConfigured)()) {
1514
+ return res.status(400).json({
1515
+ error: 'Configure a clerk model in Settings -> Orchestration before running backfill',
1516
+ });
1517
+ }
1518
+ if (backfillJob.running) {
1519
+ return res.status(409).json({ error: 'Backfill is currently running. Wait for completion before test-one.' });
1520
+ }
1521
+ const projectHints = Array.isArray(req.body?.projectHints) ? req.body.projectHints : undefined;
1522
+ const conversationId = typeof req.body?.conversationId === 'string' && req.body.conversationId.trim()
1523
+ ? req.body.conversationId.trim()
1524
+ : undefined;
1525
+ const result = await (0, backfill_1.backfillOne)(projectHints, conversationId, {
1526
+ includeSummaries: req.body?.includeSummaries === true,
1527
+ includeProjectCatalog: req.body?.includeProjectCatalog !== false,
1528
+ });
1529
+ res.json(result);
1530
+ }
1531
+ catch (err) {
1532
+ res.status(500).json({ error: err.message });
1533
+ }
1534
+ });
1085
1535
  // ─── Import ─────────────────────────────────────────────────
1086
1536
  app.post('/api/import/start', async (req, res) => {
1087
1537
  try {
@@ -1218,6 +1668,100 @@ function startLocalServer(opts) {
1218
1668
  res.status(500).json({ error: err.message });
1219
1669
  }
1220
1670
  });
1671
+ // ─── MCP Marketplace ────────────────────────────────────────────
1672
+ const mcpManager = new manager_1.MCPManager();
1673
+ // Auto-launch persisted always-on servers
1674
+ mcpManager.autoLaunch().catch((err) => {
1675
+ console.error(chalk_1.default.yellow(`[MCP] Auto-launch error: ${err.message}`));
1676
+ });
1677
+ /**
1678
+ * GET /api/mcp/catalog — full marketplace catalog with install status
1679
+ */
1680
+ app.get('/api/mcp/catalog', (_req, res) => {
1681
+ try {
1682
+ const catalog = (0, marketplace_1.buildMarketplaceCatalog)();
1683
+ const running = new Set(mcpManager.getRunningServers());
1684
+ const persisted = new Set(mcpManager.loadPersistedConfigs().map((c) => c.id));
1685
+ const tools = catalog.map(({ entry, info }) => {
1686
+ let status = 'not_installed';
1687
+ if (running.has(entry.id)) {
1688
+ status = 'running';
1689
+ }
1690
+ else if (persisted.has(entry.id)) {
1691
+ status = 'installed';
1692
+ }
1693
+ return { entry, info, status, inClaudeConfig: (0, claude_config_writer_1.isInClaudeConfig)(entry.id) };
1694
+ });
1695
+ res.json({ tools, categories: marketplace_1.MARKETPLACE_CATEGORIES });
1696
+ }
1697
+ catch (err) {
1698
+ res.status(500).json({ error: err.message });
1699
+ }
1700
+ });
1701
+ /**
1702
+ * POST /api/mcp/install — install + launch + configure Claude Code
1703
+ */
1704
+ app.post('/api/mcp/install', async (req, res) => {
1705
+ try {
1706
+ const { toolId, envVars, alwaysOn } = req.body;
1707
+ if (!toolId)
1708
+ return res.status(400).json({ error: 'toolId required' });
1709
+ const entry = registry_1.BUILTIN_REGISTRY.find((e) => e.id === toolId);
1710
+ if (!entry)
1711
+ return res.status(404).json({ error: `Unknown tool: ${toolId}` });
1712
+ // Install and launch via MCPManager
1713
+ const tools = await mcpManager.installAndLaunch(entry, envVars || {}, alwaysOn ?? true);
1714
+ // Configure Claude Code
1715
+ (0, claude_config_writer_1.addMcpToClaudeConfig)(entry, envVars || {});
1716
+ res.json({
1717
+ ok: true,
1718
+ toolId,
1719
+ toolsDiscovered: tools.length,
1720
+ inClaudeConfig: true,
1721
+ });
1722
+ }
1723
+ catch (err) {
1724
+ res.status(500).json({ error: err.message });
1725
+ }
1726
+ });
1727
+ /**
1728
+ * POST /api/mcp/uninstall — stop + remove persisted config + remove from Claude Code
1729
+ */
1730
+ app.post('/api/mcp/uninstall', async (req, res) => {
1731
+ try {
1732
+ const { toolId } = req.body;
1733
+ if (!toolId)
1734
+ return res.status(400).json({ error: 'toolId required' });
1735
+ await mcpManager.uninstallServer(toolId);
1736
+ (0, claude_config_writer_1.removeMcpFromClaudeConfig)(toolId);
1737
+ res.json({ ok: true, toolId });
1738
+ }
1739
+ catch (err) {
1740
+ res.status(500).json({ error: err.message });
1741
+ }
1742
+ });
1743
+ /**
1744
+ * GET /api/mcp/status — status map of all servers
1745
+ */
1746
+ app.get('/api/mcp/status', (_req, res) => {
1747
+ try {
1748
+ const running = mcpManager.getRunningServers();
1749
+ const persisted = mcpManager.loadPersistedConfigs();
1750
+ const status = {};
1751
+ for (const config of persisted) {
1752
+ const info = mcpManager.getServerInfo(config.id);
1753
+ status[config.id] = {
1754
+ running: running.includes(config.id),
1755
+ alwaysOn: config.alwaysOn,
1756
+ toolCount: info?.tools.length ?? 0,
1757
+ };
1758
+ }
1759
+ res.json({ status });
1760
+ }
1761
+ catch (err) {
1762
+ res.status(500).json({ error: err.message });
1763
+ }
1764
+ });
1221
1765
  // Initialize workflow engine
1222
1766
  (0, workflow_engine_1.getWorkflowEngine)(opts.projectDir);
1223
1767
  // Start server
@@ -1281,44 +1825,38 @@ async function autoSuggestTopic(convId, projectId) {
1281
1825
  if (msgs.length < 3)
1282
1826
  return;
1283
1827
  const clerk = (0, clerk_model_1.getClerk)();
1284
- let topicTitle;
1828
+ let rawTitle;
1285
1829
  if (clerk) {
1286
1830
  const transcript = msgs.slice(0, 4).map(m => `${m.role}: ${m.content.slice(0, 150)}`).join('\n');
1287
1831
  const clerkLlm = clerk.llm;
1288
1832
  const resp = await clerkLlm.chat({
1289
- messages: [{ role: 'user', content: `Generate a short topic name (2-5 words, no quotes) for this conversation:\n\n${transcript}` }],
1290
- system: 'You generate short topic names. Return ONLY the topic name, nothing else.',
1833
+ messages: [{ role: 'user', content: `Generate a short topic name (2-3 words, no quotes) for this conversation:\n\n${transcript}` }],
1834
+ system: 'You generate short topic names (2-3 words, title case). Return ONLY the topic name, nothing else.',
1291
1835
  stream: false,
1292
1836
  });
1293
- topicTitle = resp?.content?.trim().slice(0, 60) || 'General';
1837
+ rawTitle = resp?.content?.trim().slice(0, 60) || 'General';
1294
1838
  }
1295
1839
  else {
1296
1840
  // Heuristic: use first user message
1297
1841
  const firstUser = msgs.find(m => m.role === 'user');
1298
- topicTitle = firstUser ? firstUser.content.slice(0, 40).replace(/\n/g, ' ').trim() : 'General';
1842
+ rawTitle = firstUser ? firstUser.content.slice(0, 40).replace(/\n/g, ' ').trim() : 'General';
1299
1843
  }
1844
+ // Normalize for quality constraints
1845
+ const topicTitle = (0, topic_normalizer_1.normalizeTopicTitle)(rawTitle) || rawTitle;
1300
1846
  // Check if a similar topic exists for this project
1301
1847
  const topics = data.listTopics({ projectId, limit: 100 });
1302
- const matchingTopic = topics.find(t => t.title.toLowerCase() === topicTitle.toLowerCase());
1848
+ const matchingTopic = topics.find(t => t.title.toLowerCase() === topicTitle.toLowerCase() ||
1849
+ (t.normalized_title && t.normalized_title === topicTitle.toLowerCase()));
1303
1850
  if (matchingTopic) {
1304
- // Link to existing topic
1305
- data.createTopicSegment({
1306
- topicId: matchingTopic.id,
1307
- conversationId: convId,
1308
- startSeq: 1,
1309
- endSeq: 999999,
1310
- });
1851
+ // Idempotent link to existing topic
1852
+ data.upsertConversationTopicSegment(convId, matchingTopic.id);
1311
1853
  data.updateTopic(matchingTopic.id, { messageCount: matchingTopic.message_count + msgs.length, lastActivityAt: new Date().toISOString() });
1312
1854
  }
1313
1855
  else {
1314
- // Create new topic
1856
+ // Create new topic with normalized title
1315
1857
  const topic = data.createTopic({ title: topicTitle, projectId, isAuto: true });
1316
- data.createTopicSegment({
1317
- topicId: topic.id,
1318
- conversationId: convId,
1319
- startSeq: 1,
1320
- endSeq: 999999,
1321
- });
1858
+ data.updateTopic(topic.id, { normalizedTitle: topicTitle.toLowerCase() });
1859
+ data.upsertConversationTopicSegment(convId, topic.id);
1322
1860
  }
1323
1861
  console.log(chalk_1.default.gray(` [topic] Auto-suggested: "${topicTitle}" for conv ${convId.slice(0, 8)}`));
1324
1862
  }