mulmoclaude 0.6.1 → 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 (139) 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-BZdOOa5E.js → index-BwrlMMHr.js} +66 -65
  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 +7 -7
  11. package/server/accounting/eventPublisher.ts +2 -1
  12. package/server/accounting/snapshotCache.ts +2 -1
  13. package/server/agent/backend/claude-code.ts +1 -0
  14. package/server/agent/backend/types.ts +3 -0
  15. package/server/agent/config.ts +25 -2
  16. package/server/agent/index.ts +6 -0
  17. package/server/agent/prompt.ts +37 -24
  18. package/server/api/routes/accounting.ts +31 -24
  19. package/server/api/routes/agent.ts +2 -2
  20. package/server/api/routes/config-refresh.ts +49 -0
  21. package/server/api/routes/config.ts +77 -67
  22. package/server/api/routes/files.ts +41 -17
  23. package/server/api/routes/hookLog.ts +95 -0
  24. package/server/api/routes/news.ts +39 -52
  25. package/server/api/routes/notifier.ts +14 -19
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/presentSvg.ts +107 -0
  28. package/server/api/routes/scheduler.ts +100 -98
  29. package/server/api/routes/schedulerTasks.ts +98 -95
  30. package/server/api/routes/sessions.ts +22 -27
  31. package/server/api/routes/sources.ts +45 -43
  32. package/server/api/routes/wiki/history.ts +6 -15
  33. package/server/api/routes/wiki.ts +73 -276
  34. package/server/events/file-change.ts +3 -2
  35. package/server/events/session-store/index.ts +2 -1
  36. package/server/index.ts +117 -8
  37. package/server/notifier/store.ts +3 -3
  38. package/server/plugins/preset-list.ts +16 -5
  39. package/server/plugins/runtime.ts +2 -2
  40. package/server/system/config.ts +44 -2
  41. package/server/utils/asyncHandler.ts +75 -0
  42. package/server/utils/files/accounting-io.ts +19 -20
  43. package/server/utils/files/journal-io.ts +2 -1
  44. package/server/utils/files/json.ts +8 -1
  45. package/server/utils/files/reference-dirs-io.ts +2 -3
  46. package/server/utils/files/scheduler-overrides-io.ts +2 -3
  47. package/server/utils/files/svg-store.ts +27 -0
  48. package/server/utils/files/user-tasks-io.ts +2 -3
  49. package/server/utils/regex.ts +3 -12
  50. package/server/utils/text.ts +29 -0
  51. package/server/workspace/chat-index/summarizer.ts +5 -3
  52. package/server/workspace/cooking-recipes/migrate.ts +125 -0
  53. package/server/workspace/custom-dirs.ts +2 -2
  54. package/server/workspace/hooks/dispatcher.mjs +300 -0
  55. package/server/workspace/hooks/dispatcher.ts +55 -0
  56. package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
  57. package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
  58. package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
  59. package/server/workspace/hooks/provision.ts +222 -0
  60. package/server/workspace/hooks/shared/sidecar.ts +124 -0
  61. package/server/workspace/hooks/shared/stdin.ts +60 -0
  62. package/server/workspace/hooks/shared/workspace.ts +13 -0
  63. package/server/workspace/journal/dailyPass.ts +1 -6
  64. package/server/workspace/memory/io.ts +1 -34
  65. package/server/workspace/memory/migrate.ts +2 -1
  66. package/server/workspace/memory/snapshot.ts +26 -0
  67. package/server/workspace/memory/topic-io.ts +1 -18
  68. package/server/workspace/paths.ts +10 -0
  69. package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  70. package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
  71. package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
  72. package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
  73. package/server/workspace/skills-preset.ts +2 -1
  74. package/server/workspace/wiki-pages/io.ts +2 -1
  75. package/src/App.vue +51 -3
  76. package/src/components/ChatInput.vue +7 -8
  77. package/src/components/FileContentHeader.vue +1 -6
  78. package/src/components/FileDropOverlay.vue +18 -0
  79. package/src/components/RolesView.vue +14 -5
  80. package/src/components/SettingsMcpTab.vue +15 -10
  81. package/src/components/SettingsModal.vue +116 -130
  82. package/src/components/SettingsModelTab.vue +121 -0
  83. package/src/composables/useContentDisplay.ts +16 -0
  84. package/src/composables/useFileDropZone.ts +148 -0
  85. package/src/composables/useSkillsList.ts +2 -1
  86. package/src/config/apiRoutes.ts +22 -0
  87. package/src/config/roles.ts +78 -48
  88. package/src/config/toolNames.ts +4 -1
  89. package/src/lang/de.ts +36 -1
  90. package/src/lang/en.ts +36 -1
  91. package/src/lang/es.ts +36 -1
  92. package/src/lang/fr.ts +36 -1
  93. package/src/lang/ja.ts +36 -1
  94. package/src/lang/ko.ts +36 -1
  95. package/src/lang/pt-BR.ts +36 -1
  96. package/src/lang/zh.ts +36 -1
  97. package/src/lib/wiki-page/index-parse.ts +221 -0
  98. package/src/lib/wiki-page/link.ts +62 -0
  99. package/src/lib/wiki-page/lint.ts +105 -0
  100. package/src/lib/wiki-page/paths.ts +35 -0
  101. package/src/lib/wiki-page/slug.ts +28 -40
  102. package/src/main.ts +1 -0
  103. package/src/plugins/_generated/metas.ts +2 -0
  104. package/src/plugins/_generated/registrations.ts +2 -0
  105. package/src/plugins/_generated/server-bindings.ts +3 -0
  106. package/src/plugins/accounting/Preview.vue +3 -6
  107. package/src/plugins/accounting/View.vue +2 -1
  108. package/src/plugins/accounting/components/AccountsModal.vue +3 -2
  109. package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
  110. package/src/plugins/accounting/components/JournalList.vue +2 -1
  111. package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
  112. package/src/plugins/accounting/currencies.ts +13 -0
  113. package/src/plugins/manageRoles/View.vue +16 -5
  114. package/src/plugins/photoLocations/View.vue +4 -2
  115. package/src/plugins/presentSVG/Preview.vue +56 -0
  116. package/src/plugins/presentSVG/View.vue +465 -0
  117. package/src/plugins/presentSVG/definition.ts +29 -0
  118. package/src/plugins/presentSVG/index.ts +49 -0
  119. package/src/plugins/presentSVG/meta.ts +14 -0
  120. package/src/plugins/scheduler/View.vue +3 -7
  121. package/src/plugins/skill/View.vue +11 -13
  122. package/src/plugins/wiki/View.vue +1 -1
  123. package/src/plugins/wiki/helpers.ts +23 -5
  124. package/src/plugins/wiki/route.ts +12 -11
  125. package/src/tools/runtimeLoader.ts +75 -9
  126. package/src/utils/format/bytes.ts +41 -0
  127. package/src/utils/format/date.ts +14 -2
  128. package/src/utils/markdown/setup.ts +5 -0
  129. package/src/utils/markdown/workspaceLinkify.ts +73 -0
  130. package/client/assets/index-Bl3vqgA6.css +0 -2
  131. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  132. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
  133. package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
  134. package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
  135. package/server/workspace/wiki-history/provision.ts +0 -181
  136. /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
  137. /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
  138. /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
  139. /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
