mulmoclaude 0.1.2 → 0.3.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 +1 -1
- package/client/assets/{index-KNLBjwuh.css → index-Bm70FDU2.css} +1 -1
- package/client/assets/{index-D8rhwXLq.js → index-eHWB79u5.js} +3 -3
- package/client/index.html +2 -2
- package/package.json +1 -1
- package/server/agent/config.ts +12 -12
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/x.ts +5 -5
- package/server/agent/prompt.ts +9 -4
- package/server/agent/sandboxMounts.ts +7 -7
- package/server/agent/stream.ts +4 -4
- package/server/api/routes/files.ts +9 -9
- package/server/api/routes/scheduler.ts +8 -8
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +14 -14
- package/server/api/routes/sessions.ts +24 -24
- package/server/api/routes/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/wiki.ts +14 -14
- 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 +19 -19
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +9 -9
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +2 -2
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/roles-io.ts +10 -10
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +2 -2
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.ts +5 -5
- package/server/workspace/chat-index/indexer.ts +15 -15
- package/server/workspace/custom-dirs.ts +11 -11
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/indexFile.ts +29 -25
- package/server/workspace/reference-dirs.ts +18 -18
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/sources/arxivDiscovery.ts +8 -8
- 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/interests.ts +9 -9
- package/server/workspace/sources/pipeline/index.ts +6 -6
- package/server/workspace/sources/pipeline/plan.ts +5 -5
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +11 -9
- 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/src/App.vue +30 -30
- package/src/components/ChatInput.vue +7 -7
- package/src/components/LockStatusPopup.vue +2 -2
- package/src/components/NotificationToast.vue +2 -2
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +6 -6
- package/src/components/SettingsMcpTab.vue +7 -7
- package/src/components/SettingsModal.vue +3 -3
- package/src/components/SettingsReferenceDirsTab.vue +10 -10
- package/src/components/SettingsWorkspaceDirsTab.vue +5 -5
- package/src/components/SuggestionsPanel.vue +2 -2
- package/src/components/todo/TodoAddDialog.vue +2 -2
- package/src/components/todo/TodoEditPanel.vue +2 -2
- package/src/components/todo/TodoListView.vue +5 -5
- package/src/composables/useCanvasViewMode.ts +5 -5
- package/src/composables/useClickOutside.ts +2 -2
- package/src/composables/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- 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/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +2 -2
- package/src/config/roles.ts +2 -2
- package/src/plugins/chart/Preview.vue +4 -4
- package/src/plugins/manageSkills/View.vue +3 -3
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +2 -2
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/View.vue +4 -4
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +6 -6
- package/src/plugins/scheduler/TasksTab.vue +4 -4
- package/src/plugins/textResponse/View.vue +2 -2
- package/src/plugins/todo/Preview.vue +2 -2
- package/src/plugins/todo/View.vue +11 -11
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/wiki/Preview.vue +5 -5
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/router/guards.ts +12 -12
- package/src/types/session.ts +4 -3
- package/src/utils/agent/request.ts +3 -3
- 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/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- 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
|
@@ -60,11 +60,11 @@ export const runClaudeCli: Summarize = async (systemPrompt, userPrompt) => {
|
|
|
60
60
|
child.kill("SIGKILL");
|
|
61
61
|
}, CLI_TIMEOUT_MS);
|
|
62
62
|
|
|
63
|
-
child.stdout.on("data", (
|
|
64
|
-
stdout +=
|
|
63
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
64
|
+
stdout += chunk.toString();
|
|
65
65
|
});
|
|
66
|
-
child.stderr.on("data", (
|
|
67
|
-
stderr +=
|
|
66
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
67
|
+
stderr += chunk.toString();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
child.on("error", (err: Error & { code?: string }) => {
|
|
@@ -234,8 +234,8 @@ export function buildDailyUserPrompt(input: DailyArchivistInput): string {
|
|
|
234
234
|
if (input.existingTopicSummaries.length === 0) {
|
|
235
235
|
parts.push("(none yet)");
|
|
236
236
|
} else {
|
|
237
|
-
for (const
|
|
238
|
-
parts.push(`- ${
|
|
237
|
+
for (const topicSummary of input.existingTopicSummaries) {
|
|
238
|
+
parts.push(`- ${topicSummary.slug}`);
|
|
239
239
|
}
|
|
240
240
|
}
|
|
241
241
|
parts.push("");
|
|
@@ -244,24 +244,24 @@ export function buildDailyUserPrompt(input: DailyArchivistInput): string {
|
|
|
244
244
|
// sessions produced, deduped and sorted. Given to the archivist
|
|
245
245
|
// so it can link to them from the summary text.
|
|
246
246
|
const allArtifacts = new Set<string>();
|
|
247
|
-
for (const
|
|
248
|
-
for (const
|
|
247
|
+
for (const sessionExcerpt of input.sessionExcerpts) {
|
|
248
|
+
for (const artifactPath of sessionExcerpt.artifactPaths) allArtifacts.add(artifactPath);
|
|
249
249
|
}
|
|
250
250
|
parts.push("ARTIFACTS REFERENCED:");
|
|
251
251
|
if (allArtifacts.size === 0) {
|
|
252
252
|
parts.push("(none)");
|
|
253
253
|
} else {
|
|
254
|
-
for (const
|
|
255
|
-
parts.push(`- ${
|
|
254
|
+
for (const artifactPath of [...allArtifacts].sort()) {
|
|
255
|
+
parts.push(`- ${artifactPath}`);
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
parts.push("");
|
|
259
259
|
|
|
260
260
|
parts.push("SESSION EXCERPTS:");
|
|
261
|
-
for (const
|
|
262
|
-
parts.push(`### session ${
|
|
263
|
-
for (const
|
|
264
|
-
parts.push(`- [${
|
|
261
|
+
for (const sessionExcerpt of input.sessionExcerpts) {
|
|
262
|
+
parts.push(`### session ${sessionExcerpt.sessionId} (role: ${sessionExcerpt.roleId})`);
|
|
263
|
+
for (const eventExcerpt of sessionExcerpt.events) {
|
|
264
|
+
parts.push(`- [${eventExcerpt.source}/${eventExcerpt.type}] ${eventExcerpt.content}`);
|
|
265
265
|
}
|
|
266
266
|
parts.push("");
|
|
267
267
|
}
|
|
@@ -324,10 +324,10 @@ LANGUAGE
|
|
|
324
324
|
export function buildOptimizationUserPrompt(input: OptimizationInput): string {
|
|
325
325
|
const parts: string[] = [];
|
|
326
326
|
parts.push("CURRENT TOPICS:");
|
|
327
|
-
for (const
|
|
328
|
-
parts.push(`### ${
|
|
327
|
+
for (const topic of input.topics) {
|
|
328
|
+
parts.push(`### ${topic.slug}`);
|
|
329
329
|
parts.push("```md");
|
|
330
|
-
parts.push(
|
|
330
|
+
parts.push(topic.headContent);
|
|
331
331
|
parts.push("```");
|
|
332
332
|
parts.push("");
|
|
333
333
|
}
|
|
@@ -352,35 +352,35 @@ import { isRecord } from "../../utils/types.js";
|
|
|
352
352
|
// guards rather than `as` casts per project conventions.
|
|
353
353
|
export function isDailyArchivistOutput(value: unknown): value is DailyArchivistOutput {
|
|
354
354
|
if (!isRecord(value)) return false;
|
|
355
|
-
const
|
|
356
|
-
if (typeof
|
|
357
|
-
if (!Array.isArray(
|
|
358
|
-
return
|
|
355
|
+
const recordValue = value as Record<string, unknown>;
|
|
356
|
+
if (typeof recordValue.dailySummaryMarkdown !== "string") return false;
|
|
357
|
+
if (!Array.isArray(recordValue.topicUpdates)) return false;
|
|
358
|
+
return recordValue.topicUpdates.every(isTopicUpdate);
|
|
359
359
|
}
|
|
360
360
|
|
|
361
361
|
function isTopicUpdate(value: unknown): value is TopicUpdate {
|
|
362
362
|
if (!isRecord(value)) return false;
|
|
363
|
-
const
|
|
364
|
-
if (typeof
|
|
365
|
-
if (typeof
|
|
366
|
-
return
|
|
363
|
+
const recordValue = value as Record<string, unknown>;
|
|
364
|
+
if (typeof recordValue.slug !== "string") return false;
|
|
365
|
+
if (typeof recordValue.content !== "string") return false;
|
|
366
|
+
return recordValue.action === "create" || recordValue.action === "append" || recordValue.action === "rewrite";
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
export function isOptimizationOutput(value: unknown): value is OptimizationOutput {
|
|
370
370
|
if (!isRecord(value)) return false;
|
|
371
|
-
const
|
|
372
|
-
if (!Array.isArray(
|
|
373
|
-
if (!Array.isArray(
|
|
374
|
-
if (!
|
|
375
|
-
return
|
|
371
|
+
const recordValue = value as Record<string, unknown>;
|
|
372
|
+
if (!Array.isArray(recordValue.merges)) return false;
|
|
373
|
+
if (!Array.isArray(recordValue.archives)) return false;
|
|
374
|
+
if (!recordValue.merges.every(isTopicMerge)) return false;
|
|
375
|
+
return recordValue.archives.every((archiveSlug: unknown) => typeof archiveSlug === "string");
|
|
376
376
|
}
|
|
377
377
|
|
|
378
378
|
function isTopicMerge(value: unknown): value is TopicMerge {
|
|
379
379
|
if (!isRecord(value)) return false;
|
|
380
|
-
const
|
|
381
|
-
if (!Array.isArray(
|
|
382
|
-
if (!
|
|
383
|
-
if (typeof
|
|
384
|
-
if (typeof
|
|
380
|
+
const recordValue = value as Record<string, unknown>;
|
|
381
|
+
if (!Array.isArray(recordValue.from)) return false;
|
|
382
|
+
if (!recordValue.from.every((fromSlug: unknown) => typeof fromSlug === "string")) return false;
|
|
383
|
+
if (typeof recordValue.into !== "string") return false;
|
|
384
|
+
if (typeof recordValue.newContent !== "string") return false;
|
|
385
385
|
return true;
|
|
386
386
|
}
|
|
@@ -81,7 +81,7 @@ export async function runDailyPass(state: JournalState, deps: DailyPassDeps): Pr
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
// --- Phase 1: figure out what work there is to do ------------------
|
|
84
|
-
const eligible = (await listSessionMetas(chatDir)).filter((
|
|
84
|
+
const eligible = (await listSessionMetas(chatDir)).filter((sessionMeta) => !deps.activeSessionIds.has(sessionMeta.id));
|
|
85
85
|
const { dirty } = findDirtySessions(eligible, state.processedSessions);
|
|
86
86
|
if (dirty.length === 0) return { nextState: { ...state }, result };
|
|
87
87
|
|
|
@@ -110,7 +110,7 @@ export async function runDailyPass(state: JournalState, deps: DailyPassDeps): Pr
|
|
|
110
110
|
...state,
|
|
111
111
|
knownTopics: [...newTopicsSeen].sort(),
|
|
112
112
|
};
|
|
113
|
-
const dirtyMetaById = new Map(eligible.map((
|
|
113
|
+
const dirtyMetaById = new Map(eligible.map((sessionMeta) => [sessionMeta.id, sessionMeta]));
|
|
114
114
|
// Process days in chronological order so topic state accumulates
|
|
115
115
|
// naturally: an earlier day's update is visible to the next day.
|
|
116
116
|
const orderedDays = [...dayBuckets.keys()].sort();
|
|
@@ -191,7 +191,7 @@ async function processDayAndAdvance(input: ProcessDayInput): Promise<ProcessDayO
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
const justCompleted = computeJustCompletedSessions(input.date, excerpts, input.sessionToDays, input.dirtyMetaById);
|
|
194
|
-
const sessionsIngested = justCompleted.map((
|
|
194
|
+
const sessionsIngested = justCompleted.map((sessionMeta) => sessionMeta.id);
|
|
195
195
|
const nextState = advanceJournalState(input.nextState, justCompleted, input.newTopicsSeen);
|
|
196
196
|
await persistStateAfterDay(input.workspaceRoot, nextState, input.date);
|
|
197
197
|
|
|
@@ -218,7 +218,9 @@ async function maybeExtractMemory(
|
|
|
218
218
|
const excerptLines: string[] = [];
|
|
219
219
|
for (const [, byDate] of perSessionExcerpts) {
|
|
220
220
|
for (const [, excerpt] of byDate) {
|
|
221
|
-
const userLines = excerpt.events
|
|
221
|
+
const userLines = excerpt.events
|
|
222
|
+
.filter((eventExcerpt: SessionEventExcerpt) => eventExcerpt.source === "user")
|
|
223
|
+
.map((eventExcerpt: SessionEventExcerpt) => `[user] ${eventExcerpt.content}`);
|
|
222
224
|
if (userLines.length > 0) excerptLines.push(userLines.join("\n"));
|
|
223
225
|
}
|
|
224
226
|
}
|
|
@@ -362,7 +364,7 @@ async function refreshTopicSnapshot(workspaceRoot: string, slug: string, existin
|
|
|
362
364
|
const newBody = await readTopicFile(slug, workspaceRoot);
|
|
363
365
|
if (newBody === null) return;
|
|
364
366
|
const snapshot: ExistingTopicSnapshot = { slug, content: newBody };
|
|
365
|
-
const idx = existingTopics.findIndex((
|
|
367
|
+
const idx = existingTopics.findIndex((topic) => topic.slug === slug);
|
|
366
368
|
if (idx === -1) existingTopics.push(snapshot);
|
|
367
369
|
else existingTopics[idx] = snapshot;
|
|
368
370
|
}
|
|
@@ -427,7 +429,7 @@ export function buildDayBuckets(perSessionExcerpts: ReadonlyMap<string, Readonly
|
|
|
427
429
|
// to the target topic file's location.
|
|
428
430
|
export function normalizeTopicAction(update: TopicUpdate, existingTopics: readonly ExistingTopicSnapshot[]): TopicUpdate {
|
|
429
431
|
const canonicalSlug = slugify(update.slug);
|
|
430
|
-
const exists = existingTopics.some((
|
|
432
|
+
const exists = existingTopics.some((topic) => topic.slug === canonicalSlug);
|
|
431
433
|
const topicFileWsPath = path.posix.join(WORKSPACE_DIRS.summaries, "topics", `${canonicalSlug}.md`);
|
|
432
434
|
return {
|
|
433
435
|
slug: canonicalSlug,
|
|
@@ -527,10 +529,10 @@ async function listSessionMetas(chatDir: string): Promise<SessionFileMeta[]> {
|
|
|
527
529
|
if (!name.endsWith(".jsonl")) continue;
|
|
528
530
|
const full = path.join(chatDir, name);
|
|
529
531
|
try {
|
|
530
|
-
const
|
|
532
|
+
const stats = await fsp.stat(full);
|
|
531
533
|
out.push({
|
|
532
534
|
id: name.replace(/\.jsonl$/, ""),
|
|
533
|
-
mtimeMs:
|
|
535
|
+
mtimeMs: stats.mtimeMs,
|
|
534
536
|
});
|
|
535
537
|
} catch {
|
|
536
538
|
// file vanished between readdir and stat — ignore
|
|
@@ -607,8 +609,8 @@ export function bucketParsedEvents(events: readonly ParsedEntry[], sessionId: st
|
|
|
607
609
|
buckets.set(fallbackDate, bucket);
|
|
608
610
|
}
|
|
609
611
|
bucket.events.push(parsed.excerpt);
|
|
610
|
-
for (const
|
|
611
|
-
if (!bucket.artifactPaths.includes(
|
|
612
|
+
for (const artifactPath of parsed.artifactPaths) {
|
|
613
|
+
if (!bucket.artifactPaths.includes(artifactPath)) bucket.artifactPaths.push(artifactPath);
|
|
612
614
|
}
|
|
613
615
|
}
|
|
614
616
|
return buckets;
|
|
@@ -662,9 +664,10 @@ export function entryToExcerpt(entry: Record<string, unknown>): SessionEventExce
|
|
|
662
664
|
// to avoid a NullPointerException-style crash when accessing
|
|
663
665
|
// r.toolName below.
|
|
664
666
|
if (type === EVENT_TYPES.toolResult && isRecord(entry.result)) {
|
|
665
|
-
const
|
|
666
|
-
const toolName = typeof
|
|
667
|
-
const label =
|
|
667
|
+
const resultRecord = entry.result as Record<string, unknown>;
|
|
668
|
+
const toolName = typeof resultRecord.toolName === "string" ? resultRecord.toolName : "tool";
|
|
669
|
+
const label =
|
|
670
|
+
(typeof resultRecord.title === "string" && resultRecord.title) || (typeof resultRecord.message === "string" && resultRecord.message) || "(no message)";
|
|
668
671
|
return {
|
|
669
672
|
source,
|
|
670
673
|
type,
|
|
@@ -682,23 +685,23 @@ export function extractArtifactPaths(entry: Record<string, unknown>): string[] {
|
|
|
682
685
|
if (entry.type !== "tool_result") return [];
|
|
683
686
|
const result = entry.result;
|
|
684
687
|
if (!isRecord(result)) return [];
|
|
685
|
-
const
|
|
686
|
-
const data =
|
|
688
|
+
const resultRecord = result as Record<string, unknown>;
|
|
689
|
+
const data = resultRecord.data;
|
|
687
690
|
if (!isRecord(data)) return [];
|
|
688
|
-
const
|
|
691
|
+
const dataRecord = data as Record<string, unknown>;
|
|
689
692
|
const paths: string[] = [];
|
|
690
693
|
|
|
691
694
|
// Direct `filePath: string` — presentMulmoScript, presentHtml.
|
|
692
|
-
if (typeof
|
|
693
|
-
paths.push(
|
|
695
|
+
if (typeof dataRecord.filePath === "string" && dataRecord.filePath.length > 0) {
|
|
696
|
+
paths.push(dataRecord.filePath);
|
|
694
697
|
}
|
|
695
698
|
|
|
696
699
|
// Wiki uses `pageName: string` and stores the page at
|
|
697
700
|
// `wiki/pages/<pageName>.md`. The plugin itself doesn't surface
|
|
698
701
|
// the full path in the result, so we synthesise it from the
|
|
699
702
|
// convention established in server/routes/wiki.ts.
|
|
700
|
-
if (
|
|
701
|
-
paths.push(`wiki/pages/${
|
|
703
|
+
if (resultRecord.toolName === "manageWiki" && typeof dataRecord.pageName === "string") {
|
|
704
|
+
paths.push(`wiki/pages/${dataRecord.pageName}.md`);
|
|
702
705
|
}
|
|
703
706
|
|
|
704
707
|
// Paths must be workspace-relative (not absolute, no escape).
|
|
@@ -709,18 +712,18 @@ export function extractArtifactPaths(entry: Record<string, unknown>): string[] {
|
|
|
709
712
|
// Defensive: refuse absolute paths, parent-escapes, or scheme-like
|
|
710
713
|
// strings. Protects against a malformed tool result wedging a
|
|
711
714
|
// filesystem-absolute path into the archivist prompt.
|
|
712
|
-
function isSafeWorkspacePath(
|
|
713
|
-
if (!
|
|
714
|
-
if (
|
|
715
|
-
if (
|
|
716
|
-
if (
|
|
715
|
+
function isSafeWorkspacePath(candidatePath: string): boolean {
|
|
716
|
+
if (!candidatePath) return false;
|
|
717
|
+
if (candidatePath.startsWith("/")) return false;
|
|
718
|
+
if (candidatePath.startsWith("..")) return false;
|
|
719
|
+
if (candidatePath.includes("://")) return false;
|
|
717
720
|
return true;
|
|
718
721
|
}
|
|
719
722
|
|
|
720
|
-
function truncate(
|
|
723
|
+
function truncate(text: string, max: number): string {
|
|
721
724
|
if (max <= 0) return "";
|
|
722
|
-
if (
|
|
723
|
-
return `${
|
|
725
|
+
if (text.length <= max) return text;
|
|
726
|
+
return `${text.slice(0, max - 1)}…`;
|
|
724
727
|
}
|
|
725
728
|
|
|
726
729
|
async function readAllTopics(workspaceRoot: string): Promise<ExistingTopicSnapshot[]> {
|
|
@@ -59,8 +59,8 @@ export function renderTopicsSection(topics: readonly IndexTopicEntry[]): string[
|
|
|
59
59
|
// Newest-first by last update (topics with no timestamp sort
|
|
60
60
|
// last, ordered alphabetically among themselves for stability).
|
|
61
61
|
const sorted = [...topics].sort(compareTopicsNewestFirst);
|
|
62
|
-
for (const
|
|
63
|
-
lines.push(renderTopicRow(
|
|
62
|
+
for (const topicEntry of sorted) {
|
|
63
|
+
lines.push(renderTopicRow(topicEntry));
|
|
64
64
|
}
|
|
65
65
|
return lines;
|
|
66
66
|
}
|
|
@@ -72,10 +72,14 @@ export function renderRecentDaysSection(days: readonly IndexDailyEntry[], maxRec
|
|
|
72
72
|
return lines;
|
|
73
73
|
}
|
|
74
74
|
// Newest-first by date string (YYYY-MM-DD sorts lexically).
|
|
75
|
-
const sorted = [...days].sort((
|
|
75
|
+
const sorted = [...days].sort((leftDay, rightDay) => {
|
|
76
|
+
if (leftDay.date < rightDay.date) return 1;
|
|
77
|
+
if (leftDay.date > rightDay.date) return -1;
|
|
78
|
+
return 0;
|
|
79
|
+
});
|
|
76
80
|
const head = sorted.slice(0, maxRecent);
|
|
77
|
-
for (const
|
|
78
|
-
lines.push(renderDailyRow(
|
|
81
|
+
for (const dayEntry of head) {
|
|
82
|
+
lines.push(renderDailyRow(dayEntry));
|
|
79
83
|
}
|
|
80
84
|
const rest = sorted.length - head.length;
|
|
81
85
|
if (rest > 0) {
|
|
@@ -100,37 +104,37 @@ export function renderArchiveSection(archivedTopicCount: number): string[] {
|
|
|
100
104
|
// timestamps get -Infinity so they sort to the bottom (oldest).
|
|
101
105
|
function topicSortKey(entry: IndexTopicEntry): number {
|
|
102
106
|
if (!entry.lastUpdatedIso) return -Infinity;
|
|
103
|
-
const
|
|
104
|
-
return Number.isNaN(
|
|
107
|
+
const timestampMs = Date.parse(entry.lastUpdatedIso);
|
|
108
|
+
return Number.isNaN(timestampMs) ? -Infinity : timestampMs;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
function compareBySlug(
|
|
108
|
-
if (
|
|
109
|
-
if (
|
|
111
|
+
function compareBySlug(leftEntry: IndexTopicEntry, rightEntry: IndexTopicEntry): number {
|
|
112
|
+
if (leftEntry.slug < rightEntry.slug) return -1;
|
|
113
|
+
if (leftEntry.slug > rightEntry.slug) return 1;
|
|
110
114
|
return 0;
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
function compareTopicsNewestFirst(
|
|
114
|
-
const
|
|
115
|
-
const
|
|
117
|
+
function compareTopicsNewestFirst(leftEntry: IndexTopicEntry, rightEntry: IndexTopicEntry): number {
|
|
118
|
+
const leftKey = topicSortKey(leftEntry);
|
|
119
|
+
const rightKey = topicSortKey(rightEntry);
|
|
116
120
|
// Both valid timestamps → compare numerically.
|
|
117
121
|
// One or both invalid (-Infinity) → valid wins; if both invalid,
|
|
118
122
|
// fall through to the slug tie-breaker.
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
if (
|
|
123
|
+
const leftIsValid = Number.isFinite(leftKey);
|
|
124
|
+
const rightIsValid = Number.isFinite(rightKey);
|
|
125
|
+
if (leftIsValid && rightIsValid && rightKey !== leftKey) return rightKey - leftKey;
|
|
126
|
+
if (leftIsValid !== rightIsValid) return leftIsValid ? -1 : 1;
|
|
123
127
|
// Tie-break on slug for determinism.
|
|
124
|
-
return compareBySlug(
|
|
128
|
+
return compareBySlug(leftEntry, rightEntry);
|
|
125
129
|
}
|
|
126
130
|
|
|
127
|
-
function renderTopicRow(
|
|
128
|
-
const label =
|
|
129
|
-
const stamp =
|
|
130
|
-
return `- [${label}](topics/${
|
|
131
|
+
function renderTopicRow(topicEntry: IndexTopicEntry): string {
|
|
132
|
+
const label = topicEntry.title && topicEntry.title.trim().length > 0 ? topicEntry.title : topicEntry.slug;
|
|
133
|
+
const stamp = topicEntry.lastUpdatedIso ? ` — updated ${isoDateOnly(topicEntry.lastUpdatedIso)}` : "";
|
|
134
|
+
return `- [${label}](topics/${topicEntry.slug}.md)${stamp}`;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
|
-
function renderDailyRow(
|
|
134
|
-
const [year, month, day] =
|
|
135
|
-
return `- [${
|
|
137
|
+
function renderDailyRow(dayEntry: IndexDailyEntry): string {
|
|
138
|
+
const [year, month, day] = dayEntry.date.split("-");
|
|
139
|
+
return `- [${dayEntry.date}](daily/${year}/${month}/${day}.md)`;
|
|
136
140
|
}
|
|
@@ -39,11 +39,11 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
|
|
|
39
39
|
|
|
40
40
|
// ── Validation ──────────────────────────────────────────────────
|
|
41
41
|
|
|
42
|
-
function expandHome(
|
|
43
|
-
if (
|
|
44
|
-
return path.join(os.homedir(),
|
|
42
|
+
function expandHome(inputPath: string): string {
|
|
43
|
+
if (inputPath.startsWith("~/")) {
|
|
44
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
45
45
|
}
|
|
46
|
-
return
|
|
46
|
+
return inputPath;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function isSensitivePath(absPath: string): boolean {
|
|
@@ -59,8 +59,8 @@ function isSensitivePath(absPath: string): boolean {
|
|
|
59
59
|
|
|
60
60
|
// Block home-relative sensitive dirs
|
|
61
61
|
if (
|
|
62
|
-
HOME_RELATIVE_BLOCKED.some((
|
|
63
|
-
const full = path.join(home,
|
|
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((
|
|
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(
|
|
80
|
-
return
|
|
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((
|
|
121
|
-
if (!
|
|
120
|
+
.filter((entry): entry is ReferenceDirEntry => {
|
|
121
|
+
if (!entry) return false;
|
|
122
122
|
// Deduplicate labels — first entry wins
|
|
123
|
-
if (seenLabels.has(
|
|
124
|
-
seenLabels.add(
|
|
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
|
|
159
|
-
errors.push(`entry ${i}: invalid or blocked path "${
|
|
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
|
|
237
|
-
const mountPath = useDocker ? containerPath(
|
|
238
|
-
lines.push(`- \`${mountPath}\` — ${
|
|
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((
|
|
18
|
-
.flatMap((
|
|
17
|
+
.filter((fileName) => fileName.endsWith(".json"))
|
|
18
|
+
.flatMap((fileName) => {
|
|
19
19
|
try {
|
|
20
|
-
const raw = readTextUnderSync(workspacePath, path.posix.join(WORKSPACE_DIRS.roles,
|
|
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((
|
|
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(
|
|
36
|
-
return loadAllRoles().find((
|
|
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((
|
|
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
|
|
122
|
-
for (const
|
|
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((
|
|
124
|
+
return [...merged.values()].sort((leftSkill, rightSkill) => leftSkill.name.localeCompare(rightSkill.name));
|
|
125
125
|
}
|
|
@@ -32,8 +32,8 @@ export interface PersistedUserTask {
|
|
|
32
32
|
updatedAt: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function loadUserTasks(
|
|
36
|
-
return loadRaw<PersistedUserTask>(
|
|
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(
|
|
46
|
-
if (!isRecord(
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
return typeof
|
|
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 (
|
|
52
|
-
return typeof
|
|
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(
|
|
58
|
-
return
|
|
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[],
|
|
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
|
|
104
|
-
if (
|
|
105
|
-
return { kind: "error", error: `task not found: ${
|
|
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[
|
|
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
|
|
110
|
+
const patchRecord: Record<string, unknown> = { ...patch };
|
|
111
111
|
|
|
112
|
-
if (typeof
|
|
113
|
-
updated.name =
|
|
112
|
+
if (typeof patchRecord.name === "string" && patchRecord.name.trim().length > 0) {
|
|
113
|
+
updated.name = patchRecord.name.trim();
|
|
114
114
|
}
|
|
115
|
-
if (typeof
|
|
116
|
-
updated.description =
|
|
115
|
+
if (typeof patchRecord.description === "string") {
|
|
116
|
+
updated.description = patchRecord.description.trim();
|
|
117
117
|
}
|
|
118
|
-
if (isValidSchedule(
|
|
119
|
-
updated.schedule =
|
|
118
|
+
if (isValidSchedule(patchRecord.schedule)) {
|
|
119
|
+
updated.schedule = patchRecord.schedule;
|
|
120
120
|
}
|
|
121
|
-
if (isValidMissedRunPolicy(
|
|
122
|
-
updated.missedRunPolicy =
|
|
121
|
+
if (isValidMissedRunPolicy(patchRecord.missedRunPolicy)) {
|
|
122
|
+
updated.missedRunPolicy = patchRecord.missedRunPolicy;
|
|
123
123
|
}
|
|
124
|
-
if (typeof
|
|
125
|
-
updated.enabled =
|
|
124
|
+
if (typeof patchRecord.enabled === "boolean") {
|
|
125
|
+
updated.enabled = patchRecord.enabled;
|
|
126
126
|
}
|
|
127
|
-
if (typeof
|
|
128
|
-
updated.roleId =
|
|
127
|
+
if (typeof patchRecord.roleId === "string") {
|
|
128
|
+
updated.roleId = patchRecord.roleId;
|
|
129
129
|
}
|
|
130
|
-
if (typeof
|
|
131
|
-
updated.prompt =
|
|
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[
|
|
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
|
-
|
|
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
|
|
160
|
+
const { tasks: next, result } = await lockFn(current);
|
|
161
161
|
await saveUserTasks(next);
|
|
162
162
|
await refreshUserTasks();
|
|
163
163
|
return result;
|