mulmoclaude 0.1.2 → 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 (251) 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-D8rhwXLq.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/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -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) {
@@ -58,9 +58,9 @@ function validateInterests(raw: unknown): InterestsProfile | null {
58
58
  const obj = raw as Record<string, unknown>;
59
59
 
60
60
  // Filter out blank/whitespace-only keywords — "" matches every title
61
- const keywords = Array.isArray(obj.keywords) ? obj.keywords.filter((k): k is string => isNonEmptyString(k)) : [];
61
+ const keywords = Array.isArray(obj.keywords) ? obj.keywords.filter((keyword): keyword is string => isNonEmptyString(keyword)) : [];
62
62
 
63
- const categories = Array.isArray(obj.categories) ? obj.categories.filter((c): c is CategorySlug => isCategorySlug(c)) : [];
63
+ const categories = Array.isArray(obj.categories) ? obj.categories.filter((category): category is CategorySlug => isCategorySlug(category)) : [];
64
64
 
65
65
  if (keywords.length === 0 && categories.length === 0) return null;
66
66
 
@@ -88,16 +88,16 @@ export function scoreItem(item: SourceItem, profile: InterestsProfile): number {
88
88
  const titleLower = item.title.toLowerCase();
89
89
  const summaryLower = (item.summary ?? "").toLowerCase();
90
90
 
91
- for (const kw of profile.keywords) {
92
- const kwLower = kw.toLowerCase();
93
- if (titleLower.includes(kwLower)) {
91
+ for (const keyword of profile.keywords) {
92
+ const keywordLower = keyword.toLowerCase();
93
+ if (titleLower.includes(keywordLower)) {
94
94
  score += KEYWORD_TITLE_WEIGHT;
95
- } else if (summaryLower.includes(kwLower)) {
95
+ } else if (summaryLower.includes(keywordLower)) {
96
96
  score += KEYWORD_SUMMARY_WEIGHT;
97
97
  }
98
98
  }
99
99
 
100
- const hasCategory = item.categories.some((c) => profile.categories.includes(c));
100
+ const hasCategory = item.categories.some((category) => profile.categories.includes(category));
101
101
  if (hasCategory) {
102
102
  score += CATEGORY_MATCH_WEIGHT;
103
103
  }
@@ -114,7 +114,7 @@ export function scoreItem(item: SourceItem, profile: InterestsProfile): number {
114
114
  export function scoreAndFilter(items: readonly SourceItem[], profile: InterestsProfile): ScoredItem[] {
115
115
  return items
116
116
  .map((item) => ({ item, score: scoreItem(item, profile) }))
117
- .filter((s) => s.score >= profile.minRelevance)
118
- .sort((a, b) => b.score - a.score)
117
+ .filter((scoredItem) => scoredItem.score >= profile.minRelevance)
118
+ .sort((leftItem, rightItem) => rightItem.score - leftItem.score)
119
119
  .slice(0, profile.maxNotificationsPerRun);
120
120
  }
@@ -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";
@@ -92,11 +93,11 @@ export { toLocalIsoDate } from "../../../utils/date.js";
92
93
  // Convert a wall-clock millis value to the LOCAL year-month
93
94
  // key (YYYY-MM) used as the archive fallback for items without
94
95
  // a parseable publishedAt.
95
- export function toLocalYearMonth(ms: number): string {
96
- const d = new Date(ms);
97
- const y = d.getFullYear();
98
- const m = String(d.getMonth() + 1).padStart(2, "0");
99
- return `${y}-${m}`;
96
+ export function toLocalYearMonth(millis: number): string {
97
+ const date = new Date(millis);
98
+ const year = date.getFullYear();
99
+ const month = String(date.getMonth() + 1).padStart(2, "0");
100
+ return `${year}-${month}`;
100
101
  }
101
102
 
102
103
  export async function runSourcesPipeline(input: RunPipelineInput): Promise<RunPipelineResult> {
@@ -125,7 +126,7 @@ export async function runSourcesPipeline(input: RunPipelineInput): Promise<RunPi
125
126
  const allSources = await listSources(workspaceRoot);
126
127
  const statesBySlug = await readManyStates(
127
128
  workspaceRoot,
128
- allSources.map((s) => s.slug),
129
+ allSources.map((source) => source.slug),
129
130
  );
130
131
 
131
132
  // --- 2. Plan ------------------------------------------------------
@@ -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,
@@ -31,8 +31,8 @@ export interface PlanInput {
31
31
  // Sort key: slug, ascending. Deterministic ordering keeps the
32
32
  // daily summary's item sequence stable across runs for the same
33
33
  // input, which makes markdown diffs readable.
34
- function bySlug(a: Source, b: Source): number {
35
- return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
34
+ function bySlug(left: Source, right: Source): number {
35
+ return left.slug < right.slug ? -1 : left.slug > right.slug ? 1 : 0;
36
36
  }
37
37
 
38
38
  // Returns the subset of sources eligible for this cycle. Pure.
@@ -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 ts = Date.parse(state.nextAttemptAt);
64
- if (!Number.isFinite(ts)) return false;
65
- return ts > nowMs;
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);
66
+ if (!Number.isFinite(parsed)) return false;
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
 
@@ -41,7 +41,7 @@ export interface RateLimiterDeps {
41
41
  export function defaultRateLimiterDeps(): RateLimiterDeps {
42
42
  return {
43
43
  now: () => Date.now(),
44
- sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
44
+ sleep: (millis: number) => new Promise<void>((resolve) => setTimeout(resolve, millis)),
45
45
  };
46
46
  }
47
47
 
@@ -100,43 +100,43 @@ function parseValue(raw: string): string | string[] {
100
100
  if (arrayMatch) {
101
101
  return arrayMatch[1]
102
102
  .split(",")
103
- .map((s) => unquote(s.trim()))
104
- .filter((s) => s.length > 0);
103
+ .map((segment) => unquote(segment.trim()))
104
+ .filter((segment) => segment.length > 0);
105
105
  }
106
106
  return unquote(raw);
107
107
  }
108
108
 
109
- function unquote(s: string): string {
109
+ function unquote(input: string): string {
110
110
  // Double-quoted strings: yamlScalar writes JSON-compatible escape
111
111
  // sequences (\\ for \, \" for "), so JSON.parse reverses them in
112
112
  // one shot. Fall back to a plain strip if the string is
113
113
  // double-quoted but somehow malformed.
114
- if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
114
+ if (input.length >= 2 && input.startsWith('"') && input.endsWith('"')) {
115
115
  try {
116
- return JSON.parse(s);
116
+ return JSON.parse(input);
117
117
  } catch {
118
- return s.slice(1, -1);
118
+ return input.slice(1, -1);
119
119
  }
120
120
  }
121
121
  // Single-quoted scalars follow YAML's doubling convention: '' → '.
122
- if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
123
- return s.slice(1, -1).replace(/''/g, "'");
122
+ if (input.length >= 2 && input.startsWith("'") && input.endsWith("'")) {
123
+ return input.slice(1, -1).replace(/''/g, "'");
124
124
  }
125
- return s;
125
+ return input;
126
126
  }
127
127
 
128
128
  // --- Source validation / construction -----------------------------------
129
129
 
130
130
  function stringField(fields: Map<string, string | string[]>, key: string): string | null {
131
- const v = fields.get(key);
132
- return isNonEmptyString(v) ? v : null;
131
+ const value = fields.get(key);
132
+ return isNonEmptyString(value) ? value : null;
133
133
  }
134
134
 
135
135
  function numberField(fields: Map<string, string | string[]>, key: string, defaultValue: number): number {
136
- const v = fields.get(key);
137
- if (typeof v !== "string") return defaultValue;
138
- const n = Number(v);
139
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : defaultValue;
136
+ const value = fields.get(key);
137
+ if (typeof value !== "string") return defaultValue;
138
+ const parsedNumber = Number(value);
139
+ return Number.isFinite(parsedNumber) && parsedNumber > 0 ? Math.floor(parsedNumber) : defaultValue;
140
140
  }
141
141
 
142
142
  // Default per-fetch cap. Fetchers treat it as a hint — if the
@@ -302,7 +302,7 @@ export async function listSources(workspaceRoot: string): Promise<Source[]> {
302
302
  }
303
303
  }
304
304
  // Deterministic sort by slug so callers can rely on stable order.
305
- out.sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
305
+ out.sort((leftSource, rightSource) => (leftSource.slug < rightSource.slug ? -1 : leftSource.slug > rightSource.slug ? 1 : 0));
306
306
  return out;
307
307
  }
308
308
 
@@ -105,8 +105,8 @@ function applyRule(group: RobotsGroup, name: string, value: string): void {
105
105
  } else if (name === "allow") {
106
106
  group.rules.push({ kind: "allow", pattern: value });
107
107
  } else if (name === "crawl-delay") {
108
- const n = Number(value);
109
- if (Number.isFinite(n) && n >= 0) group.crawlDelaySec = n;
108
+ const seconds = Number(value);
109
+ if (Number.isFinite(seconds) && seconds >= 0) group.crawlDelaySec = seconds;
110
110
  }
111
111
  // Any other directive: ignored.
112
112
  }
@@ -138,13 +138,13 @@ function parseDirective(line: string): { name: string; value: string } | null {
138
138
  // duplicate group get ignored, which could silently let a fetcher
139
139
  // hit a path the site explicitly blocked.
140
140
  export function selectGroup(robots: ParsedRobots, userAgent: string): RobotsGroup | null {
141
- const ua = userAgent.toLowerCase();
141
+ const agent = userAgent.toLowerCase();
142
142
  const exacts: RobotsGroup[] = [];
143
143
  const stars: RobotsGroup[] = [];
144
144
  let bestPrefixScore = -1;
145
145
  let prefixMatches: RobotsGroup[] = [];
146
146
  for (const group of robots.groups) {
147
- const outcome = scoreGroupAgainstAgent(group, ua);
147
+ const outcome = scoreGroupAgainstAgent(group, agent);
148
148
  if (outcome.kind === "exact") exacts.push(outcome.group);
149
149
  else if (outcome.kind === "star") stars.push(outcome.group);
150
150
  else if (outcome.kind === "prefix") {
@@ -170,11 +170,11 @@ function mergeGroups(groups: readonly RobotsGroup[]): RobotsGroup {
170
170
  const rules: RobotsRule[] = [];
171
171
  const userAgents: string[] = [];
172
172
  let crawlDelaySec: number | null = null;
173
- for (const g of groups) {
174
- rules.push(...g.rules);
175
- userAgents.push(...g.userAgents);
176
- if (g.crawlDelaySec !== null) {
177
- crawlDelaySec = crawlDelaySec === null ? g.crawlDelaySec : Math.min(crawlDelaySec, g.crawlDelaySec);
173
+ for (const group of groups) {
174
+ rules.push(...group.rules);
175
+ userAgents.push(...group.userAgents);
176
+ if (group.crawlDelaySec !== null) {
177
+ crawlDelaySec = crawlDelaySec === null ? group.crawlDelaySec : Math.min(crawlDelaySec, group.crawlDelaySec);
178
178
  }
179
179
  }
180
180
  return { userAgents, rules, crawlDelaySec };
@@ -186,7 +186,7 @@ type AgentMatch =
186
186
  | { kind: "star"; group: RobotsGroup }
187
187
  | { kind: "none" };
188
188
 
189
- function scoreGroupAgainstAgent(group: RobotsGroup, ua: string): AgentMatch {
189
+ function scoreGroupAgainstAgent(group: RobotsGroup, agent: string): AgentMatch {
190
190
  let bestPrefix = -1;
191
191
  let hasStar = false;
192
192
  for (const listed of group.userAgents) {
@@ -194,8 +194,8 @@ function scoreGroupAgainstAgent(group: RobotsGroup, ua: string): AgentMatch {
194
194
  hasStar = true;
195
195
  continue;
196
196
  }
197
- if (listed === ua) return { kind: "exact", group };
198
- if (ua.startsWith(listed) && listed.length > bestPrefix) {
197
+ if (listed === agent) return { kind: "exact", group };
198
+ if (agent.startsWith(listed) && listed.length > bestPrefix) {
199
199
  bestPrefix = listed.length;
200
200
  }
201
201
  }
@@ -266,6 +266,6 @@ export function matchesPattern(pattern: string, path: string): number {
266
266
  .split("*")
267
267
  .map((chunk) => chunk.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
268
268
  .join(".*");
269
- const re = new RegExp("^" + regexBody + (endAnchored ? "$" : ""));
270
- return re.test(path) ? pattern.length : -1;
269
+ const regex = new RegExp("^" + regexBody + (endAnchored ? "$" : ""));
270
+ return regex.test(path) ? pattern.length : -1;
271
271
  }
@@ -47,21 +47,28 @@ export function validateSourceState(raw: unknown, slug: string): SourceState {
47
47
  if (!isRecord(raw)) {
48
48
  return defaultSourceState(slug);
49
49
  }
50
- const o = raw as Record<string, unknown>;
51
- const lastFetchedAt = typeof o.lastFetchedAt === "string" ? o.lastFetchedAt : null;
52
- const nextAttemptAt = typeof o.nextAttemptAt === "string" ? o.nextAttemptAt : null;
53
- const consecutiveFailures =
54
- typeof o.consecutiveFailures === "number" && Number.isFinite(o.consecutiveFailures) && o.consecutiveFailures >= 0 ? Math.floor(o.consecutiveFailures) : 0;
55
- const cursor = validateCursor(o.cursor);
50
+ const record = raw as Record<string, unknown>;
51
+ const lastFetchedAt = typeof record.lastFetchedAt === "string" ? record.lastFetchedAt : null;
52
+ const nextAttemptAt = typeof record.nextAttemptAt === "string" ? record.nextAttemptAt : null;
53
+ const emptyBackoffUntil = typeof record.emptyBackoffUntil === "string" ? record.emptyBackoffUntil : null;
54
+ const consecutiveFailures = toNonNegativeInt(record.consecutiveFailures);
55
+ const consecutiveEmptyFetches = toNonNegativeInt(record.consecutiveEmptyFetches);
56
+ const cursor = validateCursor(record.cursor);
56
57
  return {
57
58
  slug,
58
59
  lastFetchedAt,
59
60
  cursor,
60
61
  consecutiveFailures,
61
62
  nextAttemptAt,
63
+ consecutiveEmptyFetches,
64
+ emptyBackoffUntil,
62
65
  };
63
66
  }
64
67
 
68
+ function toNonNegativeInt(value: unknown): number {
69
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
70
+ }
71
+
65
72
  function validateCursor(raw: unknown): Record<string, string> {
66
73
  if (!isRecord(raw)) {
67
74
  return {};
@@ -127,9 +134,9 @@ export async function deleteSourceState(workspaceRoot: string, slug: string): Pr
127
134
  // deterministic regardless of which fetcher finished first.
128
135
  // Used by reporting / logging code.
129
136
  export function sortBySlug<T extends { sourceSlug?: string; slug?: string }>(items: readonly T[]): T[] {
130
- return [...items].sort((a, b) => {
131
- const ak = a.sourceSlug ?? a.slug ?? "";
132
- const bk = b.sourceSlug ?? b.slug ?? "";
133
- return ak < bk ? -1 : ak > bk ? 1 : 0;
137
+ return [...items].sort((left, right) => {
138
+ const leftKey = left.sourceSlug ?? left.slug ?? "";
139
+ const rightKey = right.sourceSlug ?? right.slug ?? "";
140
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
134
141
  });
135
142
  }
@@ -131,6 +131,13 @@ export interface SourceState {
131
131
  // Timestamp after which the next attempt is allowed, so backoff
132
132
  // survives server restarts.
133
133
  nextAttemptAt: string | null;
134
+ // Consecutive empty-success count (fetcher returned 0 items).
135
+ // Reset to 0 when items are found. Drives adaptive empty backoff.
136
+ consecutiveEmptyFetches: number;
137
+ // Timestamp after which the next attempt is allowed following
138
+ // repeated empty fetches. Separate from nextAttemptAt (error
139
+ // backoff) so the two policies don't interfere.
140
+ emptyBackoffUntil: string | null;
134
141
  }
135
142
 
136
143
  export function defaultSourceState(slug: string): SourceState {
@@ -140,5 +147,7 @@ export function defaultSourceState(slug: string): SourceState {
140
147
  cursor: {},
141
148
  consecutiveFailures: 0,
142
149
  nextAttemptAt: null,
150
+ consecutiveEmptyFetches: 0,
151
+ emptyBackoffUntil: null,
143
152
  };
144
153
  }
@@ -80,7 +80,7 @@ export function normalizeUrl(raw: string): string | null {
80
80
  // Sort remaining params for deterministic ordering. Preserve
81
81
  // multi-value params by iterating all entries.
82
82
  const entries = Array.from(url.searchParams.entries());
83
- entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
83
+ entries.sort(([leftKey], [rightKey]) => (leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0));
84
84
  // Clear and reinsert.
85
85
  for (const name of Array.from(url.searchParams.keys())) {
86
86
  url.searchParams.delete(name);
@@ -96,10 +96,10 @@ function imagePointerFromContent(content: string): string | null {
96
96
  // regardless of how the tool happened to quote it. Leave "../"
97
97
  // prefixes alone — a relative escape is a bug and we want it visible
98
98
  // rather than silently fixed up.
99
- function normalizeWorkspacePath(p: string): string {
100
- if (p.startsWith("./")) return p.slice(2);
101
- if (p.startsWith("/")) return p.slice(1);
102
- return p;
99
+ function normalizeWorkspacePath(candidatePath: string): string {
100
+ if (candidatePath.startsWith("./")) return candidatePath.slice(2);
101
+ if (candidatePath.startsWith("/")) return candidatePath.slice(1);
102
+ return candidatePath;
103
103
  }
104
104
 
105
105
  function inlineWithTruncation(content: string): Classification {
@@ -225,7 +225,7 @@ async function maybeWriteSearch(inputs: MaybeWriteSearchInputs): Promise<string
225
225
  workspaceRoot: inputs.workspaceRoot,
226
226
  query,
227
227
  sessionId: inputs.chatSessionId,
228
- ts: inputs.now,
228
+ timestamp: inputs.now,
229
229
  resultBody: inputs.content,
230
230
  });
231
231
  } catch (err) {