mulmoclaude 2.2.0 → 2.2.1
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/client/assets/JsonEditor-BC9ITErx.js +10 -0
- package/client/assets/{index-tZkbdenJ.js → index-168151sv.js} +6 -6
- package/client/assets/{marp-YZRvgIPH.js → marp-BvWwTP5d.js} +1 -1
- package/client/index.html +1 -1
- package/package.json +4 -4
- package/server/build/mcp-server.mjs +110 -110
- package/server/workspace/journal/archivist-schemas.ts +3 -2
- package/server/workspace/journal/htmlBlock.ts +41 -0
- package/server/workspace/journal/replaceIfUnchanged.ts +10 -0
- package/server/workspace/journal/sessionLinkRepair.ts +244 -0
- package/src/plugins/manageSkills/definition.ts +1 -1
- package/src/utils/path/workspaceLinkRouter.ts +5 -6
- package/client/assets/JsonEditor-zdl4EA3_.js +0 -10
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isRecord } from "../../utils/types.js";
|
|
2
|
+
import { WORKSPACE_DIRS } from "../paths.js";
|
|
2
3
|
|
|
3
4
|
export interface SessionEventExcerpt {
|
|
4
5
|
source: string; // "user" | "assistant" | "tool" | ...
|
|
@@ -80,8 +81,8 @@ ARTIFACT LINKS
|
|
|
80
81
|
- Only link to artifacts listed in "ARTIFACTS REFERENCED". Don't invent paths.
|
|
81
82
|
|
|
82
83
|
SESSION LINKS
|
|
83
|
-
- When your summary refers to a specific session (the ones listed under "SESSION EXCERPTS" with their \`session <id>\` header), link to that session using
|
|
84
|
-
- Example: "— discussed in [session 550e8400](
|
|
84
|
+
- When your summary refers to a specific session (the ones listed under "SESSION EXCERPTS" with their \`session <id>\` header), link to that session using \`/${WORKSPACE_DIRS.chat}/<sessionId>.jsonl\`.
|
|
85
|
+
- Example: "— discussed in [session 550e8400](/${WORKSPACE_DIRS.chat}/550e8400-e29b-41d4-a716-446655440000.jsonl)"
|
|
85
86
|
- The file viewer recognises this pattern and switches the sidebar chat to that session when the link is clicked, so the reader can pick up where the session left off.
|
|
86
87
|
- You do not have to link every session you mention, but linking at least the first reference per session is helpful.
|
|
87
88
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// CommonMark raw HTML blocks that continue across blank lines (spec types 1-5); other HTML blocks end at a blank line.
|
|
2
|
+
const RAW_TEXT_TAGS = ["script", "pre", "style", "textarea"];
|
|
3
|
+
const MAX_BLOCK_INDENT = 3;
|
|
4
|
+
const COMMENT_OPEN = "<!--";
|
|
5
|
+
const PROCESSING_OPEN = "<?";
|
|
6
|
+
const CDATA_OPEN = "<![CDATA[";
|
|
7
|
+
const DECLARATION_OPEN = "<!";
|
|
8
|
+
|
|
9
|
+
export interface HtmlBlockEnd {
|
|
10
|
+
// Lower-cased text whose appearance on a line ends the block.
|
|
11
|
+
terminator: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function startsRawTextTag(lowered: string): string | null {
|
|
15
|
+
const tag = RAW_TEXT_TAGS.find((name) => lowered.startsWith(`<${name}`));
|
|
16
|
+
if (tag === undefined) return null;
|
|
17
|
+
const after = lowered[tag.length + 1];
|
|
18
|
+
return after === undefined || after === ">" || after === " " || after === "\t" ? tag : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isAsciiLetter(char: string | undefined): boolean {
|
|
22
|
+
return char !== undefined && char.toLowerCase() !== char.toUpperCase() && char.charCodeAt(0) < 128;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The terminator when `line` opens a block that can span blank lines, otherwise null.
|
|
26
|
+
export function openHtmlBlock(line: string): HtmlBlockEnd | null {
|
|
27
|
+
const indent = line.length - line.trimStart().length;
|
|
28
|
+
if (indent > MAX_BLOCK_INDENT) return null;
|
|
29
|
+
const lowered = line.trimStart().toLowerCase();
|
|
30
|
+
const rawTextTag = startsRawTextTag(lowered);
|
|
31
|
+
if (rawTextTag !== null) return { terminator: `</${rawTextTag}>` };
|
|
32
|
+
if (lowered.startsWith(COMMENT_OPEN)) return { terminator: "-->" };
|
|
33
|
+
if (lowered.startsWith(PROCESSING_OPEN)) return { terminator: "?>" };
|
|
34
|
+
if (lowered.startsWith(CDATA_OPEN.toLowerCase())) return { terminator: "]]>" };
|
|
35
|
+
return lowered.startsWith(DECLARATION_OPEN) && isAsciiLetter(lowered[DECLARATION_OPEN.length]) ? { terminator: ">" } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// True when the terminator appears on `line`; `searchFrom` skips the opener on the line that opened the block.
|
|
39
|
+
export function endsHtmlBlock(line: string, end: HtmlBlockEnd, searchFrom: number): boolean {
|
|
40
|
+
return line.toLowerCase().indexOf(end.terminator, searchFrom) !== -1;
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import { writeFileAtomic } from "../../utils/files/atomic.js";
|
|
3
|
+
|
|
4
|
+
// Compares bytes, not decoded text: invalid UTF-8 and U+FFFD decode to the same string but are different files.
|
|
5
|
+
// Narrows, but cannot close, the window between this read and the rename; a concurrent writer can still win it.
|
|
6
|
+
export async function replaceIfUnchanged(filePath: string, expectedBytes: Buffer, content: string): Promise<boolean> {
|
|
7
|
+
if (!(await fsp.readFile(filePath)).equals(expectedBytes)) return false;
|
|
8
|
+
await writeFileAtomic(filePath, content);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { rewriteMarkdownLinks, splitFragmentAndQuery } from "../../utils/markdown.js";
|
|
3
|
+
import { WORKSPACE_DIRS } from "../paths.js";
|
|
4
|
+
import { endsHtmlBlock, openHtmlBlock, type HtmlBlockEnd } from "./htmlBlock.js";
|
|
5
|
+
|
|
6
|
+
const JSONL_SUFFIX = ".jsonl";
|
|
7
|
+
// Where summaries written before the prompt fix resolved their session links to.
|
|
8
|
+
const MISCOUNTED_CHAT_DIR = "chat";
|
|
9
|
+
|
|
10
|
+
export interface SessionLinkRepairResult {
|
|
11
|
+
content: string;
|
|
12
|
+
repairedCount: number;
|
|
13
|
+
// Broken links left as written because their paragraph holds raw HTML or starts indented.
|
|
14
|
+
skippedCount: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Session id when `resolvedPath` is exactly `chat/<id>.jsonl` at the workspace root.
|
|
18
|
+
export function extractMiscountedSessionId(resolvedPath: string): string | null {
|
|
19
|
+
const prefix = `${MISCOUNTED_CHAT_DIR}/`;
|
|
20
|
+
if (!resolvedPath.startsWith(prefix) || !resolvedPath.endsWith(JSONL_SUFFIX)) return null;
|
|
21
|
+
const sessionId = resolvedPath.slice(prefix.length, resolvedPath.length - JSONL_SUFFIX.length);
|
|
22
|
+
return sessionId.length === 0 || sessionId.includes("/") ? null : sessionId;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function repairHref(href: string, currentDir: string, sessionExists: (sessionId: string) => boolean): string | null {
|
|
26
|
+
const { pathPart, suffix } = splitFragmentAndQuery(href);
|
|
27
|
+
if (pathPart.startsWith("/") || pathPart.includes("://")) return null;
|
|
28
|
+
const sessionId = extractMiscountedSessionId(path.posix.join(currentDir, pathPart));
|
|
29
|
+
if (sessionId === null || !sessionExists(sessionId)) return null;
|
|
30
|
+
const target = `${WORKSPACE_DIRS.chat}/${sessionId}${JSONL_SUFFIX}`;
|
|
31
|
+
return `${path.posix.relative(currentDir, target)}${suffix}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const BACKTICK = "`";
|
|
35
|
+
const HTML_OPEN = "<";
|
|
36
|
+
const FENCE_CHARS = ["`", "~"];
|
|
37
|
+
const MIN_FENCE_LENGTH = 3;
|
|
38
|
+
const MAX_FENCE_INDENT = 3;
|
|
39
|
+
const INDENTED_CODE_SPACES = " ";
|
|
40
|
+
const TAB = "\t";
|
|
41
|
+
const DIGIT_ZERO = 48;
|
|
42
|
+
const DIGIT_NINE = 57;
|
|
43
|
+
|
|
44
|
+
interface FenceLine {
|
|
45
|
+
char: string;
|
|
46
|
+
length: number;
|
|
47
|
+
rest: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// CommonMark fence line: up to three spaces of indent, then a run of at least three backticks or tildes.
|
|
51
|
+
function parseFenceLine(line: string): FenceLine | null {
|
|
52
|
+
const indent = line.length - line.trimStart().length;
|
|
53
|
+
const body = line.slice(indent);
|
|
54
|
+
const [char] = body;
|
|
55
|
+
if (indent > MAX_FENCE_INDENT || char === undefined || !FENCE_CHARS.includes(char)) return null;
|
|
56
|
+
let length = 0;
|
|
57
|
+
while (body[length] === char) length += 1;
|
|
58
|
+
return length < MIN_FENCE_LENGTH ? null : { char, length, rest: body.slice(length) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function opensFence(line: string): FenceLine | null {
|
|
62
|
+
const fence = parseFenceLine(line);
|
|
63
|
+
// A backtick fence's info string may not contain a backtick, otherwise the line is inline code.
|
|
64
|
+
return fence !== null && fence.char === BACKTICK && fence.rest.includes(BACKTICK) ? null : fence;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function closesFence(line: string, open: FenceLine): boolean {
|
|
68
|
+
const fence = parseFenceLine(line);
|
|
69
|
+
return fence !== null && fence.char === open.char && fence.length >= open.length && fence.rest.trim() === "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Splits text (possibly several lines, never crossing a blank text) into prose and inline-code segments; an unmatched backtick run is plain prose.
|
|
73
|
+
function splitInlineCode(text: string): { text: string; isCode: boolean }[] {
|
|
74
|
+
const segments: { text: string; isCode: boolean }[] = [];
|
|
75
|
+
let proseStart = 0;
|
|
76
|
+
let index = 0;
|
|
77
|
+
while (index < text.length) {
|
|
78
|
+
if (text[index] !== BACKTICK) {
|
|
79
|
+
index += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const runLength = backtickRunLength(text, index);
|
|
83
|
+
const closeIndex = findClosingRun(text, index + runLength, runLength);
|
|
84
|
+
if (closeIndex === -1) {
|
|
85
|
+
index += runLength;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (index > proseStart) segments.push({ text: text.slice(proseStart, index), isCode: false });
|
|
89
|
+
segments.push({ text: text.slice(index, closeIndex + runLength), isCode: true });
|
|
90
|
+
proseStart = closeIndex + runLength;
|
|
91
|
+
index = proseStart;
|
|
92
|
+
}
|
|
93
|
+
if (proseStart < text.length) segments.push({ text: text.slice(proseStart), isCode: false });
|
|
94
|
+
return segments;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function backtickRunLength(text: string, from: number): number {
|
|
98
|
+
let end = from;
|
|
99
|
+
while (text[end] === BACKTICK) end += 1;
|
|
100
|
+
return end - from;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findClosingRun(text: string, from: number, runLength: number): number {
|
|
104
|
+
let index = from;
|
|
105
|
+
while (index < text.length) {
|
|
106
|
+
if (text[index] !== BACKTICK) {
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const length = backtickRunLength(text, index);
|
|
111
|
+
if (length === runLength) return index;
|
|
112
|
+
index += length;
|
|
113
|
+
}
|
|
114
|
+
return -1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const QUOTE_MARKER = ">";
|
|
118
|
+
const LIST_BULLETS = ["-", "*", "+"];
|
|
119
|
+
|
|
120
|
+
function listMarkerLength(text: string): number {
|
|
121
|
+
const [first] = text;
|
|
122
|
+
if (first !== undefined && LIST_BULLETS.includes(first)) return text[1] === " " || text[1] === TAB ? 2 : 0;
|
|
123
|
+
let digits = 0;
|
|
124
|
+
while (digits < text.length && text.charCodeAt(digits) >= DIGIT_ZERO && text.charCodeAt(digits) <= DIGIT_NINE) digits += 1;
|
|
125
|
+
const after = text[digits];
|
|
126
|
+
return digits > 0 && (after === "." || after === ")") && (text[digits + 1] === " " || text[digits + 1] === TAB) ? digits + 2 : 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The line with leading blockquote and list-item markers removed, so a fence or HTML block wrapped in a container is still recognised.
|
|
130
|
+
function stripContainers(line: string): string {
|
|
131
|
+
let rest = line.trimStart();
|
|
132
|
+
for (;;) {
|
|
133
|
+
const markerLength = rest.startsWith(QUOTE_MARKER) ? 1 : listMarkerLength(rest);
|
|
134
|
+
if (markerLength === 0) return rest;
|
|
135
|
+
rest = rest.slice(markerLength).trimStart();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The line with blockquote markers (and their one optional space) removed, keeping the indent that follows.
|
|
140
|
+
function stripQuoteMarkers(line: string): string {
|
|
141
|
+
let rest = line;
|
|
142
|
+
while (rest.trimStart().startsWith(QUOTE_MARKER) && line.length - rest.length <= MAX_FENCE_INDENT) {
|
|
143
|
+
rest = rest.trimStart().slice(1);
|
|
144
|
+
if (rest.startsWith(" ")) rest = rest.slice(1);
|
|
145
|
+
}
|
|
146
|
+
return rest;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function startsIndentedCode(line: string): boolean {
|
|
150
|
+
const rest = stripQuoteMarkers(line);
|
|
151
|
+
return rest.startsWith(INDENTED_CODE_SPACES) || rest.startsWith(TAB);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type BlockKind = "prose" | "fence" | "html" | "blank";
|
|
155
|
+
|
|
156
|
+
interface Block {
|
|
157
|
+
kind: BlockKind;
|
|
158
|
+
text: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type RawRegion = { kind: "fence"; fence: FenceLine } | { kind: "html"; end: HtmlBlockEnd };
|
|
162
|
+
|
|
163
|
+
// The region `line` opens (fenced code, or a raw HTML block that can span blank lines), and whether it already closes on that line.
|
|
164
|
+
function openRegion(rawLine: string): { region: RawRegion; closesHere: boolean } | null {
|
|
165
|
+
const line = stripContainers(rawLine);
|
|
166
|
+
const fence = opensFence(line);
|
|
167
|
+
if (fence !== null) return { region: { kind: "fence", fence }, closesHere: false };
|
|
168
|
+
const end = openHtmlBlock(line);
|
|
169
|
+
if (end === null) return null;
|
|
170
|
+
return { region: { kind: "html", end }, closesHere: endsHtmlBlock(line, end, line.toLowerCase().indexOf("<") + 1) };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function closesRegion(rawLine: string, region: RawRegion): boolean {
|
|
174
|
+
const line = stripContainers(rawLine);
|
|
175
|
+
return region.kind === "fence" ? closesFence(line, region.fence) : endsHtmlBlock(line, region.end, 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Splits into raw regions (fenced code, raw HTML blocks), blank lines and paragraphs (runs of non-blank lines).
|
|
179
|
+
function splitBlocks(content: string): Block[] {
|
|
180
|
+
const blocks: Block[] = [];
|
|
181
|
+
let paragraph: string[] = [];
|
|
182
|
+
let region: RawRegion | null = null;
|
|
183
|
+
const flush = (): void => {
|
|
184
|
+
if (paragraph.length > 0) blocks.push({ kind: "prose", text: paragraph.join("\n") });
|
|
185
|
+
paragraph = [];
|
|
186
|
+
};
|
|
187
|
+
content.split("\n").forEach((line) => {
|
|
188
|
+
if (region !== null) {
|
|
189
|
+
blocks.push({ kind: region.kind, text: line });
|
|
190
|
+
if (closesRegion(line, region)) region = null;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const opened = openRegion(line);
|
|
194
|
+
if (opened !== null) {
|
|
195
|
+
flush();
|
|
196
|
+
blocks.push({ kind: opened.region.kind, text: line });
|
|
197
|
+
region = opened.closesHere ? null : opened.region;
|
|
198
|
+
} else if (line.trim() === "") {
|
|
199
|
+
flush();
|
|
200
|
+
blocks.push({ kind: "blank", text: line });
|
|
201
|
+
} else paragraph.push(line);
|
|
202
|
+
});
|
|
203
|
+
flush();
|
|
204
|
+
return blocks;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A paragraph is edited only when it holds no construct this module does not parse: raw HTML, or an indented-code start.
|
|
208
|
+
// Code spans are parsed (they may cross lines, never a blank line); anything not parsed is left alone rather than guessed at.
|
|
209
|
+
function isEditableParagraph(text: string): boolean {
|
|
210
|
+
return !text.includes(HTML_OPEN) && !text.split("\n").some(startsIndentedCode);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Points links that resolve to `<workspace>/chat/<id>.jsonl` at the real `conversations/chat/<id>.jsonl`.
|
|
214
|
+
// A link is left alone when the session file exists in neither place, so links to deleted sessions are not touched.
|
|
215
|
+
// Rewrites link-shaped `[text](href)` destinations in editable prose (the scanner does not tell images, escaped or nested brackets apart). Fenced code, inline code spans (also across lines), paragraphs with raw HTML or an
|
|
216
|
+
// indented-code start, titled links (`[t](href "title")`), reference-style and angle-bracket links are left exactly as written.
|
|
217
|
+
export function repairSessionLinks(fileWsPath: string, content: string, sessionExists: (sessionId: string) => boolean): SessionLinkRepairResult {
|
|
218
|
+
const currentDir = path.posix.dirname(fileWsPath);
|
|
219
|
+
let repairedCount = 0;
|
|
220
|
+
let skippedCount = 0;
|
|
221
|
+
const repairLinks = (prose: string): string =>
|
|
222
|
+
rewriteMarkdownLinks(prose, (href) => {
|
|
223
|
+
const fixed = repairHref(href, currentDir, sessionExists);
|
|
224
|
+
if (fixed === null) return href;
|
|
225
|
+
repairedCount += 1;
|
|
226
|
+
return fixed;
|
|
227
|
+
});
|
|
228
|
+
const repairProse = (paragraph: string): string =>
|
|
229
|
+
splitInlineCode(paragraph)
|
|
230
|
+
.map((segment) => (segment.isCode ? segment.text : repairLinks(segment.text)))
|
|
231
|
+
.join("");
|
|
232
|
+
const countBroken = (prose: string): void => {
|
|
233
|
+
rewriteMarkdownLinks(prose, (href) => {
|
|
234
|
+
if (repairHref(href, currentDir, sessionExists) !== null) skippedCount += 1;
|
|
235
|
+
return href;
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
const repaired = splitBlocks(content).map((block) => {
|
|
239
|
+
if (block.kind === "prose" && isEditableParagraph(block.text)) return repairProse(block.text);
|
|
240
|
+
if (block.kind === "prose" || block.kind === "html") countBroken(block.text);
|
|
241
|
+
return block.text;
|
|
242
|
+
});
|
|
243
|
+
return { content: repaired.join("\n"), repairedCount, skippedCount };
|
|
244
|
+
}
|
|
@@ -14,7 +14,7 @@ const toolDefinition: ToolDefinition = {
|
|
|
14
14
|
`Use the ${TOOL_NAME} tool for the user's skill library:\n\n` +
|
|
15
15
|
`- **list** (default, no args): show every available skill in the canvas.\n` +
|
|
16
16
|
`- **save**: when the user asks to turn the current conversation into a skill (e.g. "skill 化して" / "save this as a skill"), do these steps:\n` +
|
|
17
|
-
` 1. Read the current chat transcript via the Read tool. The path is \`chat/<session-id>.jsonl\` under the workspace; if you don't know the session id, list \`chat/\` first.\n` +
|
|
17
|
+
` 1. Read the current chat transcript via the Read tool. The path is \`conversations/chat/<session-id>.jsonl\` under the workspace; if you don't know the session id, list \`conversations/chat/\` first.\n` +
|
|
18
18
|
` 2. Distill the conversation into a short markdown body explaining the steps you took, in second person ("First, do X. Then, do Y."). Keep it focused on the reusable workflow, not the one-off details.\n` +
|
|
19
19
|
` 3. Pick a kebab-case slug (lowercase letters, digits, hyphens; no leading/trailing hyphen; 1-64 chars). If the user gave a name in the request, use it.\n` +
|
|
20
20
|
` 4. Write a one-line description for the YAML frontmatter.\n` +
|
|
@@ -90,12 +90,11 @@ export function classifyWorkspacePath(href: string): WorkspaceLinkTarget | null
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// Chat session log: conversations/chat/<id>.jsonl
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
93
|
+
// Summaries written before the prompt used `conversations/chat/` carry `chat/<id>.jsonl`, which only exists on legacy layouts, so accept both.
|
|
94
|
+
const chatPath = normalized.startsWith(CHAT_LOG_PREFIX) ? normalized.slice(CHAT_LOG_PREFIX.length) : normalized;
|
|
95
|
+
const sessionId = extractSessionIdFromPath(chatPath);
|
|
96
|
+
if (sessionId) {
|
|
97
|
+
return { kind: "session", sessionId };
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
// Top-level SPA route: leading segment names one of the host's
|