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
package/server/utils/date.ts
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
* is a wall-clock question, not a UTC question.
|
|
9
9
|
*/
|
|
10
10
|
export function toLocalIsoDate(input: Date | number): string {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const day = String(
|
|
15
|
-
return `${
|
|
11
|
+
const dateValue = typeof input === "number" ? new Date(input) : input;
|
|
12
|
+
const year = dateValue.getFullYear();
|
|
13
|
+
const month = String(dateValue.getMonth() + 1).padStart(2, "0");
|
|
14
|
+
const day = String(dateValue.getDate()).padStart(2, "0");
|
|
15
|
+
return `${year}-${month}-${day}`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -20,11 +20,11 @@ export function toLocalIsoDate(input: Date | number): string {
|
|
|
20
20
|
* any context where the date must not shift with the server's
|
|
21
21
|
* local timezone.
|
|
22
22
|
*/
|
|
23
|
-
export function toUtcIsoDate(
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
return `${
|
|
23
|
+
export function toUtcIsoDate(timestamp: Date): string {
|
|
24
|
+
const year = timestamp.getUTCFullYear();
|
|
25
|
+
const month = String(timestamp.getUTCMonth() + 1).padStart(2, "0");
|
|
26
|
+
const day = String(timestamp.getUTCDate()).padStart(2, "0");
|
|
27
|
+
return `${year}-${month}-${day}`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -41,16 +41,16 @@ export function isoDateOnly(iso: string): string {
|
|
|
41
41
|
* Does NOT validate month/day ranges (Feb 30 passes); that's the
|
|
42
42
|
* caller's or LLM's responsibility.
|
|
43
43
|
*/
|
|
44
|
-
export function isValidIsoDate(
|
|
45
|
-
if (
|
|
46
|
-
if (
|
|
47
|
-
return isNumeric(
|
|
44
|
+
export function isValidIsoDate(input: string): boolean {
|
|
45
|
+
if (input.length !== 10) return false;
|
|
46
|
+
if (input[4] !== "-" || input[7] !== "-") return false;
|
|
47
|
+
return isNumeric(input.slice(0, 4)) && isNumeric(input.slice(5, 7)) && isNumeric(input.slice(8, 10));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function isNumeric(
|
|
51
|
-
if (
|
|
52
|
-
for (let
|
|
53
|
-
const code =
|
|
50
|
+
function isNumeric(input: string): boolean {
|
|
51
|
+
if (input.length === 0) return false;
|
|
52
|
+
for (let index = 0; index < input.length; index++) {
|
|
53
|
+
const code = input.charCodeAt(index);
|
|
54
54
|
if (code < 48 || code > 57) return false;
|
|
55
55
|
}
|
|
56
56
|
return true;
|
|
@@ -47,18 +47,18 @@ function isTransientRenameError(err: unknown): boolean {
|
|
|
47
47
|
return err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES";
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
async function renameWithWindowsRetry(
|
|
50
|
+
async function renameWithWindowsRetry(fromPath: string, toPath: string): Promise<void> {
|
|
51
51
|
for (const delayMs of RENAME_RETRY_DELAYS_MS) {
|
|
52
52
|
try {
|
|
53
|
-
await fs.promises.rename(
|
|
53
|
+
await fs.promises.rename(fromPath, toPath);
|
|
54
54
|
return;
|
|
55
55
|
} catch (err) {
|
|
56
56
|
if (!isTransientRenameError(err)) throw err;
|
|
57
|
-
await new Promise((
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
// Final attempt — let any error propagate.
|
|
61
|
-
await fs.promises.rename(
|
|
61
|
+
await fs.promises.rename(fromPath, toPath);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// Sync sleep that parks the thread instead of burning CPU. Only
|
|
@@ -66,21 +66,21 @@ async function renameWithWindowsRetry(from: string, to: string): Promise<void> {
|
|
|
66
66
|
// case block is the sum of RENAME_RETRY_DELAYS_MS (~430ms) and only
|
|
67
67
|
// triggers under AV/indexer contention.
|
|
68
68
|
const SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
|
|
69
|
-
function sleepSync(
|
|
70
|
-
Atomics.wait(SYNC_SLEEP_BUF, 0, 0,
|
|
69
|
+
function sleepSync(millis: number): void {
|
|
70
|
+
Atomics.wait(SYNC_SLEEP_BUF, 0, 0, millis);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function renameSyncWithWindowsRetry(
|
|
73
|
+
function renameSyncWithWindowsRetry(fromPath: string, toPath: string): void {
|
|
74
74
|
for (const delayMs of RENAME_RETRY_DELAYS_MS) {
|
|
75
75
|
try {
|
|
76
|
-
fs.renameSync(
|
|
76
|
+
fs.renameSync(fromPath, toPath);
|
|
77
77
|
return;
|
|
78
78
|
} catch (err) {
|
|
79
79
|
if (!isTransientRenameError(err)) throw err;
|
|
80
80
|
sleepSync(delayMs);
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
fs.renameSync(
|
|
83
|
+
fs.renameSync(fromPath, toPath);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -9,12 +9,12 @@ import { workspacePath } from "../../workspace/paths.js";
|
|
|
9
9
|
import { readTextUnder, writeTextUnder } from "./workspace-io.js";
|
|
10
10
|
|
|
11
11
|
const HTML_REL = path.posix.join(WORKSPACE_DIRS.html, "current.html");
|
|
12
|
-
const root = (
|
|
12
|
+
const root = (workspaceRoot?: string) => workspaceRoot ?? workspacePath;
|
|
13
13
|
|
|
14
|
-
export async function readCurrentHtml(
|
|
15
|
-
return readTextUnder(root(
|
|
14
|
+
export async function readCurrentHtml(workspaceRoot?: string): Promise<string | null> {
|
|
15
|
+
return readTextUnder(root(workspaceRoot), HTML_REL);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export async function writeCurrentHtml(html: string,
|
|
19
|
-
await writeTextUnder(root(
|
|
18
|
+
export async function writeCurrentHtml(html: string, workspaceRoot?: string): Promise<void> {
|
|
19
|
+
await writeTextUnder(root(workspaceRoot), HTML_REL, html);
|
|
20
20
|
}
|
|
@@ -35,8 +35,8 @@ async function safeResolve(relativePath: string): Promise<string> {
|
|
|
35
35
|
/** Save raw base64 (no data URI prefix) as a PNG file. Returns the workspace-relative path. */
|
|
36
36
|
export async function saveImage(base64Data: string): Promise<string> {
|
|
37
37
|
await ensureImagesDir();
|
|
38
|
-
const
|
|
39
|
-
const filename = `${
|
|
38
|
+
const imageId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
39
|
+
const filename = `${imageId}.png`;
|
|
40
40
|
const absPath = path.join(IMAGES_DIR, filename);
|
|
41
41
|
await fs.writeFile(absPath, Buffer.from(base64Data, "base64"));
|
|
42
42
|
return path.posix.join(WORKSPACE_DIRS.images, filename);
|
|
@@ -51,9 +51,9 @@ export async function writeJournalState(state: unknown, rootOverride?: string):
|
|
|
51
51
|
|
|
52
52
|
// ── Index ───────────────────────────────────────────────────────
|
|
53
53
|
|
|
54
|
-
export async function writeJournalIndex(
|
|
54
|
+
export async function writeJournalIndex(markdown: string, rootOverride?: string): Promise<void> {
|
|
55
55
|
const filePath = path.join(summariesRoot(root(rootOverride)), INDEX_FILE);
|
|
56
|
-
await writeFileAtomic(filePath,
|
|
56
|
+
await writeFileAtomic(filePath, markdown);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// ── Daily summaries ─────────────────────────────────────────────
|
|
@@ -44,7 +44,7 @@ export function buildArtifactPathRandom(dir: string, prefix: string, ext: string
|
|
|
44
44
|
// Pass fallbackSlug as slugify's default so it overrides slugify's
|
|
45
45
|
// built-in "page" default when `prefix` sanitizes to empty.
|
|
46
46
|
const slug = slugify(prefix, fallbackSlug);
|
|
47
|
-
const
|
|
48
|
-
const fname = `${slug}-${
|
|
47
|
+
const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, RANDOM_SUFFIX_LEN);
|
|
48
|
+
const fname = `${slug}-${suffix}${ext}`;
|
|
49
49
|
return path.posix.join(dir, fname);
|
|
50
50
|
}
|
|
@@ -10,16 +10,16 @@ import { workspacePath } from "../../workspace/paths.js";
|
|
|
10
10
|
import { writeFileAtomicSync } from "./atomic.js";
|
|
11
11
|
import { isEnoent } from "./safe.js";
|
|
12
12
|
|
|
13
|
-
const root = (
|
|
13
|
+
const root = (workspaceRoot?: string) => workspaceRoot ?? workspacePath;
|
|
14
14
|
|
|
15
|
-
function roleFilePath(
|
|
16
|
-
return path.join(root(
|
|
15
|
+
function roleFilePath(roleId: string, workspaceRoot?: string): string {
|
|
16
|
+
return path.join(root(workspaceRoot), WORKSPACE_DIRS.roles, `${roleId}.json`);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/** Check if a custom role file exists. */
|
|
20
|
-
export function roleExists(
|
|
20
|
+
export function roleExists(roleId: string, workspaceRoot?: string): boolean {
|
|
21
21
|
try {
|
|
22
|
-
fs.statSync(roleFilePath(
|
|
22
|
+
fs.statSync(roleFilePath(roleId, workspaceRoot));
|
|
23
23
|
return true;
|
|
24
24
|
} catch {
|
|
25
25
|
return false;
|
|
@@ -27,9 +27,9 @@ export function roleExists(id: string, r?: string): boolean {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
/** Delete a custom role file. Returns false if not found. */
|
|
30
|
-
export function deleteRole(
|
|
30
|
+
export function deleteRole(roleId: string, workspaceRoot?: string): boolean {
|
|
31
31
|
try {
|
|
32
|
-
fs.unlinkSync(roleFilePath(
|
|
32
|
+
fs.unlinkSync(roleFilePath(roleId, workspaceRoot));
|
|
33
33
|
return true;
|
|
34
34
|
} catch (err) {
|
|
35
35
|
if (isEnoent(err)) return false;
|
|
@@ -38,8 +38,8 @@ export function deleteRole(id: string, r?: string): boolean {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Save (create or overwrite) a custom role file atomically. */
|
|
41
|
-
export function saveRole(
|
|
42
|
-
const dir = path.join(root(
|
|
41
|
+
export function saveRole(roleId: string, data: unknown, workspaceRoot?: string): void {
|
|
42
|
+
const dir = path.join(root(workspaceRoot), WORKSPACE_DIRS.roles);
|
|
43
43
|
fs.mkdirSync(dir, { recursive: true });
|
|
44
|
-
writeFileAtomicSync(roleFilePath(
|
|
44
|
+
writeFileAtomicSync(roleFilePath(roleId, workspaceRoot), JSON.stringify(data, null, 2));
|
|
45
45
|
}
|
|
@@ -9,12 +9,12 @@ import { resolvePath } from "./workspace-io.js";
|
|
|
9
9
|
import { loadJsonFile } from "./json.js";
|
|
10
10
|
import { writeFileAtomicSync } from "./atomic.js";
|
|
11
11
|
|
|
12
|
-
const root = (
|
|
12
|
+
const root = (workspaceRoot?: string) => workspaceRoot ?? workspacePath;
|
|
13
13
|
|
|
14
|
-
export function loadSchedulerItems<T>(fallback: T,
|
|
15
|
-
return loadJsonFile(resolvePath(root(
|
|
14
|
+
export function loadSchedulerItems<T>(fallback: T, workspaceRoot?: string): T {
|
|
15
|
+
return loadJsonFile(resolvePath(root(workspaceRoot), WORKSPACE_FILES.schedulerItems), fallback);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function saveSchedulerItems(items: unknown,
|
|
19
|
-
writeFileAtomicSync(resolvePath(root(
|
|
18
|
+
export function saveSchedulerItems(items: unknown, workspaceRoot?: string): void {
|
|
19
|
+
writeFileAtomicSync(resolvePath(root(workspaceRoot), WORKSPACE_FILES.schedulerItems), JSON.stringify(items, null, 2));
|
|
20
20
|
}
|
|
@@ -18,12 +18,12 @@ export function ensureChatDir(): void {
|
|
|
18
18
|
ensureWorkspaceDir(CHAT);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function metaRel(
|
|
22
|
-
return path.posix.join(CHAT, `${
|
|
21
|
+
function metaRel(sessionId: string): string {
|
|
22
|
+
return path.posix.join(CHAT, `${sessionId}.json`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function jsonlRel(
|
|
26
|
-
return path.posix.join(CHAT, `${
|
|
25
|
+
function jsonlRel(sessionId: string): string {
|
|
26
|
+
return path.posix.join(CHAT, `${sessionId}.jsonl`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
// ── Meta ────────────────────────────────────────────────────────
|
|
@@ -41,8 +41,8 @@ export interface SessionMeta {
|
|
|
41
41
|
export type ReadMetaResult = { kind: "missing" } | { kind: "ok"; meta: SessionMeta } | { kind: "corrupt"; raw: string };
|
|
42
42
|
|
|
43
43
|
/** Read session metadata with full outcome discrimination. */
|
|
44
|
-
export async function readSessionMetaFull(
|
|
45
|
-
const raw = await readTextUnder(root(rootOverride), metaRel(
|
|
44
|
+
export async function readSessionMetaFull(sessionId: string, rootOverride?: string): Promise<ReadMetaResult> {
|
|
45
|
+
const raw = await readTextUnder(root(rootOverride), metaRel(sessionId));
|
|
46
46
|
if (raw === null) return { kind: "missing" };
|
|
47
47
|
try {
|
|
48
48
|
return { kind: "ok", meta: JSON.parse(raw) as SessionMeta };
|
|
@@ -53,60 +53,60 @@ export async function readSessionMetaFull(id: string, rootOverride?: string): Pr
|
|
|
53
53
|
|
|
54
54
|
/** Convenience: returns the meta or null. Treats corrupt as null
|
|
55
55
|
* (callers that need to distinguish use readSessionMetaFull). */
|
|
56
|
-
export async function readSessionMeta(
|
|
57
|
-
const result = await readSessionMetaFull(
|
|
56
|
+
export async function readSessionMeta(sessionId: string, rootOverride?: string): Promise<SessionMeta | null> {
|
|
57
|
+
const result = await readSessionMetaFull(sessionId, rootOverride);
|
|
58
58
|
return result.kind === "ok" ? result.meta : null;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
export async function writeSessionMeta(
|
|
62
|
-
await writeTextUnder(root(rootOverride), metaRel(
|
|
61
|
+
export async function writeSessionMeta(sessionId: string, meta: SessionMeta, rootOverride?: string): Promise<void> {
|
|
62
|
+
await writeTextUnder(root(rootOverride), metaRel(sessionId), JSON.stringify(meta, null, 2));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export async function createSessionMeta(
|
|
65
|
+
export async function createSessionMeta(sessionId: string, roleId: string, firstUserMessage: string, rootOverride?: string, origin?: string): Promise<void> {
|
|
66
66
|
const meta: Record<string, unknown> = {
|
|
67
67
|
roleId,
|
|
68
68
|
startedAt: new Date().toISOString(),
|
|
69
69
|
firstUserMessage,
|
|
70
70
|
};
|
|
71
71
|
if (origin) meta.origin = origin;
|
|
72
|
-
await writeSessionMeta(
|
|
72
|
+
await writeSessionMeta(sessionId, meta, rootOverride);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export async function backfillOrigin(
|
|
76
|
-
const meta = await readSessionMeta(
|
|
75
|
+
export async function backfillOrigin(sessionId: string, origin: SessionMeta["origin"], rootOverride?: string): Promise<void> {
|
|
76
|
+
const meta = await readSessionMeta(sessionId, rootOverride);
|
|
77
77
|
if (!meta || meta.origin) return; // already set
|
|
78
|
-
await writeSessionMeta(
|
|
78
|
+
await writeSessionMeta(sessionId, { ...meta, origin }, rootOverride);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export async function backfillFirstUserMessage(
|
|
82
|
-
const meta = await readSessionMeta(
|
|
81
|
+
export async function backfillFirstUserMessage(sessionId: string, message: string, rootOverride?: string): Promise<void> {
|
|
82
|
+
const meta = await readSessionMeta(sessionId, rootOverride);
|
|
83
83
|
if (!meta || meta.firstUserMessage) return;
|
|
84
|
-
await writeSessionMeta(
|
|
84
|
+
await writeSessionMeta(sessionId, { ...meta, firstUserMessage: message }, rootOverride);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export async function setClaudeSessionId(
|
|
88
|
-
const meta = await readSessionMeta(
|
|
87
|
+
export async function setClaudeSessionId(sessionId: string, claudeSessionId: string, rootOverride?: string): Promise<void> {
|
|
88
|
+
const meta = await readSessionMeta(sessionId, rootOverride);
|
|
89
89
|
if (!meta) return;
|
|
90
|
-
await writeSessionMeta(
|
|
90
|
+
await writeSessionMeta(sessionId, { ...meta, claudeSessionId }, rootOverride);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export async function clearClaudeSessionId(
|
|
94
|
-
const meta = await readSessionMeta(
|
|
93
|
+
export async function clearClaudeSessionId(sessionId: string, rootOverride?: string): Promise<void> {
|
|
94
|
+
const meta = await readSessionMeta(sessionId, rootOverride);
|
|
95
95
|
if (!meta) return;
|
|
96
96
|
const { claudeSessionId: __removed, ...rest } = meta;
|
|
97
|
-
await writeSessionMeta(
|
|
97
|
+
await writeSessionMeta(sessionId, rest, rootOverride);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
export async function updateHasUnread(
|
|
101
|
-
const meta = await readSessionMeta(
|
|
100
|
+
export async function updateHasUnread(sessionId: string, hasUnread: boolean, rootOverride?: string): Promise<void> {
|
|
101
|
+
const meta = await readSessionMeta(sessionId, rootOverride);
|
|
102
102
|
if (!meta) return;
|
|
103
|
-
await writeSessionMeta(
|
|
103
|
+
await writeSessionMeta(sessionId, { ...meta, hasUnread }, rootOverride);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// ── Jsonl ───────────────────────────────────────────────────────
|
|
107
107
|
|
|
108
|
-
export function sessionJsonlAbsPath(
|
|
109
|
-
return resolvePath(root(rootOverride), jsonlRel(
|
|
108
|
+
export function sessionJsonlAbsPath(sessionId: string, rootOverride?: string): string {
|
|
109
|
+
return resolvePath(root(rootOverride), jsonlRel(sessionId));
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
@@ -115,12 +115,12 @@ export function sessionJsonlAbsPath(id: string, rootOverride?: string): string {
|
|
|
115
115
|
* `hasUnread`, `roleId`, `startedAt`, `origin`, etc. Its mtime bumps
|
|
116
116
|
* whenever any of those fields change via `writeSessionMeta`.
|
|
117
117
|
*/
|
|
118
|
-
export function sessionMetaAbsPath(
|
|
119
|
-
return resolvePath(root(rootOverride), metaRel(
|
|
118
|
+
export function sessionMetaAbsPath(sessionId: string, rootOverride?: string): string {
|
|
119
|
+
return resolvePath(root(rootOverride), metaRel(sessionId));
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
export async function readSessionJsonl(
|
|
123
|
-
return readTextUnder(root(rootOverride), jsonlRel(
|
|
122
|
+
export async function readSessionJsonl(sessionId: string, rootOverride?: string): Promise<string | null> {
|
|
123
|
+
return readTextUnder(root(rootOverride), jsonlRel(sessionId));
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/**
|
|
@@ -130,7 +130,7 @@ export async function readSessionJsonl(id: string, rootOverride?: string): Promi
|
|
|
130
130
|
* content and don't need to worry about line termination. This
|
|
131
131
|
* prevents JSONL parse failures from missing newlines.
|
|
132
132
|
*/
|
|
133
|
-
export async function appendSessionLine(
|
|
133
|
+
export async function appendSessionLine(sessionId: string, line: string, rootOverride?: string): Promise<void> {
|
|
134
134
|
const normalized = line.endsWith("\n") ? line : `${line}\n`;
|
|
135
|
-
await appendFile(resolvePath(root(rootOverride), jsonlRel(
|
|
135
|
+
await appendFile(resolvePath(root(rootOverride), jsonlRel(sessionId)), normalized);
|
|
136
136
|
}
|
|
@@ -36,8 +36,8 @@ async function safeResolve(relativePath: string): Promise<string> {
|
|
|
36
36
|
/** Save sheets array as a JSON file. Returns the workspace-relative path. */
|
|
37
37
|
export async function saveSpreadsheet(sheets: unknown[]): Promise<string> {
|
|
38
38
|
await ensureSpreadsheetsDir();
|
|
39
|
-
const
|
|
40
|
-
const filename = `${
|
|
39
|
+
const sheetId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
40
|
+
const filename = `${sheetId}.json`;
|
|
41
41
|
await fs.writeFile(path.join(SPREADSHEETS_DIR, filename), JSON.stringify(sheets), "utf-8");
|
|
42
42
|
return path.posix.join(WORKSPACE_DIRS.spreadsheets, filename);
|
|
43
43
|
}
|
|
@@ -10,20 +10,20 @@ import { resolvePath } from "./workspace-io.js";
|
|
|
10
10
|
import { loadJsonFile } from "./json.js";
|
|
11
11
|
import { writeFileAtomicSync } from "./atomic.js";
|
|
12
12
|
|
|
13
|
-
const root = (
|
|
13
|
+
const root = (workspaceRoot?: string) => workspaceRoot ?? workspacePath;
|
|
14
14
|
|
|
15
|
-
export function loadTodos<T>(fallback: T,
|
|
16
|
-
return loadJsonFile(resolvePath(root(
|
|
15
|
+
export function loadTodos<T>(fallback: T, workspaceRoot?: string): T {
|
|
16
|
+
return loadJsonFile(resolvePath(root(workspaceRoot), WORKSPACE_FILES.todosItems), fallback);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function saveTodos(items: unknown,
|
|
20
|
-
writeFileAtomicSync(resolvePath(root(
|
|
19
|
+
export function saveTodos(items: unknown, workspaceRoot?: string): void {
|
|
20
|
+
writeFileAtomicSync(resolvePath(root(workspaceRoot), WORKSPACE_FILES.todosItems), JSON.stringify(items, null, 2));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function loadColumns<T>(fallback: T,
|
|
24
|
-
return loadJsonFile(resolvePath(root(
|
|
23
|
+
export function loadColumns<T>(fallback: T, workspaceRoot?: string): T {
|
|
24
|
+
return loadJsonFile(resolvePath(root(workspaceRoot), WORKSPACE_FILES.todosColumns), fallback);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function saveColumns(columns: unknown,
|
|
28
|
-
writeFileAtomicSync(resolvePath(root(
|
|
27
|
+
export function saveColumns(columns: unknown, workspaceRoot?: string): void {
|
|
28
|
+
writeFileAtomicSync(resolvePath(root(workspaceRoot), WORKSPACE_FILES.todosColumns), JSON.stringify(columns, null, 2));
|
|
29
29
|
}
|
|
@@ -11,15 +11,15 @@ import { resolvePath } from "./workspace-io.js";
|
|
|
11
11
|
import { loadJsonFile } from "./json.js";
|
|
12
12
|
import { writeFileAtomic } from "./atomic.js";
|
|
13
13
|
|
|
14
|
-
const root = (
|
|
14
|
+
const root = (workspaceRoot?: string) => workspaceRoot ?? workspacePath;
|
|
15
15
|
|
|
16
|
-
export function loadUserTasks<T>(
|
|
17
|
-
const tasks = loadJsonFile<T[]>(resolvePath(root(
|
|
16
|
+
export function loadUserTasks<T>(workspaceRoot?: string): T[] {
|
|
17
|
+
const tasks = loadJsonFile<T[]>(resolvePath(root(workspaceRoot), WORKSPACE_FILES.schedulerUserTasks), []);
|
|
18
18
|
return Array.isArray(tasks) ? tasks : [];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
export async function saveUserTasks<T>(tasks: T[],
|
|
22
|
-
const filePath = resolvePath(root(
|
|
21
|
+
export async function saveUserTasks<T>(tasks: T[], workspaceRoot?: string): Promise<void> {
|
|
22
|
+
const filePath = resolvePath(root(workspaceRoot), WORKSPACE_FILES.schedulerUserTasks);
|
|
23
23
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
24
24
|
await writeFileAtomic(filePath, JSON.stringify(tasks, null, 2));
|
|
25
25
|
}
|
|
@@ -52,8 +52,8 @@ export async function readManifest(workspaceRoot: string): Promise<ChatIndexMani
|
|
|
52
52
|
|
|
53
53
|
function isManifest(raw: unknown): raw is ChatIndexManifest {
|
|
54
54
|
if (!isRecord(raw)) return false;
|
|
55
|
-
const
|
|
56
|
-
return
|
|
55
|
+
const manifestRecord = raw as Record<string, unknown>;
|
|
56
|
+
return manifestRecord.version === 1 && Array.isArray(manifestRecord.entries);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// In-process mutex serializing the read-modify-write sequence on
|
|
@@ -64,7 +64,7 @@ function isManifest(raw: unknown): raw is ChatIndexManifest {
|
|
|
64
64
|
// this module's single-process assumption.
|
|
65
65
|
let manifestMutex: Promise<void> = Promise.resolve();
|
|
66
66
|
|
|
67
|
-
async function withManifestLock<T>(
|
|
67
|
+
async function withManifestLock<T>(lockedFn: () => Promise<T>): Promise<T> {
|
|
68
68
|
const prev = manifestMutex;
|
|
69
69
|
let release: () => void = () => {};
|
|
70
70
|
manifestMutex = new Promise<void>((resolve) => {
|
|
@@ -72,7 +72,7 @@ async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
72
72
|
});
|
|
73
73
|
try {
|
|
74
74
|
await prev;
|
|
75
|
-
return await
|
|
75
|
+
return await lockedFn();
|
|
76
76
|
} finally {
|
|
77
77
|
release();
|
|
78
78
|
}
|
|
@@ -83,12 +83,12 @@ async function withManifestLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
83
83
|
// already serializes callers within this process, but a unique
|
|
84
84
|
// name means the rename can't collide even if a stray .tmp file
|
|
85
85
|
// is left behind by a previous crashed run.
|
|
86
|
-
async function writeManifestAtomic(workspaceRoot: string,
|
|
86
|
+
async function writeManifestAtomic(workspaceRoot: string, manifest: ChatIndexManifest): Promise<void> {
|
|
87
87
|
// `uniqueTmp` belt-and-suspenders: the in-process mutex above
|
|
88
88
|
// already serializes callers, but a unique tmp name means the
|
|
89
89
|
// rename can't collide even if a stray .tmp file is left behind
|
|
90
90
|
// by a previous crashed run.
|
|
91
|
-
await writeJsonAtomic(manifestPathFor(workspaceRoot),
|
|
91
|
+
await writeJsonAtomic(manifestPathFor(workspaceRoot), manifest, {
|
|
92
92
|
uniqueTmp: true,
|
|
93
93
|
});
|
|
94
94
|
}
|
|
@@ -117,9 +117,9 @@ export async function isFresh(workspaceRoot: string, sessionId: string, now: num
|
|
|
117
117
|
if (!isRecord(entry)) return false;
|
|
118
118
|
const indexedAt = (entry as Record<string, unknown>).indexedAt;
|
|
119
119
|
if (typeof indexedAt !== "string") return false;
|
|
120
|
-
const
|
|
121
|
-
if (Number.isNaN(
|
|
122
|
-
return now -
|
|
120
|
+
const indexedTimestamp = Date.parse(indexedAt);
|
|
121
|
+
if (Number.isNaN(indexedTimestamp)) return false;
|
|
122
|
+
return now - indexedTimestamp < minIntervalMs;
|
|
123
123
|
} catch {
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
@@ -137,10 +137,10 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
|
|
|
137
137
|
const raw = await readFile(sessionMetaPathFor(workspaceRoot, sessionId), "utf-8");
|
|
138
138
|
const parsed: unknown = JSON.parse(raw);
|
|
139
139
|
if (!isRecord(parsed)) return {};
|
|
140
|
-
const
|
|
140
|
+
const metaRecord = parsed as Record<string, unknown>;
|
|
141
141
|
return {
|
|
142
|
-
roleId: typeof
|
|
143
|
-
startedAt: typeof
|
|
142
|
+
roleId: typeof metaRecord.roleId === "string" ? metaRecord.roleId : undefined,
|
|
143
|
+
startedAt: typeof metaRecord.startedAt === "string" ? metaRecord.startedAt : undefined,
|
|
144
144
|
};
|
|
145
145
|
} catch {
|
|
146
146
|
return {};
|
|
@@ -152,7 +152,7 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
|
|
|
152
152
|
export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
|
|
153
153
|
try {
|
|
154
154
|
const files = await readdir(chatDirFor(workspaceRoot));
|
|
155
|
-
return files.filter((
|
|
155
|
+
return files.filter((fileName) => fileName.endsWith(".jsonl")).map((fileName) => fileName.slice(0, -".jsonl".length));
|
|
156
156
|
} catch {
|
|
157
157
|
return [];
|
|
158
158
|
}
|
|
@@ -199,9 +199,9 @@ export async function indexSession(workspaceRoot: string, sessionId: string, dep
|
|
|
199
199
|
// Upsert into manifest under the in-process lock: replace any
|
|
200
200
|
// prior entry with the same id, sort newest-first by startedAt.
|
|
201
201
|
await updateManifest(workspaceRoot, (current) => {
|
|
202
|
-
const filtered = current.entries.filter((
|
|
202
|
+
const filtered = current.entries.filter((entryItem) => entryItem.id !== sessionId);
|
|
203
203
|
filtered.push(entry);
|
|
204
|
-
filtered.sort((
|
|
204
|
+
filtered.sort((leftEntry, rightEntry) => Date.parse(rightEntry.startedAt) - Date.parse(leftEntry.startedAt));
|
|
205
205
|
return { version: 1, entries: filtered };
|
|
206
206
|
});
|
|
207
207
|
|
|
@@ -45,8 +45,8 @@ const CONTROL_CHAR_RE_G = /[\x00-\x1f]/g;
|
|
|
45
45
|
|
|
46
46
|
// ── Validation ──────────────────────────────────────────────────
|
|
47
47
|
|
|
48
|
-
function isValidStructure(
|
|
49
|
-
return
|
|
48
|
+
function isValidStructure(value: unknown): value is DirStructure {
|
|
49
|
+
return value === DIR_STRUCTURES.flat || value === DIR_STRUCTURES.byName || value === DIR_STRUCTURES.byDate;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function validatePath(rawPath: string): string | null {
|
|
@@ -55,7 +55,7 @@ function validatePath(rawPath: string): string | null {
|
|
|
55
55
|
const normalized = path.posix.normalize(rawPath);
|
|
56
56
|
|
|
57
57
|
// Must start with allowed prefix
|
|
58
|
-
if (!ALLOWED_PREFIXES.some((
|
|
58
|
+
if (!ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return null;
|
|
59
59
|
|
|
60
60
|
// No path traversal
|
|
61
61
|
if (normalized.includes("..")) return null;
|
|
@@ -64,7 +64,7 @@ function validatePath(rawPath: string): string | null {
|
|
|
64
64
|
if (path.isAbsolute(normalized)) return null;
|
|
65
65
|
|
|
66
66
|
// Not a reserved system directory
|
|
67
|
-
if (RESERVED_DIRS.some((
|
|
67
|
+
if (RESERVED_DIRS.some((reservedDir) => normalized === reservedDir || normalized.startsWith(reservedDir + "/"))) {
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -112,7 +112,7 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
|
|
|
112
112
|
const entries = parsed
|
|
113
113
|
.slice(0, MAX_ENTRIES)
|
|
114
114
|
.map(validateEntry)
|
|
115
|
-
.filter((
|
|
115
|
+
.filter((entry): entry is CustomDirEntry => entry !== null);
|
|
116
116
|
|
|
117
117
|
const skipped = parsed.length - entries.length;
|
|
118
118
|
if (skipped > 0) {
|
|
@@ -153,8 +153,8 @@ export function validateCustomDirs(raw: unknown): { entries: CustomDirEntry[] }
|
|
|
153
153
|
if (entry) {
|
|
154
154
|
entries.push(entry);
|
|
155
155
|
} else {
|
|
156
|
-
const
|
|
157
|
-
errors.push(`entry ${i}: invalid path "${
|
|
156
|
+
const itemPath = isRecord(item) ? String((item as Record<string, unknown>).path ?? "") : "";
|
|
157
|
+
errors.push(`entry ${i}: invalid path "${itemPath}"`);
|
|
158
158
|
}
|
|
159
159
|
});
|
|
160
160
|
if (errors.length > 0) {
|
|
@@ -205,14 +205,14 @@ export function buildCustomDirsPrompt(entries: readonly CustomDirEntry[]): strin
|
|
|
205
205
|
"",
|
|
206
206
|
];
|
|
207
207
|
|
|
208
|
-
for (const
|
|
208
|
+
for (const entry of entries) {
|
|
209
209
|
const structureHint =
|
|
210
|
-
|
|
210
|
+
entry.structure === DIR_STRUCTURES.byName
|
|
211
211
|
? " (organize by name in subfolders)"
|
|
212
|
-
:
|
|
212
|
+
: entry.structure === DIR_STRUCTURES.byDate
|
|
213
213
|
? " (organize by date: YYYY/MM/DD/)"
|
|
214
214
|
: "";
|
|
215
|
-
lines.push(`- \`${
|
|
215
|
+
lines.push(`- \`${entry.path}/\`${structureHint} — ${entry.description}`);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
lines.push("");
|