mulmoclaude 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/index.ts +9 -3
  10. package/server/agent/mcp-tools/index.ts +6 -6
  11. package/server/agent/mcp-tools/x.ts +2 -1
  12. package/server/agent/prompt.ts +187 -26
  13. package/server/agent/resumeFailover.ts +5 -5
  14. package/server/agent/sandboxMounts.ts +3 -3
  15. package/server/api/auth/bearerAuth.ts +3 -3
  16. package/server/api/auth/token.ts +2 -2
  17. package/server/api/routes/agent.ts +21 -3
  18. package/server/api/routes/config.ts +1 -1
  19. package/server/api/routes/files.ts +13 -12
  20. package/server/api/routes/html.ts +2 -2
  21. package/server/api/routes/image.ts +7 -7
  22. package/server/api/routes/mulmo-script.ts +33 -31
  23. package/server/api/routes/pdf.ts +2 -2
  24. package/server/api/routes/plugins.ts +16 -6
  25. package/server/api/routes/roles.ts +2 -2
  26. package/server/api/routes/scheduler.ts +8 -6
  27. package/server/api/routes/schedulerTasks.ts +5 -3
  28. package/server/api/routes/sessions.ts +2 -2
  29. package/server/api/routes/sessionsCursor.ts +4 -4
  30. package/server/api/routes/skills.ts +5 -5
  31. package/server/api/routes/sources.ts +3 -3
  32. package/server/api/routes/todosHandlers.ts +1 -1
  33. package/server/api/routes/todosItemsHandlers.ts +14 -14
  34. package/server/api/routes/wiki.ts +22 -8
  35. package/server/api/sandboxStatus.ts +1 -1
  36. package/server/events/notifications.ts +6 -6
  37. package/server/events/pub-sub/index.ts +3 -3
  38. package/server/events/relay-client.ts +17 -16
  39. package/server/index.ts +40 -46
  40. package/server/system/config.ts +5 -5
  41. package/server/system/credentials.ts +7 -5
  42. package/server/system/env.ts +5 -5
  43. package/server/utils/files/atomic.ts +11 -11
  44. package/server/utils/files/image-store.ts +17 -6
  45. package/server/utils/files/journal-io.ts +2 -2
  46. package/server/utils/files/json.ts +5 -5
  47. package/server/utils/files/markdown-store.ts +4 -4
  48. package/server/utils/files/reference-dirs-io.ts +3 -3
  49. package/server/utils/files/roles-io.ts +4 -4
  50. package/server/utils/files/safe.ts +14 -14
  51. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  52. package/server/utils/files/spreadsheet-store.ts +5 -5
  53. package/server/utils/files/workspace-io.ts +12 -12
  54. package/server/utils/gemini.ts +2 -2
  55. package/server/utils/gitignore.ts +9 -9
  56. package/server/utils/json.ts +5 -5
  57. package/server/utils/logBackgroundError.ts +12 -3
  58. package/server/utils/markdown.ts +5 -5
  59. package/server/utils/port.d.mts +6 -0
  60. package/server/utils/port.mjs +48 -0
  61. package/server/utils/request.ts +12 -6
  62. package/server/utils/spawn.ts +1 -1
  63. package/server/utils/types.ts +2 -2
  64. package/server/workspace/chat-index/summarizer.ts +4 -4
  65. package/server/workspace/custom-dirs.ts +5 -5
  66. package/server/workspace/journal/diff.ts +2 -2
  67. package/server/workspace/journal/index.ts +4 -4
  68. package/server/workspace/journal/optimizationPass.ts +2 -2
  69. package/server/workspace/journal/state.ts +6 -6
  70. package/server/workspace/paths.ts +3 -3
  71. package/server/workspace/reference-dirs.ts +3 -3
  72. package/server/workspace/skills/parser.ts +6 -6
  73. package/server/workspace/skills/scheduler.ts +3 -3
  74. package/server/workspace/skills/writer.ts +3 -3
  75. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  76. package/server/workspace/sources/fetchers/rss.ts +5 -5
  77. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  78. package/server/workspace/sources/interests.ts +3 -3
  79. package/server/workspace/sources/paths.ts +6 -6
  80. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  81. package/server/workspace/sources/pipeline/index.ts +2 -7
  82. package/server/workspace/sources/pipeline/notify.ts +3 -3
  83. package/server/workspace/sources/pipeline/plan.ts +11 -9
  84. package/server/workspace/sources/pipeline/write.ts +5 -5
  85. package/server/workspace/sources/rateLimiter.ts +1 -1
  86. package/server/workspace/sources/sourceState.ts +9 -4
  87. package/server/workspace/sources/types.ts +9 -0
  88. package/server/workspace/sources/urls.ts +1 -1
  89. package/server/workspace/tool-trace/classify.ts +4 -4
  90. package/server/workspace/workspace.ts +7 -7
  91. package/src/App.vue +286 -112
  92. package/src/components/CanvasViewToggle.vue +10 -7
  93. package/src/components/ChatInput.vue +60 -26
  94. package/src/components/FileContentHeader.vue +7 -4
  95. package/src/components/FileContentRenderer.vue +20 -6
  96. package/src/components/FileTree.vue +6 -3
  97. package/src/components/FileTreePane.vue +11 -8
  98. package/src/components/FilesView.vue +5 -3
  99. package/src/components/LockStatusPopup.vue +15 -12
  100. package/src/components/NotificationBell.vue +14 -5
  101. package/src/components/NotificationToast.vue +4 -1
  102. package/src/components/PluginLauncher.vue +19 -56
  103. package/src/components/RightSidebar.vue +13 -10
  104. package/src/components/SessionHistoryPanel.vue +33 -29
  105. package/src/components/SessionTabBar.vue +8 -10
  106. package/src/components/SettingsMcpTab.vue +43 -30
  107. package/src/components/SettingsModal.vue +21 -19
  108. package/src/components/SettingsReferenceDirsTab.vue +29 -24
  109. package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
  110. package/src/components/SidebarHeader.vue +25 -4
  111. package/src/components/StackView.vue +4 -1
  112. package/src/components/SuggestionsPanel.vue +5 -2
  113. package/src/components/TodoExplorer.vue +26 -15
  114. package/src/components/ToolResultsPanel.vue +27 -13
  115. package/src/components/todo/TodoAddDialog.vue +17 -12
  116. package/src/components/todo/TodoEditDialog.vue +7 -2
  117. package/src/components/todo/TodoEditPanel.vue +15 -10
  118. package/src/components/todo/TodoKanbanView.vue +10 -5
  119. package/src/components/todo/TodoListView.vue +5 -2
  120. package/src/components/todo/TodoTableView.vue +5 -2
  121. package/src/composables/useAppApi.ts +9 -0
  122. package/src/composables/useDynamicFavicon.ts +172 -37
  123. package/src/composables/useEventListeners.ts +7 -8
  124. package/src/composables/useFaviconState.ts +13 -2
  125. package/src/composables/useFileSelection.ts +24 -6
  126. package/src/composables/useLayoutMode.ts +32 -0
  127. package/src/composables/useSessionHistory.ts +7 -17
  128. package/src/composables/useViewLayout.ts +20 -34
  129. package/src/lang/de.ts +536 -0
  130. package/src/lang/en.ts +558 -0
  131. package/src/lang/es.ts +543 -0
  132. package/src/lang/fr.ts +536 -0
  133. package/src/lang/ja.ts +536 -0
  134. package/src/lang/ko.ts +540 -0
  135. package/src/lang/pt-BR.ts +534 -0
  136. package/src/lang/zh.ts +537 -0
  137. package/src/lib/vue-i18n.ts +97 -0
  138. package/src/main.ts +2 -0
  139. package/src/plugins/canvas/View.vue +102 -186
  140. package/src/plugins/canvas/definition.ts +0 -8
  141. package/src/plugins/chart/Preview.vue +1 -1
  142. package/src/plugins/chart/View.vue +9 -4
  143. package/src/plugins/manageRoles/Preview.vue +4 -1
  144. package/src/plugins/manageRoles/View.vue +59 -43
  145. package/src/plugins/manageSkills/Preview.vue +8 -3
  146. package/src/plugins/manageSkills/View.vue +26 -22
  147. package/src/plugins/manageSource/Preview.vue +1 -1
  148. package/src/plugins/manageSource/View.vue +73 -52
  149. package/src/plugins/markdown/Preview.vue +1 -1
  150. package/src/plugins/markdown/View.vue +24 -34
  151. package/src/plugins/presentHtml/Preview.vue +1 -1
  152. package/src/plugins/presentHtml/View.vue +7 -4
  153. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  154. package/src/plugins/presentMulmoScript/View.vue +36 -26
  155. package/src/plugins/scheduler/Preview.vue +7 -4
  156. package/src/plugins/scheduler/TasksTab.vue +53 -24
  157. package/src/plugins/scheduler/View.vue +28 -19
  158. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  159. package/src/plugins/spreadsheet/Preview.vue +8 -3
  160. package/src/plugins/spreadsheet/View.vue +21 -12
  161. package/src/plugins/textResponse/Preview.vue +15 -58
  162. package/src/plugins/textResponse/View.vue +27 -7
  163. package/src/plugins/todo/Preview.vue +11 -6
  164. package/src/plugins/todo/View.vue +27 -13
  165. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  166. package/src/plugins/ui-image/ImageView.vue +7 -4
  167. package/src/plugins/wiki/Preview.vue +5 -2
  168. package/src/plugins/wiki/View.vue +202 -81
  169. package/src/plugins/wiki/route.ts +112 -0
  170. package/src/router/guards.ts +42 -24
  171. package/src/router/index.ts +41 -26
  172. package/src/types/vue-i18n.d.ts +20 -0
  173. package/src/utils/agent/request.ts +19 -0
  174. package/src/utils/canvas/layoutMode.ts +26 -0
  175. package/src/utils/image/cacheBust.ts +16 -0
  176. package/src/utils/image/resolve.ts +16 -0
  177. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  178. package/src/vite-env.d.ts +9 -0
  179. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  180. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  181. package/client/assets/index-Bm70FDU2.css +0 -1
  182. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  183. package/src/composables/useCanvasViewMode.ts +0 -121
  184. package/src/utils/canvas/viewMode.ts +0 -46
  185. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -3,11 +3,17 @@
