funolio-agent 1.0.7 → 1.0.47

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 (155) hide show
  1. package/dist/approval.d.ts +1 -0
  2. package/dist/approval.d.ts.map +1 -1
  3. package/dist/approval.js +12 -0
  4. package/dist/approval.js.map +1 -1
  5. package/dist/auth/auto-detect.d.ts +11 -3
  6. package/dist/auth/auto-detect.d.ts.map +1 -1
  7. package/dist/auth/auto-detect.js +136 -168
  8. package/dist/auth/auto-detect.js.map +1 -1
  9. package/dist/auth/subscription-runtime.js +1 -1
  10. package/dist/auth/subscription-runtime.js.map +1 -1
  11. package/dist/backfill.d.ts.map +1 -1
  12. package/dist/backfill.js +31 -28
  13. package/dist/backfill.js.map +1 -1
  14. package/dist/bot-manager.d.ts +16 -4
  15. package/dist/bot-manager.d.ts.map +1 -1
  16. package/dist/bot-manager.js +280 -73
  17. package/dist/bot-manager.js.map +1 -1
  18. package/dist/clerk-model.d.ts +10 -6
  19. package/dist/clerk-model.d.ts.map +1 -1
  20. package/dist/clerk-model.js +39 -16
  21. package/dist/clerk-model.js.map +1 -1
  22. package/dist/cli.js +7 -2
  23. package/dist/cli.js.map +1 -1
  24. package/dist/commands/import-history.js +5 -1
  25. package/dist/commands/import-history.js.map +1 -1
  26. package/dist/commands/start.d.ts.map +1 -1
  27. package/dist/commands/start.js +57 -225
  28. package/dist/commands/start.js.map +1 -1
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +13 -2
  31. package/dist/config.js.map +1 -1
  32. package/dist/context-window.js +1 -1
  33. package/dist/context-window.js.map +1 -1
  34. package/dist/import-parser-core.d.ts.map +1 -1
  35. package/dist/import-parser-core.js +74 -8
  36. package/dist/import-parser-core.js.map +1 -1
  37. package/dist/local-data.d.ts +59 -6
  38. package/dist/local-data.d.ts.map +1 -1
  39. package/dist/local-data.js +544 -62
  40. package/dist/local-data.js.map +1 -1
  41. package/dist/local-db.d.ts.map +1 -1
  42. package/dist/local-db.js +21 -1
  43. package/dist/local-db.js.map +1 -1
  44. package/dist/local-funnel.d.ts +0 -7
  45. package/dist/local-funnel.d.ts.map +1 -1
  46. package/dist/local-funnel.js +19 -28
  47. package/dist/local-funnel.js.map +1 -1
  48. package/dist/local-import-worker.d.ts.map +1 -1
  49. package/dist/local-import-worker.js +49 -4
  50. package/dist/local-import-worker.js.map +1 -1
  51. package/dist/local-memory-search.d.ts.map +1 -1
  52. package/dist/local-memory-search.js +6 -3
  53. package/dist/local-memory-search.js.map +1 -1
  54. package/dist/local-server.d.ts +37 -0
  55. package/dist/local-server.d.ts.map +1 -1
  56. package/dist/local-server.js +731 -227
  57. package/dist/local-server.js.map +1 -1
  58. package/dist/mcp/local-memory-server.d.ts +1 -1
  59. package/dist/mcp/local-memory-server.d.ts.map +1 -1
  60. package/dist/mcp/local-memory-server.js +23 -11
  61. package/dist/mcp/local-memory-server.js.map +1 -1
  62. package/dist/mcp/manager.d.ts.map +1 -1
  63. package/dist/mcp/manager.js +86 -18
  64. package/dist/mcp/manager.js.map +1 -1
  65. package/dist/message-loop.d.ts +5 -10
  66. package/dist/message-loop.d.ts.map +1 -1
  67. package/dist/message-loop.js +393 -289
  68. package/dist/message-loop.js.map +1 -1
  69. package/dist/mqtt-client.d.ts +4 -1
  70. package/dist/mqtt-client.d.ts.map +1 -1
  71. package/dist/mqtt-client.js.map +1 -1
  72. package/dist/orchestration/orchestrator-blocked-prompt.d.ts +2 -1
  73. package/dist/orchestration/orchestrator-blocked-prompt.d.ts.map +1 -1
  74. package/dist/orchestration/orchestrator-blocked-prompt.js +11 -0
  75. package/dist/orchestration/orchestrator-blocked-prompt.js.map +1 -1
  76. package/dist/orchestration/orchestrator-final-response-prompt.d.ts +4 -1
  77. package/dist/orchestration/orchestrator-final-response-prompt.d.ts.map +1 -1
  78. package/dist/orchestration/orchestrator-final-response-prompt.js +8 -6
  79. package/dist/orchestration/orchestrator-final-response-prompt.js.map +1 -1
  80. package/dist/orchestration/worker-operating-prompt.d.ts +2 -0
  81. package/dist/orchestration/worker-operating-prompt.d.ts.map +1 -1
  82. package/dist/orchestration/worker-operating-prompt.js +39 -0
  83. package/dist/orchestration/worker-operating-prompt.js.map +1 -1
  84. package/dist/orchestrator.d.ts +13 -0
  85. package/dist/orchestrator.d.ts.map +1 -1
  86. package/dist/orchestrator.js +235 -133
  87. package/dist/orchestrator.js.map +1 -1
  88. package/dist/providers/anthropic.d.ts +0 -5
  89. package/dist/providers/anthropic.d.ts.map +1 -1
  90. package/dist/providers/anthropic.js +29 -75
  91. package/dist/providers/anthropic.js.map +1 -1
  92. package/dist/providers/claude-cli.d.ts.map +1 -1
  93. package/dist/providers/claude-cli.js +16 -140
  94. package/dist/providers/claude-cli.js.map +1 -1
  95. package/dist/providers/codex-cli.d.ts.map +1 -1
  96. package/dist/providers/codex-cli.js +78 -59
  97. package/dist/providers/codex-cli.js.map +1 -1
  98. package/dist/providers/google.d.ts.map +1 -1
  99. package/dist/providers/google.js +4 -2
  100. package/dist/providers/google.js.map +1 -1
  101. package/dist/providers/index.d.ts +2 -0
  102. package/dist/providers/index.d.ts.map +1 -1
  103. package/dist/providers/index.js.map +1 -1
  104. package/dist/providers/openai.d.ts.map +1 -1
  105. package/dist/providers/openai.js +27 -2
  106. package/dist/providers/openai.js.map +1 -1
  107. package/dist/storage-mode.d.ts +5 -0
  108. package/dist/storage-mode.d.ts.map +1 -0
  109. package/dist/storage-mode.js +21 -0
  110. package/dist/storage-mode.js.map +1 -0
  111. package/dist/summarization-pipeline.d.ts +9 -0
  112. package/dist/summarization-pipeline.d.ts.map +1 -1
  113. package/dist/summarization-pipeline.js +61 -17
  114. package/dist/summarization-pipeline.js.map +1 -1
  115. package/dist/tools/analyze-image.js +2 -2
  116. package/dist/tools/analyze-image.js.map +1 -1
  117. package/dist/tools/edit-file.js +3 -3
  118. package/dist/tools/edit-file.js.map +1 -1
  119. package/dist/tools/index.d.ts.map +1 -1
  120. package/dist/tools/index.js +36 -0
  121. package/dist/tools/index.js.map +1 -1
  122. package/dist/tools/list-directory.js +7 -4
  123. package/dist/tools/list-directory.js.map +1 -1
  124. package/dist/tools/read-file.js +3 -3
  125. package/dist/tools/read-file.js.map +1 -1
  126. package/dist/tools/run-command.js +3 -3
  127. package/dist/tools/run-command.js.map +1 -1
  128. package/dist/tools/sandbox.d.ts +10 -5
  129. package/dist/tools/sandbox.d.ts.map +1 -1
  130. package/dist/tools/sandbox.js +41 -13
  131. package/dist/tools/sandbox.js.map +1 -1
  132. package/dist/tools/search-codebase.js +2 -2
  133. package/dist/tools/search-codebase.js.map +1 -1
  134. package/dist/tools/search-local-memory.d.ts.map +1 -1
  135. package/dist/tools/search-local-memory.js +19 -8
  136. package/dist/tools/search-local-memory.js.map +1 -1
  137. package/dist/tools/spawn-subagent.js.map +1 -1
  138. package/dist/tools/write-file.js +3 -3
  139. package/dist/tools/write-file.js.map +1 -1
  140. package/dist/types.d.ts +2 -0
  141. package/dist/types.d.ts.map +1 -1
  142. package/dist/verification/index.js +2 -2
  143. package/dist/verification/index.js.map +1 -1
  144. package/dist/wizard-state.d.ts.map +1 -1
  145. package/dist/wizard-state.js +16 -2
  146. package/dist/wizard-state.js.map +1 -1
  147. package/dist/wizard-support.d.ts +2 -2
  148. package/dist/wizard-support.d.ts.map +1 -1
  149. package/dist/wizard-support.js +80 -93
  150. package/dist/wizard-support.js.map +1 -1
  151. package/dist/workflow-engine.d.ts +3 -1
  152. package/dist/workflow-engine.d.ts.map +1 -1
  153. package/dist/workflow-engine.js +131 -12
  154. package/dist/workflow-engine.js.map +1 -1
  155. package/package.json +1 -1
