agent-world 0.12.3 → 0.15.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 (263) hide show
  1. package/README.md +105 -17
  2. package/dist/cli/commands.d.ts +7 -1
  3. package/dist/cli/commands.js +27 -10
  4. package/dist/cli/hitl.d.ts +9 -2
  5. package/dist/cli/hitl.js +61 -20
  6. package/dist/cli/index.js +250 -96
  7. package/dist/cli/system-events.d.ts +27 -0
  8. package/dist/cli/system-events.js +63 -0
  9. package/dist/core/activity-tracker.d.ts +38 -2
  10. package/dist/core/activity-tracker.d.ts.map +1 -1
  11. package/dist/core/activity-tracker.js +62 -9
  12. package/dist/core/activity-tracker.js.map +1 -1
  13. package/dist/core/anthropic-direct.d.ts +2 -0
  14. package/dist/core/anthropic-direct.d.ts.map +1 -1
  15. package/dist/core/anthropic-direct.js +43 -1
  16. package/dist/core/anthropic-direct.js.map +1 -1
  17. package/dist/core/chat-constants.d.ts +12 -0
  18. package/dist/core/chat-constants.d.ts.map +1 -1
  19. package/dist/core/chat-constants.js +5 -0
  20. package/dist/core/chat-constants.js.map +1 -1
  21. package/dist/core/create-agent-tool.d.ts +28 -25
  22. package/dist/core/create-agent-tool.d.ts.map +1 -1
  23. package/dist/core/create-agent-tool.js +264 -141
  24. package/dist/core/create-agent-tool.js.map +1 -1
  25. package/dist/core/events/index.d.ts +5 -2
  26. package/dist/core/events/index.d.ts.map +1 -1
  27. package/dist/core/events/index.js +5 -2
  28. package/dist/core/events/index.js.map +1 -1
  29. package/dist/core/events/memory-manager.d.ts +26 -1
  30. package/dist/core/events/memory-manager.d.ts.map +1 -1
  31. package/dist/core/events/memory-manager.js +877 -72
  32. package/dist/core/events/memory-manager.js.map +1 -1
  33. package/dist/core/events/orchestrator.d.ts +8 -0
  34. package/dist/core/events/orchestrator.d.ts.map +1 -1
  35. package/dist/core/events/orchestrator.js +214 -38
  36. package/dist/core/events/orchestrator.js.map +1 -1
  37. package/dist/core/events/persistence.d.ts +21 -14
  38. package/dist/core/events/persistence.d.ts.map +1 -1
  39. package/dist/core/events/persistence.js +100 -61
  40. package/dist/core/events/persistence.js.map +1 -1
  41. package/dist/core/events/publishers.d.ts +13 -16
  42. package/dist/core/events/publishers.d.ts.map +1 -1
  43. package/dist/core/events/publishers.js +54 -55
  44. package/dist/core/events/publishers.js.map +1 -1
  45. package/dist/core/events/subscribers.d.ts +17 -14
  46. package/dist/core/events/subscribers.d.ts.map +1 -1
  47. package/dist/core/events/subscribers.js +68 -147
  48. package/dist/core/events/subscribers.js.map +1 -1
  49. package/dist/core/events/title-scheduler.d.ts +27 -0
  50. package/dist/core/events/title-scheduler.d.ts.map +1 -0
  51. package/dist/core/events/title-scheduler.js +135 -0
  52. package/dist/core/events/title-scheduler.js.map +1 -0
  53. package/dist/core/events/tool-bridge-logging.d.ts +4 -1
  54. package/dist/core/events/tool-bridge-logging.d.ts.map +1 -1
  55. package/dist/core/events/tool-bridge-logging.js +112 -13
  56. package/dist/core/events/tool-bridge-logging.js.map +1 -1
  57. package/dist/core/events-metadata.d.ts.map +1 -1
  58. package/dist/core/events-metadata.js +8 -4
  59. package/dist/core/events-metadata.js.map +1 -1
  60. package/dist/core/export.d.ts +1 -1
  61. package/dist/core/export.d.ts.map +1 -1
  62. package/dist/core/export.js +2 -15
  63. package/dist/core/export.js.map +1 -1
  64. package/dist/core/feature-path-logging.d.ts +50 -0
  65. package/dist/core/feature-path-logging.d.ts.map +1 -0
  66. package/dist/core/feature-path-logging.js +130 -0
  67. package/dist/core/feature-path-logging.js.map +1 -0
  68. package/dist/core/file-tools.d.ts +57 -1
  69. package/dist/core/file-tools.d.ts.map +1 -1
  70. package/dist/core/file-tools.js +329 -29
  71. package/dist/core/file-tools.js.map +1 -1
  72. package/dist/core/google-direct.d.ts +6 -1
  73. package/dist/core/google-direct.d.ts.map +1 -1
  74. package/dist/core/google-direct.js +76 -7
  75. package/dist/core/google-direct.js.map +1 -1
  76. package/dist/core/heartbeat.d.ts +34 -0
  77. package/dist/core/heartbeat.d.ts.map +1 -0
  78. package/dist/core/heartbeat.js +153 -0
  79. package/dist/core/heartbeat.js.map +1 -0
  80. package/dist/core/hitl-tool.d.ts +73 -0
  81. package/dist/core/hitl-tool.d.ts.map +1 -0
  82. package/dist/core/hitl-tool.js +284 -0
  83. package/dist/core/hitl-tool.js.map +1 -0
  84. package/dist/core/hitl.d.ts +85 -8
  85. package/dist/core/hitl.d.ts.map +1 -1
  86. package/dist/core/hitl.js +375 -61
  87. package/dist/core/hitl.js.map +1 -1
  88. package/dist/core/index.d.ts +12 -7
  89. package/dist/core/index.d.ts.map +1 -1
  90. package/dist/core/index.js +11 -6
  91. package/dist/core/index.js.map +1 -1
  92. package/dist/core/llm-manager.d.ts +17 -0
  93. package/dist/core/llm-manager.d.ts.map +1 -1
  94. package/dist/core/llm-manager.js +335 -43
  95. package/dist/core/llm-manager.js.map +1 -1
  96. package/dist/core/load-skill-tool.d.ts +36 -3
  97. package/dist/core/load-skill-tool.d.ts.map +1 -1
  98. package/dist/core/load-skill-tool.js +807 -93
  99. package/dist/core/load-skill-tool.js.map +1 -1
  100. package/dist/core/logger.d.ts +14 -0
  101. package/dist/core/logger.d.ts.map +1 -1
  102. package/dist/core/logger.js +15 -0
  103. package/dist/core/logger.js.map +1 -1
  104. package/dist/core/managers.d.ts +41 -52
  105. package/dist/core/managers.d.ts.map +1 -1
  106. package/dist/core/managers.js +422 -533
  107. package/dist/core/managers.js.map +1 -1
  108. package/dist/core/mcp-server-registry.d.ts +19 -2
  109. package/dist/core/mcp-server-registry.d.ts.map +1 -1
  110. package/dist/core/mcp-server-registry.js +168 -12
  111. package/dist/core/mcp-server-registry.js.map +1 -1
  112. package/dist/core/message-cutoff.d.ts +29 -0
  113. package/dist/core/message-cutoff.d.ts.map +1 -0
  114. package/dist/core/message-cutoff.js +63 -0
  115. package/dist/core/message-cutoff.js.map +1 -0
  116. package/dist/core/message-edit-manager.d.ts +54 -0
  117. package/dist/core/message-edit-manager.d.ts.map +1 -0
  118. package/dist/core/message-edit-manager.js +602 -0
  119. package/dist/core/message-edit-manager.js.map +1 -0
  120. package/dist/core/message-prep.d.ts +2 -0
  121. package/dist/core/message-prep.d.ts.map +1 -1
  122. package/dist/core/message-prep.js +39 -12
  123. package/dist/core/message-prep.js.map +1 -1
  124. package/dist/core/message-processing-control.d.ts +1 -0
  125. package/dist/core/message-processing-control.d.ts.map +1 -1
  126. package/dist/core/message-processing-control.js +23 -6
  127. package/dist/core/message-processing-control.js.map +1 -1
  128. package/dist/core/openai-direct.d.ts +9 -3
  129. package/dist/core/openai-direct.d.ts.map +1 -1
  130. package/dist/core/openai-direct.js +267 -33
  131. package/dist/core/openai-direct.js.map +1 -1
  132. package/dist/core/optional-tracers/opik-runtime.d.ts +32 -0
  133. package/dist/core/optional-tracers/opik-runtime.d.ts.map +1 -0
  134. package/dist/core/optional-tracers/opik-runtime.js +141 -0
  135. package/dist/core/optional-tracers/opik-runtime.js.map +1 -0
  136. package/dist/core/queue-manager.d.ts +84 -0
  137. package/dist/core/queue-manager.d.ts.map +1 -0
  138. package/dist/core/queue-manager.js +814 -0
  139. package/dist/core/queue-manager.js.map +1 -0
  140. package/dist/core/reasoning-controls.d.ts +30 -0
  141. package/dist/core/reasoning-controls.d.ts.map +1 -0
  142. package/dist/core/reasoning-controls.js +118 -0
  143. package/dist/core/reasoning-controls.js.map +1 -0
  144. package/dist/core/reliability-config.d.ts +82 -0
  145. package/dist/core/reliability-config.d.ts.map +1 -0
  146. package/dist/core/reliability-config.js +106 -0
  147. package/dist/core/reliability-config.js.map +1 -0
  148. package/dist/core/reliability-runtime.d.ts +53 -0
  149. package/dist/core/reliability-runtime.d.ts.map +1 -0
  150. package/dist/core/reliability-runtime.js +92 -0
  151. package/dist/core/reliability-runtime.js.map +1 -0
  152. package/dist/core/security/guardrails.d.ts +21 -0
  153. package/dist/core/security/guardrails.d.ts.map +1 -0
  154. package/dist/core/security/guardrails.js +111 -0
  155. package/dist/core/security/guardrails.js.map +1 -0
  156. package/dist/core/send-message-tool.d.ts +79 -0
  157. package/dist/core/send-message-tool.d.ts.map +1 -0
  158. package/dist/core/send-message-tool.js +222 -0
  159. package/dist/core/send-message-tool.js.map +1 -0
  160. package/dist/core/shell-cmd-tool.d.ts +82 -1
  161. package/dist/core/shell-cmd-tool.d.ts.map +1 -1
  162. package/dist/core/shell-cmd-tool.js +854 -42
  163. package/dist/core/shell-cmd-tool.js.map +1 -1
  164. package/dist/core/skill-registry.d.ts +2 -0
  165. package/dist/core/skill-registry.d.ts.map +1 -1
  166. package/dist/core/skill-registry.js +52 -2
  167. package/dist/core/skill-registry.js.map +1 -1
  168. package/dist/core/storage/eventStorage/fileEventStorage.d.ts +5 -0
  169. package/dist/core/storage/eventStorage/fileEventStorage.d.ts.map +1 -1
  170. package/dist/core/storage/eventStorage/fileEventStorage.js +61 -0
  171. package/dist/core/storage/eventStorage/fileEventStorage.js.map +1 -1
  172. package/dist/core/storage/eventStorage/memoryEventStorage.d.ts +5 -0
  173. package/dist/core/storage/eventStorage/memoryEventStorage.d.ts.map +1 -1
  174. package/dist/core/storage/eventStorage/memoryEventStorage.js +34 -0
  175. package/dist/core/storage/eventStorage/memoryEventStorage.js.map +1 -1
  176. package/dist/core/storage/eventStorage/sqliteEventStorage.d.ts +1 -0
  177. package/dist/core/storage/eventStorage/sqliteEventStorage.d.ts.map +1 -1
  178. package/dist/core/storage/eventStorage/sqliteEventStorage.js +19 -2
  179. package/dist/core/storage/eventStorage/sqliteEventStorage.js.map +1 -1
  180. package/dist/core/storage/eventStorage/types.d.ts +6 -0
  181. package/dist/core/storage/eventStorage/types.d.ts.map +1 -1
  182. package/dist/core/storage/eventStorage/types.js +1 -0
  183. package/dist/core/storage/eventStorage/types.js.map +1 -1
  184. package/dist/core/storage/eventStorage/validation.d.ts.map +1 -1
  185. package/dist/core/storage/eventStorage/validation.js +2 -1
  186. package/dist/core/storage/eventStorage/validation.js.map +1 -1
  187. package/dist/core/storage/github-world-import.d.ts +84 -0
  188. package/dist/core/storage/github-world-import.d.ts.map +1 -0
  189. package/dist/core/storage/github-world-import.js +365 -0
  190. package/dist/core/storage/github-world-import.js.map +1 -0
  191. package/dist/core/storage/memory-storage.d.ts +19 -8
  192. package/dist/core/storage/memory-storage.d.ts.map +1 -1
  193. package/dist/core/storage/memory-storage.js +147 -49
  194. package/dist/core/storage/memory-storage.js.map +1 -1
  195. package/dist/core/storage/queue-storage.d.ts +1 -0
  196. package/dist/core/storage/queue-storage.d.ts.map +1 -1
  197. package/dist/core/storage/queue-storage.js +3 -2
  198. package/dist/core/storage/queue-storage.js.map +1 -1
  199. package/dist/core/storage/sqlite-storage.d.ts +14 -9
  200. package/dist/core/storage/sqlite-storage.d.ts.map +1 -1
  201. package/dist/core/storage/sqlite-storage.js +131 -154
  202. package/dist/core/storage/sqlite-storage.js.map +1 -1
  203. package/dist/core/storage/storage-factory.d.ts +3 -0
  204. package/dist/core/storage/storage-factory.d.ts.map +1 -1
  205. package/dist/core/storage/storage-factory.js +175 -89
  206. package/dist/core/storage/storage-factory.js.map +1 -1
  207. package/dist/core/storage/world-storage.d.ts +1 -1
  208. package/dist/core/storage/world-storage.d.ts.map +1 -1
  209. package/dist/core/storage/world-storage.js +5 -1
  210. package/dist/core/storage/world-storage.js.map +1 -1
  211. package/dist/core/storage-init.d.ts +11 -0
  212. package/dist/core/storage-init.d.ts.map +1 -0
  213. package/dist/core/storage-init.js +122 -0
  214. package/dist/core/storage-init.js.map +1 -0
  215. package/dist/core/subscription.d.ts +8 -1
  216. package/dist/core/subscription.d.ts.map +1 -1
  217. package/dist/core/subscription.js +130 -23
  218. package/dist/core/subscription.js.map +1 -1
  219. package/dist/core/tool-approval.d.ts +45 -0
  220. package/dist/core/tool-approval.d.ts.map +1 -0
  221. package/dist/core/tool-approval.js +223 -0
  222. package/dist/core/tool-approval.js.map +1 -0
  223. package/dist/core/tool-execution-envelope.d.ts +87 -0
  224. package/dist/core/tool-execution-envelope.d.ts.map +1 -0
  225. package/dist/core/tool-execution-envelope.js +168 -0
  226. package/dist/core/tool-execution-envelope.js.map +1 -0
  227. package/dist/core/tool-utils.d.ts +9 -2
  228. package/dist/core/tool-utils.d.ts.map +1 -1
  229. package/dist/core/tool-utils.js +122 -28
  230. package/dist/core/tool-utils.js.map +1 -1
  231. package/dist/core/types.d.ts +69 -36
  232. package/dist/core/types.d.ts.map +1 -1
  233. package/dist/core/types.js +3 -2
  234. package/dist/core/types.js.map +1 -1
  235. package/dist/core/utils.d.ts +16 -0
  236. package/dist/core/utils.d.ts.map +1 -1
  237. package/dist/core/utils.js +99 -24
  238. package/dist/core/utils.js.map +1 -1
  239. package/dist/core/web-fetch-tool.d.ts +72 -0
  240. package/dist/core/web-fetch-tool.d.ts.map +1 -0
  241. package/dist/core/web-fetch-tool.js +491 -0
  242. package/dist/core/web-fetch-tool.js.map +1 -0
  243. package/dist/core/world-registry.d.ts +84 -0
  244. package/dist/core/world-registry.d.ts.map +1 -0
  245. package/dist/core/world-registry.js +247 -0
  246. package/dist/core/world-registry.js.map +1 -0
  247. package/dist/public/assets/index-Be-1xtV-.js +104 -0
  248. package/dist/public/assets/index-tsDdiXDU.css +1 -0
  249. package/dist/public/index.html +2 -2
  250. package/dist/public/mcp-sandbox-proxy.html +148 -0
  251. package/dist/server/api.js +288 -58
  252. package/dist/server/error-response.d.ts +27 -0
  253. package/dist/server/error-response.js +77 -0
  254. package/dist/server/index.d.ts +2 -1
  255. package/dist/server/index.js +6 -2
  256. package/dist/server/sse-handler.d.ts +13 -2
  257. package/dist/server/sse-handler.js +194 -26
  258. package/migrations/0015_add_message_queue.sql +36 -0
  259. package/migrations/0016_add_world_heartbeat.sql +13 -0
  260. package/migrations/0017_add_title_provenance.sql +7 -0
  261. package/package.json +31 -10
  262. package/dist/public/assets/index-BO20H4xt.js +0 -96
  263. package/dist/public/assets/index-ETY7W5_S.css +0 -1
