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
@@ -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";
@@ -39,11 +39,11 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
39
39
 
40
40
  // ── Validation ──────────────────────────────────────────────────
41
41
 
42
- function expandHome(p: string): string {
43
- if (p.startsWith("~/")) {
44
- return path.join(os.homedir(), p.slice(2));
42
+ function expandHome(inputPath: string): string {
43
+ if (inputPath.startsWith("~/")) {
44
+ return path.join(homedir(), inputPath.slice(2));
45
45
  }
46
- return p;
46
+ return inputPath;
47
47
  }
48
48
 
49
49
  function isSensitivePath(absPath: string): boolean {
@@ -52,15 +52,15 @@ 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;
59
59
 
60
60
  // Block home-relative sensitive dirs
61
61
  if (
62
- HOME_RELATIVE_BLOCKED.some((bp) => {
63
- const full = path.join(home, bp);
62
+ HOME_RELATIVE_BLOCKED.some((blockedPath) => {
63
+ const full = path.join(home, blockedPath);
64
64
  return normalized === full || normalized.startsWith(full + path.sep);
65
65
  })
66
66
  ) {
@@ -68,7 +68,7 @@ function isSensitivePath(absPath: string): boolean {
68
68
  }
69
69
 
70
70
  // Block system directories
71
- return SYSTEM_BLOCKED_PREFIXES.some((p) => normalized === p || normalized.startsWith(p + path.sep));
71
+ return SYSTEM_BLOCKED_PREFIXES.some((blockedPrefix) => normalized === blockedPrefix || normalized.startsWith(blockedPrefix + path.sep));
72
72
  }
73
73
 
74
74
  function sanitizeLabel(raw: string): string {
@@ -76,8 +76,8 @@ function sanitizeLabel(raw: string): string {
76
76
  return raw.replace(CONTROL_CHAR_RE_G, " ").trim().slice(0, MAX_LABEL_LENGTH);
77
77
  }
78
78
 
79
- function hasTraversalSegment(p: string): boolean {
80
- return p.split(path.sep).some((seg) => seg === "..");
79
+ function hasTraversalSegment(inputPath: string): boolean {
80
+ return inputPath.split(path.sep).some((segment) => segment === "..");
81
81
  }
82
82
 
83
83
  function validateEntry(raw: unknown): ReferenceDirEntry | null {
@@ -117,11 +117,11 @@ export function loadReferenceDirs(root?: string): ReferenceDirEntry[] {
117
117
  const entries = parsed
118
118
  .slice(0, MAX_ENTRIES)
119
119
  .map(validateEntry)
120
- .filter((e): e is ReferenceDirEntry => {
121
- if (!e) return false;
120
+ .filter((entry): entry is ReferenceDirEntry => {
121
+ if (!entry) return false;
122
122
  // Deduplicate labels — first entry wins
123
- if (seenLabels.has(e.label)) return false;
124
- seenLabels.add(e.label);
123
+ if (seenLabels.has(entry.label)) return false;
124
+ seenLabels.add(entry.label);
125
125
  return true;
126
126
  });
127
127
 
@@ -155,8 +155,8 @@ export function validateReferenceDirs(raw: unknown): { entries: ReferenceDirEntr
155
155
  if (entry) {
156
156
  entries.push(entry);
157
157
  } else {
158
- const p = isRecord(item) ? String((item as Record<string, unknown>).hostPath ?? "") : "";
159
- errors.push(`entry ${i}: invalid or blocked path "${p}"`);
158
+ const hostPath = isRecord(item) ? String((item as Record<string, unknown>).hostPath ?? "") : "";
159
+ errors.push(`entry ${i}: invalid or blocked path "${hostPath}"`);
160
160
  }
161
161
  });
162
162
  if (errors.length > 0) {
@@ -233,9 +233,9 @@ export function buildReferenceDirsPrompt(entries: readonly ReferenceDirEntry[],
233
233
  "",
234
234
  ];
235
235
 
236
- for (const e of entries) {
237
- const mountPath = useDocker ? containerPath(e) : e.hostPath;
238
- lines.push(`- \`${mountPath}\` — ${e.label}`);
236
+ for (const entry of entries) {
237
+ const mountPath = useDocker ? containerPath(entry) : entry.hostPath;
238
+ lines.push(`- \`${mountPath}\` — ${entry.label}`);
239
239
  }
240
240
 
241
241
  if (!useDocker) {
@@ -14,10 +14,10 @@ function withSwitchRole(role: Role): Role {
14
14
 
15
15
  export function loadCustomRoles(): Role[] {
16
16
  return readdirUnderSync(workspacePath, WORKSPACE_DIRS.roles)
17
- .filter((f) => f.endsWith(".json"))
18
- .flatMap((f) => {
17
+ .filter((fileName) => fileName.endsWith(".json"))
18
+ .flatMap((fileName) => {
19
19
  try {
20
- const raw = readTextUnderSync(workspacePath, path.posix.join(WORKSPACE_DIRS.roles, f));
20
+ const raw = readTextUnderSync(workspacePath, path.posix.join(WORKSPACE_DIRS.roles, fileName));
21
21
  if (!raw) return [];
22
22
  return [withSwitchRole(RoleSchema.parse(JSON.parse(raw)))];
23
23
  } catch {
@@ -28,10 +28,10 @@ export function loadCustomRoles(): Role[] {
28
28
 
29
29
  export function loadAllRoles(): Role[] {
30
30
  const custom = loadCustomRoles();
31
- const builtIn = BUILTIN_ROLES.filter((r) => !custom.find((c) => c.id === r.id));
31
+ const builtIn = BUILTIN_ROLES.filter((role) => !custom.find((customRole) => customRole.id === role.id));
32
32
  return [...builtIn, ...custom];
33
33
  }
34
34
 
35
- export function getRole(id: string): Role {
36
- return loadAllRoles().find((r) => r.id === id) ?? BUILTIN_ROLES[0];
35
+ export function getRole(roleId: string): Role {
36
+ return loadAllRoles().find((role) => role.id === roleId) ?? BUILTIN_ROLES[0];
37
37
  }
@@ -90,7 +90,7 @@ export async function collectSkillsFromDir(root: string, source: SkillSource): P
90
90
  if (skill) results.push(skill);
91
91
  }
92
92
  // Stable alphabetical order for the UI.
93
- results.sort((a, b) => a.name.localeCompare(b.name));
93
+ results.sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name));
94
94
  return results;
95
95
  }
96
96
 
@@ -118,8 +118,8 @@ export async function discoverSkills(opts: DiscoverSkillsOptions = {}): Promise<
118
118
  // Project overrides user on name collision. Merge by building a
119
119
  // map keyed by name, starting with user, overwriting with project.
120
120
  const merged = new Map<string, Skill>();
121
- for (const s of userSkills) merged.set(s.name, s);
122
- for (const s of projectSkills) merged.set(s.name, s);
121
+ for (const skill of userSkills) merged.set(skill.name, skill);
122
+ for (const skill of projectSkills) merged.set(skill.name, skill);
123
123
 
124
- return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
124
+ return [...merged.values()].sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name));
125
125
  }
@@ -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 {
@@ -32,8 +32,8 @@ export interface PersistedUserTask {
32
32
  updatedAt: string;
33
33
  }
34
34
 
35
- export function loadUserTasks(r?: string): PersistedUserTask[] {
36
- return loadRaw<PersistedUserTask>(r);
35
+ export function loadUserTasks(workspaceRoot?: string): PersistedUserTask[] {
36
+ return loadRaw<PersistedUserTask>(workspaceRoot);
37
37
  }
38
38
 
39
39
  // ── Validation ──────────────────────────────────────────────────
@@ -42,20 +42,20 @@ function isValidDailyTime(value: string): boolean {
42
42
  return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
43
43
  }
44
44
 
45
- function isValidSchedule(s: unknown): s is LocalTaskSchedule {
46
- if (!isRecord(s)) return false;
47
- const obj = s as Record<string, unknown>;
48
- if (obj.type === SCHEDULE_TYPES.interval) {
49
- return typeof obj.intervalMs === "number" && obj.intervalMs > 0;
45
+ function isValidSchedule(scheduleValue: unknown): scheduleValue is LocalTaskSchedule {
46
+ if (!isRecord(scheduleValue)) return false;
47
+ const scheduleRecord = scheduleValue as Record<string, unknown>;
48
+ if (scheduleRecord.type === SCHEDULE_TYPES.interval) {
49
+ return typeof scheduleRecord.intervalMs === "number" && scheduleRecord.intervalMs > 0;
50
50
  }
51
- if (obj.type === SCHEDULE_TYPES.daily) {
52
- return typeof obj.time === "string" && isValidDailyTime(obj.time);
51
+ if (scheduleRecord.type === SCHEDULE_TYPES.daily) {
52
+ return typeof scheduleRecord.time === "string" && isValidDailyTime(scheduleRecord.time);
53
53
  }
54
54
  return false;
55
55
  }
56
56
 
57
- function isValidMissedRunPolicy(p: unknown): p is MissedRunPolicy {
58
- return p === MISSED_RUN_POLICIES.skip || p === MISSED_RUN_POLICIES.runOnce || p === MISSED_RUN_POLICIES.runAll;
57
+ function isValidMissedRunPolicy(policy: unknown): policy is MissedRunPolicy {
58
+ return policy === MISSED_RUN_POLICIES.skip || policy === MISSED_RUN_POLICIES.runOnce || policy === MISSED_RUN_POLICIES.runAll;
59
59
  }
60
60
 
61
61
  export type ValidateResult = { kind: "ok"; task: PersistedUserTask } | { kind: "error"; error: string };
@@ -96,44 +96,44 @@ export function validateAndCreate(input: unknown): ValidateResult {
96
96
 
97
97
  export type UpdateResult = { kind: "ok"; tasks: PersistedUserTask[] } | { kind: "error"; error: string };
98
98
 
99
- export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unknown): UpdateResult {
99
+ export function applyUpdate(tasks: PersistedUserTask[], taskId: string, patch: unknown): UpdateResult {
100
100
  if (!isRecord(patch)) {
101
101
  return { kind: "error", error: "request body required" };
102
102
  }
103
- const idx = tasks.findIndex((t) => t.id === id);
104
- if (idx === -1) {
105
- return { kind: "error", error: `task not found: ${id}` };
103
+ const index = tasks.findIndex((task) => task.id === taskId);
104
+ if (index === -1) {
105
+ return { kind: "error", error: `task not found: ${taskId}` };
106
106
  }
107
- const existing = tasks[idx];
107
+ const existing = tasks[index];
108
108
  const updated: PersistedUserTask = { ...existing };
109
109
  // patch is validated as non-null object above; spread into Record
110
- const p: Record<string, unknown> = { ...patch };
110
+ const patchRecord: Record<string, unknown> = { ...patch };
111
111
 
112
- if (typeof p.name === "string" && p.name.trim().length > 0) {
113
- updated.name = p.name.trim();
112
+ if (typeof patchRecord.name === "string" && patchRecord.name.trim().length > 0) {
113
+ updated.name = patchRecord.name.trim();
114
114
  }
115
- if (typeof p.description === "string") {
116
- updated.description = p.description.trim();
115
+ if (typeof patchRecord.description === "string") {
116
+ updated.description = patchRecord.description.trim();
117
117
  }
118
- if (isValidSchedule(p.schedule)) {
119
- updated.schedule = p.schedule;
118
+ if (isValidSchedule(patchRecord.schedule)) {
119
+ updated.schedule = patchRecord.schedule;
120
120
  }
121
- if (isValidMissedRunPolicy(p.missedRunPolicy)) {
122
- updated.missedRunPolicy = p.missedRunPolicy;
121
+ if (isValidMissedRunPolicy(patchRecord.missedRunPolicy)) {
122
+ updated.missedRunPolicy = patchRecord.missedRunPolicy;
123
123
  }
124
- if (typeof p.enabled === "boolean") {
125
- updated.enabled = p.enabled;
124
+ if (typeof patchRecord.enabled === "boolean") {
125
+ updated.enabled = patchRecord.enabled;
126
126
  }
127
- if (typeof p.roleId === "string") {
128
- updated.roleId = p.roleId;
127
+ if (typeof patchRecord.roleId === "string") {
128
+ updated.roleId = patchRecord.roleId;
129
129
  }
130
- if (typeof p.prompt === "string" && p.prompt.trim().length > 0) {
131
- updated.prompt = p.prompt.trim();
130
+ if (typeof patchRecord.prompt === "string" && patchRecord.prompt.trim().length > 0) {
131
+ updated.prompt = patchRecord.prompt.trim();
132
132
  }
133
133
  updated.updatedAt = new Date().toISOString();
134
134
 
135
135
  const next = [...tasks];
136
- next[idx] = updated;
136
+ next[index] = updated;
137
137
  return { kind: "ok", tasks: next };
138
138
  }
139
139
 
@@ -144,7 +144,7 @@ export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unkno
144
144
  let crudMutex: Promise<void> = Promise.resolve();
145
145
 
146
146
  export async function withUserTaskLock<T>(
147
- fn: (tasks: PersistedUserTask[]) => Promise<{
147
+ lockFn: (tasks: PersistedUserTask[]) => Promise<{
148
148
  tasks: PersistedUserTask[];
149
149
  result: T;
150
150
  }>,
@@ -157,7 +157,7 @@ export async function withUserTaskLock<T>(
157
157
  try {
158
158
  await prev;
159
159
  const current = loadUserTasks();
160
- const { tasks: next, result } = await fn(current);
160
+ const { tasks: next, result } = await lockFn(current);
161
161
  await saveUserTasks(next);
162
162
  await refreshUserTasks();
163
163
  return result;
@@ -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
 
@@ -33,8 +33,8 @@ const DEFAULT_MAX_ITEMS = 20;
33
33
  * Example: ["transformer", "attention"] → 'ti:"transformer" OR abs:"transformer" OR ti:"attention" OR abs:"attention"'
34
34
  */
35
35
  export function buildArxivQuery(keywords: readonly string[]): string {
36
- const terms = keywords.flatMap((kw) => {
37
- const stripped = kw.replace(/"/g, "");
36
+ const terms = keywords.flatMap((keyword) => {
37
+ const stripped = keyword.replace(/"/g, "");
38
38
  return [`ti:"${stripped}"`, `abs:"${stripped}"`];
39
39
  });
40
40
  return terms.join(" OR ");
@@ -48,8 +48,8 @@ export function buildArxivQuery(keywords: readonly string[]): string {
48
48
  */
49
49
  export function keywordsToSlug(keywords: readonly string[]): string {
50
50
  const latin = keywords
51
- .map((kw) => slugify(kw, ""))
52
- .filter((s) => s.length > 0)
51
+ .map((keyword) => slugify(keyword, ""))
52
+ .filter((slugPart) => slugPart.length > 0)
53
53
  .slice(0, 3)
54
54
  .join("-");
55
55
  // Short hash of ALL keywords ensures uniqueness even when the
@@ -88,10 +88,10 @@ 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
- const existingSlugs = new Set(existing.map((s) => s.slug));
94
+ const existingSlugs = new Set(existing.map((source) => source.slug));
95
95
 
96
96
  const registered: string[] = [];
97
97
  const skipped: string[] = [];
@@ -158,17 +158,17 @@ export async function pruneStaleAutoSources(root?: string): Promise<string[]> {
158
158
  const base = root ?? workspacePath;
159
159
  const profile = loadInterests(base);
160
160
  const existing = await listSources(base);
161
- const autoSources = existing.filter((s) => s.slug.startsWith(ARXIV_SLUG_PREFIX));
161
+ const autoSources = existing.filter((source) => source.slug.startsWith(ARXIV_SLUG_PREFIX));
162
162
 
163
163
  if (autoSources.length === 0) return [];
164
164
 
165
165
  // If no profile at all, prune everything auto-registered
166
- const currentKeywords = profile ? new Set(profile.keywords.map((k) => k.toLowerCase())) : new Set<string>();
166
+ const currentKeywords = profile ? new Set(profile.keywords.map((keyword) => keyword.toLowerCase())) : new Set<string>();
167
167
 
168
168
  const pruned: string[] = [];
169
169
  for (const source of autoSources) {
170
170
  const notes = (source.notes ?? "").toLowerCase();
171
- const hasMatch = [...currentKeywords].some((kw) => notes.includes(kw));
171
+ const hasMatch = [...currentKeywords].some((keyword) => notes.includes(keyword));
172
172
  if (!hasMatch && currentKeywords.size > 0) {
173
173
  // Keywords changed — this source is stale but don't delete,
174
174
  // just log. User can manually remove via manageSource.
@@ -115,18 +115,18 @@ export function buildClassifyPrompt(input: ClassifyInput): string {
115
115
  if (titles.length > 0) {
116
116
  lines.push("");
117
117
  lines.push("RECENT ITEM TITLES:");
118
- for (const t of titles.slice(0, 5)) {
119
- lines.push(`- ${t}`);
118
+ for (const title of titles.slice(0, 5)) {
119
+ lines.push(`- ${title}`);
120
120
  }
121
121
  }
122
122
  const summaries = input.sampleSummaries ?? [];
123
123
  if (summaries.length > 0) {
124
124
  lines.push("");
125
125
  lines.push("RECENT ITEM SUMMARIES:");
126
- for (const s of summaries.slice(0, 3)) {
126
+ for (const summary of summaries.slice(0, 3)) {
127
127
  // One-line truncation so a single long abstract doesn't
128
128
  // dominate the prompt budget.
129
- lines.push(`- ${s.replace(/\s+/g, " ").slice(0, 200)}`);
129
+ lines.push(`- ${summary.replace(/\s+/g, " ").slice(0, 200)}`);
130
130
  }
131
131
  }
132
132
  return lines.join("\n");
@@ -167,8 +167,8 @@ export function validateClassifyResult(obj: unknown): ClassifyResult {
167
167
  if (!isRecord(obj)) {
168
168
  throw new Error("[sources/classifier] output is not an object");
169
169
  }
170
- const o = obj as Record<string, unknown>;
171
- const categories = normalizeCategories(o.categories);
170
+ const record = obj as Record<string, unknown>;
171
+ const categories = normalizeCategories(record.categories);
172
172
  if (categories.length === 0) {
173
173
  // The model is required to pick at least one (min_items=1 in
174
174
  // the schema). If we end up here, something went wrong upstream
@@ -178,7 +178,7 @@ export function validateClassifyResult(obj: unknown): ClassifyResult {
178
178
  // with no categories.
179
179
  throw new Error("[sources/classifier] output has no valid categories from the taxonomy");
180
180
  }
181
- const rationale = typeof o.rationale === "string" ? o.rationale.slice(0, 400) : "";
181
+ const rationale = typeof record.rationale === "string" ? record.rationale.slice(0, 400) : "";
182
182
  return { categories, rationale };
183
183
  }
184
184
 
@@ -90,8 +90,8 @@ export function normalizeArxivFeed(feed: ParsedFeed, source: Source, cursor: Rec
90
90
  function parseCursorTs(cursor: Record<string, string>): number | null {
91
91
  const raw = cursor[ARXIV_CURSOR_KEY];
92
92
  if (!raw) return null;
93
- const ts = Date.parse(raw);
94
- return Number.isFinite(ts) ? ts : null;
93
+ const parsed = Date.parse(raw);
94
+ return Number.isFinite(parsed) ? parsed : null;
95
95
  }
96
96
 
97
97
  // Decide whether one ParsedFeedItem produces a SourceItem given
@@ -104,8 +104,8 @@ function feedItemToSourceItem(entry: ParsedFeed["items"][number], source: Source
104
104
  const normalizedUrl = normalizeUrl(entry.link);
105
105
  if (!normalizedUrl) return null;
106
106
  if (entry.publishedAt && lastSeenTs !== null) {
107
- const ts = Date.parse(entry.publishedAt);
108
- if (Number.isFinite(ts) && ts <= lastSeenTs) return null;
107
+ const publishedMs = Date.parse(entry.publishedAt);
108
+ if (Number.isFinite(publishedMs) && publishedMs <= lastSeenTs) return null;
109
109
  }
110
110
  const publishedAt = entry.publishedAt ?? new Date().toISOString();
111
111
  return {
@@ -128,9 +128,9 @@ export function updateArxivCursor(current: Record<string, string>, feed: ParsedF
128
128
  let newest: number | null = null;
129
129
  for (const entry of feed.items) {
130
130
  if (!entry.publishedAt) continue;
131
- const ts = Date.parse(entry.publishedAt);
132
- if (!Number.isFinite(ts)) continue;
133
- if (newest === null || ts > newest) newest = ts;
131
+ const publishedMs = Date.parse(entry.publishedAt);
132
+ if (!Number.isFinite(publishedMs)) continue;
133
+ if (newest === null || publishedMs > newest) newest = publishedMs;
134
134
  }
135
135
  if (newest === null) return current;
136
136
  const currentTs = current[ARXIV_CURSOR_KEY] ? Date.parse(current[ARXIV_CURSOR_KEY]) : -Infinity;
@@ -69,7 +69,7 @@ interface ParsedIssue {
69
69
  // signal.
70
70
  export function parseGithubIssue(raw: unknown): ParsedIssue | null {
71
71
  if (!isRecord(raw)) return null;
72
- const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
72
+ const issueId = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
73
73
  const issueNumber = typeof raw.number === "number" && Number.isFinite(raw.number) ? raw.number : null;
74
74
  const title = typeof raw.title === "string" ? raw.title : null;
75
75
  const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
@@ -81,7 +81,7 @@ export function parseGithubIssue(raw: unknown): ParsedIssue | null {
81
81
  // object) means this is a PR. Absence means it's an issue.
82
82
  const isPr = "pull_request" in raw && raw.pull_request !== undefined && raw.pull_request !== null;
83
83
  return {
84
- id,
84
+ id: issueId,
85
85
  number: issueNumber,
86
86
  title,
87
87
  htmlUrl,
@@ -110,7 +110,7 @@ export function issueToSourceItem(issue: ParsedIssue, source: Source, params: Is
110
110
 
111
111
  const normalizedUrl = normalizeUrl(issue.htmlUrl);
112
112
  if (!normalizedUrl) return null;
113
- const id = stableItemId(normalizedUrl);
113
+ const itemId = stableItemId(normalizedUrl);
114
114
 
115
115
  // Title annotations: `[PR]` for pulls, `[closed]` for closed
116
116
  // state so the daily summary makes state visible at a glance.
@@ -123,7 +123,7 @@ export function issueToSourceItem(issue: ParsedIssue, source: Source, params: Is
123
123
  const summary = issue.body ? firstParagraph(issue.body) : null;
124
124
 
125
125
  return {
126
- id,
126
+ id: itemId,
127
127
  title,
128
128
  url: normalizedUrl,
129
129
  publishedAt: new Date(updatedTs).toISOString(),
@@ -139,9 +139,9 @@ export function updateIssuesCursor(current: Record<string, string>, issues: read
139
139
  for (const issue of issues) {
140
140
  if (issue.isPr && !params.includePrs) continue;
141
141
  if (!issue.updatedAt) continue;
142
- const ts = Date.parse(issue.updatedAt);
143
- if (!Number.isFinite(ts)) continue;
144
- if (newest === null || ts > newest) newest = ts;
142
+ const updatedMs = Date.parse(issue.updatedAt);
143
+ if (!Number.isFinite(updatedMs)) continue;
144
+ if (newest === null || updatedMs > newest) newest = updatedMs;
145
145
  }
146
146
  if (newest === null) return current;
147
147
  const currentTs = current[ISSUES_CURSOR_KEY] ? Date.parse(current[ISSUES_CURSOR_KEY]) : -Infinity;
@@ -52,7 +52,7 @@ interface ParsedRelease {
52
52
  // hitting the network.
53
53
  export function parseGithubRelease(raw: unknown): ParsedRelease | null {
54
54
  if (!isRecord(raw)) return null;
55
- const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
55
+ const releaseId = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
56
56
  const name = typeof raw.name === "string" ? raw.name : null;
57
57
  const tagName = typeof raw.tag_name === "string" ? raw.tag_name : null;
58
58
  const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
@@ -60,7 +60,7 @@ export function parseGithubRelease(raw: unknown): ParsedRelease | null {
60
60
  const publishedAt = typeof raw.published_at === "string" ? raw.published_at : null;
61
61
  const draft = raw.draft === true;
62
62
  const prerelease = raw.prerelease === true;
63
- return { id, name, tagName, htmlUrl, body, publishedAt, draft, prerelease };
63
+ return { id: releaseId, name, tagName, htmlUrl, body, publishedAt, draft, prerelease };
64
64
  }
65
65
 
66
66
  // Build a SourceItem from a parsed release + the parent Source.
@@ -82,7 +82,7 @@ export function releaseToSourceItem(release: ParsedRelease, source: Source, last
82
82
 
83
83
  const normalizedUrl = normalizeUrl(release.htmlUrl);
84
84
  if (!normalizedUrl) return null;
85
- const id = stableItemId(normalizedUrl);
85
+ const itemId = stableItemId(normalizedUrl);
86
86
 
87
87
  // Title resolution: prefer <name> (release display name), fall
88
88
  // back to <tag_name> (e.g. "v1.2.3"). Annotate pre-releases so
@@ -92,7 +92,7 @@ export function releaseToSourceItem(release: ParsedRelease, source: Source, last
92
92
  const summary = release.body ? firstParagraph(release.body) : null;
93
93
 
94
94
  return {
95
- id,
95
+ id: itemId,
96
96
  title,
97
97
  url: normalizedUrl,
98
98
  publishedAt: new Date(publishedTs).toISOString(),
@@ -128,9 +128,9 @@ export function updateReleasesCursor(current: Record<string, string>, releases:
128
128
  for (const release of releases) {
129
129
  if (release.draft) continue;
130
130
  if (!release.publishedAt) continue;
131
- const ts = Date.parse(release.publishedAt);
132
- if (!Number.isFinite(ts)) continue;
133
- if (newest === null || ts > newest) newest = ts;
131
+ const publishedMs = Date.parse(release.publishedAt);
132
+ if (!Number.isFinite(publishedMs)) continue;
133
+ if (newest === null || publishedMs > newest) newest = publishedMs;
134
134
  }
135
135
  if (newest === null) return current;
136
136
  const currentTs = current[RELEASES_CURSOR_KEY] ? Date.parse(current[RELEASES_CURSOR_KEY]) : -Infinity;