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.
Files changed (172) hide show
  1. package/bin/mulmoclaude.js +1 -1
  2. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +3 -0
  3. package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
  4. package/client/assets/{index-BbgSjFQ8.js → index-BwrlMMHr.js} +178 -141
  5. package/client/assets/index-CvvNuegU.css +2 -0
  6. package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
  7. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  8. package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
  9. package/client/index.html +7 -6
  10. package/package.json +9 -7
  11. package/server/accounting/eventPublisher.ts +2 -1
  12. package/server/accounting/snapshotCache.ts +2 -1
  13. package/server/agent/activeTools.ts +16 -6
  14. package/server/agent/backend/claude-code.ts +1 -0
  15. package/server/agent/backend/types.ts +3 -0
  16. package/server/agent/config.ts +25 -2
  17. package/server/agent/index.ts +6 -0
  18. package/server/agent/mcp-server.ts +9 -6
  19. package/server/agent/mcp-tools/index.ts +15 -2
  20. package/server/agent/mcp-tools/notify.ts +20 -2
  21. package/server/agent/prompt.ts +37 -24
  22. package/server/api/routes/accounting.ts +31 -24
  23. package/server/api/routes/agent.ts +2 -2
  24. package/server/api/routes/config-refresh.ts +49 -0
  25. package/server/api/routes/config.ts +86 -68
  26. package/server/api/routes/files.ts +41 -17
  27. package/server/api/routes/hookLog.ts +95 -0
  28. package/server/api/routes/news.ts +39 -52
  29. package/server/api/routes/notifier.ts +14 -19
  30. package/server/api/routes/pdf.ts +2 -2
  31. package/server/api/routes/photo-locations.ts +79 -0
  32. package/server/api/routes/plugins.ts +11 -0
  33. package/server/api/routes/presentSvg.ts +107 -0
  34. package/server/api/routes/scheduler.ts +100 -98
  35. package/server/api/routes/schedulerTasks.ts +98 -95
  36. package/server/api/routes/sessions.ts +22 -27
  37. package/server/api/routes/sources.ts +45 -43
  38. package/server/api/routes/wiki/history.ts +6 -15
  39. package/server/api/routes/wiki.ts +73 -276
  40. package/server/events/file-change.ts +3 -2
  41. package/server/events/session-store/index.ts +2 -1
  42. package/server/index.ts +130 -8
  43. package/server/notifier/store.ts +3 -3
  44. package/server/plugins/preset-list.ts +16 -5
  45. package/server/plugins/runtime.ts +2 -2
  46. package/server/system/config.ts +138 -16
  47. package/server/utils/asyncHandler.ts +75 -0
  48. package/server/utils/exif.ts +321 -0
  49. package/server/utils/files/accounting-io.ts +19 -20
  50. package/server/utils/files/attachment-store.ts +69 -12
  51. package/server/utils/files/journal-io.ts +2 -1
  52. package/server/utils/files/json.ts +8 -1
  53. package/server/utils/files/reference-dirs-io.ts +2 -3
  54. package/server/utils/files/scheduler-overrides-io.ts +2 -3
  55. package/server/utils/files/svg-store.ts +27 -0
  56. package/server/utils/files/user-tasks-io.ts +2 -3
  57. package/server/utils/regex.ts +3 -12
  58. package/server/utils/text.ts +29 -0
  59. package/server/workspace/chat-index/summarizer.ts +5 -3
  60. package/server/workspace/cooking-recipes/migrate.ts +125 -0
  61. package/server/workspace/custom-dirs.ts +2 -2
  62. package/server/workspace/hooks/dispatcher.mjs +300 -0
  63. package/server/workspace/hooks/dispatcher.ts +55 -0
  64. package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
  65. package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
  66. package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
  67. package/server/workspace/hooks/provision.ts +222 -0
  68. package/server/workspace/hooks/shared/sidecar.ts +124 -0
  69. package/server/workspace/hooks/shared/stdin.ts +60 -0
  70. package/server/workspace/hooks/shared/workspace.ts +13 -0
  71. package/server/workspace/journal/dailyPass.ts +1 -6
  72. package/server/workspace/memory/io.ts +1 -34
  73. package/server/workspace/memory/migrate.ts +2 -1
  74. package/server/workspace/memory/snapshot.ts +26 -0
  75. package/server/workspace/memory/topic-io.ts +1 -18
  76. package/server/workspace/paths.ts +16 -0
  77. package/server/workspace/photo-locations/index.ts +149 -0
  78. package/server/workspace/photo-locations/list.ts +124 -0
  79. package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  80. package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
  81. package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
  82. package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
  83. package/server/workspace/skills-preset.ts +2 -1
  84. package/server/workspace/wiki-pages/io.ts +2 -1
  85. package/src/App.vue +78 -3
  86. package/src/components/ChatInput.vue +7 -8
  87. package/src/components/FileContentHeader.vue +1 -6
  88. package/src/components/FileDropOverlay.vue +18 -0
  89. package/src/components/NewsView.vue +2 -1
  90. package/src/components/RolesView.vue +14 -5
  91. package/src/components/SettingsMapTab.vue +140 -0
  92. package/src/components/SettingsMcpTab.vue +15 -10
  93. package/src/components/SettingsModal.vue +138 -112
  94. package/src/components/SettingsModelTab.vue +121 -0
  95. package/src/components/SettingsPhotosTab.vue +118 -0
  96. package/src/components/SourcesManager.vue +4 -3
  97. package/src/components/StackView.vue +43 -12
  98. package/src/composables/useContentDisplay.ts +16 -0
  99. package/src/composables/useFileDropZone.ts +148 -0
  100. package/src/composables/useImageErrorRepair.ts +29 -19
  101. package/src/composables/useSkillsList.ts +2 -1
  102. package/src/config/apiRoutes.ts +24 -0
  103. package/src/config/roles.ts +121 -70
  104. package/src/config/systemFileDescriptors.ts +2 -2
  105. package/src/config/toolNames.ts +26 -0
  106. package/src/index.css +26 -0
  107. package/src/lang/de.ts +70 -1
  108. package/src/lang/en.ts +69 -1
  109. package/src/lang/es.ts +69 -1
  110. package/src/lang/fr.ts +69 -1
  111. package/src/lang/ja.ts +69 -1
  112. package/src/lang/ko.ts +68 -1
  113. package/src/lang/pt-BR.ts +69 -1
  114. package/src/lang/zh.ts +67 -1
  115. package/src/lib/wiki-page/index-parse.ts +221 -0
  116. package/src/lib/wiki-page/link.ts +62 -0
  117. package/src/lib/wiki-page/lint.ts +105 -0
  118. package/src/lib/wiki-page/paths.ts +35 -0
  119. package/src/lib/wiki-page/slug.ts +28 -40
  120. package/src/main.ts +8 -0
  121. package/src/plugins/_extras.ts +6 -2
  122. package/src/plugins/_generated/metas.ts +4 -0
  123. package/src/plugins/_generated/registrations.ts +4 -0
  124. package/src/plugins/_generated/server-bindings.ts +6 -0
  125. package/src/plugins/accounting/Preview.vue +3 -6
  126. package/src/plugins/accounting/View.vue +2 -1
  127. package/src/plugins/accounting/components/AccountsModal.vue +3 -2
  128. package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
  129. package/src/plugins/accounting/components/JournalList.vue +2 -1
  130. package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
  131. package/src/plugins/accounting/currencies.ts +13 -0
  132. package/src/plugins/manageRoles/View.vue +16 -5
  133. package/src/plugins/manageSkills/View.vue +12 -4
  134. package/src/plugins/markdown/View.vue +6 -0
  135. package/src/plugins/photoLocations/View.vue +231 -0
  136. package/src/plugins/photoLocations/definition.ts +47 -0
  137. package/src/plugins/photoLocations/index.ts +38 -0
  138. package/src/plugins/photoLocations/meta.ts +35 -0
  139. package/src/plugins/presentMulmoScript/View.vue +76 -7
  140. package/src/plugins/presentMulmoScript/helpers.ts +15 -0
  141. package/src/plugins/presentSVG/Preview.vue +56 -0
  142. package/src/plugins/presentSVG/View.vue +465 -0
  143. package/src/plugins/presentSVG/definition.ts +29 -0
  144. package/src/plugins/presentSVG/index.ts +49 -0
  145. package/src/plugins/presentSVG/meta.ts +14 -0
  146. package/src/plugins/scheduler/View.vue +3 -7
  147. package/src/plugins/skill/View.vue +15 -16
  148. package/src/plugins/spreadsheet/View.vue +4 -0
  149. package/src/plugins/wiki/View.vue +1 -1
  150. package/src/plugins/wiki/helpers.ts +23 -5
  151. package/src/plugins/wiki/route.ts +12 -11
  152. package/src/tools/runtimeLoader.ts +75 -9
  153. package/src/utils/dom/iframeHeightClamp.ts +42 -0
  154. package/src/utils/format/bytes.ts +41 -0
  155. package/src/utils/format/date.ts +14 -2
  156. package/src/utils/image/imageRepairInlineScript.ts +192 -41
  157. package/src/utils/markdown/sanitize.ts +68 -0
  158. package/src/utils/markdown/setup.ts +36 -0
  159. package/src/utils/markdown/wikiEmbedHandlers.ts +170 -0
  160. package/src/utils/markdown/wikiEmbeds.ts +141 -0
  161. package/src/utils/markdown/workspaceLinkify.ts +73 -0
  162. package/src/utils/path/workspaceLinkRouter.ts +17 -1
  163. package/client/assets/index-ECD0lgIv.css +0 -2
  164. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  165. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
  166. package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
  167. package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
  168. package/server/workspace/wiki-history/provision.ts +0 -181
  169. /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
  170. /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
  171. /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
  172. /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