@@ -8,23 +8,41 @@
8
8
  * - Automatic ID normalization to kebab-case for consistency
9
9
  * - Environment-aware storage operations through storage-factory
10
10
  * - Agent message management with automatic agentId assignment
11
- * - Message ID migration for user message edit feature
12
- * - User message editing with removal and resubmission
13
- * - Error logging for message edit operations
11
+ *
12
+ * Queue management, message editing, and storage initialization are handled by
13
+ * dedicated sub-modules (queue-manager.ts, message-edit-manager.ts, storage-init.ts)
14
+ * whose public APIs are re-exported here for backward compatibility.
14
15
  *
15
16
  * API: World (create/get/update/delete/list), Agent (create/get/update/delete/list/updateMemory/clearMemory),
16
- * Chat (newChat/listChats/deleteChat/restoreChat), Migration (migrateMessageIds),
17
- * MessageEdit (removeMessagesFrom/editUserMessage/logEditError/getEditErrors)
17
+ * Chat (newChat/listChats/deleteChat/restoreChat),
18
+ * re-exports: migrateMessageIds, removeMessagesFrom, editUserMessage, logEditError, getEditErrors,
19
+ * addToQueue, enqueueAndProcessUserTurn, dispatchImmediateChatMessage, getQueueMessages,
20
+ * pauseChatQueue, resumeChatQueue, stopChatQueue, clearChatQueue, retryQueueMessage,
21
+ * recoverQueueSendingMessages
18
22
  *
19
23
  * Implementation Details:
