mulmoclaude 0.1.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.
- package/README.md +44 -0
- package/bin/mulmoclaude.js +202 -0
- package/bin/prepare-dist.js +93 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
- package/client/assets/index-D8rhwXLq.js +4906 -0
- package/client/assets/index-KNLBjwuh.css +1 -0
- package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
- package/client/assets/material-icons-Dr0goTwe.woff +0 -0
- package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
- package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
- package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
- package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
- package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
- package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
- package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
- package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
- package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
- package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
- package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
- package/client/index.html +28 -0
- package/package.json +66 -0
- package/server/agent/attachmentConverter.ts +270 -0
- package/server/agent/config.ts +414 -0
- package/server/agent/index.ts +260 -0
- package/server/agent/mcp-server.ts +412 -0
- package/server/agent/mcp-tools/index.ts +63 -0
- package/server/agent/mcp-tools/x.ts +188 -0
- package/server/agent/plugin-names.ts +75 -0
- package/server/agent/prompt.ts +349 -0
- package/server/agent/resumeFailover.ts +129 -0
- package/server/agent/sandboxMounts.ts +329 -0
- package/server/agent/stream.ts +194 -0
- package/server/api/auth/bearerAuth.ts +61 -0
- package/server/api/auth/token.ts +98 -0
- package/server/api/csrfGuard.ts +85 -0
- package/server/api/routes/agent.ts +478 -0
- package/server/api/routes/chart.ts +98 -0
- package/server/api/routes/chat-index.ts +46 -0
- package/server/api/routes/config.ts +258 -0
- package/server/api/routes/dispatchResponse.ts +79 -0
- package/server/api/routes/files.ts +812 -0
- package/server/api/routes/html.ts +101 -0
- package/server/api/routes/image.ts +169 -0
- package/server/api/routes/mulmo-script.ts +712 -0
- package/server/api/routes/mulmoScriptValidate.ts +101 -0
- package/server/api/routes/notifications.ts +69 -0
- package/server/api/routes/pdf.ts +163 -0
- package/server/api/routes/plugins.ts +276 -0
- package/server/api/routes/presentHtml.ts +48 -0
- package/server/api/routes/roles.ts +125 -0
- package/server/api/routes/scheduler.ts +153 -0
- package/server/api/routes/schedulerHandlers.ts +151 -0
- package/server/api/routes/schedulerTasks.ts +163 -0
- package/server/api/routes/sessions.ts +294 -0
- package/server/api/routes/sessionsCursor.ts +59 -0
- package/server/api/routes/skills.ts +195 -0
- package/server/api/routes/sources.ts +540 -0
- package/server/api/routes/todos.ts +263 -0
- package/server/api/routes/todosColumnsHandlers.ts +347 -0
- package/server/api/routes/todosHandlers.ts +274 -0
- package/server/api/routes/todosItemsHandlers.ts +386 -0
- package/server/api/routes/wiki/pageIndex.ts +53 -0
- package/server/api/routes/wiki.ts +363 -0
- package/server/api/sandboxStatus.ts +64 -0
- package/server/events/notifications.ts +160 -0
- package/server/events/pub-sub/index.ts +45 -0
- package/server/events/relay-client.ts +288 -0
- package/server/events/scheduler-adapter.ts +302 -0
- package/server/events/session-store/index.ts +492 -0
- package/server/events/task-manager/index.ts +181 -0
- package/server/index.ts +572 -0
- package/server/system/config.ts +243 -0
- package/server/system/credentials.ts +220 -0
- package/server/system/docker.ts +97 -0
- package/server/system/env.ts +109 -0
- package/server/system/logger/config.ts +112 -0
- package/server/system/logger/formatters.ts +40 -0
- package/server/system/logger/index.ts +53 -0
- package/server/system/logger/rotation.ts +37 -0
- package/server/system/logger/sinks.ts +101 -0
- package/server/system/logger/types.ts +29 -0
- package/server/utils/date.ts +57 -0
- package/server/utils/errors.ts +7 -0
- package/server/utils/fetch.ts +27 -0
- package/server/utils/files/atomic.ts +125 -0
- package/server/utils/files/html-io.ts +20 -0
- package/server/utils/files/image-store.ts +66 -0
- package/server/utils/files/index.ts +45 -0
- package/server/utils/files/journal-io.ts +213 -0
- package/server/utils/files/json.ts +69 -0
- package/server/utils/files/markdown-store.ts +33 -0
- package/server/utils/files/naming.ts +50 -0
- package/server/utils/files/reference-dirs-io.ts +45 -0
- package/server/utils/files/roles-io.ts +45 -0
- package/server/utils/files/safe.ts +106 -0
- package/server/utils/files/scheduler-io.ts +20 -0
- package/server/utils/files/scheduler-overrides-io.ts +64 -0
- package/server/utils/files/session-io.ts +136 -0
- package/server/utils/files/spreadsheet-store.ts +63 -0
- package/server/utils/files/todos-io.ts +29 -0
- package/server/utils/files/user-tasks-io.ts +25 -0
- package/server/utils/files/workspace-io.ts +221 -0
- package/server/utils/gemini.ts +59 -0
- package/server/utils/gitignore.ts +69 -0
- package/server/utils/http.ts +15 -0
- package/server/utils/httpError.ts +61 -0
- package/server/utils/id.ts +16 -0
- package/server/utils/json.ts +83 -0
- package/server/utils/logBackgroundError.ts +22 -0
- package/server/utils/markdown.ts +82 -0
- package/server/utils/request.ts +29 -0
- package/server/utils/slug.ts +50 -0
- package/server/utils/spawn.ts +62 -0
- package/server/utils/time.ts +34 -0
- package/server/utils/types.ts +47 -0
- package/server/workspace/chat-index/index.ts +153 -0
- package/server/workspace/chat-index/indexer.ts +209 -0
- package/server/workspace/chat-index/paths.ts +34 -0
- package/server/workspace/chat-index/summarizer.ts +247 -0
- package/server/workspace/chat-index/types.ts +38 -0
- package/server/workspace/custom-dirs.ts +220 -0
- package/server/workspace/helps/business.md +104 -0
- package/server/workspace/helps/github.md +23 -0
- package/server/workspace/helps/index.md +60 -0
- package/server/workspace/helps/mulmoscript.md +249 -0
- package/server/workspace/helps/sandbox.md +90 -0
- package/server/workspace/helps/spreadsheet.md +43 -0
- package/server/workspace/helps/telegram.md +135 -0
- package/server/workspace/helps/wiki.md +131 -0
- package/server/workspace/journal/archivist.ts +386 -0
- package/server/workspace/journal/dailyPass.ts +743 -0
- package/server/workspace/journal/diff.ts +71 -0
- package/server/workspace/journal/index.ts +185 -0
- package/server/workspace/journal/indexFile.ts +136 -0
- package/server/workspace/journal/linkRewrite.ts +4 -0
- package/server/workspace/journal/memoryExtractor.ts +130 -0
- package/server/workspace/journal/optimizationPass.ts +160 -0
- package/server/workspace/journal/paths.ts +76 -0
- package/server/workspace/journal/state.ts +125 -0
- package/server/workspace/paths.ts +158 -0
- package/server/workspace/reference-dirs.ts +252 -0
- package/server/workspace/roles.ts +37 -0
- package/server/workspace/skills/discovery.ts +125 -0
- package/server/workspace/skills/index.ts +10 -0
- package/server/workspace/skills/parser.ts +144 -0
- package/server/workspace/skills/paths.ts +41 -0
- package/server/workspace/skills/scheduler.ts +149 -0
- package/server/workspace/skills/types.ts +30 -0
- package/server/workspace/skills/user-tasks.ts +257 -0
- package/server/workspace/skills/writer.ts +189 -0
- package/server/workspace/sources/arxivDiscovery.ts +182 -0
- package/server/workspace/sources/classifier.ts +268 -0
- package/server/workspace/sources/fetchers/arxiv.ts +170 -0
- package/server/workspace/sources/fetchers/github.ts +106 -0
- package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
- package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
- package/server/workspace/sources/fetchers/index.ts +71 -0
- package/server/workspace/sources/fetchers/registerAll.ts +15 -0
- package/server/workspace/sources/fetchers/rss.ts +141 -0
- package/server/workspace/sources/fetchers/rssParser.ts +295 -0
- package/server/workspace/sources/httpFetcher.ts +230 -0
- package/server/workspace/sources/interests.ts +120 -0
- package/server/workspace/sources/paths.ts +110 -0
- package/server/workspace/sources/pipeline/dedup.ts +60 -0
- package/server/workspace/sources/pipeline/fetch.ts +136 -0
- package/server/workspace/sources/pipeline/index.ts +249 -0
- package/server/workspace/sources/pipeline/notify.ts +72 -0
- package/server/workspace/sources/pipeline/plan.ts +66 -0
- package/server/workspace/sources/pipeline/summarize.ts +189 -0
- package/server/workspace/sources/pipeline/write.ts +185 -0
- package/server/workspace/sources/rateLimiter.ts +148 -0
- package/server/workspace/sources/registry.ts +326 -0
- package/server/workspace/sources/robots.ts +271 -0
- package/server/workspace/sources/sourceState.ts +135 -0
- package/server/workspace/sources/taxonomy.ts +74 -0
- package/server/workspace/sources/types.ts +144 -0
- package/server/workspace/sources/urls.ts +112 -0
- package/server/workspace/tool-trace/classify.ts +114 -0
- package/server/workspace/tool-trace/index.ts +250 -0
- package/server/workspace/tool-trace/writeSearch.ts +98 -0
- package/server/workspace/wiki-backlinks/index.ts +107 -0
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
- package/server/workspace/workspace.ts +66 -0
- package/src/App.vue +720 -0
- package/src/assets/mulmo_bw.png +0 -0
- package/src/components/CanvasViewToggle.vue +27 -0
- package/src/components/ChatAttachmentPreview.vue +45 -0
- package/src/components/ChatImagePreview.vue +17 -0
- package/src/components/ChatInput.vue +208 -0
- package/src/components/FileContentHeader.vue +49 -0
- package/src/components/FileContentRenderer.vue +162 -0
- package/src/components/FileTree.vue +115 -0
- package/src/components/FileTreePane.vue +85 -0
- package/src/components/FilesView.vue +206 -0
- package/src/components/LockStatusPopup.vue +111 -0
- package/src/components/NotificationBell.vue +131 -0
- package/src/components/NotificationToast.vue +72 -0
- package/src/components/PluginLauncher.vue +138 -0
- package/src/components/RightSidebar.vue +113 -0
- package/src/components/RoleSelector.vue +64 -0
- package/src/components/SessionHistoryPanel.vue +176 -0
- package/src/components/SessionTabBar.vue +81 -0
- package/src/components/SettingsMcpTab.vue +350 -0
- package/src/components/SettingsModal.vue +275 -0
- package/src/components/SettingsReferenceDirsTab.vue +173 -0
- package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
- package/src/components/SidebarHeader.vue +69 -0
- package/src/components/StackView.vue +360 -0
- package/src/components/SuggestionsPanel.vue +65 -0
- package/src/components/TodoExplorer.vue +358 -0
- package/src/components/ToolResultsPanel.vue +77 -0
- package/src/components/todo/TodoAddDialog.vue +131 -0
- package/src/components/todo/TodoEditDialog.vue +47 -0
- package/src/components/todo/TodoEditPanel.vue +113 -0
- package/src/components/todo/TodoKanbanView.vue +249 -0
- package/src/components/todo/TodoListView.vue +79 -0
- package/src/components/todo/TodoTableView.vue +177 -0
- package/src/composables/useActiveSession.ts +40 -0
- package/src/composables/useAppApi.ts +45 -0
- package/src/composables/useCanvasViewMode.ts +121 -0
- package/src/composables/useChatScroll.ts +47 -0
- package/src/composables/useClickOutside.ts +26 -0
- package/src/composables/useClipboardCopy.ts +44 -0
- package/src/composables/useContentDisplay.ts +52 -0
- package/src/composables/useDebugBeat.ts +23 -0
- package/src/composables/useDynamicFavicon.ts +115 -0
- package/src/composables/useEventListeners.ts +42 -0
- package/src/composables/useExpandedDirs.ts +64 -0
- package/src/composables/useFaviconState.ts +30 -0
- package/src/composables/useFileSelection.ts +115 -0
- package/src/composables/useFileSortMode.ts +24 -0
- package/src/composables/useFileTree.ts +85 -0
- package/src/composables/useFreshPluginData.ts +89 -0
- package/src/composables/useHealth.ts +38 -0
- package/src/composables/useImeAwareEnter.ts +57 -0
- package/src/composables/useKeyNavigation.ts +60 -0
- package/src/composables/useMarkdownLinkHandler.ts +46 -0
- package/src/composables/useMarkdownMode.ts +17 -0
- package/src/composables/useMcpTools.ts +71 -0
- package/src/composables/useMergedSessions.ts +27 -0
- package/src/composables/useNotifications.ts +90 -0
- package/src/composables/usePdfDownload.ts +60 -0
- package/src/composables/usePendingCalls.ts +77 -0
- package/src/composables/usePubSub.ts +85 -0
- package/src/composables/useRightSidebar.ts +23 -0
- package/src/composables/useRoles.ts +34 -0
- package/src/composables/useSandboxStatus.ts +67 -0
- package/src/composables/useSelectedResult.ts +49 -0
- package/src/composables/useSessionDerived.ts +51 -0
- package/src/composables/useSessionHistory.ts +81 -0
- package/src/composables/useSessionSync.ts +57 -0
- package/src/composables/useViewLayout.ts +55 -0
- package/src/config/apiRoutes.ts +173 -0
- package/src/config/pubsubChannels.ts +45 -0
- package/src/config/roles.ts +335 -0
- package/src/config/schedulerActions.ts +25 -0
- package/src/config/toolNames.ts +71 -0
- package/src/config/workspacePaths.ts +24 -0
- package/src/index.css +107 -0
- package/src/main.ts +25 -0
- package/src/plugins/canvas/Preview.vue +13 -0
- package/src/plugins/canvas/View.vue +333 -0
- package/src/plugins/canvas/definition.ts +38 -0
- package/src/plugins/canvas/index.ts +36 -0
- package/src/plugins/chart/Preview.vue +49 -0
- package/src/plugins/chart/View.vue +143 -0
- package/src/plugins/chart/definition.ts +58 -0
- package/src/plugins/chart/index.ts +52 -0
- package/src/plugins/editImage/Preview.vue +13 -0
- package/src/plugins/editImage/View.vue +13 -0
- package/src/plugins/editImage/definition.ts +27 -0
- package/src/plugins/editImage/index.ts +36 -0
- package/src/plugins/generateImage/Preview.vue +13 -0
- package/src/plugins/generateImage/View.vue +33 -0
- package/src/plugins/generateImage/definition.ts +32 -0
- package/src/plugins/generateImage/index.ts +56 -0
- package/src/plugins/manageRoles/Preview.vue +49 -0
- package/src/plugins/manageRoles/View.vue +525 -0
- package/src/plugins/manageRoles/definition.ts +43 -0
- package/src/plugins/manageRoles/index.ts +47 -0
- package/src/plugins/manageSkills/Preview.vue +21 -0
- package/src/plugins/manageSkills/View.vue +321 -0
- package/src/plugins/manageSkills/definition.ts +49 -0
- package/src/plugins/manageSkills/index.ts +49 -0
- package/src/plugins/manageSource/Preview.vue +33 -0
- package/src/plugins/manageSource/View.vue +697 -0
- package/src/plugins/manageSource/definition.ts +63 -0
- package/src/plugins/manageSource/index.ts +66 -0
- package/src/plugins/markdown/Preview.vue +77 -0
- package/src/plugins/markdown/View.vue +476 -0
- package/src/plugins/markdown/definition.ts +50 -0
- package/src/plugins/markdown/index.ts +36 -0
- package/src/plugins/presentHtml/Preview.vue +25 -0
- package/src/plugins/presentHtml/View.vue +52 -0
- package/src/plugins/presentHtml/definition.ts +27 -0
- package/src/plugins/presentHtml/helpers.ts +72 -0
- package/src/plugins/presentHtml/index.ts +41 -0
- package/src/plugins/presentMulmoScript/Preview.vue +23 -0
- package/src/plugins/presentMulmoScript/View.vue +1166 -0
- package/src/plugins/presentMulmoScript/definition.ts +95 -0
- package/src/plugins/presentMulmoScript/helpers.ts +162 -0
- package/src/plugins/presentMulmoScript/index.ts +40 -0
- package/src/plugins/scheduler/Preview.vue +67 -0
- package/src/plugins/scheduler/TasksTab.vue +205 -0
- package/src/plugins/scheduler/View.vue +565 -0
- package/src/plugins/scheduler/definition.ts +57 -0
- package/src/plugins/scheduler/index.ts +45 -0
- package/src/plugins/scheduler/viewModes.ts +26 -0
- package/src/plugins/spreadsheet/Preview.vue +29 -0
- package/src/plugins/spreadsheet/View.vue +997 -0
- package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
- package/src/plugins/spreadsheet/definition.ts +121 -0
- package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
- package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
- package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
- package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
- package/src/plugins/spreadsheet/engine/engine.ts +176 -0
- package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
- package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
- package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
- package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
- package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
- package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
- package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
- package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
- package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
- package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
- package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
- package/src/plugins/spreadsheet/engine/index.ts +27 -0
- package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
- package/src/plugins/spreadsheet/engine/parser.ts +143 -0
- package/src/plugins/spreadsheet/engine/registry.ts +150 -0
- package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
- package/src/plugins/spreadsheet/engine/types.ts +64 -0
- package/src/plugins/spreadsheet/index.ts +36 -0
- package/src/plugins/textResponse/Preview.vue +94 -0
- package/src/plugins/textResponse/View.vue +503 -0
- package/src/plugins/textResponse/definition.ts +34 -0
- package/src/plugins/textResponse/index.ts +27 -0
- package/src/plugins/textResponse/plugin.ts +29 -0
- package/src/plugins/textResponse/samples.ts +97 -0
- package/src/plugins/textResponse/types.ts +11 -0
- package/src/plugins/todo/Preview.vue +63 -0
- package/src/plugins/todo/View.vue +364 -0
- package/src/plugins/todo/composables/useTodos.ts +177 -0
- package/src/plugins/todo/definition.ts +45 -0
- package/src/plugins/todo/index.ts +61 -0
- package/src/plugins/todo/labels.ts +163 -0
- package/src/plugins/todo/priority.ts +98 -0
- package/src/plugins/todo/viewModes.ts +19 -0
- package/src/plugins/ui-image/ImagePreview.vue +23 -0
- package/src/plugins/ui-image/ImageView.vue +34 -0
- package/src/plugins/ui-image/index.ts +3 -0
- package/src/plugins/ui-image/types.ts +4 -0
- package/src/plugins/wiki/Preview.vue +65 -0
- package/src/plugins/wiki/View.vue +342 -0
- package/src/plugins/wiki/definition.ts +25 -0
- package/src/plugins/wiki/helpers.ts +59 -0
- package/src/plugins/wiki/index.ts +52 -0
- package/src/router/guards.ts +61 -0
- package/src/router/index.ts +50 -0
- package/src/tools/index.ts +52 -0
- package/src/tools/types.ts +27 -0
- package/src/types/events.ts +16 -0
- package/src/types/fileTree.ts +13 -0
- package/src/types/notification.ts +67 -0
- package/src/types/session.ts +116 -0
- package/src/types/sse.ts +90 -0
- package/src/types/toolCallHistory.ts +13 -0
- package/src/utils/agent/eventDispatch.ts +74 -0
- package/src/utils/agent/request.ts +55 -0
- package/src/utils/agent/toolCalls.ts +62 -0
- package/src/utils/api.ts +218 -0
- package/src/utils/canvas/viewMode.ts +46 -0
- package/src/utils/dom/authTokenMeta.ts +20 -0
- package/src/utils/dom/clickOutside.ts +11 -0
- package/src/utils/dom/externalLink.ts +57 -0
- package/src/utils/dom/scrollable.ts +24 -0
- package/src/utils/errors.ts +11 -0
- package/src/utils/files/expandedDirs.ts +25 -0
- package/src/utils/files/filename.ts +12 -0
- package/src/utils/files/sortChildren.ts +20 -0
- package/src/utils/filesPreview/schedulerPreview.ts +38 -0
- package/src/utils/filesPreview/todoPreview.ts +40 -0
- package/src/utils/format/date.ts +85 -0
- package/src/utils/format/frontmatter.ts +80 -0
- package/src/utils/format/jsonSyntax.ts +109 -0
- package/src/utils/html/previewCsp.ts +65 -0
- package/src/utils/image/resolve.ts +8 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
- package/src/utils/markdown/extractFirstH1.ts +39 -0
- package/src/utils/notification/dispatch.ts +22 -0
- package/src/utils/path/relativeLink.ts +130 -0
- package/src/utils/role/icon.ts +20 -0
- package/src/utils/role/merge.ts +10 -0
- package/src/utils/role/plugins.ts +12 -0
- package/src/utils/session/mergeSessions.ts +103 -0
- package/src/utils/session/seedRoleDefault.ts +35 -0
- package/src/utils/session/sessionEntries.ts +121 -0
- package/src/utils/session/sessionFactory.ts +22 -0
- package/src/utils/session/sessionHelpers.ts +99 -0
- package/src/utils/tools/dedup.ts +17 -0
- package/src/utils/tools/mcp.ts +33 -0
- package/src/utils/tools/pendingCalls.ts +16 -0
- package/src/utils/tools/result.ts +40 -0
- package/src/utils/types.ts +44 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
// Server-side session state store. Replaces the lightweight
|
|
2
|
+
// `server/sessions.ts` SSE-send registry with a pub/sub-backed
|
|
3
|
+
// store that holds authoritative state per chat session. Multiple
|
|
4
|
+
// clients can subscribe to the same session channel and receive
|
|
5
|
+
// identical events.
|
|
6
|
+
|
|
7
|
+
import { appendFile } from "fs/promises";
|
|
8
|
+
import type { IPubSub } from "../pub-sub/index.js";
|
|
9
|
+
import { PUBSUB_CHANNELS, sessionChannel } from "../../../src/config/pubsubChannels.js";
|
|
10
|
+
import { log } from "../../system/logger/index.js";
|
|
11
|
+
import { updateHasUnread } from "../../utils/files/session-io.js";
|
|
12
|
+
import { EVENT_TYPES, GENERATION_KINDS, type GenerationKind, type PendingGeneration, generationKey } from "../../../src/types/events.js";
|
|
13
|
+
import { ONE_HOUR_MS, ONE_MINUTE_MS } from "../../utils/time.js";
|
|
14
|
+
|
|
15
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ToolCallHistoryItem {
|
|
18
|
+
toolUseId: string;
|
|
19
|
+
toolName: string;
|
|
20
|
+
args: unknown;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
result?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ServerSession {
|
|
27
|
+
chatSessionId: string;
|
|
28
|
+
roleId: string;
|
|
29
|
+
isRunning: boolean;
|
|
30
|
+
hasUnread: boolean;
|
|
31
|
+
statusMessage: string;
|
|
32
|
+
toolCallHistory: ToolCallHistoryItem[];
|
|
33
|
+
resultsFilePath: string;
|
|
34
|
+
selectedImageData?: string;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
/** Kills the spawned Claude CLI process for this session. */
|
|
38
|
+
abortRun?: () => void;
|
|
39
|
+
/**
|
|
40
|
+
* In-flight background generations keyed by `generationKey(kind, filePath, key)`.
|
|
41
|
+
* The value carries the decomposed (kind, filePath, key) so consumers never
|
|
42
|
+
* have to parse the opaque composite key back out. Non-empty means the
|
|
43
|
+
* session has ongoing work even when `isRunning` (agent turn) is false —
|
|
44
|
+
* used to keep the busy indicator lit across view navigation.
|
|
45
|
+
*/
|
|
46
|
+
pendingGenerations: Record<string, PendingGeneration>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Constants ──────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const IDLE_EVICTION_MS = ONE_HOUR_MS;
|
|
52
|
+
const EVICTION_CHECK_INTERVAL_MS = 5 * ONE_MINUTE_MS;
|
|
53
|
+
|
|
54
|
+
// ── Store ──────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const store = new Map<string, ServerSession>();
|
|
57
|
+
/**
|
|
58
|
+
* Parallel pending-generation tracking for sessions that aren't in the
|
|
59
|
+
* in-memory store. The MulmoScript view can be opened on a session
|
|
60
|
+
* whose full ServerSession entry never existed or was evicted after
|
|
61
|
+
* idle timeout — we still want to mark unread and fire
|
|
62
|
+
* notifySessionsChanged when the work drains. Cleared on drain.
|
|
63
|
+
*/
|
|
64
|
+
const storelessPending = new Map<string, Set<string>>();
|
|
65
|
+
let pubsub: IPubSub | null = null;
|
|
66
|
+
let evictionTimer: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
|
|
68
|
+
export function initSessionStore(ps: IPubSub): void {
|
|
69
|
+
pubsub = ps;
|
|
70
|
+
if (evictionTimer) clearInterval(evictionTimer);
|
|
71
|
+
evictionTimer = setInterval(evictIdleSessions, EVICTION_CHECK_INTERVAL_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Session lifecycle ──────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export function getSession(chatSessionId: string): ServerSession | undefined {
|
|
77
|
+
return store.get(chatSessionId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getOrCreateSession(
|
|
81
|
+
chatSessionId: string,
|
|
82
|
+
opts: {
|
|
83
|
+
roleId: string;
|
|
84
|
+
resultsFilePath: string;
|
|
85
|
+
selectedImageData?: string;
|
|
86
|
+
startedAt: string;
|
|
87
|
+
updatedAt: string;
|
|
88
|
+
hasUnread?: boolean;
|
|
89
|
+
},
|
|
90
|
+
): ServerSession {
|
|
91
|
+
const existing = store.get(chatSessionId);
|
|
92
|
+
if (existing) {
|
|
93
|
+
existing.selectedImageData = opts.selectedImageData;
|
|
94
|
+
existing.updatedAt = opts.updatedAt;
|
|
95
|
+
return existing;
|
|
96
|
+
}
|
|
97
|
+
const session: ServerSession = {
|
|
98
|
+
chatSessionId,
|
|
99
|
+
roleId: opts.roleId,
|
|
100
|
+
isRunning: false,
|
|
101
|
+
hasUnread: opts.hasUnread ?? false,
|
|
102
|
+
statusMessage: "",
|
|
103
|
+
toolCallHistory: [],
|
|
104
|
+
resultsFilePath: opts.resultsFilePath,
|
|
105
|
+
selectedImageData: opts.selectedImageData,
|
|
106
|
+
startedAt: opts.startedAt,
|
|
107
|
+
updatedAt: opts.updatedAt,
|
|
108
|
+
pendingGenerations: {},
|
|
109
|
+
};
|
|
110
|
+
store.set(chatSessionId, session);
|
|
111
|
+
return session;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function removeSession(chatSessionId: string): void {
|
|
115
|
+
store.delete(chatSessionId);
|
|
116
|
+
notifySessionsChanged();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── State mutations (publish to pub/sub) ───────────────────────
|
|
120
|
+
|
|
121
|
+
/** Mark a session as running. Rejects if already running (409). */
|
|
122
|
+
export function beginRun(chatSessionId: string, abortRun: () => void): boolean {
|
|
123
|
+
const session = store.get(chatSessionId);
|
|
124
|
+
if (!session) return false;
|
|
125
|
+
if (session.isRunning) return false;
|
|
126
|
+
session.isRunning = true;
|
|
127
|
+
session.statusMessage = "";
|
|
128
|
+
session.toolCallHistory = [];
|
|
129
|
+
session.abortRun = abortRun;
|
|
130
|
+
session.updatedAt = new Date().toISOString();
|
|
131
|
+
notifySessionsChanged();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Mark a session as finished. Sets hasUnread = true. */
|
|
136
|
+
export function endRun(chatSessionId: string): void {
|
|
137
|
+
const session = store.get(chatSessionId);
|
|
138
|
+
if (!session) return;
|
|
139
|
+
session.isRunning = false;
|
|
140
|
+
session.hasUnread = true;
|
|
141
|
+
session.statusMessage = "";
|
|
142
|
+
session.abortRun = undefined;
|
|
143
|
+
session.updatedAt = new Date().toISOString();
|
|
144
|
+
persistHasUnread(chatSessionId, true);
|
|
145
|
+
publishToSessionChannel(chatSessionId, {
|
|
146
|
+
type: EVENT_TYPES.sessionFinished,
|
|
147
|
+
});
|
|
148
|
+
notifySessionsChanged();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cancel a running session by killing the child process. */
|
|
152
|
+
export function cancelRun(chatSessionId: string): boolean {
|
|
153
|
+
const session = store.get(chatSessionId);
|
|
154
|
+
if (!session?.isRunning || !session.abortRun) return false;
|
|
155
|
+
session.abortRun();
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Clear the unread flag (called when a client views the session).
|
|
160
|
+
* Awaits the disk write so the caller can respond only after the
|
|
161
|
+
* flag is actually persisted — avoids the race where the client
|
|
162
|
+
* refetches before the write lands and sees the stale value. */
|
|
163
|
+
export async function markRead(chatSessionId: string): Promise<void> {
|
|
164
|
+
const session = store.get(chatSessionId);
|
|
165
|
+
if (!session) {
|
|
166
|
+
// No in-memory session — still persist to disk so the flag is
|
|
167
|
+
// cleared for the next server restart / session listing.
|
|
168
|
+
await persistHasUnread(chatSessionId, false);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (!session.hasUnread) return;
|
|
172
|
+
session.hasUnread = false;
|
|
173
|
+
await persistHasUnread(chatSessionId, false);
|
|
174
|
+
notifySessionsChanged();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Event publishing ───────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** Publish an agent event to the session's channel + update store. */
|
|
180
|
+
export function pushSessionEvent(chatSessionId: string, event: Record<string, unknown>): void {
|
|
181
|
+
const type = event.type as string;
|
|
182
|
+
const isGenerationEvent = type === EVENT_TYPES.generationStarted || type === EVENT_TYPES.generationFinished;
|
|
183
|
+
|
|
184
|
+
// Non-generation events keep the pre-existing "store or drop"
|
|
185
|
+
// behavior: toolCall / toolCallResult / status fire only during a
|
|
186
|
+
// live agent turn (which always has a store entry), and
|
|
187
|
+
// rolesUpdated / sessionFinished are equally tied to in-store
|
|
188
|
+
// sessions. Publishing them for evicted sessions would broaden the
|
|
189
|
+
// wire contract without a concrete need.
|
|
190
|
+
//
|
|
191
|
+
// Generation events are the exception — a plugin view (e.g.
|
|
192
|
+
// MulmoScript) can kick off work on a session whose store entry
|
|
193
|
+
// never existed or was evicted after idle timeout, and the client
|
|
194
|
+
// subscription lives on the channel, not on any server-side
|
|
195
|
+
// session object. We always deliver those so the UI can update.
|
|
196
|
+
const session = store.get(chatSessionId);
|
|
197
|
+
if (!session && !isGenerationEvent) return;
|
|
198
|
+
publishToSessionChannel(chatSessionId, event);
|
|
199
|
+
|
|
200
|
+
const generationDelta = resolveGenerationDelta(chatSessionId, type, event);
|
|
201
|
+
if (generationDelta === "same") return;
|
|
202
|
+
if (generationDelta === "started") {
|
|
203
|
+
notifySessionsChanged();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Drained: flag hasUnread, same semantics as endRun(). Clients
|
|
208
|
+
// viewing the session clear it via markRead.
|
|
209
|
+
if (session) {
|
|
210
|
+
session.hasUnread = true;
|
|
211
|
+
// Store is the source of truth, so the refetch already sees the
|
|
212
|
+
// flag via `live.hasUnread` — the disk write is just a backstop
|
|
213
|
+
// across server restarts and can stay fire-and-forget.
|
|
214
|
+
persistHasUnread(chatSessionId, true).catch(() => {});
|
|
215
|
+
notifySessionsChanged();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Storeless: meta.hasUnread on disk is the ONLY place the flag lives.
|
|
220
|
+
// If we notified before the write completed, the client's refetch
|
|
221
|
+
// would read the stale pre-drain value. Sequence: persist, then
|
|
222
|
+
// notify.
|
|
223
|
+
persistHasUnread(chatSessionId, true)
|
|
224
|
+
.catch(() => {})
|
|
225
|
+
.then(() => notifySessionsChanged());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Dispatch the event to whichever pending tracker the session has.
|
|
230
|
+
* Returns the empty↔non-empty transition so the caller can decide
|
|
231
|
+
* whether to flip hasUnread and notify.
|
|
232
|
+
*/
|
|
233
|
+
function resolveGenerationDelta(chatSessionId: string, type: string, event: Record<string, unknown>): GenerationDelta {
|
|
234
|
+
const session = store.get(chatSessionId);
|
|
235
|
+
if (session) return applyEventToSession(session, type, event);
|
|
236
|
+
if (type === EVENT_TYPES.generationStarted || type === EVENT_TYPES.generationFinished) {
|
|
237
|
+
return updateStorelessPending(chatSessionId, type, event);
|
|
238
|
+
}
|
|
239
|
+
return "same";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function updateStorelessPending(chatSessionId: string, type: string, event: Record<string, unknown>): GenerationDelta {
|
|
243
|
+
const payload = parseGenerationPayload(event);
|
|
244
|
+
if (!payload) {
|
|
245
|
+
log.warn("session-store", "malformed generation event", {
|
|
246
|
+
chatSessionId,
|
|
247
|
+
type,
|
|
248
|
+
});
|
|
249
|
+
return "same";
|
|
250
|
+
}
|
|
251
|
+
const mapKey = generationKey(payload.kind, payload.filePath, payload.key);
|
|
252
|
+
const existing = storelessPending.get(chatSessionId);
|
|
253
|
+
const wasEmpty = !existing || existing.size === 0;
|
|
254
|
+
|
|
255
|
+
if (type === EVENT_TYPES.generationStarted) {
|
|
256
|
+
const set = existing ?? new Set<string>();
|
|
257
|
+
set.add(mapKey);
|
|
258
|
+
if (!existing) storelessPending.set(chatSessionId, set);
|
|
259
|
+
} else if (existing) {
|
|
260
|
+
existing.delete(mapKey);
|
|
261
|
+
if (existing.size === 0) storelessPending.delete(chatSessionId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const isEmpty = (storelessPending.get(chatSessionId)?.size ?? 0) === 0;
|
|
265
|
+
if (wasEmpty === isEmpty) return "same";
|
|
266
|
+
return isEmpty ? "drained" : "started";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* How a generation event affected the session's pendingGenerations set:
|
|
271
|
+
*
|
|
272
|
+
* - `started`: empty → non-empty (first generation in a quiet session)
|
|
273
|
+
* - `drained`: non-empty → empty (last pending generation finished)
|
|
274
|
+
* - `same`: no transition (parallel starts/finishes within a burst,
|
|
275
|
+
* or a non-generation event type)
|
|
276
|
+
*
|
|
277
|
+
* Callers use this to decide whether to fire `notifySessionsChanged()`
|
|
278
|
+
* and whether to flip hasUnread on drain.
|
|
279
|
+
*/
|
|
280
|
+
type GenerationDelta = "started" | "drained" | "same";
|
|
281
|
+
|
|
282
|
+
/** Fields pulled off a validated generation event. */
|
|
283
|
+
interface GenerationPayload {
|
|
284
|
+
kind: GenerationKind;
|
|
285
|
+
filePath: string;
|
|
286
|
+
key: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const GENERATION_KIND_VALUES: ReadonlySet<string> = new Set(Object.values(GENERATION_KINDS));
|
|
290
|
+
|
|
291
|
+
function isGenerationKind(v: unknown): v is GenerationKind {
|
|
292
|
+
return typeof v === "string" && GENERATION_KIND_VALUES.has(v);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Narrow an event's generation fields at runtime. The event arrives
|
|
297
|
+
* as `Record<string, unknown>` so we can't trust its shape — validate
|
|
298
|
+
* every field before handing back a typed struct. Unknown kinds or
|
|
299
|
+
* missing fields return null; the caller should log + no-op.
|
|
300
|
+
*/
|
|
301
|
+
function parseGenerationPayload(event: Record<string, unknown>): GenerationPayload | null {
|
|
302
|
+
const { kind, filePath, key } = event;
|
|
303
|
+
if (!isGenerationKind(kind)) return null;
|
|
304
|
+
if (typeof filePath !== "string" || typeof key !== "string") return null;
|
|
305
|
+
return { kind, filePath, key };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function applyEventToSession(session: ServerSession, type: string, event: Record<string, unknown>): GenerationDelta {
|
|
309
|
+
if (type === EVENT_TYPES.toolCall) {
|
|
310
|
+
session.toolCallHistory.push({
|
|
311
|
+
toolUseId: event.toolUseId as string,
|
|
312
|
+
toolName: event.toolName as string,
|
|
313
|
+
args: event.args,
|
|
314
|
+
timestamp: Date.now(),
|
|
315
|
+
});
|
|
316
|
+
} else if (type === EVENT_TYPES.toolCallResult) {
|
|
317
|
+
const entry = session.toolCallHistory.find((e) => e.toolUseId === event.toolUseId);
|
|
318
|
+
if (entry) entry.result = event.content as string;
|
|
319
|
+
} else if (type === EVENT_TYPES.status) {
|
|
320
|
+
session.statusMessage = event.message as string;
|
|
321
|
+
// No notifySessionsChanged() here — status updates are high-frequency
|
|
322
|
+
// and flow to subscribed clients via the session.<id> channel directly.
|
|
323
|
+
} else if (type === EVENT_TYPES.generationStarted || type === EVENT_TYPES.generationFinished) {
|
|
324
|
+
return updatePendingGenerations(session, type, event);
|
|
325
|
+
}
|
|
326
|
+
return "same";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function updatePendingGenerations(session: ServerSession, type: string, event: Record<string, unknown>): GenerationDelta {
|
|
330
|
+
const payload = parseGenerationPayload(event);
|
|
331
|
+
if (!payload) {
|
|
332
|
+
log.warn("session-store", "malformed generation event", {
|
|
333
|
+
chatSessionId: session.chatSessionId,
|
|
334
|
+
type,
|
|
335
|
+
});
|
|
336
|
+
return "same";
|
|
337
|
+
}
|
|
338
|
+
const mapKey = generationKey(payload.kind, payload.filePath, payload.key);
|
|
339
|
+
const wasEmpty = Object.keys(session.pendingGenerations).length === 0;
|
|
340
|
+
|
|
341
|
+
if (type === EVENT_TYPES.generationStarted) {
|
|
342
|
+
session.pendingGenerations[mapKey] = payload;
|
|
343
|
+
} else {
|
|
344
|
+
delete session.pendingGenerations[mapKey];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const isEmpty = Object.keys(session.pendingGenerations).length === 0;
|
|
348
|
+
if (wasEmpty === isEmpty) return "same";
|
|
349
|
+
return isEmpty ? "drained" : "started";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Convenience wrapper for plugin routes that run long async jobs.
|
|
354
|
+
* Publishes a generationStarted or generationFinished event on the
|
|
355
|
+
* session channel. Safely no-ops when chatSessionId is missing — lets
|
|
356
|
+
* callers that aren't inside a session context use the same routes.
|
|
357
|
+
*/
|
|
358
|
+
export function publishGeneration(
|
|
359
|
+
chatSessionId: string | undefined,
|
|
360
|
+
kind: GenerationKind,
|
|
361
|
+
filePath: string,
|
|
362
|
+
key: string,
|
|
363
|
+
finished: boolean,
|
|
364
|
+
error?: string,
|
|
365
|
+
): void {
|
|
366
|
+
if (!chatSessionId) return;
|
|
367
|
+
const event: Record<string, unknown> = {
|
|
368
|
+
type: finished ? EVENT_TYPES.generationFinished : EVENT_TYPES.generationStarted,
|
|
369
|
+
kind,
|
|
370
|
+
filePath,
|
|
371
|
+
key,
|
|
372
|
+
};
|
|
373
|
+
if (error) event.error = error;
|
|
374
|
+
pushSessionEvent(chatSessionId, event);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export type PushToolResultOutcome = { kind: "skipped"; reason: string } | { kind: "processed" };
|
|
378
|
+
|
|
379
|
+
/** Persist a tool_result to JSONL, then publish to the session channel. */
|
|
380
|
+
export async function pushToolResult(chatSessionId: string, result: unknown): Promise<PushToolResultOutcome> {
|
|
381
|
+
const session = store.get(chatSessionId);
|
|
382
|
+
if (!session) return { kind: "skipped", reason: "unknown session" };
|
|
383
|
+
|
|
384
|
+
await appendFile(
|
|
385
|
+
session.resultsFilePath,
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
source: "tool",
|
|
388
|
+
type: EVENT_TYPES.toolResult,
|
|
389
|
+
result,
|
|
390
|
+
}) + "\n",
|
|
391
|
+
);
|
|
392
|
+
publishToSessionChannel(chatSessionId, {
|
|
393
|
+
type: EVENT_TYPES.toolResult,
|
|
394
|
+
result,
|
|
395
|
+
});
|
|
396
|
+
return { kind: "processed" };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Query helpers ──────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
export function getSessionImageData(chatSessionId: string): string | undefined {
|
|
402
|
+
return store.get(chatSessionId)?.selectedImageData;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function getActiveSessionIds(): Set<string> {
|
|
406
|
+
const ids = new Set<string>();
|
|
407
|
+
for (const [id, session] of store) {
|
|
408
|
+
if (session.isRunning) ids.add(id);
|
|
409
|
+
}
|
|
410
|
+
return ids;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── In-process session event listeners ────────────────────────
|
|
414
|
+
|
|
415
|
+
type SessionEventListener = (event: Record<string, unknown>) => void;
|
|
416
|
+
const sessionListeners = new Map<string, Set<SessionEventListener>>();
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Subscribe to session events in-process (no WebSocket needed).
|
|
420
|
+
* Returns an unsubscribe function.
|
|
421
|
+
*/
|
|
422
|
+
export function onSessionEvent(chatSessionId: string, listener: SessionEventListener): () => void {
|
|
423
|
+
let listeners = sessionListeners.get(chatSessionId);
|
|
424
|
+
if (!listeners) {
|
|
425
|
+
listeners = new Set();
|
|
426
|
+
sessionListeners.set(chatSessionId, listeners);
|
|
427
|
+
}
|
|
428
|
+
listeners.add(listener);
|
|
429
|
+
return () => {
|
|
430
|
+
listeners!.delete(listener);
|
|
431
|
+
if (listeners!.size === 0) sessionListeners.delete(chatSessionId);
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Internal helpers ───────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
async function persistHasUnread(chatSessionId: string, hasUnread: boolean): Promise<void> {
|
|
438
|
+
try {
|
|
439
|
+
await updateHasUnread(chatSessionId, hasUnread);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// updateHasUnread already no-ops when meta is missing (ENOENT is
|
|
442
|
+
// handled internally). Any error reaching here is unexpected.
|
|
443
|
+
log.warn("session-store", "persistHasUnread failed", {
|
|
444
|
+
chatSessionId,
|
|
445
|
+
hasUnread,
|
|
446
|
+
error: String(err),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function publishToSessionChannel(chatSessionId: string, data: unknown): void {
|
|
452
|
+
pubsub?.publish(sessionChannel(chatSessionId), data);
|
|
453
|
+
const listeners = sessionListeners.get(chatSessionId);
|
|
454
|
+
if (listeners) {
|
|
455
|
+
for (const listener of listeners) {
|
|
456
|
+
listener(data as Record<string, unknown>);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Notify all clients that session state has changed — refetch via REST. */
|
|
462
|
+
function notifySessionsChanged(): void {
|
|
463
|
+
pubsub?.publish(PUBSUB_CHANNELS.sessions, {});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function evictIdleSessions(): void {
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
for (const [id, session] of store) {
|
|
469
|
+
if (session.isRunning) continue;
|
|
470
|
+
const age = now - new Date(session.updatedAt).getTime();
|
|
471
|
+
if (age > IDLE_EVICTION_MS) {
|
|
472
|
+
log.info("session-store", "evicting idle session", {
|
|
473
|
+
chatSessionId: id,
|
|
474
|
+
});
|
|
475
|
+
removeSession(id);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Test-only: clear all in-memory state so a test suite can start
|
|
482
|
+
* fresh without reloading the module.
|
|
483
|
+
*/
|
|
484
|
+
export function __resetForTests(): void {
|
|
485
|
+
store.clear();
|
|
486
|
+
storelessPending.clear();
|
|
487
|
+
pubsub = null;
|
|
488
|
+
if (evictionTimer) {
|
|
489
|
+
clearInterval(evictionTimer);
|
|
490
|
+
evictionTimer = null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { log } from "../../system/logger/index.js";
|
|
2
|
+
import { ONE_SECOND_MS, ONE_MINUTE_MS, ONE_HOUR_MS } from "../../utils/time.js";
|
|
3
|
+
import { SCHEDULE_TYPES } from "@receptron/task-scheduler";
|
|
4
|
+
|
|
5
|
+
export type TaskSchedule = { type: typeof SCHEDULE_TYPES.interval; intervalMs: number } | { type: typeof SCHEDULE_TYPES.daily; time: string }; // time: "HH:MM" in UTC
|
|
6
|
+
|
|
7
|
+
export interface TaskRunContext {
|
|
8
|
+
taskId: string;
|
|
9
|
+
now: Date;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskDefinition {
|
|
13
|
+
id: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
schedule: TaskSchedule;
|
|
16
|
+
enabled?: boolean; // default: true
|
|
17
|
+
/** If set, this task only fires after the named task has completed
|
|
18
|
+
* successfully in the current tick cycle. Enforces ordering like
|
|
19
|
+
* "news fetch → journal → memory extraction". */
|
|
20
|
+
dependsOn?: string;
|
|
21
|
+
run: (ctx: TaskRunContext) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ITaskManager {
|
|
25
|
+
registerTask(def: TaskDefinition): void;
|
|
26
|
+
removeTask(taskId: string): void;
|
|
27
|
+
/** Update the schedule of an existing task. Returns false if not found. */
|
|
28
|
+
updateSchedule(taskId: string, schedule: TaskSchedule): boolean;
|
|
29
|
+
start(): void;
|
|
30
|
+
stop(): void;
|
|
31
|
+
/** Run one tick manually (for testing). */
|
|
32
|
+
tick(): Promise<void>;
|
|
33
|
+
listTasks(): Array<{
|
|
34
|
+
id: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
schedule: TaskSchedule;
|
|
37
|
+
dependsOn?: string;
|
|
38
|
+
}>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TaskManagerOptions {
|
|
42
|
+
tickMs?: number; // default: ONE_MINUTE_MS
|
|
43
|
+
now?: () => Date; // default: () => new Date()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isDue(now: Date, schedule: TaskSchedule, tickMs: number): boolean {
|
|
47
|
+
if (schedule.type === SCHEDULE_TYPES.interval) {
|
|
48
|
+
const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;
|
|
49
|
+
// Round down to tick boundary, then check if it aligns with the interval
|
|
50
|
+
const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;
|
|
51
|
+
return rounded % schedule.intervalMs === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (schedule.type === SCHEDULE_TYPES.daily) {
|
|
55
|
+
const [hh, mm] = schedule.time.split(":").map(Number);
|
|
56
|
+
const targetMs = hh * ONE_HOUR_MS + mm * ONE_MINUTE_MS;
|
|
57
|
+
const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;
|
|
58
|
+
const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;
|
|
59
|
+
return rounded === targetMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createTaskManager(options?: TaskManagerOptions): ITaskManager {
|
|
66
|
+
const tickMs = options?.tickMs ?? ONE_MINUTE_MS;
|
|
67
|
+
const now = options?.now ?? (() => new Date());
|
|
68
|
+
const registry = new Map<string, TaskDefinition>();
|
|
69
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
70
|
+
|
|
71
|
+
function collectDueTasks(currentTime: Date): {
|
|
72
|
+
independent: TaskDefinition[];
|
|
73
|
+
dependent: TaskDefinition[];
|
|
74
|
+
} {
|
|
75
|
+
const independent: TaskDefinition[] = [];
|
|
76
|
+
const dependent: TaskDefinition[] = [];
|
|
77
|
+
for (const def of registry.values()) {
|
|
78
|
+
if (def.enabled === false) continue;
|
|
79
|
+
if (!isDue(currentTime, def.schedule, tickMs)) continue;
|
|
80
|
+
if (def.dependsOn) {
|
|
81
|
+
dependent.push(def);
|
|
82
|
+
} else {
|
|
83
|
+
independent.push(def);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { independent, dependent };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function runAndTrack(def: TaskDefinition, currentTime: Date, succeeded: Set<string>): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
await def.run({ taskId: def.id, now: currentTime });
|
|
92
|
+
succeeded.add(def.id);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log.error("task-manager", "task failed", {
|
|
95
|
+
id: def.id,
|
|
96
|
+
error: String(err),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function runDependentChain(dependent: TaskDefinition[], currentTime: Date, succeeded: Set<string>): Promise<void> {
|
|
102
|
+
let remaining = [...dependent];
|
|
103
|
+
let progress = true;
|
|
104
|
+
while (remaining.length > 0 && progress) {
|
|
105
|
+
progress = false;
|
|
106
|
+
const next: TaskDefinition[] = [];
|
|
107
|
+
for (const def of remaining) {
|
|
108
|
+
if (!succeeded.has(def.dependsOn!)) {
|
|
109
|
+
next.push(def);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
await runAndTrack(def, currentTime, succeeded);
|
|
113
|
+
progress = true;
|
|
114
|
+
}
|
|
115
|
+
remaining = next;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function onTick(): Promise<void> {
|
|
120
|
+
const currentTime = now();
|
|
121
|
+
const { independent, dependent } = collectDueTasks(currentTime);
|
|
122
|
+
|
|
123
|
+
// Per-invocation set — success does not leak across tick() calls.
|
|
124
|
+
const succeeded = new Set<string>();
|
|
125
|
+
|
|
126
|
+
await Promise.all(independent.map((def) => runAndTrack(def, currentTime, succeeded)));
|
|
127
|
+
|
|
128
|
+
await runDependentChain(dependent, currentTime, succeeded);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
async tick() {
|
|
133
|
+
await onTick();
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
registerTask(def: TaskDefinition) {
|
|
137
|
+
if (registry.has(def.id)) {
|
|
138
|
+
throw new Error(`[task-manager] Task "${def.id}" is already registered`);
|
|
139
|
+
}
|
|
140
|
+
registry.set(def.id, def);
|
|
141
|
+
log.info("task-manager", "registered", { id: def.id });
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
updateSchedule(taskId: string, schedule: TaskSchedule): boolean {
|
|
145
|
+
const def = registry.get(taskId);
|
|
146
|
+
if (!def) return false;
|
|
147
|
+
def.schedule = schedule;
|
|
148
|
+
log.info("task-manager", "schedule updated", { id: taskId });
|
|
149
|
+
return true;
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
removeTask(taskId: string) {
|
|
153
|
+
if (registry.delete(taskId)) {
|
|
154
|
+
log.info("task-manager", "removed", { id: taskId });
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
start() {
|
|
159
|
+
if (timer) return;
|
|
160
|
+
timer = setInterval(onTick, tickMs);
|
|
161
|
+
log.info("task-manager", "started", { tickMs });
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
stop() {
|
|
165
|
+
if (timer) {
|
|
166
|
+
clearInterval(timer);
|
|
167
|
+
timer = null;
|
|
168
|
+
log.info("task-manager", "stopped");
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
listTasks() {
|
|
173
|
+
return [...registry.values()].map((d) => ({
|
|
174
|
+
id: d.id,
|
|
175
|
+
description: d.description,
|
|
176
|
+
schedule: d.schedule,
|
|
177
|
+
dependsOn: d.dependsOn,
|
|
178
|
+
}));
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|