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
@@ -10,9 +10,11 @@ import chartDef from "../chart/definition";
10
10
  import manageSkillsDef from "../manageSkills/definition";
11
11
  import manageSourceDef from "../manageSource/definition";
12
12
  import markdownDef from "../markdown/definition";
13
+ import photoLocationsDef from "../photoLocations/definition";
13
14
  import presentFormDef from "../presentForm/definition";
14
15
  import presentHtmlDef from "../presentHtml/definition";
15
16
  import presentMulmoScriptDef from "../presentMulmoScript/definition";
17
+ import presentSVGDef from "../presentSVG/definition";
16
18
  import schedulerAutomationsDef from "../scheduler/automationsDefinition";
17
19
  import schedulerCalendarDef from "../scheduler/calendarDefinition";
18
20
  import spreadsheetDef from "../spreadsheet/definition";
@@ -22,9 +24,11 @@ import { META as chartMeta } from "../chart/meta";
22
24
  import { META as manageSkillsMeta } from "../manageSkills/meta";
23
25
  import { META as manageSourceMeta } from "../manageSource/meta";
24
26
  import { META as markdownMeta } from "../markdown/meta";
27
+ import { META as photoLocationsMeta } from "../photoLocations/meta";
25
28
  import { META as presentFormMeta } from "../presentForm/meta";
26
29
  import { META as presentHtmlMeta } from "../presentHtml/meta";
27
30
  import { META as presentMulmoScriptMeta } from "../presentMulmoScript/meta";
31
+ import { META as presentSVGMeta } from "../presentSVG/meta";
28
32
  import { META as schedulerCalendarMeta } from "../scheduler/calendarMeta";
29
33
  import { META as spreadsheetMeta } from "../spreadsheet/meta";
30
34
 
@@ -38,9 +42,11 @@ export const GENERATED_SERVER_BINDINGS: readonly ServerPluginBinding[] = [
38
42
  { def: manageSkillsDef, endpoint: mcpEndpoint(manageSkillsMeta) },
39
43
  { def: manageSourceDef, endpoint: mcpEndpoint(manageSourceMeta) },
40
44
  { def: markdownDef, endpoint: mcpEndpoint(markdownMeta) },
45
+ { def: photoLocationsDef, endpoint: mcpEndpoint(photoLocationsMeta) },
41
46
  { def: presentFormDef, endpoint: mcpEndpoint(presentFormMeta) },
42
47
  { def: presentHtmlDef, endpoint: mcpEndpoint(presentHtmlMeta) },
43
48
  { def: presentMulmoScriptDef, endpoint: mcpEndpoint(presentMulmoScriptMeta) },
49
+ { def: presentSVGDef, endpoint: mcpEndpoint(presentSVGMeta) },
44
50
  { def: schedulerAutomationsDef, endpoint: mcpEndpoint(schedulerCalendarMeta) },
45
51
  { def: schedulerCalendarDef, endpoint: mcpEndpoint(schedulerCalendarMeta) },
46
52
  { def: spreadsheetDef, endpoint: mcpEndpoint(spreadsheetMeta) },
@@ -12,6 +12,7 @@
12
12
  <script setup lang="ts">
13
13
  import { computed } from "vue";
14
14
  import { useI18n } from "vue-i18n";
15
+ import { formatAmountNumeric } from "./currencies";
15
16
 
16
17
  const { t } = useI18n();
17
18
 
@@ -34,10 +35,6 @@ interface BookLike {
34
35
  book?: { id?: string; name?: string };
35
36
  }
36
37
 
37
- function formatAmount(value: number): string {
38
- return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
39
- }
40
-
41
38
  // Each summarise* helper returns null when its branch doesn't apply,
42
39
  // keeping the dispatch in `summary` linear (no nested if-trees).
43
40
 
@@ -65,7 +62,7 @@ function summarisePl(json: Record<string, unknown>): string | null {
65
62
  return t("pluginAccounting.preview.pl", {
66
63
  from: profitLoss.from ?? "?",
67
64
  to: profitLoss.to ?? "?",
68
- net: formatAmount(profitLoss.netIncome),
65
+ net: formatAmountNumeric(profitLoss.netIncome),
69
66
  });
70
67
  }
71
68
 
@@ -75,7 +72,7 @@ function summariseBs(json: Record<string, unknown>): string | null {
75
72
  const assets = balanceSheet.sections.find((section) => section.type === "asset");
76
73
  return t("pluginAccounting.preview.bs", {
77
74
  date: balanceSheet.asOf,
78
- assets: assets ? formatAmount(assets.total ?? 0) : "?",
75
+ assets: assets ? formatAmountNumeric(assets.total ?? 0) : "?",
79
76
  });
80
77
  }
81
78
 
@@ -138,6 +138,7 @@ import BookSettings from "./components/BookSettings.vue";
138
138
  import { getOpeningBalances, getAccounts, getBooks, type Account, type BookSummary } from "./api";
139
139
  import { ACCOUNTING_ACTIONS } from "./actions";
140
140
  import { useAccountingChannel, useAccountingBooksChannel } from "../../composables/useAccountingChannel";
141
+ import { errorMessage } from "../../utils/errors";
141
142
 
142
143
  const { t } = useI18n();
143
144
 
@@ -318,7 +319,7 @@ async function refetchBooks(): Promise<void> {
318
319
  showFirstRunForm.value = true;
319
320
  }
320
321
  } catch (err) {
321
- bookLoadError.value = err instanceof Error ? err.message : String(err);
322
+ bookLoadError.value = errorMessage(err);
322
323
  } finally {
323
324
  loadingBooks.value = false;
324
325
  }
