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,257 @@
|
|
|
1
|
+
// User-created scheduled tasks (#357 Phase 3).
|
|
2
|
+
//
|
|
3
|
+
// Users can create tasks via the API or MCP tool. Each task fires
|
|
4
|
+
// `startChat()` with its prompt when the schedule triggers.
|
|
5
|
+
//
|
|
6
|
+
// Tasks are persisted in `config/scheduler/tasks.json` and
|
|
7
|
+
// registered with the task-manager at startup. CRUD operations
|
|
8
|
+
// trigger a refresh that unregisters old tasks and registers new ones.
|
|
9
|
+
|
|
10
|
+
import { loadUserTasks as loadRaw, saveUserTasks } from "../../utils/files/user-tasks-io.js";
|
|
11
|
+
import type { MissedRunPolicy } from "@receptron/task-scheduler";
|
|
12
|
+
import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler";
|
|
13
|
+
import type { TaskSchedule as LocalTaskSchedule } from "../../events/task-manager/index.js";
|
|
14
|
+
import { DEFAULT_ROLE_ID } from "../../../src/config/roles.js";
|
|
15
|
+
import { SESSION_ORIGINS, type SessionOrigin } from "../../../src/types/session.js";
|
|
16
|
+
import { log } from "../../system/logger/index.js";
|
|
17
|
+
import type { ITaskManager } from "../../events/task-manager/index.js";
|
|
18
|
+
import { isRecord } from "../../utils/types.js";
|
|
19
|
+
|
|
20
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface PersistedUserTask {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
schedule: LocalTaskSchedule;
|
|
27
|
+
missedRunPolicy: MissedRunPolicy;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
roleId: string;
|
|
30
|
+
prompt: string;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
updatedAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function loadUserTasks(r?: string): PersistedUserTask[] {
|
|
36
|
+
return loadRaw<PersistedUserTask>(r);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Validation ──────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function isValidDailyTime(value: string): boolean {
|
|
42
|
+
return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isValidSchedule(s: unknown): s is LocalTaskSchedule {
|
|
46
|
+
if (!isRecord(s)) return false;
|
|
47
|
+
const obj = s as Record<string, unknown>;
|
|
48
|
+
if (obj.type === SCHEDULE_TYPES.interval) {
|
|
49
|
+
return typeof obj.intervalMs === "number" && obj.intervalMs > 0;
|
|
50
|
+
}
|
|
51
|
+
if (obj.type === SCHEDULE_TYPES.daily) {
|
|
52
|
+
return typeof obj.time === "string" && isValidDailyTime(obj.time);
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isValidMissedRunPolicy(p: unknown): p is MissedRunPolicy {
|
|
58
|
+
return p === MISSED_RUN_POLICIES.skip || p === MISSED_RUN_POLICIES.runOnce || p === MISSED_RUN_POLICIES.runAll;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type ValidateResult = { kind: "ok"; task: PersistedUserTask } | { kind: "error"; error: string };
|
|
62
|
+
|
|
63
|
+
export function validateAndCreate(input: unknown): ValidateResult {
|
|
64
|
+
if (!isRecord(input)) {
|
|
65
|
+
return { kind: "error", error: "request body required" };
|
|
66
|
+
}
|
|
67
|
+
const obj = input as Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
if (typeof obj.name !== "string" || obj.name.trim().length === 0) {
|
|
70
|
+
return { kind: "error", error: "name required" };
|
|
71
|
+
}
|
|
72
|
+
if (typeof obj.prompt !== "string" || obj.prompt.trim().length === 0) {
|
|
73
|
+
return { kind: "error", error: "prompt required" };
|
|
74
|
+
}
|
|
75
|
+
if (!isValidSchedule(obj.schedule)) {
|
|
76
|
+
return { kind: "error", error: "valid schedule required" };
|
|
77
|
+
}
|
|
78
|
+
const missedRunPolicy = isValidMissedRunPolicy(obj.missedRunPolicy) ? obj.missedRunPolicy : MISSED_RUN_POLICIES.runOnce;
|
|
79
|
+
const roleId = typeof obj.roleId === "string" ? obj.roleId : DEFAULT_ROLE_ID;
|
|
80
|
+
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
const task: PersistedUserTask = {
|
|
83
|
+
id: crypto.randomUUID(),
|
|
84
|
+
name: obj.name.trim(),
|
|
85
|
+
description: typeof obj.description === "string" ? obj.description.trim() : "",
|
|
86
|
+
schedule: obj.schedule,
|
|
87
|
+
missedRunPolicy,
|
|
88
|
+
enabled: true,
|
|
89
|
+
roleId,
|
|
90
|
+
prompt: obj.prompt.trim(),
|
|
91
|
+
createdAt: now,
|
|
92
|
+
updatedAt: now,
|
|
93
|
+
};
|
|
94
|
+
return { kind: "ok", task };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type UpdateResult = { kind: "ok"; tasks: PersistedUserTask[] } | { kind: "error"; error: string };
|
|
98
|
+
|
|
99
|
+
export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unknown): UpdateResult {
|
|
100
|
+
if (!isRecord(patch)) {
|
|
101
|
+
return { kind: "error", error: "request body required" };
|
|
102
|
+
}
|
|
103
|
+
const idx = tasks.findIndex((t) => t.id === id);
|
|
104
|
+
if (idx === -1) {
|
|
105
|
+
return { kind: "error", error: `task not found: ${id}` };
|
|
106
|
+
}
|
|
107
|
+
const existing = tasks[idx];
|
|
108
|
+
const updated: PersistedUserTask = { ...existing };
|
|
109
|
+
// patch is validated as non-null object above; spread into Record
|
|
110
|
+
const p: Record<string, unknown> = { ...patch };
|
|
111
|
+
|
|
112
|
+
if (typeof p.name === "string" && p.name.trim().length > 0) {
|
|
113
|
+
updated.name = p.name.trim();
|
|
114
|
+
}
|
|
115
|
+
if (typeof p.description === "string") {
|
|
116
|
+
updated.description = p.description.trim();
|
|
117
|
+
}
|
|
118
|
+
if (isValidSchedule(p.schedule)) {
|
|
119
|
+
updated.schedule = p.schedule;
|
|
120
|
+
}
|
|
121
|
+
if (isValidMissedRunPolicy(p.missedRunPolicy)) {
|
|
122
|
+
updated.missedRunPolicy = p.missedRunPolicy;
|
|
123
|
+
}
|
|
124
|
+
if (typeof p.enabled === "boolean") {
|
|
125
|
+
updated.enabled = p.enabled;
|
|
126
|
+
}
|
|
127
|
+
if (typeof p.roleId === "string") {
|
|
128
|
+
updated.roleId = p.roleId;
|
|
129
|
+
}
|
|
130
|
+
if (typeof p.prompt === "string" && p.prompt.trim().length > 0) {
|
|
131
|
+
updated.prompt = p.prompt.trim();
|
|
132
|
+
}
|
|
133
|
+
updated.updatedAt = new Date().toISOString();
|
|
134
|
+
|
|
135
|
+
const next = [...tasks];
|
|
136
|
+
next[idx] = updated;
|
|
137
|
+
return { kind: "ok", tasks: next };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Mutexed CRUD ────────────────────────────────────────────────
|
|
141
|
+
// Serialize read-modify-write sequences so concurrent API calls
|
|
142
|
+
// don't clobber each other's changes.
|
|
143
|
+
|
|
144
|
+
let crudMutex: Promise<void> = Promise.resolve();
|
|
145
|
+
|
|
146
|
+
export async function withUserTaskLock<T>(
|
|
147
|
+
fn: (tasks: PersistedUserTask[]) => Promise<{
|
|
148
|
+
tasks: PersistedUserTask[];
|
|
149
|
+
result: T;
|
|
150
|
+
}>,
|
|
151
|
+
): Promise<T> {
|
|
152
|
+
const prev = crudMutex;
|
|
153
|
+
let release: () => void = () => {};
|
|
154
|
+
crudMutex = new Promise<void>((resolve) => {
|
|
155
|
+
release = resolve;
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
await prev;
|
|
159
|
+
const current = loadUserTasks();
|
|
160
|
+
const { tasks: next, result } = await fn(current);
|
|
161
|
+
await saveUserTasks(next);
|
|
162
|
+
await refreshUserTasks();
|
|
163
|
+
return result;
|
|
164
|
+
} finally {
|
|
165
|
+
release();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Task registration ───────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const USER_TASK_PREFIX = "user.";
|
|
172
|
+
let registeredUserTaskIds = new Set<string>();
|
|
173
|
+
let cachedUserTaskDeps: UserTaskDeps | null = null;
|
|
174
|
+
let userTaskMutex: Promise<number> = Promise.resolve(0);
|
|
175
|
+
|
|
176
|
+
export interface UserTaskDeps {
|
|
177
|
+
taskManager: ITaskManager;
|
|
178
|
+
startChat: (params: { message: string; roleId: string; chatSessionId: string; origin?: SessionOrigin }) => Promise<{ kind: string; error?: string }>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function registerUserTasks(deps: UserTaskDeps): Promise<number> {
|
|
182
|
+
cachedUserTaskDeps = deps;
|
|
183
|
+
return serializedRefreshUserTasks(deps);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function refreshUserTasks(): Promise<number> {
|
|
187
|
+
if (!cachedUserTaskDeps) {
|
|
188
|
+
log.warn("user-tasks", "refreshUserTasks called before initial register");
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
return serializedRefreshUserTasks(cachedUserTaskDeps);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function serializedRefreshUserTasks(deps: UserTaskDeps): Promise<number> {
|
|
195
|
+
userTaskMutex = userTaskMutex.then(
|
|
196
|
+
() => doRegisterUserTasks(deps),
|
|
197
|
+
() => doRegisterUserTasks(deps),
|
|
198
|
+
);
|
|
199
|
+
return userTaskMutex;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function doRegisterUserTasks(deps: UserTaskDeps): Promise<number> {
|
|
203
|
+
const { taskManager, startChat } = deps;
|
|
204
|
+
|
|
205
|
+
for (const taskId of registeredUserTaskIds) {
|
|
206
|
+
taskManager.removeTask(taskId);
|
|
207
|
+
}
|
|
208
|
+
const previousCount = registeredUserTaskIds.size;
|
|
209
|
+
registeredUserTaskIds = new Set<string>();
|
|
210
|
+
|
|
211
|
+
const tasks = loadUserTasks();
|
|
212
|
+
let registered = 0;
|
|
213
|
+
|
|
214
|
+
for (const task of tasks) {
|
|
215
|
+
if (!task.enabled) continue;
|
|
216
|
+
|
|
217
|
+
const taskId = `${USER_TASK_PREFIX}${task.id}`;
|
|
218
|
+
taskManager.registerTask({
|
|
219
|
+
id: taskId,
|
|
220
|
+
description: `User task: ${task.name}`,
|
|
221
|
+
schedule: task.schedule,
|
|
222
|
+
run: async () => {
|
|
223
|
+
const chatSessionId = crypto.randomUUID();
|
|
224
|
+
log.info("user-tasks", "running user task", {
|
|
225
|
+
name: task.name,
|
|
226
|
+
roleId: task.roleId,
|
|
227
|
+
chatSessionId,
|
|
228
|
+
});
|
|
229
|
+
const result = await startChat({
|
|
230
|
+
message: task.prompt,
|
|
231
|
+
roleId: task.roleId,
|
|
232
|
+
chatSessionId,
|
|
233
|
+
origin: SESSION_ORIGINS.scheduler,
|
|
234
|
+
});
|
|
235
|
+
if (result.kind === "error") {
|
|
236
|
+
throw new Error(`user task failed: ${result.error ?? "unknown"}`);
|
|
237
|
+
}
|
|
238
|
+
log.info("user-tasks", "user task completed", {
|
|
239
|
+
name: task.name,
|
|
240
|
+
kind: result.kind,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
registeredUserTaskIds.add(taskId);
|
|
246
|
+
registered++;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (previousCount > 0 || registered > 0) {
|
|
250
|
+
log.info("user-tasks", "user tasks refreshed", {
|
|
251
|
+
previous: previousCount,
|
|
252
|
+
current: registered,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return registered;
|
|
257
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Project-scope skill writer. Phase 1 of #139.
|
|
2
|
+
//
|
|
3
|
+
// Writes are confined to <workspaceRoot>/.claude/skills/<slug>/SKILL.md.
|
|
4
|
+
// User-scope skills (~/.claude/skills/) are never touched — the
|
|
5
|
+
// safety boundary is enforced by always going through
|
|
6
|
+
// `projectSkillPath` and never accepting an arbitrary destination.
|
|
7
|
+
//
|
|
8
|
+
// `saveProjectSkill` is non-overwriting: if the slug already has a
|
|
9
|
+
// SKILL.md (in either scope), the call returns a `kind: "exists"`
|
|
10
|
+
// result and the file is left alone. The caller (REST handler /
|
|
11
|
+
// MCP bridge) maps this to a 409 Conflict so Claude can ask the
|
|
12
|
+
// user for a different name.
|
|
13
|
+
|
|
14
|
+
import { unlink, rmdir } from "node:fs/promises";
|
|
15
|
+
import { discoverSkills } from "./discovery.js";
|
|
16
|
+
import { projectSkillDir, projectSkillPath } from "./paths.js";
|
|
17
|
+
import { isValidSlug } from "../../utils/slug.js";
|
|
18
|
+
import { log } from "../../system/logger/index.js";
|
|
19
|
+
import { writeFileAtomic } from "../../utils/files/index.js";
|
|
20
|
+
|
|
21
|
+
export interface SaveSkillInput {
|
|
22
|
+
/** Workspace root (typically `~/mulmoclaude`). */
|
|
23
|
+
workspaceRoot: string;
|
|
24
|
+
/** Slug — also the dir name and the slash-command name. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** YAML frontmatter `description:` value. One-line summary. */
|
|
27
|
+
description: string;
|
|
28
|
+
/** Markdown body following the frontmatter. May be empty. */
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SaveResult =
|
|
33
|
+
| { kind: "saved"; path: string }
|
|
34
|
+
| { kind: "invalid-slug"; slug: string }
|
|
35
|
+
| { kind: "missing-field"; field: "description" | "body" }
|
|
36
|
+
| { kind: "exists"; name: string };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write a new SKILL.md atomically. Refuses to overwrite — if the
|
|
40
|
+
* skill already exists at either scope, returns `kind: "exists"`.
|
|
41
|
+
*/
|
|
42
|
+
export async function saveProjectSkill(input: SaveSkillInput): Promise<SaveResult> {
|
|
43
|
+
const { workspaceRoot, name, description, body } = input;
|
|
44
|
+
|
|
45
|
+
if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
|
|
46
|
+
if (typeof description !== "string" || description.trim().length === 0) {
|
|
47
|
+
return { kind: "missing-field", field: "description" };
|
|
48
|
+
}
|
|
49
|
+
if (typeof body !== "string") {
|
|
50
|
+
return { kind: "missing-field", field: "body" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Conflict check across BOTH scopes — we don't want to shadow a
|
|
54
|
+
// user-scope skill with the same name (project would silently
|
|
55
|
+
// override it via the precedence rule).
|
|
56
|
+
const existing = await discoverSkills({ workspaceRoot });
|
|
57
|
+
if (existing.some((s) => s.name === name)) {
|
|
58
|
+
return { kind: "exists", name };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const finalPath = projectSkillPath(workspaceRoot, name);
|
|
62
|
+
const contents = formatSkillFile(description, body);
|
|
63
|
+
|
|
64
|
+
// Atomic + uniqueTmp: same-FS rename is atomic on POSIX so a
|
|
65
|
+
// partial write can never leave a half-baked SKILL.md visible to a
|
|
66
|
+
// concurrent reader. The uniqueTmp flag guards against leftover
|
|
67
|
+
// `.tmp` from a previous crashed run colliding with a new write.
|
|
68
|
+
try {
|
|
69
|
+
await writeFileAtomic(finalPath, contents, { uniqueTmp: true });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
log.error("skills", "save failed", { name, error: String(err) });
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { kind: "saved", path: finalPath };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type UpdateResult =
|
|
79
|
+
| { kind: "updated"; path: string }
|
|
80
|
+
| { kind: "invalid-slug"; slug: string }
|
|
81
|
+
| { kind: "missing-field"; field: "description" | "body" }
|
|
82
|
+
| { kind: "not-found"; name: string }
|
|
83
|
+
| { kind: "user-scope"; name: string };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Overwrite an existing project-scope SKILL.md. Refuses to touch
|
|
87
|
+
* user-scope skills and rejects names that don't exist.
|
|
88
|
+
*/
|
|
89
|
+
export async function updateProjectSkill(input: SaveSkillInput): Promise<UpdateResult> {
|
|
90
|
+
const { workspaceRoot, name, description, body } = input;
|
|
91
|
+
|
|
92
|
+
if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
|
|
93
|
+
if (typeof description !== "string" || description.trim().length === 0) {
|
|
94
|
+
return { kind: "missing-field", field: "description" };
|
|
95
|
+
}
|
|
96
|
+
if (typeof body !== "string") {
|
|
97
|
+
return { kind: "missing-field", field: "body" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const existing = await discoverSkills({ workspaceRoot });
|
|
101
|
+
const skill = existing.find((s) => s.name === name);
|
|
102
|
+
if (!skill) return { kind: "not-found", name };
|
|
103
|
+
if (skill.source === "user") return { kind: "user-scope", name };
|
|
104
|
+
|
|
105
|
+
const finalPath = projectSkillPath(workspaceRoot, name);
|
|
106
|
+
const contents = formatSkillFile(description, body);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await writeFileAtomic(finalPath, contents, { uniqueTmp: true });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log.error("skills", "update failed", { name, error: String(err) });
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { kind: "updated", path: finalPath };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface DeleteSkillInput {
|
|
119
|
+
workspaceRoot: string;
|
|
120
|
+
name: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type DeleteResult =
|
|
124
|
+
| { kind: "deleted"; name: string }
|
|
125
|
+
| { kind: "invalid-slug"; slug: string }
|
|
126
|
+
| { kind: "not-found"; name: string }
|
|
127
|
+
| { kind: "user-scope"; name: string };
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove a project-scope skill (SKILL.md + its containing folder).
|
|
131
|
+
* Refuses to touch the user scope even if a user skill with this
|
|
132
|
+
* name exists — protects against accidental ~/.claude mutation.
|
|
133
|
+
*/
|
|
134
|
+
export async function deleteProjectSkill(input: DeleteSkillInput): Promise<DeleteResult> {
|
|
135
|
+
const { workspaceRoot, name } = input;
|
|
136
|
+
|
|
137
|
+
if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
|
|
138
|
+
|
|
139
|
+
// Look up the skill's effective source via discovery — if the
|
|
140
|
+
// matching name is user-scope, we refuse.
|
|
141
|
+
const all = await discoverSkills({ workspaceRoot });
|
|
142
|
+
const skill = all.find((s) => s.name === name);
|
|
143
|
+
if (!skill) return { kind: "not-found", name };
|
|
144
|
+
if (skill.source === "user") return { kind: "user-scope", name };
|
|
145
|
+
|
|
146
|
+
const dir = projectSkillDir(workspaceRoot, name);
|
|
147
|
+
// Remove SKILL.md, then try to remove the directory if it's empty.
|
|
148
|
+
// If the user has dropped extra files alongside SKILL.md (e.g. a
|
|
149
|
+
// README, assets), rmdir() fails and we leave the directory in
|
|
150
|
+
// place — the skill itself (the SKILL.md) is gone either way.
|
|
151
|
+
try {
|
|
152
|
+
await unlink(projectSkillPath(workspaceRoot, name));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// ENOENT is fine — discovery may be stale. Anything else is
|
|
155
|
+
// surfaced so the caller knows the delete didn't fully work.
|
|
156
|
+
const error = err as { code?: string };
|
|
157
|
+
if (error.code !== "ENOENT") throw err;
|
|
158
|
+
}
|
|
159
|
+
await rmdir(dir).catch(() => {
|
|
160
|
+
// Dir may contain user-added files (e.g. the user dropped a
|
|
161
|
+
// README.md alongside SKILL.md). Don't fail in that case —
|
|
162
|
+
// the skill itself is gone.
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return { kind: "deleted", name };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Compose the final SKILL.md content. Body is trimmed of trailing
|
|
169
|
+
* whitespace; a final newline is always added. */
|
|
170
|
+
function formatSkillFile(description: string, body: string): string {
|
|
171
|
+
const escaped = escapeYamlScalar(description);
|
|
172
|
+
return `---\ndescription: ${escaped}\n---\n\n${body.trimEnd()}\n`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Escape a one-line string for use as a YAML scalar value. We
|
|
177
|
+
* stay defensive: if the value contains any character that could
|
|
178
|
+
* confuse the parser (`:`, `#`, `'`, `"`, leading whitespace), wrap
|
|
179
|
+
* it in double quotes and JSON-escape the inner content. Plain
|
|
180
|
+
* ASCII text passes through unchanged so the file stays readable.
|
|
181
|
+
*/
|
|
182
|
+
function escapeYamlScalar(value: string): string {
|
|
183
|
+
const oneLine = value.replace(/\r?\n/g, " ").trim();
|
|
184
|
+
const needsQuoting = /[:#'"\\[\]{}>|`*&!%@?]/.test(oneLine) || /^\s|\s$/.test(oneLine) || /^(true|false|null|~|yes|no|on|off)$/i.test(oneLine);
|
|
185
|
+
if (!needsQuoting) return oneLine;
|
|
186
|
+
// JSON.stringify gives us escapes for `\`, `"`, control chars in
|
|
187
|
+
// one shot — the result is also valid YAML when wrapped in `"..."`.
|
|
188
|
+
return JSON.stringify(oneLine);
|
|
189
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// arXiv auto-discovery (#469).
|
|
2
|
+
//
|
|
3
|
+
// Reads user interests from config/interests.json and automatically
|
|
4
|
+
// registers arXiv sources for keywords that don't already have one.
|
|
5
|
+
// Called from the pipeline's startup or on interest profile change.
|
|
6
|
+
//
|
|
7
|
+
// For each keyword (or group of related keywords), generates an
|
|
8
|
+
// arXiv query and registers it as a daily source. Existing arXiv
|
|
9
|
+
// sources are not duplicated.
|
|
10
|
+
|
|
11
|
+
import crypto from "crypto";
|
|
12
|
+
import { loadInterests } from "./interests.js";
|
|
13
|
+
import { listSources, writeSource } from "./registry.js";
|
|
14
|
+
import { sourcesRoot } from "./paths.js";
|
|
15
|
+
import { workspacePath } from "../paths.js";
|
|
16
|
+
import { log } from "../../system/logger/index.js";
|
|
17
|
+
import { slugify } from "../../utils/slug.js";
|
|
18
|
+
import type { Source } from "./types.js";
|
|
19
|
+
import fs from "fs";
|
|
20
|
+
|
|
21
|
+
// ── Constants ───────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const ARXIV_SLUG_PREFIX = "arxiv-auto-";
|
|
24
|
+
const MAX_AUTO_SOURCES = 10;
|
|
25
|
+
const DEFAULT_MAX_ITEMS = 20;
|
|
26
|
+
|
|
27
|
+
// ── Query building ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build an arXiv query string from a list of keywords.
|
|
31
|
+
* Searches in title and abstract fields. Double quotes in keywords
|
|
32
|
+
* are stripped (not escaped) so the arXiv query stays valid.
|
|
33
|
+
* Example: ["transformer", "attention"] → 'ti:"transformer" OR abs:"transformer" OR ti:"attention" OR abs:"attention"'
|
|
34
|
+
*/
|
|
35
|
+
export function buildArxivQuery(keywords: readonly string[]): string {
|
|
36
|
+
const terms = keywords.flatMap((kw) => {
|
|
37
|
+
const stripped = kw.replace(/"/g, "");
|
|
38
|
+
return [`ti:"${stripped}"`, `abs:"${stripped}"`];
|
|
39
|
+
});
|
|
40
|
+
return terms.join(" OR ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Generate a slug from keywords. Uses a short hash of all keywords
|
|
45
|
+
* to avoid collisions when chunks share the same leading words or
|
|
46
|
+
* when keywords are non-ASCII (CJK, etc.).
|
|
47
|
+
* Example: ["WebAssembly", "WASM"] → "arxiv-auto-webassembly-wasm-a1b2"
|
|
48
|
+
*/
|
|
49
|
+
export function keywordsToSlug(keywords: readonly string[]): string {
|
|
50
|
+
const latin = keywords
|
|
51
|
+
.map((kw) => slugify(kw, ""))
|
|
52
|
+
.filter((s) => s.length > 0)
|
|
53
|
+
.slice(0, 3)
|
|
54
|
+
.join("-");
|
|
55
|
+
// Short hash of ALL keywords ensures uniqueness even when the
|
|
56
|
+
// Latin portion is empty (non-ASCII) or identical across chunks.
|
|
57
|
+
const hash = crypto.createHash("sha256").update(keywords.join("|")).digest("hex").slice(0, 6);
|
|
58
|
+
const base = latin ? `${latin}-${hash}` : hash;
|
|
59
|
+
return `${ARXIV_SLUG_PREFIX}${base}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a human-readable title from keywords.
|
|
64
|
+
*/
|
|
65
|
+
function keywordsToTitle(keywords: readonly string[]): string {
|
|
66
|
+
return `arXiv: ${keywords.join(", ")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Discovery ───────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export interface DiscoveryResult {
|
|
72
|
+
registered: string[];
|
|
73
|
+
skipped: string[];
|
|
74
|
+
reason: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Discover and register arXiv sources based on user interests.
|
|
79
|
+
* Groups all keywords into a single arXiv query source.
|
|
80
|
+
* Skips if the source already exists.
|
|
81
|
+
*/
|
|
82
|
+
export async function discoverAndRegister(root?: string): Promise<DiscoveryResult> {
|
|
83
|
+
const base = root ?? workspacePath;
|
|
84
|
+
const profile = loadInterests(base);
|
|
85
|
+
if (!profile || profile.keywords.length === 0) {
|
|
86
|
+
return { registered: [], skipped: [], reason: "no keywords in interests" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Ensure sources directory exists
|
|
90
|
+
const dir = sourcesRoot(base);
|
|
91
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
const existing = await listSources(base);
|
|
94
|
+
const existingSlugs = new Set(existing.map((s) => s.slug));
|
|
95
|
+
|
|
96
|
+
const registered: string[] = [];
|
|
97
|
+
const skipped: string[] = [];
|
|
98
|
+
|
|
99
|
+
// Strategy: group keywords into chunks of ~5 for separate sources,
|
|
100
|
+
// or put them all in one if few enough
|
|
101
|
+
const chunks = chunkKeywords(profile.keywords, 5);
|
|
102
|
+
|
|
103
|
+
for (const chunk of chunks.slice(0, MAX_AUTO_SOURCES)) {
|
|
104
|
+
const slug = keywordsToSlug(chunk);
|
|
105
|
+
|
|
106
|
+
if (existingSlugs.has(slug)) {
|
|
107
|
+
skipped.push(slug);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const query = buildArxivQuery(chunk);
|
|
112
|
+
const source: Source = {
|
|
113
|
+
slug,
|
|
114
|
+
title: keywordsToTitle(chunk),
|
|
115
|
+
url: `https://arxiv.org/search/?query=${encodeURIComponent(chunk.join(" "))}`,
|
|
116
|
+
fetcherKind: "arxiv",
|
|
117
|
+
fetcherParams: {
|
|
118
|
+
arxiv_query: query,
|
|
119
|
+
arxiv_sort: "submittedDate",
|
|
120
|
+
arxiv_order: "descending",
|
|
121
|
+
},
|
|
122
|
+
schedule: "daily",
|
|
123
|
+
categories: profile.categories.length > 0 ? profile.categories : ["papers"],
|
|
124
|
+
maxItemsPerFetch: DEFAULT_MAX_ITEMS,
|
|
125
|
+
addedAt: new Date().toISOString(),
|
|
126
|
+
notes: `Auto-registered from interests.json keywords: ${chunk.join(", ")}`,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await writeSource(base, source);
|
|
131
|
+
registered.push(slug);
|
|
132
|
+
existingSlugs.add(slug);
|
|
133
|
+
log.info("arxiv-discovery", "registered arXiv source", { slug, query });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
log.warn("arxiv-discovery", "failed to register source", {
|
|
136
|
+
slug,
|
|
137
|
+
error: String(err),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { registered, skipped, reason: null };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function chunkKeywords(keywords: readonly string[], size: number): string[][] {
|
|
146
|
+
const chunks: string[][] = [];
|
|
147
|
+
for (let i = 0; i < keywords.length; i += size) {
|
|
148
|
+
chunks.push(keywords.slice(i, i + size) as string[]);
|
|
149
|
+
}
|
|
150
|
+
return chunks;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove auto-registered arXiv sources that no longer match
|
|
155
|
+
* any keyword in the current interests profile.
|
|
156
|
+
*/
|
|
157
|
+
export async function pruneStaleAutoSources(root?: string): Promise<string[]> {
|
|
158
|
+
const base = root ?? workspacePath;
|
|
159
|
+
const profile = loadInterests(base);
|
|
160
|
+
const existing = await listSources(base);
|
|
161
|
+
const autoSources = existing.filter((s) => s.slug.startsWith(ARXIV_SLUG_PREFIX));
|
|
162
|
+
|
|
163
|
+
if (autoSources.length === 0) return [];
|
|
164
|
+
|
|
165
|
+
// If no profile at all, prune everything auto-registered
|
|
166
|
+
const currentKeywords = profile ? new Set(profile.keywords.map((k) => k.toLowerCase())) : new Set<string>();
|
|
167
|
+
|
|
168
|
+
const pruned: string[] = [];
|
|
169
|
+
for (const source of autoSources) {
|
|
170
|
+
const notes = (source.notes ?? "").toLowerCase();
|
|
171
|
+
const hasMatch = [...currentKeywords].some((kw) => notes.includes(kw));
|
|
172
|
+
if (!hasMatch && currentKeywords.size > 0) {
|
|
173
|
+
// Keywords changed — this source is stale but don't delete,
|
|
174
|
+
// just log. User can manually remove via manageSource.
|
|
175
|
+
log.info("arxiv-discovery", "stale auto-source detected", {
|
|
176
|
+
slug: source.slug,
|
|
177
|
+
});
|
|
178
|
+
pruned.push(source.slug);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return pruned;
|
|
182
|
+
}
|