mulmoclaude 1.2.0 → 1.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/README.md +1 -1
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
- package/client/assets/index-7QnbsZZr.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
- package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +7 -6
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/agent/mcp-server.ts +27 -8
- package/server/api/auth/viewToken.ts +16 -8
- package/server/api/routes/agent.ts +49 -11
- package/server/api/routes/collections.ts +274 -102
- package/server/api/routes/files.ts +57 -54
- package/server/api/routes/mulmo-script.ts +257 -895
- package/server/api/routes/wiki/history.ts +17 -19
- package/server/build/dispatcher.mjs +2 -2
- package/server/events/collection-change.ts +1 -5
- package/server/events/file-change.ts +1 -6
- package/server/events/pub-sub/index.ts +15 -2
- package/server/events/relay-client.ts +6 -1
- package/server/events/session-store/index.ts +11 -2
- package/server/index.ts +51 -78
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -3
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/getFeed.ts +10 -5
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/services/translation/index.ts +10 -3
- package/server/utils/errors.ts +7 -21
- package/server/utils/files/html-store.ts +2 -16
- package/server/utils/files/journal-io.ts +0 -12
- package/server/utils/files/plugins-io.ts +2 -11
- package/server/utils/files/safe.ts +39 -0
- package/server/utils/httpError.ts +6 -0
- package/server/utils/text.ts +7 -23
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +6 -4
- package/server/workspace/collections/remoteView.ts +31 -22
- package/server/workspace/collections/watcher.ts +1 -0
- package/server/workspace/custom-dirs.ts +16 -6
- package/server/workspace/journal/state.ts +1 -9
- package/server/workspace/memory/topic-index-hook.ts +0 -9
- package/server/workspace/memory/topic-io.ts +0 -4
- package/server/workspace/reference-dirs.ts +8 -3
- package/server/workspace/skills/external/catalog.ts +24 -13
- package/server/workspace/skills/writer.ts +20 -16
- package/server/workspace/wiki-pages/snapshot.ts +13 -12
- package/src/components/SettingsGoogleTab.vue +4 -1
- package/src/components/StackView.vue +12 -1
- package/src/composables/collections/uiHost.ts +2 -1
- package/src/composables/useChatScroll.ts +18 -6
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/composables/useFileSelection.ts +4 -1
- package/src/composables/useStickToBottom.ts +51 -0
- package/src/config/apiRoutes.ts +20 -0
- package/src/config/roles.ts +0 -4
- package/src/lang/de.ts +0 -30
- package/src/lang/en.ts +0 -30
- package/src/lang/es.ts +0 -30
- package/src/lang/fr.ts +0 -30
- package/src/lang/ja.ts +0 -30
- package/src/lang/ko.ts +0 -30
- package/src/lang/pt-BR.ts +0 -30
- package/src/lang/zh.ts +0 -30
- package/src/plugins/accounting/index.ts +0 -2
- package/src/plugins/api.ts +0 -6
- package/src/plugins/canvas/index.ts +0 -2
- package/src/plugins/chart/index.ts +0 -2
- package/src/plugins/editImages/index.ts +0 -2
- package/src/plugins/generateImage/index.ts +0 -2
- package/src/plugins/manageSkills/index.ts +0 -2
- package/src/plugins/markdown/index.ts +0 -2
- package/src/plugins/photoLocations/index.ts +0 -2
- package/src/plugins/presentCollection/index.ts +0 -2
- package/src/plugins/presentForm/index.ts +0 -2
- package/src/plugins/presentHtml/index.ts +0 -2
- package/src/plugins/presentMulmoScript/definition.ts +11 -116
- package/src/plugins/presentMulmoScript/index.ts +62 -12
- package/src/plugins/presentSVG/index.ts +0 -2
- package/src/plugins/skill/index.ts +0 -3
- package/src/plugins/spreadsheet/engine/parser.ts +0 -103
- package/src/plugins/spreadsheet/index.ts +0 -2
- package/src/plugins/textResponse/index.ts +0 -3
- package/src/plugins/wiki/index.ts +0 -2
- package/src/types/notification.ts +0 -10
- package/src/utils/api.ts +0 -4
- package/src/utils/dom/scrollable.ts +15 -0
- package/src/utils/errors.ts +7 -38
- package/client/assets/index-D5nJbMO1.css +0 -2
- package/server/api/routes/mulmoScriptValidate.ts +0 -101
- package/server/utils/mulmoErrorCapture.ts +0 -93
- package/src/plugins/presentMulmoScript/Preview.vue +0 -23
- package/src/plugins/presentMulmoScript/View.vue +0 -1949
- package/src/plugins/presentMulmoScript/helpers.ts +0 -206
|
@@ -8,17 +8,22 @@
|
|
|
8
8
|
import {
|
|
9
9
|
createCalendarEvent,
|
|
10
10
|
DEFAULT_LIST_MAX_RESULTS,
|
|
11
|
+
getCalendarColors,
|
|
11
12
|
getGoogleAccessToken,
|
|
12
13
|
isIsoDateTimeWithOffset,
|
|
13
14
|
listCalendarEvents,
|
|
15
|
+
listCalendars,
|
|
14
16
|
MAX_LIST_RESULTS,
|
|
17
|
+
type CalendarColorEntry,
|
|
15
18
|
} from "@mulmoclaude/core/google";
|
|
16
|
-
import type { CommandHandler, JsonObject } from "../commandChannel.js";
|
|
19
|
+
import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
|
|
17
20
|
|
|
18
21
|
export interface GoogleCalendarDeps {
|
|
19
22
|
getAccessToken: typeof getGoogleAccessToken;
|
|
20
23
|
createEvent: typeof createCalendarEvent;
|
|
21
24
|
listEvents: typeof listCalendarEvents;
|
|
25
|
+
listCalendars: typeof listCalendars;
|
|
26
|
+
getColors: typeof getCalendarColors;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
const requiredString = (params: JsonObject, key: string): string => {
|
|
@@ -27,6 +32,15 @@ const requiredString = (params: JsonObject, key: string): string => {
|
|
|
27
32
|
return value;
|
|
28
33
|
};
|
|
29
34
|
|
|
35
|
+
const optionalString = (params: JsonObject, key: string): string | undefined => {
|
|
36
|
+
const value = params[key];
|
|
37
|
+
if (value === undefined || value === null) return undefined;
|
|
38
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${key} must be a non-empty string`);
|
|
39
|
+
// Return trimmed so whitespace can't reach the Google API (matches the
|
|
40
|
+
// plugin's Zod .trim() normalization).
|
|
41
|
+
return value.trim();
|
|
42
|
+
};
|
|
43
|
+
|
|
30
44
|
// Calendar's `dateTime`/`timeMin` are RFC3339 and reject date-only,
|
|
31
45
|
// offset-less, or impossible values with an opaque 400, so the strict shared
|
|
32
46
|
// validator runs here where the remote gets an actionable message.
|
|
@@ -47,6 +61,14 @@ const clampMaxResults = (value: unknown): number => {
|
|
|
47
61
|
return Math.min(Math.max(value, 1), MAX_LIST_RESULTS);
|
|
48
62
|
};
|
|
49
63
|
|
|
64
|
+
// Spread rebuilds an anonymous object type — the named CalendarColorEntry
|
|
65
|
+
// interface (no index signature) can't satisfy the channel's structural
|
|
66
|
+
// JsonValue directly (same constraint as CalendarEventSummary).
|
|
67
|
+
const toColorMapJson = (map: Record<string, CalendarColorEntry>): JsonObject =>
|
|
68
|
+
Object.fromEntries(
|
|
69
|
+
Object.entries(map).map(([colorId, entry]): [string, JsonValue] => [colorId, { background: entry.background, foreground: entry.foreground }]),
|
|
70
|
+
);
|
|
71
|
+
|
|
50
72
|
export const createGoogleCalendarCreateEvent =
|
|
51
73
|
(deps: GoogleCalendarDeps): CommandHandler =>
|
|
52
74
|
async (params: JsonObject) => {
|
|
@@ -55,6 +77,8 @@ export const createGoogleCalendarCreateEvent =
|
|
|
55
77
|
startDateTime: asDateTime(requiredString(params, "start"), "start"),
|
|
56
78
|
endDateTime: asDateTime(requiredString(params, "end"), "end"),
|
|
57
79
|
description: typeof params.description === "string" ? params.description : undefined,
|
|
80
|
+
calendarId: optionalString(params, "calendarId"),
|
|
81
|
+
colorId: optionalString(params, "colorId"),
|
|
58
82
|
};
|
|
59
83
|
const event = await deps.createEvent(await deps.getAccessToken(), input);
|
|
60
84
|
// Spread rebuilds an anonymous object type — the CalendarEventSummary
|
|
@@ -68,10 +92,33 @@ export const createGoogleCalendarListEvents =
|
|
|
68
92
|
async (params: JsonObject) => {
|
|
69
93
|
const timeMin = optionalDateTime(params, "timeMin");
|
|
70
94
|
const maxResults = clampMaxResults(params.maxResults);
|
|
71
|
-
const
|
|
95
|
+
const calendarId = optionalString(params, "calendarId");
|
|
96
|
+
const events = await deps.listEvents(await deps.getAccessToken(), { timeMin, maxResults, calendarId });
|
|
72
97
|
return { events: events.map((event) => ({ ...event })) };
|
|
73
98
|
};
|
|
74
99
|
|
|
75
|
-
const
|
|
100
|
+
export const createGoogleCalendarListCalendars =
|
|
101
|
+
(deps: GoogleCalendarDeps): CommandHandler =>
|
|
102
|
+
async () => {
|
|
103
|
+
const calendars = await deps.listCalendars(await deps.getAccessToken());
|
|
104
|
+
return { calendars: calendars.map((calendar) => ({ ...calendar })) };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const createGoogleCalendarColors =
|
|
108
|
+
(deps: GoogleCalendarDeps): CommandHandler =>
|
|
109
|
+
async () => {
|
|
110
|
+
const colors = await deps.getColors(await deps.getAccessToken());
|
|
111
|
+
return { colors: { event: toColorMapJson(colors.event), calendar: toColorMapJson(colors.calendar) } };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const deps: GoogleCalendarDeps = {
|
|
115
|
+
getAccessToken: getGoogleAccessToken,
|
|
116
|
+
createEvent: createCalendarEvent,
|
|
117
|
+
listEvents: listCalendarEvents,
|
|
118
|
+
listCalendars,
|
|
119
|
+
getColors: getCalendarColors,
|
|
120
|
+
};
|
|
76
121
|
export const googleCalendarCreateEvent = createGoogleCalendarCreateEvent(deps);
|
|
77
122
|
export const googleCalendarListEvents = createGoogleCalendarListEvents(deps);
|
|
123
|
+
export const googleCalendarListCalendars = createGoogleCalendarListCalendars(deps);
|
|
124
|
+
export const googleCalendarColors = createGoogleCalendarColors(deps);
|
|
@@ -6,7 +6,7 @@ import { getCollection } from "./getCollection.js";
|
|
|
6
6
|
import { getFeed } from "./getFeed.js";
|
|
7
7
|
import { getRemoteView } from "./getRemoteView.js";
|
|
8
8
|
import { getRemoteViewItems } from "./getRemoteViewItems.js";
|
|
9
|
-
import { googleCalendarCreateEvent, googleCalendarListEvents } from "./googleCalendar.js";
|
|
9
|
+
import { googleCalendarColors, googleCalendarCreateEvent, googleCalendarListCalendars, googleCalendarListEvents } from "./googleCalendar.js";
|
|
10
10
|
import { listAccountingBooks } from "./listAccountingBooks.js";
|
|
11
11
|
import { listCollections } from "./listCollections.js";
|
|
12
12
|
import { listFeeds } from "./listFeeds.js";
|
|
@@ -29,4 +29,6 @@ export const handlers: CommandHandlers = {
|
|
|
29
29
|
startChat,
|
|
30
30
|
"google.calendar.createEvent": googleCalendarCreateEvent,
|
|
31
31
|
"google.calendar.listEvents": googleCalendarListEvents,
|
|
32
|
+
"google.calendar.listCalendars": googleCalendarListCalendars,
|
|
33
|
+
"google.calendar.colors": googleCalendarColors,
|
|
32
34
|
};
|
|
@@ -88,9 +88,16 @@ export function createTranslationService(deps: TranslationServiceDeps): Translat
|
|
|
88
88
|
const next = prev.catch(() => undefined).then(runner);
|
|
89
89
|
const tracked = next.catch(() => undefined);
|
|
90
90
|
chains.set(namespace, tracked);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
// Housekeeping only — the caller waits on `next`, not on this. Still needs
|
|
92
|
+
// a terminal handler: `tracked` swallowed the runner's rejection, but the
|
|
93
|
+
// cleanup callback itself is not covered by that. Silent by design: a
|
|
94
|
+
// failed map-entry cleanup is not worth a log line, and it used to reach
|
|
95
|
+
// the process-level `unhandledRejection` handler and exit the server.
|
|
96
|
+
tracked
|
|
97
|
+
.then(() => {
|
|
98
|
+
if (chains.get(namespace) === tracked) chains.delete(namespace);
|
|
99
|
+
})
|
|
100
|
+
.catch(() => {});
|
|
94
101
|
return next;
|
|
95
102
|
}
|
|
96
103
|
|
package/server/utils/errors.ts
CHANGED
|
@@ -2,24 +2,10 @@
|
|
|
2
2
|
// `err instanceof Error ? err.message : String(err)` — searching for
|
|
3
3
|
// one canonical helper is easier than grepping for the inline form.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
// message ("rebuild failed") rather than `String(err)` noise. Prefer
|
|
13
|
-
// passing a fallback at error-response boundaries — omit it for
|
|
14
|
-
// logging contexts where `String(err)` is fine.
|
|
15
|
-
|
|
16
|
-
export function errorMessage(err: unknown, fallback?: string): string {
|
|
17
|
-
if (err instanceof Error) return err.message;
|
|
18
|
-
if (err !== null && typeof err === "object") {
|
|
19
|
-
const obj = err as { details?: unknown; message?: unknown };
|
|
20
|
-
if (typeof obj.details === "string" && obj.details) return obj.details;
|
|
21
|
-
if (typeof obj.message === "string" && obj.message) return obj.message;
|
|
22
|
-
}
|
|
23
|
-
if (fallback !== undefined) return fallback;
|
|
24
|
-
return String(err);
|
|
25
|
-
}
|
|
5
|
+
// The implementation lives in `@mulmoclaude/core/utils` so the host, the
|
|
6
|
+
// collection engine, the scheduler and the Google engine can't drift apart
|
|
7
|
+
// again: before #2217 this function existed 14 times across 4 behaviours, and
|
|
8
|
+
// gRPC-shaped errors surfaced as "[object Object]" through half of them.
|
|
9
|
+
// Re-exported (rather than repointing 92 files) so host code keeps reaching
|
|
10
|
+
// for `server/utils/errors.js` by name.
|
|
11
|
+
export { errorMessage } from "@mulmoclaude/core/utils";
|
|
@@ -1,20 +1,6 @@
|
|
|
1
|
-
import path from "path";
|
|
2
|
-
import { workspacePath } from "../../workspace/workspace.js";
|
|
3
1
|
import { WORKSPACE_DIRS } from "../../workspace/paths.js";
|
|
4
|
-
import { writeFileAtomic } from "./atomic.js";
|
|
5
2
|
import { makePathValidator } from "./path-validator.js";
|
|
6
3
|
|
|
7
|
-
//
|
|
4
|
+
// The sole guard on html-store paths: `path.join` elsewhere doesn't normalize
|
|
5
|
+
// traversal, so callers must pre-check writes with this validator.
|
|
8
6
|
export const isHtmlPath = makePathValidator({ prefix: WORKSPACE_DIRS.htmls, ext: ".html" });
|
|
9
|
-
|
|
10
|
-
// Defense in depth (matches `overwriteSvg`): if a caller forgets to
|
|
11
|
-
// pre-check via `isHtmlPath`, `path.join(workspacePath, relativePath)`
|
|
12
|
-
// would silently produce a traversal escape. The re-check inside the
|
|
13
|
-
// write closes that trust chain.
|
|
14
|
-
export async function overwriteHtml(relativePath: string, content: string): Promise<void> {
|
|
15
|
-
if (!isHtmlPath(relativePath)) {
|
|
16
|
-
throw new Error(`invalid html path: ${relativePath}`);
|
|
17
|
-
}
|
|
18
|
-
const absPath = path.join(workspacePath, relativePath);
|
|
19
|
-
await writeFileAtomic(absPath, content);
|
|
20
|
-
}
|
|
@@ -7,20 +7,8 @@ import { isEnoent } from "./safe.js";
|
|
|
7
7
|
import { log } from "../../system/logger/index.js";
|
|
8
8
|
import { summariesRoot, dailyPathFor, topicPathFor, TOPICS_DIR, INDEX_FILE, STATE_FILE, DAILY_DIR, ARCHIVE_DIR } from "../../workspace/journal/paths.js";
|
|
9
9
|
|
|
10
|
-
import { statSync } from "node:fs";
|
|
11
|
-
|
|
12
10
|
const root = (rootOverride?: string) => rootOverride ?? workspacePath;
|
|
13
11
|
|
|
14
|
-
export function journalStateExists(rootOverride?: string): boolean {
|
|
15
|
-
const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
|
|
16
|
-
try {
|
|
17
|
-
statSync(filePath);
|
|
18
|
-
return true;
|
|
19
|
-
} catch {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
12
|
export async function readJournalState<T>(fallback: T, rootOverride?: string): Promise<T> {
|
|
25
13
|
const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
|
|
26
14
|
try {
|
|
@@ -15,10 +15,9 @@
|
|
|
15
15
|
// written ledger never bricks server boot. Writes go through the
|
|
16
16
|
// atomic helper, so a crashed install can't leave a corrupt file.
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
import { mkdir, readFile } from "node:fs/promises";
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
20
19
|
import path from "node:path";
|
|
21
|
-
import { loadJsonFile
|
|
20
|
+
import { loadJsonFile } from "./json.js";
|
|
22
21
|
import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
23
22
|
|
|
24
23
|
export interface LedgerEntry {
|
|
@@ -49,14 +48,6 @@ export function readLedger(): LedgerEntry[] {
|
|
|
49
48
|
return sanitiseLedger(raw);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
export async function writeLedger(entries: readonly LedgerEntry[]): Promise<void> {
|
|
53
|
-
const dir = path.dirname(WORKSPACE_PATHS.pluginsLedger);
|
|
54
|
-
if (!existsSync(dir)) {
|
|
55
|
-
await mkdir(dir, { recursive: true });
|
|
56
|
-
}
|
|
57
|
-
await writeJsonAtomic(WORKSPACE_PATHS.pluginsLedger, [...entries]);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
/** Read a runtime-plugin asset (extracted under
|
|
61
52
|
* `plugins/.cache/<name>/<version>/`) and return its bytes plus
|
|
62
53
|
* the inferred Content-Type. The route handler in
|
|
@@ -99,6 +99,45 @@ export function hasTraversalSegment(value: string): boolean {
|
|
|
99
99
|
return value.split(/[/\\]/).some((segment) => segment === ".." || segment === ".");
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Lazily realpath a directory once and cache the result. Returns null
|
|
103
|
+
// until the directory exists on disk (a fresh workspace hasn't created
|
|
104
|
+
// it yet) and retries on the next call. The /artifacts/* static mounts
|
|
105
|
+
// each need their storage root as a realpath for the traversal check,
|
|
106
|
+
// but the root may not be materialised at boot.
|
|
107
|
+
export function makeCachedRealpath(dir: string): () => Promise<string | null> {
|
|
108
|
+
let cached: string | null = null;
|
|
109
|
+
return async () => {
|
|
110
|
+
if (cached) return cached;
|
|
111
|
+
try {
|
|
112
|
+
cached = await promises.realpath(dir);
|
|
113
|
+
return cached;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Decode and traversal-guard an `/artifacts/*` request path against an
|
|
121
|
+
// already-realpath'd storage root. Returns the in-root relative path to
|
|
122
|
+
// serve, or null when the URL is malformed, escapes the root, or (when
|
|
123
|
+
// `denyDotfiles`) touches a dotfile segment. The images / html / svg
|
|
124
|
+
// static mounts share this so the decode + traversal + dotfile policy is
|
|
125
|
+
// defined once. `rootReal` MUST be a realpath (see `resolveWithinRoot`).
|
|
126
|
+
export function resolveArtifactRequestPath(rootReal: string, reqPath: string, denyDotfiles: boolean): string | null {
|
|
127
|
+
let relPath: string;
|
|
128
|
+
try {
|
|
129
|
+
// decodeURIComponent throws URIError on malformed escapes (`%ZZ`,
|
|
130
|
+
// stray `%`). Fail closed so a junk URL 404s instead of bubbling a
|
|
131
|
+
// 500 out of the express error chain.
|
|
132
|
+
relPath = decodeURIComponent(reqPath.replace(/^\//, ""));
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (!resolveWithinRoot(rootReal, relPath)) return null;
|
|
137
|
+
if (denyDotfiles && containsDotfileSegment(relPath)) return null;
|
|
138
|
+
return relPath;
|
|
139
|
+
}
|
|
140
|
+
|
|
102
141
|
// `rootReal` MUST already be a realpath. Returns null on traversal or if either path doesn't exist on disk.
|
|
103
142
|
export function resolveWithinRoot(rootReal: string, relPath: string): string | null {
|
|
104
143
|
const normalized = path.normalize(relPath || "");
|
|
@@ -50,6 +50,12 @@ export function notFound(res: Response, error: string): Response {
|
|
|
50
50
|
return sendError(res, 404, error);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** 405 Method Not Allowed — the resource exists but refuses this verb
|
|
54
|
+
* (e.g. a write against a read-only `dataSource` collection). */
|
|
55
|
+
export function methodNotAllowed(res: Response, error: string): Response {
|
|
56
|
+
return sendError(res, 405, error);
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
/** 409 Conflict — duplicate, concurrent modification, already running, etc. */
|
|
54
60
|
export function conflict(res: Response, error: string): Response {
|
|
55
61
|
return sendError(res, 409, error);
|
package/server/utils/text.ts
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
// Shared text helpers. Use these instead of re-implementing the
|
|
2
2
|
// same operation per file (#1306).
|
|
3
3
|
//
|
|
4
|
+
// `truncate` lives in `@mulmoclaude/core/utils` so the host, the collection
|
|
5
|
+
// engine and the Google engine share one implementation — the copies had
|
|
6
|
+
// already drifted on the `ellipsis.length >= max` guard (#2217). Re-exported
|
|
7
|
+
// (rather than repointing ~5 import sites) to keep `server/utils/text.ts` the
|
|
8
|
+
// one place host code looks for general string helpers.
|
|
9
|
+
//
|
|
4
10
|
// Why not in `format/`: these are general string operations, not
|
|
5
11
|
// presentation-layer formatters. Reserve `format/` for locale-aware
|
|
6
12
|
// or unit-aware display helpers.
|
|
7
|
-
|
|
8
|
-
/** Truncate `text` to at most `max` characters, appending `ellipsis`
|
|
9
|
-
* when the input is too long.
|
|
10
|
-
*
|
|
11
|
-
* The ellipsis is part of the budget: `truncate("hello world", 8)`
|
|
12
|
-
* yields `"hello w…"` (7 chars of the original + the ellipsis = 8
|
|
13
|
-
* total). This avoids the off-by-one bug where naive
|
|
14
|
-
* `slice(0, max) + "…"` overshoots `max`.
|
|
15
|
-
*
|
|
16
|
-
* Edge cases:
|
|
17
|
-
* - `text.length <= max` → return `text` unchanged.
|
|
18
|
-
* - `max <= 0` → return the empty string (callers asking for "no
|
|
19
|
-
* output" should get "no output", not a stray ellipsis).
|
|
20
|
-
* - If `ellipsis.length > max`, the ellipsis itself is truncated
|
|
21
|
-
* to fit `max` rather than throwing — keeps callers safe from
|
|
22
|
-
* surprising errors when they pick a tiny max with a multi-char
|
|
23
|
-
* ellipsis. */
|
|
24
|
-
export function truncate(text: string, max: number, ellipsis = "…"): string {
|
|
25
|
-
if (max <= 0) return "";
|
|
26
|
-
if (text.length <= max) return text;
|
|
27
|
-
if (ellipsis.length >= max) return ellipsis.slice(0, max);
|
|
28
|
-
return text.slice(0, max - ellipsis.length) + ellipsis;
|
|
29
|
-
}
|
|
13
|
+
export { truncate } from "@mulmoclaude/core/utils";
|
package/server/utils/time.ts
CHANGED
|
@@ -47,6 +47,3 @@ export const SUBPROCESS_WORK_TIMEOUT_MS = ONE_MINUTE_MS;
|
|
|
47
47
|
|
|
48
48
|
/** CLI subprocess timeout (claude -p for summarization, etc.) */
|
|
49
49
|
export const CLI_SUBPROCESS_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
|
|
50
|
-
|
|
51
|
-
/** Maximum one-shot notification delay */
|
|
52
|
-
export const MAX_NOTIFICATION_DELAY_SEC = 3_600; // 1 hour in seconds
|
|
@@ -11,13 +11,15 @@ export { validateCollectionRecords, validateRecordObject, COMPUTED_TYPES, type R
|
|
|
11
11
|
export { buildWorkspaceOntology, schemaRelations, type CollectionOntologyEntry, type OntologyRelation } from "@mulmoclaude/core/collection/server";
|
|
12
12
|
export { applyMutateAction, firstMutateParamProblem, type MutateActionOutcome } from "@mulmoclaude/core/collection/server";
|
|
13
13
|
export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection/server";
|
|
14
|
+
export { storeFor, collectionWritable, readOnlyRefusal, type CollectionStore } from "@mulmoclaude/core/collection/server";
|
|
14
15
|
export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
|
|
15
16
|
export { deleteCustomView, type DeleteViewResult } from "@mulmoclaude/core/collection/server";
|
|
17
|
+
// NOTE (storage virtualization, plans/refactor-storage-virtualization.md):
|
|
18
|
+
// the raw io functions (`listItems` / `readItem` / `writeItem` /
|
|
19
|
+
// `deleteItem`) are deliberately NOT re-exported — host code reads AND
|
|
20
|
+
// writes records only through `storeFor(...)` (write/delete exist only on
|
|
21
|
+
// writable stores) so a future storage backend can't be bypassed.
|
|
16
22
|
export {
|
|
17
|
-
listItems,
|
|
18
|
-
readItem,
|
|
19
|
-
writeItem,
|
|
20
|
-
deleteItem,
|
|
21
23
|
safeRecordId,
|
|
22
24
|
generateItemId,
|
|
23
25
|
resolveCreateItemId,
|
|
@@ -23,16 +23,14 @@ import {
|
|
|
23
23
|
} from "@mulmoclaude/core/remote-view";
|
|
24
24
|
import { enrichItems } from "@mulmoclaude/core/collection/server";
|
|
25
25
|
import {
|
|
26
|
-
deleteItem,
|
|
27
|
-
listItems,
|
|
28
26
|
readCustomViewHtml,
|
|
29
27
|
readCustomViewI18n,
|
|
30
|
-
readItem,
|
|
31
28
|
safeRecordId,
|
|
32
|
-
|
|
29
|
+
storeFor,
|
|
33
30
|
type CollectionCustomView,
|
|
34
31
|
type CollectionItem,
|
|
35
32
|
type CollectionSchema,
|
|
33
|
+
type CollectionStore,
|
|
36
34
|
type LoadedCollection,
|
|
37
35
|
} from "./index.js";
|
|
38
36
|
import { resolveThumbnail } from "../../utils/files/thumbnail-store.js";
|
|
@@ -108,6 +106,7 @@ export type MutateRemoteViewResult =
|
|
|
108
106
|
| { kind: "view-not-found"; viewId: string }
|
|
109
107
|
| { kind: "not-mobile"; viewId: string }
|
|
110
108
|
| { kind: "not-writable"; viewId: string }
|
|
109
|
+
| { kind: "read-only-collection" }
|
|
111
110
|
| { kind: "field-not-editable"; field: string }
|
|
112
111
|
| { kind: "delete-not-allowed" }
|
|
113
112
|
| { kind: "invalid-patch" }
|
|
@@ -116,9 +115,7 @@ export type MutateRemoteViewResult =
|
|
|
116
115
|
| { kind: "path-escape" };
|
|
117
116
|
|
|
118
117
|
export interface MutateRemoteViewDeps {
|
|
119
|
-
|
|
120
|
-
writeItem: typeof writeItem;
|
|
121
|
-
deleteItem: typeof deleteItem;
|
|
118
|
+
storeFor: (collection: LoadedCollection) => CollectionStore;
|
|
122
119
|
enrichItems: typeof enrichItems;
|
|
123
120
|
resolveThumbnail: typeof resolveThumbnail;
|
|
124
121
|
}
|
|
@@ -129,13 +126,18 @@ export const createMutateRemoteView =
|
|
|
129
126
|
const view = (collection.schema.views ?? []).find((entry) => entry.id === viewId);
|
|
130
127
|
if (!view) return { kind: "view-not-found", viewId };
|
|
131
128
|
if (view.target !== "mobile") return { kind: "not-mobile", viewId };
|
|
129
|
+
// A dataSource collection is read-only regardless of what write surface
|
|
130
|
+
// the view declares — the collection-level rule outranks the view's. The
|
|
131
|
+
// store encodes it as absent write/delete methods.
|
|
132
|
+
const store = deps.storeFor(collection);
|
|
133
|
+
if (!store.write || !store.delete) return { kind: "read-only-collection" };
|
|
132
134
|
if (!isWritableView(view)) return { kind: "not-writable", viewId };
|
|
133
|
-
return request.op === "delete" ? deleteViaView(
|
|
135
|
+
return request.op === "delete" ? deleteViaView(store.delete, view.allowDelete === true, request.id) : updateViaView(deps, store, collection, view, request);
|
|
134
136
|
};
|
|
135
137
|
|
|
136
|
-
async function deleteViaView(
|
|
138
|
+
async function deleteViaView(remove: NonNullable<CollectionStore["delete"]>, allowDelete: boolean, itemId: string): Promise<MutateRemoteViewResult> {
|
|
137
139
|
if (!allowDelete) return { kind: "delete-not-allowed" };
|
|
138
|
-
const result = await
|
|
140
|
+
const result = await remove(itemId);
|
|
139
141
|
if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
|
|
140
142
|
if (result.kind === "path-escape") return { kind: "path-escape" };
|
|
141
143
|
if (result.kind === "not-found") return { kind: "item-not-found", id: result.itemId };
|
|
@@ -144,10 +146,13 @@ async function deleteViaView(deps: MutateRemoteViewDeps, collection: LoadedColle
|
|
|
144
146
|
|
|
145
147
|
async function updateViaView(
|
|
146
148
|
deps: MutateRemoteViewDeps,
|
|
149
|
+
store: CollectionStore,
|
|
147
150
|
collection: LoadedCollection,
|
|
148
151
|
view: CollectionCustomView,
|
|
149
152
|
request: Extract<RemoteViewMutateRequest, { op: "update" }>,
|
|
150
153
|
): Promise<MutateRemoteViewResult> {
|
|
154
|
+
const { write } = store;
|
|
155
|
+
if (!write) return { kind: "read-only-collection" }; // unreachable: caller guards presence
|
|
151
156
|
const { primaryKey } = collection.schema;
|
|
152
157
|
const patchKeys = Object.keys(request.patch);
|
|
153
158
|
if (patchKeys.length === 0) return { kind: "invalid-patch" };
|
|
@@ -156,17 +161,17 @@ async function updateViaView(
|
|
|
156
161
|
// desync the file name from the record) even if an author listed it.
|
|
157
162
|
const offending = patchKeys.find((key) => key === primaryKey || !allowed.has(key));
|
|
158
163
|
if (offending) return { kind: "field-not-editable", field: offending };
|
|
159
|
-
// Classify a bad id BEFORE
|
|
160
|
-
// path-escape, AND a genuinely-missing record alike — so update reports
|
|
161
|
-
// same explicit `invalid-id` the delete path does (via
|
|
162
|
-
// masking it as a 404. (A valid id whose
|
|
163
|
-
// hold no record, so it still resolves to item-not-found; a
|
|
164
|
-
// additionally refused by
|
|
164
|
+
// Classify a bad id BEFORE the store read — which returns null for an unsafe
|
|
165
|
+
// id, a path-escape, AND a genuinely-missing record alike — so update reports
|
|
166
|
+
// the same explicit `invalid-id` the delete path does (via `store.delete`)
|
|
167
|
+
// instead of masking it as a 404. (A valid id whose data location escapes the
|
|
168
|
+
// workspace can hold no record, so it still resolves to item-not-found; a
|
|
169
|
+
// real write is additionally refused by the store's own containment guard.)
|
|
165
170
|
if (safeRecordId(request.id) === null) return { kind: "invalid-id", id: request.id };
|
|
166
|
-
const existing = await
|
|
171
|
+
const existing = await store.read(request.id);
|
|
167
172
|
if (!existing) return { kind: "item-not-found", id: request.id };
|
|
168
173
|
const merged: CollectionItem = { ...existing, ...request.patch, [primaryKey]: request.id };
|
|
169
|
-
const result = await
|
|
174
|
+
const result = await write(request.id, merged);
|
|
170
175
|
if (result.kind === "invalid-id") return { kind: "invalid-id", id: result.itemId };
|
|
171
176
|
if (result.kind === "path-escape") return { kind: "path-escape" };
|
|
172
177
|
if (result.kind === "conflict") return { kind: "item-not-found", id: result.itemId }; // unreachable: refuseOverwrite is false
|
|
@@ -196,7 +201,7 @@ async function updateViaView(
|
|
|
196
201
|
return { kind: "ok", op: "update", item: item as CollectionItem };
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
export const mutateRemoteView = createMutateRemoteView({
|
|
204
|
+
export const mutateRemoteView = createMutateRemoteView({ storeFor, enrichItems, resolveThumbnail });
|
|
200
205
|
|
|
201
206
|
// ── Item pages with inlined image thumbnails (phase 5 — plans/feat-remote-view-images.md) ──
|
|
202
207
|
// A mobile view's `getItems`, view-aware so it can inline the `imageFields` its
|
|
@@ -213,7 +218,9 @@ export type RemoteViewItemsResult =
|
|
|
213
218
|
| { kind: "too-large"; bytes: number };
|
|
214
219
|
|
|
215
220
|
export interface RemoteViewItemsDeps {
|
|
216
|
-
|
|
221
|
+
/** Load every record of the collection — store-aware (file records or a
|
|
222
|
+
* `dataSource` CSV's rows), unlike a raw dataDir `listItems`. */
|
|
223
|
+
listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
|
|
217
224
|
enrichItems: typeof enrichItems;
|
|
218
225
|
resolveThumbnail: typeof resolveThumbnail;
|
|
219
226
|
}
|
|
@@ -271,7 +278,7 @@ export const createRemoteViewItems =
|
|
|
271
278
|
// formulas evaluated with a full ref cache (`ticker.price`, `shares * ticker.price`
|
|
272
279
|
// resolve), toggles projected, embeds resolved. The phone gets plain resolved
|
|
273
280
|
// scalars — no network, no dataUrl — so mobile numbers match desktop exactly.
|
|
274
|
-
const items = await deps.
|
|
281
|
+
const items = await deps.listRecords(collection);
|
|
275
282
|
const derived = (await deps.enrichItems(collection, items)) as RemoteViewItem[];
|
|
276
283
|
const page = pageFromItems(derived, request, collection.schema.primaryKey);
|
|
277
284
|
// Resolving an `embed` column attaches a whole target record per row, so the
|
|
@@ -289,7 +296,7 @@ export const createRemoteViewItems =
|
|
|
289
296
|
return { kind: "ok", page, inlined, omitted };
|
|
290
297
|
};
|
|
291
298
|
|
|
292
|
-
export const remoteViewItems = createRemoteViewItems({
|
|
299
|
+
export const remoteViewItems = createRemoteViewItems({ listRecords: (collection) => storeFor(collection).list(), enrichItems, resolveThumbnail });
|
|
293
300
|
|
|
294
301
|
/** Message per non-ok item-page kind — shared by the channel handler (throws)
|
|
295
302
|
* and the HTTP route (sends with the matching status). */
|
|
@@ -307,6 +314,8 @@ export function mutateRemoteViewFailureMessage(result: Exclude<MutateRemoteViewR
|
|
|
307
314
|
if (result.kind === "not-mobile") return `custom view '${result.viewId}' is not a mobile view — declare target: "mobile" in its views[] entry`;
|
|
308
315
|
if (result.kind === "not-writable")
|
|
309
316
|
return `mobile view '${result.viewId}' is read-only — declare editableFields and/or allowDelete in its views[] entry to allow writes`;
|
|
317
|
+
if (result.kind === "read-only-collection")
|
|
318
|
+
return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
|
|
310
319
|
if (result.kind === "field-not-editable")
|
|
311
320
|
return `field '${result.field}' is not editable from this view — add it to the view's editableFields (the primary key is never editable)`;
|
|
312
321
|
if (result.kind === "delete-not-allowed") return `this view may not delete records — set allowDelete: true in its views[] entry`;
|
|
@@ -9,7 +9,7 @@ import path from "path";
|
|
|
9
9
|
import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
|
|
10
10
|
import { log } from "../system/logger/index.js";
|
|
11
11
|
import { writeJsonAtomicSync } from "../utils/files/json.js";
|
|
12
|
-
import { isRecord } from "../utils/types.js";
|
|
12
|
+
import { hasStringProp, isRecord } from "../utils/types.js";
|
|
13
13
|
|
|
14
14
|
// ── Types ───────────────────────────────────────────────────────
|
|
15
15
|
|
|
@@ -82,16 +82,23 @@ function sanitizeDescription(raw: string): string {
|
|
|
82
82
|
|
|
83
83
|
function validateEntry(raw: unknown): CustomDirEntry | null {
|
|
84
84
|
if (!isRecord(raw)) return null;
|
|
85
|
-
const obj = raw as Record<string, unknown>;
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
// Type-check the field rather than `String(...)`-ing it. This file is fed by a
|
|
87
|
+
// hand-edited config, so `path` can be an array or object; stringifying one
|
|
88
|
+
// handed `validatePath` the literal "[object Object]" to evaluate as a path.
|
|
89
|
+
// (`validatePath`'s own `typeof !== "string"` check could never fire behind a
|
|
90
|
+
// `String()` call.)
|
|
91
|
+
if (!hasStringProp(raw, "path")) return null;
|
|
92
|
+
const validPath = validatePath(raw.path);
|
|
88
93
|
if (!validPath) return null;
|
|
89
94
|
|
|
90
|
-
const structure = isValidStructure(
|
|
95
|
+
const structure = isValidStructure(raw.structure) ? raw.structure : DIR_STRUCTURES.flat;
|
|
91
96
|
|
|
92
97
|
return {
|
|
93
98
|
path: validPath,
|
|
94
|
-
|
|
99
|
+
// A non-string description is dropped rather than described as
|
|
100
|
+
// "[object Object]" — it is optional, so absent is the honest reading.
|
|
101
|
+
description: sanitizeDescription(hasStringProp(raw, "description") ? raw.description : ""),
|
|
95
102
|
structure,
|
|
96
103
|
};
|
|
97
104
|
}
|
|
@@ -153,7 +160,10 @@ export function validateCustomDirs(raw: unknown): { entries: CustomDirEntry[] }
|
|
|
153
160
|
if (entry) {
|
|
154
161
|
entries.push(entry);
|
|
155
162
|
} else {
|
|
156
|
-
|
|
163
|
+
// Only echo a genuine string back in the error; a non-string `path` is
|
|
164
|
+
// exactly the case where "[object Object]" would mislead the reader about
|
|
165
|
+
// what their config actually says.
|
|
166
|
+
const itemPath = hasStringProp(item, "path") ? item.path : "";
|
|
157
167
|
errors.push(`entry ${i}: invalid path "${itemPath}"`);
|
|
158
168
|
}
|
|
159
169
|
});
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readJournalState as readJournalStateRaw,
|
|
3
|
-
writeJournalState as writeJournalStateRaw,
|
|
4
|
-
journalStateExists as journalStateExistsRaw,
|
|
5
|
-
} from "../../utils/files/journal-io.js";
|
|
1
|
+
import { readJournalState as readJournalStateRaw, writeJournalState as writeJournalStateRaw } from "../../utils/files/journal-io.js";
|
|
6
2
|
import { ONE_HOUR_MS, ONE_DAY_MS } from "../../utils/time.js";
|
|
7
3
|
import { log } from "../../system/logger/index.js";
|
|
8
4
|
import { isRecord } from "../../utils/types.js";
|
|
@@ -106,7 +102,3 @@ export async function readState(workspaceRoot: string): Promise<JournalState> {
|
|
|
106
102
|
export async function writeState(workspaceRoot: string, state: JournalState): Promise<void> {
|
|
107
103
|
await writeJournalStateRaw(state, workspaceRoot);
|
|
108
104
|
}
|
|
109
|
-
|
|
110
|
-
export function stateFileExists(workspaceRoot: string): boolean {
|
|
111
|
-
return journalStateExistsRaw(workspaceRoot);
|
|
112
|
-
}
|
|
@@ -117,12 +117,3 @@ export async function maybeRegenerateTopicIndex(relativePath: string): Promise<b
|
|
|
117
117
|
return false;
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
/** Test-only — drop the per-workspace regen chain so each test
|
|
122
|
-
* starts with a fresh queue. Without this, a chain rejection from
|
|
123
|
-
* one test could carry into the next (the .catch in `chainRegen`
|
|
124
|
-
* swallows it for the next caller, but the test runner sees the
|
|
125
|
-
* unhandled-rejection from the original promise). */
|
|
126
|
-
export function _resetTopicIndexRegenChainForTesting(): void {
|
|
127
|
-
regenChains.clear();
|
|
128
|
-
}
|
|
@@ -33,10 +33,6 @@ export function topicMemoryIndexPath(workspaceRoot: string): string {
|
|
|
33
33
|
return path.join(workspaceRoot, WORKSPACE_FILES.memoryIndex);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function topicFilePath(workspaceRoot: string, type: MemoryType, topic: string): string {
|
|
37
|
-
return path.join(topicMemoryRoot(workspaceRoot), type, `${topic}.md`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
36
|
// Both loaders walk types in `MEMORY_TYPES` order (a stable
|
|
41
37
|
// constant) and sort filenames within each type by name. This pins
|
|
42
38
|
// the order of entries in the agent's system prompt so a `readdir`
|