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.
- package/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +2 -1
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +13 -12
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +8 -6
- package/server/api/routes/schedulerTasks.ts +5 -3
- package/server/api/routes/sessions.ts +2 -2
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +22 -8
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/index.ts +40 -46
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/files/atomic.ts +11 -11
- package/server/utils/files/image-store.ts +17 -6
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +2 -7
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +286 -112
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +60 -26
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +15 -12
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/SessionHistoryPanel.vue +33 -29
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +43 -30
- package/src/components/SettingsModal.vue +21 -19
- package/src/components/SettingsReferenceDirsTab.vue +29 -24
- package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +5 -2
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +5 -2
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useViewLayout.ts +20 -34
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +26 -22
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +24 -34
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +53 -24
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +27 -7
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +42 -24
- package/src/router/index.ts +41 -26
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +19 -0
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
package/server/utils/request.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
33
|
+
const raw = readQueryKey(req.query, key);
|
|
28
34
|
return typeof raw === "string" ? raw : undefined;
|
|
29
35
|
}
|
package/server/utils/spawn.ts
CHANGED
|
@@ -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((
|
|
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 : "";
|
package/server/utils/types.ts
CHANGED
|
@@ -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((
|
|
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((
|
|
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
|
|
156
|
-
const title = typeof
|
|
157
|
-
const summary = typeof
|
|
158
|
-
const keywords = Array.isArray(
|
|
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
|
|
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 (!
|
|
106
|
-
const raw =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
if (!seenNow.has(
|
|
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((
|
|
154
|
-
date: `${
|
|
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
|
|
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(
|
|
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((
|
|
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((
|
|
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
|
|
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 :
|
|
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 :
|
|
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((
|
|
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 [
|
|
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[
|
|
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
|
|
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(
|
|
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 `
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
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
|
|
74
|
-
if (!
|
|
75
|
-
const intervalMs = value *
|
|
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
|
|
141
|
-
if (!
|
|
140
|
+
const schedule = parsed?.schedule?.parsed;
|
|
141
|
+
if (!schedule) return null;
|
|
142
142
|
return {
|
|
143
|
-
schedule
|
|
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((
|
|
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((
|
|
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((
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
111
|
-
if (!Number.isFinite(
|
|
112
|
-
if (newest === null ||
|
|
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
|
|
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:
|
|
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
|
|
293
|
-
if (Number.isFinite(
|
|
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
|
|
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 (!
|
|
45
|
-
const raw =
|
|
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
|
|
75
|
-
if (!
|
|
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] =
|
|
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
|
|
98
|
-
if (!
|
|
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] =
|
|
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
|
|
102
|
-
return Math.min(
|
|
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
|
-
// -
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// -
|
|
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
|
|
115
|
-
// -
|
|
116
|
-
// -
|
|
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 ? "
|
|
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((
|
|
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((
|
|
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).
|
|
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
|
-
//
|
|
55
|
-
//
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
134
|
-
if (!Number.isFinite(
|
|
135
|
-
const
|
|
136
|
-
const year =
|
|
137
|
-
const month = String(
|
|
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
|
|