mulmoclaude 2.1.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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client/assets/JsonEditor-BC9ITErx.js +10 -0
  3. package/client/assets/{index-k-aOQrSm.js → index-168151sv.js} +368 -368
  4. package/client/assets/{marp-12IgaShm.js → marp-BvWwTP5d.js} +1 -1
  5. package/client/index.html +1 -1
  6. package/package.json +5 -5
  7. package/server/agent/activeTools.ts +9 -5
  8. package/server/agent/backend/claude-code.ts +3 -0
  9. package/server/agent/backend/types.ts +3 -0
  10. package/server/agent/config.ts +11 -1
  11. package/server/agent/dispatchableMcpTool.ts +19 -0
  12. package/server/agent/index.ts +2 -1
  13. package/server/agent/mcp-server.ts +7 -5
  14. package/server/agent/skillListing.ts +34 -0
  15. package/server/agent/skillListingState.ts +40 -0
  16. package/server/api/routes/config.ts +5 -0
  17. package/server/api/routes/roles.ts +1 -0
  18. package/server/build/mcp-server.mjs +491 -491
  19. package/server/prompts/system/memory-management-atomic.md +1 -27
  20. package/server/prompts/system/memory-management-topic.md +4 -53
  21. package/server/prompts/system/system.md +2 -5
  22. package/server/system/config.ts +25 -3
  23. package/server/workspace/journal/archivist-schemas.ts +3 -2
  24. package/server/workspace/journal/htmlBlock.ts +41 -0
  25. package/server/workspace/journal/replaceIfUnchanged.ts +10 -0
  26. package/server/workspace/journal/sessionLinkRepair.ts +244 -0
  27. package/src/components/SettingsModelTab.vue +58 -1
  28. package/src/config/roles.ts +6 -1
  29. package/src/lang/de.ts +6 -0
  30. package/src/lang/en.ts +6 -0
  31. package/src/lang/es.ts +6 -0
  32. package/src/lang/fr.ts +6 -0
  33. package/src/lang/ja.ts +6 -0
  34. package/src/lang/ko.ts +6 -0
  35. package/src/lang/pt-BR.ts +6 -0
  36. package/src/lang/zh.ts +6 -0
  37. package/src/plugins/manageRoles/definition.ts +6 -0
  38. package/src/plugins/manageRoles/index.ts +3 -0
  39. package/src/plugins/manageRoles/roleForm.ts +8 -2
  40. package/src/plugins/manageSkills/definition.ts +1 -1
  41. package/src/utils/path/workspaceLinkRouter.ts +5 -6
  42. package/client/assets/JsonEditor-zdl4EA3_.js +0 -10
  43. package/server/system/logs/aaa +0 -737
  44. package/server/system/logs/bb +0 -446
@@ -1,33 +1,7 @@
1
1
  ## Memory Management
2
2
 
3
- When you learn something from the conversation that would be useful to remember in future sessions, silently save it as a typed entry under `conversations/memory/`. Do not ask permission — just write it.
4
-
5
- Each entry is one markdown file with YAML frontmatter:
6
-
7
- ```yaml
8
- ---
9
- name: <one-line label>
10
- description: <short blurb shown in the index>
11
- type: <preference|interest|fact|reference>
12
- ---
13
- <optional longer body>
14
- ```
15
-
16
- Pick the type:
17
-
18
- - `preference` — durable habit, preference, or convention. Examples: "uses yarn (npm not allowed)", "prefers Emacs", "writes commits in English".
19
- - `interest` — a topic, hobby, or domain followed long-term. Examples: "AI research papers", "robotics", "Impressionist painting".
20
- - `fact` — a concrete personal fact that could become stale over time. Examples: "planning a trip to Egypt", "owns a toaster oven", "currently working on BootCamp project".
21
- - `reference` — pointer to an internal/external resource. Examples: "main repo at ~/ss/llm/mulmoclaude4", "weekly art-exhibitions-watch task".
22
-
23
- Filename convention: `<type>_<short-slug>.md` (lowercase ASCII, hyphenated). The frontmatter `type` is the source of truth — the filename is just for ergonomics. After writing the entry, also add a 1-line entry to `conversations/memory/MEMORY.md` of the form:
24
-
25
- ```
26
- - [<name>](<filename>) — <description>
27
- ```
3
+ When you learn something from the conversation that would be useful to remember in future sessions, silently save it as a typed entry under `conversations/memory/`. Do not ask permission — just write it. Each entry is one file, `<type>_<short-slug>.md`, with `name` / `description` / `type` frontmatter (type: `preference`, `interest`, `fact` or `reference`), plus a 1-line entry in `conversations/memory/MEMORY.md`. Before writing, `Read` `config/helps/memory-atomic.md` for the exact format, how to pick the type, and the index-line form.
28
4
 