@@ -73,6 +73,7 @@ import AccountEditor from "./AccountEditor.vue";
73
73
  import type { AccountDraft } from "./accountDraft";
74
74
  import { validateAccountDraft, type AccountValidationError } from "./accountValidation";
75
75
  import { suggestNextCode } from "./accountNumbering";
76
+ import { errorMessage } from "../../../utils/errors";
76
77
 
77
78
  const { t } = useI18n();
78
79
 
@@ -222,7 +223,7 @@ async function onSave(next: AccountDraft): Promise<void> {
222
223
  // result.ok=false, so this is a belt-and-braces guard against
223
224
  // a runtime failure that would otherwise leave the Save button
224
225
  // stuck on "Saving…".
225
- error.value = err instanceof Error ? err.message : String(err);
226
+ error.value = errorMessage(err);
226
227
  } finally {
227
228
  saving.value = false;
228
229
  }
@@ -268,7 +269,7 @@ async function onToggleActive(account: Account): Promise<void> {
268
269
  }
269
270
  emit("changed");
270
271
  } catch (err) {
271
- toggleError.value = err instanceof Error ? err.message : String(err);
272
+ toggleError.value = errorMessage(err);
272
273
  } finally {
273
274
  toggleSaving.value = false;
274
275
  }
@@ -182,6 +182,7 @@ import { localDateString } from "../dates";
182
182
  import { countryHasFeature, type SupportedCountryCode } from "../countries";
183
183
  import { isTaxAccountCode } from "./accountNumbering";
184
184
  import AccountsModal from "./AccountsModal.vue";
185
+ import { errorMessage } from "../../../utils/errors";
185
186
 
186
187
  const { t } = useI18n();
187
188
 
@@ -413,7 +414,7 @@ async function onSubmit(): Promise<void> {
413
414
  // belt-and-braces guard against a runtime failure leaving the
414
415
  // submit button stuck — the user gets a visible error
415
416
  // instead of an unhandled rejection.
416
- error.value = err instanceof Error ? err.message : String(err);
417
+ error.value = errorMessage(err);
417
418
  } finally {
418
419
  submitting.value = false;
419
420
  }
