Warnt vor teuren Nachrichten in großen, kalt gewordenen Sitzungen, zeigt die Cache-Restzeit unten rechts nur bei Bedarf und fragt vor dem Abkühlen per Telegram…

Claude-Code-Mod für den Prompt-Cache (Abo: 1 h für den Hauptverlauf).
~/.claude/telegram.env (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID); am einfachsten mit /telegram einrichten aus der Mod telegram-draht. Knöpfe und Texte für die Mod telegram-draht bleiben beim Abholen eine Minute liegen.Installieren: /plugin install cache-waechter --marketplace Savo2610/claude-mods
/cache zeigt den Zustand; /cache anzeige aus|knapp|immer|unten|oben, /cache warnung an|aus|test, /cache telegram an|aus|test.
Prüfen: claude plugin validate cache-waechter und claude plugin test cache-waechter. Nach Änderungen in einer laufenden Sitzung /reload-plugins.
hooks/register.tsx 792 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MIN = 60 * 1000
4// Abo-Sitzungen bekommen für den Hauptverlauf eine Stunde Cache (Doku
5// „Which TTL each request gets“). Mit API-Schlüssel wären es 5 min, dann
6// promptCacheTtl auf "1h" setzen, sonst stimmt hier nichts.
7export const TTL_MS = 60 * MIN
8// Angehalten wird erst ab 150k: darunter kostet ein Neuaufbau zu wenig, als
9// dass sich das Anhalten lohnt (so gewählt am 09.10., vorher 50k).
10export const WARN_AB = 150_000
11// Die Anzeige „knapp“ meldet sich schon ab 50k: sehen schadet nicht, anhalten nervt.
12export const ANZEIGE_AB = 50_000
13// Telegram nur, wo es um richtig viel geht: sonst wird das Handy zum Wecker.
14// (09.10.: von 100k auf 200k angehoben, wie gewünscht.)
15export const TELEGRAM_AB = 200_000
16// 50 min nach der letzten Anfrage fragen lässt 10 min zum Antworten.
17export const FRAGE_NACH_MS = 50 * MIN
18// Wer erst kurz vor Schluss dran wäre (Mod neu geladen, Telegram eben erst an),
19// bekommt keine Frage mehr: in unter 2 min antwortet niemand.
20const FRAGE_MIN_REST_MS = 2 * MIN
21// „knapp“ zeigt die Angabe ab 25 min Restzeit: früh genug, um noch etwas
22// Größeres zu Ende zu bringen, aber nicht die ganze Stunde lang.
23export const KNAPP_MS = 25 * MIN
24// Alle 4 s nachsehen: ein Knopfdruck wirkt spürbar sofort, und das Abholen
25// läuft nur, solange eine Frage offen ist (höchstens 10 min).
26const POLL_MS = 4000
27// Bei einem Ausfall (am 09.10. ein 502 von Telegram) hing ein Abholen bis zu
28// 30 s; nach 20 s gilt es als verloren und das nächste darf los.
29const HAENGT_MS = 20 * 1000
30// Ist Telegram nicht erreichbar, erst nach einer Minute neu senden, nicht alle 4 s.
31const NEUVERSUCH_MS = MIN
32const TICK_MS = 30 * 1000
33const PING_PROMPT = 'Antworte nur mit dem Wort: warm'
34
35export type Anzeige = 'aus' | 'knapp' | 'immer'
36// „unten“: klein rechts in der Fußzeile, nah an der Verbrauchsanzeige der App
37// (in die selbst darf eine Mod nicht zeichnen). „oben“: die breite Zeile über der Eingabe.
38export type Ort = 'unten' | 'oben'
39type Aktion = 'kompakt' | 'warm' | 'nichts'
40const AKTIONEN: readonly string[] = ['kompakt', 'warm', 'nichts']
41
42export type Lage = {
43 /** Wann die Hauptsitzung zuletzt ans Modell ging (ms); 0 = noch nie. */
44 letzteAnfrage: number
45 /** Tokens, die die nächste Anfrage mitschickt. */
46 ctx: number
47 /** Nach /compact steht erst die Zusammenfassung im Cache, die kostet wenig. */
48 kompaktiert: boolean
49}
50
51export function istKalt(l: Lage, now: number): boolean {
52 return l.letzteAnfrage > 0 && !l.kompaktiert && now - l.letzteAnfrage >= TTL_MS
53}
54
55// Abgerundet: „noch 5 min“ heißt mindestens fünf, nie weniger als gezeigt.
56export function fmtDauer(ms: number): string {
57 if (ms < MIN) return 'unter 1 min'
58 const m = Math.floor(ms / MIN)
59 const std = Math.floor(m / 60)
60 return std ? `${std} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
61}
62
63export function fmtTok(n: number): string {
64 // Ab 999,5k rundet „k“ auf 1000k: dann lieber gleich Millionen.
65 if (n >= 999_500) return (n / 1e6).toFixed(1).replace('.', ',') + ' Mio.'
66 return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
67}
68
69/** Die Zeile über der Eingabe, oder null, wenn sie nichts zu sagen hat. */
70export function bandText(l: Lage, now: number, anzeige: Anzeige): string | null {
71 if (anzeige === 'aus') return null
72 // Nach /compact gibt es keinen Ablauf zu zählen: der Cache entsteht erst mit der
73 // nächsten Nachricht neu. Bei „immer“ sagt die Zeile das, statt einfach zu verschwinden.
74 if (l.kompaktiert) return anzeige === 'immer' ? `Cache komprimiert auf ${fmtTok(l.ctx)} Tokens · baut sich mit der nächsten Nachricht neu auf` : null
75 if (!l.letzteAnfrage || !l.ctx) return null
76 if (anzeige === 'knapp' && l.ctx < ANZEIGE_AB) return null
77 if (istKalt(l, now)) return `Cache kalt seit ${fmtDauer(now - l.letzteAnfrage - TTL_MS)} · die nächste Nachricht liest ${fmtTok(l.ctx)} Tokens neu ein`
78 const rest = l.letzteAnfrage + TTL_MS - now
79 if (anzeige === 'knapp' && rest > KNAPP_MS) return null
80 return `Cache warm · noch ${fmtDauer(rest)} · ${fmtTok(l.ctx)} Tokens`
81}
82
83/** Kästchen für die Stunde: voll gerade erst benutzt, leer kurz vorm Abkühlen. */
84export function balken(rest: number, zellen = 10): string {
85 const voll = Math.max(0, Math.min(zellen, Math.ceil((rest / TTL_MS) * zellen)))
86 return '▰'.repeat(voll) + '▱'.repeat(zellen - voll)
87}
88
89/** Die kurze Angabe für die Fußzeile; dieselben Regeln wie bandText, nur knapper. */
90export function modusText(l: Lage, now: number, anzeige: Anzeige): string | null {
91 if (!bandText(l, now, anzeige)) return null
92 if (l.kompaktiert) return 'Cache 🗜 komprimiert'
93 if (istKalt(l, now)) return `❄ Cache kalt · ${fmtTok(l.ctx)}`
94 // Fünf Kästchen: die Fußzeile ist schmal, und eins steht für 12 min.
95 const rest = l.letzteAnfrage + TTL_MS - now
96 return `Cache ${balken(rest, 5)} ${fmtDauer(rest)}`
97}
98
99/** Grün mit Luft, gelb in den letzten 15 min: so wenig Farbe wie möglich. */
100export function farbe(rest: number): 'success' | 'warning' {
101 return rest > 15 * MIN ? 'success' : 'warning'
102}
103
104type TgZugang = { token: string; chat: string }
105type TgUpdate = {
106 update_id: number
107 callback_query?: { id: string; data?: string; message?: { message_id?: number; chat?: { id?: number } } }
108 message?: { text?: string }
109}
110type Frage = { kennung: string; messageId: number; bis: number; text: string }
111
112/** Was eine Sitzung im Store hinterlässt, damit ein Neuladen der Mod nahtlos weitermacht. */
113type Merk = { zeit: number; kompakt: boolean; gefragt: number; frage: Frage | null; at: number }
114
115/** Liest ~/.claude/telegram.env, dieselbe Datei wie telegram-draht und ~/.claude/bin/tg. */
116export function leseZugang(text: string): TgZugang | null {
117 const werte: Record<string, string> = {}
118 for (const zeile of text.split('\n')) {
119 const m = /^\s*(?:export\s+)?([A-Z_]+)=(.*)$/.exec(zeile)
120 if (m?.[1] && m[2] !== undefined) werte[m[1]] = m[2].trim().replace(/^(['"])(.*)\1$/, '$2')
121 }
122 const token = werte.TELEGRAM_BOT_TOKEN
123 const chat = werte.TELEGRAM_CHAT_ID
124 return token && chat ? { token, chat } : null
125}
126
127type Angehalten = { kaltSeit: number; ctx: number; probe: boolean; at: number }
128
129type Zustand = {
130 lage: Lage
131 anzeige: Anzeige
132 ort: Ort
133 warnung: boolean
134 telegram: boolean
135 /** Die Zeit der letzten Anfrage, deren Warnung schon gezeigt wurde: die zweite Eingabe geht durch. */
136 quittiert: number
137 laeuft: boolean
138 projekt: string
139 zugang: TgZugang | null | undefined
140 offset: number
141 frage: Frage | null
142 fragt: boolean
143 /** Die letzte Anfrage, zu der schon gefragt wurde: „Nichts“ heißt nichts, auch nach einem Neuladen. */
144 gefragt: number
145 /** Nach einem Sendefehler erst ab dann wieder versuchen. */
146 naechsterVersuch: number
147 /** Seit wann ein Abholen läuft; 0 = keins. */
148 holtSeit: number
149 tgFehler: string
150 /** Schon beantwortete Knopfdrücke: zwei Sitzungen können denselben abholen. */
151 erledigt: string[]
152 /** Seit wann ein Update für telegram-draht liegt (nach update_id). */
153 fremdSeit: Map<number, number>
154 /** Die nächste Eingabe wird zur Probe angehalten, auch bei warmem Cache. */
155 warnTest: boolean
156 /** Die zuletzt angehaltene Nachricht: die Karte über der Eingabe zeigt sie. */
157 angehalten: Angehalten | null
158 /** Ob die letzte Messung Abo-Limits kannte; null = noch keine Messung. */
159 abo: boolean | null
160}
161
162function neuerZustand(): Zustand {
163 return {
164 lage: { letzteAnfrage: 0, ctx: 0, kompaktiert: false }, anzeige: 'knapp', ort: 'unten', warnung: true, telegram: true,
165 quittiert: 0, laeuft: false, projekt: '', zugang: undefined, offset: 0, frage: null, fragt: false,
166 gefragt: 0, naechsterVersuch: 0, holtSeit: 0, tgFehler: '', erledigt: [], fremdSeit: new Map(), warnTest: false, angehalten: null, abo: null,
167 }
168}
169
170/** Alles, was zu genau einer Unterhaltung gehört; nach /clear beginnt es neu. */
171function vergiss(z: Zustand) {
172 z.lage = { letzteAnfrage: 0, ctx: 0, kompaktiert: false }
173 z.quittiert = 0
174 z.gefragt = 0
175 z.angehalten = null
176}
177
178function kennung(): string {
179 const b = new Uint8Array(4)
180 crypto.getRandomValues(b)
181 return [...b].map(x => x.toString(16).padStart(2, '0')).join('')
182}
183
184async function zugangLesen($: EngineInterface, z: Zustand): Promise<TgZugang | null> {
185 if (z.zugang !== undefined) return z.zugang
186 const home = await $.env.get('HOME')
187 try {
188 z.zugang = home ? leseZugang(await $.fs.read(`${home}/.claude/telegram.env`)) : null
189 } catch {
190 z.zugang = null
191 }
192 return z.zugang
193}
194
195// Erst nachdem das Feld geleert ist, den Text zurücklegen. Wo es kein Feld
196// gibt, das sich füllen lässt, steht er wenigstens zum Kopieren im Verlauf.
197function haltAn($: EngineInterface, text: string) {
198 $.clock.after(150, () => {
199 void $.prompt.fill({ text }).then(r => {
200 if (!r.isFilled) $.ui.log(`Dein angehaltener Text:\n${text}`)
201 })
202 })
203}
204
205// /clear und Fortsetzen wechseln die Sitzungskennung im selben Prozess:
206// deshalb jedes Mal frisch fragen statt sie beim Start zu merken.
207async function merkKey($: EngineInterface): Promise<string> {
208 return `sitzung:${await $.session.id()}`
209}
210
211async function speichern($: EngineInterface, z: Zustand) {
212 const m: Merk = { zeit: z.lage.letzteAnfrage, kompakt: z.lage.kompaktiert, gefragt: z.gefragt, frage: z.frage, at: await $.clock.now() }
213 await $.store.set(await merkKey($), m)
214}
215
216/** Holt zurück, was diese Sitzung vor dem Neuladen wusste. */
217async function laden($: EngineInterface, z: Zustand, now: number) {
218 const m = await $.store.get(await merkKey($)) as Merk | undefined
219 if (!m) {
220 // Die erste Fassung der Mod merkte sich nur die Zeit, unter eigenem Schlüssel.
221 const alt = await $.store.get(`zeit:${await $.session.id()}`)
222 if (typeof alt === 'number' && !z.lage.letzteAnfrage) z.lage.letzteAnfrage = alt
223 return
224 }
225 if (m.zeit && !z.lage.letzteAnfrage) z.lage.letzteAnfrage = m.zeit
226 z.lage.kompaktiert = m.kompakt
227 z.gefragt = m.gefragt
228 if (m.frage && m.frage.bis > now) z.frage = m.frage
229}
230
231async function aufraeumen($: EngineInterface, now: number) {
232 for (const key of await $.store.keys()) {
233 // Was eine frühere Fassung der Mod hinterließ.
234 if (key === 'diag' || key === 'blick' || key === 'blickOben' || /^(zeit|kompakt|frage):/.test(key)) await $.store.delete(key)
235 else if (key.startsWith('antwort:') || key.startsWith('sitzung:')) {
236 const v = await $.store.get(key) as { at?: number } | undefined
237 // Antworten, die keine Sitzung abgeholt hat, und Sitzungen von gestern.
238 const alt = key.startsWith('antwort:') ? 2 * TTL_MS : 24 * TTL_MS
239 if (!v?.at || now - v.at > alt) await $.store.delete(key)
240 }
241 }
242}
243
244// Die Fehlermeldung nennt nie den Bot-Token: er steckt in der URL, und manche
245// Netzfehler zitieren sie.
246async function tg($: EngineInterface, z: Zustand, methode: string, body: object): Promise<unknown> {
247 const zg = await zugangLesen($, z)
248 if (!zg) throw new Error('keine Zugangsdaten in ~/.claude/telegram.env')
249 let r
250 try {
251 r = await $.http.fetch(`https://api.telegram.org/bot${zg.token}/${methode}`, {
252 method: 'POST',
253 headers: { 'content-type': 'application/json' },
254 body: JSON.stringify(body),
255 })
256 } catch (err) {
257 const text = (err instanceof Error ? err.message : String(err)).split(zg.token).join('…')
258 throw new Error(`Telegram ${methode}: nicht erreichbar (${text})`)
259 }
260 let d: { ok?: boolean; result?: unknown; description?: string } | null = null
261 try { d = JSON.parse(r.text) } catch { /* bleibt null */ }
262 if (!d?.ok) throw new Error(`Telegram ${methode}: ${r.status} ${d?.description ?? ''}`.trim())
263 z.tgFehler = ''
264 return d.result
265}
266
267async function tgLeise($: EngineInterface, z: Zustand, methode: string, body: object) {
268 try { await tg($, z, methode, body) } catch (err) { merkeFehler($, z, err) }
269}
270
271// Fürs Aufräumen fremder oder doppelter Knöpfe: dort ist ein Fehler
272// („query is too old“, „message is not modified“) erwartbar und kein Befund.
273async function tgStill($: EngineInterface, z: Zustand, methode: string, body: object) {
274 try { await tg($, z, methode, body) } catch { /* bewusst still */ }
275}
276
277function merkeFehler($: EngineInterface, z: Zustand, err: unknown) {
278 const text = err instanceof Error ? err.message : String(err)
279 if (text !== z.tgFehler) $.ui.log(`cache-waechter: ${text}`, { to: 'debug' })
280 z.tgFehler = text
281}
282
283function neuZeichnen($: EngineInterface) {
284 $.ui.invalidate('ui.render')
285}
286
287async function schliesseFrage($: EngineInterface, z: Zustand, zusatz: string) {
288 const f = z.frage
289 if (!f) return
290 z.frage = null
291 // Erst die Nachricht: beim Beenden bleibt nur gut eine Sekunde.
292 const zg = await zugangLesen($, z)
293 if (zg) await tgLeise($, z, 'editMessageText', { chat_id: zg.chat, message_id: f.messageId, text: `${f.text}\n\n${zusatz}` })
294 await speichern($, z)
295}
296
297/** Ob jetzt die Frage vor dem Abkühlen fällig ist; der Abholtakt fragt das alle 4 s. */
298function faellig(z: Zustand, now: number): boolean {
299 const l = z.lage
300 if (!z.telegram || z.laeuft || z.frage || z.fragt || l.kompaktiert || l.ctx < TELEGRAM_AB || !l.letzteAnfrage) return false
301 if (z.gefragt === l.letzteAnfrage || now < z.naechsterVersuch) return false
302 const alter = now - l.letzteAnfrage
303 return alter >= FRAGE_NACH_MS && TTL_MS - alter >= FRAGE_MIN_REST_MS
304}
305
306async function stelleFrage($: EngineInterface, z: Zustand, test: boolean): Promise<string> {
307 if (z.fragt) return 'Eine Frage wird gerade geschickt.'
308 const zg = await zugangLesen($, z)
309 if (!zg) return 'Keine Zugangsdaten: ~/.claude/telegram.env braucht TELEGRAM_BOT_TOKEN und TELEGRAM_CHAT_ID.'
310 z.fragt = true
311 try {
312 if (z.frage) await schliesseFrage($, z, 'Ersetzt durch eine neue Frage.')
313 const now = await $.clock.now()
314 const id = kennung()
315 const rest = z.lage.letzteAnfrage && !istKalt(z.lage, now) ? z.lage.letzteAnfrage + TTL_MS - now : 0
316 const text = [
317 test ? '🧪 Testfrage, die Knöpfe wirken echt' : `🧊 Cache kühlt in ${fmtDauer(rest)} ab`,
318 `📁 ${z.projekt || 'Claude Code'} · ${fmtTok(z.lage.ctx)} Tokens`,
319 '',
320 'Was soll passieren?',
321 ].join('\n')
322 const msg = await tg($, z, 'sendMessage', {
323 chat_id: zg.chat,
324 text,
325 reply_markup: {
326 inline_keyboard: [[
327 { text: '🗜 Komprimieren', callback_data: `cw:${id}:kompakt` },
328 { text: '🔥 Warmhalten', callback_data: `cw:${id}:warm` },
329 { text: 'Nichts', callback_data: `cw:${id}:nichts` },
330 ]],
331 },
332 }) as { message_id: number }
333 // Die Testfrage gilt zehn Minuten, eine echte bis der Cache kalt ist.
334 z.frage = { kennung: id, messageId: msg.message_id, bis: test || !rest ? now + 10 * MIN : now + rest, text }
335 if (!test) z.gefragt = z.lage.letzteAnfrage
336 await speichern($, z)
337 return 'Frage geschickt.'
338 } catch (err) {
339 merkeFehler($, z, err)
340 z.naechsterVersuch = (await $.clock.now()) + NEUVERSUCH_MS
341 return err instanceof Error ? err.message : String(err)
342 } finally {
343 z.fragt = false
344 }
345}
346
347/** Ob irgendeine Sitzung diese Frage noch offen hat. */
348async function offenAnderswo($: EngineInterface, id: string, now: number): Promise<boolean> {
349 for (const key of await $.store.keys()) {
350 if (!key.startsWith('sitzung:')) continue
351 const m = await $.store.get(key) as Merk | undefined
352 if (m?.frage?.kennung === id && m.frage.bis > now) return true
353 }
354 return false
355}
356
357// Kurzes Abfragen statt Long-Polling: zwei Sitzungen, die gleichzeitig lange
358// warten, wirft Telegram mit 409 raus. Der Offset liegt im gemeinsamen Store,
359// damit keine Sitzung Knopfdrücke abholt, die eine andere schon erledigt hat;
360// Antworten für andere Sitzungen landen dort ebenfalls.
361async function hole($: EngineInterface, z: Zustand) {
362 const jetzt = await $.clock.now()
363 if (!z.frage || (z.holtSeit && jetzt - z.holtSeit < HAENGT_MS)) return
364 z.holtSeit = jetzt
365 try {
366 const zg = await zugangLesen($, z)
367 if (!zg) return
368 const gemeinsam = await $.store.get('tg:offset')
369 z.offset = Math.max(z.offset, typeof gemeinsam === 'number' ? gemeinsam : 0)
370 const updates = await tg($, z, 'getUpdates', { offset: z.offset, timeout: 0 }) as TgUpdate[]
371 let frei = true
372 for (const u of updates) {
373 // Knöpfe und Texte der Mod telegram-draht („td:“, Antworten) liegen
374 // lassen, bis sie sie holt: der Offset quittiert alles darunter für alle.
375 const fuerDraht = u.callback_query?.data?.startsWith('td:') || u.message?.text !== undefined
376 if (fuerDraht && !verwaist(z, u.update_id, jetzt)) frei = false
377 if (frei) z.offset = Math.max(z.offset, u.update_id + 1)
378 if (fuerDraht) continue
379 const cq = u.callback_query
380 if (!cq?.data?.startsWith('cw:') || String(cq.message?.chat?.id) !== zg.chat) continue
381 if (z.erledigt.includes(cq.id)) continue
382 z.erledigt = [...z.erledigt.slice(-19), cq.id]
383 const [, id = '', aktion = ''] = cq.data.split(':')
384 if (!AKTIONEN.includes(aktion)) continue
385 const now = await $.clock.now()
386 if (z.frage && id === z.frage.kennung) await beantworte($, z, aktion as Aktion, cq.id)
387 else if (await offenAnderswo($, id, now)) await $.store.set(`antwort:${id}`, { aktion, cq: cq.id, at: now })
388 else await alterKnopf($, z, zg, cq.id, cq.message?.message_id)
389 }
390 if (updates.length) await $.store.set('tg:offset', z.offset)
391 for (const id of z.fremdSeit.keys()) if (id < z.offset) z.fremdSeit.delete(id)
392 if (z.frage) {
393 const key = `antwort:${z.frage.kennung}`
394 const fremd = await $.store.get(key) as { aktion: Aktion; cq: string } | undefined
395 if (fremd) {
396 await $.store.delete(key)
397 await beantworte($, z, fremd.aktion, fremd.cq)
398 }
399 }
400 if (z.frage && (await $.clock.now()) >= z.frage.bis) await schliesseFrage($, z, '❄ Abgekühlt, ohne Antwort.')
401 } catch (err) {
402 merkeFehler($, z, err)
403 } finally {
404 z.holtSeit = 0
405 }
406}
407
408/** Ob ein Update für telegram-draht schon eine Minute liegt: dann holt es offenbar niemand mehr. */
409function verwaist(z: Zustand, id: number, now: number): boolean {
410 const seit = z.fremdSeit.get(id)
411 if (seit === undefined) {
412 z.fremdSeit.set(id, now)
413 return false
414 }
415 return now - seit >= MIN
416}
417
418// Ein Knopf unter einer Frage, die keine Sitzung mehr hat (beendet, doppelt
419// gedrückt): sonst dreht er auf dem Handy ewig und wirkt kaputt.
420async function alterKnopf($: EngineInterface, z: Zustand, zg: TgZugang, cqId: string, messageId?: number) {
421 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: cqId, text: 'Diese Frage ist schon erledigt.' })
422 if (messageId) await tgStill($, z, 'editMessageReplyMarkup', { chat_id: zg.chat, message_id: messageId, reply_markup: { inline_keyboard: [] } })
423}
424
425async function beantworte($: EngineInterface, z: Zustand, aktion: Aktion, cqId: string) {
426 const f = z.frage
427 if (!f) return
428 z.frage = null
429 // Sofort quittieren: Telegram lässt den Knopf sonst drehen, und Komprimieren dauert.
430 await tgStill($, z, 'answerCallbackQuery', { callback_query_id: cqId, text: aktion === 'nichts' ? 'Okay' : 'Wird erledigt …' })
431 let ergebnis: string
432 try {
433 ergebnis = aktion === 'kompakt' ? await kompaktiere($, z)
434 : aktion === 'warm' ? await halteWarm($, z)
435 : '👍 Okay, er darf abkühlen.'
436 } catch (err) {
437 // Was auch schiefgeht: die Nachricht bekommt eine Antwort und die Frage wird geschlossen.
438 merkeFehler($, z, err)
439 ergebnis = `⚠️ Ging nicht: ${err instanceof Error ? err.message : String(err)}`
440 }
441 z.frage = f
442 await schliesseFrage($, z, ergebnis)
443}
444
445/** Nach dem Komprimieren: keine Uhr mehr, bis der nächste Zug den Cache neu aufbaut. */
446async function merkeKompakt($: EngineInterface, z: Zustand, nachher: number | undefined) {
447 z.lage.kompaktiert = true
448 if (nachher) z.lage.ctx = nachher
449 await speichern($, z)
450 neuZeichnen($)
451}
452
453async function kompaktiere($: EngineInterface, z: Zustand): Promise<string> {
454 if (z.laeuft) return 'Gerade läuft eine Antwort, der Cache bleibt dadurch ohnehin warm.'
455 const vorher = z.lage.ctx
456 let r
457 try {
458 r = await $.session.compact()
459 } catch {
460 // Die Desktop-App läuft als SDK-Sitzung („not available in a headless
461 // session“): dort komprimiert nur der Befehl /compact. Als Prompt
462 // eingereicht lehnt die Engine ihn ab (Text mit „/“ vorn).
463 await $.command.run({ command: 'compact' })
464 // Der Befehl meldet sein Ergebnis über session.compact, das merkeKompakt aufruft.
465 return z.lage.kompaktiert
466 ? `✅ Komprimiert: ${fmtTok(vorher)} → ${fmtTok(z.lage.ctx)} Tokens.`
467 : `🗜 /compact läuft in der Sitzung (${fmtTok(vorher)} Tokens vorher).`
468 }
469 if (r.skip !== undefined) return `Nicht komprimiert: ${r.skip}`
470 // Nicht auf session.compact verlassen: ob der Hook bei einem Aufruf aus
471 // der Mod selbst feuert, ist nicht zugesichert.
472 await merkeKompakt($, z, r.tokensAfter)
473 return `✅ Komprimiert: ${fmtTok(r.tokensBefore ?? vorher)} → ${fmtTok(r.tokensAfter ?? z.lage.ctx)} Tokens.`
474}
475
476async function halteWarm($: EngineInterface, z: Zustand): Promise<string> {
477 if (z.laeuft) return 'Gerade läuft eine Antwort, der Cache bleibt dadurch ohnehin warm.'
478 // Der Fork schickt die letzte Anfrage des Hauptverlaufs noch einmal, samt
479 // ihrer Cache-Marken: so frischt er genau den Eintrag auf, den die Sitzung braucht.
480 const r = await $.model.fork({ prompt: PING_PROMPT })
481 if (!r.isAnswered) return `⚠️ Warmhalten ging nicht (${r.reason}).`
482 const u = r.usage
483 const now = await $.clock.now()
484 z.lage.letzteAnfrage = now
485 await speichern($, z)
486 neuZeichnen($)
487 const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
488 return warm
489 ? `🔥 Warmgehalten: ${fmtTok(u.cache_read_input_tokens)} Tokens aus dem Cache gelesen. In 50 min frage ich wieder.`
490 : `🔥 Der Cache war schon weg: ${fmtTok(u.cache_creation_input_tokens)} Tokens neu geschrieben, jetzt ist er wieder warm.`
491}
492
493function karte(z: Zustand, now: number): string {
494 const l = z.lage
495 const zustand = !l.letzteAnfrage ? 'noch keine Anfrage in dieser Sitzung'
496 : l.kompaktiert ? `komprimiert auf ${fmtTok(l.ctx)} Tokens, die nächste Nachricht baut ihn neu auf`
497 : istKalt(l, now) ? `kalt seit ${fmtDauer(now - l.letzteAnfrage - TTL_MS)} · ${fmtTok(l.ctx)} Tokens`
498 : `warm, noch ${fmtDauer(l.letzteAnfrage + TTL_MS - now)} · ${fmtTok(l.ctx)} Tokens`
499 const anz = z.anzeige === 'knapp' ? `knapp – ab ${fmtTok(ANZEIGE_AB)} Tokens in den letzten ${KNAPP_MS / MIN} min und wenn kalt`
500 : z.anzeige === 'immer' ? 'immer' : 'aus'
501 const wo = z.anzeige === 'aus' ? '' : z.ort === 'unten' ? ', unten rechts in der Fußzeile' : ', als Zeile über der Eingabe'
502 let tele: string
503 if (!z.telegram) tele = 'aus'
504 else if (z.zugang === null) tele = 'an, aber ~/.claude/telegram.env fehlt oder ist unvollständig'
505 else {
506 tele = `an – fragt nach 50 min, ab ${fmtTok(TELEGRAM_AB)} Tokens`
507 if (z.frage) tele += ` · Frage offen, noch ${fmtDauer(z.frage.bis - now)}`
508 else if (l.ctx >= TELEGRAM_AB && !l.kompaktiert && l.letzteAnfrage && z.gefragt !== l.letzteAnfrage && now - l.letzteAnfrage < FRAGE_NACH_MS)
509 tele += ` · nächste in ${fmtDauer(l.letzteAnfrage + FRAGE_NACH_MS - now)}`
510 if (z.tgFehler) tele += `\n letzter Fehler: ${z.tgFehler}`
511 }
512 const zeilen = [
513 `Cache ${zustand}`,
514 `Anzeige ${anz}${wo}`,
515 `Warnung ${z.warnung ? `an – hält Nachrichten in einen kalten Cache ab ${fmtTok(WARN_AB)} Tokens einmal an` : 'aus'}`,
516 `Telegram ${tele}`,
517 ]
518 // Ohne Abo-Limits ist es vermutlich ein API-Schlüssel: dort gilt die Stunde nur mit promptCacheTtl.
519 if (z.abo === false) zeilen.push('', 'Hinweis: kein Abo erkannt. Ohne Abo hält der Cache 5 min, außer promptCacheTtl steht auf "1h".')
520 zeilen.push('', '/cache anzeige aus|knapp|immer|unten|oben · warnung an|aus|test · telegram an|aus|test')
521 return zeilen.join('\n')
522}
523
524export const register: Register = on => {
525 const z = neuerZustand()
526
527 on('session.start', async ($, e, next) => {
528 const r = await next(e)
529 const now = await $.clock.now()
530 z.projekt = e.cwd.replace(/\/+$/, '').split('/').pop() ?? ''
531 const a = await $.store.get('anzeige')
532 z.anzeige = a === 'aus' || a === 'immer' ? a : 'knapp'
533 z.ort = (await $.store.get('ort')) === 'oben' ? 'oben' : 'unten'
534 z.warnung = (await $.store.get('warnung')) !== false
535 z.telegram = (await $.store.get('telegram')) !== false
536 // Ein Neuladen der Mod beginnt mit leerem Zustand: die Uhr liegt im Store,
537 // den Kontext kennt die Engine (außer nach /compact, da zählt die letzte
538 // Antwort noch den alten Verlauf).
539 await laden($, z, now)
540 const live = (await $.session.usage()).context.tokens
541 if (live && live > 0 && !z.lage.kompaktiert) z.lage.ctx = live
542 await aufraeumen($, now)
543 await $.command.register({
544 name: 'cache',
545 description: 'Prompt-Cache: Zustand, Anzeige, Warnung und Telegram-Frage (cache-waechter)',
546 argumentHint: '[anzeige aus|knapp|immer|unten|oben | warnung an|aus|test | telegram an|aus|test]',
547 immediate: true,
548 })
549 // Ein Takt für alles Telegram: er stellt die Frage, wenn sie fällig ist, und
550 // holt Antworten ab, solange sie offen ist. Er läuft ab Sitzungsstart; einer,
551 // den ein Befehl startet, überlebt dessen Ende nicht verlässlich, und ein
552 // einmaliger Wecker ginge beim Neuladen der Mod verloren.
553 $.clock.every(POLL_MS, () => {
554 if (z.frage) return void hole($, z)
555 void $.clock.now().then(t => { if (faellig(z, t)) void stelleFrage($, z, false) })
556 })
557 $.clock.every(TICK_MS, () => {
558 if (z.anzeige !== 'aus' || z.angehalten) neuZeichnen($)
559 })
560 return r
561 })
562
563 // /clear: die Unterhaltung endet, der Prozess läuft unter neuer Kennung weiter.
564 on('session.end', async ($, e, next) => {
565 await schliesseFrage($, z,
566 e.reason === 'clear' ? '🧹 Sitzung mit /clear geleert.'
567 : e.reason === 'resume' ? '↪ Eine andere Sitzung wurde geöffnet.'
568 : '👋 Sitzung beendet.')
569 if (e.reason === 'clear' || e.reason === 'resume') vergiss(z)
570 neuZeichnen($)
571 return next(e)
572 })
573
574 // Fortgesetzte Sitzungen bringen ihr Alter mit, bevor der erste Zug läuft.
575 on('classic.SessionStart', async ($, e, next) => {
576 const r = await next(e)
577 const now = await $.clock.now()
578 if (e.source === 'clear') {
579 await schliesseFrage($, z, '🧹 Sitzung mit /clear geleert.')
580 vergiss(z)
581 } else if (e.source === 'resume' || e.source === 'fork') {
582 vergiss(z)
583 await laden($, z, now)
584 if (typeof e.context_tokens === 'number') z.lage.ctx = e.context_tokens
585 if (typeof e.seconds_since_last_response === 'number') z.lage.letzteAnfrage = now - e.seconds_since_last_response * 1000
586 z.lage.kompaktiert = false
587 }
588 neuZeichnen($)
589 return r
590 })
591
592 // Die Engine misst nach jeder Antwort: so stimmt der Kontext auch zwischen den Zügen.
593 on('session.measure', async ($, e, next) => {
594 const r = await next(e)
595 const t = e.context.tokens
596 if (e.changed.includes('context') && t && t > 0 && !z.lage.kompaktiert) z.lage.ctx = t
597 if (e.changed.includes('rateLimits') || z.abo === null) z.abo = e.rateLimits.length > 0
598 return r
599 })
600
601 on('command.run', { command: 'cache' }, async ($, e) => {
602 const [wort = '', roh = ''] = String(e.args ?? '').trim().toLowerCase().split(/\s+/)
603 // Vertipper wie „anzeigen“ oder „einblenden“ meinen dasselbe wie „anzeige“.
604 const was = wort === 'anzeigen' || wort === 'einblenden' ? 'anzeige' : wort
605 // „anzeige an“ meint „immer“, „ein“ meint „an“.
606 const wert = was === 'anzeige' && (roh === 'an' || roh === 'ein') ? 'immer' : roh === 'ein' ? 'an' : roh
607 const now = await $.clock.now()
608 if (was === 'anzeige') {
609 if (wert === 'unten' || wert === 'oben') {
610 z.ort = wert
611 await $.store.set('ort', wert)
612 neuZeichnen($)
613 return { text: wert === 'unten' ? 'Anzeige: unten rechts in der Fußzeile' : 'Anzeige: als Zeile über der Eingabe' }
614 }
615 if (wert !== 'aus' && wert !== 'knapp' && wert !== 'immer') return { text: 'Anzeige: aus, knapp, immer, unten oder oben?' }
616 z.anzeige = wert
617 await $.store.set('anzeige', wert)
618 neuZeichnen($)
619 return { text: `Anzeige: ${wert}` }
620 }
621 if (was === 'warnung') {
622 if (wert === 'test') {
623 z.warnTest = true
624 return { text: 'Die nächste Nachricht wird zur Probe einmal angehalten.' }
625 }
626 if (wert !== 'an' && wert !== 'aus') return { text: 'Warnung: an, aus oder test?' }
627 z.warnung = wert === 'an'
628 await $.store.set('warnung', z.warnung)
629 return { text: `Warnung: ${wert}` }
630 }
631 if (was === 'telegram') {
632 if (wert === 'test') return { text: await stelleFrage($, z, true) }
633 if (wert !== 'an' && wert !== 'aus') return { text: 'Telegram: an, aus oder test?' }
634 z.telegram = wert === 'an'
635 await $.store.set('telegram', z.telegram)
636 if (!z.telegram) await schliesseFrage($, z, 'Telegram-Fragen wurden ausgeschaltet.')
637 return { text: `Telegram: ${wert}` }
638 }
639 await zugangLesen($, z)
640 const unbekannt = was && was !== 'hilfe' && was !== 'help' && was !== 'status' ? `„${String(e.args).trim()}“ kenne ich nicht.\n\n` : ''
641 return { text: unbekannt + karte(z, now) }
642 })
643
644 // turn.start kommt nur für den Hauptverlauf; Subagenten haben ihren eigenen Cache.
645 on('turn.start', async ($, e, next) => {
646 z.laeuft = true
647 z.angehalten = null
648 await schliesseFrage($, z, '💬 Du hast in der Sitzung weitergeschrieben.')
649 return next(e)
650 })
651
652 on('turn.step', async function* ($, e, next) {
653 if (!e.agentId) z.lage.letzteAnfrage = await $.clock.now()
654 return yield* next(e)
655 })
656
657 on('turn.complete', async ($, e, next) => {
658 const r = await next(e)
659 if (e.agentId) return r
660 const now = await $.clock.now()
661 z.laeuft = false
662 z.quittiert = 0
663 // turn.step stempelt die genaue Anfragezeit; fehlt der Stempel, gilt das
664 // Zugende. Ein abgebrochener Zug ohne Schritt hat nichts angefragt.
665 const ohneStempel = now - z.lage.letzteAnfrage > e.durationMs
666 const angefragt = !ohneStempel || e.reason !== 'aborted'
667 if (angefragt) {
668 if (ohneStempel) z.lage.letzteAnfrage = now
669 z.lage.kompaktiert = false
670 }
671 const live = (await $.session.usage()).context.tokens
672 const u = e.usage
673 if (!z.lage.kompaktiert) {
674 if (live && live > 0) z.lage.ctx = live
675 else if (u) z.lage.ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
676 }
677 await speichern($, z)
678 neuZeichnen($)
679 return r
680 })
681
682 on('session.compact', async ($, e, next) => {
683 const r = await next(e)
684 if (e.agentId || r.skip !== undefined) return r
685 await merkeKompakt($, z, r.tokensAfter)
686 return r
687 })
688
689 on('prompt.submit', async ($, e, next) => {
690 if (e.origin.kind === 'plugin' || e.text.trimStart().startsWith('/')) return next(e)
691 const now = await $.clock.now()
692 // Wer nach der Warnung etwas schickt, hat sie gelesen: die Karte geht.
693 if (z.angehalten) {
694 z.angehalten = null
695 neuZeichnen($)
696 }
697 // Die Probe gilt auch bei ausgeschalteter Warnung: man will ja sehen, wie sie aussähe.
698 if (z.warnTest) {
699 z.warnTest = false
700 z.angehalten = { kaltSeit: 72 * MIN, ctx: Math.max(z.lage.ctx, 310_000), probe: true, at: now }
701 haltAn($, e.text)
702 neuZeichnen($)
703 return { drop: '❄ Probe: angehalten. Der Text liegt wieder im Feld, Enter schickt ihn.' }
704 }
705 if (!z.warnung || !istKalt(z.lage, now) || z.lage.ctx < WARN_AB) return next(e)
706 if (z.quittiert === z.lage.letzteAnfrage) {
707 z.quittiert = 0
708 return next(e)
709 }
710 const kalt = `Cache seit ${fmtDauer(now - z.lage.letzteAnfrage - TTL_MS)} kalt, die Nachricht liest ${fmtTok(z.lage.ctx)} Tokens neu ein.`
711 // Ein Anhang ließe sich nicht zurück ins Feld legen: dann nur warnen.
712 if (e.attachments?.length) {
713 $.ui.log(`❄ ${kalt} Mit Anhang geht sie trotzdem raus.`)
714 return next(e)
715 }
716 z.quittiert = z.lage.letzteAnfrage
717 z.angehalten = { kaltSeit: now - z.lage.letzteAnfrage - TTL_MS, ctx: z.lage.ctx, probe: false, at: now }
718 haltAn($, e.text)
719 neuZeichnen($)
720 return { drop: `❄ Angehalten: ${kalt} Der Text liegt wieder im Feld, Enter schickt ihn.` }
721 })
722
723 // Unten rechts reiht sich die Angabe zu den Modi der Fußzeile („focus & …“):
724 // dieselbe Stelle, dieselbe Schrift, nichts Eigenes, das zappelt.
725 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
726 if (z.ort !== 'unten') return next(e)
727 const text = modusText(z.lage, await $.clock.now(), z.anzeige)
728 if (!text) return next(e)
729 // Selbst gezeichnet statt die Liste umgeschrieben: die Desktop-App fragte
730 // die Fußzeile an, zeigte eine umgeschriebene Liste aber nicht (09.10.).
731 const { Text } = $.ui.resolve(e)
732 return <Text dimColor>{[...e.props.modes, text].join(' & ')}</Text>
733 })
734
735 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
736 const rest = await next(e)
737 if (e.props.hasSurvey || e.props.isWorking) return rest
738 const now = await $.clock.now()
739 const { Box, Text } = $.ui.resolve(e)
740 // Die Karte nach dem Anhalten: gerahmt, bis die nächste Nachricht kommt (höchstens 10 min).
741 const ah = z.angehalten
742 if (ah && now - ah.at < 10 * MIN) {
743 return (
744 <Box flexDirection="column">
745 {rest}
746 <Box key="karte" flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
747 <Text bold color="warning">{ah.probe ? '❄ Probe: so sieht die Warnung aus' : `❄ Angehalten: der Cache ist seit ${fmtDauer(ah.kaltSeit)} kalt`}</Text>
748 <Text>{`Diese Nachricht liest ${fmtTok(ah.ctx)} Tokens neu ein, statt sie aus dem Cache zu holen.`}</Text>
749 <Text dimColor>Enter schickt sie trotzdem · günstiger: /clear und in zwei Sätzen sagen, worum es ging</Text>
750 </Box>
751 </Box>
752 )
753 }
754 if (z.ort !== 'oben' || !bandText(z.lage, now, z.anzeige)) return rest
755 if (z.lage.kompaktiert) {
756 return (
757 <Box flexDirection="column">
758 {rest}
759 <Box key="cache" flexDirection="row" gap={1}>
760 <Text dimColor>Cache</Text>
761 <Text color="success">🗜 komprimiert</Text>
762 <Text dimColor>{`· ${fmtTok(z.lage.ctx)} Tokens, baut sich mit der nächsten Nachricht neu auf`}</Text>
763 </Box>
764 </Box>
765 )
766 }
767 if (istKalt(z.lage, now)) {
768 return (
769 <Box flexDirection="column">
770 {rest}
771 <Box key="cache" flexDirection="row" gap={1}>
772 <Text color="suggestion">{`❄ Cache kalt seit ${fmtDauer(now - z.lage.letzteAnfrage - TTL_MS)}`}</Text>
773 <Text dimColor>{`· die nächste Nachricht liest ${fmtTok(z.lage.ctx)} Tokens neu ein`}</Text>
774 </Box>
775 </Box>
776 )
777 }
778 const bleibt = z.lage.letzteAnfrage + TTL_MS - now
779 return (
780 <Box flexDirection="column">
781 {rest}
782 <Box key="cache" flexDirection="row" gap={1}>
783 <Text dimColor>Cache</Text>
784 <Text color={farbe(bleibt)}>{balken(bleibt)}</Text>
785 <Text bold color={farbe(bleibt)}>{fmtDauer(bleibt)}</Text>
786 <Text dimColor>{`· ${fmtTok(z.lage.ctx)} Tokens`}</Text>
787 </Box>
788 </Box>
789 )
790 })
791}
792