29
5
  Write when: the fact is durable, not derivable from code or git history, and not already covered by an existing entry. Update an existing entry (and its index line) instead of creating a near-duplicate.
30
6
 
31
7
  Skip when: it is ephemeral task state, sensitive (credentials, `~/.ssh`, tokens), a duplicate, or something the user asked you to forget.
32
-
33
- Keep entries short — name + description + a few lines of body at most. Bias toward fewer high-signal entries rather than exhaustive logging.
@@ -1,60 +1,11 @@
1
1
  ## Memory Management
2
2
 
3
- When you learn something from the conversation that would be useful to remember in future sessions, silently save it under `conversations/memory/`. Do not ask permission — just write it.
4
-
5
- Memory is organised by **topic file**. Each file lives at `conversations/memory/<type>/<topic>.md` and groups related bullets under H2 sections. The system prompt's Memory section above shows the existing topics — pick from that list when adding a new bullet, and only create a new topic when nothing fits.
6
-
7
- ### Using memory proactively
8
-
9
- Before answering, scan the Memory section above for topics related to the user's current message. The H2 tags after each `<type>/<topic>.md` line are searchable hints — match against the user's words (e.g. art / music / travel / tooling). When a topic looks relevant, `Read` the file first and weave the relevant bullets naturally into your answer. Examples:
10
-
11
- - The user mentions a trip → check `fact/travel.md` (and any related interest topic) before suggesting destinations.
12
- - The user asks about a tool / language → check `preference/dev.md` so you don't suggest something they've already vetoed.
13
- - The user picks up a long-running project → check the matching `fact` or `reference` topic for prior context.
14
-
15
- Do NOT announce that you are using memory ("according to your memory…"). The recall is for grounding your answer, not for narration. If nothing in memory is relevant, just answer normally.
16
-
17
- Each topic file is one markdown document:
18
-
19
- ```yaml
20
- ---
21
- type: <preference|interest|fact|reference>
22
- topic: <slug>
23
- ---
24
-
25
- # <Topic Name>
26
-
27
- ## <H2 Section>
28
- - bullet
29
- - another bullet
30
-
31
- ## <Another H2>
32
- - bullet
33
- ```
34
-
35
- Pick the type:
36
-
37
- - `preference` — durable habit, preference, or convention. Examples: yarn over npm, prefers Emacs, writes commits in English.
38
- - `interest` — a topic, hobby, or domain followed long-term. Examples: AI research papers, robotics, Impressionist painting.
39
- - `fact` — a concrete personal fact that could become stale over time. Examples: planning a trip to Egypt, owns a toaster oven, currently working on BootCamp project.
40
- - `reference` — pointer to an internal/external resource. Examples: main repo path, weekly art-exhibitions-watch task.
41
-
42
- Adding a new bullet:
43
-
44
- 1. Read the Memory section above. Find the topic file whose subject covers the new bullet.
45
- 2. `Read` that topic file. Pick the H2 section the bullet fits under (or add a new H2 if none fits — H2 sections are optional, you may also append directly under H1 for a small / new topic).
46
- 3. Append your bullet. Keep it short, one line ideally.
47
- 4. `Write` the file back.
48
- 5. `MEMORY.md` is rebuilt during clustering and on explicit `regenerateTopicIndex` calls; individual topic-file writes do NOT update the index immediately. If your bullet adds a new H2 that should appear in the index right away, also `Write` an updated `MEMORY.md` line for that topic.
49
-
50
- Creating a new topic file:
51
-
52
- - Filename: `<type>/<topic>.md` where `<topic>` is a short lowercase ASCII slug (a-z, 0-9, hyphenated). Examples: `interest/music.md`, `fact/travel.md`, `reference/tasks.md`.
53
- - Body: H1 with a humanised topic name + bullet(s) under it. H2 sections are optional and best added once the topic has enough material to warrant grouping.
54
- - After the topic file is written, also `Write` a matching line into `conversations/memory/MEMORY.md` so the new topic is discoverable in the next turn's Memory context. Same caveat as adding an H2: individual topic-file writes do NOT update `MEMORY.md` automatically — the index is only rebuilt during clustering or on explicit `regenerateTopicIndex` calls.
3
+ When you learn something from the conversation that would be useful to remember in future sessions, silently save it under `conversations/memory/`. Do not ask permission — just write it. Memory is organised as topic files at `conversations/memory/<type>/<topic>.md` (type: `preference`, `interest`, `fact` or `reference`); add to an existing topic from the Memory section and create a new one only when nothing fits. Before writing, `Read` `config/helps/memory-topic.md` for the file format, how to pick the type, and the steps — a new topic or H2 also needs a `conversations/memory/MEMORY.md` line.
55
4
 