20
24
  * - Ensures all agent messages include agentId for proper export functionality
21
25
  * - Compatible with both SQLite and memory storage backends
22
26
  * - Automatic agent identification for message source tracking
23
- * - Idempotent message ID migration supporting both file and SQL storage
24
- * - Comprehensive error tracking for partial failures
25
- * - Error log persistence with 100-entry retention policy
26
27
  *
27
28
  * Recent Changes:
29
+ * - 2026-03-11: Synchronized active runtime world metadata during `updateWorld` so live chats pick up
30
+ * updated `variables`/`working_directory` and other top-level config without requiring a runtime refresh.
31
+ * - 2026-03-10: Renamed queue-backed ingress to `enqueueAndProcessUserTurn` and split non-user immediate dispatch into `dispatchImmediateChatMessage`.
32
+ * - 2026-03-10: Removed restore-time user-last resend from persisted chat memory; queue-owned rows are now the only automatic resume authority.
33
+ * - 2026-03-10: Added `restoreChat(..., { suppressAutoResume: true })` support for edit/delete mutation flows so failed last-turn messages are not replayed before mutation.
34
+ * - 2026-03-09: God-module decomposition — extracted queue management (queue-manager.ts),
35
+ * message edit/migration logic (message-edit-manager.ts), and storage singleton +
36
+ * identifier resolution (storage-init.ts) into focused sub-modules. managers.ts reduced
37
+ * from 2928 → 1300 lines; all public exports preserved via re-exports.
38
+ * - 2026-03-09: Added `activateChatResources` helper to consolidate the resource-reactivation
39
+ * sequence (memory sync, skill approvals, HITL replay, queue resume) shared between the
40
+ * two branches of `restoreChat`.
41
+ * - 2026-03-09: Added SSE terminal-state guard in `triggerPendingLastMessageResume` to prevent
42
+ * infinite auto-resume loops when the last SSE event is already terminal.
43
+ * - 2026-03-06: Moved chat-selection runtime control flow onto explicit/persisted chat helpers.
44
+ * - 2026-03-06: Removed runtime `world.currentChatId` fallback from exported chat-memory helpers.
45
+ * - 2026-02-20: Added `claimAgentCreationSlot` / `allowWhileWorldProcessing` for approval-gated tool calls.
28
46
  * - 2026-02-16: Added `branchChatFromMessage` to create a new chat branched from an assistant message and copy source-chat history up to the target message.
29
47
  * - 2026-02-14: Updated `editUserMessage` to be fully core-managed for clear+resend behavior without client-side subscription refresh logic.
30
48
  * - Edit resubmission now prefers active subscribed world runtimes.
@@ -33,7 +51,7 @@
33
51
  * - 2026-02-13: Added world-level `mainAgent` routing config and agent-level `autoReply` toggle support.
34
52
  * - 2026-02-13: Moved edit-resubmission title-regeneration reset into core `editUserMessage` so all clients share the same behavior.
35
53
  * - Auto-generated chat titles are reset to `New Chat` before edit resubmission only when the latest persisted
36
- * chat-title CRUD payload name still matches the current chat name.
54
+ * `chat-title-updated` payload title still matches the current chat name.
37
55
  * - 2026-02-13: Centralized default chat-title semantics via shared chat constants.
38
56
  * - Uses a single `NEW_CHAT_TITLE` source for reusable chat detection and creation paths.
39
57
  * - 2026-02-12: Hardened `getMemory` to auto-migrate legacy messages missing `messageId` before returning memory payloads.
@@ -66,177 +84,159 @@
66
84
  * - logEditError/getEditErrors: Error persistence in edit-errors.json
67
85
  *
68
86
  * Note: Export functionality has been moved to core/export.ts
69
- */ // Core module imports
70
- import { createCategoryLogger, initializeLogger } from './logger.js';
87
+ */
88
+ // Core module imports
89
+ import { createCategoryLogger } from './logger.js';
71
90
  import { EventEmitter } from 'events';
72
- import { createStorageWithWrappers } from './storage/storage-factory.js';
73
91
  import * as utils from './utils.js';
74
- import { nanoid } from 'nanoid';
75
- import * as fs from 'fs';
76
- import * as path from 'path';
77
- import { getWorldDir } from './storage/world-storage.js';
78
- import { getDefaultRootPath } from './storage/storage-factory.js';
79
- import { publishCRUDEvent } from './events/index.js';
80
- import { NEW_CHAT_TITLE, isDefaultChatTitle } from './chat-constants.js';
81
- import { hasActiveChatMessageProcessing, stopMessageProcessing } from './message-processing-control.js';
82
- // Initialize logger and storage
92
+ import { NEW_CHAT_TITLE, isDefaultChatTitle, TITLE_PROVENANCE_MANUAL } from './chat-constants.js';
93
+ import { hasActiveChatMessageProcessing } from './message-processing-control.js';
94
+ import { replayPendingHitlRequests, listPendingHitlPromptEventsFromMessages } from './hitl.js';
95
+ import { clearChatSkillApprovals, reconstructSkillApprovalsFromMessages } from './load-skill-tool.js';
96
+ import { resumePendingToolCallsForChat } from './events/memory-manager.js';
97
+ import { stopWorldRuntimesByWorldId } from './world-registry.js';
98
+ import { storageWrappers, ensureInitialization, getResolvedWorldId, getResolvedAgentId, } from './storage-init.js';
99
+ import { triggerPendingQueueResume, autoPauseQueueForChat, clearQueuePauseForChat, } from './queue-manager.js';
100
+ import { syncRuntimeAgentMemoryFromStorage, migrateMessageIds, } from './message-edit-manager.js';
101
+ // Per-module state
83
102
  const logger = createCategoryLogger('core.managers');
