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.
- package/bin/mulmoclaude.js +1 -1
- package/client/assets/PluginScopedRoot-YjvQq0Nn.js +3 -0
- package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
- package/client/assets/{index-BZdOOa5E.js → index-BwrlMMHr.js} +66 -65
- package/client/assets/index-CvvNuegU.css +2 -0
- package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
- package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
- package/client/index.html +7 -6
- package/package.json +7 -7
- package/server/accounting/eventPublisher.ts +2 -1
- package/server/accounting/snapshotCache.ts +2 -1
- package/server/agent/backend/claude-code.ts +1 -0
- package/server/agent/backend/types.ts +3 -0
- package/server/agent/config.ts +25 -2
- package/server/agent/index.ts +6 -0
- package/server/agent/prompt.ts +37 -24
- package/server/api/routes/accounting.ts +31 -24
- package/server/api/routes/agent.ts +2 -2
- package/server/api/routes/config-refresh.ts +49 -0
- package/server/api/routes/config.ts +77 -67
- package/server/api/routes/files.ts +41 -17
- package/server/api/routes/hookLog.ts +95 -0
- package/server/api/routes/news.ts +39 -52
- package/server/api/routes/notifier.ts +14 -19
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/presentSvg.ts +107 -0
- package/server/api/routes/scheduler.ts +100 -98
- package/server/api/routes/schedulerTasks.ts +98 -95
- package/server/api/routes/sessions.ts +22 -27
- package/server/api/routes/sources.ts +45 -43
- package/server/api/routes/wiki/history.ts +6 -15
- package/server/api/routes/wiki.ts +73 -276
- package/server/events/file-change.ts +3 -2
- package/server/events/session-store/index.ts +2 -1
- package/server/index.ts +117 -8
- package/server/notifier/store.ts +3 -3
- package/server/plugins/preset-list.ts +16 -5
- package/server/plugins/runtime.ts +2 -2
- package/server/system/config.ts +44 -2
- package/server/utils/asyncHandler.ts +75 -0
- package/server/utils/files/accounting-io.ts +19 -20
- package/server/utils/files/journal-io.ts +2 -1
- package/server/utils/files/json.ts +8 -1
- package/server/utils/files/reference-dirs-io.ts +2 -3
- package/server/utils/files/scheduler-overrides-io.ts +2 -3
- package/server/utils/files/svg-store.ts +27 -0
- package/server/utils/files/user-tasks-io.ts +2 -3
- package/server/utils/regex.ts +3 -12
- package/server/utils/text.ts +29 -0
- package/server/workspace/chat-index/summarizer.ts +5 -3
- package/server/workspace/cooking-recipes/migrate.ts +125 -0
- package/server/workspace/custom-dirs.ts +2 -2
- package/server/workspace/hooks/dispatcher.mjs +300 -0
- package/server/workspace/hooks/dispatcher.ts +55 -0
- package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
- package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
- package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
- package/server/workspace/hooks/provision.ts +222 -0
- package/server/workspace/hooks/shared/sidecar.ts +124 -0
- package/server/workspace/hooks/shared/stdin.ts +60 -0
- package/server/workspace/hooks/shared/workspace.ts +13 -0
- package/server/workspace/journal/dailyPass.ts +1 -6
- package/server/workspace/memory/io.ts +1 -34
- package/server/workspace/memory/migrate.ts +2 -1
- package/server/workspace/memory/snapshot.ts +26 -0
- package/server/workspace/memory/topic-io.ts +1 -18
- package/server/workspace/paths.ts +10 -0
- package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
- package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
- package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
- package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
- package/server/workspace/skills-preset.ts +2 -1
- package/server/workspace/wiki-pages/io.ts +2 -1
- package/src/App.vue +51 -3
- package/src/components/ChatInput.vue +7 -8
- package/src/components/FileContentHeader.vue +1 -6
- package/src/components/FileDropOverlay.vue +18 -0
- package/src/components/RolesView.vue +14 -5
- package/src/components/SettingsMcpTab.vue +15 -10
- package/src/components/SettingsModal.vue +116 -130
- package/src/components/SettingsModelTab.vue +121 -0
- package/src/composables/useContentDisplay.ts +16 -0
- package/src/composables/useFileDropZone.ts +148 -0
- package/src/composables/useSkillsList.ts +2 -1
- package/src/config/apiRoutes.ts +22 -0
- package/src/config/roles.ts +78 -48
- package/src/config/toolNames.ts +4 -1
- package/src/lang/de.ts +36 -1
- package/src/lang/en.ts +36 -1
- package/src/lang/es.ts +36 -1
- package/src/lang/fr.ts +36 -1
- package/src/lang/ja.ts +36 -1
- package/src/lang/ko.ts +36 -1
- package/src/lang/pt-BR.ts +36 -1
- package/src/lang/zh.ts +36 -1
- package/src/lib/wiki-page/index-parse.ts +221 -0
- package/src/lib/wiki-page/link.ts +62 -0
- package/src/lib/wiki-page/lint.ts +105 -0
- package/src/lib/wiki-page/paths.ts +35 -0
- package/src/lib/wiki-page/slug.ts +28 -40
- package/src/main.ts +1 -0
- package/src/plugins/_generated/metas.ts +2 -0
- package/src/plugins/_generated/registrations.ts +2 -0
- package/src/plugins/_generated/server-bindings.ts +3 -0
- package/src/plugins/accounting/Preview.vue +3 -6
- package/src/plugins/accounting/View.vue +2 -1
- package/src/plugins/accounting/components/AccountsModal.vue +3 -2
- package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
- package/src/plugins/accounting/components/JournalList.vue +2 -1
- package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
- package/src/plugins/accounting/currencies.ts +13 -0
- package/src/plugins/manageRoles/View.vue +16 -5
- package/src/plugins/photoLocations/View.vue +4 -2
- package/src/plugins/presentSVG/Preview.vue +56 -0
- package/src/plugins/presentSVG/View.vue +465 -0
- package/src/plugins/presentSVG/definition.ts +29 -0
- package/src/plugins/presentSVG/index.ts +49 -0
- package/src/plugins/presentSVG/meta.ts +14 -0
- package/src/plugins/scheduler/View.vue +3 -7
- package/src/plugins/skill/View.vue +11 -13
- package/src/plugins/wiki/View.vue +1 -1
- package/src/plugins/wiki/helpers.ts +23 -5
- package/src/plugins/wiki/route.ts +12 -11
- package/src/tools/runtimeLoader.ts +75 -9
- package/src/utils/format/bytes.ts +41 -0
- package/src/utils/format/date.ts +14 -2
- package/src/utils/markdown/setup.ts +5 -0
- package/src/utils/markdown/workspaceLinkify.ts +73 -0
- package/client/assets/index-Bl3vqgA6.css +0 -2
- package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
- package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
- package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
- package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
- package/server/workspace/wiki-history/provision.ts +0 -181
- /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
- /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
- /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
- /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
|
@@ -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(
|
|
60
|
-
|
|
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
|
-
}
|
|
64
|
-
|
|
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(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
}
|
|
200
|
-
|
|
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(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
11
|
-
// `wiki-
|
|
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:
|
|
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
|
-
|
|
18
|
+
// All wiki-text helpers (slug rules, index parsing, lint rules)
|
|
19
|
+
// live in `src/lib/wiki-page/` as pure modules so the frontend can
|
|
20
|
+
// share them without pulling in server-only deps. This route is
|
|
21
|
+
// just the HTTP shell on top of those.
|
|
22
|
+
import { parseWikiLink } from "../../../src/lib/wiki-page/link.js";
|
|
23
|
+
import { wikiSlugify } from "../../../src/lib/wiki-page/slug.js";
|
|
24
|
+
import { type WikiPageEntry, parseIndexEntries } from "../../../src/lib/wiki-page/index-parse.js";
|
|
25
|
+
import { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift, formatLintReport } from "../../../src/lib/wiki-page/lint.js";
|
|
19
26
|
|
|
20
27
|
const router = Router();
|
|
21
28
|
|
|
@@ -27,222 +34,47 @@ function readFileOrEmpty(absPath: string): string {
|
|
|
27
34
|
return readTextSafeSync(absPath) ?? "";
|
|
28
35
|
}
|
|
29
36
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// isn't a-z / 0-9 / hyphen. Used for both index parsing and page
|
|
39
|
-
// lookup so the two stay consistent.
|
|
40
|
-
export function wikiSlugify(text: string): string {
|
|
41
|
-
return text
|
|
42
|
-
.toLowerCase()
|
|
43
|
-
.replace(/\s+/g, "-")
|
|
44
|
-
.replace(/[^a-z0-9-]/g, "");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const TABLE_SEPARATOR_PATTERN = /^\|[\s|:-]+\|$/;
|
|
48
|
-
// Bullet-link patterns (BULLET_LINK_PATTERN, BULLET_WIKI_LINK_PATTERN)
|
|
49
|
-
// live in `server/utils/regex.ts` alongside other server regex audit
|
|
50
|
-
// notes. Capture the href (group 2) alongside the title (group 1) so
|
|
51
|
-
// we can derive the slug from the file name instead of re-slugifying
|
|
52
|
-
// the title — important for non-ASCII titles like "さくらインターネット"
|
|
53
|
-
// where `wikiSlugify` returns "" and the slug would otherwise be lost.
|
|
54
|
-
// Unicode-aware tag body: any letter or number in any script
|
|
55
|
-
// (so Japanese / Chinese / Korean tags like `#クラウド` or `#可視化`
|
|
56
|
-
// work), plus `-` and `_` as internal joiners. First char is a
|
|
57
|
-
// letter or number only — no leading punctuation.
|
|
58
|
-
const HASHTAG_PATTERN = /(?:^|\s)#([\p{L}\p{N}][\p{L}\p{N}_-]*)/gu;
|
|
59
|
-
|
|
60
|
-
// Extract `#tag` tokens from a bullet description, returning the
|
|
61
|
-
// stripped description and a sorted, deduped, lowercased tag list.
|
|
62
|
-
// Only matches at word boundaries so mid-word `#` (e.g. anchor URLs)
|
|
63
|
-
// is left alone.
|
|
64
|
-
export function extractHashTags(text: string): { description: string; tags: string[] } {
|
|
65
|
-
const tags: string[] = [];
|
|
66
|
-
HASHTAG_PATTERN.lastIndex = 0;
|
|
67
|
-
let match: RegExpExecArray | null;
|
|
68
|
-
while ((match = HASHTAG_PATTERN.exec(text)) !== null) {
|
|
69
|
-
tags.push(match[1].toLowerCase());
|
|
70
|
-
}
|
|
71
|
-
const description = text.replace(HASHTAG_PATTERN, "").replace(/\s+/g, " ").trim();
|
|
72
|
-
const deduped = [...new Set(tags)].sort();
|
|
73
|
-
return { description, tags: deduped };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Split a table Tags cell — tolerates comma, whitespace, or `#`
|
|
77
|
-
// prefixes. Empty cell yields an empty list.
|
|
78
|
-
export function parseTagsCell(cell: string): string[] {
|
|
79
|
-
const tokens = cell
|
|
80
|
-
.split(/[,\s]+/)
|
|
81
|
-
.map((token) => token.trim().replace(/^#/, "").toLowerCase())
|
|
82
|
-
.filter((token) => token.length > 0);
|
|
83
|
-
return [...new Set(tokens)].sort();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Map header cell names → column indices, case- and whitespace-
|
|
87
|
-
// tolerant. Used by `parseTableRow` to locate the Tags column (and
|
|
88
|
-
// any other named column) without assuming a fixed position, so
|
|
89
|
-
// older 3- and 4-column tables keep working.
|
|
90
|
-
export function buildTableColumnMap(headerRow: string): Map<string, number> {
|
|
91
|
-
const cells = headerRow
|
|
92
|
-
.split("|")
|
|
93
|
-
.slice(1, -1)
|
|
94
|
-
// Mirror `parseTableRow`'s cell-normalising: strip the surrounding
|
|
95
|
-
// backticks that commonly wrap cell values in wiki tables. Without
|
|
96
|
-
// this, a `| \`tags\` |` header maps to the key "`tags`" and the
|
|
97
|
-
// subsequent `columnMap.get("tags")` lookup silently misses the
|
|
98
|
-
// column, falling back to `tags: []`.
|
|
99
|
-
.map((cell) => cell.trim().replace(/^`|`$/g, "").toLowerCase());
|
|
100
|
-
const map = new Map<string, number>();
|
|
101
|
-
cells.forEach((cell, i) => {
|
|
102
|
-
if (cell) map.set(cell, i);
|
|
103
|
-
});
|
|
104
|
-
return map;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
interface TableColumnIndices {
|
|
108
|
-
slug: number;
|
|
109
|
-
title: number;
|
|
110
|
-
summary: number;
|
|
111
|
-
/** Undefined when the table has no `tags` column — caller skips
|
|
112
|
-
* the tags lookup entirely and the row gets `tags: []`. */
|
|
113
|
-
tags: number | undefined;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Resolve the per-column indices the row parser needs. Falls back
|
|
117
|
-
* to positional defaults (0/1/2) when the table has no header map.
|
|
118
|
-
* "summary" is the canonical column name; "description" is accepted
|
|
119
|
-
* as a legacy alias used by older fixtures. */
|
|
120
|
-
function resolveTableColumnIndices(columnMap: Map<string, number> | null): TableColumnIndices {
|
|
121
|
-
return {
|
|
122
|
-
slug: columnMap?.get("slug") ?? 0,
|
|
123
|
-
title: columnMap?.get("title") ?? 1,
|
|
124
|
-
summary: columnMap?.get("summary") ?? columnMap?.get("description") ?? 2,
|
|
125
|
-
tags: columnMap?.get("tags"),
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Each parser returns the entry it produced (if any). The parent
|
|
130
|
-
// loop tries them in order; the first non-null result wins.
|
|
131
|
-
function parseTableRow(trimmed: string, columnMap: Map<string, number> | null): WikiPageEntry | null {
|
|
132
|
-
const cols = trimmed
|
|
133
|
-
.split("|")
|
|
134
|
-
.slice(1, -1)
|
|
135
|
-
.map((column) => column.trim().replace(/^`|`$/g, ""));
|
|
136
|
-
if (cols.length < 2) return null;
|
|
137
|
-
|
|
138
|
-
const idx = resolveTableColumnIndices(columnMap);
|
|
139
|
-
const slug = cols[idx.slug] ?? "";
|
|
140
|
-
const title = cols[idx.title] || slug;
|
|
141
|
-
if (!slug || !title) return null;
|
|
142
|
-
|
|
143
|
-
const description = cols[idx.summary] ?? "";
|
|
144
|
-
const tags = idx.tags !== undefined ? parseTagsCell(cols[idx.tags] ?? "") : [];
|
|
145
|
-
return { title, slug, description, tags };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Extract the slug segment from a bullet link's href. Accepts the
|
|
149
|
-
// canonical `pages/<slug>.md`, a bare `<slug>.md`, or just `<slug>`
|
|
150
|
-
// — the three forms produced by different historical writers of
|
|
151
|
-
// index.md. Returns "" for hrefs that don't look like a wiki page
|
|
152
|
-
// reference (e.g. `https://example.com`) so the caller can fall
|
|
153
|
-
// back to title-based slugification.
|
|
154
|
-
export function extractSlugFromBulletHref(rawHref: string): string {
|
|
155
|
-
const href = rawHref.trim();
|
|
156
|
-
if (!href) return "";
|
|
157
|
-
if (/^[a-z]+:\/\//i.test(href)) return "";
|
|
158
|
-
const lastSegment = href.split("/").pop() ?? href;
|
|
159
|
-
return lastSegment.replace(/\.md$/i, "");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
163
|
-
const match = BULLET_LINK_PATTERN.exec(trimmed);
|
|
164
|
-
if (!match) return null;
|
|
165
|
-
const title = match[1].trim();
|
|
166
|
-
const href = match[2] ?? "";
|
|
167
|
-
const raw = match[3]?.trim() ?? "";
|
|
168
|
-
const { description, tags } = extractHashTags(raw);
|
|
169
|
-
// Prefer the slug embedded in the href so non-ASCII titles keep
|
|
170
|
-
// a navigable slug. Fall back to slugifying the title only when
|
|
171
|
-
// the href has no recognisable slug (rare — usually means the
|
|
172
|
-
// author put an external URL here).
|
|
173
|
-
const slug = extractSlugFromBulletHref(href) || wikiSlugify(title);
|
|
174
|
-
return { title, slug, description, tags };
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {
|
|
178
|
-
const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);
|
|
179
|
-
if (!match) return null;
|
|
180
|
-
const title = match[1].trim();
|
|
181
|
-
const raw = match[2]?.trim() ?? "";
|
|
182
|
-
const { description, tags } = extractHashTags(raw);
|
|
183
|
-
return { title, slug: wikiSlugify(title), description, tags };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Parse entries from index.md — supports three formats:
|
|
187
|
-
// 1. Table: | `slug` | Title | Summary | Date |
|
|
188
|
-
// 2. Bullet link: - [Title](pages/slug.md) — description
|
|
189
|
-
// 3. Wiki link: - [[Title]] — description
|
|
190
|
-
export function parseIndexEntries(content: string): WikiPageEntry[] {
|
|
191
|
-
const entries: WikiPageEntry[] = [];
|
|
192
|
-
let inTable = false;
|
|
193
|
-
let columnMap: Map<string, number> | null = null;
|
|
194
|
-
|
|
195
|
-
for (const line of content.split("\n")) {
|
|
196
|
-
const trimmed = line.trim();
|
|
197
|
-
|
|
198
|
-
if (trimmed.startsWith("|")) {
|
|
199
|
-
if (TABLE_SEPARATOR_PATTERN.test(trimmed)) {
|
|
200
|
-
inTable = true;
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
if (!inTable) {
|
|
204
|
-
// First `|`-line before the separator is the header. Capture
|
|
205
|
-
// the column map so parseTableRow can locate the Tags
|
|
206
|
-
// column (if any) by name rather than position.
|
|
207
|
-
columnMap = buildTableColumnMap(trimmed);
|
|
208
|
-
inTable = true;
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
const entry = parseTableRow(trimmed, columnMap);
|
|
212
|
-
if (entry) entries.push(entry);
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
inTable = false;
|
|
217
|
-
columnMap = null;
|
|
218
|
-
|
|
219
|
-
const bullet = parseBulletLinkRow(trimmed) ?? parseBulletWikiLinkRow(trimmed);
|
|
220
|
-
if (bullet) entries.push(bullet);
|
|
221
|
-
}
|
|
222
|
-
return entries;
|
|
223
|
-
}
|
|
37
|
+
// Wiki-text helpers (slugify, index parsing, lint rules) now live
|
|
38
|
+
// under `src/lib/wiki-page/` as pure modules — see imports above.
|
|
39
|
+
// Re-exports kept here for legacy callers that import via this
|
|
40
|
+
// route module (e.g. tests in `test/routes/test_wikiHelpers.ts`).
|
|
41
|
+
export { wikiSlugify } from "../../../src/lib/wiki-page/slug.js";
|
|
42
|
+
export type { WikiPageEntry } from "../../../src/lib/wiki-page/index-parse.js";
|
|
43
|
+
export { buildTableColumnMap, extractHashTags, extractSlugFromBulletHref, parseIndexEntries, parseTagsCell } from "../../../src/lib/wiki-page/index-parse.js";
|
|
44
|
+
export { findBrokenLinksInPage, findMissingFiles, findOrphanPages, findTagDrift, formatLintReport } from "../../../src/lib/wiki-page/lint.js";
|
|
224
45
|
|
|
225
46
|
// Resolve a page name to an absolute `.md` path using the in-memory
|
|
226
47
|
// page index (see ./wiki/pageIndex.ts). Index is kept fresh via
|
|
227
48
|
// pagesDir mtime, so zero readdir cost on cache hit.
|
|
49
|
+
//
|
|
50
|
+
// `pageName` may carry the `[[target|display]]` form on legacy
|
|
51
|
+
// codepaths (e.g. an old chat-history entry that pre-dates the
|
|
52
|
+
// renderer fix). `parseWikiLink` strips the display half so the
|
|
53
|
+
// lookup uses just the target — same parser the renderer + lint
|
|
54
|
+
// agree on, which is the whole point of the #1297 refactor.
|
|
55
|
+
// Below this length the fuzzy `includes` step skips altogether.
|
|
56
|
+
// CJK / emoji-only / very-short page names get slugified down to a
|
|
57
|
+
// short noise tail (e.g. "日本語タイトル-chromium-{nonce}" →
|
|
58
|
+
// "-chromium-{nonce}"), and that noise will partial-match almost
|
|
59
|
+
// any page that happens to share the suffix. Skipping fuzzy for
|
|
60
|
+
// these slugs avoids silent miss-resolves; the index.md title-match
|
|
61
|
+
// fallback below still handles the legitimate non-ASCII case (#1194).
|
|
62
|
+
const MIN_FUZZY_SLUG_LEN = 6;
|
|
63
|
+
|
|
228
64
|
async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
229
65
|
const dir = pagesDir();
|
|
230
66
|
const { slugs } = await getPageIndex(dir);
|
|
231
67
|
if (slugs.size === 0) return null;
|
|
232
68
|
|
|
233
|
-
const
|
|
69
|
+
const { target } = parseWikiLink(pageName);
|
|
70
|
+
const slug = wikiSlugify(target);
|
|
234
71
|
|
|
235
72
|
if (slug.length > 0) {
|
|
236
73
|
const exact = slugs.get(slug);
|
|
237
74
|
if (exact) return path.join(dir, exact);
|
|
238
75
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
for (const [key, file] of slugs) {
|
|
242
|
-
if (slug.includes(key) || key.includes(slug)) {
|
|
243
|
-
return path.join(dir, file);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
76
|
+
const fuzzy = pickFuzzyMatch(slug, slugs);
|
|
77
|
+
if (fuzzy) return path.join(dir, fuzzy);
|
|
246
78
|
}
|
|
247
79
|
|
|
248
80
|
// Non-ASCII page names (e.g. Japanese [[wiki links]]) produce empty
|
|
@@ -250,7 +82,7 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
|
250
82
|
// wiki index so the link resolves to its page file.
|
|
251
83
|
const indexContent = readFileOrEmpty(indexFile());
|
|
252
84
|
const entries = parseIndexEntries(indexContent);
|
|
253
|
-
const titleMatch = entries.find((entry) => entry.title ===
|
|
85
|
+
const titleMatch = entries.find((entry) => entry.title === target);
|
|
254
86
|
if (titleMatch) {
|
|
255
87
|
const file = slugs.get(titleMatch.slug);
|
|
256
88
|
if (file) return path.join(dir, file);
|
|
@@ -259,6 +91,39 @@ async function resolvePagePath(pageName: string): Promise<string | null> {
|
|
|
259
91
|
return null;
|
|
260
92
|
}
|
|
261
93
|
|
|
94
|
+
// Walk every indexed slug looking for an `includes`-style match.
|
|
95
|
+
// Returns the single best candidate, or null when the slug is too
|
|
96
|
+
// short to be meaningful OR multiple candidates tie at the top score
|
|
97
|
+
// (ambiguous — leave the resolution to the caller's title-match
|
|
98
|
+
// fallback rather than silently picking iteration-order-first).
|
|
99
|
+
//
|
|
100
|
+
// Score = min(slug.length, key.length) / max(slug.length, key.length).
|
|
101
|
+
// A perfect match where both strings are identical scores 1.0 (but
|
|
102
|
+
// that path is taken by the exact `slugs.get` above, never reaches
|
|
103
|
+
// here). Otherwise the highest-scoring partial match wins, and the
|
|
104
|
+
// score is decoupled from Map iteration order (= filesystem readdir
|
|
105
|
+
// order) so the resolver is deterministic across hosts.
|
|
106
|
+
export function pickFuzzyMatch(slug: string, slugs: ReadonlyMap<string, string>): string | null {
|
|
107
|
+
if (slug.length < MIN_FUZZY_SLUG_LEN) return null;
|
|
108
|
+
let bestFile: string | null = null;
|
|
109
|
+
let bestScore = 0;
|
|
110
|
+
let bestIsTied = false;
|
|
111
|
+
for (const [key, file] of slugs) {
|
|
112
|
+
if (!slug.includes(key) && !key.includes(slug)) continue;
|
|
113
|
+
const shorter = Math.min(slug.length, key.length);
|
|
114
|
+
const longer = Math.max(slug.length, key.length);
|
|
115
|
+
const score = shorter / longer;
|
|
116
|
+
if (score > bestScore) {
|
|
117
|
+
bestScore = score;
|
|
118
|
+
bestFile = file;
|
|
119
|
+
bestIsTied = false;
|
|
120
|
+
} else if (score === bestScore) {
|
|
121
|
+
bestIsTied = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return bestIsTied ? null : bestFile;
|
|
125
|
+
}
|
|
126
|
+
|
|
262
127
|
router.get(API_ROUTES.wiki.base, async (req: Request, res: Response<WikiResponse | ErrorResponse>) => {
|
|
263
128
|
const slug = getOptionalStringQuery(req, "slug");
|
|
264
129
|
if (slug) {
|
|
@@ -410,78 +275,10 @@ function buildLogResponse(action: string): WikiResponse {
|
|
|
410
275
|
};
|
|
411
276
|
}
|
|
412
277
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// touching the filesystem.
|
|
418
|
-
|
|
419
|
-
export function findOrphanPages(fileSlugs: ReadonlySet<string>, indexedSlugs: ReadonlySet<string>): string[] {
|
|
420
|
-
const issues: string[] = [];
|
|
421
|
-
for (const slug of fileSlugs) {
|
|
422
|
-
if (!indexedSlugs.has(slug)) {
|
|
423
|
-
issues.push(`- **Orphan page**: \`${slug}.md\` exists but is missing from index.md`);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
return issues;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
export function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlugs: ReadonlySet<string>): string[] {
|
|
430
|
-
const issues: string[] = [];
|
|
431
|
-
for (const entry of pageEntries) {
|
|
432
|
-
if (!fileSlugs.has(entry.slug)) {
|
|
433
|
-
issues.push(`- **Missing file**: index.md references \`${entry.slug}\` but the file does not exist`);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
return issues;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
export function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {
|
|
440
|
-
const issues: string[] = [];
|
|
441
|
-
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((match) => match[1]);
|
|
442
|
-
for (const link of wikiLinks) {
|
|
443
|
-
const linkSlug = wikiSlugify(link);
|
|
444
|
-
if (!fileSlugs.has(linkSlug)) {
|
|
445
|
-
issues.push(`- **Broken link** in \`${fileName}\`: [[${link}]] → \`${linkSlug}.md\` not found`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
return issues;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function formatTagList(tags: readonly string[]): string {
|
|
452
|
-
return `[${[...tags].sort().join(", ")}]`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Flag any slug whose index.md tags differ from the page's own
|
|
456
|
-
// frontmatter `tags:` field. Comparison is set-based and order-
|
|
457
|
-
// insensitive; both sides are lowercased at parse time. Slugs
|
|
458
|
-
// missing from `frontmatterTagsBySlug` are ignored here — the
|
|
459
|
-
// missing file itself is already reported by `findMissingFiles`.
|
|
460
|
-
export function findTagDrift(pageEntries: readonly WikiPageEntry[], frontmatterTagsBySlug: ReadonlyMap<string, readonly string[]>): string[] {
|
|
461
|
-
const issues: string[] = [];
|
|
462
|
-
for (const entry of pageEntries) {
|
|
463
|
-
// Lowercase on lookup — `collectLintIssues` keys the map with
|
|
464
|
-
// lowercased slugs, so a `MyPage.md` filename still matches an
|
|
465
|
-
// `entry.slug` of `mypage` produced by `wikiSlugify` on the
|
|
466
|
-
// wiki-link parser path.
|
|
467
|
-
const pageTags = frontmatterTagsBySlug.get(entry.slug.toLowerCase());
|
|
468
|
-
if (pageTags === undefined) continue;
|
|
469
|
-
const pageSet = new Set(pageTags);
|
|
470
|
-
const indexSet = new Set(entry.tags);
|
|
471
|
-
if (pageSet.size !== indexSet.size || [...pageSet].some((tag) => !indexSet.has(tag))) {
|
|
472
|
-
issues.push(`- **Tag drift**: \`${entry.slug}.md\` frontmatter has ${formatTagList(pageTags)} but index.md has ${formatTagList(entry.tags)}`);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return issues;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
export function formatLintReport(issues: readonly string[]): string {
|
|
479
|
-
if (issues.length === 0) {
|
|
480
|
-
return "# Wiki Lint Report\n\n✓ No issues found. Wiki is healthy.";
|
|
481
|
-
}
|
|
482
|
-
const noun = `issue${issues.length !== 1 ? "s" : ""}`;
|
|
483
|
-
return `# Wiki Lint Report\n\n${issues.length} ${noun} found:\n\n${issues.join("\n")}`;
|
|
484
|
-
}
|
|
278
|
+
// Pure lint helpers (findOrphanPages, findMissingFiles,
|
|
279
|
+
// findBrokenLinksInPage, findTagDrift, formatLintReport) now live
|
|
280
|
+
// in `src/lib/wiki-page/lint.ts` and are re-exported above. The
|
|
281
|
+
// route below is the thin filesystem shell around them.
|
|
485
282
|
|
|
486
283
|
async function collectLintIssues(): Promise<string[]> {
|
|
487
284
|
const dir = pagesDir();
|
|
@@ -17,6 +17,7 @@ import { fileChannel, toPosixWorkspacePath, type FileChannelPayload } from "../.
|
|
|
17
17
|
import { workspacePath } from "../workspace/workspace.js";
|
|
18
18
|
import { maybeRegenerateTopicIndex, TOPIC_INDEX_RELATIVE_PATH } from "../workspace/memory/topic-index-hook.js";
|
|
19
19
|
import { log } from "../system/logger/index.js";
|
|
20
|
+
import { errorMessage } from "../utils/errors.js";
|
|
20
21
|
|
|
21
22
|
let pubsub: IPubSub | null = null;
|
|
22
23
|
|
|
@@ -42,7 +43,7 @@ export async function publishFileChange(relativePath: string): Promise<void> {
|
|
|
42
43
|
} catch (err) {
|
|
43
44
|
log.warn("file-change", "stat failed; falling back to Date.now()", {
|
|
44
45
|
pathPreview: relativePath,
|
|
45
|
-
error:
|
|
46
|
+
error: errorMessage(err),
|
|
46
47
|
});
|
|
47
48
|
mtimeMs = Date.now();
|
|
48
49
|
}
|
|
@@ -59,7 +60,7 @@ export async function publishFileChange(relativePath: string): Promise<void> {
|
|
|
59
60
|
} catch (err) {
|
|
60
61
|
log.warn("file-change", "publish failed; subscribers will miss this event", {
|
|
61
62
|
pathPreview: posixPath,
|
|
62
|
-
error:
|
|
63
|
+
error: errorMessage(err),
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
// Side-effect hook: keep the topic-format MEMORY.md index in sync
|
|
@@ -12,6 +12,7 @@ import { env } from "../../system/env.js";
|
|
|
12
12
|
import { updateHasUnread } from "../../utils/files/session-io.js";
|
|
13
13
|
import { EVENT_TYPES, GENERATION_KINDS, type GenerationKind, type PendingGeneration, generationKey } from "../../../src/types/events.js";
|
|
14
14
|
import { ONE_HOUR_MS, ONE_MINUTE_MS } from "../../utils/time.js";
|
|
15
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
15
16
|
|
|
16
17
|
// ── Types ──────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -394,7 +395,7 @@ function applyEventToSession(session: ServerSession, type: string, event: Record
|
|
|
394
395
|
enqueueJsonlAppend(session, buildToolCallLine(event)).catch((err: unknown) => {
|
|
395
396
|
log.warn("session-store", "persist tool_call failed (non-fatal)", {
|
|
396
397
|
chatSessionId: session.chatSessionId,
|
|
397
|
-
error:
|
|
398
|
+
error: errorMessage(err),
|
|
398
399
|
});
|
|
399
400
|
});
|
|
400
401
|
}
|