@@ -58,9 +58,20 @@ exports.createConversation = createConversation;
58
58
  exports.getConversation = getConversation;
59
59
  exports.listConversations = listConversations;
60
60
  exports.countConversations = countConversations;
61
+ exports.countMessagesForBot = countMessagesForBot;
62
+ exports.listBotConversationActivity = listBotConversationActivity;
61
63
  exports.updateConversation = updateConversation;
64
+ exports.touchConversationActivity = touchConversationActivity;
62
65
  exports.deleteConversation = deleteConversation;
63
66
  exports.addMessage = addMessage;
67
+ exports.updateMessage = updateMessage;
68
+ exports.createChatJob = createChatJob;
69
+ exports.getChatJob = getChatJob;
70
+ exports.getLatestConversationChatJob = getLatestConversationChatJob;
71
+ exports.listQueuedChatJobs = listQueuedChatJobs;
72
+ exports.countRunningChatJobs = countRunningChatJobs;
73
+ exports.updateChatJob = updateChatJob;
74
+ exports.markRunningChatJobsInterrupted = markRunningChatJobsInterrupted;
64
75
  exports.createMessageActivity = createMessageActivity;
65
76
  exports.attachMessageActivitiesToMessage = attachMessageActivitiesToMessage;
66
77
  exports.listMessageActivities = listMessageActivities;
@@ -104,6 +115,7 @@ exports.setSetting = setSetting;
104
115
  exports.setJsonSetting = setJsonSetting;
105
116
  exports.getAllSettings = getAllSettings;
106
117
  exports.deleteSetting = deleteSetting;
118
+ exports.purgeLegacyExtractionDataOnce = purgeLegacyExtractionDataOnce;
107
119
  exports.defaultOrchestrationPolicy = defaultOrchestrationPolicy;
108
120
  exports.resolveCurrentUserIdentity = resolveCurrentUserIdentity;
109
121
  exports.getUserOrchestrationPolicy = getUserOrchestrationPolicy;
@@ -211,6 +223,7 @@ const memory_extraction_1 = require("./memory-extraction");
211
223
  const orchestrator_blocked_prompt_1 = require("./orchestration/orchestrator-blocked-prompt");
212
224
  const crypto = __importStar(require("crypto"));
213
225
  const default_tool_profile_1 = require("./default-tool-profile");
226
+ const approval_1 = require("./approval");
214
227
  let _db = null;
215
228
  /** Get or open the shared database connection */
216
229
  function getDb() {
@@ -249,7 +262,7 @@ function createAgentProfile(p) {
249
262
  if (p.isOrchestrator) {
250
263
  db.prepare("UPDATE agent_profile SET is_orchestrator = 0, updated_at = datetime('now') WHERE is_orchestrator = 1").run();
251
264
  }
252
- insert.run(id, p.provider, p.model, p.name, p.soulMd ?? null, p.memoryMd ?? null, p.toolsMd ?? null, p.skillsMd ?? null, p.apiKeyEnc ?? null, p.permissionMode ?? default_tool_profile_1.DEFAULT_PERMISSION_MODE, p.isDefault ? 1 : 0, p.roleLabel ?? null, p.roleClass ?? null, p.isActive !== false ? 1 : 0, p.priority ?? 100, p.providerConnectionId ?? null, p.color ?? null, p.purposeMd ?? null, p.identitySummary ?? null, p.finalPrompt ?? null, p.enabledBuiltinToolsJson ?? null, p.enabledMcpToolsJson ?? null, p.isOrchestrator ? 1 : 0, ts, ts);
265
+ insert.run(id, p.provider, p.model, p.name, p.soulMd ?? null, p.memoryMd ?? null, p.toolsMd ?? null, p.skillsMd ?? null, p.apiKeyEnc ?? null, (0, approval_1.normalizePermissionMode)(p.permissionMode ?? default_tool_profile_1.DEFAULT_PERMISSION_MODE), p.isDefault ? 1 : 0, p.roleLabel ?? null, p.roleClass ?? null, p.isActive !== false ? 1 : 0, p.priority ?? 100, p.providerConnectionId ?? null, p.color ?? null, p.purposeMd ?? null, p.identitySummary ?? null, p.finalPrompt ?? null, p.enabledBuiltinToolsJson ?? null, p.enabledMcpToolsJson ?? null, p.isOrchestrator ? 1 : 0, ts, ts);
253
266
  });