- import { BULLET_LINK_PATTERN, BULLET_WIKI_LINK_PATTERN } from "../../utils/regex.js";
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
- export interface WikiPageEntry {
31
- title: string;
32
- slug: string;
33
- description: string;
34
- tags: string[];
35
- }
36
-
37
- // Slug rules: lowercase, spaces to hyphens, strip everything that
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 slug = wikiSlugify(pageName);
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
- // Fuzzy: same `includes` semantics as the old sync path — iterate
240
- // the index's keys, no filesystem access.
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 === pageName);
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
- const WIKI_LINK_PATTERN = /\[\[([^\][\r\n]{1,200})\]\]/g;
414
-
415
- // Pure helpers extracted from the lint pass they take what they
416
- // need as plain inputs so each rule can be unit-tested without
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: err instanceof Error ? err.message : String(err),
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: err instanceof Error ? err.message : String(err),
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: err instanceof Error ? err.message : String(err),
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 { provisionWikiHistoryHook } from "./workspace/wiki-history/provision.js";
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
- // Provision the LLM-write hook in the workspace's
969
- // `.claude/settings.json` (#763 PR 2). Idempotent — safe on every
970
- // startup. Done BEFORE the agent ever spawns a claude CLI subprocess
971
- // so the hook is in place from the first turn.
972
- await provisionWikiHistoryHook().catch((err) => {
973
- log.warn("wiki-history", "hook provisioning failed; LLM wiki edits will not be snapshotted this session", {
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
  });