Clawd memory office: an animated pane that shows which memory files Claude reads and writes, across every session on this machine.

A Claude Code mod that turns Claude's memory into a little office. A pixel crab (Clawd) sits at a desk, and every time Claude reads or writes a memory file you see it happen: a filing drawer opens, a note goes up on the whiteboard, and a line lights up from the crab to the file.
Open the All sessions view and every Claude Code session on your machine gets its own crab, so you can see at a glance who is reading which memory folder, who is thinking, and who is on a coffee break.
<img src="docs/screenshots/session.png" alt="This session: the office, the memory files it touched and a log" width="400"> <img src="docs/screenshots/control.png" alt="All sessions: one crab per session and a warning for a double write" width="400">
| State | In the office |
|---|---|
| Reading memory | The drawer for that folder opens and glows; a line runs from the file to the crab (paper floats up) |
| Writing memory | The crab hops, a sticky note is added to the whiteboard, the file is marked updated |
| Thinking | Paces on the desk with a thought bubble (…, sometimes ?) |
| Running a tool | Types on the laptop; if one tool runs over 6 s, the printer starts printing |
| Idle | Stretches, then after 30 s walks to the water cooler, after 2 min has coffee, after 8 min dozes off |
<img src="docs/screenshots/states.png" alt="Thinking, running a tool, at the water cooler, dozing off" width="720">
In All sessions the crabs walk to the filing cabinet of the folder they read or write. That view knows whether another session is mid-turn, but not which tool it runs, so busy sessions show as thinking.
Each session that loads the mod appends one JSON line to ~/.claude/clawd-memory/events-YYYY-MM-DD.jsonl when it reads or writes a memory file (tool.call), when memory is loaded at the start of a prompt (prompt.context), and when a turn starts or ends. The pane checks that file every 2 seconds and only rereads it when it changed. Nothing is sent anywhere, and the mod uses no model calls, so it costs no tokens.
A path counts as memory when it is under one of:
~/.claude/projects/<folder>/memory/ (Claude Code's own memory)…/claude-shared/memory/<folder>/, …/claude-shared/memory-common/ and …/claude-shared/memory-index.md (a shared layout, for memory kept in a synced folder)Reads and writes through Read, Edit, Write and friends are exact. Through Bash the mod guesses: a command with >, tee, sed -i, mv, cp, rm and the like counts as a write, anything else as a read.
claude --plugin-dir /path/to/clawd-memoryCLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json and start a new session (separate several folders with :): { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/clawd-memory" } }
For All sessions to show other sessions, they need the mod too, which the env block gives every new session.
/clawd, or /clawd all for every session. The buttons above the office switch between the two views.~/.claude/settings.json under pluginConfigs.clawd-memory.options:| Option | Default | Meaning |
|---|---|---|
language | auto | auto (follows LANG), en, zh-TW |
default_view | session | session or control (all sessions) |
The office is drawn as SVG, so it needs the desktop app or another surface that draws images; in a terminal the pane falls back to text. It animates at 8 frames a second while something happens and 4 while idle, and only while the pane is in view.
claude plugin validate .
claude plugin test .
| File | What it holds |
|---|---|
.claude-plugin/plugin.json | Manifest and options |
hooks/register.tsx | Hooks, the shared event file, the scene and the pane |
hooks/office.ts | The drawings: office, crab sprites, props, the whole card |
hooks/memory.ts | Memory path matching, event parsing, double-write detection |
hooks/i18n.ts | English and Traditional Chinese text |
types/index.d.ts | $.state contract |
tests/clawd-memory.test.ts | Plugin tests |
MIT
hooks/register.tsx 464 lines1import { atom, read, update } from 'claude-code'
2import type { ElementConstructor, EngineInterface, Register, SvgProps } from 'claude-code'
3
4import type { LocalState, MemEvent, MemOp, TurnMark, ViewMode } from '../types'
5import { activeTurns, baseName, eventsFile, findConflicts, islandColor, islandLabel, matchMemory, opOf, parseEvents, parseTurns, sessionLabel } from './memory'
6import type { Conflict } from './memory'
7import { cardSvg } from './office'
8import { asLanguage, pickLang, STRINGS } from './i18n'
9import type { LanguageOption, Strings } from './i18n'
10import type { CrabMood, LogRow, OfficeCrab, OfficeIsland, Spot, TreeRow } from './office'
11
12const PANE = 'clawd-memory'
13const VIEW_KEY = { plugin: 'clawd-memory', key: 'view' } as const
14const events = atom({ plugin: 'clawd-memory', key: 'events' } as const, [] as MemEvent[])
15const frame = atom({ plugin: 'clawd-memory', key: 'frame' } as const, 0)
16const view = atom(VIEW_KEY, 'session' as ViewMode)
17const turns = atom({ plugin: 'clawd-memory', key: 'turns' } as const, [] as TurnMark[])
18const local = atom({ plugin: 'clawd-memory', key: 'local' } as const, { turn: false, tool: null, turnAt: 0, idleAt: 0, toolAt: 0 } as LocalState)
19
20/** 有動靜時每秒 8 格,待命時每秒 1 格(時鐘、眨眼)。 */
21const FPS = 8
22/** 螃蟹一趟:走過去、停著讀寫、走回座位(毫秒)。 */
23const WALK = 700
24const STAY = 1800
25const BACK = 700
26const TRIP = WALK + STAY + BACK
27/** 總控只看最近 30 分鐘有動靜的 session。 */
28const ACTIVE_MS = 30 * 60_000
29const SESSION_COLORS = ['#D97757', '#B794F4', '#22D3EE', '#F5C451', '#86EFAC']
30
31let home = ''
32let lastFile = ''
33let lastSize = -1
34let lastEventTs = 0
35/** 這個 session 或別的 session 正在回合中:思考動畫要用較高的影格數。 */
36let anyTurn = false
37let localTurn = false
38let stageSeenAt = 0
39let tickN = 0
40let defaultView: ViewMode = 'session'
41let langOption: LanguageOption = 'auto'
42/** 目前的介面文字:每次畫面板、跑指令前依 language 選項與 LANG 決定。 */
43let T: Strings = STRINGS['zh-TW']
44const lbl = (island: string) => islandLabel(island, T.names)
45/** 這個 session 已記過的自動載入檔(每輪都會觸發 prompt.context,只記第一次)。 */
46const autoloaded = new Set<string>()
47/** 已提醒過的同時寫入。 */
48const warned = new Set<string>()
49
50// ---- 寫入端:每個 session 把自己的記憶讀寫追加到同一個事件檔 ----------------
51
52async function useLang($: EngineInterface): Promise<void> {
53 const env = `${(await $.env.get('LC_ALL')) ?? ''} ${(await $.env.get('LANG')) ?? ''}`
54 T = STRINGS[pickLang(langOption, env)]
55}
56
57async function ensureHome($: EngineInterface): Promise<string> {
58 if (home) return home
59 const r = await $.process.run(['/bin/sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 3_000 })
60 home = r.stdout.trim()
61 return home
62}
63
64async function append($: EngineInterface, ev: MemEvent | TurnMark): Promise<void> {
65 const dir = await ensureHome($)
66 if (!dir) return
67 // O_APPEND 寫一行小於 PIPE_BUF 是原子的,幾個 session 同時寫不會互相蓋掉
68 await $.process.run(['/bin/sh', '-c', 'mkdir -p "$(dirname "$2")" && printf "%s\\n" "$1" >> "$2"', 'sh', JSON.stringify(ev), eventsFile(dir)], { timeoutMs: 5_000 })
69}
70
71async function record($: EngineInterface, tool: string, raw: string, op: MemOp, agentId?: string): Promise<void> {
72 const hit = matchMemory(raw)
73 if (!hit) return
74 const ev: MemEvent = {
75 ts: Date.now(),
76 session: await $.session.id(),
77 cwd: await $.session.cwd(),
78 tool,
79 op,
80 path: tool === 'Bash' ? hit.path : raw,
81 island: hit.island,
82 ...(agentId ? { agentId } : {}),
83 }
84 lastEventTs = ev.ts
85 await update($, events, list => [...list, ev].slice(-2000))
86 await append($, ev)
87}
88
89// ---- 讀取端:定時看事件檔有沒有變,有變才重讀 ------------------------------
90
91async function refresh($: EngineInterface): Promise<void> {
92 const dir = await ensureHome($)
93 if (!dir) return
94 const file = eventsFile(dir)
95 if (!(await $.fs.exists(file))) {
96 if (file !== lastFile) { lastFile = file; lastSize = 0; await update($, events, () => []); await update($, turns, () => []) }
97 return
98 }
99 const st = await $.fs.stat(file)
100 if (file === lastFile && st.size === lastSize) return
101 lastFile = file
102 lastSize = st.size
103 const text = await $.fs.read(file)
104 const list = parseEvents(text)
105 const marks = parseTurns(text)
106 for (const ev of list) if (ev.ts > lastEventTs) lastEventTs = ev.ts
107 anyTurn = activeTurns(marks, Date.now()).size > 0
108 await update($, events, () => list)
109 await update($, turns, () => marks.slice(-500))
110 await warnConflicts($, list)
111}
112
113/** 自己剛寫的檔,別的 session 兩分鐘內也寫過:跳一次提醒。 */
114async function warnConflicts($: EngineInterface, list: MemEvent[]): Promise<void> {
115 await useLang($).catch(() => undefined)
116 const me = await $.session.id()
117 for (const c of recentConflicts(list, Date.now())) {
118 const key = `${c.path}@${c.at}`
119 if (warned.has(key) || !c.sessions.includes(me)) continue
120 warned.add(key)
121 const other = c.cwds[c.sessions[0] === me ? 1 : 0] ?? ''
122 $.ui.toast(T.toast(baseName(c.path), sessionLabel(other)))
123 }
124}
125
126function recentConflicts(list: MemEvent[], now: number): Conflict[] {
127 return findConflicts(list).filter(c => now - c.at < ACTIVE_MS)
128}
129
130// ---- 畫面:把事件換成辦公室的場景 -----------------------------------------
131
132type Phase = { u: number; mood: CrabMood; op: MemOp | null }
133
134/** 依最新一筆事件算螃蟹走到哪:走過去 → 停著讀寫 → 走回座位。 */
135function phaseOf(ev: MemEvent | undefined, now: number): Phase {
136 if (!ev) return { u: 0, mood: 'idle', op: null }
137 const e = now - ev.ts
138 if (e < 0 || e >= TRIP) return { u: 0, mood: 'idle', op: null }
139 if (e < WALK) return { u: e / WALK, mood: 'walk', op: null }
140 if (e < WALK + STAY) return { u: 1, mood: ev.op === 'w' ? 'write' : 'read', op: ev.op }
141 return { u: 1 - (e - WALK - STAY) / BACK, mood: 'walk', op: null }
142}
143
144type SessionInfo = { id: string; cwd: string; color: string; last?: MemEvent; thinking: boolean; tool: string | null; idleAt: number }
145type Scene = {
146 islands: OfficeIsland[]
147 crabs: OfficeCrab[]
148 sessions: SessionInfo[]
149 mine: MemEvent[]
150 mood: 'idle' | 'read' | 'write' | 'think' | 'type'
151 status: string
152}
153
154/** 待命越久越放鬆:伸懶腰 → 坐著 → 去飲水機 → 喝咖啡 → 回座位打瞌睡。 */
155type IdlePlan = { mood: CrabMood; place?: { from: Spot; to: Spot; u: number }; label: string }
156function idlePlan(idleFor: number): IdlePlan {
157 const s = idleFor / 1000
158 if (s < 6) return { mood: 'stretch', label: T.stretch }
159 if (s < 30) return { mood: 'idle', label: T.idle }
160 if (s < 120) { const u = Math.min(1, (s - 30) / 1.2); return { mood: u < 1 ? 'walk' : 'drink', place: { from: 'desk', to: 'water', u }, label: T.drink } }
161 if (s < 480) { const u = Math.min(1, (s - 120) / 1); return { mood: u < 1 ? 'walk' : 'coffee', place: { from: 'water', to: 'coffee', u }, label: T.coffee } }
162 const u = Math.min(1, (s - 480) / 1.2)
163 return { mood: u < 1 ? 'walk' : 'sleep', place: { from: 'coffee', to: 'desk', u }, label: T.sleep }
164}
165
166/** 螃蟹此刻的動作:碰記憶(讀/寫/走路) > 跑一般工具(敲筆電) > 回合中(思考) > 待命。 */
167function moodOf(s: SessionInfo, now: number): { p: Phase; mood: CrabMood; plan?: IdlePlan } {
168 const p = phaseOf(s.last, now)
169 if (p.mood !== 'idle') return { p, mood: p.mood }
170 if (s.tool) return { p, mood: 'type' }
171 if (s.thinking) return { p, mood: 'think' }
172 const plan = idlePlan(now - s.idleAt)
173 return { p, mood: plan.mood, plan }
174}
175
176function sceneOf(list: MemEvent[], me: string, mode: ViewMode, now: number, marks: TurnMark[], local: LocalState): Scene {
177 const turns = activeTurns(marks, now)
178 const endAt = new Map<string, number>()
179 for (const m of marks) if (m.turn === 'end') endAt.set(m.session, m.ts)
180 const scope = mode === 'session' ? list.filter(ev => ev.session === me) : list.filter(ev => now - ev.ts < ACTIVE_MS)
181 // 島:最近碰過的 6 座,照第一次出現的順序排,畫面才不會跳
182 const lastTouch = new Map<string, number>()
183 for (const ev of scope) lastTouch.set(ev.island, ev.ts)
184 const keep = new Set([...lastTouch.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k]) => k))
185 const order: string[] = []
186 for (const ev of scope) if (keep.has(ev.island) && !order.includes(ev.island)) order.push(ev.island)
187 // session:總控最多 5 隻,照第一次出現排;正在回合中(思考)的 session 也算
188 const lastBy = new Map<string, MemEvent>()
189 for (const ev of scope) lastBy.set(ev.session, ev)
190 const cwdOf = new Map<string, string>()
191 for (const ev of scope) cwdOf.set(ev.session, ev.cwd)
192 for (const [id, m] of turns) if (!cwdOf.has(id)) cwdOf.set(id, m.cwd)
193 const ids = mode === 'session' ? [me] : [...cwdOf.keys()].slice(0, 5)
194 const sessions: SessionInfo[] = ids.flatMap((id, i) => {
195 const cwd = cwdOf.get(id)
196 if (mode === 'control' && cwd === undefined) return []
197 const mineNow = id === me
198 return [{
199 id, cwd: cwd ?? '', color: mode === 'session' ? '#D97757' : SESSION_COLORS[i % SESSION_COLORS.length]!, ...(lastBy.get(id) ? { last: lastBy.get(id)! } : {}),
200 thinking: mineNow ? local.turn : turns.has(id), tool: mineNow ? local.tool : null,
201 idleAt: mineNow ? (local.idleAt || now) : (endAt.get(id) ?? lastBy.get(id)?.ts ?? now),
202 }]
203 })
204 const opOn = new Map<string, MemOp>()
205 const crabs: OfficeCrab[] = sessions.map(s => {
206 const { p, mood: m, plan } = moodOf(s, now)
207 if (p.op && s.last) opOn.set(s.last.island, opOn.get(s.last.island) === 'w' ? 'w' : p.op)
208 const target = s.last && p.u > 0 ? order.indexOf(s.last.island) : -1
209 // 單 session 的大螃蟹讀寫記憶時不走去檔案櫃(用光束);待命走動照常
210 const memWalk = mode === 'session' && m === 'walk' && !plan
211 const mood: CrabMood = memWalk ? (s.tool ? 'type' : s.thinking ? 'think' : 'idle') : m
212 return { name: sessionLabel(s.cwd), color: s.color, target: target >= 0 ? target : null, u: target >= 0 ? p.u : 0, mood, ...(plan?.place ? { place: plan.place } : {}) }
213 })
214 const islands: OfficeIsland[] = order.map(key => {
215 const evs = scope.filter(ev => ev.island === key)
216 return { key, label: lbl(key), color: islandColor(key), op: opOn.get(key) ?? null, notes: evs.length, written: evs.some(ev => ev.op === 'w') }
217 })
218 const ops = [...opOn.values()]
219 const moods = crabs.map(c => c.mood)
220 const mood = ops.includes('w') ? 'write' : ops.length ? 'read' : moods.includes('type') ? 'type' : moods.includes('think') ? 'think' : 'idle'
221 const mine = list.filter(ev => ev.session === me)
222 let status: string
223 if (mode === 'session') {
224 const last = lastBy.get(me), p = phaseOf(last, now)
225 status = last && p.op ? `${p.op === 'w' ? T.writing : T.reading} ${baseName(last.path)}`
226 : local.tool ? T.run(local.tool, now - local.toolAt > 3000 ? Math.round((now - local.toolAt) / 1000) : 0)
227 : local.turn ? T.thinking
228 : (() => { const plan = idlePlan(now - (local.idleAt || now)); return plan.mood === 'idle' ? T.idleToday(mine.filter(e => e.op === 'r').length, mine.filter(e => e.op === 'w').length) : plan.label })()
229 } else {
230 const busy = sessions.filter(s => moodOf(s, now).mood !== 'idle').length
231 status = busy ? T.ctlBusy(busy) : T.ctlIdle(sessions.length)
232 }
233 return { islands, crabs, sessions, mine, mood, status }
234}
235
236function ago(ts: number, now: number): string {
237 const s = Math.max(0, Math.round((now - ts) / 1000))
238 if (s < 60) return T.secAgo(s)
239 if (s < 3600) return T.minAgo(Math.floor(s / 60))
240 const d = new Date(ts)
241 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
242}
243
244function describe(ev: MemEvent): string {
245 return `${ev.op === 'w' ? T.verbWrote : T.verbRead} ${lbl(ev.island)} / ${baseName(ev.path)}`
246}
247
248/** 記憶樹(畫布同款):這個 session 碰過的檔,依總目錄、memory/<島>、memory-common 分層。 */
249function buildTree(mine: MemEvent[], mountIsland: string, now: number): TreeRow[] {
250 type F = { ev: MemEvent; seen: boolean; updated: boolean }
251 const files = new Map<string, F>()
252 // 同一份檔可能用捷徑或 Drive 實體路徑兩種寫法碰到:用「島 + 檔名」當同一份
253 const keyOf = (ev: MemEvent) => `${ev.island}/${baseName(ev.path)}`
254 for (const ev of mine) {
255 const f = files.get(keyOf(ev)) ?? { ev, seen: false, updated: false }
256 f.ev = ev
257 if (ev.op === 'w') f.updated = true
258 else f.seen = true
259 files.set(keyOf(ev), f)
260 }
261 const keep = [...files.values()].sort((a, b) => b.ev.ts - a.ev.ts).slice(0, 12).sort((a, b) => a.ev.ts - b.ev.ts)
262 const fileRow = (f: F, depth: number): TreeRow => ({ depth, kind: 'file', name: baseName(f.ev.path), key: f.ev.path, seen: f.seen, updated: f.updated, live: phaseOf(f.ev, now).op })
263 const dirRow = (name: string, depth: number, tag?: string, mount?: boolean): TreeRow => ({ depth, kind: 'dir', name, key: `dir:${name}`, seen: false, updated: false, live: null, ...(tag ? { tag } : {}), ...(mount ? { mount } : {}) })
264 const rows: TreeRow[] = []
265 for (const f of keep) if (f.ev.island === 'memory-index.md') rows.push({ ...fileRow(f, 0), tag: T.tagIndex })
266 const islands: string[] = []
267 for (const f of keep) if (f.ev.island !== 'memory-index.md' && f.ev.island !== 'memory-common' && !islands.includes(f.ev.island)) islands.push(f.ev.island)
268 if (islands.length) rows.push(dirRow('memory/', 0))
269 for (const isl of islands) {
270 const mount = isl === mountIsland
271 rows.push(dirRow(isl, 1, mount ? T.tagMount : lbl(isl) === T.names.home ? T.tagHome : undefined, mount))
272 for (const f of keep) if (f.ev.island === isl) rows.push(fileRow(f, 2))
273 }
274 const common = keep.filter(f => f.ev.island === 'memory-common')
275 if (common.length) {
276 rows.push(dirRow('memory-common/', 0, T.tagCommon))
277 for (const f of common) rows.push(fileRow(f, 1))
278 }
279 return rows
280}
281
282function hhmmss(ts: number): string {
283 const d = new Date(ts), p = (n: number) => String(n).padStart(2, '0')
284 return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
285}
286
287/** 紀錄:這個 session 最近 6 筆,新的在上面。 */
288function buildLog(mine: MemEvent[]): LogRow[] {
289 return mine.slice(-6).reverse().map(ev => {
290 const load = ev.tool.startsWith('autoload')
291 return { time: hhmmss(ev.ts), verb: load ? T.logLoaded : ev.op === 'w' ? T.logWrote : T.logRead, text: `${lbl(ev.island)} / ${baseName(ev.path)}`, op: load ? 'load' : ev.op }
292 })
293}
294
295async function markTurn($: EngineInterface, turn: 'start' | 'end'): Promise<void> {
296 await append($, { ts: Date.now(), session: await $.session.id(), cwd: await $.session.cwd(), turn })
297}
298
299async function openPane($: EngineInterface): Promise<void> {
300 await $.ui.open({ id: PANE, title: T.paneTitle })
301}
302
303export const register: Register = (on, options) => {
304 defaultView = options.default_view === 'control' ? 'control' : 'session'
305 langOption = asLanguage(options.language)
306
307 on('session.start', async ($, e, next) => {
308 const started = await next(e)
309 const { value: saved } = await $.state.get(VIEW_KEY)
310 if (saved === undefined) await $.state.set(VIEW_KEY, defaultView)
311 await update($, local, s => ({ ...s, turn: false, tool: null, idleAt: s.idleAt || Date.now() }))
312 await useLang($).catch(() => undefined)
313 await $.command.register({ name: 'clawd', description: T.cmdDesc })
314 await refresh($).catch(() => undefined)
315 $.clock.every(2_000, () => { void refresh($).catch(() => undefined) })
316 // 只在面板最近有畫的時候推影格;有人在讀寫時每秒 8 格,否則每秒 1 格
317 $.clock.every(Math.round(1000 / FPS), () => {
318 if (Date.now() - stageSeenAt > 3_000) return
319 tickN++
320 // 讀寫、思考、敲筆電時每秒 8 格;待命每秒 4 格(走去茶水間、蒸氣、打瞌睡)
321 const busy = Date.now() - lastEventTs < TRIP + 500 || localTurn || anyTurn
322 if (busy || tickN % 2 === 0) void update($, frame, n => n + 1)
323 })
324 return started
325 })
326
327 // 回合開始/結束:本機狀態給這個 session 用,標記寫進事件檔給總控看(子代理的回合不算)
328 on('turn.start', async ($, e, next) => {
329 const out = await next(e)
330 if (!e.agentId) {
331 localTurn = true
332 await update($, local, s => ({ ...s, turn: true, tool: null, turnAt: Date.now() }))
333 await markTurn($, 'start').catch(() => undefined)
334 }
335 return out
336 })
337 on('turn.complete', async ($, e, next) => {
338 const out = await next(e)
339 if (!e.agentId) {
340 localTurn = false
341 await update($, local, s => ({ ...s, turn: false, tool: null, idleAt: Date.now() }))
342 await markTurn($, 'end').catch(() => undefined)
343 }
344 return out
345 })
346
347 // 工具呼叫:執行中讓螃蟹敲筆電;讀寫記憶目錄裡的檔就記一筆
348 on('tool.call', async ($, e, next) => {
349 if (!e.agentId) await update($, local, s => ({ ...s, tool: String(e.tool), toolAt: Date.now() }))
350 let ran
351 try {
352 ran = await next(e)
353 } finally {
354 if (!e.agentId) await update($, local, s => ({ ...s, tool: null }))
355 }
356 const args = e as unknown as Record<string, unknown>
357 for (const key of ['file_path', 'notebook_path', 'path', 'command', 'pattern']) {
358 const v = args[key]
359 if (typeof v === 'string' && matchMemory(v)) {
360 await record($, String(e.tool), v, opOf(String(e.tool), v), e.agentId).catch(() => undefined)
361 break
362 }
363 }
364 return ran
365 })
366
367 // 自動載入:session 開頭載入的記憶島索引、@import 的總目錄
368 on('prompt.context', async ($, e, next) => {
369 const out = await next(e)
370 for (const f of e.instructionFiles ?? []) {
371 if (autoloaded.has(f.path) || !matchMemory(f.path)) continue
372 autoloaded.add(f.path)
373 await record($, `autoload:${f.kind}`, f.path, 'r').catch(() => undefined)
374 }
375 return out
376 })
377
378 on('command.run', { command: 'clawd' }, async ($, e) => {
379 const arg = String(e.args ?? '').trim()
380 await useLang($).catch(() => undefined)
381 if (/^(總控|control|all)$/i.test(arg)) await update($, view, () => 'control')
382 else if (/^(session|自己|this)$/i.test(arg)) await update($, view, () => 'session')
383 await refresh($).catch(() => undefined)
384 await openPane($)
385 const mode = await read($, view)
386 return { text: mode === 'control' ? T.cmdControl : T.cmdSession }
387 })
388
389 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
390 await useLang($).catch(() => undefined)
391 const elements = $.ui.resolve(e)
392 const { Box, Text, Button } = elements
393 // 終端機沒有 Svg:退回文字
394 const Svg = e.surface === 'terminal' ? undefined : (elements as { Svg?: ElementConstructor<SvgProps> }).Svg
395 const list = await read($, events)
396 const tick = await read($, frame)
397 const mode = await read($, view)
398 const me = await $.session.id()
399 const now = Date.now()
400 const marks = await read($, turns)
401 const mine = await read($, local)
402 const scene = sceneOf(list, me, mode, now, marks, mine)
403 const conflicts = recentConflicts(list, now)
404 const d = new Date(now)
405 if (Svg) stageSeenAt = now
406
407 const cwd = await $.session.cwd()
408 const stageInput = { emptyBoard: T.card.emptyBoard, t: tick / FPS, clock: { h: d.getHours(), m: d.getMinutes(), s: d.getSeconds() }, islands: scene.islands, crabs: scene.crabs, big: mode === 'session', status: scene.status, mood: scene.mood, printing: Boolean(mine.tool) && now - mine.toolAt > 6000, alarm: conflicts.length > 0 }
409 const conflictText = conflicts.map(c => T.conflict(baseName(c.path), c.cwds.map(sessionLabel).join(T === STRINGS.en ? ' & ' : '、'), ago(c.at, now)))
410 const tree = buildTree(scene.mine, cwd.replace(/\//g, '-'), now)
411 const log = buildLog(scene.mine)
412 const rows = scene.sessions.map(s => {
413 const { p, mood, plan } = moodOf(s, now)
414 const action = p.op && s.last ? describe(s.last)
415 : mood === 'walk' && s.last ? T.goTo(lbl(s.last.island))
416 : mood === 'type' ? T.run(String(s.tool), 0)
417 : mood === 'think' ? T.thinking
418 : plan && plan.mood !== 'idle' ? plan.label
419 : s.last ? T.lastDid(describe(s.last), ago(s.last.ts, now)) : T.idle
420 return {
421 color: s.color, name: sessionLabel(s.cwd), here: s.id === me, home: lbl(s.cwd.replace(/\//g, '-')),
422 action,
423 state: (p.op === 'w' ? 'w' : mood !== 'idle' ? 'busy' : 'idle') as 'idle' | 'busy' | 'w',
424 }
425 })
426 const tab = (key: string, label: string, active: boolean, to: ViewMode) => (
427 <Button key={key} variant={active ? 'primary' : 'secondary'} onPress={() => { void update($, view, () => to) }}>{label}</Button>
428 )
429 const buttons = (
430 <Box gap={1} justifyContent="flex-end">
431 {tab('view-session', T.btnSession, mode === 'session', 'session')}
432 {tab('view-control', T.btnControl, mode === 'control', 'control')}
433 </Box>
434 )
435 if (!Svg) {
436 // 終端機:沒有圖,列文字
437 return (
438 <Box flexDirection="column">
439 {buttons}
440 <Text bold>{scene.status}</Text>
441 {conflictText.map(t => <Text key={t} color="red">{t}</Text>)}
442 {mode === 'session'
443 ? tree.map(r => <Text key={`t-${r.key}`} wrap="truncate">{' '.repeat(r.depth)}{r.name}{r.tag ? ` (${r.tag})` : ''}{r.updated ? T.termUpdated : ''}{r.live ? (r.live === 'w' ? T.termWriting : T.termReading) : ''}</Text>)
444 : rows.map(r => <Text key={`s-${r.name}-${r.color}`} wrap="truncate"><Text color={r.color}>●</Text> {r.name}{r.here ? T.here : ''} <Text dimColor>{r.action}</Text></Text>)}
445 </Box>
446 )
447 }
448 const card = cardSvg({
449 mode,
450 line1: mode === 'control' ? [T.controlLine[0], T.controlLine[1](scene.sessions.length, scene.islands.length)] : [T.sessionLine[0], cwd.replace(/^\/Users\/[^/]+/, '~')],
451 line2: mode === 'control' ? [T.viewLine[0], T.viewLine[1]] : [T.sessionLine[1], cwd.replace(/\//g, '-')],
452 text: T.card,
453 stage: stageInput, conflicts: conflictText, tree, log, sessions: rows,
454 })
455 const alt = [scene.status, ...conflictText, ...(mode === 'session' ? log.map(l => `${l.verb} ${l.text}`) : rows.map(r => `${r.name}:${r.action}`))].join(';')
456 return (
457 <Box flexDirection="column" gap={1}>
458 {buttons}
459 <Svg source={card} alt={alt} />
460 </Box>
461 )
462 })
463}
464hooks/memory.ts 132 lines1// 記憶路徑的辨識與整理:純函式,不碰 $,方便測試。
2import type { MemEvent, MemOp, TurnMark } from '../types'
3
4// 記憶目錄的各種寫法:捷徑(~/.claude/projects/<島>/memory)、共用固定路徑、Drive 實體路徑
5const PATTERNS: RegExp[] = [
6 /\/\.claude(?:-work)?\/projects\/([^/\s'"]+)\/memory(?:\/[^\s'"]*)?/,
7 /\/claude-shared\/memory\/([^/\s'"]+)(?:\/[^\s'"]*)?/,
8 /\/claude-shared\/(memory-common)(?:\/[^\s'"]*)?/,
9 /\/claude-shared\/(memory-index\.md)/,
10]
11
12/** 文字裡有記憶路徑就回傳那段路徑與它屬於哪座島。 */
13export function matchMemory(text: string): { path: string; island: string } | undefined {
14 for (const re of PATTERNS) {
15 const m = re.exec(text)
16 if (m) return { path: m[0], island: m[1] ?? '' }
17 }
18 return undefined
19}
20
21const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
22const BASH_WRITES = /(>>?|\btee\b|\bsed\s+-i|\bmv\b|\bcp\b|\brm\b|\btouch\b|\bln\b)/
23
24/** 這次工具呼叫算讀還是寫。Bash 只看指令裡有沒有寫檔的動作,是推測。 */
25export function opOf(tool: string, raw: string): MemOp {
26 if (WRITE_TOOLS.has(tool)) return 'w'
27 if (tool === 'Bash') return BASH_WRITES.test(raw) ? 'w' : 'r'
28 return 'r'
29}
30
31/** 島的顯示名稱:主島、canline、通用、總目錄…… */
32export function islandLabel(island: string, names = { index: '總目錄', common: '通用', home: '主島' }): string {
33 if (island === 'memory-index.md') return names.index
34 if (island === 'memory-common') return names.common
35 const ws = /^-Users-[^-]+-Workspace(?:-(.+))?$/.exec(island)
36 if (ws) return ws[1] ?? names.home
37 return island.replace(/^-Users-[^-]+-?/, '') || island
38}
39
40const NOTE_COLORS = ['#A8D8FF', '#FFB3C7', '#E2C6FF', '#B5F0EA', '#FFD3A8', '#D9F99D', '#FBCFE8']
41
42/** 便利貼顏色:總目錄黃、通用綠,其他依名稱固定配色。 */
43export function islandColor(island: string): string {
44 if (island === 'memory-index.md') return '#FFE27A'
45 if (island === 'memory-common') return '#B8F0B0'
46 let h = 0
47 for (const ch of island) h = (h * 31 + ch.charCodeAt(0)) >>> 0
48 return NOTE_COLORS[h % NOTE_COLORS.length]!
49}
50
51/** session 的顯示名稱:它開在哪個資料夾。 */
52export function sessionLabel(cwd: string): string {
53 const parts = cwd.split('/').filter(Boolean)
54 return parts[parts.length - 1] ?? '~'
55}
56
57export function baseName(path: string): string {
58 const parts = path.split('/').filter(Boolean)
59 return parts[parts.length - 1] ?? path
60}
61
62/** 本地日期 YYYY-MM-DD,事件檔一天一個。 */
63export function dayStamp(d: Date = new Date()): string {
64 const p = (n: number) => String(n).padStart(2, '0')
65 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
66}
67
68export function eventsFile(home: string, d: Date = new Date()): string {
69 return `${home}/.claude/clawd-memory/events-${dayStamp(d)}.jsonl`
70}
71
72/** 解析事件檔,壞行略過,只留最後 limit 筆。 */
73export function parseEvents(text: string, limit = 2000): MemEvent[] {
74 const out: MemEvent[] = []
75 for (const line of text.split('\n')) {
76 if (!line.trim()) continue
77 try {
78 const ev = JSON.parse(line) as MemEvent
79 if (typeof ev.ts === 'number' && typeof ev.session === 'string' && typeof ev.path === 'string') out.push(ev)
80 } catch {
81 // 寫到一半的行:下次再讀
82 }
83 }
84 return out.slice(-limit)
85}
86
87/** 解析事件檔裡的回合標記(有 turn 欄位的行)。 */
88export function parseTurns(text: string): TurnMark[] {
89 const out: TurnMark[] = []
90 for (const line of text.split('\n')) {
91 if (!line.includes('"turn"')) continue
92 try {
93 const m = JSON.parse(line) as TurnMark
94 if (typeof m.ts === 'number' && typeof m.session === 'string' && (m.turn === 'start' || m.turn === 'end')) out.push(m)
95 } catch {
96 // 寫到一半的行
97 }
98 }
99 return out
100}
101
102/** 每個 session 最後一個標記是 start、且不超過 30 分鐘:還在回合中(思考或工作)。 */
103export function activeTurns(marks: TurnMark[], now: number, maxMs = 30 * 60_000): Map<string, TurnMark> {
104 const last = new Map<string, TurnMark>()
105 for (const m of marks) last.set(m.session, m)
106 const out = new Map<string, TurnMark>()
107 for (const [s, m] of last) if (m.turn === 'start' && now - m.ts < maxMs) out.set(s, m)
108 return out
109}
110
111export type Conflict = { path: string; sessions: string[]; cwds: string[]; at: number }
112
113/** 不同 session 在 windowMs 內寫同一份記憶:Drive 是最後寫的贏,可能有一邊被蓋掉。 */
114export function findConflicts(events: MemEvent[], windowMs = 120_000): Conflict[] {
115 const writes = events.filter(ev => ev.op === 'w')
116 const byPath = new Map<string, MemEvent[]>()
117 for (const ev of writes) {
118 const key = baseName(ev.path) + '@' + ev.island
119 byPath.set(key, [...(byPath.get(key) ?? []), ev])
120 }
121 const out: Conflict[] = []
122 for (const list of byPath.values()) {
123 for (let i = 1; i < list.length; i++) {
124 const a = list[i - 1]!, b = list[i]!
125 if (a.session !== b.session && b.ts - a.ts <= windowMs) {
126 out.push({ path: b.path, sessions: [a.session, b.session], cwds: [a.cwd, b.cwd], at: b.ts })
127 }
128 }
129 }
130 return out
131}
132hooks/office.ts 493 lines1// 一般辦公室舞台:給定時間與狀態,畫出整張 SVG(純函式)。
2// 白板一欄、檔案櫃一格是一座記憶島;螃蟹讀寫時走到那格檔案櫃,寫入在白板多貼一張便利貼。
3import type { MemOp } from '../types'
4import type { CardText } from './i18n'
5
6/** think:思考中(泡泡、托下巴);type:跑一般工具(對著筆電敲)。 */
7export type CrabMood = 'idle' | 'read' | 'write' | 'walk' | 'think' | 'type' | 'stretch' | 'drink' | 'coffee' | 'sleep'
8/** 辦公室裡螃蟹會去的地方:座位、飲水機、咖啡機。 */
9export type Spot = 'desk' | 'water' | 'coffee'
10
11export type OfficeIsland = {
12 key: string
13 label: string
14 color: string
15 /** 此刻有螃蟹在讀(r)或寫(w)它。 */
16 op: MemOp | null
17 /** 白板上的便利貼數(今天碰過幾次,最多 4)。 */
18 notes: number
19 /** 今天寫過:最後一張便利貼換成黃色。 */
20 written: boolean
21}
22
23export type OfficeCrab = {
24 name: string
25 color: string
26 /** 正在前往或停在哪一格(islands 的索引),null 是待在座位。 */
27 target: number | null
28 /** 0 在座位、1 在檔案櫃前。 */
29 u: number
30 mood: CrabMood
31 /** 待命時的移動:從哪走到哪、走了多少(0~1)。有 target 時不用。 */
32 place?: { from: Spot; to: Spot; u: number }
33}
34
35export type OfficeInput = {
36 t: number
37 clock: { h: number; m: number; s: number }
38 islands: OfficeIsland[]
39 crabs: OfficeCrab[]
40 /** true:只有一隻大螃蟹(這個 session);false:總控,多隻小螃蟹掛名牌。 */
41 big: boolean
42 status: string
43 mood: 'idle' | 'read' | 'write' | 'think' | 'type'
44 /** 工具跑很久:印表機吐紙。 */
45 printing?: boolean
46 /** 有 session 同時寫同一份記憶:警示燈閃。 */
47 alarm?: boolean
48 /** 白板空白時的字。 */
49 emptyBoard?: string
50}
51
52const W = 440
53const H = 236
54const FONT = "-apple-system, 'PingFang TC', 'Noto Sans TC', sans-serif"
55const r1 = (v: number) => Math.round(v * 10) / 10
56// eslint-disable-next-line no-control-regex
57const esc = (s: string) => s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
58const ease = (u: number) => { const k = Math.max(0, Math.min(1, u)); return k * k * (3 - 2 * k) }
59/** 大約的文字寬度:中日韓字一格,其他半格。 */
60const textWidth = (s: string, size: number) => [...s].reduce((w, ch) => w + (ch.charCodeAt(0) > 0x2e80 ? size : size * 0.58), 0)
61/** 依實際寬度截斷,放不下就加「…」。 */
62const clipW = (s: string, size: number, maxW: number) => {
63 if (textWidth(s, size) <= maxW) return s
64 const chars = [...s]
65 while (chars.length && textWidth(chars.join('') + '…', size) > maxW) chars.pop()
66 return chars.join('') + '…'
67}
68const clip = (s: string, max: number) => ([...s].length > max ? [...s].slice(0, max - 1).join('') + '…' : s)
69
70// ---- 螃蟹像素 -------------------------------------------------------------
71
72const CRAB_COLORS: Record<string, string> = { h: '#EFA083', c: '#D97757', d: '#B5593A', a: '#D97757', p: '#C4613D', E: '#24140F' }
73
74export function crabPaths(mood: CrabMood, t: number): Record<string, string> {
75 const g: string[][] = Array.from({ length: 10 }, () => Array<string>(16).fill('.'))
76 const set = (x: number, y: number, k: string) => { if (x >= 0 && x < 16 && y >= 0 && y < 10) g[y]![x] = k }
77 for (let y = 1; y <= 6; y++) for (let x = 3; x <= 12; x++) set(x, y, y === 1 ? (x > 3 && x < 12 ? 'h' : 'c') : y === 6 ? 'd' : 'c')
78 set(3, 1, '.'); set(12, 1, '.')
79 const blink = !['write', 'stretch', 'drink', 'coffee', 'sleep'].includes(mood) && (t % 3.7) < 0.14
80 let eyes: [number, number][]
81 if (blink) eyes = [[5, 3], [10, 3]]
82 else if (mood === 'read') eyes = [[5, 3], [5, 4], [10, 3], [10, 4]]
83 else if (mood === 'write') eyes = [[4, 3], [5, 3], [10, 3], [11, 3]]
84 else if (mood === 'think') { const dx = Math.cos(t * 1.1) > 0 ? 1 : 0; eyes = [[5 + dx, 2], [5 + dx, 3], [10 + dx, 2], [10 + dx, 3]] }
85 else if (mood === 'type') eyes = [[5, 4], [10, 4]]
86 else if (mood === 'stretch' || mood === 'drink' || mood === 'coffee') eyes = [[4, 3], [5, 2], [6, 3], [9, 3], [10, 2], [11, 3]]
87 else if (mood === 'sleep') eyes = [[4, 3], [5, 3], [6, 3], [9, 3], [10, 3], [11, 3]]
88 else eyes = [[5, 2], [5, 3], [10, 2], [10, 3]]
89 for (const [x, y] of eyes) set(x, y, 'E')
90 const f = Math.floor(t * 7)
91 let la = 4, ra = 4, lo = false, ro = false
92 if (mood === 'idle') { lo = ro = (t % 2.4) < 0.5 && (t % 0.5) < 0.25 }
93 if (mood === 'read') { la = ra = 5; lo = ro = (f % 4) < 2 }
94 if (mood === 'write') { la = f % 2 ? 3 : 5; ra = f % 2 ? 5 : 3; lo = f % 2 === 0; ro = f % 2 === 1 }
95 if (mood === 'think') { la = 3; ra = 4; lo = false; ro = (t % 1.6) < 0.3 }
96 if (mood === 'stretch') { la = ra = 2; lo = ro = (t % 1.2) < 0.6 }
97 if (mood === 'drink' || mood === 'coffee') { la = 3; ra = 4; lo = false; ro = false }
98 if (mood === 'sleep') { la = ra = 5; lo = ro = false }
99 if (mood === 'type') { const g = Math.floor(t * 10); la = g % 2 ? 3 : 6; ra = g % 2 ? 6 : 3; lo = g % 4 < 2; ro = !lo }
100 const claw = (side: number, ay: number, open: boolean) => {
101 const X = (x: number) => (side < 0 ? x : 15 - x)
102 set(X(2), ay, 'a'); set(X(1), ay, 'a')
103 if (open) { set(X(0), ay - 1, 'p'); set(X(0), ay + 1, 'p'); set(X(1), ay - 1, 'p') } else { set(X(0), ay, 'p'); set(X(0), ay - 1, 'p') }
104 }
105 claw(-1, la, lo); claw(1, ra, ro)
106 const step = (mood === 'walk' || mood === 'write' || mood === 'think') && f % 2 === 1
107 for (const x of step ? [5, 7, 8, 10] : [4, 6, 9, 11]) { set(x, 7, 'd'); set(x, 8, 'd') }
108 const paths: Record<string, string> = {}
109 g.forEach((row, y) => row.forEach((k, x) => { if (k !== '.') paths[k] = (paths[k] ?? '') + `M${x} ${y}h1v1h-1z` }))
110 return paths
111}
112
113/** 思考泡泡:兩顆小圓接一朵雲,裡面三個點輪流亮。 */
114function thoughtBubble(x: number, top: number, t: number, s: number): string {
115 const bx = x + 26 * s, by = top - 14 * s
116 const ask = Math.floor(t / 1.6) % 3 === 2
117 const dots = ask
118 ? `<text x="${r1(bx)}" y="${r1(by + 4.5 * s)}" text-anchor="middle" font-family="${FONT}" font-weight="900" font-size="${r1(13 * s)}" fill="#4C3FA8">?</text>`
119 : [0, 1, 2].map(i => `<circle cx="${r1(bx + (i - 1) * 6 * s)}" cy="${r1(by)}" r="${r1(1.9 * s)}" fill="#4C3FA8" opacity="${Math.floor(t * 4) % 3 === i ? 1 : 0.25}"/>`).join('')
120 return `<g><circle cx="${r1(x + 9 * s)}" cy="${r1(top - 2 * s)}" r="${r1(1.6 * s)}" fill="#EFEAFF" stroke="#7C6FD0" stroke-width="1.1"/>` +
121 `<circle cx="${r1(x + 15 * s)}" cy="${r1(top - 7 * s)}" r="${r1(2.6 * s)}" fill="#EFEAFF" stroke="#7C6FD0" stroke-width="1.1"/>` +
122 `<ellipse cx="${r1(bx)}" cy="${r1(by)}" rx="${r1(14 * s)}" ry="${r1(8.5 * s)}" fill="#EFEAFF" stroke="#7C6FD0" stroke-width="1.2"/>${dots}</g>`
123}
124
125function crabSvg(mood: CrabMood, t: number, x: number, y: number, px: number, cap?: string): string {
126 const paths = crabPaths(mood, t)
127 const body = Object.entries(paths).map(([k, d]) => `<path d="${d}" fill="${CRAB_COLORS[k]}"/>`).join('')
128 const hat = cap ? `<rect x="4" y="0" width="8" height="1" fill="${cap}"/>` : ''
129 return `<g transform="translate(${r1(x - 8 * px)} ${r1(y - 9 * px)}) scale(${px})" shape-rendering="crispEdges">${body}${hat}</g>`
130}
131
132// ---- 版面 -----------------------------------------------------------------
133
134/** 檔案櫃第 i 格的位置;超過 4 座島改成三欄窄抽屜。 */
135export function drawerBox(i: number, n: number): { x: number; y: number; w: number } {
136 const three = n > 4
137 const w = three ? 28 : 40
138 const col = Math.floor(i / 2), row = i % 2
139 return { x: (three ? 341 + col * 29 : 342 + col * 44), y: 98 + row * 39, w }
140}
141
142function stickyXY(x0: number, n: number, cw: number): [number, number] {
143 const per = cw >= 36 ? 2 : 1
144 return [per === 2 ? x0 + 4 + (n % 2) * 17 : x0 + (cw - 14) / 2, 32 + Math.floor(n / per) * 15]
145}
146
147function sticky(x0: number, n: number, cw: number, color: string): string {
148 const [x, y] = stickyXY(x0, n, cw), rot = ((n * 37) % 9) - 4
149 return `<g transform="rotate(${rot} ${x + 7} ${y + 6})"><rect x="${x}" y="${y}" width="14" height="12" fill="${color}"/>` +
150 `<rect x="${x + 2}" y="${y + 4}" width="9" height="1" fill="#00000033"/><rect x="${x + 2}" y="${y + 7}" width="6" height="1" fill="#00000033"/></g>`
151}
152
153/** 螃蟹的座位(照畫布):大螃蟹在桌子正中,總控的小螃蟹沿桌面排開。 */
154function spotPos(spot: Spot, i: number, big: boolean): { x: number; y: number } {
155 if (spot === 'water') return big ? { x: 74, y: 224 } : { x: 66 + (i % 3) * 10, y: 214 + (i % 2) * 8 }
156 if (spot === 'coffee') return big ? { x: 112, y: 224 } : { x: 108 + (i % 3) * 10, y: 214 + (i % 2) * 8 }
157 return seat(i, big)
158}
159
160function seat(i: number, big: boolean): { x: number; y: number } {
161 return big ? { x: 200, y: 146 } : { x: 200 + (i - 1.5) * 44, y: 146 }
162}
163
164export function officeSvg(inp: OfficeInput): string {
165 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${officeBody(inp)}</svg>`
166}
167
168/** 舞台本體(不含外層 svg),440×236。 */
169export function officeBody(inp: OfficeInput): string {
170 const { t, islands, crabs, big } = inp
171 const n = Math.min(islands.length, 6)
172 const o: string[] = []
173 o.push(
174 '<defs><linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9FD4F2"/><stop offset="1" stop-color="#E3F4FC"/></linearGradient>',
175 '<filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="a"/><feGaussianBlur in="SourceGraphic" stdDeviation="4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="a"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>',
176 `<rect width="${W}" height="176" fill="#ECE7DE"/><rect y="172" width="${W}" height="4" fill="#CFC6B8"/>`,
177 )
178 // 窗、雲、百葉窗
179 o.push('<rect x="14" y="16" width="108" height="96" fill="#D3CDC2"/><rect x="19" y="21" width="98" height="86" fill="url(#sky)"/>')
180 for (const [x0, y, p] of [[30, 30, 0], [80, 10, 50]] as const) {
181 const x = 19 + ((x0 + t * 4 + p) % 110) - 6
182 o.push(`<g transform="translate(${r1(x)} ${y + 40})" opacity=".9"><ellipse cx="0" cy="0" rx="12" ry="5" fill="#fff"/><ellipse cx="8" cy="-3" rx="8" ry="5" fill="#fff"/></g>`)
183 }
184 o.push('<rect x="14" y="16" width="108" height="5" fill="#D3CDC2"/>')
185 for (let y = 22; y < 62; y += 5) o.push(`<rect x="19" y="${y}" width="98" height="3" fill="#FAFAF7" opacity=".92"/>`)
186 o.push('<line x1="108" y1="21" x2="108" y2="86" stroke="#B8B2A6"/><rect x="66" y="21" width="4" height="86" fill="#D3CDC2"/>')
187 // 時鐘:真的時間
188 o.push('<circle cx="148" cy="34" r="13" fill="#fff" stroke="#4B5563" stroke-width="2"/>')
189 for (let i = 0; i < 12; i++) {
190 const a = (i * Math.PI) / 6
191 o.push(`<line x1="${r1(148 + Math.cos(a) * 10)}" y1="${r1(34 + Math.sin(a) * 10)}" x2="${r1(148 + Math.cos(a) * 11.5)}" y2="${r1(34 + Math.sin(a) * 11.5)}" stroke="#9CA3AF"/>`)
192 }
193 const hand = (deg: number, len: number, color: string, w: number) => {
194 const a = ((deg - 90) * Math.PI) / 180
195 return `<line x1="148" y1="34" x2="${r1(148 + Math.cos(a) * len)}" y2="${r1(34 + Math.sin(a) * len)}" stroke="${color}" stroke-width="${w}" stroke-linecap="round"/>`
196 }
197 const { h, m, s } = inp.clock
198 o.push(hand((h % 12) * 30 + m * 0.5, 6, '#1F2328', 1.8), hand(m * 6 + s * 0.1, 9, '#1F2328', 1.2), hand(s * 6, 10, '#E5484D', 0.8))
199 // 白板:一座島一欄
200 o.push('<rect x="170" y="12" width="166" height="88" rx="2" fill="#FAFBFC" stroke="#AEB4BA" stroke-width="3"/><rect x="176" y="100" width="154" height="3" fill="#9AA0A6"/>')
201 for (const [x, c] of [[190, '#2563EB'], [200, '#DC2626'], [210, '#16A34A']] as const) o.push(`<rect x="${x}" y="97" width="8" height="3" rx="1" fill="${c}"/>`)
202 const cw = 160 / Math.max(n, 4)
203 if (n === 0) o.push(`<text x="253" y="58" text-anchor="middle" font-family="${FONT}" font-size="9" fill="#9CA3AF">${esc(inp.emptyBoard ?? '')}</text>`)
204 islands.slice(0, n).forEach((is, i) => {
205 const x0 = 174 + i * cw
206 if (is.op) o.push(`<rect x="${r1(x0)}" y="15" width="${r1(cw - 1)}" height="82" fill="${is.color}" opacity=".35"/>`)
207 if (i) o.push(`<line x1="${r1(x0)}" y1="18" x2="${r1(x0)}" y2="94" stroke="#D5DAE0" stroke-dasharray="2 2"/>`)
208 o.push(`<text x="${r1(x0 + cw / 2)}" y="25" text-anchor="middle" font-family="${FONT}" font-weight="700" font-size="7" fill="#374151">${esc(clip(is.label, cw >= 36 ? 8 : 5))}</text>`)
209 if (is.op) o.push(`<rect x="${r1(x0 + 4)}" y="27" width="${r1(cw - 8)}" height="1.5" fill="${is.op === 'w' ? '#E0A800' : '#2563EB'}"/>`)
210 const count = Math.max(1, Math.min(4, is.notes))
211 for (let k = 0; k < count; k++) o.push(sticky(x0, k, cw, is.written && k === count - 1 ? '#FFC93C' : is.color))
212 })
213 // 檔案櫃:一座島一格
214 o.push('<rect x="340" y="94" width="88" height="82" fill="#7B8794"/>')
215 // 印表機(檔案櫃上):工具跑很久時吐紙
216 o.push('<rect x="356" y="74" width="56" height="20" rx="3" fill="#E5E7EB" stroke="#B8C0C8"/><rect x="362" y="70" width="44" height="5" rx="1" fill="#F3F4F6" stroke="#C9CED6" stroke-width=".6"/>')
217 o.push(`<circle cx="404" cy="84" r="1.8" fill="${inp.printing ? (Math.floor(t * 4) % 2 ? '#22C55E' : '#86EFAC') : '#9CA3AF'}"/>`)
218 if (inp.printing) {
219 const q = (t * 0.7) % 1
220 o.push(`<rect x="366" y="${r1(88 + q * 8)}" width="30" height="${r1(4 + q * 12)}" fill="#fff" stroke="#D1D5DB" stroke-width=".6"/>`)
221 for (let k = 0; k < 3; k++) o.push(`<rect x="370" y="${r1(92 + q * 8 + k * 3.5)}" width="${16 + (k % 2) * 6}" height="1" fill="#9CA3AF" opacity="${q > 0.2 + k * 0.2 ? 1 : 0}"/>`)
222 }
223 // 警示燈(右上角牆上):同時寫入時閃紅光
224 const on = inp.alarm && Math.floor(t * 3) % 2 === 0
225 if (on) o.push('<circle cx="424" cy="18" r="11" fill="#EF4444" opacity=".35" filter="url(#bloom)"/>')
226 o.push(`<rect x="418" y="22" width="12" height="4" rx="1" fill="#4B5563"/><path d="M419 22 a5 6 0 0 1 10 0 z" fill="${inp.alarm ? (on ? '#FF5A5A' : '#991B1B') : '#C9CED6'}"/>`)
227 const slots = n > 4 ? 6 : 4
228 for (let i = 0; i < slots; i++) {
229 const { x, y, w } = drawerBox(i, n)
230 const is = i < n ? islands[i] : undefined
231 o.push(`<rect x="${x}" y="${y}" width="${w}" height="36" fill="#4B5563"/>`)
232 const open = Boolean(is?.op)
233 if (open) o.push(`<rect x="${x}" y="${y}" width="${w}" height="36" fill="${is!.color}" opacity="${r1(0.6 + 0.2 * Math.sin(t * 5))}" filter="url(#bloom)"/>`)
234 const sc = open ? 1.1 : 1, cx = x + w / 2, cy = y + 18
235 o.push(`<g transform="translate(${r1(cx * (1 - sc))} ${r1(cy * (1 - sc) + (open ? 4 : 0))}) scale(${sc})">`)
236 o.push(`<rect x="${x}" y="${y}" width="${w}" height="36" rx="1" fill="#AEB8C2" stroke="#8A96A3"/>`)
237 if (is) {
238 o.push(`<rect x="${x + 3}" y="${y + 5}" width="${w - 6}" height="10" fill="#fff" stroke="#8A96A3" stroke-width=".6"/>`)
239 o.push(`<rect x="${x + 3}" y="${y + 5}" width="3" height="10" fill="${is.color}"/>`)
240 o.push(`<text x="${r1(x + w / 2 + 1)}" y="${y + 12.6}" text-anchor="middle" font-family="${FONT}" font-size="6" font-weight="700" fill="#1F2328">${esc(clip(is.label, w >= 36 ? 6 : 4))}</text>`)
241 }
242 o.push(`<rect x="${x + w / 2 - 7}" y="${y + 22}" width="14" height="3" rx="1.5" fill="#5B6673"/></g>`)
243 }
244 // 地板、盆栽
245 o.push(`<rect y="176" width="${W}" height="${H - 176}" fill="#CDB089"/>`)
246 for (let y = 176; y < H; y += 12) {
247 o.push(`<rect y="${y}" width="${W}" height="1" fill="#B9996F"/>`)
248 for (let x = (y / 12) % 2 ? 30 : 0; x < W; x += 60) o.push(`<rect x="${x}" y="${y}" width="1" height="12" fill="#B9996F"/>`)
249 }
250 o.push(`<g transform="translate(-26 0) rotate(${r1(Math.sin(t * 0.9) * 1.5)} 50 160)">`)
251 for (const [dx, hh, a] of [[-8, 26, -22], [0, 34, 0], [8, 28, 20], [-3, 22, -40], [4, 24, 40]] as const) {
252 o.push(`<ellipse cx="${50 + dx}" cy="${160 - hh / 2}" rx="4.5" ry="${hh / 2}" fill="#4E9A5B" transform="rotate(${a} ${50 + dx} 160)"/>`)
253 }
254 o.push('</g><path d="M12 158 H36 L32 178 H16 Z" fill="#C8794B"/>')
255 const drinking = crabs.some(c => c.mood === 'drink'), sipping = crabs.some(c => c.mood === 'coffee')
256 // 飲水機:有人喝水時水桶冒氣泡
257 o.push('<rect x="56" y="146" width="28" height="32" rx="2" fill="#D9DEE3" stroke="#B8C0C8"/><rect x="61" y="152" width="18" height="8" rx="1" fill="#C3CBD3"/>')
258 o.push('<rect x="63" y="140" width="4" height="6" fill="#3B82F6"/><rect x="73" y="140" width="4" height="6" fill="#EF4444"/>')
259 o.push('<rect x="59" y="110" width="22" height="31" rx="7" fill="#9FD4F2" fill-opacity=".85" stroke="#7DB9DC"/><rect x="66" y="106" width="8" height="5" rx="1" fill="#7DB9DC"/>')
260 for (let k = 0; k < 4; k++) {
261 const q = (t * (drinking ? 0.9 : 0.25) + k / 4) % 1
262 o.push(`<circle cx="${r1(66 + (k % 2) * 7 + Math.sin(t * 2 + k) * 1.5)}" cy="${r1(138 - q * 24)}" r="${drinking ? 1.8 : 1.1}" fill="#fff" opacity="${r1((drinking ? 0.9 : 0.5) * (1 - q))}"/>`)
263 }
264 // 茶水台與咖啡機:有人喝咖啡時蒸氣變大
265 o.push('<rect x="92" y="150" width="40" height="4" fill="#A47148"/><rect x="95" y="154" width="3" height="24" fill="#8A5A36"/><rect x="126" y="154" width="3" height="24" fill="#8A5A36"/>')
266 o.push('<rect x="99" y="122" width="26" height="28" rx="2" fill="#3A3F47"/><rect x="102" y="125" width="20" height="5" rx="1" fill="#1F2328"/><circle cx="119" cy="127.5" r="1.4" fill="#EF4444"/>')
267 o.push('<rect x="105" y="136" width="14" height="12" rx="2" fill="#CFE3EE" fill-opacity=".7" stroke="#8FA3AE" stroke-width=".6"/><rect x="106" y="141" width="12" height="6.5" fill="#6B3E1F"/>')
268 o.push('<rect x="128" y="143" width="6" height="7" rx="1" fill="#fff" stroke="#C9CED6" stroke-width=".5"/>')
269 for (let k = 0; k < 3; k++) {
270 const q = (t * 0.6 + k / 3) % 1
271 o.push(`<path d="M${r1(110 + k * 4 + Math.sin(t * 3 + k) * 1.5)} ${r1(120 - q * 16)} q2 -3 0 -6" fill="none" stroke="#9CA3AF" stroke-width="${sipping ? 1.4 : 0.9}" opacity="${r1((sipping ? 0.9 : 0.45) * (1 - q))}"/>`)
272 }
273 // 桌子、筆電、馬克杯
274 o.push('<rect x="150" y="152" width="146" height="16" fill="#E3E0DA"/><rect x="146" y="152" width="4" height="26" fill="#8A9099"/><rect x="296" y="152" width="4" height="26" fill="#8A9099"/>')
275 o.push('<rect x="138" y="146" width="170" height="6" rx="1" fill="#F7F7F5" stroke="#D5D2CC"/>')
276 const screen = { idle: '#1E293B', read: '#0F2E4A', write: '#3A2E0F', think: '#2A2550', type: '#10243A' }[inp.mood]
277 o.push(`<rect x="248" y="122" width="42" height="24" rx="2" fill="#2F3640"/><rect x="251" y="125" width="36" height="18" fill="${screen}"/>`)
278 for (let i = 0; i < 4; i++) {
279 const w = 10 + ((i * 7 + Math.floor(t * (inp.mood === 'write' || inp.mood === 'type' ? 8 : 1.5))) % 18)
280 o.push(`<rect x="${254 + (i % 2) * 3}" y="${r1(128 + i * 3.6)}" width="${w}" height="1.6" fill="#7DD3FC" opacity=".8"/>`)
281 }
282 o.push('<rect x="242" y="145" width="54" height="3" rx="1" fill="#C4C8CE"/>')
283 o.push('<rect x="146" y="136" width="9" height="10" rx="1.5" fill="#fff" stroke="#C9CED6" stroke-width=".6"/><path d="M155 139 h2.5 v4 h-2.5" fill="none" stroke="#C9CED6"/>')
284 // 螃蟹:從座位走到檔案櫃前
285 const px = big ? 5 : 3
286 crabs.slice(0, big ? 1 : 5).forEach((c, i) => {
287 const home = seat(i, big)
288 // 照畫布:大螃蟹待在桌上(讀寫用光束連到下方記憶樹);總控的小螃蟹走到那格檔案櫃前
289 const box = !big && c.target !== null && c.target < n ? drawerBox(c.target, n) : undefined
290 const dest = box ? { x: Math.min(W - 8 * px - 2, box.x + box.w / 2 + (i - 2) * 5), y: 198 } : home
291 const k = ease(c.u)
292 const sc = big ? 1 : 0.55
293 const ti = t + i * 0.9
294 // 思考:在桌上左右踱步;敲筆電:往筆電靠;其他留在原位
295 const shift = c.mood === 'think' ? Math.sin(ti * 1.1) * 26 * sc : c.mood === 'type' && big ? 6 : 0
296 let x = home.x + (dest.x - home.x) * k + shift, y = home.y + (dest.y - home.y) * k
297 if (!box && c.place) {
298 const a = spotPos(c.place.from, i, big), b = spotPos(c.place.to, i, big), e = ease(c.place.u)
299 x = a.x + (b.x - a.x) * e
300 y = a.y + (b.y - a.y) * e
301 }
302 const hop = c.mood === 'write' ? -Math.abs(Math.sin(t * 9 + i)) * (big ? 5 : 3)
303 : c.mood === 'type' ? -Math.abs(Math.sin(ti * 10)) * 3 * sc
304 : c.mood === 'think' ? -Math.abs(Math.sin(ti * 2.2)) * 2.5 * sc
305 : c.mood === 'idle' ? Math.round(Math.sin(ti * 2.2) * 1.5 * sc)
306 : c.mood === 'sleep' ? Math.sin(ti * 1.2) * 1.5 * sc
307 : c.mood === 'stretch' ? -Math.abs(Math.sin(ti * 2.6)) * 4 * sc
308 : 0
309 // 敲筆電:鍵盤冒出小光點
310 if (c.mood === 'type' && big) for (let kk = 0; kk < 4; kk++) {
311 const q = (t * 1.6 + kk / 4) % 1
312 o.push(`<rect x="${r1(254 + kk * 9 + Math.sin(t * 7 + kk) * 3)}" y="${r1(142 - q * 22)}" width="3" height="3" fill="${kk % 2 ? '#7DD3FC' : '#FFFFFF'}" opacity="${r1(1 - q)}"/>`)
313 }
314 o.push(crabSvg(c.mood, t + i * 0.7, x, y + hop, px, big ? undefined : c.color))
315 if (c.mood === 'sleep') for (let kk = 0; kk < 3; kk++) {
316 const q = (t * 0.45 + kk / 3) % 1
317 o.push(`<text x="${r1(x + (14 + q * 14) * (big ? 1 : 0.6))}" y="${r1(y + hop - 9 * px - q * 22 * (big ? 1 : 0.6))}" font-family="${FONT}" font-weight="900" font-size="${r1((8 + q * 6) * (big ? 1 : 0.7))}" fill="#6B7280" opacity="${r1(1 - q)}">z</text>`)
318 }
319 if (c.mood === 'coffee' || c.mood === 'drink') {
320 // 拿在鉗子上的杯子
321 const cx = x - 8 * px + 1 * px, cy = y + hop - 6 * px
322 o.push(`<rect x="${r1(cx - 3 * (big ? 1 : 0.6))}" y="${r1(cy - 5 * (big ? 1 : 0.6))}" width="${r1(6 * (big ? 1 : 0.6))}" height="${r1(7 * (big ? 1 : 0.6))}" rx="1" fill="${c.mood === 'coffee' ? '#fff' : '#BFE3F5'}" stroke="#9CA3AF" stroke-width=".6"/>`)
323 if (c.mood === 'coffee') { const q = (t * 0.8) % 1; o.push(`<path d="M${r1(cx)} ${r1(cy - 6 - q * 8)} q2 -3 0 -6" fill="none" stroke="#9CA3AF" stroke-width="1" opacity="${r1(1 - q)}"/>`) }
324 }
325 if (c.mood === 'think') o.push(thoughtBubble(x, y + hop - 9 * px, t + i * 0.4, big ? 1.5 : 0.85))
326 if (!big) {
327 const ty = y + hop - 9 * px - 3 - (i % 2) * 9
328 o.push(`<text x="${r1(x)}" y="${r1(ty)}" text-anchor="middle" font-family="${FONT}" font-weight="700" font-size="8" fill="${c.color}" stroke="#1F2328" stroke-opacity=".7" stroke-width="2.5" paint-order="stroke">${esc(clip(c.name, 10))}</text>`)
329 }
330 })
331 // 狀態標籤
332 const label = clip(inp.status, 26)
333 const lw = textWidth(label, 11) + 26
334 const dot = { idle: '#8FD18B', read: '#5BB8FF', write: '#FFC93C', think: '#B9A8FF', type: '#7DD3FC' }[inp.mood]
335 o.push(`<g transform="translate(${r1(W - 12 - lw)} 206)"><rect width="${r1(lw)}" height="20" rx="6" fill="#fff" stroke="#D0D7DE"/>` +
336 `<circle cx="11" cy="10" r="3.5" fill="${dot}"/><text x="19" y="14" font-family="${FONT}" font-size="11" font-weight="500" fill="#1F2328">${esc(label)}</text></g>`)
337 return o.join('')
338}
339
340// ---- 整張卡片:照畫布的深色卡片,標題、SESSION 列、舞台、清單都畫在圖裡 ----------
341// (面板元件的底色會被 App 換掉,畫進 SVG 才能跟畫布一樣)
342
343/** 記憶樹的一列:資料夾或檔案,畫布同款(縮排、圖示、標籤、右側接點)。 */
344export type TreeRow = { depth: number; kind: 'dir' | 'file'; name: string; tag?: string; mount?: boolean; key: string; seen: boolean; updated: boolean; live: MemOp | null }
345/** 紀錄的一列:時間、動作、內容。 */
346export type LogRow = { time: string; verb: string; text: string; op: MemOp | 'load' }
347export type CardSessionRow = { color: string; name: string; here: boolean; home: string; action: string; state: 'idle' | 'busy' | 'w' }
348export type CardInput = {
349 mode: 'session' | 'control'
350 line1: [string, string]
351 line2: [string, string]
352 stage: OfficeInput
353 conflicts: string[]
354 tree: TreeRow[]
355 log: LogRow[]
356 sessions: CardSessionRow[]
357 text: CardText
358}
359
360const K = { bg: '#13161A', line: '#2C3138', chip: '#1A1F25', fg: '#ECE9F0', muted: '#A09CAA', read: '#5BB8FF', write: '#FFC93C', acc: '#8FD18B', clay: '#D97757', liveR: '#16283A', liveW: '#2E2A17', file: '#D6D2DE', guide: '#2A2833', port: '#2B2E38' }
361const MONO = "ui-monospace, 'SF Mono', Menlo, monospace"
362const CW = 460
363const PORT_X = 442
364
365const ICON_DIR = 'M1.5 4.5v8h13v-6.5H7.5L6 4.5z'
366const ICON_FILE = 'M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3'
367
368/** 三次貝茲曲線上的一點。 */
369function bez(p: number[][], u: number): [number, number] {
370 const v = 1 - u
371 const x = v * v * v * p[0]![0]! + 3 * v * v * u * p[1]![0]! + 3 * v * u * u * p[2]![0]! + u * u * u * p[3]![0]!
372 const y = v * v * v * p[0]![1]! + 3 * v * v * u * p[1]![1]! + 3 * v * u * u * p[2]![1]! + u * u * u * p[3]![1]!
373 return [x, y]
374}
375
376/** 把一段說明切成最多兩行,每行不超過 maxW。 */
377function twoLines(s: string, size: number, maxW: number): string[] {
378 const chars = [...s]
379 let a = ''
380 while (chars.length && textWidth(a + chars[0], size) <= maxW) a += chars.shift()
381 if (!chars.length) return [a]
382 // 英文在空格處換行,不切斷單字
383 const cut = a.lastIndexOf(' ')
384 if (cut > 0 && /[A-Za-z]/.test(chars[0] ?? '')) { chars.unshift(...a.slice(cut + 1)); a = a.slice(0, cut) }
385 return [a, clipW(chars.join('').trim(), size, maxW)]
386}
387
388export function cardSvg(c: CardInput): string {
389 const o: string[] = []
390 const tx = (x: number, yy: number, s: string, size: number, fill: string, extra = '') => `<text x="${r1(x)}" y="${r1(yy)}" font-family="${FONT}" font-size="${size}" fill="${fill}" ${extra}>${esc(s)}</text>`
391 const t = c.stage.t
392 // 標題列:左邊名稱與標籤,右邊說明(畫布的 style-name 列)
393 const L = c.text
394 o.push(tx(14, 24, L.title, 14, K.fg, 'font-weight="700"'))
395 const tag = c.mode === 'control' ? L.tagControl : L.tagSession
396 const tw = textWidth(tag, 10.5) + 12
397 const tagX = 14 + textWidth(L.title, 14) + 10
398 o.push(`<rect x="${r1(tagX)}" y="12" width="${r1(tw)}" height="16" rx="8" fill="${c.mode === 'control' ? '#F5F5F0' : K.clay}"/>`, tx(tagX + 6, 23.5, tag, 10.5, c.mode === 'control' ? '#111418' : '#1A0F0A', 'font-weight="700"'))
399 const note = c.mode === 'control' ? L.noteControl : L.noteSession
400 const noteLines = twoLines(note, 11, CW - 28 - (tagX + tw + 12))
401 noteLines.forEach((line, i) => o.push(tx(CW - 14, 18 + i * 14, line, 11, K.muted, 'text-anchor="end"')))
402 let y = Math.max(38, 18 + noteLines.length * 14) + 4
403 // SESSION 列
404 o.push(`<rect x="10" y="${y}" width="440" height="42" rx="6" fill="${K.chip}"/>`)
405 o.push(tx(22, y + 17, c.line1[0], 10.5, K.muted, 'letter-spacing=".06em"'), `<text x="84" y="${y + 17}" font-family="${MONO}" font-size="11.5" fill="${K.fg}">${esc(clip(c.line1[1], 48))}</text>`)
406 o.push(tx(22, y + 34, c.line2[0], 10.5, K.muted), `<circle cx="88" cy="${y + 30.5}" r="3.5" fill="${K.acc}"/>`, `<text x="97" y="${y + 34}" font-family="${MONO}" font-size="11.5" fill="${K.fg}">${esc(clip(c.line2[1], 46))}</text>`)
407 y += 52
408 // 舞台
409 const stageY = y
410 o.push(`<defs><clipPath id="stageclip"><rect x="10" y="${y}" width="440" height="236" rx="8"/></clipPath>` +
411 '<filter id="beamglow" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceGraphic" stdDeviation="1.2" result="a"/><feMerge><feMergeNode in="a"/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs>')
412 o.push(`<g clip-path="url(#stageclip)"><g transform="translate(10 ${y})">${officeBody(c.stage)}</g></g>`)
413 y += 236 + 10
414 // 同時寫入提醒
415 for (const msg of c.conflicts) {
416 o.push(`<rect x="10" y="${y}" width="440" height="24" rx="5" fill="#3A1D1D"/>`, tx(20, y + 16, clipW(msg, 11.5, 420), 11.5, '#FF8A8A'))
417 y += 30
418 }
419 if (c.mode === 'session') {
420 // 記憶樹(畫布同款)
421 o.push(`<text x="12" y="${y + 12}" font-family="${MONO}" font-size="11" fill="${K.muted}">${esc(L.treeRoot)}</text>`)
422 y += 20
423 if (c.tree.length === 0) { o.push(tx(14, y + 14, L.noTree, 11.5, K.muted)); y += 24 }
424 let beam: { x1: number; y1: number; op: MemOp } | undefined
425 c.tree.forEach((r, idx) => {
426 const rowH = 28, cy = y + rowH / 2
427 if (r.live) o.push(`<rect x="10" y="${y}" width="420" height="${rowH}" rx="5" fill="${r.live === 'w' ? K.liveW : K.liveR}"/>`)
428 // 縮排導引線:接到下一列同層以下才畫
429 for (let d = 1; d <= r.depth; d++) o.push(`<rect x="${25 + (d - 1) * 18}" y="${y}" width="1" height="${rowH}" fill="${K.guide}"/>`)
430 const ix = 18 + r.depth * 18
431 o.push(`<path d="${r.kind === 'dir' ? ICON_DIR : ICON_FILE}" transform="translate(${ix} ${cy - 7.5}) scale(.94)" fill="none" stroke="${K.muted}" stroke-width="1.3" stroke-linejoin="round"/>`)
432 let x = ix + 22
433 const nm = clipW(r.name, 12.5 * 1.1, 300 - x)
434 const nameFill = r.live ? '#FFFFFF' : r.kind === 'dir' ? K.fg : K.file
435 o.push(`<text x="${x}" y="${cy + 4.5}" font-family="${MONO}" font-size="12.5" fill="${nameFill}" ${r.kind === 'dir' ? 'font-weight="600"' : ''}>${esc(nm)}</text>`)
436 x += [...nm].length * 7.6 + 8
437 if (r.tag) {
438 const w = textWidth(r.tag, 10.5) + 14
439 o.push(`<rect x="${r1(x)}" y="${cy - 8}" width="${r1(w)}" height="16" rx="8" fill="${K.chip}" ${r.mount ? `stroke="${K.acc}" stroke-opacity=".55"` : ''}/>`, tx(x + 7, cy + 4, r.tag, 10.5, r.mount ? K.acc : K.muted))
440 x += w + 6
441 }
442 if (r.updated) {
443 o.push(`<rect x="${r1(x)}" y="${cy - 8}" width="${r1(textWidth(L.updated, 10.5) + 14)}" height="16" rx="8" fill="#FFC93C33"/>`, tx(x + 7, cy + 4, L.updated, 10.5, K.write, 'font-weight="600"'))
444 }
445 // 右側接點
446 const portFill = r.live === 'r' ? K.read : r.live === 'w' || r.updated ? K.write : r.seen ? '#3F6A8F' : K.port
447 if (r.live) o.push(`<circle cx="${PORT_X}" cy="${cy}" r="9" fill="${r.live === 'w' ? K.write : K.read}" opacity=".35" filter="url(#beamglow)"/>`)
448 o.push(`<circle cx="${PORT_X}" cy="${cy}" r="4.5" fill="${portFill}" stroke="${K.bg}" stroke-width="2"/>`)
449 if (r.live && !beam) beam = { x1: PORT_X, y1: cy, op: r.live }
450 y += rowH + (idx === c.tree.length - 1 ? 0 : 1)
451 })
452 // 光束:從桌上的螃蟹接到正在讀寫的那一列(讀:檔案 → 螃蟹;寫:螃蟹 → 檔案)
453 if (beam) {
454 const x0 = 210, y0 = stageY + 146
455 const pts = [[x0, y0], [x0 + 30, y0 + 70], [Math.min(CW - 4, beam.x1 + 12), beam.y1 - 50], [beam.x1, beam.y1]]
456 const col = beam.op === 'w' ? K.write : K.read
457 o.push(`<path d="M${x0} ${y0} C${pts[1]!.join(' ')} ${pts[2]!.join(' ')} ${beam.x1} ${beam.y1}" fill="none" stroke="${col}" stroke-width="1.2" stroke-opacity=".5" stroke-dasharray="4 3"/>`)
458 for (let i = 0; i < 8; i++) {
459 const q = (t * 0.75 + i / 8) % 1
460 const [px, py] = bez(pts, beam.op === 'w' ? q : 1 - q)
461 const fade = Math.min(1, q * 6, (1 - q) * 6)
462 const fill = beam.op === 'w' ? '#FFE27A' : '#FFFFFF', stroke = beam.op === 'w' ? '#E0A800' : col
463 o.push(`<rect x="-2.5" y="-3" width="5" height="6" fill="${fill}" stroke="${stroke}" stroke-width=".7" opacity="${r1(fade)}" transform="translate(${r1(px)} ${r1(py)}) rotate(${r1(Math.sin(t * 3 + i) * 25)})" filter="url(#beamglow)"/>`)
464 }
465 }
466 // 紀錄
467 y += 10
468 o.push(`<line x1="10" y1="${y}" x2="450" y2="${y}" stroke="${K.guide}" stroke-dasharray="3 3"/>`)
469 y += 8
470 for (const l of c.log) {
471 const vc = l.op === 'w' ? K.write : l.op === 'r' ? K.read : K.fg
472 o.push(`<text x="22" y="${y + 12}" font-family="${MONO}" font-size="11" fill="#6F6A7C">${esc(l.time)}</text>`, tx(84, y + 12, l.verb, 11.5, vc, 'font-weight="500"'), tx(84 + textWidth(l.verb, 11.5) + 6, y + 12, clipW(l.text, 11.5, 330), 11.5, K.muted))
473 y += 18
474 }
475 if (c.log.length === 0) { o.push(tx(22, y + 12, L.noLog, 11.5, K.muted)); y += 18 }
476 } else {
477 if (c.sessions.length === 0) { o.push(tx(14, y + 14, L.noSessions, 11.5, K.muted)); y += 22 }
478 for (const r of c.sessions) {
479 o.push(`<rect x="10" y="${y}" width="440" height="42" rx="6" fill="${r.state === 'w' ? K.liveW : r.state === 'busy' ? K.liveR : K.chip}"/>`)
480 o.push(`<rect x="20" y="${y + 9}" width="10" height="10" rx="3" fill="${r.color}"/>`)
481 const nm = clip(r.name, 22)
482 o.push(tx(38, y + 18, nm, 13, K.fg, 'font-weight="700"'))
483 if (r.here) o.push(tx(38 + textWidth(nm, 13) + 4, y + 18, L.here, 11, K.muted))
484 o.push(`<text x="440" y="${y + 18}" text-anchor="end" font-family="${MONO}" font-size="10.5" fill="${K.muted}">${esc(L.mount)}${esc(clip(r.home, 16))}</text>`)
485 o.push(tx(38, y + 34, clipW(r.action, 11.5, 400), 11.5, r.state === 'w' ? K.write : r.state === 'busy' ? K.fg : K.muted))
486 y += 48
487 }
488 }
489 y += 8
490 return `<svg xmlns="http://www.w3.org/2000/svg" width="${CW}" height="${y}" viewBox="0 0 ${CW} ${y}">` +
491 `<rect x=".5" y=".5" width="${CW - 1}" height="${y - 1}" rx="10" fill="${K.bg}" stroke="${K.line}"/>` + o.join('') + '</svg>'
492}
493hooks/i18n.ts 197 lines1// 介面文字:英文與繁體中文。language 選項 auto 時看 LC_ALL / LANG。
2
3export type Lang = 'en' | 'zh-TW'
4
5export type CardText = {
6 title: string
7 tagSession: string
8 tagControl: string
9 noteSession: string
10 noteControl: string
11 treeRoot: string
12 noTree: string
13 noLog: string
14 noSessions: string
15 here: string
16 mount: string
17 updated: string
18 emptyBoard: string
19}
20
21export type Strings = {
22 paneTitle: string
23 cmdDesc: string
24 cmdSession: string
25 cmdControl: string
26 btnSession: string
27 btnControl: string
28 names: { index: string; common: string; home: string }
29 tagIndex: string
30 tagMount: string
31 tagHome: string
32 tagCommon: string
33 reading: string
34 writing: string
35 verbRead: string
36 verbWrote: string
37 logRead: string
38 logWrote: string
39 logLoaded: string
40 run: (tool: string, secs: number) => string
41 thinking: string
42 idleToday: (r: number, w: number) => string
43 idle: string
44 stretch: string
45 drink: string
46 coffee: string
47 sleep: string
48 ctlBusy: (n: number) => string
49 ctlIdle: (n: number) => string
50 secAgo: (s: number) => string
51 minAgo: (m: number) => string
52 goTo: (island: string) => string
53 lastDid: (what: string, ago: string) => string
54 conflict: (file: string, who: string, ago: string) => string
55 toast: (file: string, who: string) => string
56 termUpdated: string
57 termWriting: string
58 termReading: string
59 here: string
60 sessionLine: [string, string]
61 controlLine: [string, (n: number, m: number) => string]
62 viewLine: [string, string]
63 card: CardText
64}
65
66export const STRINGS: Record<Lang, Strings> = {
67 en: {
68 paneTitle: 'Clawd memory office',
69 cmdDesc: 'Open the memory office pane; /clawd all shows every session on this machine',
70 cmdSession: 'Memory office: this session',
71 cmdControl: 'Memory office: all sessions on this machine',
72 btnSession: 'This session',
73 btnControl: 'All sessions',
74 names: { index: 'Index', common: 'Common', home: 'Home' },
75 tagIndex: 'cross-project index',
76 tagMount: 'mounted',
77 tagHome: 'home',
78 tagCommon: 'shared lessons',
79 reading: 'Reading',
80 writing: 'Writing',
81 verbRead: 'read',
82 verbWrote: 'wrote',
83 logRead: 'Read',
84 logWrote: 'Wrote',
85 logLoaded: 'Loaded',
86 run: (tool, secs) => `Running ${tool}${secs ? ` · ${secs}s` : ''}`,
87 thinking: 'Thinking…',
88 idleToday: (r, w) => `Idle · ${r} reads, ${w} writes today`,
89 idle: 'Idle',
90 stretch: 'Stretching',
91 drink: 'At the water cooler',
92 coffee: 'Coffee break',
93 sleep: 'Dozing off',
94 ctlBusy: n => `All sessions · ${n} busy`,
95 ctlIdle: n => `All sessions · ${n} idle`,
96 secAgo: s => `${s}s ago`,
97 minAgo: m => `${m}m ago`,
98 goTo: island => `Heading to ${island}`,
99 lastDid: (what, ago) => `Idle · last ${what} · ${ago}`,
100 conflict: (file, who, ago) => `⚠ ${file}: ${who} both wrote it ${ago}; one may overwrite the other`,
101 toast: (file, who) => `⚠ ${file}: the ${who} session just wrote it too. Check that nothing was overwritten`,
102 termUpdated: ' updated',
103 termWriting: ' ← writing',
104 termReading: ' ← reading',
105 here: ' (here)',
106 sessionLine: ['SESSION', 'MEMORY'],
107 controlLine: ['ALL', (n, m) => `${n} sessions · ${m} memory folders`],
108 viewLine: ['VIEW', 'who is touching which folder'],
109 card: {
110 title: 'Office',
111 tagSession: 'This session',
112 tagControl: 'All sessions',
113 noteSession: 'Reads open a filing drawer; writes stick a note on the whiteboard',
114 noteControl: 'One crab per session, walking to the folder it reads or writes',
115 treeRoot: 'Memory files',
116 noTree: 'No memory touched yet',
117 noLog: 'Nothing yet',
118 noSessions: 'No session touched memory in the last 30 minutes',
119 here: '(here)',
120 mount: 'in ',
121 updated: 'updated',
122 emptyBoard: 'No memory touched today',
123 },
124 },
125 'zh-TW': {
126 paneTitle: 'Clawd 記憶辦公室',
127 cmdDesc: '打開記憶辦公室面板;/clawd 總控 看本機所有 session',
128 cmdSession: '記憶辦公室:這個 session',
129 cmdControl: '記憶辦公室:總控(本機所有 session)',
130 btnSession: '這個 session',
131 btnControl: '總控',
132 names: { index: '總目錄', common: '通用', home: '主島' },
133 tagIndex: '跨專案總目錄',
134 tagMount: '掛載中',
135 tagHome: '主島',
136 tagCommon: '通用教訓',
137 reading: '讀取',
138 writing: '寫入',
139 verbRead: '讀',
140 verbWrote: '寫',
141 logRead: '讀取',
142 logWrote: '寫入',
143 logLoaded: '載入',
144 run: (tool, secs) => `執行 ${tool}${secs ? ` · ${secs} 秒` : ''}`,
145 thinking: '思考中…',
146 idleToday: (r, w) => `待命 · 今天讀 ${r} 寫 ${w}`,
147 idle: '待命',
148 stretch: '伸懶腰',
149 drink: '在飲水機喝水',
150 coffee: '在喝咖啡',
151 sleep: '在打瞌睡',
152 ctlBusy: n => `總控 · ${n} 個 session 在動`,
153 ctlIdle: n => `總控 · ${n} 個 session · 待命`,
154 secAgo: s => `${s} 秒前`,
155 minAgo: m => `${m} 分前`,
156 goTo: island => `前往${island}`,
157 lastDid: (what, ago) => `待命 · 上次${what} · ${ago}`,
158 conflict: (file, who, ago) => `⚠ ${file}:${who} 兩個 session ${ago}都寫過,可能互相覆蓋`,
159 toast: (file, who) => `⚠ ${file}:${who} 的 session 剛也寫過,可能互相覆蓋,請檢查`,
160 termUpdated: ' 已更新',
161 termWriting: ' ← 寫入中',
162 termReading: ' ← 讀取中',
163 here: '(這裡)',
164 sessionLine: ['SESSION', '記憶島'],
165 controlLine: ['總控', (n, m) => `本機 ${n} 個 session · ${m} 座記憶島`],
166 viewLine: ['視角', '只看誰在碰哪座島'],
167 card: {
168 title: '一般辦公室',
169 tagSession: '這個 session',
170 tagControl: '總控',
171 noteSession: '白天的開放式辦公室:讀取拉開檔案櫃,寫入在白板貼一張便利貼',
172 noteControl: '每隻螃蟹是一個 session,走到牠正在讀寫的那座島',
173 treeRoot: '記憶檔',
174 noTree: '這個 session 還沒碰記憶',
175 noLog: '還沒有紀錄',
176 noSessions: '最近 30 分鐘沒有 session 碰記憶',
177 here: '(這裡)',
178 mount: '掛',
179 updated: '已更新',
180 emptyBoard: '今天還沒有碰記憶',
181 },
182 },
183}
184
185export type LanguageOption = 'auto' | Lang
186
187export function asLanguage(value: unknown): LanguageOption {
188 const v = String(value ?? '').trim()
189 return v === 'en' || v === 'zh-TW' ? v : 'auto'
190}
191
192/** auto:環境變數 LC_ALL / LANG 有 zh 就用繁中,否則英文。 */
193export function pickLang(option: LanguageOption, env: string): Lang {
194 if (option !== 'auto') return option
195 return /zh/i.test(env) ? 'zh-TW' : 'en'
196}
197types/index.d.ts 48 lines1/** 讀(r)或寫(w)一份記憶。 */
2export type MemOp = 'r' | 'w'
3
4/** 共用事件檔裡的一行:某個 session 在某個時間讀或寫了某份記憶。 */
5export type MemEvent = {
6 ts: number
7 session: string
8 cwd: string
9 /** 工具名稱,自動載入記成 `autoload:<kind>`。 */
10 tool: string
11 op: MemOp
12 path: string
13 /** 記憶島資料夾名,或 `memory-common`、`memory-index.md`。 */
14 island: string
15 agentId?: string
16}
17
18/** 回合開始/結束的標記:總控用它判斷別的 session 正在思考。 */
19export type TurnMark = { ts: number; session: string; cwd: string; turn: 'start' | 'end' }
20
21/** 這個 session 自己的狀態:回合進行中、正在跑哪個工具。 */
22export type LocalState = {
23 turn: boolean
24 tool: string | null
25 /** 回合開始、進入待命、工具開始的時間(毫秒)。 */
26 turnAt: number
27 idleAt: number
28 toolAt: number
29}
30
31/** session:只看這個 session、列到檔案;control:總控,本機所有 session、只看資料夾。 */
32export type ViewMode = 'session' | 'control'
33
34declare module 'claude-code' {
35 interface PluginState {
36 'clawd-memory': {
37 /** 今天的事件(本機所有 session),從共用事件檔讀來。 */
38 events: MemEvent[]
39 /** 舞台影格,時鐘推進它讓面板重畫。 */
40 frame: number
41 view: ViewMode
42 /** 今天各 session 的回合標記。 */
43 turns: TurnMark[]
44 local: LocalState
45 }
46 }
47}
48