@@ -36,6 +36,7 @@ import { badRequest, conflict, sendError, serverError } from "../../utils/httpEr
36
36
  import { errorMessage } from "../../utils/errors.js";
37
37
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
38
38
  import { bindRoute } from "../../utils/router.js";
39
+ import { asyncHandler } from "../../utils/asyncHandler.js";
39
40
  import { isNonEmptyString, isRecord } from "../../utils/types.js";
40
41
 
41
42
  const router = Router();
@@ -56,15 +57,14 @@ interface ErrorResponse {
56
57
  error: string;
57
58
  }
58
59
 
59
- bindRoute(router, API_ROUTES.sources.list, async (_req: Request, res: Response<ListSourcesResponse | ErrorResponse>) => {
60
- try {
60
+ bindRoute(
61
+ router,
62
+ API_ROUTES.sources.list,
63
+ asyncHandler<Request, Response<ListSourcesResponse | ErrorResponse>>("sources", "failed to list sources", async (_req, res) => {
61
64
  const sources = await listSources(workspacePath);
62
65
  res.json({ sources });
63
- } catch (err) {
64
- log.warn("sources", "list failed", { error: String(err) });
65
- serverError(res, errorMessage(err, "unknown error"));
66
- }
67
- });
66
+ }),
67
+ );
68
68
 