3
3
  // Centralizes patterns that were duplicated across route handlers
4
4
  // (3+ different ways to read `req.query.session`).
5
5
 
6
- // Use a minimal interface so the helpers work with any Express
7
- // Request generic (Request<object, ...>, Request<Params, ...>, etc.)
8
- // without type incompatibility.
6
+ // `query: object` so the helpers work with any Express Request
7
+ // generic — `Request<Params, ResBody, ReqBody, Query>`. A narrow
8
+ // `Query` generic like `{ path?: string }` isn't assignable to
9
+ // `Record<string, unknown>` (no index signature), so we widen to
10
+ // `object` and cast internally when reading a key.
9
11
  interface HasQuery {
10
- query: Record<string, unknown>;
12
+ query: object;
13
+ }
14
+
15
+ function readQueryKey(queryObj: object, key: string): unknown {
16
+ return (queryObj as Record<string, unknown>)[key];
11
17
  }
12
18
 
13
19
  /**
@@ -15,7 +21,7 @@ interface HasQuery {
15
21
  * Returns the string value, or "" if missing/non-string.
16
22
  */
17
23
  export function getSessionQuery(req: HasQuery): string {
18
- const raw = req.query.session;
24
+ const raw = readQueryKey(req.query, "session");
19
25
  return typeof raw === "string" ? raw : "";
20
26
  }