254
267
  transaction();
255
268
  return db.prepare('SELECT * FROM agent_profile WHERE id = ?').get(id);
@@ -286,7 +299,8 @@ function migrateAgentProfilesToDefaultToolProfile() {
286
299
  catch {
287
300
  tools = [];
288
301
  }
289
- const needsPermission = !row.permission_mode || row.permission_mode === 'autopilot' || row.permission_mode === 'default';
302
+ const normalizedPermission = (0, approval_1.normalizePermissionMode)(row.permission_mode);
303
+ const needsPermission = !row.permission_mode || row.permission_mode !== normalizedPermission;
290
304
  const needsTools = tools.length === 0;
291
305
  if (!needsPermission && !needsTools)
292
306
  continue;
@@ -294,7 +308,7 @@ function migrateAgentProfilesToDefaultToolProfile() {
294
308
  const vals = [now()];
295
309
  if (needsPermission) {
296
310
  sets.push('permission_mode = ?');
297
- vals.push(default_tool_profile_1.DEFAULT_PERMISSION_MODE);
311
+ vals.push(normalizedPermission);
298
312
  }
299
313
  if (needsTools) {
300
314
  sets.push('enabled_builtin_tools_json = ?');
@@ -347,7 +361,7 @@ function updateAgentProfile(id, fields) {
347
361
  }
348
362
  if (fields.permissionMode !== undefined) {
349
363
  sets.push('permission_mode = ?');
350
- vals.push(fields.permissionMode);
364
+ vals.push((0, approval_1.normalizePermissionMode)(fields.permissionMode));
351
365
  }
352
366
  if (fields.isDefault !== undefined) {
353
367
  sets.push('is_default = ?');
@@ -660,37 +674,206 @@ function createConversation(agentId, title, source, opts) {
660
674
  `).run(id, agentId, title ?? null, source ?? 'local', projectId, projectName, ts, ts);
661
675
  return db.prepare('SELECT * FROM conversation WHERE id = ?').get(id);
662
676
  }
677
+ function latestConversationSummarySql(conversationAlias) {
678
+ return `(
679
+ SELECT cs.summary_text
680
+ FROM conversation_summary cs
681
+ WHERE cs.conversation_id = ${conversationAlias}.id
682
+ ORDER BY cs.turn_range_end DESC, cs.created_at DESC
683
+ LIMIT 1
684
+ )`;
685
+ }
686
+ function primaryConversationTopicTitleSql(conversationAlias) {
687
+ return `(
688
+ SELECT t.title
689
+ FROM topic_segment ts
690
+ INNER JOIN topic t ON t.id = ts.topic_id
691
+ WHERE ts.conversation_id = ${conversationAlias}.id
692
+ ORDER BY ts.created_at ASC
693
+ LIMIT 1
694
+ )`;
695
+ }
696
+ function firstConversationUserMessageSql(conversationAlias, maxLength = 120) {
697
+ return `(
698
+ SELECT substr(trim(m.content), 1, ${maxLength})
699
+ FROM message m
700
+ WHERE m.conversation_id = ${conversationAlias}.id
701
+ AND m.role = 'user'
702
+ AND trim(coalesce(m.content, '')) <> ''
703
+ AND trim(coalesce(m.content, '')) NOT LIKE '[System Instructions]%'
704
+ AND trim(coalesce(m.content, '')) NOT LIKE '# AGENTS.md%'
705
+ AND trim(coalesce(m.content, '')) NOT LIKE '<INSTRUCTIONS%'
706
+ AND trim(coalesce(m.content, '')) NOT LIKE '<environment_context%'
707
+ AND trim(coalesce(m.content, '')) NOT LIKE 'CURRENT TODO ID:%'
708
+ ORDER BY m.seq ASC
709
+ LIMIT 1
710
+ )`;
711
+ }
712
+ function firstConversationAssistantMessageSql(conversationAlias, maxLength = 120) {
713
+ return `(
714
+ SELECT substr(trim(m.content), 1, ${maxLength})
715
+ FROM message m
716
+ WHERE m.conversation_id = ${conversationAlias}.id
717
+ AND m.role = 'assistant'
718
+ AND trim(coalesce(m.content, '')) <> ''
719
+ ORDER BY m.seq ASC
720
+ LIMIT 1
721
+ )`;
722
+ }
723
+ function conversationSelectSql(conversationAlias) {
724
+ const latestSummary = latestConversationSummarySql(conversationAlias);
725
+ const topicTitle = primaryConversationTopicTitleSql(conversationAlias);
726
+ const firstUserMessage = firstConversationUserMessageSql(conversationAlias);
727
+ const firstAssistantMessage = firstConversationAssistantMessageSql(conversationAlias);
728
+ return `
729
+ SELECT
730
+ ${conversationAlias}.*,
731
+ COALESCE(
732
+ CASE
733
+ WHEN ${conversationAlias}.title IS NOT NULL
734
+ AND trim(${conversationAlias}.title) <> ''
735
+ AND trim(${conversationAlias}.title) <> 'New Conversation'
736
+ AND trim(${conversationAlias}.title) NOT LIKE '[System Instructions]%'
737
+ AND trim(${conversationAlias}.title) NOT LIKE '# AGENTS.md%'
738
+ AND trim(${conversationAlias}.title) NOT LIKE '<INSTRUCTIONS%'
739
+ AND trim(${conversationAlias}.title) NOT LIKE '<environment_context%'
740
+ AND trim(${conversationAlias}.title) NOT LIKE 'CURRENT TODO ID:%'
741
+ THEN ${conversationAlias}.title
742
+ ELSE NULL
743
+ END,
744
+ ${topicTitle},
745
+ ${firstUserMessage},
746
+ ${firstAssistantMessage},
747
+ ${conversationAlias}.title
748
+ ) AS resolved_title,
749
+ COALESCE(${latestSummary}, ${conversationAlias}.summary) AS resolved_summary,
750
+ CASE
751
+ WHEN ${conversationAlias}.source = 'import'
752
+ AND ${conversationAlias}.conversation_started_at IS NOT NULL
753
+ THEN ${conversationAlias}.conversation_started_at
754
+ ELSE ${conversationAlias}.created_at
755
+ END AS effective_created_at,
756
+ CASE
757
+ WHEN ${conversationAlias}.source = 'import'
758
+ AND ${conversationAlias}.conversation_ended_at IS NOT NULL
759
+ THEN ${conversationAlias}.conversation_ended_at
760
+ ELSE ${conversationAlias}.updated_at
761
+ END AS effective_updated_at
762
+ FROM conversation ${conversationAlias}
763
+ `;
764
+ }
765
+ function hydrateConversationRow(row) {
766
+ if (!row)
767
+ return undefined;
768
+ return {
769
+ ...row,
770
+ title: row.resolved_title ?? row.title,
771
+ summary: row.resolved_summary ?? row.summary,
772
+ created_at: row.effective_created_at ?? row.created_at,
773
+ updated_at: row.effective_updated_at ?? row.updated_at,
774
+ };
775
+ }
663
776
  function getConversation(id) {
664
- return getDb().prepare('SELECT * FROM conversation WHERE id = ?').get(id);
777
+ return hydrateConversationRow(getDb().prepare(`${conversationSelectSql('c')} WHERE c.id = ?`).get(id));
665
778
  }
666
779
  function listConversations(opts) {
667
780
  const db = getDb();
668
781
  const limit = opts?.limit ?? 50;
669
782
  const offset = opts?.offset ?? 0;
670
- let sql = 'SELECT * FROM conversation';
783
+ let sql = conversationSelectSql('c');
671
784
  const params = [];
672
785
  const wheres = [];
673
786
  if (opts?.agentId) {
674
- wheres.push('agent_id = ?');
675
- params.push(opts.agentId);
787
+ wheres.push(`(
788
+ c.agent_id = ?
789
+ OR EXISTS (
790
+ SELECT 1
791
+ FROM message m
792
+ WHERE m.conversation_id = c.id
793
+ AND m.bot_id = ?
794
+ )
795
+ )`);
796
+ params.push(opts.agentId, opts.agentId);
676
797
  }
677
798
  if (opts?.search) {
678
- wheres.push('(title LIKE ? OR summary LIKE ?)');
679
- params.push(`%${opts.search}%`, `%${opts.search}%`);
799
+ wheres.push(`(
800
+ coalesce(c.title, '') LIKE ?
801
+ OR coalesce(c.summary, '') LIKE ?
802
+ OR coalesce(${latestConversationSummarySql('c')}, '') LIKE ?
803
+ OR coalesce(${primaryConversationTopicTitleSql('c')}, '') LIKE ?
804
+ OR coalesce(c.project_name, '') LIKE ?
805
+ OR coalesce(c.description, '') LIKE ?
806
+ OR coalesce(c.tags, '') LIKE ?
807
+ OR coalesce(c.topics_discussed, '') LIKE ?
808
+ OR coalesce(c.tech_stack, '') LIKE ?
809
+ OR coalesce(c.category, '') LIKE ?
810
+ )`);
811
+ const needle = `%${opts.search}%`;
812
+ params.push(needle, needle, needle, needle, needle, needle, needle, needle, needle, needle);
680
813
  }
681
814
  if (wheres.length)
682
815
  sql += ' WHERE ' + wheres.join(' AND ');
683
- sql += ' ORDER BY updated_at DESC LIMIT ? OFFSET ?';
816
+ sql += ' ORDER BY c.updated_at DESC LIMIT ? OFFSET ?';
684
817
  params.push(limit, offset);
685
- return db.prepare(sql).all(...params);
818
+ return db.prepare(sql).all(...params).map((row) => hydrateConversationRow(row));
686
819
  }
687
820
  function countConversations(agentId) {
688
821
  const db = getDb();
689
822
  if (agentId) {
690
- return db.prepare('SELECT count(*) as cnt FROM conversation WHERE agent_id = ?').get(agentId).cnt;
823
+ return db.prepare(`
824
+ SELECT count(DISTINCT c.id) as cnt
825
+ FROM conversation c
826
+ WHERE c.agent_id = ?
827
+ OR EXISTS (
828
+ SELECT 1
829
+ FROM message m
830
+ WHERE m.conversation_id = c.id
831
+ AND m.bot_id = ?
832
+ )
833
+ `).get(agentId, agentId).cnt;
691
834
  }
692
835
  return db.prepare('SELECT count(*) as cnt FROM conversation').get().cnt;
693
836
  }
837
+ function countMessagesForBot(botId) {
838
+ return getDb().prepare(`
839
+ SELECT count(*) as cnt
840
+ FROM message
841
+ WHERE bot_id = ?
842
+ `).get(botId).cnt;
843
+ }
844
+ function listBotConversationActivity(botId, limit = 10) {
845
+ const db = getDb();
846
+ return db.prepare(`
847
+ SELECT
848
+ conv.*,
849
+ COALESCE((
850
+ SELECT count(*)
851
+ FROM message bm
852
+ WHERE bm.conversation_id = conv.id
853
+ AND bm.bot_id = ?
854
+ ), 0) AS bot_message_count,
855
+ (
856
+ SELECT max(bm.created_at)
857
+ FROM message bm
858
+ WHERE bm.conversation_id = conv.id
859
+ AND bm.bot_id = ?
860
+ ) AS bot_last_message_at
861
+ FROM (
862
+ ${conversationSelectSql('c')}
863
+ WHERE (
864
+ c.agent_id = ?
865
+ OR EXISTS (
866
+ SELECT 1
867
+ FROM message m
868
+ WHERE m.conversation_id = c.id
869
+ AND m.bot_id = ?
870
+ )
871
+ )
872
+ ) conv
873
+ ORDER BY COALESCE(bot_last_message_at, conv.updated_at, conv.created_at) DESC
874
+ LIMIT ?
875
+ `).all(botId, botId, botId, botId, limit).map((row) => hydrateConversationRow(row));
876
+ }
694
877
  function updateConversation(id, fields) {
695
878
  const db = getDb();
696
879
  const sets = [];
@@ -731,6 +914,8 @@ function updateConversation(id, fields) {
731
914
  processingError: ['processing_error', fields.processingError],
732
915
  botId: ['bot_id', fields.botId],
733
916
  forkedFromId: ['forked_from_id', fields.forkedFromId],
917
+ createdAt: ['created_at', fields.createdAt],
918
+ updatedAt: ['updated_at', fields.updatedAt],
734
919
  };
735
920
  for (const [key, [col, val]] of Object.entries(map)) {
736
921
  if (fields[key] !== undefined) {
@@ -748,17 +933,30 @@ function updateConversation(id, fields) {
748
933
  }
749
934
  if (sets.length === 0)
750
935
  return;
751
- sets.push("updated_at = datetime('now')");
936
+ if (fields.updatedAt === undefined) {
937
+ sets.push("updated_at = datetime('now')");
938
+ }
752
939
  vals.push(id);
753
940
  db.prepare(`UPDATE conversation SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
754
941
  }
942
+ function touchConversationActivity(id, ts) {
943
+ const stamp = ts?.trim() || now();
944
+ getDb().prepare(`
945
+ UPDATE conversation
946
+ SET updated_at = CASE
947
+ WHEN datetime(updated_at) > datetime(?) THEN updated_at
948
+ ELSE ?
949
+ END
950
+ WHERE id = ?
951
+ `).run(stamp, stamp, id);
952
+ }
755
953
  function deleteConversation(id) {
756
954
  return getDb().prepare('DELETE FROM conversation WHERE id = ?').run(id).changes > 0;
757
955
  }
758
- function addMessage(conversationId, role, content, model, toolCallsJson, botId, agentName) {
956
+ function addMessage(conversationId, role, content, model, toolCallsJson, botId, agentName, createdAt) {
759
957
  const db = getDb();
760
958
  const id = newId();
761
- const ts = now();
959
+ const ts = createdAt?.trim() || now();
762
960
  // Get next sequence number
763
961
  const maxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) as m FROM message WHERE conversation_id = ?').get(conversationId);
764
962
  const seq = maxSeq.m + 1;
@@ -767,9 +965,158 @@ function addMessage(conversationId, role, content, model, toolCallsJson, botId,
767
965
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
768
966
  `).run(id, conversationId, seq, role, content, model ?? null, toolCallsJson ?? null, botId ?? null, agentName ?? null, ts);
769
967
  // Update conversation message_count and updated_at
770
- db.prepare("UPDATE conversation SET message_count = message_count + 1, updated_at = datetime('now') WHERE id = ?").run(conversationId);
968
+ db.prepare(`
969
+ UPDATE conversation
970
+ SET message_count = message_count + 1,
971
+ updated_at = CASE
972
+ WHEN datetime(updated_at) > datetime(?) THEN updated_at
973
+ ELSE ?
974
+ END
975
+ WHERE id = ?
976
+ `).run(ts, ts, conversationId);
771
977
  return db.prepare('SELECT * FROM message WHERE id = ?').get(id);
772
978
  }
979
+ function updateMessage(id, fields) {
980
+ const db = getDb();
981
+ const row = db.prepare('SELECT * FROM message WHERE id = ?').get(id);
982
+ if (!row)
983
+ return undefined;
984
+ const sets = [];
985
+ const vals = [];
986
+ if (fields.content !== undefined) {
987
+ sets.push('content = ?');
988
+ vals.push(fields.content);
989
+ }
990
+ if (fields.model !== undefined) {
991
+ sets.push('model = ?');
992
+ vals.push(fields.model);
993
+ }
994
+ if (fields.toolCallsJson !== undefined) {
995
+ sets.push('tool_calls_json = ?');
996
+ vals.push(fields.toolCallsJson);
997
+ }
998
+ if (fields.botId !== undefined) {
999
+ sets.push('bot_id = ?');
1000
+ vals.push(fields.botId);
1001
+ }
1002
+ if (fields.agentName !== undefined) {
1003
+ sets.push('agent_name = ?');
1004
+ vals.push(fields.agentName);
1005
+ }
1006
+ if (fields.resultArtifact !== undefined) {
1007
+ sets.push('result_artifact = ?');
1008
+ vals.push(fields.resultArtifact);
1009
+ }
1010
+ if (fields.resultSummary !== undefined) {
1011
+ sets.push('result_summary = ?');
1012
+ vals.push(fields.resultSummary);
1013
+ }
1014
+ if (fields.resultStatus !== undefined) {
1015
+ sets.push('result_status = ?');
1016
+ vals.push(fields.resultStatus);
1017
+ }
1018
+ if (sets.length === 0)
1019
+ return row;
1020
+ vals.push(id);
1021
+ db.prepare(`UPDATE message SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1022
+ return db.prepare('SELECT * FROM message WHERE id = ?').get(id);
1023
+ }
1024
+ function createChatJob(input) {
1025
+ const db = getDb();
1026
+ const id = newId();
1027
+ const ts = now();
1028
+ db.prepare(`
1029
+ INSERT INTO chat_job (
1030
+ id, conversation_id, user_message_id, assistant_message_id, bot_id,
1031
+ status, request_json, created_at, updated_at
1032
+ )
1033
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1034
+ `).run(id, input.conversationId, input.userMessageId, input.assistantMessageId, input.botId, input.status ?? 'queued', input.requestJson ?? null, ts, ts);
1035
+ return db.prepare('SELECT * FROM chat_job WHERE id = ?').get(id);
1036
+ }
1037
+ function getChatJob(id) {
1038
+ return getDb().prepare('SELECT * FROM chat_job WHERE id = ?').get(id);
1039
+ }
1040
+ function getLatestConversationChatJob(conversationId) {
1041
+ return getDb().prepare(`
1042
+ SELECT *
1043
+ FROM chat_job
1044
+ WHERE conversation_id = ?
1045
+ ORDER BY datetime(created_at) DESC, rowid DESC
1046
+ LIMIT 1
1047
+ `).get(conversationId);
1048
+ }
1049
+ function listQueuedChatJobs(limit = 50) {
1050
+ return getDb().prepare(`
1051
+ SELECT *
1052
+ FROM chat_job
1053
+ WHERE status = 'queued'
1054
+ ORDER BY datetime(created_at) ASC, rowid ASC
1055
+ LIMIT ?
1056
+ `).all(limit);
1057
+ }
1058
+ function countRunningChatJobs() {
1059
+ return getDb().prepare(`
1060
+ SELECT count(*) AS cnt
1061
+ FROM chat_job
1062
+ WHERE status = 'running'
1063
+ `).get().cnt;
1064
+ }
1065
+ function updateChatJob(id, fields) {
1066
+ const db = getDb();
1067
+ const row = db.prepare('SELECT * FROM chat_job WHERE id = ?').get(id);
1068
+ if (!row)
1069
+ return undefined;
1070
+ const sets = [];
1071
+ const vals = [];
1072
+ if (fields.status !== undefined) {
1073
+ sets.push('status = ?');
1074
+ vals.push(fields.status);
1075
+ }
1076
+ if (fields.error !== undefined) {
1077
+ sets.push('error = ?');
1078
+ vals.push(fields.error);
1079
+ }
1080
+ if (fields.requestJson !== undefined) {
1081
+ sets.push('request_json = ?');
1082
+ vals.push(fields.requestJson);
1083
+ }
1084
+ if (fields.startedAt !== undefined) {
1085
+ sets.push('started_at = ?');
1086
+ vals.push(fields.startedAt);
1087
+ }
1088
+ if (fields.completedAt !== undefined) {
1089
+ sets.push('completed_at = ?');
1090
+ vals.push(fields.completedAt);
1091
+ }
1092
+ if (fields.cancelledAt !== undefined) {
1093
+ sets.push('cancelled_at = ?');
1094
+ vals.push(fields.cancelledAt);
1095
+ }
1096
+ if (sets.length === 0)
1097
+ return row;
1098
+ sets.push('updated_at = ?');
1099
+ vals.push(now());
1100
+ vals.push(id);
1101
+ db.prepare(`UPDATE chat_job SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1102
+ return db.prepare('SELECT * FROM chat_job WHERE id = ?').get(id);
1103
+ }
1104
+ function markRunningChatJobsInterrupted(reason = 'Interrupted when app closed') {
1105
+ const db = getDb();
1106
+ const ts = now();
1107
+ const result = db.prepare(`
1108
+ UPDATE chat_job
1109
+ SET status = 'failed',
1110
+ error = CASE
1111
+ WHEN error IS NULL OR trim(error) = '' THEN ?
1112
+ ELSE error
1113
+ END,
1114
+ completed_at = COALESCE(completed_at, ?),
1115
+ updated_at = ?
1116
+ WHERE status = 'running'
1117
+ `).run(reason, ts, ts);
1118
+ return result.changes;
1119
+ }
773
1120
  function createMessageActivity(activity) {
774
1121
  const db = getDb();
775
1122
  const id = newId();
@@ -1198,11 +1545,6 @@ function listMemoryFacts(opts) {
1198
1545
  function deleteMemoryFact(id) {
1199
1546
  return getDb().prepare('DELETE FROM memory_fact WHERE id = ?').run(id).changes > 0;
1200
1547
  }
1201
- /**
1202
- * Get user-profile facts (person entities) for injection into the system prompt.
1203
- * Returns high-confidence facts about people (address, preferences, etc.)
1204
- * limited to ~500 tokens worth of content.
1205
- */
1206
1548
  function getUserProfileFacts(agentId, limit = 15) {
1207
1549
  const db = getDb();
1208
1550
  return db.prepare(`
@@ -1398,6 +1740,19 @@ function getAllSettings() {
1398
1740
  function deleteSetting(key) {
1399
1741
  return getDb().prepare('DELETE FROM settings WHERE key = ?').run(key).changes > 0;
1400
1742
  }
1743
+ function purgeLegacyExtractionDataOnce() {
1744
+ if (getSetting('legacy_extraction_cleanup_v1') === 'done')
1745
+ return false;
1746
+ const db = getDb();
1747
+ const tx = db.transaction(() => {
1748
+ for (const table of ['entity_relationship', 'entity', 'memory_fact', 'decision', 'action_item']) {
1749
+ db.prepare(`DELETE FROM ${table}`).run();
1750
+ }
1751
+ setSetting('legacy_extraction_cleanup_v1', 'done');
1752
+ });
1753
+ tx();
1754
+ return true;
1755
+ }
1401
1756
  const AUTH_SESSION_KEY = 'auth.session';
1402
1757
  function defaultOrchestrationPolicy() {
1403
1758
  return {
@@ -1852,6 +2207,36 @@ function inferTaskTypeForAgent(agent, requested) {
1852
2207
  return explicit;
1853
2208
  return roleClassToTaskType(agent?.role_class) || roleClassToTaskType(agent?.role_label) || null;
1854
2209
  }
2210
+ function buildQueuedTaskContext(task) {
2211
+ const lines = [
2212
+ 'NEXT TASK CONTEXT',
2213
+ `Title: ${task.title}`,
2214
+ task.task_type ? `Task type: ${task.task_type}` : null,
2215
+ task.details?.trim() ? `Details: ${task.details.trim()}` : null,
2216
+ task.success_criteria?.trim() ? `Success criteria: ${task.success_criteria.trim()}` : null,
2217
+ ].filter(Boolean);
2218
+ return lines.join('\n');
2219
+ }
2220
+ function injectWorkerHandoffIntoPrompt(existingPrompt, handoffText, nextTask) {
2221
+ const taskReplacement = [
2222
+ 'Task:',
2223
+ 'PREVIOUS WORKER HANDOFF',
2224
+ handoffText,
2225
+ '',
2226
+ buildQueuedTaskContext(nextTask),
2227
+ '',
2228
+ 'RUNTIME CONTEXT',
2229
+ ].join('\n');
2230
+ if (existingPrompt.includes('\nRUNTIME CONTEXT\n')) {
2231
+ return existingPrompt.replace(/Task:\s*[\s\S]*?\nRUNTIME CONTEXT\n/, `${taskReplacement}\n`);
2232
+ }
2233
+ return [
2234
+ 'PREVIOUS WORKER HANDOFF',
2235
+ handoffText,
2236
+ '',
2237
+ buildQueuedTaskContext(nextTask),
2238
+ ].join('\n');
2239
+ }
1855
2240
  function normalizeArtifactRow(row) {
1856
2241
  return {
1857
2242
  id: Number(row.id),
@@ -1982,6 +2367,44 @@ function resolveAllowedWorkerNamesForInsertion(currentTask) {
1982
2367
  allowed.add(previous.owner_name.trim().toLowerCase());
1983
2368
  return Array.from(allowed);
1984
2369
  }
2370
+ function resolvePreviousWorkerName(currentTask) {
2371
+ const db = getDb();
2372
+ const previous = currentTask.project_id
2373
+ ? db.prepare(`
2374
+ SELECT owner_name
2375
+ FROM (
2376
+ SELECT owner_name, created_order, internal_only FROM todo_active WHERE project_id = ?
2377
+ UNION ALL
2378
+ SELECT owner_name, created_order, internal_only FROM todo_completed WHERE project_id = ?
2379
+ )
2380
+ WHERE created_order < ? AND COALESCE(internal_only, 0) = 0 AND owner_name IS NOT NULL
2381
+ ORDER BY created_order DESC
2382
+ LIMIT 1
2383
+ `).get(currentTask.project_id, currentTask.project_id, currentTask.position)
2384
+ : db.prepare(`
2385
+ SELECT owner_name
2386
+ FROM (
2387
+ SELECT owner_name, created_order, internal_only FROM todo_active WHERE project_id IS NULL
2388
+ UNION ALL
2389
+ SELECT owner_name, created_order, internal_only FROM todo_completed WHERE project_id IS NULL
2390
+ )
2391
+ WHERE created_order < ? AND COALESCE(internal_only, 0) = 0 AND owner_name IS NOT NULL
2392
+ ORDER BY created_order DESC
2393
+ LIMIT 1
2394
+ `).get(currentTask.position);
2395
+ return previous?.owner_name?.trim() || null;
2396
+ }
2397
+ function shouldAutoCreateQaFixTask(task, outputSummary, handoffPrompt) {
2398
+ if (String(task.task_type || '').trim().toLowerCase() !== 'qa')
2399
+ return false;
2400
+ const combined = `${outputSummary || ''}\n${handoffPrompt || ''}`.trim().toLowerCase();
2401
+ if (!combined)
2402
+ return false;
2403
+ if (/(^|\b)(qa passed|all checks passed|no issues found|looks good to ship)(\b|$)/i.test(combined)) {
2404
+ return false;
2405
+ }
2406
+ return /\b(fix|defect|issue|problem|error|not ready|fails?|failed|remaining problem|remaining issue|validator|re-qa)\b/i.test(combined);
2407
+ }
1985
2408
  function normalizeArtifactInput(ref) {
1986
2409
  if (typeof ref === 'string') {
1987
2410
  const trimmed = ref.trim();
@@ -2424,10 +2847,49 @@ function completeTodoTaskByWorker(taskId, params) {
2424
2847
  }
2425
2848
  }
2426
2849
  else {
2427
- const nextWorkerName = completedTask.next_worker_name?.trim();
2428
- if (nextWorkerName) {
2429
- const nextRow = completedTask.next_worker_bot_id
2430
- ? db.prepare(`
2850
+ if (shouldAutoCreateQaFixTask(completedTask, outputSummary, handoffPrompt)) {
2851
+ const previousWorkerName = resolvePreviousWorkerName(completedTask);
2852
+ const previousWorker = resolveAgentByName(previousWorkerName);
2853
+ if (previousWorkerName && previousWorker) {
2854
+ const participants = [previousWorker.name];
2855
+ insertedTask = insertTodoTaskTx(db, {
2856
+ projectId: completedTask.project_id,
2857
+ conversationId: completedTask.conversation_id,
2858
+ title: 'Fix QA findings',
2859
+ details: handoffPrompt || outputSummary || 'Address the QA findings and update the implementation.',
2860
+ participants,
2861
+ successCriteria: 'Address the QA findings and return the updated work for re-QA.',
2862
+ ownerBotId: previousWorker.id,
2863
+ ownerName: previousWorker.name,
2864
+ taskType: inferTaskTypeForAgent(previousWorker, null),
2865
+ profileName: completedTask.profile_name || 'Programming',
2866
+ nextWorkerBotId: currentTask.owner_bot_id,
2867
+ nextWorkerName: currentTask.owner_name,
2868
+ nextWorkerRole: actorBot?.role_label || actorBot?.role_class || null,
2869
+ taskPrompt: handoffPrompt || outputSummary || 'Address the QA findings and keep the approved parts unchanged.',
2870
+ artifactIds,
2871
+ insertAfterTaskId: completedTask.id,
2872
+ actor: params.actor,
2873
+ });
2874
+ logTodoAuditTx(db, insertedTask.id, 'active', 'worker_insert', params.actor, {
2875
+ insertedAfterTaskId: completedTask.id,
2876
+ requestedWorkerName: previousWorker.name,
2877
+ autoCreatedFromQaFailure: true,
2878
+ });
2879
+ }
2880
+ else {
2881
+ returnedToOrchestrator = true;
2882
+ logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
2883
+ handoffPrompt,
2884
+ missingPreviousWorkerForQaFix: true,
2885
+ });
2886
+ }
2887
+ }
2888
+ else {
2889
+ const nextWorkerName = completedTask.next_worker_name?.trim();
2890
+ if (nextWorkerName) {
2891
+ const nextRow = completedTask.next_worker_bot_id
2892
+ ? db.prepare(`
2431
2893
  SELECT * FROM todo_active
2432
2894
  WHERE conversation_id = ?
2433
2895
  AND id <> ?
@@ -2437,7 +2899,7 @@ function completeTodoTaskByWorker(taskId, params) {
2437
2899
  ORDER BY created_order ASC, id ASC
2438
2900
  LIMIT 1
2439
2901
  `).get(completedTask.conversation_id, completedTask.id, completedTask.next_worker_bot_id, completedTask.created_order)
2440
- : db.prepare(`
2902
+ : db.prepare(`
2441
2903
  SELECT * FROM todo_active
2442
2904
  WHERE conversation_id = ?
2443
2905
  AND id <> ?
@@ -2447,21 +2909,15 @@ function completeTodoTaskByWorker(taskId, params) {
2447
2909
  ORDER BY created_order ASC, id ASC
2448
2910
  LIMIT 1
2449
2911
  `).get(completedTask.conversation_id, completedTask.id, nextWorkerName, completedTask.created_order);
2450
- if (nextRow) {
2451
- const nextTask = normalizeTodoRow(nextRow, 'active');
2452
- const handoffText = handoffPrompt?.trim()
2453
- || outputSummary?.trim()
2454
- || `Continue from the completed work summary: ${completedTask.title}`;
2455
- const existingPrompt = nextTask.task_prompt?.trim() || nextTask.details || nextTask.title;
2456
- const mergedArtifactIds = Array.from(new Set([...(nextTask.artifact_ids || []), ...artifactIds]));
2457
- const nextPrompt = [
2458
- 'PREVIOUS WORKER HANDOFF',
2459
- handoffText,
2460
- '',
2461
- 'ORIGINAL ASSIGNED TODO',
2462
- existingPrompt,
2463
- ].join('\n');
2464
- db.prepare(`
2912
+ if (nextRow) {
2913
+ const nextTask = normalizeTodoRow(nextRow, 'active');
2914
+ const handoffText = handoffPrompt?.trim()
2915
+ || outputSummary?.trim()
2916
+ || `Continue from the completed work summary: ${completedTask.title}`;
2917
+ const existingPrompt = nextTask.task_prompt?.trim() || nextTask.details || nextTask.title;
2918
+ const mergedArtifactIds = Array.from(new Set([...(nextTask.artifact_ids || []), ...artifactIds]));
2919
+ const nextPrompt = injectWorkerHandoffIntoPrompt(existingPrompt, handoffText, nextTask);
2920
+ db.prepare(`
2465
2921
  UPDATE todo_active
2466
2922
  SET task_prompt = ?,
2467
2923
  artifact_ids_json = ?,
@@ -2469,26 +2925,27 @@ function completeTodoTaskByWorker(taskId, params) {
2469
2925
  version = version + 1
2470
2926
  WHERE id = ?
2471
2927
  `).run(nextPrompt, JSON.stringify(mergedArtifactIds), now(), nextTask.id);
2472
- handedOffTask = normalizeTodoRow(db.prepare('SELECT * FROM todo_active WHERE id = ?').get(nextTask.id), 'active');
2473
- logTodoAuditTx(db, nextTask.id, 'active', 'edit', params.actor, {
2474
- previousTaskId: completedTask.id,
2475
- receivedHandoff: true,
2476
- });
2928
+ handedOffTask = normalizeTodoRow(db.prepare('SELECT * FROM todo_active WHERE id = ?').get(nextTask.id), 'active');
2929
+ logTodoAuditTx(db, nextTask.id, 'active', 'edit', params.actor, {
2930
+ previousTaskId: completedTask.id,
2931
+ receivedHandoff: true,
2932
+ });
2933
+ }
2934
+ else {
2935
+ returnedToOrchestrator = true;
2936
+ logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
2937
+ handoffPrompt,
2938
+ missingQueuedNextWorker: nextWorkerName,
2939
+ });
2940
+ }
2477
2941
  }
2478
2942
  else {
2479
2943
  returnedToOrchestrator = true;
2480
2944
  logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
2481
2945
  handoffPrompt,
2482
- missingQueuedNextWorker: nextWorkerName,
2483
2946
  });
2484
2947
  }
2485
2948
  }
2486
- else {
2487
- returnedToOrchestrator = true;
2488
- logTodoAuditTx(db, taskId, 'completed', 'return_to_orchestrator', params.actor, {
2489
- handoffPrompt,
2490
- });
2491
- }
2492
2949
  }
2493
2950
  return {
2494
2951
  completedTask,
@@ -3040,13 +3497,20 @@ function listTopicConversations(topicId, opts) {
3040
3497
  const db = getDb();
3041
3498
  const limit = opts?.limit ?? 50;
3042
3499
  const offset = opts?.offset ?? 0;
3043
- return db.prepare(`
3044
- SELECT DISTINCT c.* FROM conversation c
3045
- INNER JOIN topic_segment ts ON ts.conversation_id = c.id
3500
+ const rows = db.prepare(`
3501
+ SELECT DISTINCT conv.*
3502
+ FROM (
3503
+ ${conversationSelectSql('c')}
3504
+ ) conv
3505
+ INNER JOIN topic_segment ts ON ts.conversation_id = conv.id
3046
3506
  WHERE ts.topic_id = ?
3047
- ORDER BY c.updated_at DESC
3507
+ ORDER BY datetime(COALESCE(conv.effective_updated_at, conv.updated_at)) DESC,
3508
+ datetime(COALESCE(conv.effective_created_at, conv.created_at)) DESC
3048
3509
  LIMIT ? OFFSET ?
3049
3510
  `).all(topicId, limit, offset);
3511
+ return rows
3512
+ .map((row) => hydrateConversationRow(row))
3513
+ .filter((row) => !!row);
3050
3514
  }
3051
3515
  function getPrimaryTopicIdForConversation(conversationId) {
3052
3516
  const db = getDb();
@@ -3090,16 +3554,29 @@ function listTopicConversationsWithPreviews(topicId, opts) {
3090
3554
  const limit = opts?.limit ?? 50;
3091
3555
  const offset = opts?.offset ?? 0;
3092
3556
  // Primary: find conversations via topic_segment links
3093
- let convs = db.prepare(`
3094
- SELECT DISTINCT c.* FROM conversation c
3095
- INNER JOIN topic_segment ts ON ts.conversation_id = c.id
3557
+ const rawConvs = db.prepare(`
3558
+ SELECT DISTINCT conv.*
3559
+ FROM (
3560
+ ${conversationSelectSql('c')}
3561
+ ) conv
3562
+ INNER JOIN topic_segment ts ON ts.conversation_id = conv.id
3096
3563
  WHERE ts.topic_id = ?
3097
- ORDER BY c.updated_at DESC
3564
+ ORDER BY datetime(COALESCE(conv.effective_updated_at, conv.updated_at)) DESC,
3565
+ datetime(COALESCE(conv.effective_created_at, conv.created_at)) DESC
3098
3566
  LIMIT ? OFFSET ?
3099
3567
  `).all(topicId, limit, offset);
3568
+ const convs = rawConvs
3569
+ .map((row) => hydrateConversationRow(row))
3570
+ .filter((row) => !!row);
3100
3571
  // No fallback — topics with no segments have no conversations yet.
3101
3572
  // Backfill/import must create topic_segment rows explicitly.
3102
3573
  return convs.map(conv => {
3574
+ const latestJob = getLatestConversationChatJob(conv.id);
3575
+ const jobStatus = latestJob?.status === 'running'
3576
+ ? 'working'
3577
+ : latestJob?.status === 'completed'
3578
+ ? 'done'
3579
+ : latestJob?.status ?? null;
3103
3580
  // Get latest summary text
3104
3581
  const summary = db.prepare('SELECT summary_text FROM conversation_summary WHERE conversation_id = ? ORDER BY turn_range_end DESC LIMIT 1').get(conv.id);
3105
3582
  // Get first user message, truncated to 200 chars
@@ -3108,6 +3585,7 @@ function listTopicConversationsWithPreviews(topicId, opts) {
3108
3585
  ...conv,
3109
3586
  summary_text: summary?.summary_text ?? null,
3110
3587
  first_user_message: firstMsg ? firstMsg.content.slice(0, 200) : null,
3588
+ job_status: jobStatus,
3111
3589
  };
3112
3590
  });
3113
3591
  }
@@ -3159,6 +3637,10 @@ function updateWorkflowTemplate(id, fields) {
3159
3637
  const db = getDb();
3160
3638
  const sets = [];
3161
3639
  const vals = [];
3640
+ if (fields.projectId !== undefined) {
3641
+ sets.push('project_id = ?');
3642
+ vals.push(fields.projectId);
3643
+ }
3162
3644
  if (fields.name !== undefined) {
3163
3645
  sets.push('name = ?');
3164
3646
  vals.push(fields.name);