@@ -207,6 +207,7 @@ import type { SupportedCountryCode } from "../countries";
207
207
  import { useLatestRequest } from "./useLatestRequest";
208
208
  import DateRangePicker from "./DateRangePicker.vue";
209
209
  import JournalEntryForm from "./JournalEntryForm.vue";
210
+ import { errorMessage } from "../../../utils/errors";
210
211
 
211
212
  const { t } = useI18n();
212
213
 
@@ -460,7 +461,7 @@ async function onVoid(entry: JournalEntry): Promise<void> {
460
461
  const result = await voidEntry({ entryId: entry.id, reason: reason || undefined, bookId: props.bookId });
461
462
  if (!result.ok) error.value = result.error;
462
463
  } catch (err) {
463
- error.value = err instanceof Error ? err.message : String(err);
464
+ error.value = errorMessage(err);
464
465
  }
465
466
  }
466
467
 
@@ -98,6 +98,7 @@ import { formatAmount, inputStepFor } from "../currencies";
98
98
  import { localDateString } from "../dates";
99
99
  import { useLatestRequest } from "./useLatestRequest";
100
100
  import AccountsModal from "./AccountsModal.vue";
101
+ import { errorMessage } from "../../../utils/errors";
101
102
 
102
103
  const { t } = useI18n();
103
104
 
@@ -238,7 +239,7 @@ async function onSubmit(): Promise<void> {
238
239
  successMessage.value = t("pluginAccounting.openingForm.success");
239
240
  emit("submitted");
240
241
  } catch (err) {
241
- error.value = err instanceof Error ? err.message : String(err);
242
+ error.value = errorMessage(err);
242
243
  } finally {
243
244
  submitting.value = false;
244
245
  }
@@ -62,3 +62,16 @@ export function formatAmount(value: number, currency: string, locale?: string):
62
62
  return value.toFixed(fractionDigitsFor(currency));
63
63
  }
64
64
  }
