kimal3 0.6.3 → 0.6.5

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.
@@ -1,7 +1,7 @@
1
- {
2
- "name": "kimal3-logo",
3
- "version": "0.3.1",
4
- "description": "KImal3-Logo über dem Eingabefeld, öffnet beim Start das Projekt in der App · KImal3 logo above the prompt, opens the project in the app on start",
5
- "author": { "name": "KImal3", "url": "https://kimal3.de" },
6
- "types": "./types/index.d.ts"
7
- }
1
+ {
2
+ "name": "kimal3-logo",
3
+ "version": "0.5.0",
4
+ "description": "KImal3-Logo über dem Eingabefeld, öffnet beim Start das Projekt in der App, benennt den Chat nach seinem Thema · KImal3 logo above the prompt, opens the project in the app on start, names the chat after its topic",
5
+ "author": { "name": "KImal3", "url": "https://kimal3.de" },
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -0,0 +1,8 @@
1
+ // Wo `kimal3` auf diesem Rechner liegt und welche Fassung es hat. `kimal3` ersetzt beim Ablegen die Zeile
2
+ // `const ABLAGE: Ablage = KEINE_ABLAGE` in register.tsx durch die echten Werte (`mitAblage` in `cli/src/logo-ablegen.ts`,
3
+ // Bauplan 2026-10-23, Abschnitt 1.1). Hier im Paket bleibt sie leer: Ein Mod ohne Ablage (Entwicklung) zeigt keine
4
+ // Fassung und fragt npm nicht (U-9). Die Felder stehen in `cli/src/logo-ablegen.ts` noch einmal (`Ablage`) -- die CLI
5
+ // kann diese Datei nicht einbinden; `check:cli` hält beide Feldlisten gleich.
6
+ export type Ablage = { fassung: string; node: string; npm: string; kimal3: string; kimal3Ordner: string }
7
+
8
+ export const KEINE_ABLAGE: Ablage = { fassung: '', node: '', npm: '', kimal3: '', kimal3Ordner: '' }
@@ -1,158 +1,158 @@
1
- import { atom, read, update, type Engine, type Register } from 'claude-code'
2
-
3
- // Beim Start von Claude Code die Projektseite im Browser der Claude-App öffnen (Dennis, 23.10.: „Automatisch bei jedem
4
- // Start“, und zwar „in dem Browser in der Claude-Code-App“ — nicht im Standardbrowser).
5
- // Szenario-Tabelle: docs/superpowers/specs/2026-10-23-mod-app-oeffnen.md
6
-
7
- const APP = 'https://app.kimal3.de'
8
- // Positivliste (messen-und-pruefen §23): Nur eine Projektnummer kommt in die Adresse, nie ein anderer Wert aus der Datei (S-4).
9
- const PROJEKTNUMMER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
10
- // Der eingebaute Browser der Claude-App, wie ihn das Werkzeug `mcp__Claude_Browser__navigate` nennt.
11
- const BROWSER = 'Claude_Browser'
12
- const NAVIGATE = 'mcp__Claude_Browser__navigate'
13
- // Den Browser-Bereich neben dem Chat einblenden: `navigate` allein öffnet die Seite in einem VERSTECKTEN Bereich
14
- // (gemessen 23.10. in einer frischen Sitzung: `navOk: true`, „Browser pane opened“, Dennis sah nichts).
15
- const ANSICHT = 'ccd_view'
16
- const EINBLENDEN = 'mcp__ccd_view__show_pane'
17
- const geoeffnet = atom({ plugin: 'kimal3-logo', key: 'appGeoeffnet' } as const, false)
18
-
19
- /** Die Adresse für den Inhalt von `plan/stand.json` (`null`: keine Datei). Ohne gültige Nummer der Kurs, wie der Link in der Leiste (S-3, S-4). */
20
- export function adresseFuer(standJson: string | null): string {
21
- if (standJson !== null) {
22
- try {
23
- // Ein BOM am Anfang (Windows-Editoren) lehnt JSON.parse ab, wie in `cli/src/plan-datei.ts`.
24
- const id: unknown = JSON.parse(standJson.replace(/^\uFEFF/, ''))?.projekt_id
25
- if (typeof id === 'string' && PROJEKTNUMMER.test(id)) return `${APP}/projekt/${id.toLowerCase()}`
26
- } catch {
27
- // kaputtes JSON: wie keine Datei
28
- }
29
- }
30
- return `${APP}/kurs`
31
- }
32
-
33
- /** Die eigene Erlaubnis (unten): nur der Aufruf dieses Mods, nur eine Adresse der App. Herausgelöst, damit `check:logo` die Grenze prüft. */
34
- export function istEigenerAufruf(plugin: string, input: unknown): boolean {
35
- const url = (input as { url?: unknown } | null)?.url
36
- return plugin === 'kimal3-logo' && typeof url === 'string' && url.startsWith(`${APP}/`)
37
- }
38
-
39
- /** Die eigene Erlaubnis zum Einblenden: nur der Aufruf dieses Mods, nur der Browser-Bereich (`preview`), nie ein anderer. */
40
- export function istEigenesEinblenden(plugin: string, input: unknown): boolean {
41
- return plugin === 'kimal3-logo' && (input as { pane?: unknown } | null)?.pane === 'preview'
42
- }
43
-
44
- // Gegen zwei gleichzeitige Versuche in diesem Lauf (`session.start` und `session.attach` kurz hintereinander).
45
- // Das Neuladen des Mods setzt die Variable zurück; dagegen hält der Merker `geoeffnet`.
46
- let laeuft = false
47
-
48
- /**
49
- * Höchstens einmal je Sitzung (S-7, S-8, S-10): Der Merker in `$.state` übersteht das Neuladen des Mods. Er wird erst
50
- * nach einem gelungenen Aufruf gesetzt — scheitert der erste Versuch, darf ein späteres `session.attach` es noch einmal.
51
- */
52
- async function einmalOeffnen($: Engine): Promise<void> {
53
- if (laeuft || (await read($, geoeffnet))) return
54
- laeuft = true
55
- try {
56
- if (await oeffne($)) await update($, geoeffnet, () => true)
57
- } finally {
58
- laeuft = false
59
- }
60
- }
61
-
62
- /** Der Startordner und alle Ordner darüber, nächster zuerst; Trenner immer `/` (Windows nimmt beide). */
63
- export function ordnerNachOben(cwd: string): string[] {
64
- const teile = cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
65
- return teile.map((_, i) => teile.slice(0, teile.length - i).join('/'))
66
- }
67
-
68
- /**
69
- * Das Hauptprojekt einer Git-Arbeitskopie aus ihrer `.git`-Datei (`gitdir: C:/…/kimal3-app/.git/worktrees/s8`).
70
- * `null` für alles andere, etwa ein Untermodul (`gitdir: ../.git/modules/…`).
71
- */
72
- export function hauptordnerAusGitdatei(text: string): string | null {
73
- const treffer = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/.exec(text.trim())
74
- return treffer === null ? null : treffer[1].replace(/\\/g, '/')
75
- }
76
-
77
- async function liesDatei($: Engine, pfad: string): Promise<string | null> {
78
- try {
79
- return await $.fs.read(pfad)
80
- } catch {
81
- return null
82
- }
83
- }
84
-
85
- /**
86
- * `plan/stand.json` des Projekts, in dem Claude läuft (S-5, S-24, S-25): vom Startordner nach oben, bis zur Wurzel des
87
- * eigenen Git-Projekts und nie darüber hinaus — sonst bekäme ein Projekt ohne Plan die Seite eines anderen. Ist die
88
- * Wurzel eine Arbeitskopie (`.git` ist dort eine Datei), zählt danach die Projektdatei ihres Hauptprojekts.
89
- */
90
- async function findeStand($: Engine, cwd: string): Promise<string | null> {
91
- for (const ordner of ordnerNachOben(cwd)) {
92
- const stand = await liesDatei($, `${ordner}/plan/stand.json`)
93
- if (stand !== null) return stand
94
- if (!(await $.fs.exists(`${ordner}/.git`))) continue
95
- // Ein Ordner `.git` lässt sich nicht lesen: echte Projektwurzel, hier ist Schluss.
96
- const gitdatei = await liesDatei($, `${ordner}/.git`)
97
- const haupt = gitdatei === null ? null : hauptordnerAusGitdatei(gitdatei)
98
- return haupt === null ? null : liesDatei($, `${haupt}/plan/stand.json`)
99
- }
100
- return null
101
- }
102
-
103
- async function oeffne($: Engine): Promise<boolean> {
104
- const stand = await findeStand($, await $.session.cwd())
105
- // Scheitern meldet sich als Scheitern, nie im Chat (S-13): eine Zeile nur im Debug-Protokoll (`to: 'debug'` —
106
- // ohne steht sie im Chatverlauf, Befund der Durchsicht 23.10.).
107
- try {
108
- const antwort = await $.mcp.call(BROWSER, 'navigate', { url: adresseFuer(stand) })
109
- if (!antwort.isError) {
110
- await einblenden($)
111
- return true
112
- }
113
- $.ui.log(`kimal3-logo: Projektseite nicht geöffnet: ${JSON.stringify(antwort.content).slice(0, 300)}`, { to: 'debug' })
114
- } catch (fehler) {
115
- $.ui.log(`kimal3-logo: Projektseite nicht geöffnet: ${String(fehler).slice(0, 300)}`, { to: 'debug' })
116
- }
117
- return false
118
- }
119
-
120
- /** Die geöffnete Seite auch zeigen. Scheitert das, ist die Seite trotzdem offen — eine Zeile im Debug-Protokoll, kein zweiter Versuch. */
121
- async function einblenden($: Engine): Promise<void> {
122
- try {
123
- const antwort = await $.mcp.call(ANSICHT, 'show_pane', { pane: 'preview' })
124
- if (antwort.isError) $.ui.log(`kimal3-logo: Browser nicht eingeblendet: ${JSON.stringify(antwort.content).slice(0, 300)}`, { to: 'debug' })
125
- } catch (fehler) {
126
- $.ui.log(`kimal3-logo: Browser nicht eingeblendet: ${String(fehler).slice(0, 300)}`, { to: 'debug' })
127
- }
128
- }
129
-
130
- export const appOeffnen: Register = on => {
131
- // Die eigene Erlaubnis, eng: nur der Aufruf dieses Mods, nur eine Adresse der App. Ohne sie lehnt der Auto-Modus
132
- // ab, weil beim Start niemand um etwas gebeten hat (gemessen 23.10. mit einer Probe). Claudes eigene Aufrufe und
133
- // jede andere Adresse entscheidet die Kette darunter wie bisher.
134
- on('tool.check', { tool: NAVIGATE }, ($, e, next) =>
135
- istEigenerAufruf(next.origin.plugin, e.input)
136
- ? { decision: 'allow', reason: 'kimal3: die eigene Projektseite beim Start' }
137
- : next(e),
138
- )
139
- on('tool.check', { tool: EINBLENDEN }, ($, e, next) =>
140
- istEigenesEinblenden(next.origin.plugin, e.input)
141
- ? { decision: 'allow', reason: 'kimal3: den Browser mit der Projektseite einblenden' }
142
- : next(e),
143
- )
144
- // Den Browser gibt es nur in der Claude-App, nicht im Terminal oder in VS Code. Beim Start meldet die App
145
- // `isInteractive: false`, ist aber schon angehängt (gemessen 23.10.: surfaces ["desktop"]). `claude -p`, Skripte,
146
- // geplante Aufgaben, das Terminal: kein Aufruf (S-6).
147
- on('session.start', async ($, e, next) => {
148
- const result = await next(e)
149
- if ((await $.session.surfaces()).includes('desktop')) await einmalOeffnen($)
150
- return result
151
- })
152
- // Hängt sich die App erst nach dem Start an, kommt das Signal hier.
153
- on('session.attach', async ($, e, next) => {
154
- const result = await next(e)
155
- if (e.surface === 'desktop') await einmalOeffnen($)
156
- return result
157
- })
158
- }
1
+ import { atom, read, update, type Engine, type Register } from 'claude-code'
2
+
3
+ // Beim Start von Claude Code die Projektseite im Browser der Claude-App öffnen (Dennis, 23.10.: „Automatisch bei jedem
4
+ // Start“, und zwar „in dem Browser in der Claude-Code-App“ — nicht im Standardbrowser).
5
+ // Szenario-Tabelle: docs/superpowers/specs/2026-10-23-mod-app-oeffnen.md
6
+
7
+ const APP = 'https://app.kimal3.de'
8
+ // Positivliste (messen-und-pruefen §23): Nur eine Projektnummer kommt in die Adresse, nie ein anderer Wert aus der Datei (S-4).
9
+ const PROJEKTNUMMER = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
10
+ // Der eingebaute Browser der Claude-App, wie ihn das Werkzeug `mcp__Claude_Browser__navigate` nennt.
11
+ const BROWSER = 'Claude_Browser'
12
+ const NAVIGATE = 'mcp__Claude_Browser__navigate'
13
+ // Den Browser-Bereich neben dem Chat einblenden: `navigate` allein öffnet die Seite in einem VERSTECKTEN Bereich
14
+ // (gemessen 23.10. in einer frischen Sitzung: `navOk: true`, „Browser pane opened“, Dennis sah nichts).
15
+ const ANSICHT = 'ccd_view'
16
+ const EINBLENDEN = 'mcp__ccd_view__show_pane'
17
+ const geoeffnet = atom({ plugin: 'kimal3-logo', key: 'appGeoeffnet' } as const, false)
18
+
19
+ /** Die Adresse für den Inhalt von `plan/stand.json` (`null`: keine Datei). Ohne gültige Nummer der Kurs, wie der Link in der Leiste (S-3, S-4). */
20
+ export function adresseFuer(standJson: string | null): string {
21
+ if (standJson !== null) {
22
+ try {
23
+ // Ein BOM am Anfang (Windows-Editoren) lehnt JSON.parse ab, wie in `cli/src/plan-datei.ts`.
24
+ const id: unknown = JSON.parse(standJson.replace(/^\uFEFF/, ''))?.projekt_id
25
+ if (typeof id === 'string' && PROJEKTNUMMER.test(id)) return `${APP}/projekt/${id.toLowerCase()}`
26
+ } catch {
27
+ // kaputtes JSON: wie keine Datei
28
+ }
29
+ }
30
+ return `${APP}/kurs`
31
+ }
32
+
33
+ /** Die eigene Erlaubnis (unten): nur der Aufruf dieses Mods, nur eine Adresse der App. Herausgelöst, damit `check:logo` die Grenze prüft. */
34
+ export function istEigenerAufruf(plugin: string, input: unknown): boolean {
35
+ const url = (input as { url?: unknown } | null)?.url
36
+ return plugin === 'kimal3-logo' && typeof url === 'string' && url.startsWith(`${APP}/`)
37
+ }
38
+
39
+ /** Die eigene Erlaubnis zum Einblenden: nur der Aufruf dieses Mods, nur der Browser-Bereich (`preview`), nie ein anderer. */
40
+ export function istEigenesEinblenden(plugin: string, input: unknown): boolean {
41
+ return plugin === 'kimal3-logo' && (input as { pane?: unknown } | null)?.pane === 'preview'
42
+ }
43
+
44
+ // Gegen zwei gleichzeitige Versuche in diesem Lauf (`session.start` und `session.attach` kurz hintereinander).
45
+ // Das Neuladen des Mods setzt die Variable zurück; dagegen hält der Merker `geoeffnet`.
46
+ let laeuft = false
47
+
48
+ /**
49
+ * Höchstens einmal je Sitzung (S-7, S-8, S-10): Der Merker in `$.state` übersteht das Neuladen des Mods. Er wird erst
50
+ * nach einem gelungenen Aufruf gesetzt — scheitert der erste Versuch, darf ein späteres `session.attach` es noch einmal.
51
+ */
52
+ async function einmalOeffnen($: Engine): Promise<void> {
53
+ if (laeuft || (await read($, geoeffnet))) return
54
+ laeuft = true
55
+ try {
56
+ if (await oeffne($)) await update($, geoeffnet, () => true)
57
+ } finally {
58
+ laeuft = false
59
+ }
60
+ }
61
+
62
+ /** Der Startordner und alle Ordner darüber, nächster zuerst; Trenner immer `/` (Windows nimmt beide). */
63
+ export function ordnerNachOben(cwd: string): string[] {
64
+ const teile = cwd.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
65
+ return teile.map((_, i) => teile.slice(0, teile.length - i).join('/'))
66
+ }
67
+
68
+ /**
69
+ * Das Hauptprojekt einer Git-Arbeitskopie aus ihrer `.git`-Datei (`gitdir: C:/…/kimal3-app/.git/worktrees/s8`).
70
+ * `null` für alles andere, etwa ein Untermodul (`gitdir: ../.git/modules/…`).
71
+ */
72
+ export function hauptordnerAusGitdatei(text: string): string | null {
73
+ const treffer = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+\s*$/.exec(text.trim())
74
+ return treffer === null ? null : treffer[1].replace(/\\/g, '/')
75
+ }
76
+
77
+ async function liesDatei($: Engine, pfad: string): Promise<string | null> {
78
+ try {
79
+ return await $.fs.read(pfad)
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * `plan/stand.json` des Projekts, in dem Claude läuft (S-5, S-24, S-25): vom Startordner nach oben, bis zur Wurzel des
87
+ * eigenen Git-Projekts und nie darüber hinaus — sonst bekäme ein Projekt ohne Plan die Seite eines anderen. Ist die
88
+ * Wurzel eine Arbeitskopie (`.git` ist dort eine Datei), zählt danach die Projektdatei ihres Hauptprojekts.
89
+ */
90
+ async function findeStand($: Engine, cwd: string): Promise<string | null> {
91
+ for (const ordner of ordnerNachOben(cwd)) {
92
+ const stand = await liesDatei($, `${ordner}/plan/stand.json`)
93
+ if (stand !== null) return stand
94
+ if (!(await $.fs.exists(`${ordner}/.git`))) continue
95
+ // Ein Ordner `.git` lässt sich nicht lesen: echte Projektwurzel, hier ist Schluss.
96
+ const gitdatei = await liesDatei($, `${ordner}/.git`)
97
+ const haupt = gitdatei === null ? null : hauptordnerAusGitdatei(gitdatei)
98
+ return haupt === null ? null : liesDatei($, `${haupt}/plan/stand.json`)
99
+ }
100
+ return null
101
+ }
102
+
103
+ async function oeffne($: Engine): Promise<boolean> {
104
+ const stand = await findeStand($, await $.session.cwd())
105
+ // Scheitern meldet sich als Scheitern, nie im Chat (S-13): eine Zeile nur im Debug-Protokoll (`to: 'debug'` —
106
+ // ohne steht sie im Chatverlauf, Befund der Durchsicht 23.10.).
107
+ try {
108
+ const antwort = await $.mcp.call(BROWSER, 'navigate', { url: adresseFuer(stand) })
109
+ if (!antwort.isError) {
110
+ await einblenden($)
111
+ return true
112
+ }
113
+ $.ui.log(`kimal3-logo: Projektseite nicht geöffnet: ${JSON.stringify(antwort.content).slice(0, 300)}`, { to: 'debug' })
114
+ } catch (fehler) {
115
+ $.ui.log(`kimal3-logo: Projektseite nicht geöffnet: ${String(fehler).slice(0, 300)}`, { to: 'debug' })
116
+ }
117
+ return false
118
+ }
119
+
120
+ /** Die geöffnete Seite auch zeigen. Scheitert das, ist die Seite trotzdem offen — eine Zeile im Debug-Protokoll, kein zweiter Versuch. */
121
+ async function einblenden($: Engine): Promise<void> {
122
+ try {
123
+ const antwort = await $.mcp.call(ANSICHT, 'show_pane', { pane: 'preview' })
124
+ if (antwort.isError) $.ui.log(`kimal3-logo: Browser nicht eingeblendet: ${JSON.stringify(antwort.content).slice(0, 300)}`, { to: 'debug' })
125
+ } catch (fehler) {
126
+ $.ui.log(`kimal3-logo: Browser nicht eingeblendet: ${String(fehler).slice(0, 300)}`, { to: 'debug' })
127
+ }
128
+ }
129
+
130
+ export const appOeffnen: Register = on => {
131
+ // Die eigene Erlaubnis, eng: nur der Aufruf dieses Mods, nur eine Adresse der App. Ohne sie lehnt der Auto-Modus
132
+ // ab, weil beim Start niemand um etwas gebeten hat (gemessen 23.10. mit einer Probe). Claudes eigene Aufrufe und
133
+ // jede andere Adresse entscheidet die Kette darunter wie bisher.
134
+ on('tool.check', { tool: NAVIGATE }, ($, e, next) =>
135
+ istEigenerAufruf(next.origin.plugin, e.input)
136
+ ? { decision: 'allow', reason: 'kimal3: die eigene Projektseite beim Start' }
137
+ : next(e),
138
+ )
139
+ on('tool.check', { tool: EINBLENDEN }, ($, e, next) =>
140
+ istEigenesEinblenden(next.origin.plugin, e.input)
141
+ ? { decision: 'allow', reason: 'kimal3: den Browser mit der Projektseite einblenden' }
142
+ : next(e),
143
+ )
144
+ // Den Browser gibt es nur in der Claude-App, nicht im Terminal oder in VS Code. Beim Start meldet die App
145
+ // `isInteractive: false`, ist aber schon angehängt (gemessen 23.10.: surfaces ["desktop"]). `claude -p`, Skripte,
146
+ // geplante Aufgaben, das Terminal: kein Aufruf (S-6).
147
+ on('session.start', async ($, e, next) => {
148
+ const result = await next(e)
149
+ if ((await $.session.surfaces()).includes('desktop')) await einmalOeffnen($)
150
+ return result
151
+ })
152
+ // Hängt sich die App erst nach dem Start an, kommt das Signal hier.
153
+ on('session.attach', async ($, e, next) => {
154
+ const result = await next(e)
155
+ if (e.surface === 'desktop') await einmalOeffnen($)
156
+ return result
157
+ })
158
+ }
@@ -0,0 +1,135 @@
1
+ import { atom, read, update, type Engine, type Register, type SessionMessage } from 'claude-code'
2
+
3
+ // Den Chat in der Claude-App umbenennen, sobald sein Thema klar ist (Dennis, 23.10.: „nach jeder Antwort prüft ein
4
+ // kleines Modell, ob das Thema klar ist, benannt wird einmal, nur in der Claude-App, über die Umbenennen-Funktion der App“).
5
+ // Anlass: Die App nimmt den Namen aus der ersten Nachricht — ist das `/k3start`, heißt der Chat für immer „K3start“.
6
+ //
7
+ // Fälle (B-…), geprüft in `npm run check:logo`:
8
+ // B-1 Thema klar → ein Titel aus 2 bis 5 Wörtern, einmal gesetzt · B-2 noch unklar → nichts, nach der nächsten Antwort
9
+ // wieder fragen · B-3 höchstens EIN Versuch je Sitzung, auch wenn die App ablehnt (der Mensch hat den Namen selbst
10
+ // vergeben und Nein gesagt — dann nicht nach jeder Antwort wieder fragen) · B-4 nur in der Claude-App, sonst nicht einmal
11
+ // das Modell fragen · B-5 Antworten von Subagenten und abgebrochene Antworten zählen nicht · B-6 fortgesetzte Chats
12
+ // behalten ihren Namen · B-7 eine Modellantwort, die kein Titel ist, zählt als unklar · B-8 höchstens 8 Fragen je Sitzung
13
+ // · B-9 nicht, wenn alle Erlaubnis-Abfragen aus sind, und nicht, solange der Modus unbekannt ist.
14
+ //
15
+ // Den Schutz für selbst vergebene Namen bringt die App mit: Sie fragt den Menschen, bevor sie einen solchen ersetzt —
16
+ // außer im Modus `bypassPermissions` (Beschreibung von `set_session_title`). Deshalb B-9. Gemessen 23.10. im Modus
17
+ // `auto`: Der Aufruf aus einem Mod benennt den eigenen Chat um.
18
+ //
19
+ // Bekannte Grenzen (Durchsicht 23.10.): Startet die App den Prozess neu, bevor das Thema klar war, kommt der Chat als
20
+ // `resume` zurück und bleibt unbenannt (B-6 geht vor). Was `/clear` mit dem Merker macht, ist nicht gemessen.
21
+
22
+ const SITZUNGEN = 'ccd_session_mgmt'
23
+ const UMBENENNEN = 'mcp__ccd_session_mgmt__set_session_title'
24
+ const MAX_FRAGEN = 8
25
+ const fertig = atom({ plugin: 'kimal3-logo', key: 'chatBenannt' } as const, false)
26
+ const fragen = atom({ plugin: 'kimal3-logo', key: 'benennenFragen' } as const, 0)
27
+
28
+ // Das Modell antwortet mit einem Titel oder genau diesem Wort.
29
+ const UNKLAR = 'UNKLAR'
30
+ const SYSTEM = `Du gibst einem Chat einen Namen. Du bekommst die letzten Nachrichten des Menschen und Claudes letzte Antwort.
31
+ Ist klar, worum es geht, antworte NUR mit einem Titel aus 2 bis 5 Wörtern in der Sprache des Gesprächs, ohne Anführungszeichen und ohne Punkt am Ende.
32
+ Ist es noch nicht klar — nur eine Begrüßung, nur ein Startbefehl, nur die Frage nach dem Stand —, antworte NUR mit: ${UNKLAR}`
33
+
34
+ /**
35
+ * Die Modellantwort als Titel, oder `null` (B-7). Positivliste: eine Zeile, 2 bis 6 Wörter (ein Wort Spielraum über
36
+ * die erbetenen 5), höchstens 60 Zeichen, ohne das Wort „unklar“ (auch nicht „UNKLAR – nur eine Begrüßung“ oder „Es ist
37
+ * noch unklar“, Befund der Durchsicht 23.10.). Ein vorangestelltes „Titel:“ fällt weg.
38
+ */
39
+ export function titelAus(antwort: string): string | null {
40
+ const zeile = antwort.trim().replace(/^(titel|title)\s*:\s*/i, '').replace(/^["'„“”«»]+|["'„“”«».]+$/g, '').trim()
41
+ if (zeile.includes('\n') || zeile.length > 60 || /(^|[^\p{L}])(unklar|unclear)($|[^\p{L}])/iu.test(zeile)) return null
42
+ const woerter = zeile.split(/\s+/).length
43
+ return woerter >= 2 && woerter <= 6 ? zeile : null
44
+ }
45
+
46
+ /**
47
+ * Was das Modell liest: die Nachrichten des Menschen (ohne die Einschübe von Claude Code und ohne die Markierungen eines
48
+ * Startbefehls wie `/k3start`) und die letzte Antwort, gekürzt.
49
+ * ⚠️ Ungemessen: ob der ausgeklappte Text eines Befehls als eigene Nachricht des Menschen ankommt (Durchsicht 23.10.).
50
+ */
51
+ export function gespraechFuer(nachrichten: readonly SessionMessage[], antwort: string): string {
52
+ const vomMenschen = nachrichten
53
+ .filter(n => n.role === 'user')
54
+ .map(n =>
55
+ n.text
56
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
57
+ .replace(/<(command-[a-z-]+)>[\s\S]*?<\/\1>/g, '')
58
+ .trim(),
59
+ )
60
+ .filter(text => text !== '')
61
+ .slice(-6)
62
+ .map(text => `Mensch: ${text.slice(0, 400)}`)
63
+ return [...vomMenschen, `Claude: ${antwort.slice(0, 1500)}`].join('\n\n')
64
+ }
65
+
66
+ /** Die eigene Erlaubnis: nur der Aufruf dieses Mods, nur für den eigenen Chat. Herausgelöst, damit `check:logo` die Grenze prüft. */
67
+ export function istEigenesUmbenennen(plugin: string, input: unknown): boolean {
68
+ return plugin === 'kimal3-logo' && (input as { session_id?: unknown } | null)?.session_id === 'self'
69
+ }
70
+
71
+ // Gegen zwei Antworten, die kurz hintereinander enden, während das Modell noch denkt.
72
+ let laeuft = false
73
+ // Der Erlaubnis-Modus aus der letzten Nachricht des Menschen (B-9). Nach dem Neuladen des Mods leer, bis die nächste kommt.
74
+ let modus: string | undefined
75
+
76
+ async function benennen($: Engine, antwort: string): Promise<void> {
77
+ // Erst sperren, dann lesen: Zwischen Prüfen und Sperren darf kein `await` liegen (Durchsicht 23.10.).
78
+ if (laeuft) return
79
+ laeuft = true
80
+ try {
81
+ if (await read($, fertig)) return
82
+ if ((await read($, fragen)) >= MAX_FRAGEN) return
83
+ await update($, fragen, n => n + 1)
84
+ const gespraech = gespraechFuer(await $.session.messages(), antwort)
85
+ const r = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt: gespraech, maxTokens: 40, effort: 'low', timeoutMs: 20_000 })
86
+ if (!r.isAnswered) {
87
+ $.ui.log(`kimal3-logo: kein Titel vom Modell: ${r.reason}`, { to: 'debug' })
88
+ return
89
+ }
90
+ const titel = titelAus(r.text)
91
+ // Was das Modell statt eines Titels sagte, steht im Debug-Protokoll: Beim Live-Test am 23.10. wurde die erste
92
+ // Antwort verworfen, und ohne diese Zeile ließ sich nicht mehr sagen, warum.
93
+ if (titel === null) {
94
+ $.ui.log(`kimal3-logo: noch kein Titel, Modell sagte: ${JSON.stringify(r.text.slice(0, 200))}`, { to: 'debug' })
95
+ return
96
+ }
97
+ // B-3: ab hier zählt es als Versuch, wie auch immer die App antwortet.
98
+ await update($, fertig, () => true)
99
+ const ergebnis = await $.mcp.call(SITZUNGEN, 'set_session_title', { session_id: 'self', title: titel })
100
+ if (ergebnis.isError) $.ui.log(`kimal3-logo: Chat nicht umbenannt: ${JSON.stringify(ergebnis.content).slice(0, 300)}`, { to: 'debug' })
101
+ } catch (fehler) {
102
+ $.ui.log(`kimal3-logo: Chat nicht umbenannt: ${String(fehler).slice(0, 300)}`, { to: 'debug' })
103
+ } finally {
104
+ laeuft = false
105
+ }
106
+ }
107
+
108
+ export const chatBenennen: Register = on => {
109
+ // Die eigene Erlaubnis, eng wie beim Öffnen der Projektseite (hooks/app-oeffnen.ts): Der Aufruf kommt nach einer
110
+ // Antwort, um die niemand gebeten hat — ohne sie lehnt der Auto-Modus ab. Claudes eigene Aufrufe entscheidet die Kette darunter.
111
+ on('tool.check', { tool: UMBENENNEN }, ($, e, next) =>
112
+ istEigenesUmbenennen(next.origin.plugin, e.input)
113
+ ? { decision: 'allow', reason: 'kimal3: den eigenen Chat nach seinem Thema benennen' }
114
+ : next(e),
115
+ )
116
+ // B-6: Ein fortgesetzter Chat hat seinen Namen schon.
117
+ on('classic.SessionStart', async ($, e, next) => {
118
+ if (e.source === 'resume' || e.source === 'fork') await update($, fertig, () => true)
119
+ return next(e)
120
+ })
121
+ // B-9: Den Modus trägt nur ein klassisches Ereignis; die Nachricht des Menschen kommt vor jeder Antwort.
122
+ on('classic.UserPromptSubmit', ($, e, next) => {
123
+ modus = e.permission_mode
124
+ return next(e)
125
+ })
126
+ on('turn.complete', async ($, e, next) => {
127
+ const ergebnis = await next(e)
128
+ if (e.agentId !== undefined || e.reason !== 'answer') return ergebnis
129
+ if (modus === undefined || modus === 'bypassPermissions') return ergebnis
130
+ if (!(await $.session.surfaces()).includes('desktop')) return ergebnis
131
+ // Nach der Antwort, nicht in ihr: Das Modell braucht ein, zwei Sekunden, die Antwort soll darauf nicht warten.
132
+ $.clock.after(0, () => void benennen($, e.answer))
133
+ return ergebnis
134
+ })
135
+ }