84
- let storageWrappers = null;
85
- let moduleInitialization = null;
86
- async function initializeModules() {
87
- if (storageWrappers) {
88
- return; // Already initialized
89
- }
90
- try {
91
- initializeLogger();
92
- storageWrappers = await createStorageWithWrappers();
93
- }
94
- catch (error) {
95
- // Log error but don't throw - allows tests to proceed with mocked storage
96
- logger.error('Failed to initialize storage', { error: error instanceof Error ? error.message : error });
97
- throw error;
98
- }
99
- }
100
- function ensureInitialization() {
101
- if (!moduleInitialization) {
102
- moduleInitialization = initializeModules();
103
- }
104
- return moduleInitialization;
105
- }
106
- const NEW_CHAT_CONFIG = { REUSABLE_CHAT_TITLE: NEW_CHAT_TITLE };
107
- function extractGeneratedChatTitleFromCrudPayload(payload) {
108
- if (!payload || typeof payload !== 'object')
109
- return null;
110
- if (payload.operation !== 'update')
111
- return null;
112
- if (payload.entityType !== 'chat')
113
- return null;
114
- const entityData = payload.entityData && typeof payload.entityData === 'object' ? payload.entityData : null;
115
- const title = typeof entityData?.name === 'string' ? entityData.name.trim() : '';
116
- return title || null;
117
- }
118
- async function resetAutoGeneratedChatTitleForEditResubmission(world, chatId) {
119
- const chat = world.chats.get(chatId) ?? await storageWrappers.loadChatData(world.id, chatId);
120
- if (!chat)
103
+ const loggerRestore = createCategoryLogger('chat.restore');
104
+ const loggerRestoreResume = createCategoryLogger('chat.restore.resume');
105
+ const inFlightToolResumeKeys = new Set();
106
+ function triggerPendingToolCallResume(world, chatId, targetAssistantMessageId) {
107
+ if (!chatId) {
121
108
  return;
122
- const currentTitle = String(chat.name || '').trim();
123
- if (!currentTitle || isDefaultChatTitle(currentTitle)) {
109
+ }
110
+ if (hasActiveChatMessageProcessing(world.id, chatId)) {
124
111
  return;
125
112
  }
126
- const eventStorage = world.eventStorage;
127
- if (!eventStorage) {
113
+ const resumeKey = `${world.id}:${chatId}`;
114
+ if (inFlightToolResumeKeys.has(resumeKey)) {
128
115
  return;
129
116
  }
130
- let latestGeneratedTitle = null;
131
- try {
132
- const crudEvents = await eventStorage.getEventsByWorldAndChat(world.id, chatId, {
133
- types: ['crud'],
134
- order: 'desc',
135
- limit: 25
136
- });
137
- for (const event of crudEvents) {
138
- const generatedTitle = extractGeneratedChatTitleFromCrudPayload(event?.payload);
139
- if (generatedTitle) {
140
- latestGeneratedTitle = generatedTitle;
141
- break;
117
+ inFlightToolResumeKeys.add(resumeKey);
118
+ void (async () => {
119
+ try {
120
+ const resumedCount = await resumePendingToolCallsForChat(world, chatId, targetAssistantMessageId);
121
+ loggerRestoreResume.debug('Attempted pending tool-call resume after chat restore', {
122
+ worldId: world.id,
123
+ chatId,
124
+ targetAssistantMessageId: targetAssistantMessageId || null,
125
+ resumedCount,
126
+ });
127
+ if (resumedCount > 0) {
128
+ loggerRestoreResume.debug('Resumed pending persisted tool calls after chat restore', {
129
+ worldId: world.id,
130
+ chatId,
131
+ targetAssistantMessageId,
132
+ resumedCount,
133
+ });
142
134
  }
143
135
  }
136
+ catch (error) {
137
+ loggerRestoreResume.warn('Failed to resume pending persisted tool calls after chat restore', {
138
+ worldId: world.id,
139
+ chatId,
140
+ targetAssistantMessageId,
141
+ error: error instanceof Error ? error.message : String(error),
142
+ });
143
+ }
144
+ finally {
145
+ inFlightToolResumeKeys.delete(resumeKey);
146
+ }
147
+ })();
148
+ }
149
+ function hasPendingToolCallsOnLastAssistantMessage(lastMessage, chatMemory) {
150
+ if (lastMessage.role !== 'assistant' || !Array.isArray(lastMessage.tool_calls) || lastMessage.tool_calls.length === 0) {
151
+ return false;
144
152
  }
145
- catch (error) {
146
- logger.debug('Skipping auto-title reset because chat CRUD events could not be queried', {
147
- worldId: world.id,
148
- chatId,
149
- error: error instanceof Error ? error.message : String(error)
150
- });
151
- return;
152
- }
153
- if (!latestGeneratedTitle || latestGeneratedTitle !== currentTitle) {
154
- return;
155
- }
156
- let resetSucceeded = false;
157
- if (typeof storageWrappers.updateChatNameIfCurrent === 'function') {
158
- resetSucceeded = await storageWrappers.updateChatNameIfCurrent(world.id, chatId, currentTitle, NEW_CHAT_TITLE);
159
- }
160
- else {
161
- const updated = await storageWrappers.updateChatData(world.id, chatId, { name: NEW_CHAT_TITLE });
162
- resetSucceeded = !!updated;
163
- }
164
- if (!resetSucceeded) {
165
- return;
153
+ const completedToolCallIds = new Set();
154
+ for (const message of chatMemory) {
155
+ if (message.role !== 'tool' || typeof message.tool_call_id !== 'string') {
156
+ continue;
157
+ }
158
+ const toolCallId = message.tool_call_id.trim();
159
+ if (toolCallId) {
160
+ completedToolCallIds.add(toolCallId);
161
+ }
166
162
  }
167
- const runtimeChat = world.chats.get(chatId);
168
- if (runtimeChat) {
169
- runtimeChat.name = NEW_CHAT_TITLE;
163
+ for (const toolCall of lastMessage.tool_calls) {
164
+ const toolCallId = String(toolCall?.id || '').trim();
165
+ if (!toolCallId) {
166
+ continue;
167
+ }
168
+ if (!completedToolCallIds.has(toolCallId)) {
169
+ return true;
170
+ }
170
171
  }
172
+ return false;
171
173
  }
172
- async function syncRuntimeAgentMemoryFromStorage(world, worldId) {
173
- if (!world?.agents || world.agents.size === 0)
174
+ function triggerPendingToolCallResumeFromLastMessage(world, chatId) {
175
+ if (!chatId) {
174
176
  return;
175
- for (const runtimeAgent of world.agents.values()) {
176
- const persistedAgent = await storageWrappers.loadAgent(worldId, runtimeAgent.id);
177
- runtimeAgent.memory = Array.isArray(persistedAgent?.memory)
178
- ? [...persistedAgent.memory]
179
- : [];
180
177
  }
178
+ void (async () => {
179
+ try {
180
+ const chatMemory = await storageWrappers.getMemory(world.id, chatId);
181
+ if (!chatMemory || chatMemory.length === 0) {
182
+ return;
183
+ }
184
+ const lastMessage = chatMemory[chatMemory.length - 1];
185
+ if (!hasPendingToolCallsOnLastAssistantMessage(lastMessage, chatMemory)) {
186
+ return;
187
+ }
188
+ loggerRestoreResume.debug('Detected pending tool-call-last message during chat-restore inspection', {
189
+ worldId: world.id,
190
+ chatId,
191
+ messageId: lastMessage.messageId || null,
192
+ });
193
+ triggerPendingToolCallResume(world, chatId, lastMessage.messageId);
194
+ }
195
+ catch (error) {
196
+ loggerRestoreResume.warn('Failed to inspect last message for pending tool-call resume during chat restore', {
197
+ worldId: world.id,
198
+ chatId,
199
+ error: error instanceof Error ? error.message : String(error),
200
+ });
201
+ }
202
+ })();
181
203
  }
204
+ // TOCTOU guard: prevents two concurrent createAgent calls from both passing the
205
+ // `agentExists` check before either write lands. Maps worldId → Set<agentId>.
206
+ const pendingAgentCreates = new Map();
182
207
  /**
183
- * Resolve a world identifier to the persisted world ID.
184
- * Accepts either world ID or world name and supports historical rename drift.
185
- */
186
- async function resolveWorldIdentifier(worldIdOrName) {
187
- const normalizedInput = utils.toKebabCase(worldIdOrName);
188
- if (!normalizedInput)
189
- return null;
190
- // Fast path: direct normalized ID lookup
191
- const directWorld = await storageWrappers.loadWorld(normalizedInput);
192
- if (directWorld?.id) {
193
- return directWorld.id;
194
- }
195
- // Fallback: scan worlds and match by normalized ID or normalized name
196
- const worlds = await storageWrappers.listWorlds();
197
- const matched = worlds.find((world) => {
198
- const storedId = String(world.id || '');
199
- const storedName = String(world.name || '');
200
- return (storedId === worldIdOrName ||
201
- storedName === worldIdOrName ||
202
- utils.toKebabCase(storedId) === normalizedInput ||
203
- utils.toKebabCase(storedName) === normalizedInput);
204
- });
205
- return matched?.id || null;
206
- }
207
- async function getResolvedWorldId(worldIdOrName) {
208
- const resolved = await resolveWorldIdentifier(worldIdOrName);
209
- return resolved || utils.toKebabCase(worldIdOrName);
210
- }
211
- /**
212
- * Resolve an agent identifier to the persisted agent ID within a world.
213
- * Accepts either agent ID or agent name and supports historical rename drift.
208
+ * Pre-claim an agent creation slot before showing an approval dialog.
209
+ * Prevents race conditions where two concurrent create_agent tool calls both
210
+ * pass approval before either calls createAgent.
211
+ * Returns a release() function that MUST be called if createAgent is not called.
212
+ * createAgent({ slotAlreadyClaimed: true }) also cleans up the slot itself,
213
+ * so calling release() after createAgent is safe (idempotent).
214
214
  */
215
- async function resolveAgentIdentifier(worldIdOrName, agentIdOrName) {
216
- const resolvedWorldId = await getResolvedWorldId(worldIdOrName);
217
- const normalizedInput = utils.toKebabCase(agentIdOrName);
218
- if (!normalizedInput)
219
- return null;
220
- // Fast path: direct normalized ID lookup
221
- const directAgent = await storageWrappers.loadAgent(resolvedWorldId, normalizedInput);
222
- if (directAgent?.id) {
223
- return directAgent.id;
224
- }
225
- // Fallback: scan agents and match by normalized ID or normalized name
226
- const agents = await storageWrappers.listAgents(resolvedWorldId);
227
- const matched = agents.find((agent) => {
228
- const storedId = String(agent.id || '');
229
- const storedName = String(agent.name || '');
230
- return (storedId === agentIdOrName ||
231
- storedName === agentIdOrName ||
232
- utils.toKebabCase(storedId) === normalizedInput ||
233
- utils.toKebabCase(storedName) === normalizedInput);
234
- });
235
- return matched?.id || null;
236
- }
237
- async function getResolvedAgentId(worldIdOrName, agentIdOrName) {
238
- const resolved = await resolveAgentIdentifier(worldIdOrName, agentIdOrName);
239
- return resolved || utils.toKebabCase(agentIdOrName);
215
+ export async function claimAgentCreationSlot(worldId, agentName) {
216
+ await ensureInitialization();
217
+ const resolvedWorldId = await getResolvedWorldId(worldId);
218
+ const agentId = utils.toKebabCase(agentName);
219
+ const worldPending = pendingAgentCreates.get(resolvedWorldId) ?? new Set();
220
+ if (!pendingAgentCreates.has(resolvedWorldId)) {
221
+ pendingAgentCreates.set(resolvedWorldId, worldPending);
222
+ }
223
+ if (worldPending.has(agentId)) {
224
+ return { claimed: false, reason: 'already_pending', name: agentName };
225
+ }
226
+ const exists = await storageWrappers.agentExists(resolvedWorldId, agentId);
227
+ if (exists) {
228
+ return { claimed: false, reason: 'already_exists', name: agentName };
229
+ }
230
+ worldPending.add(agentId);
231
+ return {
232
+ claimed: true,
233
+ release: () => {
234
+ worldPending.delete(agentId);
235
+ if (worldPending.size === 0) {
236
+ pendingAgentCreates.delete(resolvedWorldId);
237
+ }
238
+ },
239
+ };
240
240
  }
241
241
  /**
242
242
  * Create new world with configuration and automatically create a new chat
@@ -258,6 +258,9 @@ export async function createWorld(params) {
258
258
  chatLLMModel: params.chatLLMModel,
259
259
  mcpConfig: params.mcpConfig,
260
260
  variables: params.variables,
261
+ heartbeatEnabled: params.heartbeatEnabled === true,
262
+ heartbeatInterval: params.heartbeatInterval ? String(params.heartbeatInterval).trim() : null,
263
+ heartbeatPrompt: params.heartbeatPrompt ? String(params.heartbeatPrompt) : null,
261
264
  createdAt: new Date(),
262
265
  lastUpdated: new Date(),
263
266
  totalAgents: 0,
@@ -302,8 +305,59 @@ export async function updateWorld(worldId, updates) {
302
305
  lastUpdated: new Date()
303
306
  };
304
307
  await storageWrappers.saveWorld(updatedData);
308
+ const { getActiveSubscribedWorlds } = await import('./subscription.js');
309
+ const activeRuntimeWorlds = getActiveSubscribedWorlds(resolvedWorldId);
310
+ if (activeRuntimeWorlds.length > 0) {
311
+ const shouldSyncCurrentChatId = normalizedUpdates.currentChatId !== undefined;
312
+ for (const activeRuntimeWorld of activeRuntimeWorlds) {
313
+ syncActiveRuntimeWorldMetadata(activeRuntimeWorld, updatedData);
314
+ if (shouldSyncCurrentChatId) {
315
+ activeRuntimeWorld.currentChatId = updatedData.currentChatId ?? null;
316
+ }
317
+ }
318
+ return activeRuntimeWorlds[0] ?? null;
319
+ }
305
320
  return getWorld(resolvedWorldId);
306
321
  }
322
+ function syncActiveRuntimeWorldMetadata(targetWorld, persistedWorld) {
323
+ targetWorld.name = persistedWorld.name;
324
+ targetWorld.description = persistedWorld.description;
325
+ targetWorld.turnLimit = persistedWorld.turnLimit;
326
+ targetWorld.mainAgent = persistedWorld.mainAgent ?? null;
327
+ targetWorld.chatLLMProvider = persistedWorld.chatLLMProvider;
328
+ targetWorld.chatLLMModel = persistedWorld.chatLLMModel;
329
+ targetWorld.mcpConfig = persistedWorld.mcpConfig ?? null;
330
+ targetWorld.variables = persistedWorld.variables;
331
+ targetWorld.uiMode = persistedWorld.uiMode;
332
+ targetWorld.dashboardZones = persistedWorld.dashboardZones;
333
+ targetWorld.heartbeatEnabled = persistedWorld.heartbeatEnabled === true;
334
+ targetWorld.heartbeatInterval = persistedWorld.heartbeatInterval ?? null;
335
+ targetWorld.heartbeatPrompt = persistedWorld.heartbeatPrompt ?? null;
336
+ targetWorld.lastUpdated = persistedWorld.lastUpdated;
337
+ }
338
+ async function getPersistedCurrentChatId(worldId) {
339
+ const resolvedWorldId = await getResolvedWorldId(worldId);
340
+ const worldData = await storageWrappers.loadWorld(resolvedWorldId);
341
+ const chatId = String(worldData?.currentChatId || '').trim();
342
+ return chatId || null;
343
+ }
344
+ async function setPersistedCurrentChatId(worldId, chatId) {
345
+ const resolvedWorldId = await getResolvedWorldId(worldId);
346
+ const worldData = await storageWrappers.loadWorld(resolvedWorldId);
347
+ if (!worldData) {
348
+ return;
349
+ }
350
+ const normalizedChatId = String(chatId || '').trim() || null;
351
+ const existingChatId = String(worldData.currentChatId || '').trim() || null;
352
+ if (existingChatId === normalizedChatId) {
353
+ return;
354
+ }
355
+ await storageWrappers.saveWorld({
356
+ ...worldData,
357
+ currentChatId: normalizedChatId,
358
+ lastUpdated: new Date(),
359
+ });
360
+ }
307
361
  /**
308
362
  * Set the raw .env-style variables text for a world
309
363
  */
@@ -356,7 +410,11 @@ export async function deleteWorld(worldId) {
356
410
  if (worldData?._activityListenerCleanup) {
357
411
  worldData._activityListenerCleanup();
358
412
  }
359
- return await storageWrappers.deleteWorld(resolvedWorldId);
413
+ const result = await storageWrappers.deleteWorld(resolvedWorldId);
414
+ // Stop any active runtime so subsequent subscriptions get a fresh world
415
+ // instead of reusing state from the deleted incarnation.
416
+ await stopWorldRuntimesByWorldId(resolvedWorldId);
417
+ return result;
360
418
  }
361
419
  /**
362
420
  * Get all world IDs and basic information
@@ -420,50 +478,72 @@ export async function getWorld(worldId) {
420
478
  world._eventPersistenceCleanup = setupEventPersistence(world);
421
479
  world._activityListenerCleanup = setupWorldActivityListener(world);
422
480
  }
481
+ // Initialize queued chat set; per-chat seeding deferred to explicit queue operations
482
+ world._queuedChatIds = new Set();
423
483
  return world;
424
484
  }
425
485
  /**
426
486
  * Create new agent with configuration and system prompt
427
487
  */
428
- export async function createAgent(worldId, params, options) {
488
+ export async function createAgent(worldId, params, options = {}) {
429
489
  await ensureInitialization();
430
490
  const resolvedWorldId = await getResolvedWorldId(worldId);
431
491
  // Check if world is processing to prevent agent creation during concurrent chat sessions
432
492
  const { getActiveSubscribedWorld } = await import('./subscription.js');
433
493
  const activeSubscribedWorld = getActiveSubscribedWorld(resolvedWorldId);
434
494
  const world = activeSubscribedWorld || await getWorld(resolvedWorldId);
435
- if (world?.isProcessing && options?.allowWhileProcessing !== true) {
495
+ if (world?.isProcessing && !options.allowWhileWorldProcessing) {
436
496
  throw new Error('Cannot create agent while world is processing');
437
497
  }
438
498
  const agentId = params.id || utils.toKebabCase(params.name);
439
- const exists = await storageWrappers.agentExists(resolvedWorldId, agentId);
440
- if (exists) {
441
- throw new Error(`Agent with ID '${agentId}' already exists`);
499
+ // Resolve the pending-creates Set (needed in finally regardless of who claimed it).
500
+ const worldPending = pendingAgentCreates.get(resolvedWorldId) ?? new Set();
501
+ if (!pendingAgentCreates.has(resolvedWorldId)) {
502
+ pendingAgentCreates.set(resolvedWorldId, worldPending);
503
+ }
504
+ if (!options.slotAlreadyClaimed) {
505
+ // TOCTOU guard: claim the slot before the async agentExists check.
506
+ // Skipped when the caller already claimed the slot via claimAgentCreationSlot().
507
+ if (worldPending.has(agentId)) {
508
+ throw new Error(`Agent '${agentId}' is already being created`);
509
+ }
510
+ worldPending.add(agentId);
442
511
  }
443
- const now = new Date();
444
- const agent = {
445
- id: agentId,
446
- name: params.name,
447
- type: params.type,
448
- autoReply: params.autoReply ?? true,
449
- status: 'inactive',
450
- provider: params.provider,
451
- model: params.model,
452
- systemPrompt: params.systemPrompt,
453
- temperature: params.temperature,
454
- maxTokens: params.maxTokens,
455
- createdAt: now,
456
- lastActive: now,
457
- llmCallCount: 0,
458
- memory: [],
459
- };
460
- await storageWrappers.saveAgent(resolvedWorldId, agent);
461
- // Emit CRUD event for real-time updates
462
- if (world) {
463
- world.agents.set(agent.id, agent);
464
- publishCRUDEvent(world, 'create', 'agent', agent.id, agent);
512
+ try {
513
+ const exists = await storageWrappers.agentExists(resolvedWorldId, agentId);
514
+ if (exists) {
515
+ throw new Error(`Agent with ID '${agentId}' already exists`);
516
+ }
517
+ const now = new Date();
518
+ const agent = {
519
+ id: agentId,
520
+ name: params.name,
521
+ type: params.type,
522
+ autoReply: params.autoReply ?? true,
523
+ status: 'inactive',
524
+ provider: params.provider,
525
+ model: params.model,
526
+ systemPrompt: params.systemPrompt,
527
+ temperature: params.temperature,
528
+ maxTokens: params.maxTokens,
529
+ createdAt: now,
530
+ lastActive: now,
531
+ llmCallCount: 0,
532
+ memory: [],
533
+ };
534
+ await storageWrappers.saveAgent(resolvedWorldId, agent);
535
+ if (world) {
536
+ world.agents.set(agent.id, agent);
537
+ }
538
+ return agent;
539
+ }
540
+ finally {
541
+ // Clean up the slot whether it was claimed here or via claimAgentCreationSlot().
542
+ worldPending.delete(agentId);
543
+ if (worldPending.size === 0) {
544
+ pendingAgentCreates.delete(resolvedWorldId);
545
+ }
465
546
  }
466
- return agent;
467
547
  }
468
548
  /**
469
549
  * Load agent by ID with full configuration and memory
@@ -509,17 +589,14 @@ export async function updateAgent(worldId, agentId, updates) {
509
589
  lastActive: new Date()
510
590
  };
511
591
  await storageWrappers.saveAgent(resolvedWorldId, updatedAgent);
512
- // Emit CRUD event for real-time updates
513
592
  if (world) {
514
593
  const runtimeAgent = world.agents.get(resolvedAgentId);
515
594
  if (runtimeAgent) {
516
595
  Object.assign(runtimeAgent, updatedAgent);
517
596
  world.agents.set(resolvedAgentId, runtimeAgent);
518
- publishCRUDEvent(world, 'update', 'agent', resolvedAgentId, runtimeAgent);
519
597
  }
520
598
  else {
521
599
  world.agents.set(resolvedAgentId, updatedAgent);
522
- publishCRUDEvent(world, 'update', 'agent', resolvedAgentId, updatedAgent);
523
600
  }
524
601
  }
525
602
  return updatedAgent;
@@ -539,10 +616,15 @@ export async function deleteAgent(worldId, agentId) {
539
616
  throw new Error('Cannot delete agent while world is processing');
540
617
  }
541
618
  const success = await storageWrappers.deleteAgent(resolvedWorldId, resolvedAgentId);
542
- // Emit CRUD event for real-time updates
543
619
  if (success && world) {
620
+ // Remove the agent's message listener BEFORE removing from the agents map
621
+ // to prevent the stale closure from continuing to process messages.
622
+ const unsubscribe = world._agentUnsubscribers?.get(resolvedAgentId);
623
+ if (unsubscribe) {
624
+ unsubscribe();
625
+ world._agentUnsubscribers.delete(resolvedAgentId);
626
+ }
544
627
  world.agents.delete(resolvedAgentId);
545
- publishCRUDEvent(world, 'delete', 'agent', resolvedAgentId);
546
628
  }
547
629
  return success;
548
630
  }
@@ -648,18 +730,16 @@ async function createChat(worldId, params) {
648
730
  const chatData = {
649
731
  id: chatId,
650
732
  worldId,
651
- name: NEW_CHAT_CONFIG.REUSABLE_CHAT_TITLE,
733
+ name: NEW_CHAT_TITLE,
652
734
  description: params.description,
653
735
  createdAt: now,
654
736
  updatedAt: now,
655
737
  messageCount: 0,
656
738
  };
657
739
  await storageWrappers.saveChatData(worldId, chatData);
658
- // Emit CRUD event for real-time updates
659
740
  const world = await getWorld(worldId);
660
741
  if (world) {
661
742
  world.chats.set(chatData.id, chatData);
662
- publishCRUDEvent(world, 'create', 'chat', chatData.id, chatData, chatData.id);
663
743
  }
664
744
  return chatData;
665
745
  }
@@ -675,7 +755,8 @@ export async function newChat(worldId) {
675
755
  if (existingChat) {
676
756
  const messages = await storageWrappers.getMemory(resolvedWorldId, existingChat.id);
677
757
  if (messages.length === 0) {
678
- return await updateWorld(resolvedWorldId, { currentChatId: existingChat.id });
758
+ await setPersistedCurrentChatId(resolvedWorldId, existingChat.id);
759
+ return await getWorld(resolvedWorldId);
679
760
  }
680
761
  // If chat has messages, fall through to create a new one
681
762
  }
@@ -683,7 +764,8 @@ export async function newChat(worldId) {
683
764
  name: NEW_CHAT_TITLE,
684
765
  captureChat: false
685
766
  });
686
- return await updateWorld(resolvedWorldId, { currentChatId: chatData.id });
767
+ await setPersistedCurrentChatId(resolvedWorldId, chatData.id);
768
+ return await getWorld(resolvedWorldId);
687
769
  }
688
770
  /**
689
771
  * Create a branched chat from a source chat up to (and including) the provided message.
@@ -740,13 +822,10 @@ export async function branchChatFromMessage(worldId, sourceChatId, messageId) {
740
822
  };
741
823
  const cutoffTimestamp = toEpochMillis(targetMessage?.createdAt);
742
824
  const updatedWorld = await newChat(resolvedWorldId);
743
- if (!updatedWorld || !updatedWorld.currentChatId) {
825
+ const newChatId = await getPersistedCurrentChatId(resolvedWorldId);
826
+ if (!updatedWorld || !newChatId) {
744
827
  throw new Error('Failed to create branch chat.');
745
828
  }
746
- const newChatId = String(updatedWorld.currentChatId || '').trim();
747
- if (!newChatId) {
748
- throw new Error('Failed to resolve new chat ID for branch.');
749
- }
750
829
  let copiedMessageCount = 0;
751
830
  const agents = await listAgents(resolvedWorldId);
752
831
  try {
@@ -813,6 +892,11 @@ export async function listChats(worldId) {
813
892
  export async function updateChat(worldId, chatId, updates) {
814
893
  await ensureInitialization();
815
894
  const resolvedWorldId = await getResolvedWorldId(worldId);
895
+ // When the caller explicitly sets a name, treat it as a manual title so the
896
+ // auto-title scheduler will not overwrite it.
897
+ if (typeof updates.name === 'string' && updates.name.trim().length > 0) {
898
+ updates = { ...updates, titleProvenance: TITLE_PROVENANCE_MANUAL };
899
+ }
816
900
  const chat = await storageWrappers.updateChatData(resolvedWorldId, chatId, updates);
817
901
  if (!chat) {
818
902
  return null;
@@ -833,16 +917,10 @@ export async function deleteChat(worldId, chatId) {
833
917
  // First, delete all agent memory items associated with this chat
834
918
  const deletedMemoryCount = await storageWrappers.deleteMemoryByChatId(resolvedWorldId, chatId);
835
919
  logger.debug('Deleted memory items for chat', { worldId, resolvedWorldId, chatId, deletedMemoryCount });
836
- // Get the world to check if this was the current chat
920
+ const persistedCurrentChatId = await getPersistedCurrentChatId(resolvedWorldId);
921
+ // Get the world to update in-memory chat cache only
837
922
  const world = await getWorld(resolvedWorldId);
838
- let shouldSetNewCurrentChat = false;
839
- if (world && world.currentChatId === chatId) {
840
- shouldSetNewCurrentChat = true;
841
- }
842
- // Emit CRUD event BEFORE deletion (while chat_id still exists in DB)
843
- if (world) {
844
- publishCRUDEvent(world, 'delete', 'chat', chatId, undefined, chatId);
845
- }
923
+ const shouldSetNewCurrentChat = persistedCurrentChatId === chatId;
846
924
  // Then delete the chat itself
847
925
  const chatDeleted = await storageWrappers.deleteChatData(resolvedWorldId, chatId);
848
926
  // Remove from world's in-memory chat map
@@ -855,7 +933,7 @@ export async function deleteChat(worldId, chatId) {
855
933
  if (remainingChats.length > 0) {
856
934
  // Set the most recently updated chat as current
857
935
  const latestChat = remainingChats.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0];
858
- await updateWorld(resolvedWorldId, { currentChatId: latestChat.id });
936
+ await setPersistedCurrentChatId(resolvedWorldId, latestChat.id);
859
937
  }
860
938
  else {
861
939
  // No chats left, create a new one
@@ -865,14 +943,64 @@ export async function deleteChat(worldId, chatId) {
865
943
  }
866
944
  return chatDeleted;
867
945
  }
868
- export async function restoreChat(worldId, chatId) {
946
+ async function activateChatResources(world, resolvedWorldId, chatId, options) {
947
+ await syncRuntimeAgentMemoryFromStorage(world, resolvedWorldId);
948
+ loggerRestore.debug('Restore chat memory sync complete', { worldId: world.id, chatId });
949
+ const memoryForApprovals = await storageWrappers.getMemory(resolvedWorldId, chatId);
950
+ clearChatSkillApprovals(resolvedWorldId, chatId);
951
+ reconstructSkillApprovalsFromMessages(resolvedWorldId, chatId, Array.isArray(memoryForApprovals) ? memoryForApprovals : []);
952
+ const { getActiveSubscribedWorld } = await import('./subscription.js');
953
+ const runtimeWorld = getActiveSubscribedWorld(resolvedWorldId, chatId) || world;
954
+ const replayedHitlPromptCount = replayPendingHitlRequests(runtimeWorld, chatId);
955
+ loggerRestore.debug('Restore chat pending HITL replay triggered', { worldId: world.id, chatId });
956
+ if (options?.suppressAutoResume === true) {
957
+ loggerRestore.debug('Restore chat auto-resume suppressed for mutation flow', {
958
+ worldId: world.id,
959
+ chatId,
960
+ });
961
+ return;
962
+ }
963
+ if (replayedHitlPromptCount > 0) {
964
+ loggerRestore.debug('Restore chat auto-resume skipped because chat is awaiting HITL input', {
965
+ worldId: world.id,
966
+ chatId,
967
+ replayedHitlPromptCount,
968
+ });
969
+ return;
970
+ }
971
+ triggerPendingToolCallResumeFromLastMessage(runtimeWorld, chatId);
972
+ clearQueuePauseForChat(runtimeWorld.id, chatId);
973
+ triggerPendingQueueResume(runtimeWorld, chatId, { recoverStaleSending: true });
974
+ }
975
+ export async function restoreChat(worldId, chatId, options) {
869
976
  await ensureInitialization();
870
977
  const resolvedWorldId = await getResolvedWorldId(worldId);
978
+ const restoreStartedAt = Date.now();
979
+ loggerRestore.debug('Restore chat started', {
980
+ worldId: resolvedWorldId,
981
+ requestedChatId: chatId,
982
+ });
871
983
  let world = await getWorld(resolvedWorldId);
984
+ const persistedCurrentChatId = await getPersistedCurrentChatId(resolvedWorldId);
872
985
  if (!world) {
986
+ loggerRestore.debug('Restore chat aborted: world missing', {
987
+ worldId: resolvedWorldId,
988
+ requestedChatId: chatId,
989
+ });
873
990
  return null;
874
991
  }
875
- if (world.currentChatId === chatId) {
992
+ if (persistedCurrentChatId === chatId) {
993
+ loggerRestore.debug('Restore chat detected already-current chat', {
994
+ worldId: world.id,
995
+ chatId,
996
+ action: 'sync-memory+resume',
997
+ });
998
+ await activateChatResources(world, resolvedWorldId, chatId, options);
999
+ loggerRestore.debug('Restore chat resume inspection triggered', {
1000
+ worldId: world.id,
1001
+ chatId,
1002
+ elapsedMs: Date.now() - restoreStartedAt,
1003
+ });
876
1004
  return world;
877
1005
  }
878
1006
  const runtimeChatExists = world.chats.has(chatId);
@@ -880,13 +1008,81 @@ export async function restoreChat(worldId, chatId) {
880
1008
  ? true
881
1009
  : !!(await storageWrappers.loadChatData(resolvedWorldId, chatId));
882
1010
  if (!persistedChatExists) {
1011
+ loggerRestore.debug('Restore chat aborted: chat missing', {
1012
+ worldId: resolvedWorldId,
1013
+ requestedChatId: chatId,
1014
+ runtimeChatExists,
1015
+ });
883
1016
  return null;
884
1017
  }
885
- world = await updateWorld(resolvedWorldId, {
886
- currentChatId: chatId
1018
+ // Auto-pause the old chat's queue when switching away (FR-8 safety)
1019
+ const previousChatId = persistedCurrentChatId;
1020
+ if (previousChatId && previousChatId !== chatId) {
1021
+ autoPauseQueueForChat(resolvedWorldId, previousChatId);
1022
+ loggerRestore.debug('Auto-paused queue for previous chat on switch', {
1023
+ worldId: resolvedWorldId,
1024
+ previousChatId,
1025
+ newChatId: chatId,
1026
+ });
1027
+ }
1028
+ loggerRestore.debug('Restore chat switching current chat', {
1029
+ worldId: resolvedWorldId,
1030
+ fromChatId: previousChatId,
1031
+ toChatId: chatId,
1032
+ });
1033
+ await setPersistedCurrentChatId(resolvedWorldId, chatId);
1034
+ world = await getWorld(resolvedWorldId);
1035
+ if (world) {
1036
+ await activateChatResources(world, resolvedWorldId, chatId, options);
1037
+ loggerRestore.debug('Restore chat resume inspection triggered', {
1038
+ worldId: world.id,
1039
+ chatId,
1040
+ elapsedMs: Date.now() - restoreStartedAt,
1041
+ });
1042
+ }
1043
+ else {
1044
+ loggerRestore.warn('Restore chat update-world returned null', {
1045
+ worldId: resolvedWorldId,
1046
+ requestedChatId: chatId,
1047
+ elapsedMs: Date.now() - restoreStartedAt,
1048
+ });
1049
+ }
1050
+ loggerRestore.debug('Restore chat completed', {
1051
+ worldId: resolvedWorldId,
1052
+ requestedChatId: chatId,
1053
+ restoredCurrentChatId: await getPersistedCurrentChatId(resolvedWorldId),
1054
+ elapsedMs: Date.now() - restoreStartedAt,
887
1055
  });
888
1056
  return world;
889
1057
  }
1058
+ export async function activateChatWithSnapshot(worldId, chatId) {
1059
+ const world = await restoreChat(worldId, chatId);
1060
+ if (!world) {
1061
+ return null;
1062
+ }
1063
+ const resolvedChatId = String(chatId || '').trim();
1064
+ if (!resolvedChatId) {
1065
+ return null;
1066
+ }
1067
+ const memory = await storageWrappers.getMemory(world.id, resolvedChatId);
1068
+ const safeMemory = Array.isArray(memory) ? memory : [];
1069
+ // Message-authoritative: pending HITL state is derived from persisted messages only.
1070
+ // Runtime pending map serves transport/notification purposes; the snapshot always uses
1071
+ // messages as the single source of truth (AD-1, AD-4).
1072
+ const hitlPrompts = listPendingHitlPromptEventsFromMessages(safeMemory, resolvedChatId);
1073
+ loggerRestore.debug('Activate chat snapshot assembled', {
1074
+ worldId: world.id,
1075
+ chatId: resolvedChatId,
1076
+ memoryCount: safeMemory.length,
1077
+ hitlPromptCount: hitlPrompts.length,
1078
+ });
1079
+ return {
1080
+ world,
1081
+ chatId: resolvedChatId,
1082
+ memory: safeMemory,
1083
+ hitlPrompts,
1084
+ };
1085
+ }
890
1086
  export async function getMemory(worldId, chatId) {
891
1087
  await ensureInitialization();
892
1088
  const resolvedWorldId = await getResolvedWorldId(worldId);
@@ -894,7 +1090,10 @@ export async function getMemory(worldId, chatId) {
894
1090
  if (!world) {
895
1091
  return null;
896
1092
  }
897
- const resolvedChatId = chatId || world.currentChatId;
1093
+ const resolvedChatId = String(chatId || '').trim();
1094
+ if (!resolvedChatId) {
1095
+ throw new Error('getMemory: chatId is required.');
1096
+ }
898
1097
  const memory = await storageWrappers.getMemory(resolvedWorldId, resolvedChatId);
899
1098
  // Auto-repair legacy memories so downstream clients can rely on messageId without UI fallbacks.
900
1099
  if (memory.some(message => !message.messageId)) {
@@ -907,316 +1106,6 @@ export async function getMemory(worldId, chatId) {
907
1106
  }
908
1107
  return memory;
909
1108
  }
910
- /**
911
- * Migrate messages to include messageId for user message edit feature
912
- * Automatically detects storage type and handles both file and SQL storage
913
- * Idempotent - safe to run multiple times
914
- *
915
- * @param worldId - World ID to migrate messages for
916
- * @returns Number of messages migrated
917
- */
918
- export async function migrateMessageIds(worldId) {
919
- await ensureInitialization();
920
- const resolvedWorldId = await getResolvedWorldId(worldId);
921
- let totalMigrated = 0;
922
- const world = await getWorld(resolvedWorldId);
923
- if (!world) {
924
- throw new Error(`World '${worldId}' not found`);
925
- }
926
- // Get all agents in the world
927
- const agents = await listAgents(resolvedWorldId);
928
- // Get all chats for the world
929
- const chats = await storageWrappers.listChats(resolvedWorldId);
930
- // Migrate messages for each chat
931
- for (const chat of chats) {
932
- const chatId = chat.id;
933
- // Get all memory for this chat
934
- const memory = await storageWrappers.getMemory(resolvedWorldId, chatId);
935
- if (!memory || memory.length === 0) {
936
- continue;
937
- }
938
- // Check which messages need messageId
939
- let needsMigration = false;
940
- const updatedMemory = [];
941
- for (const message of memory) {
942
- if (!message.messageId) {
943
- needsMigration = true;
944
- updatedMemory.push({
945
- ...message,
946
- messageId: nanoid(10)
947
- });
948
- totalMigrated++;
949
- }
950
- else {
951
- updatedMemory.push(message);
952
- }
953
- }
954
- // If any messages were updated, save the entire memory back
955
- if (needsMigration) {
956
- // For each agent, update their memory with the migrated messages
957
- for (const agent of agents) {
958
- const agentMessages = updatedMemory.filter(m => m.agentId === agent.id);
959
- if (agentMessages.length > 0) {
960
- await storageWrappers.saveAgentMemory(resolvedWorldId, agent.id, agentMessages);
961
- }
962
- }
963
- }
964
- }
965
- logger.info(`Migrated ${totalMigrated} messages with messageId for world '${resolvedWorldId}'`);
966
- return totalMigrated;
967
- }
968
- /**
969
- * Remove a message and all subsequent messages from all agents in a world
970
- * Used for user message editing feature
971
- *
972
- * @param worldId - World ID
973
- * @param messageId - ID of the message to remove (and all after it)
974
- * @param chatId - Chat ID to filter messages
975
- * @returns RemovalResult with per-agent removal details
976
- */
977
- export async function removeMessagesFrom(worldId, messageId, chatId) {
978
- await ensureInitialization();
979
- const resolvedWorldId = await getResolvedWorldId(worldId);
980
- const world = await getWorld(resolvedWorldId);
981
- if (!world) {
982
- throw new Error(`World '${worldId}' not found`);
983
- }
984
- // Get all agents
985
- const agents = await listAgents(resolvedWorldId);
986
- // Track results per agent
987
- const processedAgents = [];
988
- const failedAgents = [];
989
- let messagesRemovedTotal = 0;
990
- let foundTargetInAnyAgent = false;
991
- let targetTimestampValue = null;
992
- const loadedAgentsById = new Map();
993
- const toTimestamp = (value) => {
994
- if (value instanceof Date) {
995
- return value.getTime();
996
- }
997
- if (value) {
998
- const parsed = new Date(value).getTime();
999
- if (Number.isFinite(parsed)) {
1000
- return parsed;
1001
- }
1002
- }
1003
- return Date.now();
1004
- };
1005
- // First pass: load each agent and discover the deletion cutoff timestamp
1006
- for (const agent of agents) {
1007
- try {
1008
- const fullAgent = await storageWrappers.loadAgent(resolvedWorldId, agent.id);
1009
- if (!fullAgent || !fullAgent.memory || fullAgent.memory.length === 0) {
1010
- continue;
1011
- }
1012
- loadedAgentsById.set(agent.id, fullAgent);
1013
- // Find the target message in this chat for global cutoff derivation
1014
- const targetMsg = fullAgent.memory.find(m => m.messageId === messageId && m.chatId === chatId);
1015
- if (targetMsg) {
1016
- foundTargetInAnyAgent = true;
1017
- const candidateTimestamp = toTimestamp(targetMsg.createdAt);
1018
- if (targetTimestampValue === null || candidateTimestamp < targetTimestampValue) {
1019
- targetTimestampValue = candidateTimestamp;
1020
- }
1021
- }
1022
- }
1023
- catch (error) {
1024
- const errorMsg = error instanceof Error ? error.message : String(error);
1025
- failedAgents.push({
1026
- agentId: agent.id,
1027
- error: errorMsg
1028
- });
1029
- }
1030
- }
1031
- if (!foundTargetInAnyAgent || targetTimestampValue === null) {
1032
- const notFoundFailures = agents.length > 0
1033
- ? [
1034
- ...failedAgents,
1035
- { agentId: 'all', error: `Message with ID '${messageId}' not found in chat '${chatId}'` }
1036
- ]
1037
- : failedAgents;
1038
- return {
1039
- success: false,
1040
- messageId,
1041
- totalAgents: agents.length,
1042
- processedAgents,
1043
- failedAgents: notFoundFailures,
1044
- messagesRemovedTotal,
1045
- requiresRetry: false,
1046
- resubmissionStatus: 'skipped',
1047
- newMessageId: undefined
1048
- };
1049
- }
1050
- // Second pass: apply cutoff to all agents in the target chat
1051
- for (const agent of agents) {
1052
- if (failedAgents.some(entry => entry.agentId === agent.id)) {
1053
- continue;
1054
- }
1055
- const fullAgent = loadedAgentsById.get(agent.id);
1056
- if (!fullAgent || !Array.isArray(fullAgent.memory) || fullAgent.memory.length === 0) {
1057
- processedAgents.push(agent.id);
1058
- continue;
1059
- }
1060
- try {
1061
- const messagesToKeep = fullAgent.memory.filter(m => {
1062
- if (m.chatId !== chatId) {
1063
- return true;
1064
- }
1065
- const msgTimestamp = toTimestamp(m.createdAt);
1066
- return msgTimestamp < targetTimestampValue;
1067
- });
1068
- const removedCount = fullAgent.memory.length - messagesToKeep.length;
1069
- if (removedCount === 0) {
1070
- processedAgents.push(agent.id);
1071
- continue;
1072
- }
1073
- await storageWrappers.saveAgentMemory(resolvedWorldId, agent.id, messagesToKeep);
1074
- messagesRemovedTotal += removedCount;
1075
- processedAgents.push(agent.id);
1076
- }
1077
- catch (error) {
1078
- const errorMsg = error instanceof Error ? error.message : String(error);
1079
- failedAgents.push({
1080
- agentId: agent.id,
1081
- error: errorMsg
1082
- });
1083
- }
1084
- }
1085
- logger.info('Message removal completed', {
1086
- messageId,
1087
- success: failedAgents.length === 0,
1088
- totalAgents: agents.length,
1089
- processedAgents: processedAgents.length,
1090
- failedAgents: failedAgents.length,
1091
- messagesRemovedTotal
1092
- });
1093
- return {
1094
- success: failedAgents.length === 0,
1095
- messageId,
1096
- totalAgents: agents.length,
1097
- processedAgents,
1098
- failedAgents,
1099
- messagesRemovedTotal,
1100
- requiresRetry: failedAgents.length > 0,
1101
- resubmissionStatus: 'skipped', // Will be updated by editUserMessage
1102
- newMessageId: undefined
1103
- };
1104
- }
1105
- /**
1106
- * Edit a user message by removing it and all subsequent messages, then resubmitting with new content
1107
- * Combines removal and resubmission in a single operation with comprehensive error tracking
1108
- *
1109
- * @param worldId - World ID
1110
- * @param messageId - ID of the message to edit
1111
- * @param newContent - New message content
1112
- * @param chatId - Chat ID for the message
1113
- * @returns RemovalResult with removal and resubmission details
1114
- */
1115
- export async function editUserMessage(worldId, messageId, newContent, chatId, targetWorld) {
1116
- await ensureInitialization();
1117
- const resolvedWorldId = await getResolvedWorldId(worldId);
1118
- const { getActiveSubscribedWorld } = await import('./subscription.js');
1119
- const activeSubscribedWorld = targetWorld || getActiveSubscribedWorld(resolvedWorldId);
1120
- const world = activeSubscribedWorld || await getWorld(resolvedWorldId);
1121
- if (!world) {
1122
- throw new Error(`World '${worldId}' not found`);
1123
- }
1124
- if (hasActiveChatMessageProcessing(resolvedWorldId, chatId)) {
1125
- stopMessageProcessing(resolvedWorldId, chatId);
1126
- }
1127
- // Step 1: Remove the message and all subsequent messages
1128
- const removalResult = await removeMessagesFrom(resolvedWorldId, messageId, chatId);
1129
- if (!removalResult.success) {
1130
- return removalResult;
1131
- }
1132
- await syncRuntimeAgentMemoryFromStorage(activeSubscribedWorld || world, resolvedWorldId);
1133
- // Step 2: Reset auto-generated chat title so post-resubmission title generation can run again.
1134
- await resetAutoGeneratedChatTitleForEditResubmission(world, chatId);
1135
- const worldForResubmission = activeSubscribedWorld || world;
1136
- if (!activeSubscribedWorld) {
1137
- const { subscribeAgentToMessages, subscribeWorldToMessages } = await import('./events/index.js');
1138
- for (const agent of worldForResubmission.agents.values()) {
1139
- subscribeAgentToMessages(worldForResubmission, agent);
1140
- }
1141
- subscribeWorldToMessages(worldForResubmission);
1142
- }
1143
- // Step 3: Attempt resubmission using publishMessage directly
1144
- try {
1145
- const { publishMessage } = await import('./events/index.js');
1146
- const messageEvent = publishMessage(worldForResubmission, newContent, 'human', chatId);
1147
- logger.info(`Resubmitted edited message to world '${resolvedWorldId}' with new messageId '${messageEvent.messageId}'`);
1148
- return {
1149
- ...removalResult,
1150
- resubmissionStatus: 'success',
1151
- newMessageId: messageEvent.messageId
1152
- };
1153
- }
1154
- catch (error) {
1155
- const errorMsg = error instanceof Error ? error.message : String(error);
1156
- logger.error(`Failed to resubmit message to world '${resolvedWorldId}': ${errorMsg}`);
1157
- return {
1158
- ...removalResult,
1159
- resubmissionStatus: 'failed',
1160
- resubmissionError: errorMsg
1161
- };
1162
- }
1163
- }
1164
- /**
1165
- * Log an error from a message edit operation for troubleshooting and retry
1166
- * Stores errors in data/worlds/{worldName}/edit-errors.json
1167
- * Keeps only the last 100 errors
1168
- *
1169
- * @param worldId - World ID
1170
- * @param errorLog - EditErrorLog to persist
1171
- */
1172
- export async function logEditError(worldId, errorLog) {
1173
- await ensureInitialization();
1174
- const resolvedWorldId = await getResolvedWorldId(worldId);
1175
- const rootPath = getDefaultRootPath();
1176
- const worldDir = getWorldDir(rootPath, resolvedWorldId);
1177
- const errorsFile = path.join(worldDir, 'edit-errors.json');
1178
- try {
1179
- // Read existing errors
1180
- let errors = [];
1181
- if (fs.existsSync(errorsFile)) {
1182
- const data = fs.readFileSync(errorsFile, 'utf-8');
1183
- errors = JSON.parse(data);
1184
- }
1185
- // Add new error
1186
- errors.push(errorLog);
1187
- // Keep only last 100 errors
1188
- if (errors.length > 100) {
1189
- errors = errors.slice(-100);
1190
- }
1191
- // Write back to file
1192
- fs.writeFileSync(errorsFile, JSON.stringify(errors, null, 2), 'utf-8');
1193
- logger.debug(`Logged edit error for world '${resolvedWorldId}'`);
1194
- }
1195
- catch (error) {
1196
- logger.error(`Failed to log edit error for world '${resolvedWorldId}': ${error instanceof Error ? error.message : error}`);
1197
- }
1198
- }
1199
- /**
1200
- * Get edit error logs for a world
1201
- *
1202
- * @param worldId - World ID
1203
- * @returns Array of EditErrorLog entries
1204
- */
1205
- export async function getEditErrors(worldId) {
1206
- await ensureInitialization();
1207
- const resolvedWorldId = await getResolvedWorldId(worldId);
1208
- const rootPath = getDefaultRootPath();
1209
- const worldDir = getWorldDir(rootPath, resolvedWorldId);
1210
- const errorsFile = path.join(worldDir, 'edit-errors.json');
1211
- try {
1212
- if (!fs.existsSync(errorsFile)) {
1213
- return [];
1214
- }
1215
- const data = fs.readFileSync(errorsFile, 'utf-8');
1216
- return JSON.parse(data);
1217
- }
1218
- catch (error) {
1219
- logger.error(`Failed to read edit errors for world '${resolvedWorldId}': ${error instanceof Error ? error.message : error}`);
1220
- return [];
1221
- }
1222
- }
1109
+ // Re-exports from extracted modules
1110
+ export { migrateMessageIds, removeMessagesFrom, editUserMessage, logEditError, getEditErrors } from './message-edit-manager.js';
1111
+ export { addToQueue, recoverQueueSendingMessages, enqueueAndProcessUserTurn, dispatchImmediateChatMessage, getQueueMessages, removeFromQueue, pauseChatQueue, resumeChatQueue, stopChatQueue, clearChatQueue, retryQueueMessage } from './queue-manager.js';