21
27
 
@@ -24,6 +30,6 @@ export function getSessionQuery(req: HasQuery): string {
24
30
  * Returns the string value, or undefined if missing/non-string.
25
31
  */
26
32
  export function getOptionalStringQuery(req: HasQuery, key: string): string | undefined {
27
- const raw = req.query[key];
33
+ const raw = readQueryKey(req.query, key);
28
34
  return typeof raw === "string" ? raw : undefined;
29
35
  }
@@ -28,7 +28,7 @@ export function extractClaudeErrorMessage(stdout: string): string | null {
28
28
  if (!isRecord(parsed)) return null;
29
29
  if (parsed.is_error !== true) return null;
30
30
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
31
- const joined = parsed.errors.filter((e): e is string => typeof e === "string").join("; ");
31
+ const joined = parsed.errors.filter((err): err is string => typeof err === "string").join("; ");
32
32
  if (joined.length > 0) return joined;
33
33
  }
34
34
  const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
@@ -23,12 +23,12 @@ export function isNonEmptyString(value: unknown): value is string {
23
23
  /** Record whose values are all strings. */
24
24
  export function isStringRecord(value: unknown): value is Record<string, string> {
25
25
  if (!isRecord(value)) return false;
26
- return Object.values(value).every((v) => typeof v === "string");
26
+ return Object.values(value).every((val) => typeof val === "string");
27
27
  }
28
28
 
29
29
  /** String array (every element is a string). */
30
30
  export function isStringArray(value: unknown): value is string[] {
31
- return Array.isArray(value) && value.every((v) => typeof v === "string");
31
+ return Array.isArray(value) && value.every((val) => typeof val === "string");
32
32
  }
33
33
 
34
34
  /** Error-like object with a `code` property (e.g. Node.js fs errors). */
@@ -152,10 +152,10 @@ export function validateSummaryResult(obj: unknown): SummaryResult {
152
152
  if (!isRecord(obj)) {
153
153
  throw new Error("[chat-index] summary result is not an object");
154
154
  }
155
- const o = obj as Record<string, unknown>;
156
- const title = typeof o.title === "string" ? o.title : "";
157
- const summary = typeof o.summary === "string" ? o.summary : "";
158
- const keywords = Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === "string") : [];
155
+ const record = obj as Record<string, unknown>;
156
+ const title = typeof record.title === "string" ? record.title : "";
157
+ const summary = typeof record.summary === "string" ? record.summary : "";
158
+ const keywords = Array.isArray(record.keywords) ? record.keywords.filter((keyword): keyword is string => typeof keyword === "string") : [];
159
159
  return { title, summary, keywords };
160
160
  }
161
161
 
@@ -4,7 +4,7 @@
4
4
  // directories under `data/` and `artifacts/` for organizing files.
5
5
  // Claude sees these in the system prompt and routes saves accordingly.
6
6
 
7
- import fs from "fs";
7
+ import { existsSync, mkdirSync, readFileSync } from "fs";
8
8
  import path from "path";
9
9
  import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
10
10
  import { log } from "../system/logger/index.js";
@@ -102,8 +102,8 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
102
102
  const base = root ?? workspacePath;
103
103
  const filePath = path.join(base, CONFIG_FILE);
104
104
  try {
105
- if (!fs.existsSync(filePath)) return [];
106
- const raw = fs.readFileSync(filePath, "utf-8");
105
+ if (!existsSync(filePath)) return [];
106
+ const raw = readFileSync(filePath, "utf-8");
107
107
  const parsed: unknown = JSON.parse(raw);
108
108
  if (!Array.isArray(parsed)) {
109
109
  log.warn("custom-dirs", "workspace-dirs.json is not an array");
@@ -132,7 +132,7 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
132
132
  export function saveCustomDirs(entries: readonly CustomDirEntry[], root?: string): void {
133
133
  const base = root ?? workspacePath;
134
134
  const filePath = path.join(base, CONFIG_FILE);
135
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
135
+ mkdirSync(path.dirname(filePath), { recursive: true });
136
136
  writeFileAtomicSync(filePath, JSON.stringify(entries, null, 2));
137
137
  invalidateCache();
138
138
  }
@@ -186,7 +186,7 @@ export function ensureCustomDirs(entries: readonly CustomDirEntry[], root?: stri
186
186
  const base = root ?? workspacePath;
187
187
  for (const entry of entries) {
188
188
  const dirPath = path.join(base, entry.path);
189
- fs.mkdirSync(dirPath, { recursive: true });
189
+ mkdirSync(dirPath, { recursive: true });
190
190
  }
191
191
  }
192
192
 
@@ -52,8 +52,8 @@ export function findDirtySessions(current: readonly SessionFileMeta[], processed
52
52
  }
53
53
 
54
54
  const missing: string[] = [];
55
- for (const id of Object.keys(processed)) {
56
- if (!seenNow.has(id)) missing.push(id);
55
+ for (const sessionId of Object.keys(processed)) {
56
+ if (!seenNow.has(sessionId)) missing.push(sessionId);
57
57
  }
58
58
 
59
59
  return { dirty, missing };
@@ -150,17 +150,17 @@ async function runJournalPass(opts: MaybeRunJournalOptions): Promise<void> {
150
150
  async function rebuildIndex(workspaceRoot: string): Promise<void> {
151
151
  const topics = await walkTopics(workspaceRoot);
152
152
  const dailyEntries = await listDailyFilesIO(workspaceRoot);
153
- const days: IndexDailyEntry[] = dailyEntries.map((e) => ({
154
- date: `${e.year}-${e.month}-${e.day}`,
153
+ const days: IndexDailyEntry[] = dailyEntries.map((entry) => ({
154
+ date: `${entry.year}-${entry.month}-${entry.day}`,
155
155
  }));
156
156
  const archivedCount = await countArchivedIO(workspaceRoot);
157
- const md = buildIndexMarkdown({
157
+ const markdown = buildIndexMarkdown({
158
158
  topics,
159
159
  days,
160
160
  archivedTopicCount: archivedCount,
161
161
  builtAtIso: new Date().toISOString(),
162
162
  });
163
- await writeJournalIndex(md, workspaceRoot);
163
+ await writeJournalIndex(markdown, workspaceRoot);
164
164
  }
165
165
 
166
166
  async function walkTopics(workspaceRoot: string): Promise<IndexTopicEntry[]> {
@@ -54,7 +54,7 @@ export function planMerges(merges: readonly RawMerge[]): MergePlanItem[] {
54
54
  const plans: MergePlanItem[] = [];
55
55
  for (const merge of merges) {
56
56
  const intoSlug = slugify(merge.into);
57
- const fromSlugs = merge.from.map(slugify).filter((s) => s !== intoSlug);
57
+ const fromSlugs = merge.from.map(slugify).filter((slug) => slug !== intoSlug);
58
58
  if (fromSlugs.length === 0) continue;
59
59
  plans.push({ intoSlug, fromSlugs, newContent: merge.newContent });
60
60
  }
@@ -66,7 +66,7 @@ export function planMerges(merges: readonly RawMerge[]): MergePlanItem[] {
66
66
  export function applyRemovedTopics(state: JournalState, removed: ReadonlySet<string>): JournalState {
67
67
  return {
68
68
  ...state,
69
- knownTopics: state.knownTopics.filter((t) => !removed.has(t)),
69
+ knownTopics: state.knownTopics.filter((topic) => !removed.has(topic)),
70
70
  };
71
71
  }
72
72
 
@@ -65,27 +65,27 @@ export function parseState(raw: unknown): JournalState {
65
65
  // Version mismatch → throw it all out. Cheap to rebuild.
66
66
  if (obj.version !== JOURNAL_STATE_VERSION) return defaultState();
67
67
 
68
- const d = defaultState();
68
+ const fallback = defaultState();
69
69
  return {
70
70
  version: JOURNAL_STATE_VERSION,
71
71
  lastDailyRunAt: typeof obj.lastDailyRunAt === "string" ? obj.lastDailyRunAt : null,
72
72
  lastOptimizationRunAt: typeof obj.lastOptimizationRunAt === "string" ? obj.lastOptimizationRunAt : null,
73
- dailyIntervalHours: typeof obj.dailyIntervalHours === "number" && obj.dailyIntervalHours > 0 ? obj.dailyIntervalHours : d.dailyIntervalHours,
73
+ dailyIntervalHours: typeof obj.dailyIntervalHours === "number" && obj.dailyIntervalHours > 0 ? obj.dailyIntervalHours : fallback.dailyIntervalHours,
74
74
  optimizationIntervalDays:
75
- typeof obj.optimizationIntervalDays === "number" && obj.optimizationIntervalDays > 0 ? obj.optimizationIntervalDays : d.optimizationIntervalDays,
75
+ typeof obj.optimizationIntervalDays === "number" && obj.optimizationIntervalDays > 0 ? obj.optimizationIntervalDays : fallback.optimizationIntervalDays,
76
76
  processedSessions: parseProcessedSessions(obj.processedSessions),
77
- knownTopics: Array.isArray(obj.knownTopics) ? obj.knownTopics.filter((t): t is string => typeof t === "string") : [],
77
+ knownTopics: Array.isArray(obj.knownTopics) ? obj.knownTopics.filter((topic): topic is string => typeof topic === "string") : [],
78
78
  };
79
79
  }
80
80
 
81
81
  function parseProcessedSessions(raw: unknown): Record<string, ProcessedSessionRecord> {
82
82
  if (!isRecord(raw)) return {};
83
83
  const out: Record<string, ProcessedSessionRecord> = {};
84
- for (const [id, rec] of Object.entries(raw as Record<string, unknown>)) {
84
+ for (const [sessionId, rec] of Object.entries(raw as Record<string, unknown>)) {
85
85
  if (!isRecord(rec)) continue;
86
86
  const mtime = (rec as Record<string, unknown>).lastMtimeMs;
87
87
  if (typeof mtime === "number" && mtime >= 0) {
88
- out[id] = { lastMtimeMs: mtime };
88
+ out[sessionId] = { lastMtimeMs: mtime };
89
89
  }
90
90
  }
91
91
  return out;
@@ -21,7 +21,7 @@
21
21
  // `WORKSPACE_DIRS` record below. The absolute path is derived
22
22
  // automatically via `WORKSPACE_PATHS`.
23
23
 
24
- import os from "os";
24
+ import { homedir } from "os";
25
25
  import path from "path";
26
26
 
27
27
  // Workspace root. Hard-coded to `~/mulmoclaude` — there is no
@@ -29,7 +29,7 @@ import path from "path";
29
29
  // requires a code edit or a symlink. Re-exported by
30
30
  // `server/workspace.ts` for backwards compatibility of existing
31
31
  // callers that `import { workspacePath } from "./workspace.js"`.
32
- export const workspacePath = path.join(os.homedir(), "mulmoclaude");
32
+ export const workspacePath = path.join(homedir(), "mulmoclaude");
33
33
 
34
34
  // Workspace-relative paths. Keys are the stable code-side identifiers
35
35
  // (e.g. `markdowns` — unchanged for call-site compatibility); values
@@ -90,7 +90,7 @@ import { WORKSPACE_FILES } from "../../src/config/workspacePaths.js";
90
90
  export { WORKSPACE_FILES };
91
91
 
92
92
  // Absolute paths, built once at module load from `workspacePath`.
93
- // The `workspacePath` const is itself fixed (reads `os.homedir()`
93
+ // The `workspacePath` const is itself fixed (reads `homedir()`
94
94
  // at process start — no env override, see `server/workspace.ts`),
95
95
  // so freezing these paths is safe.
96
96
  export const WORKSPACE_PATHS = {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { createHash } from "crypto";
10
10
  import path from "path";
11
- import os from "os";
11
+ import { homedir } from "os";
12
12
  import { log } from "../system/logger/index.js";
13
13
  import { readReferenceDirsJson, writeReferenceDirsJson, isExistingDirectory } from "../utils/files/reference-dirs-io.js";
14
14
  import { isRecord } from "../utils/types.js";
@@ -41,7 +41,7 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
41
41
 
42
42
  function expandHome(inputPath: string): string {
43
43
  if (inputPath.startsWith("~/")) {
44
- return path.join(os.homedir(), inputPath.slice(2));
44
+ return path.join(homedir(), inputPath.slice(2));
45
45
  }
46
46
  return inputPath;
47
47
  }
@@ -52,7 +52,7 @@ function isSensitivePath(absPath: string): boolean {
52
52
  // Reject filesystem root
53
53
  if (normalized === path.parse(normalized).root) return true;
54
54
 
55
- const home = os.homedir();
55
+ const home = homedir();
56
56
 
57
57
  // Block $HOME itself (transitively exposes .ssh etc.)
58
58
  if (normalized === home) return true;
@@ -56,9 +56,9 @@ function parseScheduleValue(raw: string): SkillSchedule["parsed"] {
56
56
  // daily HH:MM — validate range: HH 00-23, MM 00-59
57
57
  const dailyMatch = trimmed.match(/^daily\s+(\d{2}):(\d{2})$/);
58
58
  if (dailyMatch) {
59
- const hh = Number(dailyMatch[1]);
60
- const mm = Number(dailyMatch[2]);
61
- if (hh > 23 || mm > 59) return null;
59
+ const hours = Number(dailyMatch[1]);
60
+ const minutes = Number(dailyMatch[2]);
61
+ if (hours > 23 || minutes > 59) return null;
62
62
  return {
63
63
  type: SCHEDULE_TYPES.daily,
64
64
  time: `${dailyMatch[1]}:${dailyMatch[2]}`,
@@ -70,9 +70,9 @@ function parseScheduleValue(raw: string): SkillSchedule["parsed"] {
70
70
  if (intervalMatch) {
71
71
  const value = Number(intervalMatch[1]);
72
72
  const unit = intervalMatch[2];
73
- const ms = TIME_UNIT_MS[unit];
74
- if (!ms) return null;
75
- const intervalMs = value * ms;
73
+ const unitMs = TIME_UNIT_MS[unit];
74
+ if (!unitMs) return null;
75
+ const intervalMs = value * unitMs;
76
76
  if (intervalMs < MIN_INTERVAL_MS) return null;
77
77
  return { type: SCHEDULE_TYPES.interval, intervalMs };
78
78
  }
@@ -137,10 +137,10 @@ function readSkillScheduleInfo(skill: Skill): SkillScheduleInfo | null {
137
137
  try {
138
138
  const raw = readFileSync(skill.path, "utf-8");
139
139
  const parsed = parseSkillFrontmatter(raw);
140
- const s = parsed?.schedule?.parsed;
141
- if (!s) return null;
140
+ const schedule = parsed?.schedule?.parsed;
141
+ if (!schedule) return null;
142
142
  return {
143
- schedule: s,
143
+ schedule,
144
144
  roleId: parsed?.roleId ?? DEFAULT_ROLE_ID,
145
145
  };
146
146
  } catch {
@@ -54,7 +54,7 @@ export async function saveProjectSkill(input: SaveSkillInput): Promise<SaveResul
54
54
  // user-scope skill with the same name (project would silently
55
55
  // override it via the precedence rule).
56
56
  const existing = await discoverSkills({ workspaceRoot });
57
- if (existing.some((s) => s.name === name)) {
57
+ if (existing.some((skill) => skill.name === name)) {
58
58
  return { kind: "exists", name };
59
59
  }
60
60
 
@@ -98,7 +98,7 @@ export async function updateProjectSkill(input: SaveSkillInput): Promise<UpdateR
98
98
  }
99
99
 
100
100
  const existing = await discoverSkills({ workspaceRoot });
101
- const skill = existing.find((s) => s.name === name);
101
+ const skill = existing.find((candidate) => candidate.name === name);
102
102
  if (!skill) return { kind: "not-found", name };
103
103
  if (skill.source === "user") return { kind: "user-scope", name };
104
104
 
@@ -139,7 +139,7 @@ export async function deleteProjectSkill(input: DeleteSkillInput): Promise<Delet
139
139
  // Look up the skill's effective source via discovery — if the
140
140
  // matching name is user-scope, we refuse.
141
141
  const all = await discoverSkills({ workspaceRoot });
142
- const skill = all.find((s) => s.name === name);
142
+ const skill = all.find((candidate) => candidate.name === name);
143
143
  if (!skill) return { kind: "not-found", name };
144
144
  if (skill.source === "user") return { kind: "user-scope", name };
145
145
 
@@ -16,7 +16,7 @@ import { workspacePath } from "../paths.js";
16
16
  import { log } from "../../system/logger/index.js";
17
17
  import { slugify } from "../../utils/slug.js";
18
18
  import type { Source } from "./types.js";
19
- import fs from "fs";
19
+ import { mkdirSync } from "fs";
20
20
 
21
21
  // ── Constants ───────────────────────────────────────────────────
22
22
 
@@ -88,7 +88,7 @@ export async function discoverAndRegister(root?: string): Promise<DiscoveryResul
88
88
 
89
89
  // Ensure sources directory exists
90
90
  const dir = sourcesRoot(base);
91
- fs.mkdirSync(dir, { recursive: true });
91
+ mkdirSync(dir, { recursive: true });
92
92
 
93
93
  const existing = await listSources(base);
94
94
  const existingSlugs = new Set(existing.map((source) => source.slug));
@@ -74,7 +74,7 @@ function entryToSourceItem(entry: ParsedFeedItem, source: Source, lastSeenTs: nu
74
74
  // Use the feed's own id as a hint, but always derive the
75
75
  // SourceItem.id from the normalized URL so cross-source dedup
76
76
  // (see #188 Q3) lines up regardless of feed conventions.
77
- const id = stableItemId(normalizedUrl);
77
+ const itemId = stableItemId(normalizedUrl);
78
78
  const publishedAt =
79
79
  entry.publishedAt ??
80
80
  // Synthesize a fetch-time timestamp when the feed didn't
@@ -85,7 +85,7 @@ function entryToSourceItem(entry: ParsedFeedItem, source: Source, lastSeenTs: nu
85
85
  // carry `undefined` fields that break exactOptionalPropertyTypes
86
86
  // on the server tsconfig.
87
87
  return {
88
- id,
88
+ id: itemId,
89
89
  title: entry.title,
90
90
  url: normalizedUrl,
91
91
  publishedAt,
@@ -107,9 +107,9 @@ export function updateCursor(current: Record<string, string>, feed: ParsedFeed):
107
107
  let newest: number | null = null;
108
108
  for (const entry of feed.items) {
109
109
  if (!entry.publishedAt) continue;
110
- const ts = Date.parse(entry.publishedAt);
111
- if (!Number.isFinite(ts)) continue;
112
- if (newest === null || ts > newest) newest = ts;
110
+ const publishedMs = Date.parse(entry.publishedAt);
111
+ if (!Number.isFinite(publishedMs)) continue;
112
+ if (newest === null || publishedMs > newest) newest = publishedMs;
113
113
  }
114
114
  if (newest === null) return current;
115
115
  // Only advance forwards. A feed whose newest item is older
@@ -178,7 +178,7 @@ function parseAtom(feed: Record<string, unknown>): ParsedFeed | null {
178
178
 
179
179
  function parseAtomEntry(raw: Record<string, unknown>): ParsedFeedItem | null {
180
180
  const title = readString(raw.title);
181
- const id = readString(raw.id);
181
+ const entryId = readString(raw.id);
182
182
  const link = resolveAtomLink(raw.link);
183
183
  const published = readString(raw.published) ?? readString(raw.updated) ?? null;
184
184
  const publishedAt = published ? normalizeDate(published) : null;
@@ -189,7 +189,7 @@ function parseAtomEntry(raw: Record<string, unknown>): ParsedFeedItem | null {
189
189
  const summary = readString(raw.summary) ?? content;
190
190
  if (!title) return null;
191
191
  return {
192
- feedId: id ?? link ?? null,
192
+ feedId: entryId ?? link ?? null,
193
193
  title,
194
194
  link,
195
195
  publishedAt,
@@ -289,7 +289,7 @@ function stripBom(text: string): string {
289
289
  // date is more useful to the pipeline than a null.
290
290
  function normalizeDate(raw: string | null): string | null {
291
291
  if (!raw) return null;
292
- const ts = Date.parse(raw);
293
- if (Number.isFinite(ts)) return new Date(ts).toISOString();
292
+ const parsed = Date.parse(raw);
293
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
294
294
  return raw;
295
295
  }
@@ -4,7 +4,7 @@
4
4
  // during conversation when it detects user interest in a topic.
5
5
  // The pipeline's notify phase uses it to score and filter articles.
6
6
 
7
- import fs from "fs";
7
+ import { existsSync, readFileSync } from "fs";
8
8
  import path from "path";
9
9
  import { workspacePath } from "../paths.js";
10
10
  import { log } from "../../system/logger/index.js";
@@ -41,8 +41,8 @@ export function loadInterests(root?: string): InterestsProfile | null {
41
41
  const base = root ?? workspacePath;
42
42
  const filePath = path.join(base, CONFIG_FILE);
43
43
  try {
44
- if (!fs.existsSync(filePath)) return null;
45
- const raw = fs.readFileSync(filePath, "utf-8");
44
+ if (!existsSync(filePath)) return null;
45
+ const raw = readFileSync(filePath, "utf-8");
46
46
  const parsed: unknown = JSON.parse(raw);
47
47
  return validateInterests(parsed);
48
48
  } catch (err) {
@@ -71,11 +71,11 @@ export function newsRoot(workspaceRoot: string): string {
71
71
  export function dailyNewsPath(workspaceRoot: string, isoDate: string): string {
72
72
  // Validate shape at the boundary so an empty / bogus date can't
73
73
  // produce "undefined/undefined/undefined.md" downstream.
74
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
75
- if (!m) {
74
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
75
+ if (!match) {
76
76
  throw new Error(`[sources] dailyNewsPath: expected YYYY-MM-DD, got "${isoDate}"`);
77
77
  }
78
- const [, year, month, day] = m;
78
+ const [, year, month, day] = match;
79
79
  return path.join(newsRoot(workspaceRoot), DAILY_DIR, year, month, `${day}.md`);
80
80
  }
81
81
 
@@ -94,11 +94,11 @@ export function archiveDir(workspaceRoot: string, slug: string): string {
94
94
  // to split; we do the split here.
95
95
  export function archivePath(workspaceRoot: string, slug: string, yearMonth: string): string {
96
96
  assertValidSlug(slug);
97
- const m = /^(\d{4})-(\d{2})$/.exec(yearMonth);
98
- if (!m) {
97
+ const match = /^(\d{4})-(\d{2})$/.exec(yearMonth);
98
+ if (!match) {
99
99
  throw new Error(`[sources] archivePath: expected YYYY-MM, got "${yearMonth}"`);
100
100
  }
101
- const [, year, month] = m;
101
+ const [, year, month] = match;
102
102
  return path.join(archiveDir(workspaceRoot, slug), year, `${month}.md`);
103
103
  }
104
104
 
@@ -15,7 +15,7 @@ import type { FetcherDeps, FetchResult, SourceFetcher } from "../fetchers/index.
15
15
  import type { FetcherKind, Source, SourceState } from "../types.js";
16
16
  import { defaultSourceState } from "../types.js";
17
17
  import { errorMessage } from "../../../utils/errors.js";
18
- import { ONE_MINUTE_MS, ONE_DAY_MS } from "../../../utils/time.js";
18
+ import { ONE_MINUTE_MS, ONE_HOUR_MS, ONE_DAY_MS } from "../../../utils/time.js";
19
19
 
20
20
  // Outcome of one source's fetch attempt.
21
21
  export type FetchOutcome =
@@ -98,31 +98,52 @@ export function backoffDelayMs(consecutiveFailures: number): number {
98
98
  if (consecutiveFailures <= 0) return 0;
99
99
  // 1m, 2m, 4m, 8m, 16m, ..., capped at 24h.
100
100
  const base = ONE_MINUTE_MS;
101
- const ms = base * 2 ** Math.min(consecutiveFailures - 1, 20);
102
- return Math.min(ms, BACKOFF_MAX_MS);
101
+ const delayMs = base * 2 ** Math.min(consecutiveFailures - 1, 20);
102
+ return Math.min(delayMs, BACKOFF_MAX_MS);
103
+ }
104
+
105
+ // Number of consecutive empty fetches before adaptive backoff kicks in.
106
+ export const EMPTY_FETCH_THRESHOLD = 3;
107
+ export const EMPTY_BACKOFF_MAX_MS = ONE_DAY_MS;
108
+
109
+ // Exponential backoff (in ms) for Nth consecutive empty-success fetch.
110
+ // Returns 0 when below the threshold so callers can use it as a guard.
111
+ // Starts at 1h after threshold, doubling each time up to 24h.
112
+ export function emptyBackoffDelayMs(consecutiveEmptyFetches: number): number {
113
+ if (consecutiveEmptyFetches < EMPTY_FETCH_THRESHOLD) return 0;
114
+ const steps = consecutiveEmptyFetches - EMPTY_FETCH_THRESHOLD;
115
+ const delayMs = ONE_HOUR_MS * 2 ** Math.min(steps, 10);
116
+ return Math.min(delayMs, EMPTY_BACKOFF_MAX_MS);
103
117
  }
104
118
 
105
119
  // Compute the next per-source state given the outcome. Pure.
106
120
  //
107
- // On success:
108
- // - lastFetchedAt = now
109
- // - cursor = outcome.cursor (replace wholesale — fetchers
110
- // return the merged cursor map)
111
- // - consecutiveFailures = 0
112
- // - nextAttemptAt = null
121
+ // On success with items:
122
+ // - lastFetchedAt = now, cursor updated
123
+ // - consecutiveFailures = 0, nextAttemptAt = null
124
+ // - consecutiveEmptyFetches = 0, emptyBackoffUntil = null
125
+ // On success with 0 items:
126
+ // - lastFetchedAt = now, cursor updated
127
+ // - consecutiveFailures = 0, nextAttemptAt = null
128
+ // - consecutiveEmptyFetches += 1
129
+ // - emptyBackoffUntil = now + emptyBackoffDelayMs(newCount) if above threshold
113
130
  // On any non-success:
114
- // - lastFetchedAt unchanged (we didn't successfully fetch)
115
- // - cursor unchanged
116
- // - consecutiveFailures += 1
117
- // - nextAttemptAt = now + backoffDelayMs(newCount)
131
+ // - lastFetchedAt/cursor unchanged
132
+ // - consecutiveFailures += 1, nextAttemptAt = now + backoffDelayMs(newCount)
133
+ // - consecutiveEmptyFetches/emptyBackoffUntil unchanged
118
134
  export function computeNextState(prev: SourceState, outcome: FetchOutcome, nowMs: number): SourceState {
119
135
  if (outcome.kind === "success") {
136
+ const hasItems = outcome.items.length > 0;
137
+ const emptyCount = hasItems ? 0 : prev.consecutiveEmptyFetches + 1;
138
+ const emptyDelayMs = emptyBackoffDelayMs(emptyCount);
120
139
  return {
121
140
  slug: prev.slug,
122
141
  lastFetchedAt: new Date(nowMs).toISOString(),
123
142
  cursor: outcome.cursor,
124
143
  consecutiveFailures: 0,
125
144
  nextAttemptAt: null,
145
+ consecutiveEmptyFetches: emptyCount,
146
+ emptyBackoffUntil: emptyDelayMs > 0 ? new Date(nowMs + emptyDelayMs).toISOString() : null,
126
147
  };
127
148
  }
128
149
  const failures = prev.consecutiveFailures + 1;
@@ -132,5 +153,7 @@ export function computeNextState(prev: SourceState, outcome: FetchOutcome, nowMs
132
153
  cursor: prev.cursor,
133
154
  consecutiveFailures: failures,
134
155
  nextAttemptAt: new Date(nowMs + backoffDelayMs(failures)).toISOString(),
156
+ consecutiveEmptyFetches: prev.consecutiveEmptyFetches,
157
+ emptyBackoffUntil: prev.emptyBackoffUntil,
135
158
  };
136
159
  }
@@ -34,6 +34,7 @@ import { listSources } from "../registry.js";
34
34
  import { readManyStates, writeManyStates } from "../sourceState.js";
35
35
  import { dailyNewsPath } from "../paths.js";
36
36
  import { getFetcher as registryGetFetcher, type FetcherDeps, type SourceFetcher } from "../fetchers/index.js";
37
+ import { defaultSourceState } from "../types.js";
37
38
  import type { FetcherKind, Source, SourceItem, SourceState, SourceSchedule } from "../types.js";
38
39
  import { planEligibleSources } from "./plan.js";
39
40
  import { runFetchPhase, computeNextState, type FetchOutcome } from "./fetch.js";
@@ -234,13 +235,7 @@ function buildNextStates(
234
235
  }
235
236
  const nextStates: SourceState[] = [];
236
237
  for (const source of eligible) {
237
- const prev = statesBySlug.get(source.slug) ?? {
238
- slug: source.slug,
239
- lastFetchedAt: null,
240
- cursor: {},
241
- consecutiveFailures: 0,
242
- nextAttemptAt: null,
243
- };
238
+ const prev = statesBySlug.get(source.slug) ?? defaultSourceState(source.slug);
244
239
  const outcome = outcomeBySlug.get(source.slug);
245
240
  if (!outcome) continue; // unreachable in practice; defensive
246
241
  nextStates.push(computeNextState(prev, outcome, nowMs));
@@ -30,7 +30,7 @@ export function runNotifyPhase(items: readonly SourceItem[], workspaceRoot?: str
30
30
  }
31
31
 
32
32
  function formatSingleBody(item: SourceItem): string {
33
- const suffix = item.summary ? " \u2014 " + item.summary : "";
33
+ const suffix = item.summary ? " " + item.summary : "";
34
34
  return "From " + item.sourceSlug + suffix;
35
35
  }
36
36
 
@@ -52,12 +52,12 @@ function publishBatchNotification(scored: readonly ScoredItem[]): void {
52
52
 
53
53
  const bullets = scored
54
54
  .slice(0, 5)
55
- .map((s) => `\u2022 ${s.item.title} (${s.item.sourceSlug})`)
55
+ .map((row) => `• ${row.item.title} (${row.item.sourceSlug})`)
56
56
  .join("\n");
57
57
  const extra = scored.length > 5 ? `\n+${scored.length - 5} more` : "";
58
58
 
59
59
  // Preserve high priority if any item in the batch is critical
60
- const hasCritical = scored.some((s) => s.item.severity === "critical");
60
+ const hasCritical = scored.some((row) => row.item.severity === "critical");
61
61
 
62
62
  publishNotification({
63
63
  kind: NOTIFICATION_KINDS.push,
@@ -49,18 +49,20 @@ export function planEligibleSources(input: PlanInput): Source[] {
49
49
  }
50
50
 
51
51
  // True when the state indicates the source is STILL in backoff
52
- // (so we should SKIP it). False means eligible to run now.
52
+ // (so we should SKIP it). Checks both error backoff (nextAttemptAt)
53
+ // and empty-fetch adaptive backoff (emptyBackoffUntil). Either one
54
+ // being in the future is enough to skip this cycle.
53
55
  //
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.
56
+ // Corrupt / unparseable timestamps are ignored so a bad state file
57
+ // never permanently locks out a source.
60
58
  function isWithinBackoff(state: SourceState | undefined, nowMs: number): boolean {
61
59
  if (!state) return false;
62
- if (!state.nextAttemptAt) return false;
63
- const parsed = Date.parse(state.nextAttemptAt);
60
+ return isFutureTimestamp(state.nextAttemptAt, nowMs) || isFutureTimestamp(state.emptyBackoffUntil, nowMs);
61
+ }
62
+
63
+ function isFutureTimestamp(timestamp: string | null | undefined, nowMs: number): boolean {
64
+ if (!timestamp) return false;
65
+ const parsed = Date.parse(timestamp);
64
66
  if (!Number.isFinite(parsed)) return false;
65
67
  return parsed > nowMs;
66
68
  }
@@ -130,11 +130,11 @@ export function renderItemForArchive(item: SourceItem): string {
130
130
  // Malformed dates fall back to the caller-supplied default
131
131
  // (typically the current YYYY-MM) so we don't drop items.
132
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");
133
+ const parsed = Date.parse(isoPublishedAt);
134
+ if (!Number.isFinite(parsed)) return fallbackMonth;
135
+ const date = new Date(parsed);
136
+ const year = date.getUTCFullYear();
137
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
138
138
  return `${year}-${String(month).padStart(2, "0")}`;
139
139
  }
140
140