65
+
66
+ /** Currency-agnostic amount formatter — "1,130.00" — for places that
67
+ * don't carry the currency code on the data path (compact preview
68
+ * envelopes etc.). Use `formatAmount(value, currency)` whenever the
69
+ * currency IS available — the currency-aware path picks the right
70
+ * fraction-digit count automatically (JPY = 0, USD = 2).
71
+ *
72
+ * `locale` mirrors `formatAmount`'s signature: pass an explicit BCP-47
73
+ * locale (`"en-US"`, `"ja-JP"`, …) when the caller knows the desired
74
+ * grouping / digit-shape; omit to fall back to the runtime default. */
75
+ export function formatAmountNumeric(value: number, decimals = 2, locale?: string): string {
76
+ return value.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
77
+ }
@@ -297,18 +297,29 @@ interface PluginEntry {
297
297
 
298
298
  // Plugins the user can assign — exclude internal/auto-managed ones
299
299
  const EXCLUDED = new Set(["text-response"]);
300
- const guiPlugins: PluginEntry[] = pluginAllPluginNames()
301
- .filter((name) => !EXCLUDED.has(name))
302
- .map((name) => ({ name, enabled: true, requiredEnv: [] }));
300
+ // `pluginAllPluginNames()` reads from the reactive `runtimeRegistry`
301
+ // (src/tools/runtimeLoader.ts). Wrapping in `computed` makes this
302
+ // re-evaluate when `loadRuntimePlugins` populates the registry
303
+ // post-mount — runtime plugins (e.g. server-only `edgar`) only
304
+ // become visible to the role editor through this reactive read.
305
+ // Snapshotting once into a non-reactive const meant a setup() that
306
+ // ran before the loader resolved would never see them.
307
+ const guiPlugins = computed<PluginEntry[]>(() =>
308
+ pluginAllPluginNames()
309
+ .filter((name) => !EXCLUDED.has(name))
310
+ .map((name) => ({ name, enabled: true, requiredEnv: [] })),
311
+ );
312
+
313
+ const mcpTools = ref<PluginEntry[]>([]);
303
314
 
304
- const availablePlugins = ref<PluginEntry[]>(guiPlugins);
315
+ const availablePlugins = computed<PluginEntry[]>(() => [...guiPlugins.value, ...mcpTools.value]);
305
316
 
306
317
  const mcpEndpoints = pluginEndpoints<{ list: string }>("mcpTools");
307
318
 
308
319
  onMounted(async () => {
309
320
  const result = await apiGet<PluginEntry[]>(mcpEndpoints.list);
310
321
  if (result.ok) {
311
- availablePlugins.value = [...guiPlugins, ...result.data];
322
+ mcpTools.value = result.data;
312
323
  }
313
324
  // Non-critical: MCP tools enrich the plugin palette for role editing
314
325
  // but the view works fine with GUI plugins alone. No error banner needed.
@@ -129,8 +129,15 @@
129
129
  </div>
130
130
  </div>
131
131
  <!-- View mode -->
132
- <!-- eslint-disable-next-line vue/no-v-html -- sanitized via DOMPurify -->
133
- <div v-else-if="detail && renderedBody" class="markdown-content text-gray-700" data-testid="skill-body-rendered" v-html="renderedBody"></div>
132
+ <!-- eslint-disable vue/no-v-html -- sanitized via DOMPurify; multi-line element so disable/enable pair (CLAUDE.md UI rule) instead of -next-line -->
133
+ <div
134
+ v-else-if="detail && renderedBody"
135
+ class="markdown-content text-gray-700"
136
+ data-testid="skill-body-rendered"
137
+ @click="handleExternalLinkClick"
138
+ v-html="renderedBody"
139
+ ></div>
140
+ <!-- eslint-enable vue/no-v-html -->
134
141
  <p v-else-if="detail" class="text-sm text-gray-400 italic">{{ t("pluginManageSkills.emptyBody") }}</p>
135
142
  </div>
136
143
  </div>
@@ -142,11 +149,12 @@
142
149
  import { computed, onMounted, ref, watch } from "vue";
143
150
  import { useI18n } from "vue-i18n";
144
151
  import { marked } from "marked";
145
- import DOMPurify from "dompurify";
146
152
  import type { ToolResultComplete } from "gui-chat-protocol/vue";
147
153
  import type { ManageSkillsData, SkillSummary } from "./index";
148
154
  import { useAppApi } from "../../composables/useAppApi";
149
155
  import { apiGet, apiPut, apiDelete } from "../../utils/api";
156
+ import { handleExternalLinkClick } from "../../utils/dom/externalLink";
157
+ import { sanitizeMarkdownHtml } from "../../utils/markdown/sanitize";
150
158
  import { pluginEndpoints } from "../api";
151
159
  import { buildRouteUrl } from "../meta-types";
152
160
  import type { SkillsEndpoints } from "./definition";
@@ -184,7 +192,7 @@ const selected = computed(() => skills.value.find((skill) => skill.name === sele
184
192
  const renderedBody = computed(() => {
185
193
  const body = detail.value?.body;
186
194
  if (!body) return "";
187
- return DOMPurify.sanitize(marked(body) as string);
195
+ return sanitizeMarkdownHtml(marked(body) as string);
188
196
  });
189
197
 
190
198
  // Reset the selection when the tool result is replaced (e.g. the
@@ -87,6 +87,7 @@ import { rewriteMarkdownImageRefs } from "../../utils/image/rewriteMarkdownImage
87
87
  import { findTaskLines, makeTasksInteractive, toggleTaskAt } from "../../utils/markdown/taskList";
88
88
  import { usePdfDownload } from "../../composables/usePdfDownload";
89
89
  import { apiGet, apiPut } from "../../utils/api";
90
+ import { handleExternalLinkClick } from "../../utils/dom/externalLink";
90
91
  import { pluginEndpoints } from "../api";
91
92
  import { useClipboardCopy } from "../../composables/useClipboardCopy";
92
93
  import { buildPdfFilename } from "../../utils/files/filename";
@@ -354,6 +355,11 @@ async function persistTaskMarkdown(relativePath: string, markdown: string): Prom
354
355
  }
355
356
 
356
357
  function onMarkdownClick(event: MouseEvent): void {
358
+ // External http(s) links: open in a new tab instead of letting the
359
+ // SPA navigate away. Same handler the wiki / textResponse renders
360
+ // use; without it, clicking an external link from a markdown file
361
+ // tore the user out of MulmoClaude (#1221).
362
+ if (handleExternalLinkClick(event)) return;
357
363
  const { target } = event;
358
364
  if (!(target instanceof HTMLInputElement)) return;
359
365
  if (target.type !== "checkbox") return;
@@ -0,0 +1,231 @@
1
+ <script setup lang="ts">
2
+ // Photo-locations View (#1222 PR-B). A list of every captured
3
+ // sidecar — photo path, lat/lng, takenAt, camera. The map handoff
4
+ // is by chat (the LLM calls `mapControl({action: "addMarker", lat,
5
+ // lng})` once asked) rather than embedded here, so this view stays
6
+ // small + always-loadable regardless of whether the user has a
7
+ // Google Maps API key set.
8
+
9
+ import { computed, onMounted, ref } from "vue";
10
+ import { useI18n } from "vue-i18n";
11
+ import { apiPost } from "../../utils/api";
12
+ import { pluginEndpoints } from "../api";
13
+ import type { ResolvedRoute } from "../meta-types";
14
+ import { errorMessage as toErrorMessage } from "../../utils/errors";
15
+ import { formatDate } from "../../utils/format/date";
16
+
17
+ interface Sidecar {
18
+ version: 1;
19
+ photo: { relativePath: string; mimeType: string };
20
+ exif: {
21
+ lat?: number;
22
+ lng?: number;
23
+ altitude?: number;
24
+ takenAt?: string;
25
+ make?: string;
26
+ model?: string;
27
+ lens?: string;
28
+ };
29
+ capturedAt: string;
30
+ }
31
+
32
+ interface ListedSidecar {
33
+ id: string;
34
+ relativePath: string;
35
+ sidecar: Sidecar;
36
+ }
37
+
38
+ interface ListResult {
39
+ message?: string;
40
+ data?: { locations: ListedSidecar[]; total: number };
41
+ }
42
+
43
+ interface PhotoLocationsEndpoints {
44
+ dispatch: ResolvedRoute;
45
+ }
46
+
47
+ const { t } = useI18n();
48
+ const endpoints = pluginEndpoints<PhotoLocationsEndpoints>("photoLocations");
49
+
50
+ const locations = ref<ListedSidecar[]>([]);
51
+ const loading = ref(true);
52
+ const errorMessage = ref<string>("");
53
+
54
+ async function refresh(): Promise<void> {
55
+ loading.value = true;
56
+ errorMessage.value = "";
57
+ try {
58
+ const result = await apiPost<ListResult>(endpoints.dispatch.url, { kind: "list" });
59
+ if (!result.ok) throw new Error(result.error);
60
+ locations.value = result.data.data?.locations ?? [];
61
+ } catch (err) {
62
+ errorMessage.value = toErrorMessage(err);
63
+ } finally {
64
+ loading.value = false;
65
+ }
66
+ }
67
+
68
+ const withGps = computed(() => locations.value.filter((row) => hasFiniteCoords(row.sidecar.exif)));
69
+
70
+ onMounted(() => {
71
+ void refresh();
72
+ });
73
+
74
+ function fmtCoord(value: unknown): string {
75
+ // The handler validates lat/lng on write, but a hand-edited
76
+ // sidecar can still ship a string / null past the type — guard
77
+ // before calling `toFixed` so one bad row doesn't crash the View.
78
+ // (Codex review on PR #1250.)
79
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(5) : "—";
80
+ }
81
+
82
+ function fmtAltitude(value: unknown): string | null {
83
+ return typeof value === "number" && Number.isFinite(value) ? value.toFixed(0) : null;
84
+ }
85
+
86
+ function fmtDate(iso: string | undefined): string {
87
+ if (!iso) return "—";
88
+ return formatDate(iso);
89
+ }
90
+
91
+ function hasFiniteCoords(exif: { lat?: unknown; lng?: unknown }): boolean {
92
+ return typeof exif.lat === "number" && Number.isFinite(exif.lat) && typeof exif.lng === "number" && Number.isFinite(exif.lng);
93
+ }
94
+ </script>
95
+
96
+ <template>
97
+ <div class="photo-locations-view">
98
+ <header>
99
+ <h2>{{ t("photoLocations.title") }}</h2>
100
+ <span class="count" data-testid="photo-locations-count">{{ t("photoLocations.summary", { total: locations.length, withGps: withGps.length }) }}</span>
101
+ </header>
102
+
103
+ <p class="hint">{{ t("photoLocations.mapHint") }}</p>
104
+
105
+ <div v-if="loading" class="message loading">{{ t("photoLocations.loading") }}</div>
106
+ <div v-else-if="errorMessage" class="message error">⚠ {{ errorMessage }}</div>
107
+ <div v-else-if="locations.length === 0" class="message empty">{{ t("photoLocations.empty") }}</div>
108
+
109
+ <ul v-else class="rows">
110
+ <li v-for="row in locations" :key="row.id" class="row" :data-testid="`photo-locations-row-${row.id}`">
111
+ <div class="row-main">
112
+ <code class="path">{{ row.sidecar.photo.relativePath }}</code>
113
+ <span class="taken">{{ fmtDate(row.sidecar.exif.takenAt) }}</span>
114
+ </div>
115
+ <div class="row-meta">
116
+ <span v-if="hasFiniteCoords(row.sidecar.exif)" class="coords">
117
+ <!-- eslint-disable @intlify/vue-i18n/no-raw-text -- coordinates emoji + decimal pair + altitude unit are language-neutral numeric formatters, not user-facing prose -->
118
+ 📍 {{ fmtCoord(row.sidecar.exif.lat) }}, {{ fmtCoord(row.sidecar.exif.lng) }}
119
+ <span v-if="fmtAltitude(row.sidecar.exif.altitude)" class="altitude">({{ fmtAltitude(row.sidecar.exif.altitude) }}m)</span>
120
+ <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
121
+ </span>
122
+ <span v-else class="no-gps">{{ t("photoLocations.noGps") }}</span>
123
+ <span v-if="row.sidecar.exif.model" class="camera">{{ row.sidecar.exif.model }}</span>
124
+ </div>
125
+ </li>
126
+ </ul>
127
+ </div>
128
+ </template>
129
+
130
+ <style scoped>
131
+ .photo-locations-view {
132
+ height: 100%;
133
+ display: flex;
134
+ flex-direction: column;
135
+ gap: 0.5rem;
136
+ padding: 0.75rem 1rem;
137
+ font-family: system-ui, sans-serif;
138
+ overflow-y: auto;
139
+ }
140
+ header {
141
+ display: flex;
142
+ align-items: baseline;
143
+ gap: 0.75rem;
144
+ }
145
+ h2 {
146
+ margin: 0;
147
+ font-size: 1rem;
148
+ font-weight: 600;
149
+ color: #1f2937;
150
+ }
151
+ .count {
152
+ font-size: 0.75rem;
153
+ color: #6b7280;
154
+ }
155
+ .hint {
156
+ margin: 0;
157
+ font-size: 0.75rem;
158
+ color: #6b7280;
159
+ font-style: italic;
160
+ }
161
+ .message {
162
+ padding: 0.75rem 1rem;
163
+ border-radius: 0.5rem;
164
+ font-size: 0.875rem;
165
+ }
166
+ .message.loading,
167
+ .message.empty {
168
+ color: #6b7280;
169
+ }
170
+ .message.error {
171
+ background: #fee2e2;
172
+ border: 1px solid #fecaca;
173
+ color: #991b1b;
174
+ }
175
+ .rows {
176
+ list-style: none;
177
+ margin: 0;
178
+ padding: 0;
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 0.375rem;
182
+ }
183
+ .row {
184
+ padding: 0.5rem 0.75rem;
185
+ border-radius: 0.375rem;
186
+ background: #f9fafb;
187
+ border: 1px solid #e5e7eb;
188
+ }
189
+ .row-main {
190
+ display: flex;
191
+ justify-content: space-between;
192
+ gap: 0.5rem;
193
+ align-items: baseline;
194
+ }
195
+ .path {
196
+ font-family: monospace;
197
+ font-size: 0.75rem;
198
+ color: #374151;
199
+ overflow: hidden;
200
+ text-overflow: ellipsis;
201
+ white-space: nowrap;
202
+ flex: 1;
203
+ min-width: 0;
204
+ }
205
+ .taken {
206
+ font-size: 0.75rem;
207
+ color: #6b7280;
208
+ flex-shrink: 0;
209
+ }
210
+ .row-meta {
211
+ margin-top: 0.25rem;
212
+ display: flex;
213
+ flex-wrap: wrap;
214
+ gap: 0.5rem;
215
+ font-size: 0.75rem;
216
+ color: #4b5563;
217
+ }
218
+ .coords {
219
+ font-family: monospace;
220
+ }
221
+ .altitude {
222
+ color: #9ca3af;
223
+ }
224
+ .no-gps {
225
+ color: #d97706;
226
+ font-style: italic;
227
+ }
228
+ .camera {
229
+ color: #6b7280;
230
+ }
231
+ </style>
@@ -0,0 +1,47 @@
1
+ // MCP tool definition for `managePhotoLocations` (#1222 PR-B).
2
+ //
3
+ // Two kinds for v1:
4
+ // - `list` — every sidecar, newest first.
5
+ // - `count` — quick scalar for the View's badge.
6
+ //
7
+ // `extractExif` (on-demand re-read for one photo) and `rescan`
8
+ // (backfill sidecars for old uploads) ride a follow-up PR.
9
+
10
+ import type { ToolDefinition } from "gui-chat-protocol";
11
+ import { META } from "./meta";
12
+
13
+ export const TOOL_NAME = META.toolName;
14
+
15
+ export const PHOTO_LOCATIONS_KINDS = {
16
+ list: "list",
17
+ count: "count",
18
+ } as const;
19
+ export type PhotoLocationsKind = (typeof PHOTO_LOCATIONS_KINDS)[keyof typeof PHOTO_LOCATIONS_KINDS];
20
+
21
+ export interface PhotoLocationsArgs {
22
+ kind: PhotoLocationsKind;
23
+ }
24
+
25
+ /** Single tool, kind-discriminated. Same shape as
26
+ * manageAccounting / manageScheduler so the MCP bridge plugs in
27
+ * unchanged. */
28
+ const TOOL_DEFINITION: ToolDefinition = {
29
+ type: "function",
30
+ name: TOOL_NAME,
31
+ description:
32
+ "Read the photo-location sidecars produced by the EXIF auto-capture hook. " +
33
+ "Use `list` to fetch every captured location (lat/lng/altitude/takenAt/camera) " +
34
+ "for queries like 'show last week's photos on a map' — the lat/lng pair is " +
35
+ 'shape-compatible with `mapControl({action: "addMarker"})` so you can hand ' +
36
+ "the result straight to the Google Map plugin without reshape. " +
37
+ "Use `count` for a quick scalar.",
38
+ parameters: {
39
+ type: "object",
40
+ properties: {
41
+ kind: { type: "string", enum: Object.values(PHOTO_LOCATIONS_KINDS) },
42
+ },
43
+ required: ["kind"],
44
+ },
45
+ };
46
+
47
+ export default TOOL_DEFINITION;
@@ -0,0 +1,38 @@
1
+ import type { PluginRegistration, ToolPlugin } from "../../tools/types";
2
+ import type { ToolResult } from "gui-chat-protocol";
3
+ import View from "./View.vue";
4
+ import toolDefinition from "./definition";
5
+ import { META } from "./meta";
6
+ import { wrapWithScope } from "../scope";
7
+ import { apiCall } from "../../utils/api";
8
+ import { makeUuid } from "../../utils/id";
9
+
10
+ // Same one-line pass-through pattern as accounting / scheduler:
11
+ // production tool calls flow Claude → MCP → REST, never through
12
+ // `execute()`. The body satisfies the gui-chat-protocol shape and
13
+ // supports any host that does call it.
14
+ export type PhotoLocationsActionData = Record<string, unknown>;
15
+
16
+ const photoLocationsPlugin: ToolPlugin<PhotoLocationsActionData> = {
17
+ toolDefinition,
18
+
19
+ async execute(_context, args) {
20
+ const { method, path } = META.apiRoutes.dispatch;
21
+ const result = await apiCall<ToolResult<PhotoLocationsActionData>>(`/api/${META.apiNamespace}${path}`, { method, body: args });
22
+ if (!result.ok) {
23
+ return { toolName: toolDefinition.name, uuid: makeUuid(), message: result.error };
24
+ }
25
+ return { ...result.data, toolName: toolDefinition.name, uuid: result.data.uuid ?? makeUuid() };
26
+ },
27
+
28
+ isEnabled: () => true,
29
+ generatingMessage: "Reading photo locations…",
30
+ viewComponent: wrapWithScope("photoLocations", View),
31
+ };
32
+
33
+ export default photoLocationsPlugin;
34
+
35
+ export const REGISTRATION: PluginRegistration = {
36
+ toolName: META.toolName,
37
+ entry: photoLocationsPlugin,
38
+ };
@@ -0,0 +1,35 @@
1
+ // Central-registry metadata for the photo-locations plugin
2
+ // (#1222 PR-B). Owns one tool — `managePhotoLocations` — exposing
3
+ // the sidecars produced by the post-save EXIF hook (PR-A) so the
4
+ // LLM can answer "where were my photos taken" / "show last week's
5
+ // shots on a map" without poking at `data/locations/` directly.
6
+ //
7
+ // `mapControl` (`@gui-chat-plugin/google-map`) is the natural
8
+ // downstream consumer: a sidecar's `lat` / `lng` flow into
9
+ // `mapControl({ action: "addMarker", lat, lng })` without reshape.
10
+ //
11
+ // Browser-safe: no Vue imports, no server-only imports.
12
+
13
+ import { definePluginMeta } from "../meta-types";
14
+
15
+ export const META = definePluginMeta({
16
+ toolName: "managePhotoLocations",
17
+ apiNamespace: "photoLocations",
18
+ apiRoutes: {
19
+ /** POST /api/photoLocations — single dispatch with action
20
+ * discriminator. Mirrors the accounting / scheduler
21
+ * convention so the MCP bridge plugs in unchanged. */
22
+ dispatch: { method: "POST", path: "" },
23
+ },
24
+ mcpDispatch: "dispatch",
25
+ // Sidecar storage already lives at the host-level
26
+ // `WORKSPACE_DIRS.locations` (declared by the host because the
27
+ // hook runs server-side on every saved attachment). No new
28
+ // directories belong to this plugin — it's a read surface over
29
+ // host data. This META therefore omits `workspaceDirs`.
30
+ staticChannels: {
31
+ /** Published whenever a sidecar is added / removed so an open
32
+ * View refreshes without polling. */
33
+ locationsChanged: "photoLocations:locations-changed",
34
+ },
35
+ });