56
5
  Write when: the fact is durable, not derivable from code or git history, and not already covered by an existing bullet. Update an existing bullet instead of adding a near-duplicate.
57
6
 
58
7
  Skip when: it is ephemeral task state, sensitive (credentials, `~/.ssh`, tokens), a duplicate, or something the user asked you to forget.
59
8
 
60
- Keep entries short — bias toward fewer high-signal bullets rather than exhaustive logging.
9
+ ### Using memory proactively
10
+
11
+ Before answering, scan the Memory section above for topics related to the user's current message — the H2 tags after each `<type>/<topic>.md` line are searchable hints. When a topic looks relevant, `Read` the file first and weave the relevant bullets naturally into your answer. Do NOT announce that you are using memory ("according to your memory…"). If nothing in memory is relevant, just answer normally.
@@ -55,12 +55,9 @@ When you finish creating, updating, or surfacing a file in your reply (PDF, Mark
55
55
 
56
56
  Before scheduling a skill / task (`schedule:` in SKILL.md frontmatter — `daily HH:MM` is **UTC**) or changing a system task's frequency, read `config/helps/scheduling.md` for the syntax, recommended intervals, and the overrides API.
57
57
 
58
- ## Collection custom views — two incompatible contracts
58
+ ## Collection custom views
59
59
 
60
- A collection custom view is an HTML file under the skill's `views/`, registered in `schema.json` `views[]`. BEFORE authoring or editing one, read the help for the right contract — they are incompatible:
61
-
62
- - **Desktop** (default): `config/helps/custom-view.md` — the view fetches records itself via the injected token + `dataUrl`.
63
- - **Phone remote app** — when the user wants a view for the remote / mobile / phone app (リモート / スマホ / モバイル): `config/helps/custom-view-remote.md`. Register it with `target: "mobile"`; records arrive via `await __MC_VIEW.getItems(...)` over a postMessage bridge, and `fetch` is blocked entirely. Do NOT satisfy such a request by baking records into a standalone HTML artifact — author a `target: "mobile"` view (it auto-previews on the desktop in a phone-sized frame).
60
+ Before authoring or editing a collection custom view (an HTML file under the skill's `views/`, registered in `schema.json` `views[]`), read its help — the two contracts are incompatible: `config/helps/custom-view.md` (desktop, the default) or `config/helps/custom-view-remote.md` when the user wants it for the phone / remote / mobile app (リモート / スマホ / モバイル). For the phone, author a `target: "mobile"` view — never bake records into a standalone HTML artifact instead.
64
61
 
65
62
  ## When a tool call fails, or the user says something is broken
66
63
 
@@ -117,6 +117,13 @@ export interface AppSettings {
117
117
  // still wins, but an icon launch (#2613) passes neither, so for those
118
118
  // users this setting is the only way to turn the sink off (#2617).
119
119
  macosRemindersEnabled?: boolean;
120
+
121
+ // Whether the agent CLI loads Claude Code's USER-level settings (#3406):
122
+ // `~/.claude` skills, installed plugins and their hooks. On by default, so
123
+ // skills shared with Claude Code just work; off passes
124
+ // `--setting-sources project,local`, for a user whose plugins carry so many
125
+ // skills that their listing dominates every request.
126
+ loadClaudeUserSettings?: boolean;
120
127
  }
121
128
 
122
129
  const DEFAULT_SETTINGS: AppSettings = { extraAllowedTools: [] };
@@ -137,6 +144,7 @@ export const APP_SETTINGS_KEYS = [
137
144
  "journal",
138
145
  "pushEnabled",
139
146
  "macosRemindersEnabled",
147
+ "loadClaudeUserSettings",
140
148
  ] as const satisfies readonly (keyof AppSettings)[];
141
149
 
142
150
  export type AppSettingsKey = (typeof APP_SETTINGS_KEYS)[number];
@@ -155,6 +163,7 @@ export const SAFE_SETTINGS_KEYS = [
155
163
  "journal",
156
164
  "pushEnabled",
157
165
  "macosRemindersEnabled",
166
+ "loadClaudeUserSettings",
158
167
  ] as const satisfies readonly AppSettingsKey[];
159
168
 
160
169
  export type SafeSettingsKey = (typeof SAFE_SETTINGS_KEYS)[number];
@@ -236,6 +245,7 @@ const OPTIONAL_SETTING_VALIDATORS: Record<OptionalAppSettingsKey, (value: unknow
236
245
  journal: optional(isJournalMode),
237
246
  pushEnabled: isOptionalBoolean,
238
247
  macosRemindersEnabled: isOptionalBoolean,
248
+ loadClaudeUserSettings: isOptionalBoolean,
239
249
  };
240
250
 
241
251
  function hasValidOptionalAppSettings(value: Record<string, unknown>): boolean {
@@ -301,6 +311,8 @@ const isOptionalNullableChatModel = (value: unknown): boolean => value === undef
301
311
  const isOptionalNullableChatIndexMode = (value: unknown): boolean => value === undefined || value === null || isChatIndexMode(value);
302
312
  const isOptionalNullableJournalMode = (value: unknown): boolean => value === undefined || value === null || isJournalMode(value);
303
313
 
314
+ const PATCH_BOOLEAN_KEYS = ["pushEnabled", "macosRemindersEnabled", "loadClaudeUserSettings"] as const satisfies readonly AppSettingsKey[];
315
+
304
316
  export function isAppSettingsPatch(value: unknown): value is AppSettingsPatch {
305
317
  if (!isRecord(value)) return false;
306
318
  if (value.extraAllowedTools !== undefined && !isStringArray(value.extraAllowedTools)) return false;
@@ -311,9 +323,7 @@ export function isAppSettingsPatch(value: unknown): value is AppSettingsPatch {
311
323
  if (value.voiceInput !== undefined && !isVoiceInputSettings(value.voiceInput)) return false;
312
324
  if (!isOptionalNullableChatIndexMode(value.chatIndex)) return false;
313
325
  if (!isOptionalNullableJournalMode(value.journal)) return false;
314
- if (!isOptionalBoolean(value.pushEnabled)) return false;
315
- if (!isOptionalBoolean(value.macosRemindersEnabled)) return false;
316
- return true;
326
+ return PATCH_BOOLEAN_KEYS.every((key) => isOptionalBoolean(value[key]));
317
327
  }
318
328
 
319
329
  function parseSettingsRaw(raw: string, file: string): unknown {
@@ -361,6 +371,9 @@ function cloneAppSettings(settings: AppSettings): AppSettings {
361
371
  if (settings.macosRemindersEnabled !== undefined) {
362
372
  copy.macosRemindersEnabled = settings.macosRemindersEnabled;
363
373
  }
374
+ if (settings.loadClaudeUserSettings !== undefined) {
375
+ copy.loadClaudeUserSettings = settings.loadClaudeUserSettings;
376
+ }
364
377
  return copy;
365
378
  }
366
379
 
@@ -401,6 +414,12 @@ export function isMacosRemindersEnabled(settings: AppSettings): boolean {
401
414
  return settings.macosRemindersEnabled ?? true;
402
415
  }
403
416
 
417
+ /** Whether the agent CLI loads Claude Code's user-level settings, default
418
+ * `true` — MulmoClaude has always read `~/.claude` skills and plugins. */
419
+ export function isClaudeUserSettingsEnabled(settings: AppSettings): boolean {
420
+ return settings.loadClaudeUserSettings ?? true;
421
+ }
422
+
404
423
  export function loadSettings(): AppSettings {
405
424
  const file = settingsPath();
406
425
  const raw = readTextSafeSync(file);
@@ -458,6 +477,9 @@ export function saveSettings(settings: AppSettings): void {
458
477
  if (settings.macosRemindersEnabled !== undefined) {
459
478
  payload.macosRemindersEnabled = settings.macosRemindersEnabled;
460
479
  }
480
+ if (settings.loadClaudeUserSettings !== undefined) {
481
+ payload.loadClaudeUserSettings = settings.loadClaudeUserSettings;
482
+ }
461
483
  const serialised = JSON.stringify(payload, null, 2);
462
484
  writeFileAtomicSync(settingsPath(), `${serialised}\n`, { mode: 0o600 });
463
485
  }
@@ -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 \`/chat/<sessionId>.jsonl\`.
84
- - Example: "— discussed in [session 550e8400](/chat/550e8400-e29b-41d4-a716-446655440000.jsonl)"
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
+ }
@@ -32,6 +32,31 @@
32
32
  <p class="text-xs text-gray-500">{{ t("settingsModal.modelTab.helperText") }}</p>
33
33
  </div>
34
34
 
35
+ <div class="flex items-start gap-3 border-t border-gray-200 pt-3">
36
+ <input
37
+ id="settings-model-user-settings"
38
+ v-model="userSettingsEnabled"
39
+ type="checkbox"
40
+ class="mt-1 h-4 w-4"
41
+ :disabled="savingUserSettings || !loaded"
42
+ data-testid="settings-model-user-settings-input"
43
+ @change="saveUserSettings"
44
+ />
45
+ <label for="settings-model-user-settings" class="flex-1">
46
+ <span class="block text-sm font-medium text-gray-800">{{ t("settingsModal.modelTab.userSettingsLabel") }}</span>
47
+ <span class="block text-xs text-gray-500 mt-0.5">{{ t("settingsModal.modelTab.userSettingsHint") }}</span>
48
+ <span v-if="skillListing" class="block text-xs text-gray-600 mt-1" data-testid="settings-model-skill-listing">
49
+ {{ t("settingsModal.modelTab.skillListingSummary", { count: skillListing.skillCount }) }}
50
+ <template v-if="skillListing.pluginNames.length > 0">
51
+ {{ t("settingsModal.modelTab.skillListingPlugins", { plugins: skillListing.pluginNames.join(", ") }) }}
52
+ </template>
53
+ </span>
54
+ <span v-if="skillListing?.heavy" class="block text-xs text-amber-700 mt-1" data-testid="settings-model-skill-listing-heavy">
55
+ {{ t("settingsModal.modelTab.skillListingHeavy") }}
56
+ </span>
57
+ </label>
58
+ </div>
59
+
35
60
  <div v-if="loaded && !errorMessage" class="flex items-center gap-3 text-xs">
36
61
  <span :class="colourOf(modelField)" data-testid="settings-model-model-status">
37
62
  {{ modelStatusText }}
@@ -63,8 +88,15 @@ const emit = defineEmits<{
63
88
  saved: [];
64
89
  }>();
65
90
 
91
+ interface SkillListing {
92
+ skillCount: number;
93
+ pluginNames: string[];
94
+ heavy: boolean;
95
+ }
96
+
66
97
  interface SettingsResponse {
67
- settings: { extraAllowedTools: string[]; effortLevel?: EffortLevel; chatModel?: ChatModel };
98
+ settings: { extraAllowedTools: string[]; effortLevel?: EffortLevel; chatModel?: ChatModel; loadClaudeUserSettings?: boolean };
99
+ skillListing?: SkillListing | null;
68
100
  }
69
101
 
70
102
  // One select's whole state, so the save dance below is written once
@@ -87,6 +119,12 @@ const storedModel = ref<ChatModel | "">("");
87
119
  const savingModel = ref(false);
88
120
  const modelField: SettingField<ChatModel> = { key: "chatModel", draft: modelDraft, stored: storedModel, saving: savingModel };
89
121
 
122
+ // Default true matches `isClaudeUserSettingsEnabled` on the server.
123
+ const userSettingsEnabled = ref(true);
124
+ const storedUserSettings = ref(true);
125
+ const savingUserSettings = ref(false);
126
+ const skillListing = ref<SkillListing | null>(null);
127
+
90
128
  const loaded = ref(false);
91
129
  const errorMessage = ref("");
92
130
 
@@ -119,6 +157,9 @@ async function load(): Promise<void> {
119
157
  effortDraft.value = storedEffort.value;
120
158
  storedModel.value = response.data.settings.chatModel ?? "";
121
159
  modelDraft.value = storedModel.value;
160
+ storedUserSettings.value = response.data.settings.loadClaudeUserSettings ?? true;
161
+ userSettingsEnabled.value = storedUserSettings.value;
162
+ skillListing.value = response.data.skillListing ?? null;
122
163
  loaded.value = true;
123
164
  }
124
165
 
@@ -150,6 +191,22 @@ async function save<T extends string>(field: SettingField<T>): Promise<void> {
150
191
  }
151
192
  }
152
193
 
194
+ async function saveUserSettings(): Promise<void> {
195
+ if (savingUserSettings.value || userSettingsEnabled.value === storedUserSettings.value) return;
196
+ const requested = userSettingsEnabled.value;
197
+ savingUserSettings.value = true;
198
+ errorMessage.value = "";
199
+ const response = await apiPut<unknown>(API_ROUTES.config.settings, { loadClaudeUserSettings: requested });
200
+ savingUserSettings.value = false;
201
+ if (!response.ok) {
202
+ errorMessage.value = response.error || t("settingsModal.modelTab.saveError");
203
+ userSettingsEnabled.value = storedUserSettings.value;
204
+ return;
205
+ }
206
+ storedUserSettings.value = requested;
207
+ emit("saved");
208
+ }
209
+
153
210
  watch(
154
211
  () => props.reloadToken,
155
212
  () => {
@@ -42,6 +42,9 @@ export const RoleSchema = z.object({
42
42
  icon: z.string(),
43
43
  prompt: z.string(),
44
44
  availablePlugins: availablePluginsSchema,
45
+ // `alwaysActive` tools reach every role regardless of `availablePlugins`; a
46
+ // role that has no use for one names it here to keep its prompt smaller.
47
+ excludedAlwaysActiveTools: z.array(z.string()).optional(),
45
48
  queries: z.array(z.string()).optional(),
46
49
  isDebugRole: z.boolean().optional(),
47
50
  // Model family this role's sessions run on (#3104). Absent means the role
@@ -96,7 +99,7 @@ export const ROLES: [Role, ...Role[]] = [
96
99
  },
97
100
  {
98
101
  // Keeps the per-turn system prompt small for plain conversation (#3369):
99
- // only the form and collection display tools, plus the always-active ones.
102
+ // only the form and collection display tools, plus `manageCollection`.
100
103
  id: "simple",
101
104
  name: "Simple",
102
105
  icon: "chat_bubble",
@@ -104,6 +107,8 @@ export const ROLES: [Role, ...Role[]] = [
104
107
  "You are a concise conversational assistant. Answer directly in the chat, and use the workspace files and collections when the question needs them.\n\n" +
105
108
  "This role carries few display tools to keep each turn light. When the user wants a document, slides, an image, or an HTML page, say that the General role can make it and suggest switching.",
106
109
  availablePlugins: [TOOL_NAMES.presentForm, TOOL_NAMES.presentCollection],
110
+ // Plain conversation never pre-generates artifacts in the background.
111
+ excludedAlwaysActiveTools: [TOOL_NAMES.spawnBackgroundChat],
107
112
  queries: ["What can you help me with in this role?", "Summarise what's in my collections.", "Help me think through a decision I'm stuck on."],
108
113
  },
109
114
  {
package/src/lang/de.ts CHANGED
@@ -388,6 +388,12 @@ const deMessages = {
388
388
  helperText: "Höhere Stufen erlauben mehr Denkzeit, erhöhen aber Latenz und Token-Verbrauch.",
389
389
  configured: "Effort: {level}",
390
390
  notConfigured: "Nicht gesetzt",
391
+ userSettingsLabel: "Benutzereinstellungen und Plugins von Claude Code laden",
392
+ userSettingsHint:
393
+ "Liest Skills, Plugins und Hooks aus ~/.claude – dieselben, die Claude Code verwendet. Schalten Sie dies aus, wenn installierte Plugins so viele Skills mitbringen, dass jede Anfrage schwer wird; die Skills des Workspace bleiben nutzbar. Gilt ab der nächsten Nachricht. Ausgeschaltet folgt ein nicht gesetztes Modell auch nicht mehr ~/.claude/settings.json.",
394
+ skillListingSummary: "Im letzten Durchgang geladene Skills: {count}.",
395
+ skillListingPlugins: "Aus Plugins: {plugins}.",
396
+ skillListingHeavy: "So viele Skills machen jede Anfrage schwer; ausschalten entfernt die aus ~/.claude.",
391
397
  loadError: "Einstellungen konnten nicht geladen werden",
392
398
  saveError: "Speichern fehlgeschlagen",
393
399
  },