mulmoclaude 0.6.0 → 0.6.2
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/PluginScopedRoot-YjvQq0Nn.js +3 -0
- package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
- package/client/assets/{index-BbgSjFQ8.js → index-BwrlMMHr.js} +178 -141
- package/client/assets/index-CvvNuegU.css +2 -0
- package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
- package/client/index.html +7 -6
- package/package.json +9 -7
- package/server/accounting/eventPublisher.ts +2 -1
- package/server/accounting/snapshotCache.ts +2 -1
- package/server/agent/activeTools.ts +16 -6
- package/server/agent/backend/claude-code.ts +1 -0
- package/server/agent/backend/types.ts +3 -0
- package/server/agent/config.ts +25 -2
- package/server/agent/index.ts +6 -0
- package/server/agent/mcp-server.ts +9 -6
- package/server/agent/mcp-tools/index.ts +15 -2
- package/server/agent/mcp-tools/notify.ts +20 -2
- package/server/agent/prompt.ts +37 -24
- package/server/api/routes/accounting.ts +31 -24
- package/server/api/routes/agent.ts +2 -2
- package/server/api/routes/config-refresh.ts +49 -0
- package/server/api/routes/config.ts +86 -68
- package/server/api/routes/files.ts +41 -17
- package/server/api/routes/hookLog.ts +95 -0
- package/server/api/routes/news.ts +39 -52
- package/server/api/routes/notifier.ts +14 -19
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/photo-locations.ts +79 -0
- package/server/api/routes/plugins.ts +11 -0
- package/server/api/routes/presentSvg.ts +107 -0
- package/server/api/routes/scheduler.ts +100 -98
- package/server/api/routes/schedulerTasks.ts +98 -95
- package/server/api/routes/sessions.ts +22 -27
- package/server/api/routes/sources.ts +45 -43
- package/server/api/routes/wiki/history.ts +6 -15
- package/server/api/routes/wiki.ts +73 -276
- package/server/events/file-change.ts +3 -2
- package/server/events/session-store/index.ts +2 -1
- package/server/index.ts +130 -8
- package/server/notifier/store.ts +3 -3
- package/server/plugins/preset-list.ts +16 -5
- package/server/plugins/runtime.ts +2 -2
- package/server/system/config.ts +138 -16
- package/server/utils/asyncHandler.ts +75 -0
- package/server/utils/exif.ts +321 -0
- package/server/utils/files/accounting-io.ts +19 -20
- package/server/utils/files/attachment-store.ts +69 -12
- package/server/utils/files/journal-io.ts +2 -1
- package/server/utils/files/json.ts +8 -1
- package/server/utils/files/reference-dirs-io.ts +2 -3
- package/server/utils/files/scheduler-overrides-io.ts +2 -3
- package/server/utils/files/svg-store.ts +27 -0
- package/server/utils/files/user-tasks-io.ts +2 -3
- package/server/utils/regex.ts +3 -12
- package/server/utils/text.ts +29 -0
- package/server/workspace/chat-index/summarizer.ts +5 -3
- package/server/workspace/cooking-recipes/migrate.ts +125 -0
- package/server/workspace/custom-dirs.ts +2 -2
- package/server/workspace/hooks/dispatcher.mjs +300 -0
- package/server/workspace/hooks/dispatcher.ts +55 -0
- package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
- package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
- package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
- package/server/workspace/hooks/provision.ts +222 -0
- package/server/workspace/hooks/shared/sidecar.ts +124 -0
- package/server/workspace/hooks/shared/stdin.ts +60 -0
- package/server/workspace/hooks/shared/workspace.ts +13 -0
- package/server/workspace/journal/dailyPass.ts +1 -6
- package/server/workspace/memory/io.ts +1 -34
- package/server/workspace/memory/migrate.ts +2 -1
- package/server/workspace/memory/snapshot.ts +26 -0
- package/server/workspace/memory/topic-io.ts +1 -18
- package/server/workspace/paths.ts +16 -0
- package/server/workspace/photo-locations/index.ts +149 -0
- package/server/workspace/photo-locations/list.ts +124 -0
- package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
- package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
- package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
- package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
- package/server/workspace/skills-preset.ts +2 -1
- package/server/workspace/wiki-pages/io.ts +2 -1
- package/src/App.vue +78 -3
- package/src/components/ChatInput.vue +7 -8
- package/src/components/FileContentHeader.vue +1 -6
- package/src/components/FileDropOverlay.vue +18 -0
- package/src/components/NewsView.vue +2 -1
- package/src/components/RolesView.vue +14 -5
- package/src/components/SettingsMapTab.vue +140 -0
- package/src/components/SettingsMcpTab.vue +15 -10
- package/src/components/SettingsModal.vue +138 -112
- package/src/components/SettingsModelTab.vue +121 -0
- package/src/components/SettingsPhotosTab.vue +118 -0
- package/src/components/SourcesManager.vue +4 -3
- package/src/components/StackView.vue +43 -12
- package/src/composables/useContentDisplay.ts +16 -0
- package/src/composables/useFileDropZone.ts +148 -0
- package/src/composables/useImageErrorRepair.ts +29 -19
- package/src/composables/useSkillsList.ts +2 -1
- package/src/config/apiRoutes.ts +24 -0
- package/src/config/roles.ts +121 -70
- package/src/config/systemFileDescriptors.ts +2 -2
- package/src/config/toolNames.ts +26 -0
- package/src/index.css +26 -0
- package/src/lang/de.ts +70 -1
- package/src/lang/en.ts +69 -1
- package/src/lang/es.ts +69 -1
- package/src/lang/fr.ts +69 -1
- package/src/lang/ja.ts +69 -1
- package/src/lang/ko.ts +68 -1
- package/src/lang/pt-BR.ts +69 -1
- package/src/lang/zh.ts +67 -1
- package/src/lib/wiki-page/index-parse.ts +221 -0
- package/src/lib/wiki-page/link.ts +62 -0
- package/src/lib/wiki-page/lint.ts +105 -0
- package/src/lib/wiki-page/paths.ts +35 -0
- package/src/lib/wiki-page/slug.ts +28 -40
- package/src/main.ts +8 -0
- package/src/plugins/_extras.ts +6 -2
- package/src/plugins/_generated/metas.ts +4 -0
- package/src/plugins/_generated/registrations.ts +4 -0
- package/src/plugins/_generated/server-bindings.ts +6 -0
- package/src/plugins/accounting/Preview.vue +3 -6
- package/src/plugins/accounting/View.vue +2 -1
- package/src/plugins/accounting/components/AccountsModal.vue +3 -2
- package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
- package/src/plugins/accounting/components/JournalList.vue +2 -1
- package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
- package/src/plugins/accounting/currencies.ts +13 -0
- package/src/plugins/manageRoles/View.vue +16 -5
- package/src/plugins/manageSkills/View.vue +12 -4
- package/src/plugins/markdown/View.vue +6 -0
- package/src/plugins/photoLocations/View.vue +231 -0
- package/src/plugins/photoLocations/definition.ts +47 -0
- package/src/plugins/photoLocations/index.ts +38 -0
- package/src/plugins/photoLocations/meta.ts +35 -0
- package/src/plugins/presentMulmoScript/View.vue +76 -7
- package/src/plugins/presentMulmoScript/helpers.ts +15 -0
- package/src/plugins/presentSVG/Preview.vue +56 -0
- package/src/plugins/presentSVG/View.vue +465 -0
- package/src/plugins/presentSVG/definition.ts +29 -0
- package/src/plugins/presentSVG/index.ts +49 -0
- package/src/plugins/presentSVG/meta.ts +14 -0
- package/src/plugins/scheduler/View.vue +3 -7
- package/src/plugins/skill/View.vue +15 -16
- package/src/plugins/spreadsheet/View.vue +4 -0
- package/src/plugins/wiki/View.vue +1 -1
- package/src/plugins/wiki/helpers.ts +23 -5
- package/src/plugins/wiki/route.ts +12 -11
- package/src/tools/runtimeLoader.ts +75 -9
- package/src/utils/dom/iframeHeightClamp.ts +42 -0
- package/src/utils/format/bytes.ts +41 -0
- package/src/utils/format/date.ts +14 -2
- package/src/utils/image/imageRepairInlineScript.ts +192 -41
- package/src/utils/markdown/sanitize.ts +68 -0
- package/src/utils/markdown/setup.ts +36 -0
- package/src/utils/markdown/wikiEmbedHandlers.ts +170 -0
- package/src/utils/markdown/wikiEmbeds.ts +141 -0
- package/src/utils/markdown/workspaceLinkify.ts +73 -0
- package/src/utils/path/workspaceLinkRouter.ts +17 -1
- package/client/assets/index-ECD0lgIv.css +0 -2
- package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
- package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
- package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
- package/server/workspace/wiki-history/provision.ts +0 -181
- /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
- /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
- /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
- /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
|
@@ -15,7 +15,14 @@ import { previewSnippet } from "../../utils/logPreview.js";
|
|
|
15
15
|
// different name avoids the no-shadow clash without renaming the
|
|
16
16
|
// long-standing local.
|
|
17
17
|
import { errorMessage as formatError } from "../../utils/errors.js";
|
|
18
|
-
|
|
18
|
+
// All wiki-text helpers (slug rules, index parsing, lint rules)
|
|
19
|
+
// live in `src/lib/wiki-page/` as pure modules so the frontend can
|
|
20
|
+
// share them without pulling in server-only deps. This route is
|
|
21
|
+
// just the HTTP shell on top of those.
|
|
22
|
+
import { parseWikiLink } from "../../../src/lib/wiki-page/link.js";
|
|
23
|
+
import { wikiSlugify } from "../../../src/lib/wiki-page/slug.js";
|
|
24
|
+
import { type WikiPageEntry, parseIndexEntries } from "../../../src/lib/wiki-page/index-parse.js";
|
|
25
|
+
import { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift, formatLintReport } from "../../../src/lib/wiki-page/lint.js";
|
|
19
26
|
|
|
20
27
|
const router = Router();
|
|
21
28
|
|
|
@@ -27,222 +34,47 @@ function readFileOrEmpty(absPath: string): string {
|
|
|
27
34
|
return readTextSafeSync(absPath) ?? "";
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// isn't a-z / 0-9 / hyphen. Used for both index parsing and page
|
|
39
|
-
// lookup so the two stay consistent.
|
|
40
|
-
export function wikiSlugify(text: string): string {
|
|
41
|
-
return text
|
|
42
|
-
.toLowerCase()
|
|
43
|
-
.replace(/\s+/g, "-")
|
|
44
|
-
.replace(/[^a-z0-9-]/g, "");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const TABLE_SEPARATOR_PATTERN = /^\|[\s|:-]+\|$/;
|
|
48
|
-
// Bullet-link patterns (BULLET_LINK_PATTERN, BULLET_WIKI_LINK_PATTERN)
|
|
49
|
-
// live in `server/utils/regex.ts` alongside other server regex audit
|
|
50
|
-
// notes. Capture the href (group 2) alongside the title (group 1) so
|
|
51
|
-
// we can derive the slug from the file name instead of re-slugifying
|
|
52
|
-
// the title — important for non-ASCII titles like "さくらインターネット"
|
|
53
|
-
// where `wikiSlugify` returns "" and the slug would otherwise be lost.
|
|
54
|
-
// Unicode-aware tag body: any letter or number in any script
|
|
55
|
-
// (so Japanese / Chinese / Korean tags like `#クラウド` or `#可視化`
|
|
56
|
-
// work), plus `-` and `_` as internal joiners. First char is a
|
|
57
|
-
// letter or number only — no leading punctuation.
|
|
58
|
-
const HASHTAG_PATTERN = /(?:^|\s)#([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
|
|
59
|
-
|
|
60
|
-
// Extract `#tag` tokens from a bullet description, returning the
|
|
61
|
-
// stripped description and a sorted, deduped, lowercased tag list.
|
|
62
|
-
// Only matches at word boundaries so mid-word `#` (e.g. anchor URLs)
|
|
63
|
-
// is left alone.
|
|
64
|
-
export function extractHashTags(text: string): { description: string; tags: string[] } {
|
|
65
|
-
const tags: string[] = [];
|
|
66
|
-
HASHTAG_PATTERN.lastIndex = 0;
|
|
67
|
-
let match: RegExpExecArray | null;
|
|
68
|
-
while ((match = HASHTAG_PATTERN.exec(text)) !== null) {
|
|
69
|
-
tags.push(match[1].toLowerCase());
|
|
70
|
-
}
|
|
71
|
-
const description = text.replace(HASHTAG_PATTERN, "").replace(/\s+/g, " ").trim();
|
|
72
|
-
const deduped = [...new Set(tags)].sort();
|
|
73
|
-
return { description, tags: deduped };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Split a table Tags cell — tolerates comma, whitespace, or `#`
|
|
77
|
-
// prefixes. Empty cell yields an empty list.
|
|
78
|
-
export function parseTagsCell(cell: string): string[] {
|
|
79
|
-
const tokens = cell
|
|
80
|
-
.split(/[,\s]+/)
|
|
81
|
-
.map((token) => token.trim().replace(/^#/, "").toLowerCase())
|
|
82
|
-
.filter((token) => token.length > 0);
|
|
83
|
-
return [...new Set(tokens)].sort();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Map header cell names → column indices, case- and whitespace-
|
|
87
|
-
// tolerant. Used by `parseTableRow` to locate the Tags column (and
|
|
88
|
-
// any other named column) without assuming a fixed position, so
|
|
89
|
-
// older 3- and 4-column tables keep working.
|
|
90
|
-
export function buildTableColumnMap(headerRow: string): Map<string, number> {
|
|
91
|
-
const cells = headerRow
|
|
92
|
-
.split("|")
|
|
93
|
-
.slice(1, -1)
|
|
94
|
-
// Mirror `parseTableRow`'s cell-normalising: strip the surrounding
|
|
95
|
-
// backticks that commonly wrap cell values in wiki tables. Without
|
|
96
|
-
// this, a `| \`tags\` |` header maps to the key "`tags`" and the
|
|
97
|
-
// subsequent `columnMap.get("tags")` lookup silently misses the
|
|
98
|
-
// column, falling back to `tags: []`.
|
|
99
|
-
.map((cell) => cell.trim().replace(/^`|`$/g, "").toLowerCase());
|
|
100
|
-
const map = new Map<string, number>();
|
|
101
|
-
cells.forEach((cell, i) => {
|
|
102
|
-
if (cell) map.set(cell, i);
|
|
103
|
-
});
|
|
104
|
-
return map;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface TableColumnIndices {
|
|
108
|
-
slug: number;
|
|
109
|
-
title: number;
|
|
110
|
-
summary: number;
|
|
111
|
-
/** Undefined when the table has no `tags` column — caller skips
|
|
112
|
-
* the tags lookup entirely and the row gets `tags: []`. */
|
|
113
|
-
tags: number | undefined;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Resolve the per-column indices the row parser needs. Falls back
|
|
117
|
-
* to positional defaults (0/1/2) when the table has no header map.
|
|
118
|
-
* "summary" is the canonical column name; "description" is accepted
|
|
119
|
-
* as a legacy alias used by older fixtures. */
|
|
120
|
-
function resolveTableColumnIndices(columnMap: Map<string, number> | null): TableColumnIndices {
|
|
121
|
-
return {
|
|
122
|
-
slug: columnMap?.get("slug") ?? 0,
|
|
123
|
-
title: columnMap?.get("title") ?? 1,
|
|
124
|
-
summary: columnMap?.get("summary") ?? columnMap?.get("description") ?? 2,
|
|
125
|
-
tags: columnMap?.get("tags"),
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Each parser returns the entry it produced (if any). The parent
|
|
130
|
-
// loop tries them in order; the first non-null result wins.
|
|
131
|
-
function parseTableRow(trimmed: string, columnMap: Map<string, number> | null): WikiPageEntry | null {
|
|
132
|
-
const cols = trimmed
|
|
133
|
-
.split("|")
|
|
134
|
-
.slice(1, -1)
|
|
135
|
-
.map((column) => column.trim().replace(/^`|`$/g, ""));
|
|
136
|
-
if (cols.length < 2) return null;
|
|
137
|
-
|
|
138
|
-
const idx = resolveTableColumnIndices(columnMap);
|
|
139
|
-
const slug = cols[idx.slug] ?? "";
|
|
140
|
-
const title = cols[idx.title] || slug;
|
|
141
|
-
if (!slug || !title) return null;
|
|
142
|
-
|
|
143
|
-
const description = cols[idx.summary] ?? "";
|
|
144
|
-
const tags = idx.tags !== undefined ? parseTagsCell(cols[idx.tags] ?? "") : [];
|
|
145
|
-
return { title, slug, description, tags };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Extract the slug segment from a bullet link's href. Accepts the
|
|
149
|
-
// canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`
|
|
150
|
-
// — the three forms produced by different historical writers of
|
|
151
|
-
// index.md. Returns "" for hrefs that don't look like a wiki page
|
|
152
|
-
// reference (e.g. `https://example.com`) so the caller can fall
|
|
153
|
-
// back to title-based slugification.
|
|
154
|
-
export function extractSlugFromBulletHref(rawHref: string): string {
|
|
155
|
-
const href = rawHref.trim();
|
|
156
|
-
if (!href) return "";
|
|
157
|
-
if (/^[a-z]+:\/\//i.test(href)) return "";
|
|
158
|
-
const lastSegment = href.split("/").pop() ?? href;
|
|
159
|
-
return lastSegment.replace(/\.md$/i, "");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
163
|
-
const match = BULLET_LINK_PATTERN.exec(trimmed);
|
|
164
|
-
if (!match) return null;
|
|
165
|
-
const title = match[1].trim();
|
|
166
|
-
const href = match[2] ?? "";
|
|
167
|
-
const raw = match[3]?.trim() ?? "";
|
|
168
|
-
const { description, tags } = extractHashTags(raw);
|
|
169
|
-
// Prefer the slug embedded in the href so non-ASCII titles keep
|
|
170
|
-
// a navigable slug. Fall back to slugifying the title only when
|
|
171
|
-
// the href has no recognisable slug (rare — usually means the
|
|
172
|
-
// author put an external URL here).
|
|
173
|
-
const slug = extractSlugFromBulletHref(href) || wikiSlugify(title);
|
|
174
|
-
return { title, slug, description, tags };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {
|
|
178
|
-
const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);
|
|
179
|
-
if (!match) return null;
|
|
180
|
-
const title = match[1].trim();
|
|
181
|
-
const raw = match[2]?.trim() ?? "";
|
|
182
|
-
const { description, tags } = extractHashTags(raw);
|
|
183
|
-
return { title, slug: wikiSlugify(title), description, tags };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Parse entries from index.md — supports three formats:
|
|
187
|
-
// 1. Table: | `slug` | Title | Summary | Date |
|
|
188
|
-
// 2. Bullet link: - [Title](pages/slug.md) — description
|
|
189
|
-
// 3. Wiki link: - [[Title]] — description
|
|
190
|
-
export function parseIndexEntries(content: string): WikiPageEntry[] {
|
|
191
|
-
const entries: WikiPageEntry[] = [];
|
|
192
|
-
let inTable = false;
|
|
193
|
-
let columnMap: Map<string, number> | null = null;
|
|
194
|
-
|
|
195
|
-
for (const line of content.split("\n")) {
|
|
196
|
-
const trimmed = line.trim();
|
|
197
|
-
|
|
198
|
-
if (trimmed.startsWith("|")) {
|
|
199
|
-
if (TABLE_SEPARATOR_PATTERN.test(trimmed)) {
|
|
200
|
-
inTable = true;
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (!inTable) {
|
|
204
|
-
// First `|`-line before the separator is the header. Capture
|
|
205
|
-
// the column map so parseTableRow can locate the Tags
|
|
206
|
-
// column (if any) by name rather than position.
|
|
207
|
-
columnMap = buildTableColumnMap(trimmed);
|
|
208
|
-
inTable = true;
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const entry = parseTableRow(trimmed, columnMap);
|
|
212
|
-
if (entry) entries.push(entry);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
inTable = false;
|
|
217
|
-
columnMap = null;
|
|
218
|
-
|
|
219
|
-
const bullet = parseBulletLinkRow(trimmed) ?? parseBulletWikiLinkRow(trimmed);
|
|
220
|
-
if (bullet) entries.push(bullet);
|
|
221
|
-
}
|
|
222
|
-
return entries;
|
|
223
|
-
}
|
|
37
|
+
// Wiki-text helpers (slugify, index parsing, lint rules) now live
|
|
38
|
+
// under `src/lib/wiki-page/` as pure modules — see imports above.
|
|
39
|
+
// Re-exports kept here for legacy callers that import via this
|
|
40
|
+
// route module (e.g. tests in `test/routes/test_wikiHelpers.ts`).
|
|
41
|
+
export { wikiSlugify } from "../../../src/lib/wiki-page/slug.js";
|
|
42
|
+
export type { WikiPageEntry } from "../../../src/lib/wiki-page/index-parse.js";
|
|
43
|
+
export { buildTableColumnMap, extractHashTags, extractSlugFromBulletHref, parseIndexEntries, parseTagsCell } from "../../../src/lib/wiki-page/index-parse.js";
|
|
44
|
+
export { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift, formatLintReport } from "../../../src/lib/wiki-page/lint.js";
|
|
224
45
|
|
|
225
46
|
// Resolve a page name to an absolute `.md` path using the in-memory
|
|
226
47
|
// page index (see ./wiki/pageIndex.ts). Index is kept fresh via
|
|
227
48
|
// pagesDir mtime, so zero readdir cost on cache hit.
|
|
49
|
+
//
|
|
50
|
+
// `pageName` may carry the `[[target|display]]` form on legacy
|
|
51
|
+
// codepaths (e.g. an old chat-history entry that pre-dates the
|
|
52
|
+
// renderer fix). `parseWikiLink` strips the display half so the
|
|
53
|
+
// lookup uses just the target — same parser the renderer + lint
|
|
54
|
+
// agree on, which is the whole point of the #1297 refactor.
|
|
55
|
+
// Below this length the fuzzy `includes` step skips altogether.
|
|
56
|
+
// CJK / emoji-only / very-short page names get slugified down to a
|
|
57
|
+
// short noise tail (e.g. "日本語タイトル-chromium-{nonce}" →
|
|
58
|
+
// "-chromium-{nonce}"), and that noise will partial-match almost
|
|
59
|
+
// any page that happens to share the suffix. Skipping fuzzy for
|
|
60
|
+
// these slugs avoids silent miss-resolves; the index.md title-match
|
|
61
|
+
// fallback below still handles the legitimate non-ASCII case (#1194).
|
|
62
|
+
const MIN_FUZZY_SLUG_LEN = 6;
|
|
63
|
+
|
|
228
64
|
async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
229
65
|
const dir = pagesDir();
|
|
230
66
|
const { slugs } = await getPageIndex(dir);
|
|
231
67
|
if (slugs.size === 0) return null;
|
|
232
68
|
|
|
233
|
-
const
|
|
69
|
+
const { target } = parseWikiLink(pageName);
|
|
70
|
+
const slug = wikiSlugify(target);
|
|
234
71
|
|
|
235
72
|
if (slug.length > 0) {
|
|
236
73
|
const exact = slugs.get(slug);
|
|
237
74
|
if (exact) return path.join(dir, exact);
|
|
238
75
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
for (const [key, file] of slugs) {
|
|
242
|
-
if (slug.includes(key) || key.includes(slug)) {
|
|
243
|
-
return path.join(dir, file);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
76
|
+
const fuzzy = pickFuzzyMatch(slug, slugs);
|
|
77
|
+
if (fuzzy) return path.join(dir, fuzzy);
|
|
246
78
|
}
|
|
247
79
|
|
|
248
80
|
// Non-ASCII page names (e.g. Japanese [[wiki links]]) produce empty
|
|
@@ -250,7 +82,7 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
|
250
82
|
// wiki index so the link resolves to its page file.
|
|
251
83
|
const indexContent = readFileOrEmpty(indexFile());
|
|
252
84
|
const entries = parseIndexEntries(indexContent);
|
|
253
|
-
const titleMatch = entries.find((entry) => entry.title ===
|
|
85
|
+
const titleMatch = entries.find((entry) => entry.title === target);
|
|
254
86
|
if (titleMatch) {
|
|
255
87
|
const file = slugs.get(titleMatch.slug);
|
|
256
88
|
if (file) return path.join(dir, file);
|
|
@@ -259,6 +91,39 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
|
259
91
|
return null;
|
|
260
92
|
}
|
|
261
93
|
|
|
94
|
+
// Walk every indexed slug looking for an `includes`-style match.
|
|
95
|
+
// Returns the single best candidate, or null when the slug is too
|
|
96
|
+
// short to be meaningful OR multiple candidates tie at the top score
|
|
97
|
+
// (ambiguous — leave the resolution to the caller's title-match
|
|
98
|
+
// fallback rather than silently picking iteration-order-first).
|
|
99
|
+
//
|
|
100
|
+
// Score = min(slug.length, key.length) / max(slug.length, key.length).
|
|
101
|
+
// A perfect match where both strings are identical scores 1.0 (but
|
|
102
|
+
// that path is taken by the exact `slugs.get` above, never reaches
|
|
103
|
+
// here). Otherwise the highest-scoring partial match wins, and the
|
|
104
|
+
// score is decoupled from Map iteration order (= filesystem readdir
|
|
105
|
+
// order) so the resolver is deterministic across hosts.
|
|
106
|
+
export function pickFuzzyMatch(slug: string, slugs: ReadonlyMap<string, string>): string | null {
|
|
107
|
+
if (slug.length < MIN_FUZZY_SLUG_LEN) return null;
|
|
108
|
+
let bestFile: string | null = null;
|
|
109
|
+
let bestScore = 0;
|
|
110
|
+
let bestIsTied = false;
|
|
111
|
+
for (const [key, file] of slugs) {
|
|
112
|
+
if (!slug.includes(key) && !key.includes(slug)) continue;
|
|
113
|
+
const shorter = Math.min(slug.length, key.length);
|
|
114
|
+
const longer = Math.max(slug.length, key.length);
|
|
115
|
+
const score = shorter / longer;
|
|
116
|
+
if (score > bestScore) {
|
|
117
|
+
bestScore = score;
|
|
118
|
+
bestFile = file;
|
|
119
|
+
bestIsTied = false;
|
|
120
|
+
} else if (score === bestScore) {
|
|
121
|
+
bestIsTied = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return bestIsTied ? null : bestFile;
|
|
125
|
+
}
|
|
126
|
+
|
|
262
127
|
router.get(API_ROUTES.wiki.base, async (req: Request, res: Response<WikiResponse | ErrorResponse>) => {
|
|
263
128
|
const slug = getOptionalStringQuery(req, "slug");
|
|
264
129
|
if (slug) {
|
|
@@ -410,78 +275,10 @@ function buildLogResponse(action: string): WikiResponse {
|
|
|
410
275
|
};
|
|
411
276
|
}
|
|
412
277
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// touching the filesystem.
|
|
418
|
-
|
|
419
|
-
export function findOrphanPages(fileSlugs: ReadonlySet<string>, indexedSlugs: ReadonlySet<string>): string[] {
|
|
420
|
-
const issues: string[] = [];
|
|
421
|
-
for (const slug of fileSlugs) {
|
|
422
|
-
if (!indexedSlugs.has(slug)) {
|
|
423
|
-
issues.push(`- **Orphan page**: \`${slug}.md\` exists but is missing from index.md`);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return issues;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlugs: ReadonlySet<string>): string[] {
|
|
430
|
-
const issues: string[] = [];
|
|
431
|
-
for (const entry of pageEntries) {
|
|
432
|
-
if (!fileSlugs.has(entry.slug)) {
|
|
433
|
-
issues.push(`- **Missing file**: index.md references \`${entry.slug}\` but the file does not exist`);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return issues;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {
|
|
440
|
-
const issues: string[] = [];
|
|
441
|
-
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((match) => match[1]);
|
|
442
|
-
for (const link of wikiLinks) {
|
|
443
|
-
const linkSlug = wikiSlugify(link);
|
|
444
|
-
if (!fileSlugs.has(linkSlug)) {
|
|
445
|
-
issues.push(`- **Broken link** in \`${fileName}\`: [[${link}]] → \`${linkSlug}.md\` not found`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return issues;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function formatTagList(tags: readonly string[]): string {
|
|
452
|
-
return `[${[...tags].sort().join(", ")}]`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Flag any slug whose index.md tags differ from the page's own
|
|
456
|
-
// frontmatter `tags:` field. Comparison is set-based and order-
|
|
457
|
-
// insensitive; both sides are lowercased at parse time. Slugs
|
|
458
|
-
// missing from `frontmatterTagsBySlug` are ignored here — the
|
|
459
|
-
// missing file itself is already reported by `findMissingFiles`.
|
|
460
|
-
export function findTagDrift(pageEntries: readonly WikiPageEntry[], frontmatterTagsBySlug: ReadonlyMap<string, readonly string[]>): string[] {
|
|
461
|
-
const issues: string[] = [];
|
|
462
|
-
for (const entry of pageEntries) {
|
|
463
|
-
// Lowercase on lookup — `collectLintIssues` keys the map with
|
|
464
|
-
// lowercased slugs, so a `MyPage.md` filename still matches an
|
|
465
|
-
// `entry.slug` of `mypage` produced by `wikiSlugify` on the
|
|
466
|
-
// wiki-link parser path.
|
|
467
|
-
const pageTags = frontmatterTagsBySlug.get(entry.slug.toLowerCase());
|
|
468
|
-
if (pageTags === undefined) continue;
|
|
469
|
-
const pageSet = new Set(pageTags);
|
|
470
|
-
const indexSet = new Set(entry.tags);
|
|
471
|
-
if (pageSet.size !== indexSet.size || [...pageSet].some((tag) => !indexSet.has(tag))) {
|
|
472
|
-
issues.push(`- **Tag drift**: \`${entry.slug}.md\` frontmatter has ${formatTagList(pageTags)} but index.md has ${formatTagList(entry.tags)}`);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return issues;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
export function formatLintReport(issues: readonly string[]): string {
|
|
479
|
-
if (issues.length === 0) {
|
|
480
|
-
return "# Wiki Lint Report\n\n✓ No issues found. Wiki is healthy.";
|
|
481
|
-
}
|
|
482
|
-
const noun = `issue${issues.length !== 1 ? "s" : ""}`;
|
|
483
|
-
return `# Wiki Lint Report\n\n${issues.length} ${noun} found:\n\n${issues.join("\n")}`;
|
|
484
|
-
}
|
|
278
|
+
// Pure lint helpers (findOrphanPages, findMissingFiles,
|
|
279
|
+
// findBrokenLinksInPage, findTagDrift, formatLintReport) now live
|
|
280
|
+
// in `src/lib/wiki-page/lint.ts` and are re-exported above. The
|
|
281
|
+
// route below is the thin filesystem shell around them.
|
|
485
282
|
|
|
486
283
|
async function collectLintIssues(): Promise<string[]> {
|
|
487
284
|
const dir = pagesDir();
|
|
@@ -17,6 +17,7 @@ import { fileChannel, toPosixWorkspacePath, type FileChannelPayload } from "../.
|
|
|
17
17
|
import { workspacePath } from "../workspace/workspace.js";
|
|
18
18
|
import { maybeRegenerateTopicIndex, TOPIC_INDEX_RELATIVE_PATH } from "../workspace/memory/topic-index-hook.js";
|
|
19
19
|
import { log } from "../system/logger/index.js";
|
|
20
|
+
import { errorMessage } from "../utils/errors.js";
|
|
20
21
|
|
|
21
22
|
let pubsub: IPubSub | null = null;
|
|
22
23
|
|
|
@@ -42,7 +43,7 @@ export async function publishFileChange(relativePath: string): Promise<void> {
|
|
|
42
43
|
} catch (err) {
|
|
43
44
|
log.warn("file-change", "stat failed; falling back to Date.now()", {
|
|
44
45
|
pathPreview: relativePath,
|
|
45
|
-
error:
|
|
46
|
+
error: errorMessage(err),
|
|
46
47
|
});
|
|
47
48
|
mtimeMs = Date.now();
|
|
48
49
|
}
|
|
@@ -59,7 +60,7 @@ export async function publishFileChange(relativePath: string): Promise<void> {
|
|
|
59
60
|
} catch (err) {
|
|
60
61
|
log.warn("file-change", "publish failed; subscribers will miss this event", {
|
|
61
62
|
pathPreview: posixPath,
|
|
62
|
-
error:
|
|
63
|
+
error: errorMessage(err),
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
// Side-effect hook: keep the topic-format MEMORY.md index in sync
|
|
@@ -12,6 +12,7 @@ import { env } from "../../system/env.js";
|
|
|
12
12
|
import { updateHasUnread } from "../../utils/files/session-io.js";
|
|
13
13
|
import { EVENT_TYPES, GENERATION_KINDS, type GenerationKind, type PendingGeneration, generationKey } from "../../../src/types/events.js";
|
|
14
14
|
import { ONE_HOUR_MS, ONE_MINUTE_MS } from "../../utils/time.js";
|
|
15
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
15
16
|
|
|
16
17
|
// ── Types ──────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -394,7 +395,7 @@ function applyEventToSession(session: ServerSession, type: string, event: Record
|
|
|
394
395
|
enqueueJsonlAppend(session, buildToolCallLine(event)).catch((err: unknown) => {
|
|
395
396
|
log.warn("session-store", "persist tool_call failed (non-fatal)", {
|
|
396
397
|
chatSessionId: session.chatSessionId,
|
|
397
|
-
error:
|
|
398
|
+
error: errorMessage(err),
|
|
398
399
|
});
|
|
399
400
|
});
|
|
400
401
|
}
|
package/server/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import agentRoutes, { startChat } from "./api/routes/agent.js";
|
|
6
6
|
import accountingRoutes from "./api/routes/accounting.js";
|
|
7
|
+
import photoLocationsRoutes from "./api/routes/photo-locations.js";
|
|
7
8
|
import schedulerRoutes from "./api/routes/scheduler.js";
|
|
8
9
|
import sessionsRoutes, { loadAllSessions } from "./api/routes/sessions.js";
|
|
9
10
|
import chatIndexRoutes from "./api/routes/chat-index.js";
|
|
@@ -13,16 +14,19 @@ import pluginsRoutes from "./api/routes/plugins.js";
|
|
|
13
14
|
import imageRoutes from "./api/routes/image.js";
|
|
14
15
|
import attachmentRoutes from "./api/routes/attachment.js";
|
|
15
16
|
import presentHtmlRoutes from "./api/routes/presentHtml.js";
|
|
17
|
+
import presentSvgRoutes from "./api/routes/presentSvg.js";
|
|
16
18
|
import chartRoutes from "./api/routes/chart.js";
|
|
17
19
|
import rolesRoutes from "./api/routes/roles.js";
|
|
18
20
|
import { DEFAULT_ROLE_ID } from "../src/config/roles.js";
|
|
19
21
|
import mulmoScriptRoutes from "./api/routes/mulmo-script.js";
|
|
20
22
|
import wikiRoutes from "./api/routes/wiki.js";
|
|
21
23
|
import wikiHistoryRoutes from "./api/routes/wiki/history.js";
|
|
22
|
-
import {
|
|
24
|
+
import { provisionDispatcherHook } from "./workspace/hooks/provision.js";
|
|
23
25
|
import pdfRoutes from "./api/routes/pdf.js";
|
|
24
26
|
import filesRoutes from "./api/routes/files.js";
|
|
25
27
|
import configRoutes from "./api/routes/config.js";
|
|
28
|
+
import configRefreshRoutes from "./api/routes/config-refresh.js";
|
|
29
|
+
import hookLogRoutes from "./api/routes/hookLog.js";
|
|
26
30
|
import skillsRoutes from "./api/routes/skills.js";
|
|
27
31
|
import runtimePluginRoutes from "./api/routes/runtime-plugin.js";
|
|
28
32
|
import { loadRuntimePlugins } from "./plugins/runtime-loader.js";
|
|
@@ -36,6 +40,8 @@ import { createNotificationsRouter } from "./api/routes/notifications.js";
|
|
|
36
40
|
import { startLegacyAdapters } from "./notifier/legacy-adapters.js";
|
|
37
41
|
import notifierRoutes from "./api/routes/notifier.js";
|
|
38
42
|
import { initNotifier } from "./notifier/engine.js";
|
|
43
|
+
import { registerSaveAttachmentHook } from "./utils/files/attachment-store.js";
|
|
44
|
+
import { capturePhotoLocation } from "./workspace/photo-locations/index.js";
|
|
39
45
|
import { createJournalRouter } from "./api/routes/journal.js";
|
|
40
46
|
import { createTranslationRouter } from "./api/routes/translation.js";
|
|
41
47
|
import { announcePluginMetaDiagnostics } from "./plugins/diagnostics.js";
|
|
@@ -53,6 +59,7 @@ import { mcpToolsRouter, mcpTools, isMcpToolEnabled } from "./agent/mcp-tools/in
|
|
|
53
59
|
import { initWorkspace, workspacePath } from "./workspace/workspace.js";
|
|
54
60
|
import { runMemoryMigrationOnce } from "./workspace/memory/run.js";
|
|
55
61
|
import { runTopicMigrationOnce } from "./workspace/memory/topic-run.js";
|
|
62
|
+
import { migrateCookingRecipesFromPlugin } from "./workspace/cooking-recipes/migrate.js";
|
|
56
63
|
import { env, isGeminiAvailable } from "./system/env.js";
|
|
57
64
|
import { buildSandboxStatus } from "./api/sandboxStatus.js";
|
|
58
65
|
import { existsSync, readFileSync } from "fs";
|
|
@@ -76,6 +83,7 @@ import { bearerAuth } from "./api/auth/bearerAuth.js";
|
|
|
76
83
|
import { deleteTokenFile, generateAndWriteToken, getCurrentToken } from "./api/auth/token.js";
|
|
77
84
|
import { log } from "./system/logger/index.js";
|
|
78
85
|
import { logBackgroundError } from "./utils/logBackgroundError.js";
|
|
86
|
+
import { errorMessage } from "./utils/errors.js";
|
|
79
87
|
import { registerScheduledSkills } from "./workspace/skills/scheduler.js";
|
|
80
88
|
import { registerUserTasks } from "./workspace/skills/user-tasks.js";
|
|
81
89
|
import { API_ROUTES } from "../src/config/apiRoutes.js";
|
|
@@ -127,8 +135,28 @@ runMemoryMigrationOnce(workspacePath)
|
|
|
127
135
|
.then(() => runTopicMigrationOnce(workspacePath))
|
|
128
136
|
.then(noop, noop);
|
|
129
137
|
|
|
138
|
+
// Recipe-book plugin → `mc-cooking-coach` skill migration (#1286).
|
|
139
|
+
// Boot-time idempotent copy from the plugin's `files.data` scope
|
|
140
|
+
// (`data/plugins/<sanitised-pkg>/recipes/`) to the canonical
|
|
141
|
+
// `data/cooking/recipes/` path the skill drives. Sentinel-gated so
|
|
142
|
+
// every boot after the first is a no-op.
|
|
143
|
+
migrateCookingRecipesFromPlugin().catch((err) => {
|
|
144
|
+
log.warn("cooking-recipes", "migration from plugin failed; falling back to original plugin path", {
|
|
145
|
+
error: errorMessage(err),
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
130
149
|
let sandboxEnabled = false;
|
|
131
150
|
|
|
151
|
+
// --- Photo-EXIF capture hook (#1222 PR-A) ---
|
|
152
|
+
// Registered at module load (NOT inside `startRuntimeServices`)
|
|
153
|
+
// because uploads can land in the gap between `app.listen` accepting
|
|
154
|
+
// connections and the runtime-services bootstrap finishing. The hook
|
|
155
|
+
// itself short-circuits on non-image MIME / auto-capture opt-out, so
|
|
156
|
+
// registering early is free for non-photo flows. (CodeRabbit review
|
|
157
|
+
// on PR #1247.)
|
|
158
|
+
registerSaveAttachmentHook(capturePhotoLocation);
|
|
159
|
+
|
|
132
160
|
const app = express();
|
|
133
161
|
|
|
134
162
|
app.disable("x-powered-by");
|
|
@@ -182,7 +210,16 @@ app.use("/api", (req, res, next) => {
|
|
|
182
210
|
next();
|
|
183
211
|
return;
|
|
184
212
|
}
|
|
185
|
-
if (req.method === "GET" && RUNTIME_PLUGIN_ASSET_PATH_RE.test(req.path)) {
|
|
213
|
+
if ((req.method === "GET" || req.method === "HEAD") && RUNTIME_PLUGIN_ASSET_PATH_RE.test(req.path)) {
|
|
214
|
+
// HEAD is bypassed for the same reason as GET: the frontend
|
|
215
|
+
// runtime-plugin loader HEAD-probes `dist/vue.js` to distinguish
|
|
216
|
+
// "no Vue bundle (404, server-only plugin)" from real load
|
|
217
|
+
// failures before `import()`-ing the asset (#1273 follow-up).
|
|
218
|
+
// That probe is a raw `fetch`, not the bearer-attaching `apiGet`,
|
|
219
|
+
// and the actual `import()` itself can't attach Authorization
|
|
220
|
+
// either — so the auth-bypass must cover both verbs or every
|
|
221
|
+
// runtime plugin's Vue View silently downgrades to a
|
|
222
|
+
// definition-only entry (401 → "unexpected status" → no view).
|
|
186
223
|
next();
|
|
187
224
|
return;
|
|
188
225
|
}
|
|
@@ -399,6 +436,81 @@ app.use(
|
|
|
399
436
|
express.static(WORKSPACE_PATHS.htmls, { dotfiles: "deny", fallthrough: false }),
|
|
400
437
|
);
|
|
401
438
|
|
|
439
|
+
// Static mount for SVG artifacts. SVG files are loaded into the View
|
|
440
|
+
// and Preview as `<img src="/artifacts/svg/<name>.svg">`. Browsers
|
|
441
|
+
// refuse to execute `<script>` inside an SVG loaded via `<img>`, so
|
|
442
|
+
// the `<img>` tag itself is the sandbox for that consumer path.
|
|
443
|
+
//
|
|
444
|
+
// BUT `/artifacts/svg/<file>.svg` is also a directly addressable URL on
|
|
445
|
+
// the SPA's origin (loopback-only, bearer-auth bypassed for `<img src>`
|
|
446
|
+
// access), so a user who navigates straight to that URL — or is tricked
|
|
447
|
+
// into clicking a markdown link — would otherwise get the SVG rendered
|
|
448
|
+
// as a TOP-LEVEL document with full script execution in the app's
|
|
449
|
+
// origin (localStorage, /api/* with the user's session, etc.). Since
|
|
450
|
+
// the SVG body is LLM-generated and writable via the update route, a
|
|
451
|
+
// prompt-injected SVG becomes a stored-XSS vector.
|
|
452
|
+
//
|
|
453
|
+
// Mitigation: send a strict response CSP. The `sandbox` directive gives
|
|
454
|
+
// the response an opaque origin and disables script execution for the
|
|
455
|
+
// top-level navigation case; the other directives starve subresource
|
|
456
|
+
// loads (block external script/font/connect, only allow `<image>` refs
|
|
457
|
+
// to self / data URIs). CSP on a subresource response is mostly
|
|
458
|
+
// informational — `<img>` rendering ignores the bytes' CSP — so this
|
|
459
|
+
// header doesn't interfere with the normal View / Preview path.
|
|
460
|
+
const SVG_RESPONSE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox";
|
|
461
|
+
|
|
462
|
+
// Strict three-layer guard mirroring the `/artifacts/images` mount:
|
|
463
|
+
// extension allowlist, realpath traversal check, dotfiles deny +
|
|
464
|
+
// fallthrough false on `express.static`. Bearer auth bypassed for the
|
|
465
|
+
// same reason as `/artifacts/images` / `/artifacts/html`: an
|
|
466
|
+
// `<img src>` request can't carry an Authorization header. Loopback-
|
|
467
|
+
// only listener + `requireSameOrigin` remain the trust boundary.
|
|
468
|
+
const SVG_EXT_RE = /\.svg$/i;
|
|
469
|
+
let svgsDirReal: string | null = null;
|
|
470
|
+
async function getSvgsDirReal(): Promise<string | null> {
|
|
471
|
+
if (svgsDirReal) return svgsDirReal;
|
|
472
|
+
try {
|
|
473
|
+
svgsDirReal = await fsRealpath(WORKSPACE_PATHS.svgs);
|
|
474
|
+
return svgsDirReal;
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
app.use(
|
|
480
|
+
"/artifacts/svg",
|
|
481
|
+
async (req, res, next) => {
|
|
482
|
+
if (!SVG_EXT_RE.test(req.path)) {
|
|
483
|
+
res.status(404).end();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const root = await getSvgsDirReal();
|
|
487
|
+
if (!root) {
|
|
488
|
+
res.status(404).end();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
let relPath: string;
|
|
492
|
+
try {
|
|
493
|
+
relPath = decodeURIComponent(req.path.replace(/^\//, ""));
|
|
494
|
+
} catch {
|
|
495
|
+
res.status(404).end();
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (!resolveWithinRoot(root, relPath)) {
|
|
499
|
+
res.status(404).end();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (containsDotfileSegment(relPath)) {
|
|
503
|
+
res.status(404).end();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
507
|
+
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
|
|
508
|
+
res.setHeader("Content-Security-Policy", SVG_RESPONSE_CSP);
|
|
509
|
+
next();
|
|
510
|
+
},
|
|
511
|
+
express.static(WORKSPACE_PATHS.svgs, { dotfiles: "deny", fallthrough: false }),
|
|
512
|
+
);
|
|
513
|
+
|
|
402
514
|
app.get(API_ROUTES.health, (_req: Request, res: Response) => {
|
|
403
515
|
// `os.loadavg()[0]` is the kernel 1-minute load average. On Linux /
|
|
404
516
|
// macOS it's the primary "is this machine busy" signal; on Windows
|
|
@@ -437,6 +549,7 @@ app.get(API_ROUTES.sandbox, (_req: Request, res: Response) => {
|
|
|
437
549
|
// the `/api` literal into each `router.post(API_ROUTES.…)` call.
|
|
438
550
|
app.use(agentRoutes);
|
|
439
551
|
app.use(accountingRoutes);
|
|
552
|
+
app.use(photoLocationsRoutes);
|
|
440
553
|
// todosRoutes removed (#1145) — todo is now a runtime plugin
|
|
441
554
|
// (`@mulmoclaude/todo-plugin`); the dispatch route is generated by
|
|
442
555
|
// `runtime-plugin.ts` at `/api/plugins/runtime/<pkg>/dispatch`.
|
|
@@ -449,6 +562,7 @@ app.use(pluginsRoutes);
|
|
|
449
562
|
app.use(imageRoutes);
|
|
450
563
|
app.use(attachmentRoutes);
|
|
451
564
|
app.use(presentHtmlRoutes);
|
|
565
|
+
app.use(presentSvgRoutes);
|
|
452
566
|
app.use(chartRoutes);
|
|
453
567
|
app.use(rolesRoutes);
|
|
454
568
|
app.use(mulmoScriptRoutes);
|
|
@@ -460,6 +574,8 @@ app.use("/api/wiki", wikiHistoryRoutes);
|
|
|
460
574
|
app.use(pdfRoutes);
|
|
461
575
|
app.use(filesRoutes);
|
|
462
576
|
app.use(configRoutes);
|
|
577
|
+
app.use(configRefreshRoutes);
|
|
578
|
+
app.use(hookLogRoutes);
|
|
463
579
|
app.use(skillsRoutes);
|
|
464
580
|
app.use(runtimePluginRoutes);
|
|
465
581
|
async function listSessionsForBridge(opts: { limit: number; offset: number }) {
|
|
@@ -965,12 +1081,18 @@ process.on("SIGTERM", () => {
|
|
|
965
1081
|
sandboxEnabled = await setupSandbox();
|
|
966
1082
|
logMcpStatus();
|
|
967
1083
|
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
|
|
1084
|
+
// Unified PostToolUse dispatcher (#763 PR 2, #1283, #1295). One
|
|
1085
|
+
// entry in `<workspace>/.claude/settings.json` that fans out to:
|
|
1086
|
+
// - wiki-snapshot (page Writes → snapshot pipeline)
|
|
1087
|
+
// - config-refresh (SKILL.md / scheduler tasks.json / data/skills/*.md → POST /api/config/refresh)
|
|
1088
|
+
// - skill-bridge (data/skills/*.md ↔ .claude/skills/<slug>/SKILL.md)
|
|
1089
|
+
//
|
|
1090
|
+
// Done BEFORE the agent ever spawns a claude CLI subprocess so the
|
|
1091
|
+
// hook is in place from the first turn. The provisioner also strips
|
|
1092
|
+
// pre-unification entries (wikiHistory / configRefresh owner markers)
|
|
1093
|
+
// so existing workspaces upgrade cleanly without double-firing.
|
|
1094
|
+
await provisionDispatcherHook().catch((err) => {
|
|
1095
|
+
log.warn("hooks", "dispatcher provisioning failed; PostToolUse side-effects (snapshots, refresh, skill bridge) will not run this session", {
|
|
974
1096
|
error: String(err),
|
|
975
1097
|
});
|
|
976
1098
|
});
|