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,71 @@
|
|
|
1
|
+
// "Which sessions are new or have changed since the last journal
|
|
2
|
+
// run?" — pure logic that takes in-memory representations of the
|
|
3
|
+
// current filesystem and the persisted state, and returns the list
|
|
4
|
+
// of session ids that need re-ingest.
|
|
5
|
+
//
|
|
6
|
+
// Extracted from the filesystem layer so tests can exercise it with
|
|
7
|
+
// hand-rolled inputs instead of mocking `fs`.
|
|
8
|
+
|
|
9
|
+
import type { JournalState, ProcessedSessionRecord } from "./state.js";
|
|
10
|
+
|
|
11
|
+
export interface SessionFileMeta {
|
|
12
|
+
// Session id (matches the .jsonl filename without extension).
|
|
13
|
+
id: string;
|
|
14
|
+
// mtime in ms since epoch. The only signal we use to detect
|
|
15
|
+
// appends — sessions don't have a version counter.
|
|
16
|
+
mtimeMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DirtySessionDecision {
|
|
20
|
+
dirty: string[];
|
|
21
|
+
// Sessions already in state whose files have vanished from disk.
|
|
22
|
+
// We keep them in the state record (no harm) but the caller may
|
|
23
|
+
// choose to prune them separately.
|
|
24
|
+
missing: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Core diff. Given the current directory listing and the persisted
|
|
28
|
+
// processed-sessions record, return:
|
|
29
|
+
// - `dirty`: sessions that were never seen, or whose mtime has
|
|
30
|
+
// advanced since we last ingested them, or whose mtime we don't
|
|
31
|
+
// have a record of (treat as dirty — safer to re-ingest than miss).
|
|
32
|
+
// - `missing`: sessions we had previously processed that no longer
|
|
33
|
+
// exist on disk. Not an error, just information.
|
|
34
|
+
//
|
|
35
|
+
// The caller may additionally exclude currently-active sessions
|
|
36
|
+
// (whose jsonl could be mid-write); that's a separate concern and
|
|
37
|
+
// kept out of the pure diff.
|
|
38
|
+
export function findDirtySessions(current: readonly SessionFileMeta[], processed: Record<string, ProcessedSessionRecord>): DirtySessionDecision {
|
|
39
|
+
const dirty: string[] = [];
|
|
40
|
+
const seenNow = new Set<string>();
|
|
41
|
+
|
|
42
|
+
for (const meta of current) {
|
|
43
|
+
seenNow.add(meta.id);
|
|
44
|
+
const prev = processed[meta.id];
|
|
45
|
+
if (!prev) {
|
|
46
|
+
dirty.push(meta.id);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (meta.mtimeMs > prev.lastMtimeMs) {
|
|
50
|
+
dirty.push(meta.id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const missing: string[] = [];
|
|
55
|
+
for (const id of Object.keys(processed)) {
|
|
56
|
+
if (!seenNow.has(id)) missing.push(id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { dirty, missing };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Produce the next processedSessions map after a successful ingest
|
|
63
|
+
// of the given dirty ids. Pure — doesn't mutate input. Sessions not
|
|
64
|
+
// in the dirty list keep their existing record.
|
|
65
|
+
export function applyProcessed(previous: JournalState["processedSessions"], justProcessed: readonly SessionFileMeta[]): JournalState["processedSessions"] {
|
|
66
|
+
const next: JournalState["processedSessions"] = { ...previous };
|
|
67
|
+
for (const meta of justProcessed) {
|
|
68
|
+
next[meta.id] = { lastMtimeMs: meta.mtimeMs };
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Public entry point for the workspace journal. The agent route
|
|
2
|
+
// calls `maybeRunJournal()` from its `finally` block — fire-and-
|
|
3
|
+
// forget. This module decides whether a pass is actually due, holds
|
|
4
|
+
// an in-process lock so concurrent sessions don't double-run,
|
|
5
|
+
// orchestrates daily + optimization passes, and rebuilds _index.md.
|
|
6
|
+
//
|
|
7
|
+
// All failures are caught and logged here; nothing ever bubbles
|
|
8
|
+
// back to the request handler.
|
|
9
|
+
|
|
10
|
+
import { workspacePath as defaultWorkspacePath } from "../workspace.js";
|
|
11
|
+
import {
|
|
12
|
+
writeJournalIndex,
|
|
13
|
+
listTopicSlugs as listTopicSlugsIO,
|
|
14
|
+
readTopicFile,
|
|
15
|
+
listDailyFiles as listDailyFilesIO,
|
|
16
|
+
countArchivedTopics as countArchivedIO,
|
|
17
|
+
} from "../../utils/files/journal-io.js";
|
|
18
|
+
import { readState, writeState, isDailyDue, isOptimizationDue } from "./state.js";
|
|
19
|
+
import { runDailyPass } from "./dailyPass.js";
|
|
20
|
+
import { runOptimizationPass } from "./optimizationPass.js";
|
|
21
|
+
import { buildIndexMarkdown, type IndexTopicEntry, type IndexDailyEntry } from "./indexFile.js";
|
|
22
|
+
import { runClaudeCli, ClaudeCliNotFoundError, type Summarize } from "./archivist.js";
|
|
23
|
+
import { extractFirstH1 } from "../../../src/utils/markdown/extractFirstH1.js";
|
|
24
|
+
import { log } from "../../system/logger/index.js";
|
|
25
|
+
|
|
26
|
+
export { extractFirstH1 };
|
|
27
|
+
|
|
28
|
+
// Module-level lock. A boolean is enough for the single-process
|
|
29
|
+
// single-user MulmoClaude server; if two sessions finish at the
|
|
30
|
+
// same instant, the second call returns immediately.
|
|
31
|
+
let running = false;
|
|
32
|
+
|
|
33
|
+
// Once we hit ENOENT on the `claude` CLI we disable the journal
|
|
34
|
+
// for the rest of the server lifetime to avoid spamming warnings
|
|
35
|
+
// on every session-end. Reset on server restart.
|
|
36
|
+
let disabled = false;
|
|
37
|
+
|
|
38
|
+
// The agent route calls this as `maybeRunJournal().catch(...)`.
|
|
39
|
+
export interface MaybeRunJournalOptions {
|
|
40
|
+
summarize?: Summarize;
|
|
41
|
+
workspaceRoot?: string;
|
|
42
|
+
activeSessionIds?: ReadonlySet<string>;
|
|
43
|
+
// Skip the interval check and run both passes unconditionally.
|
|
44
|
+
// Useful for debugging / CLI-driven manual runs — the feature's
|
|
45
|
+
// disable flags (claude CLI missing, in-process lock) still apply.
|
|
46
|
+
force?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Everything inside swallows its own errors so the promise never
|
|
50
|
+
// rejects in practice, but we still attach a catch at the call
|
|
51
|
+
// site defensively.
|
|
52
|
+
export async function maybeRunJournal(opts: MaybeRunJournalOptions = {}): Promise<void> {
|
|
53
|
+
if (disabled) return;
|
|
54
|
+
if (running) return;
|
|
55
|
+
running = true;
|
|
56
|
+
try {
|
|
57
|
+
await runJournalPass(opts);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof ClaudeCliNotFoundError) {
|
|
60
|
+
disabled = true;
|
|
61
|
+
log.warn("journal", err.message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
log.warn("journal", "unexpected failure, continuing", {
|
|
65
|
+
error: String(err),
|
|
66
|
+
});
|
|
67
|
+
} finally {
|
|
68
|
+
running = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runJournalPass(opts: MaybeRunJournalOptions): Promise<void> {
|
|
73
|
+
const workspaceRoot = opts.workspaceRoot ?? defaultWorkspacePath;
|
|
74
|
+
const summarize = opts.summarize ?? runClaudeCli;
|
|
75
|
+
const activeSessionIds = opts.activeSessionIds ?? new Set<string>();
|
|
76
|
+
|
|
77
|
+
const state = await readState(workspaceRoot);
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
|
|
80
|
+
// `force: true` bypasses the interval gate entirely so debug /
|
|
81
|
+
// startup flows can trigger a full pass even when nothing is
|
|
82
|
+
// technically due.
|
|
83
|
+
const daily = opts.force === true || isDailyDue(state, now);
|
|
84
|
+
const optimize = opts.force === true || isOptimizationDue(state, now);
|
|
85
|
+
if (!daily && !optimize) return;
|
|
86
|
+
if (opts.force === true) {
|
|
87
|
+
log.info("journal", "force-run: skipping interval gates");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let nextState = state;
|
|
91
|
+
|
|
92
|
+
if (daily) {
|
|
93
|
+
log.info("journal", "running daily pass");
|
|
94
|
+
const { nextState: afterDaily, result } = await runDailyPass(nextState, {
|
|
95
|
+
workspaceRoot,
|
|
96
|
+
summarize,
|
|
97
|
+
activeSessionIds,
|
|
98
|
+
});
|
|
99
|
+
// Only advance lastDailyRunAt when no days were skipped —
|
|
100
|
+
// otherwise we'd wait a full interval before retrying a failed
|
|
101
|
+
// day, letting transient archivist failures silently lose events.
|
|
102
|
+
nextState = {
|
|
103
|
+
...afterDaily,
|
|
104
|
+
...(result.skipped.length === 0 && {
|
|
105
|
+
lastDailyRunAt: new Date(now).toISOString(),
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
log.info("journal", "daily pass done", {
|
|
109
|
+
sessions: result.sessionsIngested.length,
|
|
110
|
+
days: result.daysTouched.length,
|
|
111
|
+
topicsCreated: result.topicsCreated.length,
|
|
112
|
+
topicsUpdated: result.topicsUpdated.length,
|
|
113
|
+
daysSkipped: result.skipped.length,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (optimize) {
|
|
118
|
+
log.info("journal", "running optimization pass");
|
|
119
|
+
const { nextState: afterOpt, result } = await runOptimizationPass(nextState, { workspaceRoot, summarize });
|
|
120
|
+
// Same rule as daily: only advance the timestamp when the pass
|
|
121
|
+
// actually ran to completion. A "skipped: too few topics" case
|
|
122
|
+
// is still considered successful — there was simply nothing to
|
|
123
|
+
// do — and we allow it to bump so we don't re-check on every
|
|
124
|
+
// session-end.
|
|
125
|
+
const optimizationSucceeded = !result.skipped || result.skippedReason === "fewer than 2 topics";
|
|
126
|
+
nextState = {
|
|
127
|
+
...afterOpt,
|
|
128
|
+
...(optimizationSucceeded && {
|
|
129
|
+
lastOptimizationRunAt: new Date(now).toISOString(),
|
|
130
|
+
}),
|
|
131
|
+
};
|
|
132
|
+
if (result.skipped) {
|
|
133
|
+
log.info("journal", "optimization pass skipped", {
|
|
134
|
+
reason: result.skippedReason,
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
log.info("journal", "optimization pass done", {
|
|
138
|
+
merged: result.mergedSlugs.length,
|
|
139
|
+
archived: result.archivedSlugs.length,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await rebuildIndex(workspaceRoot);
|
|
145
|
+
await writeState(workspaceRoot, nextState);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- Index rebuild -------------------------------------------------
|
|
149
|
+
|
|
150
|
+
async function rebuildIndex(workspaceRoot: string): Promise<void> {
|
|
151
|
+
const topics = await walkTopics(workspaceRoot);
|
|
152
|
+
const dailyEntries = await listDailyFilesIO(workspaceRoot);
|
|
153
|
+
const days: IndexDailyEntry[] = dailyEntries.map((e) => ({
|
|
154
|
+
date: `${e.year}-${e.month}-${e.day}`,
|
|
155
|
+
}));
|
|
156
|
+
const archivedCount = await countArchivedIO(workspaceRoot);
|
|
157
|
+
const md = buildIndexMarkdown({
|
|
158
|
+
topics,
|
|
159
|
+
days,
|
|
160
|
+
archivedTopicCount: archivedCount,
|
|
161
|
+
builtAtIso: new Date().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
await writeJournalIndex(md, workspaceRoot);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function walkTopics(workspaceRoot: string): Promise<IndexTopicEntry[]> {
|
|
167
|
+
const slugs = await listTopicSlugsIO(workspaceRoot);
|
|
168
|
+
const out: IndexTopicEntry[] = [];
|
|
169
|
+
for (const slug of slugs) {
|
|
170
|
+
const content = await readTopicFile(slug, workspaceRoot);
|
|
171
|
+
out.push({
|
|
172
|
+
slug,
|
|
173
|
+
title: content ? (extractFirstH1(content) ?? undefined) : undefined,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const DAY_FILE_PATTERN = /^(\d{2})\.md$/;
|
|
180
|
+
|
|
181
|
+
// Pure: returns the two-digit day if `name` matches `DD.md`, else null.
|
|
182
|
+
export function parseDailyFilename(name: string): string | null {
|
|
183
|
+
const match = DAY_FILE_PATTERN.exec(name);
|
|
184
|
+
return match ? (match[1] ?? null) : null;
|
|
185
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Pure builder for summaries/_index.md. Takes in-memory listings of
|
|
2
|
+
// the journal's current topic / daily files and returns the full
|
|
3
|
+
// markdown for the index. All filesystem walking happens in the
|
|
4
|
+
// caller; this function is deterministic and easy to snapshot-test.
|
|
5
|
+
|
|
6
|
+
import { isoDateOnly } from "../../utils/date.js";
|
|
7
|
+
|
|
8
|
+
export interface IndexTopicEntry {
|
|
9
|
+
// Filesystem slug (matches topics/<slug>.md).
|
|
10
|
+
slug: string;
|
|
11
|
+
// Optional human-readable title extracted from the topic file's
|
|
12
|
+
// first H1 heading. Falls back to `slug` if absent so the index
|
|
13
|
+
// row always reads sensibly.
|
|
14
|
+
title?: string;
|
|
15
|
+
// ISO timestamp of the last write to the topic file. Rendered
|
|
16
|
+
// for "stale topic" visibility.
|
|
17
|
+
lastUpdatedIso?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IndexDailyEntry {
|
|
21
|
+
// YYYY-MM-DD in local time. Matches the folder layout.
|
|
22
|
+
date: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IndexInputs {
|
|
26
|
+
topics: readonly IndexTopicEntry[];
|
|
27
|
+
days: readonly IndexDailyEntry[];
|
|
28
|
+
archivedTopicCount: number;
|
|
29
|
+
builtAtIso: string;
|
|
30
|
+
// How many "Recent days" rows to list before collapsing the
|
|
31
|
+
// remainder. The full listing still lives under daily/ on disk.
|
|
32
|
+
maxRecentDays?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_MAX_RECENT_DAYS = 14;
|
|
36
|
+
|
|
37
|
+
export function buildIndexMarkdown(input: IndexInputs): string {
|
|
38
|
+
const maxRecent = input.maxRecentDays ?? DEFAULT_MAX_RECENT_DAYS;
|
|
39
|
+
return [
|
|
40
|
+
"# Workspace Journal",
|
|
41
|
+
"",
|
|
42
|
+
`*Last updated: ${input.builtAtIso}*`,
|
|
43
|
+
"",
|
|
44
|
+
...renderTopicsSection(input.topics),
|
|
45
|
+
"",
|
|
46
|
+
...renderRecentDaysSection(input.days, maxRecent),
|
|
47
|
+
"",
|
|
48
|
+
...renderArchiveSection(input.archivedTopicCount),
|
|
49
|
+
"",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function renderTopicsSection(topics: readonly IndexTopicEntry[]): string[] {
|
|
54
|
+
const lines: string[] = ["## Topics", ""];
|
|
55
|
+
if (topics.length === 0) {
|
|
56
|
+
lines.push("_No topics yet._");
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
// Newest-first by last update (topics with no timestamp sort
|
|
60
|
+
// last, ordered alphabetically among themselves for stability).
|
|
61
|
+
const sorted = [...topics].sort(compareTopicsNewestFirst);
|
|
62
|
+
for (const t of sorted) {
|
|
63
|
+
lines.push(renderTopicRow(t));
|
|
64
|
+
}
|
|
65
|
+
return lines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderRecentDaysSection(days: readonly IndexDailyEntry[], maxRecent: number): string[] {
|
|
69
|
+
const lines: string[] = ["## Recent days", ""];
|
|
70
|
+
if (days.length === 0) {
|
|
71
|
+
lines.push("_No daily entries yet._");
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
// Newest-first by date string (YYYY-MM-DD sorts lexically).
|
|
75
|
+
const sorted = [...days].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
|
76
|
+
const head = sorted.slice(0, maxRecent);
|
|
77
|
+
for (const d of head) {
|
|
78
|
+
lines.push(renderDailyRow(d));
|
|
79
|
+
}
|
|
80
|
+
const rest = sorted.length - head.length;
|
|
81
|
+
if (rest > 0) {
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(`_…and ${rest} earlier day${rest === 1 ? "" : "s"}._`);
|
|
84
|
+
}
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renderArchiveSection(archivedTopicCount: number): string[] {
|
|
89
|
+
const lines: string[] = ["## Archive", ""];
|
|
90
|
+
if (archivedTopicCount === 0) {
|
|
91
|
+
lines.push("_No archived topics._");
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
const noun = archivedTopicCount === 1 ? "archived topic" : "archived topics";
|
|
95
|
+
lines.push(`- [Archived topics](archive/topics/) — ${archivedTopicCount} ${noun}`);
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parse an ISO timestamp into a numeric sort key. Invalid or missing
|
|
100
|
+
// timestamps get -Infinity so they sort to the bottom (oldest).
|
|
101
|
+
function topicSortKey(entry: IndexTopicEntry): number {
|
|
102
|
+
if (!entry.lastUpdatedIso) return -Infinity;
|
|
103
|
+
const ms = Date.parse(entry.lastUpdatedIso);
|
|
104
|
+
return Number.isNaN(ms) ? -Infinity : ms;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function compareBySlug(a: IndexTopicEntry, b: IndexTopicEntry): number {
|
|
108
|
+
if (a.slug < b.slug) return -1;
|
|
109
|
+
if (a.slug > b.slug) return 1;
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function compareTopicsNewestFirst(a: IndexTopicEntry, b: IndexTopicEntry): number {
|
|
114
|
+
const ak = topicSortKey(a);
|
|
115
|
+
const bk = topicSortKey(b);
|
|
116
|
+
// Both valid timestamps → compare numerically.
|
|
117
|
+
// One or both invalid (-Infinity) → valid wins; if both invalid,
|
|
118
|
+
// fall through to the slug tie-breaker.
|
|
119
|
+
const aValid = Number.isFinite(ak);
|
|
120
|
+
const bValid = Number.isFinite(bk);
|
|
121
|
+
if (aValid && bValid && bk !== ak) return bk - ak;
|
|
122
|
+
if (aValid !== bValid) return aValid ? -1 : 1;
|
|
123
|
+
// Tie-break on slug for determinism.
|
|
124
|
+
return compareBySlug(a, b);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function renderTopicRow(t: IndexTopicEntry): string {
|
|
128
|
+
const label = t.title && t.title.trim().length > 0 ? t.title : t.slug;
|
|
129
|
+
const stamp = t.lastUpdatedIso ? ` — updated ${isoDateOnly(t.lastUpdatedIso)}` : "";
|
|
130
|
+
return `- [${label}](topics/${t.slug}.md)${stamp}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderDailyRow(d: IndexDailyEntry): string {
|
|
134
|
+
const [year, month, day] = d.date.split("-");
|
|
135
|
+
return `- [${d.date}](daily/${year}/${month}/${day}.md)`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Extracts durable user facts from chat session excerpts and
|
|
2
|
+
// appends them to memory.md. Called at the end of the journal
|
|
3
|
+
// daily pass so new facts are picked up even if the agent didn't
|
|
4
|
+
// write them during the conversation.
|
|
5
|
+
//
|
|
6
|
+
// Only appends facts that aren't already in memory.md — the LLM
|
|
7
|
+
// receives the current memory content as context and is instructed
|
|
8
|
+
// to return ONLY new facts.
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync } from "fs";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { WORKSPACE_FILES } from "../paths.js";
|
|
13
|
+
import { writeFileAtomic } from "../../utils/files/atomic.js";
|
|
14
|
+
import { log } from "../../system/logger/index.js";
|
|
15
|
+
import { ClaudeCliNotFoundError } from "./archivist.js";
|
|
16
|
+
|
|
17
|
+
const EXTRACTION_SYSTEM_PROMPT = `You are a personal-fact extractor. Given a batch of chat excerpts between a user and an AI assistant, extract ONLY durable facts about the USER — things that would still be true next week.
|
|
18
|
+
|
|
19
|
+
Categories to look for:
|
|
20
|
+
- Food preferences (likes, dislikes, allergies, diet)
|
|
21
|
+
- Daily routines & habits (exercise, hobbies, recurring activities)
|
|
22
|
+
- Possessions (car, devices, tools)
|
|
23
|
+
- Family & pets (members, names, ages)
|
|
24
|
+
- Location (city, commute, travel patterns)
|
|
25
|
+
- Interests & hobbies (topics they follow, activities)
|
|
26
|
+
- Schedule patterns (weekly meetings, monthly tasks)
|
|
27
|
+
- Health (conditions, habits)
|
|
28
|
+
- Work (job, role, company, work style)
|
|
29
|
+
- Coding preferences (tools, conventions, style preferences)
|
|
30
|
+
- Communication style (language, verbosity, formality)
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
- Extract ONLY what the user explicitly stated — never infer or guess.
|
|
34
|
+
- Each fact should be one concise bullet point.
|
|
35
|
+
- If the user corrected a previous fact, output the corrected version only.
|
|
36
|
+
- Do NOT extract facts about the AI, the app, or technical implementation details.
|
|
37
|
+
- Do NOT extract ephemeral information (today's weather, a specific bug being debugged).
|
|
38
|
+
- Output ONLY the bullet points, one per line, prefixed with "- ". No headers, no categories, no explanation.
|
|
39
|
+
- If there are no new user facts, output exactly: NONE`;
|
|
40
|
+
|
|
41
|
+
export interface MemoryExtractionDeps {
|
|
42
|
+
workspaceRoot: string;
|
|
43
|
+
/** The excerpts from today's dirty sessions, concatenated. */
|
|
44
|
+
excerpts: string;
|
|
45
|
+
/** Spawns claude CLI and returns stdout. Injectable for tests. */
|
|
46
|
+
summarize: (systemPrompt: string, userPrompt: string) => Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract new user facts from chat excerpts and append to memory.md.
|
|
51
|
+
* Returns the number of facts appended (0 if none found or on error).
|
|
52
|
+
*/
|
|
53
|
+
export async function extractAndAppendMemory(deps: MemoryExtractionDeps): Promise<number> {
|
|
54
|
+
const memoryPath = path.join(deps.workspaceRoot, WORKSPACE_FILES.memory);
|
|
55
|
+
const existingMemory = existsSync(memoryPath) ? readFileSync(memoryPath, "utf-8") : "";
|
|
56
|
+
|
|
57
|
+
const userPrompt = buildUserPrompt(existingMemory, deps.excerpts);
|
|
58
|
+
let raw: string;
|
|
59
|
+
try {
|
|
60
|
+
raw = await deps.summarize(EXTRACTION_SYSTEM_PROMPT, userPrompt);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof ClaudeCliNotFoundError) throw err;
|
|
63
|
+
log.warn("memory-extractor", "LLM call failed", {
|
|
64
|
+
error: String(err),
|
|
65
|
+
});
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const newFacts = parseExtractedFacts(raw);
|
|
70
|
+
if (newFacts.length === 0) return 0;
|
|
71
|
+
|
|
72
|
+
const factsToAppend = filterNewFacts(existingMemory, newFacts);
|
|
73
|
+
if (factsToAppend.length === 0) return 0;
|
|
74
|
+
|
|
75
|
+
const updatedContent = appendFacts(existingMemory, factsToAppend);
|
|
76
|
+
await writeFileAtomic(memoryPath, updatedContent);
|
|
77
|
+
log.info("memory-extractor", "appended new facts", {
|
|
78
|
+
count: factsToAppend.length,
|
|
79
|
+
});
|
|
80
|
+
return factsToAppend.length;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Build the user prompt with existing memory + new excerpts. */
|
|
84
|
+
export function buildUserPrompt(existingMemory: string, excerpts: string): string {
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
if (existingMemory.trim()) {
|
|
87
|
+
parts.push("## Already known (do NOT repeat these):\n\n" + existingMemory);
|
|
88
|
+
}
|
|
89
|
+
parts.push("## New chat excerpts:\n\n" + excerpts);
|
|
90
|
+
parts.push("\nExtract any NEW user facts not already in the 'Already known' section above. If none, output: NONE");
|
|
91
|
+
return parts.join("\n\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse LLM output into a list of fact strings. */
|
|
95
|
+
export function parseExtractedFacts(raw: string): string[] {
|
|
96
|
+
const trimmed = raw.trim();
|
|
97
|
+
if (trimmed === "NONE" || trimmed === "") return [];
|
|
98
|
+
return trimmed
|
|
99
|
+
.split("\n")
|
|
100
|
+
.map((line) => line.trim())
|
|
101
|
+
.filter((line) => line.startsWith("- "))
|
|
102
|
+
.filter((line) => line.length > 3);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeFact(fact: string): string {
|
|
106
|
+
return fact.replace(/^- /, "").trim().toLowerCase();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Remove facts that already exist in the current memory content. */
|
|
110
|
+
export function filterNewFacts(existingMemory: string, facts: readonly string[]): string[] {
|
|
111
|
+
const seen = new Set(parseExtractedFacts(existingMemory).map(normalizeFact));
|
|
112
|
+
const out: string[] = [];
|
|
113
|
+
for (const fact of facts) {
|
|
114
|
+
const key = normalizeFact(fact);
|
|
115
|
+
if (seen.has(key)) continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
out.push(fact);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Append facts to existing memory content. */
|
|
123
|
+
export function appendFacts(existing: string, facts: string[]): string {
|
|
124
|
+
const trimmed = existing.trimEnd();
|
|
125
|
+
const factsBlock = facts.join("\n");
|
|
126
|
+
if (!trimmed) {
|
|
127
|
+
return `# Memory\n\nDistilled facts about you and your work.\n\n${factsBlock}\n`;
|
|
128
|
+
}
|
|
129
|
+
return `${trimmed}\n${factsBlock}\n`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Weekly-ish topic optimization pass: merge near-duplicates, move
|
|
2
|
+
// stale topics into archive/. Separate file from dailyPass so the
|
|
3
|
+
// two can evolve independently and so the optimizer stays opt-in
|
|
4
|
+
// from the top-level runner.
|
|
5
|
+
|
|
6
|
+
import { workspacePath as defaultWorkspacePath } from "../workspace.js";
|
|
7
|
+
import { writeTopicFile, readAllTopicFiles, archiveTopic } from "../../utils/files/journal-io.js";
|
|
8
|
+
import {
|
|
9
|
+
type Summarize,
|
|
10
|
+
type OptimizationTopicSnapshot,
|
|
11
|
+
OPTIMIZATION_SYSTEM_PROMPT,
|
|
12
|
+
buildOptimizationUserPrompt,
|
|
13
|
+
extractJsonObject,
|
|
14
|
+
isOptimizationOutput,
|
|
15
|
+
ClaudeCliNotFoundError,
|
|
16
|
+
} from "./archivist.js";
|
|
17
|
+
import { slugify } from "./paths.js";
|
|
18
|
+
import type { JournalState } from "./state.js";
|
|
19
|
+
import { log } from "../../system/logger/index.js";
|
|
20
|
+
|
|
21
|
+
// How many characters of each topic file we hand to the optimizer.
|
|
22
|
+
// Enough to judge duplication without blowing up the prompt.
|
|
23
|
+
const OPTIMIZER_HEAD_CHARS = 500;
|
|
24
|
+
|
|
25
|
+
export interface OptimizationPassDeps {
|
|
26
|
+
workspaceRoot?: string;
|
|
27
|
+
summarize: Summarize;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OptimizationPassResult {
|
|
31
|
+
mergedSlugs: string[];
|
|
32
|
+
archivedSlugs: string[];
|
|
33
|
+
skipped: boolean;
|
|
34
|
+
skippedReason?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Pure planner: turns the optimizer's raw merge instructions into a
|
|
38
|
+
// concrete list of slug-level operations. Empty merges (where every
|
|
39
|
+
// source resolves to the merge target itself) are dropped, and slugs
|
|
40
|
+
// are normalized via slugify so the I/O layer never has to.
|
|
41
|
+
export interface MergePlanItem {
|
|
42
|
+
intoSlug: string;
|
|
43
|
+
fromSlugs: string[];
|
|
44
|
+
newContent: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RawMerge {
|
|
48
|
+
into: string;
|
|
49
|
+
from: string[];
|
|
50
|
+
newContent: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function planMerges(merges: readonly RawMerge[]): MergePlanItem[] {
|
|
54
|
+
const plans: MergePlanItem[] = [];
|
|
55
|
+
for (const merge of merges) {
|
|
56
|
+
const intoSlug = slugify(merge.into);
|
|
57
|
+
const fromSlugs = merge.from.map(slugify).filter((s) => s !== intoSlug);
|
|
58
|
+
if (fromSlugs.length === 0) continue;
|
|
59
|
+
plans.push({ intoSlug, fromSlugs, newContent: merge.newContent });
|
|
60
|
+
}
|
|
61
|
+
return plans;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pure transform: returns the next JournalState with any slug in
|
|
65
|
+
// `removed` filtered out of knownTopics.
|
|
66
|
+
export function applyRemovedTopics(state: JournalState, removed: ReadonlySet<string>): JournalState {
|
|
67
|
+
return {
|
|
68
|
+
...state,
|
|
69
|
+
knownTopics: state.knownTopics.filter((t) => !removed.has(t)),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function executeMergePlans(workspaceRoot: string, plans: MergePlanItem[], removed: Set<string>, mergedSlugs: string[]): Promise<void> {
|
|
74
|
+
for (const plan of plans) {
|
|
75
|
+
await writeTopicFile(plan.intoSlug, plan.newContent, workspaceRoot);
|
|
76
|
+
for (const src of plan.fromSlugs) {
|
|
77
|
+
// Only record the merge as successful if the source file
|
|
78
|
+
// actually moved. If archiveTopic fails (missing file, IO
|
|
79
|
+
// error) we leave the source out of the removed set so the
|
|
80
|
+
// in-memory knownTopics state stays accurate.
|
|
81
|
+
if (!(await archiveTopic(src, workspaceRoot))) continue;
|
|
82
|
+
removed.add(src);
|
|
83
|
+
mergedSlugs.push(src);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function executeArchives(workspaceRoot: string, rawSlugs: readonly string[], removed: Set<string>, archivedSlugs: string[]): Promise<void> {
|
|
89
|
+
for (const raw of rawSlugs) {
|
|
90
|
+
const slug = slugify(raw);
|
|
91
|
+
if (removed.has(slug)) continue;
|
|
92
|
+
if (!(await archiveTopic(slug, workspaceRoot))) continue;
|
|
93
|
+
removed.add(slug);
|
|
94
|
+
archivedSlugs.push(slug);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runOptimizationPass(
|
|
99
|
+
state: JournalState,
|
|
100
|
+
deps: OptimizationPassDeps,
|
|
101
|
+
): Promise<{ nextState: JournalState; result: OptimizationPassResult }> {
|
|
102
|
+
const workspaceRoot = deps.workspaceRoot ?? defaultWorkspacePath;
|
|
103
|
+
const result: OptimizationPassResult = {
|
|
104
|
+
mergedSlugs: [],
|
|
105
|
+
archivedSlugs: [],
|
|
106
|
+
skipped: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const topics = await loadTopicHeads(workspaceRoot);
|
|
110
|
+
if (topics.length < 2) {
|
|
111
|
+
// Nothing to optimise — need at least 2 topics for a merge to
|
|
112
|
+
// be meaningful, and archiving a single topic would leave an
|
|
113
|
+
// empty journal which feels wrong.
|
|
114
|
+
result.skipped = true;
|
|
115
|
+
result.skippedReason = "fewer than 2 topics";
|
|
116
|
+
return { nextState: { ...state }, result };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let raw: string;
|
|
120
|
+
try {
|
|
121
|
+
raw = await deps.summarize(OPTIMIZATION_SYSTEM_PROMPT, buildOptimizationUserPrompt({ topics }));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof ClaudeCliNotFoundError) throw err;
|
|
124
|
+
log.warn("journal", "optimization summarize failed", {
|
|
125
|
+
error: String(err),
|
|
126
|
+
});
|
|
127
|
+
result.skipped = true;
|
|
128
|
+
result.skippedReason = "summarize failed";
|
|
129
|
+
return { nextState: { ...state }, result };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const parsed = extractJsonObject(raw);
|
|
133
|
+
if (!isOptimizationOutput(parsed)) {
|
|
134
|
+
log.warn("journal", "optimizer returned unusable JSON, skipping");
|
|
135
|
+
result.skipped = true;
|
|
136
|
+
result.skippedReason = "unusable optimizer JSON";
|
|
137
|
+
return { nextState: { ...state }, result };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const removed = new Set<string>();
|
|
141
|
+
|
|
142
|
+
// Apply merges first, then archives (which skip slugs already
|
|
143
|
+
// removed by a merge).
|
|
144
|
+
await executeMergePlans(workspaceRoot, planMerges(parsed.merges), removed, result.mergedSlugs);
|
|
145
|
+
await executeArchives(workspaceRoot, parsed.archives, removed, result.archivedSlugs);
|
|
146
|
+
|
|
147
|
+
return { nextState: applyRemovedTopics(state, removed), result };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadTopicHeads(workspaceRoot: string): Promise<OptimizationTopicSnapshot[]> {
|
|
151
|
+
const topicMap = await readAllTopicFiles(workspaceRoot);
|
|
152
|
+
const out: OptimizationTopicSnapshot[] = [];
|
|
153
|
+
for (const [slug, content] of topicMap) {
|
|
154
|
+
out.push({
|
|
155
|
+
slug,
|
|
156
|
+
headContent: content.slice(0, OPTIMIZER_HEAD_CHARS),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|