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.
- package/README.md +1 -1
- package/client/assets/JsonEditor-BC9ITErx.js +10 -0
- package/client/assets/{index-k-aOQrSm.js → index-168151sv.js} +368 -368
- package/client/assets/{marp-12IgaShm.js → marp-BvWwTP5d.js} +1 -1
- package/client/index.html +1 -1
- package/package.json +5 -5
- package/server/agent/activeTools.ts +9 -5
- package/server/agent/backend/claude-code.ts +3 -0
- package/server/agent/backend/types.ts +3 -0
- package/server/agent/config.ts +11 -1
- package/server/agent/dispatchableMcpTool.ts +19 -0
- package/server/agent/index.ts +2 -1
- package/server/agent/mcp-server.ts +7 -5
- package/server/agent/skillListing.ts +34 -0
- package/server/agent/skillListingState.ts +40 -0
- package/server/api/routes/config.ts +5 -0
- package/server/api/routes/roles.ts +1 -0
- package/server/build/mcp-server.mjs +491 -491
- package/server/prompts/system/memory-management-atomic.md +1 -27
- package/server/prompts/system/memory-management-topic.md +4 -53
- package/server/prompts/system/system.md +2 -5
- package/server/system/config.ts +25 -3
- 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/components/SettingsModelTab.vue +58 -1
- package/src/config/roles.ts +6 -1
- package/src/lang/de.ts +6 -0
- package/src/lang/en.ts +6 -0
- package/src/lang/es.ts +6 -0
- package/src/lang/fr.ts +6 -0
- package/src/lang/ja.ts +6 -0
- package/src/lang/ko.ts +6 -0
- package/src/lang/pt-BR.ts +6 -0
- package/src/lang/zh.ts +6 -0
- package/src/plugins/manageRoles/definition.ts +6 -0
- package/src/plugins/manageRoles/index.ts +3 -0
- package/src/plugins/manageRoles/roleForm.ts +8 -2
- package/src/plugins/manageSkills/definition.ts +1 -1
- package/src/utils/path/workspaceLinkRouter.ts +5 -6
- package/client/assets/JsonEditor-zdl4EA3_.js +0 -10
- package/server/system/logs/aaa +0 -737
- 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
|
-
|
|
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
|
|
58
|
+
## Collection custom views
|
|
59
59
|
|
|
60
|
-
|
|
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
|
|
package/server/system/config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
() => {
|
package/src/config/roles.ts
CHANGED
|
@@ -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
|
|
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
|
},
|