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.
- 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-D8rhwXLq.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/config.ts +12 -12
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +7 -6
- package/server/agent/prompt.ts +195 -29
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +10 -10
- package/server/agent/stream.ts +4 -4
- 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 +22 -21
- 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 +14 -12
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +19 -17
- package/server/api/routes/sessions.ts +26 -26
- 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/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +36 -22
- 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/events/scheduler-adapter.ts +20 -20
- package/server/events/session-store/index.ts +10 -10
- package/server/events/task-manager/index.ts +7 -7
- package/server/index.ts +59 -65
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +16 -16
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +19 -8
- package/server/utils/files/journal-io.ts +4 -4
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +12 -12
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +7 -7
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.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/indexer.ts +15 -15
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +16 -16
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/indexFile.ts +29 -25
- 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 +20 -20
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +10 -10
- package/server/workspace/sources/classifier.ts +7 -7
- package/server/workspace/sources/fetchers/arxiv.ts +7 -7
- package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
- package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +12 -12
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +8 -13
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +15 -13
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +17 -10
- 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/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +26 -16
- package/server/workspace/wiki-backlinks/index.ts +8 -8
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +315 -141
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +67 -33
- 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 +17 -14
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +6 -3
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +38 -34
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +49 -36
- package/src/components/SettingsModal.vue +24 -22
- package/src/components/SettingsReferenceDirsTab.vue +39 -34
- package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +7 -4
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +19 -14
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +17 -12
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +10 -7
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useClickOutside.ts +2 -2
- 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/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useMcpTools.ts +2 -2
- package/src/composables/useNotifications.ts +3 -3
- package/src/composables/usePdfDownload.ts +4 -4
- package/src/composables/usePendingCalls.ts +1 -1
- package/src/composables/usePubSub.ts +10 -10
- package/src/composables/useRoles.ts +1 -1
- package/src/composables/useSandboxStatus.ts +1 -1
- package/src/composables/useSessionDerived.ts +3 -3
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +20 -34
- package/src/config/roles.ts +2 -2
- 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 +5 -5
- 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 +29 -25
- package/src/plugins/manageSource/Preview.vue +2 -2
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +26 -36
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +40 -30
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +13 -10
- package/src/plugins/scheduler/TasksTab.vue +57 -28
- 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 +29 -9
- package/src/plugins/todo/Preview.vue +13 -8
- package/src/plugins/todo/View.vue +38 -24
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +10 -7
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +46 -28
- package/src/router/index.ts +41 -26
- package/src/types/session.ts +4 -3
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +22 -3
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/dom/scrollable.ts +2 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/files/sortChildren.ts +6 -6
- package/src/utils/format/frontmatter.ts +6 -6
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/utils/role/icon.ts +2 -2
- package/src/utils/role/merge.ts +2 -2
- package/src/utils/role/plugins.ts +1 -1
- package/src/utils/session/sessionFactory.ts +2 -2
- package/src/utils/session/sessionHelpers.ts +2 -2
- package/src/utils/tools/dedup.ts +4 -4
- package/src/utils/tools/result.ts +3 -3
- package/src/utils/types.ts +2 -2
- 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-KNLBjwuh.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
|
@@ -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) {
|
|
@@ -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((
|
|
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((
|
|
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
|
|
92
|
-
const
|
|
93
|
-
if (titleLower.includes(
|
|
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(
|
|
95
|
+
} else if (summaryLower.includes(keywordLower)) {
|
|
96
96
|
score += KEYWORD_SUMMARY_WEIGHT;
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
const hasCategory = item.categories.some((
|
|
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((
|
|
118
|
-
.sort((
|
|
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
|
|
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";
|
|
@@ -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(
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
return `${
|
|
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((
|
|
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 ? "
|
|
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,
|
|
@@ -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(
|
|
35
|
-
return
|
|
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).
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
|
@@ -41,7 +41,7 @@ export interface RateLimiterDeps {
|
|
|
41
41
|
export function defaultRateLimiterDeps(): RateLimiterDeps {
|
|
42
42
|
return {
|
|
43
43
|
now: () => Date.now(),
|
|
44
|
-
sleep: (
|
|
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((
|
|
104
|
-
.filter((
|
|
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(
|
|
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 (
|
|
114
|
+
if (input.length >= 2 && input.startsWith('"') && input.endsWith('"')) {
|
|
115
115
|
try {
|
|
116
|
-
return JSON.parse(
|
|
116
|
+
return JSON.parse(input);
|
|
117
117
|
} catch {
|
|
118
|
-
return
|
|
118
|
+
return input.slice(1, -1);
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
// Single-quoted scalars follow YAML's doubling convention: '' → '.
|
|
122
|
-
if (
|
|
123
|
-
return
|
|
122
|
+
if (input.length >= 2 && input.startsWith("'") && input.endsWith("'")) {
|
|
123
|
+
return input.slice(1, -1).replace(/''/g, "'");
|
|
124
124
|
}
|
|
125
|
-
return
|
|
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
|
|
132
|
-
return isNonEmptyString(
|
|
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
|
|
137
|
-
if (typeof
|
|
138
|
-
const
|
|
139
|
-
return Number.isFinite(
|
|
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((
|
|
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
|
|
109
|
-
if (Number.isFinite(
|
|
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
|
|
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,
|
|
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
|
|
174
|
-
rules.push(...
|
|
175
|
-
userAgents.push(...
|
|
176
|
-
if (
|
|
177
|
-
crawlDelaySec = crawlDelaySec === null ?
|
|
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,
|
|
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 ===
|
|
198
|
-
if (
|
|
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
|
|
270
|
-
return
|
|
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
|
|
51
|
-
const lastFetchedAt = typeof
|
|
52
|
-
const nextAttemptAt = typeof
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const
|
|
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((
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
return
|
|
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(([
|
|
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(
|
|
100
|
-
if (
|
|
101
|
-
if (
|
|
102
|
-
return
|
|
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
|
-
|
|
228
|
+
timestamp: inputs.now,
|
|
229
229
|
resultBody: inputs.content,
|
|
230
230
|
});
|
|
231
231
|
} catch (err) {
|