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,66 @@
|
|
|
1
|
+
// Pipeline planner — pure.
|
|
2
|
+
//
|
|
3
|
+
// Given the current source registry + per-source state + the
|
|
4
|
+
// schedule type being run (daily / hourly / weekly), decide
|
|
5
|
+
// which sources should fetch this cycle:
|
|
6
|
+
//
|
|
7
|
+
// 1. Schedule match — a daily run skips hourly sources and
|
|
8
|
+
// vice versa. "on-demand" sources are never picked up by
|
|
9
|
+
// any scheduled run.
|
|
10
|
+
// 2. Backoff respect — sources with a `nextAttemptAt` in the
|
|
11
|
+
// future are skipped until that time arrives, so a flapping
|
|
12
|
+
// source doesn't monopolize the rate limit.
|
|
13
|
+
//
|
|
14
|
+
// Separate module so tests can pin the filtering semantics
|
|
15
|
+
// without touching the rest of the pipeline.
|
|
16
|
+
|
|
17
|
+
import type { Source, SourceSchedule, SourceState } from "../types.js";
|
|
18
|
+
|
|
19
|
+
export interface PlanInput {
|
|
20
|
+
sources: readonly Source[];
|
|
21
|
+
statesBySlug: ReadonlyMap<string, SourceState>;
|
|
22
|
+
// Schedule type this run is handling. The caller (task-manager
|
|
23
|
+
// cron or the manual `rebuild` endpoint) knows which kind it is.
|
|
24
|
+
scheduleType: SourceSchedule;
|
|
25
|
+
// Wall-clock ms since epoch. Passed in (rather than calling
|
|
26
|
+
// Date.now() internally) so tests can drive a deterministic
|
|
27
|
+
// clock.
|
|
28
|
+
nowMs: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Sort key: slug, ascending. Deterministic ordering keeps the
|
|
32
|
+
// daily summary's item sequence stable across runs for the same
|
|
33
|
+
// input, which makes markdown diffs readable.
|
|
34
|
+
function bySlug(a: Source, b: Source): number {
|
|
35
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Returns the subset of sources eligible for this cycle. Pure.
|
|
39
|
+
export function planEligibleSources(input: PlanInput): Source[] {
|
|
40
|
+
const eligible: Source[] = [];
|
|
41
|
+
for (const source of input.sources) {
|
|
42
|
+
if (source.schedule !== input.scheduleType) continue;
|
|
43
|
+
if (!isWithinBackoff(input.statesBySlug.get(source.slug), input.nowMs)) {
|
|
44
|
+
eligible.push(source);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
eligible.sort(bySlug);
|
|
48
|
+
return eligible;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// True when the state indicates the source is STILL in backoff
|
|
52
|
+
// (so we should SKIP it). False means eligible to run now.
|
|
53
|
+
//
|
|
54
|
+
// - No state at all → run.
|
|
55
|
+
// - No nextAttemptAt → run.
|
|
56
|
+
// - nextAttemptAt unparseable → run (don't let a corrupt state
|
|
57
|
+
// file permanently lock out a source).
|
|
58
|
+
// - nextAttemptAt in the future → skip.
|
|
59
|
+
// - nextAttemptAt at or before now → run.
|
|
60
|
+
function isWithinBackoff(state: SourceState | undefined, nowMs: number): boolean {
|
|
61
|
+
if (!state) return false;
|
|
62
|
+
if (!state.nextAttemptAt) return false;
|
|
63
|
+
const ts = Date.parse(state.nextAttemptAt);
|
|
64
|
+
if (!Number.isFinite(ts)) return false;
|
|
65
|
+
return ts > nowMs;
|
|
66
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Daily-summary generator.
|
|
2
|
+
//
|
|
3
|
+
// Takes the cross-source-deduped list of new items and asks
|
|
4
|
+
// `claude` (haiku, budget-capped) to produce the human-readable
|
|
5
|
+
// daily brief markdown. The pipeline then pairs that markdown
|
|
6
|
+
// with a machine-readable JSON block (see write.ts) so the
|
|
7
|
+
// dashboard can consume item metadata without parsing markdown.
|
|
8
|
+
//
|
|
9
|
+
// Shape mirrors `chat-index/summarizer.ts` — same CLI flags, same
|
|
10
|
+
// "errors on STDOUT not stderr" handling, same injectable
|
|
11
|
+
// `SummarizeFn` so tests never invoke the real CLI.
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { ClaudeCliNotFoundError } from "../../journal/archivist.js";
|
|
16
|
+
import { formatSpawnFailure } from "../../../utils/spawn.js";
|
|
17
|
+
import { errorMessage } from "../../../utils/errors.js";
|
|
18
|
+
import type { SourceItem } from "../types.js";
|
|
19
|
+
import { CLI_SUBPROCESS_TIMEOUT_MS } from "../../../utils/time.js";
|
|
20
|
+
|
|
21
|
+
// A function that takes items and returns markdown. The
|
|
22
|
+
// production implementation spawns claude; tests pass a
|
|
23
|
+
// deterministic stub.
|
|
24
|
+
export type SummarizeFn = (items: readonly SourceItem[]) => Promise<string>;
|
|
25
|
+
|
|
26
|
+
// Wall-clock cap per summarize call. 5 minutes is plenty for a
|
|
27
|
+
// daily brief across a few dozen items; beyond that the call is
|
|
28
|
+
// almost certainly wedged.
|
|
29
|
+
export const DEFAULT_TIMEOUT_MS = CLI_SUBPROCESS_TIMEOUT_MS;
|
|
30
|
+
|
|
31
|
+
// Budget per summarize call. A daily brief is longer and more
|
|
32
|
+
// expensive than a classify call, so the cap is higher than the
|
|
33
|
+
// classifier's $0.05. $0.25 covers several hundred items
|
|
34
|
+
// comfortably.
|
|
35
|
+
const MAX_BUDGET_USD = 0.25;
|
|
36
|
+
|
|
37
|
+
const SYSTEM_PROMPT =
|
|
38
|
+
"You write a daily information brief from a JSON list of items. " +
|
|
39
|
+
"Group items by the `categories` field (one heading per category you see), " +
|
|
40
|
+
"sorted by the most items per category first; within each category, list newest-first by `publishedAt`. " +
|
|
41
|
+
"Use Markdown headings: `# Daily brief — YYYY-MM-DD` as the top heading, then `## <Category>` per group. " +
|
|
42
|
+
"Each item is one bullet: `- [title](url) — one-line summary`. " +
|
|
43
|
+
"Keep summaries under 140 characters. Prefer the item's own summary when present; otherwise paraphrase the title. " +
|
|
44
|
+
"Do NOT emit a JSON block, table of contents, or anything outside the brief itself — the caller appends machine-readable data separately. " +
|
|
45
|
+
"Output Markdown only — no code fences, no prose commentary.";
|
|
46
|
+
|
|
47
|
+
// Shape passed to claude. Kept deliberately compact so the
|
|
48
|
+
// prompt stays within budget even for a busy day: no `content`
|
|
49
|
+
// field (full body), just `summary` truncated to 200 chars.
|
|
50
|
+
interface PromptItem {
|
|
51
|
+
title: string;
|
|
52
|
+
url: string;
|
|
53
|
+
publishedAt: string;
|
|
54
|
+
categories: string[];
|
|
55
|
+
sourceSlug: string;
|
|
56
|
+
summary?: string;
|
|
57
|
+
severity?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build the user-prompt JSON body. Exported so tests can verify
|
|
61
|
+
// the exact shape the CLI sees, and so a future "generate a
|
|
62
|
+
// test brief without summarizing" workflow can use the same
|
|
63
|
+
// input format.
|
|
64
|
+
export function buildSummarizePromptBody(items: readonly SourceItem[], isoDate: string): string {
|
|
65
|
+
const compactItems: PromptItem[] = items.map((item) => {
|
|
66
|
+
const base: PromptItem = {
|
|
67
|
+
title: item.title,
|
|
68
|
+
url: item.url,
|
|
69
|
+
publishedAt: item.publishedAt,
|
|
70
|
+
categories: [...item.categories],
|
|
71
|
+
sourceSlug: item.sourceSlug,
|
|
72
|
+
};
|
|
73
|
+
if (item.summary) base.summary = item.summary.slice(0, 200);
|
|
74
|
+
if (item.severity) base.severity = item.severity;
|
|
75
|
+
return base;
|
|
76
|
+
});
|
|
77
|
+
return [`DATE: ${isoDate}`, "", "ITEMS (JSON):", JSON.stringify(compactItems, null, 2)].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Fallback markdown when there are zero new items today.
|
|
81
|
+
// Writing a file even on an empty day makes it clear the pipeline
|
|
82
|
+
// ran; dashboards can still read it and show "no new items".
|
|
83
|
+
export function buildEmptyDayMarkdown(isoDate: string): string {
|
|
84
|
+
return `# Daily brief — ${isoDate}\n\n_No new items today._\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Pure: parse the CLI envelope, surface structured errors,
|
|
88
|
+
// return the markdown body.
|
|
89
|
+
export function parseSummarizeOutput(stdout: string): string {
|
|
90
|
+
let parsed: {
|
|
91
|
+
is_error?: boolean;
|
|
92
|
+
result?: string;
|
|
93
|
+
errors?: unknown;
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(stdout.trim());
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new Error(`[sources/summarize] failed to parse claude json: ${errorMessage(err)}`);
|
|
99
|
+
}
|
|
100
|
+
if (parsed.is_error) {
|
|
101
|
+
const msg = Array.isArray(parsed.errors) && parsed.errors.length > 0 ? parsed.errors.join("; ") : (parsed.result ?? "unknown");
|
|
102
|
+
throw new Error(`[sources/summarize] claude error: ${msg}`);
|
|
103
|
+
}
|
|
104
|
+
const result = typeof parsed.result === "string" ? parsed.result : "";
|
|
105
|
+
if (!result) {
|
|
106
|
+
throw new Error("[sources/summarize] claude returned empty / missing result");
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- spawn layer --------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function spawnClaudeSummarize(userPrompt: string, timeoutMs: number): Promise<string> {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
// `--output-format json` returns a result envelope containing
|
|
116
|
+
// the model's text response as `.result` — we don't use
|
|
117
|
+
// `--json-schema` here because the model produces free-form
|
|
118
|
+
// markdown. Same "errors on stdout" handling as the
|
|
119
|
+
// classifier / chat-index summarizer.
|
|
120
|
+
const args = [
|
|
121
|
+
"--print",
|
|
122
|
+
"--no-session-persistence",
|
|
123
|
+
"--output-format",
|
|
124
|
+
"json",
|
|
125
|
+
"--model",
|
|
126
|
+
"haiku",
|
|
127
|
+
"--max-budget-usd",
|
|
128
|
+
String(MAX_BUDGET_USD),
|
|
129
|
+
"--system-prompt",
|
|
130
|
+
SYSTEM_PROMPT,
|
|
131
|
+
"-p",
|
|
132
|
+
userPrompt,
|
|
133
|
+
];
|
|
134
|
+
const proc = spawn("claude", args, {
|
|
135
|
+
cwd: tmpdir(),
|
|
136
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let stdout = "";
|
|
140
|
+
let stderr = "";
|
|
141
|
+
let settled = false;
|
|
142
|
+
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
proc.kill("SIGKILL");
|
|
147
|
+
reject(new Error(`[sources/summarize] claude timed out after ${timeoutMs}ms`));
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
|
|
150
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
151
|
+
stdout += chunk.toString();
|
|
152
|
+
});
|
|
153
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
154
|
+
stderr += chunk.toString();
|
|
155
|
+
});
|
|
156
|
+
proc.on("error", (err: Error & { code?: string }) => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (err.code === "ENOENT") {
|
|
161
|
+
reject(new ClaudeCliNotFoundError());
|
|
162
|
+
} else {
|
|
163
|
+
reject(err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
proc.on("close", (code) => {
|
|
167
|
+
if (settled) return;
|
|
168
|
+
settled = true;
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
if (code !== 0) {
|
|
171
|
+
reject(new Error(formatSpawnFailure("[sources/summarize]", code, stdout, stderr)));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
resolve(stdout);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Build the production SummarizeFn. `isoDate` is captured once
|
|
180
|
+
// per pipeline run so every call in that run uses the same date
|
|
181
|
+
// header (even if the run crosses midnight).
|
|
182
|
+
export function makeDefaultSummarize(isoDate: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): SummarizeFn {
|
|
183
|
+
return async (items) => {
|
|
184
|
+
if (items.length === 0) return buildEmptyDayMarkdown(isoDate);
|
|
185
|
+
const prompt = buildSummarizePromptBody(items, isoDate);
|
|
186
|
+
const stdout = await spawnClaudeSummarize(prompt, timeoutMs);
|
|
187
|
+
return parseSummarizeOutput(stdout);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Write-phase helpers for the source pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Two responsibilities (kept pure where possible, with small
|
|
4
|
+
// async wrappers for the filesystem side):
|
|
5
|
+
//
|
|
6
|
+
// 1. Compose the daily file content — the LLM-written markdown
|
|
7
|
+
// brief + a trailing fenced JSON block the dashboard reads
|
|
8
|
+
// without re-parsing markdown (#188 Q2).
|
|
9
|
+
// 2. Write the daily file + append every item to its
|
|
10
|
+
// source-specific monthly archive under
|
|
11
|
+
// `archive/<slug>/YYYY/MM.md` (#188 Q4).
|
|
12
|
+
|
|
13
|
+
import fsp from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { archivePath, dailyNewsPath } from "../paths.js";
|
|
16
|
+
import { errorMessage } from "../../../utils/errors.js";
|
|
17
|
+
import type { SourceItem } from "../types.js";
|
|
18
|
+
import { writeFileAtomic } from "../../../utils/files/index.js";
|
|
19
|
+
|
|
20
|
+
// --- JSON index --------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
// Compact shape of the JSON block appended at the bottom of each
|
|
23
|
+
// daily file. Mirrors what the dashboard (#143) expects:
|
|
24
|
+
// - itemCount: total items across all categories
|
|
25
|
+
// - byCategory: per-slug counts, sorted-by-count-desc for
|
|
26
|
+
// quick "which genre was hot today" read
|
|
27
|
+
// - items: the raw per-item metadata (no body, no summary) so
|
|
28
|
+
// the dashboard can render compact cards
|
|
29
|
+
export interface DailyJsonIndex {
|
|
30
|
+
itemCount: number;
|
|
31
|
+
byCategory: Record<string, number>;
|
|
32
|
+
items: Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
url: string;
|
|
36
|
+
publishedAt: string;
|
|
37
|
+
categories: string[];
|
|
38
|
+
sourceSlug: string;
|
|
39
|
+
severity?: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildDailyJsonIndex(items: readonly SourceItem[]): DailyJsonIndex {
|
|
44
|
+
const byCategory: Record<string, number> = {};
|
|
45
|
+
for (const item of items) {
|
|
46
|
+
for (const cat of item.categories) {
|
|
47
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
itemCount: items.length,
|
|
52
|
+
byCategory,
|
|
53
|
+
items: items.map((item) => ({
|
|
54
|
+
id: item.id,
|
|
55
|
+
title: item.title,
|
|
56
|
+
url: item.url,
|
|
57
|
+
publishedAt: item.publishedAt,
|
|
58
|
+
categories: [...item.categories],
|
|
59
|
+
sourceSlug: item.sourceSlug,
|
|
60
|
+
...(item.severity !== undefined && { severity: item.severity }),
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Assemble the full markdown file: claude's brief + a trailing
|
|
66
|
+
// ```json block with the structured index. Pure.
|
|
67
|
+
//
|
|
68
|
+
// `markdown` is the LLM output verbatim (it's supposed to end
|
|
69
|
+
// with a trailing newline already; we guard both cases). The
|
|
70
|
+
// JSON block always ends with a final newline so editors don't
|
|
71
|
+
// complain about "no newline at end of file".
|
|
72
|
+
export function assembleDailyFile(markdown: string, items: readonly SourceItem[]): string {
|
|
73
|
+
const trimmed = markdown.endsWith("\n") ? markdown.slice(0, -1) : markdown;
|
|
74
|
+
const index = buildDailyJsonIndex(items);
|
|
75
|
+
// Pretty-printed JSON — the daily file is meant to be
|
|
76
|
+
// readable in a text editor as much as machine-consumable.
|
|
77
|
+
const json = JSON.stringify(index, null, 2);
|
|
78
|
+
return `${trimmed}\n\n\`\`\`json\n${json}\n\`\`\`\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- daily file write ---------------------------------------------------
|
|
82
|
+
|
|
83
|
+
// Atomic write: stage to a sibling `.tmp` then rename. Crash
|
|
84
|
+
// mid-write can't leave a half-written daily file visible to
|
|
85
|
+
// downstream readers.
|
|
86
|
+
export async function writeDailyFile(workspaceRoot: string, isoDate: string, markdown: string, items: readonly SourceItem[]): Promise<string> {
|
|
87
|
+
const target = dailyNewsPath(workspaceRoot, isoDate);
|
|
88
|
+
await writeFileAtomic(target, assembleDailyFile(markdown, items));
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- per-source archive append -----------------------------------------
|
|
93
|
+
|
|
94
|
+
// Each item lands in its source's monthly archive file under
|
|
95
|
+
// `archive/<slug>/YYYY/MM.md`. Per-month bucket chosen in
|
|
96
|
+
// #188 Q4 — keeps single-year browsing doable without per-day
|
|
97
|
+
// explosion.
|
|
98
|
+
|
|
99
|
+
// Pure: render one item as the markdown block we append to the
|
|
100
|
+
// archive. Exported for tests; idempotent-safe so re-appending
|
|
101
|
+
// the same item produces the same bytes.
|
|
102
|
+
export function renderItemForArchive(item: SourceItem): string {
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
lines.push(`## ${item.title}`);
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push(`- **Published:** ${item.publishedAt}`);
|
|
107
|
+
lines.push(`- **Source:** ${item.sourceSlug}`);
|
|
108
|
+
lines.push(`- **URL:** ${item.url}`);
|
|
109
|
+
if (item.categories.length > 0) {
|
|
110
|
+
lines.push(`- **Categories:** ${item.categories.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
if (item.severity) {
|
|
113
|
+
lines.push(`- **Severity:** ${item.severity}`);
|
|
114
|
+
}
|
|
115
|
+
if (item.summary) {
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push(item.summary);
|
|
118
|
+
}
|
|
119
|
+
if (item.content && item.content !== item.summary) {
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push(item.content);
|
|
122
|
+
}
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push("---");
|
|
125
|
+
lines.push("");
|
|
126
|
+
return lines.join("\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ISO `publishedAt` → `YYYY-MM` used as the archive-month key.
|
|
130
|
+
// Malformed dates fall back to the caller-supplied default
|
|
131
|
+
// (typically the current YYYY-MM) so we don't drop items.
|
|
132
|
+
export function archiveMonthFor(isoPublishedAt: string, fallbackMonth: string): string {
|
|
133
|
+
const ts = Date.parse(isoPublishedAt);
|
|
134
|
+
if (!Number.isFinite(ts)) return fallbackMonth;
|
|
135
|
+
const d = new Date(ts);
|
|
136
|
+
const year = d.getUTCFullYear();
|
|
137
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
138
|
+
return `${year}-${String(month).padStart(2, "0")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Group items by (sourceSlug, YYYY-MM) so each destination file
|
|
142
|
+
// gets one append instead of many. Exported so the orchestrator
|
|
143
|
+
// can size the concurrency and tests can pin the bucketing logic.
|
|
144
|
+
export function groupItemsForArchive(items: readonly SourceItem[], fallbackMonth: string): Map<string, SourceItem[]> {
|
|
145
|
+
const groups = new Map<string, SourceItem[]>();
|
|
146
|
+
for (const item of items) {
|
|
147
|
+
const month = archiveMonthFor(item.publishedAt, fallbackMonth);
|
|
148
|
+
const key = `${item.sourceSlug}::${month}`;
|
|
149
|
+
const existing = groups.get(key);
|
|
150
|
+
if (existing) existing.push(item);
|
|
151
|
+
else groups.set(key, [item]);
|
|
152
|
+
}
|
|
153
|
+
return groups;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Append every item's rendered markdown block to the appropriate
|
|
157
|
+
// `archive/<slug>/YYYY/MM.md`. Idempotency is the caller's
|
|
158
|
+
// responsibility (deduping via `stableItemId` earlier in the
|
|
159
|
+
// pipeline) — this helper blindly appends.
|
|
160
|
+
//
|
|
161
|
+
// Errors on individual source groups are collected, not thrown,
|
|
162
|
+
// so one bad group can't lose the others. Returns the list of
|
|
163
|
+
// successfully-written archive paths.
|
|
164
|
+
export async function appendItemsToArchives(
|
|
165
|
+
workspaceRoot: string,
|
|
166
|
+
items: readonly SourceItem[],
|
|
167
|
+
fallbackMonth: string,
|
|
168
|
+
): Promise<{ writtenPaths: string[]; errors: string[] }> {
|
|
169
|
+
const writtenPaths: string[] = [];
|
|
170
|
+
const errors: string[] = [];
|
|
171
|
+
const groups = groupItemsForArchive(items, fallbackMonth);
|
|
172
|
+
for (const [key, groupItems] of groups) {
|
|
173
|
+
const [slug, month] = key.split("::");
|
|
174
|
+
try {
|
|
175
|
+
const target = archivePath(workspaceRoot, slug, month);
|
|
176
|
+
await fsp.mkdir(path.dirname(target), { recursive: true });
|
|
177
|
+
const body = groupItems.map(renderItemForArchive).join("");
|
|
178
|
+
await fsp.appendFile(target, body, "utf-8");
|
|
179
|
+
writtenPaths.push(target);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
errors.push(`[sources/archive] ${slug}/${month}: ${errorMessage(err)}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { writtenPaths, errors };
|
|
185
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Per-host rate limiter for outbound HTTP fetches.
|
|
2
|
+
//
|
|
3
|
+
// Design decision from #188 Q7: fetchers run parallel across hosts
|
|
4
|
+
// but serial per host. This module is the "serial per host" part.
|
|
5
|
+
// Two concurrent requests to the same hostname wait in FIFO order;
|
|
6
|
+
// different hostnames proceed independently.
|
|
7
|
+
//
|
|
8
|
+
// Mechanism:
|
|
9
|
+
//
|
|
10
|
+
// - Per-host chain: one `Promise<void>` per hostname. Each new
|
|
11
|
+
// request `await`s the previous promise, runs the work, then
|
|
12
|
+
// releases the next waiter.
|
|
13
|
+
// - Minimum delay: optional per-host floor between the end of one
|
|
14
|
+
// request and the start of the next. Defaults to
|
|
15
|
+
// DEFAULT_MIN_DELAY_MS to keep us well-behaved on cooperative
|
|
16
|
+
// hosts; robots.txt `Crawl-delay` callers pass their host's
|
|
17
|
+
// value.
|
|
18
|
+
// - Clock-injectable: the `now` + `sleep` deps make this testable
|
|
19
|
+
// without wall-clock waits.
|
|
20
|
+
//
|
|
21
|
+
// Pure in the sense that all state is captured in the limiter
|
|
22
|
+
// instance — no module-level globals. Tests can spin up a fresh
|
|
23
|
+
// limiter per case and fully observe the ordering.
|
|
24
|
+
|
|
25
|
+
import { ONE_SECOND_MS } from "../../utils/time.js";
|
|
26
|
+
|
|
27
|
+
// Seconds of quiet between requests to the same host when the
|
|
28
|
+
// caller doesn't specify an explicit `minDelayMs` for the host.
|
|
29
|
+
// One second is polite-default for public feeds.
|
|
30
|
+
export const DEFAULT_MIN_DELAY_MS = ONE_SECOND_MS;
|
|
31
|
+
|
|
32
|
+
export interface RateLimiterDeps {
|
|
33
|
+
// Returns current wall-clock milliseconds. `Date.now` in prod,
|
|
34
|
+
// tests inject a controllable counter.
|
|
35
|
+
now: () => number;
|
|
36
|
+
// Sleep for `ms` milliseconds. `setTimeout` wrapper in prod,
|
|
37
|
+
// tests inject a faketimer-backed implementation.
|
|
38
|
+
sleep: (ms: number) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function defaultRateLimiterDeps(): RateLimiterDeps {
|
|
42
|
+
return {
|
|
43
|
+
now: () => Date.now(),
|
|
44
|
+
sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface HostState {
|
|
49
|
+
// Tail of the per-host promise chain. New work waits for this,
|
|
50
|
+
// then replaces it with its own completion promise.
|
|
51
|
+
tail: Promise<void>;
|
|
52
|
+
// Wall-clock time the most-recent completed request returned,
|
|
53
|
+
// or `null` when no request has completed yet. Using `null`
|
|
54
|
+
// rather than 0 as the sentinel avoids ambiguity against fake
|
|
55
|
+
// test clocks that legitimately start at t=0.
|
|
56
|
+
lastFinishedAt: number | null;
|
|
57
|
+
// Number of tasks currently queued or in-flight for this host.
|
|
58
|
+
// Must hit zero before the host is eligible for eviction —
|
|
59
|
+
// otherwise evictIdle can delete a state with pending work,
|
|
60
|
+
// letting a later run() recreate a fresh chain and break serial
|
|
61
|
+
// ordering.
|
|
62
|
+
activeCount: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class HostRateLimiter {
|
|
66
|
+
private readonly hosts = new Map<string, HostState>();
|
|
67
|
+
private readonly deps: RateLimiterDeps;
|
|
68
|
+
|
|
69
|
+
constructor(deps: RateLimiterDeps = defaultRateLimiterDeps()) {
|
|
70
|
+
this.deps = deps;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Run `task` under the host's rate-limit slot. Resolves with the
|
|
74
|
+
// task's return value, or rejects with the task's error (without
|
|
75
|
+
// poisoning the queue — the next waiter still gets to run).
|
|
76
|
+
//
|
|
77
|
+
// `minDelayMs` is the minimum ms between the END of the previous
|
|
78
|
+
// request to this host and the START of this one. Defaults to
|
|
79
|
+
// DEFAULT_MIN_DELAY_MS.
|
|
80
|
+
run<T>(host: string, task: () => Promise<T>, minDelayMs: number = DEFAULT_MIN_DELAY_MS): Promise<T> {
|
|
81
|
+
const key = host.toLowerCase();
|
|
82
|
+
const state: HostState = this.hosts.get(key) ?? {
|
|
83
|
+
tail: Promise.resolve(),
|
|
84
|
+
lastFinishedAt: null,
|
|
85
|
+
activeCount: 0,
|
|
86
|
+
};
|
|
87
|
+
const prev = state.tail;
|
|
88
|
+
|
|
89
|
+
// Build the new tail: wait for prev, enforce delay, run task.
|
|
90
|
+
let resolveTail: () => void = () => {};
|
|
91
|
+
const newTail = new Promise<void>((resolve) => {
|
|
92
|
+
resolveTail = resolve;
|
|
93
|
+
});
|
|
94
|
+
state.tail = newTail;
|
|
95
|
+
state.activeCount++;
|
|
96
|
+
this.hosts.set(key, state);
|
|
97
|
+
|
|
98
|
+
return (async () => {
|
|
99
|
+
try {
|
|
100
|
+
await prev;
|
|
101
|
+
const wait = state.lastFinishedAt === null ? 0 : minDelayMs - (this.deps.now() - state.lastFinishedAt);
|
|
102
|
+
if (wait > 0) await this.deps.sleep(wait);
|
|
103
|
+
try {
|
|
104
|
+
return await task();
|
|
105
|
+
} finally {
|
|
106
|
+
// Mark finished time even on error so a flapping host
|
|
107
|
+
// doesn't get spammed with retries. Read state from the
|
|
108
|
+
// map (rather than the closure-captured variable) in
|
|
109
|
+
// case a parallel call has since updated it.
|
|
110
|
+
const fresh = this.hosts.get(key);
|
|
111
|
+
if (fresh) fresh.lastFinishedAt = this.deps.now();
|
|
112
|
+
}
|
|
113
|
+
} finally {
|
|
114
|
+
resolveTail();
|
|
115
|
+
const fresh = this.hosts.get(key);
|
|
116
|
+
if (fresh) fresh.activeCount--;
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Test / debug: how many hosts currently have a chain. Returns
|
|
122
|
+
// 0 when the limiter is fresh.
|
|
123
|
+
hostCount(): number {
|
|
124
|
+
return this.hosts.size;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Release internal state for hosts that have been idle longer
|
|
128
|
+
// than `idleMs`. Not strictly required for correctness (the
|
|
129
|
+
// map grows linearly with distinct hosts, which is bounded for
|
|
130
|
+
// any real workspace), but handy for long-lived processes.
|
|
131
|
+
evictIdle(idleMs: number): number {
|
|
132
|
+
const cutoff = this.deps.now() - idleMs;
|
|
133
|
+
let removed = 0;
|
|
134
|
+
for (const [key, state] of this.hosts) {
|
|
135
|
+
// Only evict states whose queue is empty. An idle
|
|
136
|
+
// `lastFinishedAt` alone isn't enough — if `tail` still has
|
|
137
|
+
// queued or in-flight work we'd delete live state and a
|
|
138
|
+
// later run() would recreate a fresh chain, breaking serial
|
|
139
|
+
// per-host ordering.
|
|
140
|
+
if (state.activeCount > 0) continue;
|
|
141
|
+
if (state.lastFinishedAt !== null && state.lastFinishedAt < cutoff) {
|
|
142
|
+
this.hosts.delete(key);
|
|
143
|
+
removed++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return removed;
|
|
147
|
+
}
|
|
148
|
+
}
|