69
69
  // --- POST /api/sources --------------------------------------------------
70
70
 
@@ -164,13 +164,15 @@ interface RebuildBody {
164
164
  scheduleType?: unknown;
165
165
  }
166
166
 
167
- bindRoute(router, API_ROUTES.sources.rebuild, async (req: Request<object, unknown, RebuildBody>, res: Response<ErrorResponse | Record<string, unknown>>) => {
168
- const scheduleType = validateSchedule(req.body?.scheduleType, "daily");
169
- if (!scheduleType) {
170
- badRequest(res, `scheduleType must be one of: ${[...SOURCE_SCHEDULES].join(", ")}`);
171
- return;
172
- }
173
- try {
167
+ bindRoute(
168
+ router,
169
+ API_ROUTES.sources.rebuild,
170
+ asyncHandler<Request<object, unknown, RebuildBody>, Response<ErrorResponse | Record<string, unknown>>>("sources", "rebuild failed", async (req, res) => {
171
+ const scheduleType = validateSchedule(req.body?.scheduleType, "daily");
172
+ if (!scheduleType) {
173
+ badRequest(res, `scheduleType must be one of: ${[...SOURCE_SCHEDULES].join(", ")}`);
174
+ return;
175
+ }
174
176
  log.info("sources", "manual rebuild triggered", { scheduleType });
175
177
  const result = await runSourcesPipeline({
176
178
  workspaceRoot: workspacePath,
@@ -196,11 +198,8 @@ bindRoute(router, API_ROUTES.sources.rebuild, async (req: Request<object, unknow
196
198
  archiveErrors: result.archiveErrors,
197
199
  isoDate: result.isoDate,
198
200
  });
199
- } catch (err) {
200
- log.warn("sources", "rebuild failed", { error: String(err) });
201
- serverError(res, errorMessage(err, "rebuild failed"));
202
- }
203
- });
201
+ }),
202
+ );
204
203
 
205
204
  // --- POST /api/sources/manage -------------------------------------------
206
205
  //
@@ -244,31 +243,34 @@ interface ManageSourceSuccess {
244
243
 
245
244
  const MANAGE_ACTIONS = new Set(["list", "register", "remove", "rebuild"]);
246
245
 
247
- bindRoute(router, API_ROUTES.sources.manage, async (req: Request<object, unknown, ManageSourceBody>, res: Response<ManageSourceSuccess | ErrorResponse>) => {
248
- const action = req.body?.action;
249
- if (typeof action !== "string" || !MANAGE_ACTIONS.has(action)) {
250
- badRequest(res, `action must be one of: ${[...MANAGE_ACTIONS].join(", ")}`);
251
- return;
252
- }
253
- try {
254
- switch (action) {
255
- case "list":
256
- await respondWithList(res, "Loaded source registry.");
257
- return;
258
- case "register":
259
- await handleRegister(req.body, res);
260
- return;
261
- case "remove":
262
- await handleRemove(req.body, res);
246
+ bindRoute(
247
+ router,
248
+ API_ROUTES.sources.manage,
249
+ asyncHandler<Request<object, unknown, ManageSourceBody>, Response<ManageSourceSuccess | ErrorResponse>>(
250
+ "sources",
251
+ "manageSource dispatch failed",
252
+ async (req, res) => {
253
+ const action = req.body?.action;
254
+ if (typeof action !== "string" || !MANAGE_ACTIONS.has(action)) {
255
+ badRequest(res, `action must be one of: ${[...MANAGE_ACTIONS].join(", ")}`);
263
256
  return;
264
- case "rebuild":
265
- await handleRebuild(res);
266
- }
267
- } catch (err) {
268
- log.warn("sources", "manage failed", { action, error: String(err) });
269
- serverError(res, errorMessage(err, "manage failed"));
270
- }
271
- });
257
+ }
258
+ switch (action) {
259
+ case "list":
260
+ await respondWithList(res, "Loaded source registry.");
261
+ return;
262
+ case "register":
263
+ await handleRegister(req.body, res);
264
+ return;
265
+ case "remove":
266
+ await handleRemove(req.body, res);
267
+ return;
268
+ case "rebuild":
269
+ await handleRebuild(res);
270
+ }
271
+ },
272
+ ),
273
+ );
272
274
 
273
275
  async function respondWithList(res: Response<ManageSourceSuccess | ErrorResponse>, message: string, extra: Partial<ManageSourceData> = {}): Promise<void> {
274
276
  const sources = await listSources(workspacePath);
@@ -7,9 +7,9 @@
7
7
  // itself, so undo stays cheap).
8
8
  //
9
9
  // Path safety: both `:slug` and `:stamp` are validated *before*
10
- // they are joined with the workspace root. The slug check matches
11
- // `wiki-pages/io.ts`'s `isSafeSlug`; the stamp check is the
12
- // `FILENAME_RE` shape exposed via `isSafeStamp`.
10
+ // they are joined with the workspace root. The slug check is the
11
+ // shared `isSafeSlug` from `src/lib/wiki-page/slug.ts`; the stamp
12
+ // check is the `FILENAME_RE` shape exposed via `isSafeStamp`.
13
13
 
14
14
  import { Router, type Request, type Response } from "express";
15
15
  import path from "node:path";
@@ -24,20 +24,11 @@ import { readTextOrNull } from "../../../utils/files/safe.js";
24
24
  import { workspacePath } from "../../../workspace/workspace.js";
25
25
  import { pushToolResult } from "../../../events/session-store/index.js";
26
26
  import { log } from "../../../system/logger/index.js";
27
+ import { errorMessage } from "../../../utils/errors.js";
28
+ import { isSafeSlug } from "../../../../src/lib/wiki-page/slug.js";
27
29
 
28
30
  const router = Router();
29
31
 
30
- // Mirrors `isSafeSlug` from wiki-pages/io.ts (kept independent so
31
- // the route layer doesn't import the helper through a circular
32
- // dependency — io.ts already imports snapshot.ts).
33
- function isSafeSlug(slug: string): boolean {
34
- if (slug.length === 0) return false;
35
- if (slug === "." || slug === "..") return false;
36
- if (slug.includes("/") || slug.includes("\\")) return false;
37
- if (slug.includes("\0")) return false;
38
- return true;
39
- }
40
-
41
32
  // Restore is a write under the user's workspace; record a short
42
33
  // reason on the new snapshot so the history reads "Restored from
43
34
  // 2026-04-28T01-23-45-789Z" rather than an empty cell. Editor stays
@@ -250,7 +241,7 @@ router.post("/internal/snapshot", async (req: Request<object, unknown, InternalS
250
241
  } catch (err) {
251
242
  log.warn("wiki", "page-edit toolResult publish failed", {
252
243
  slug,
253
- error: err instanceof Error ? err.message : String(err),
244
+ error: errorMessage(err),
254
245
  });
255
246
  }
256
247
  }
@@ -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
  }