SLOPSHOPPER

clawd-memory

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

newpaneguardcommandtoastprompt
v1.0.0MITupdated 2026-10-20Jincoco88912/clawd-memory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clawd-memory
│ ┃ Clawd memory office ✕ › fix the failing auth test and add an audit log call │ ┃ [ This session ] [ All sessions ] │ ┃ Stretching ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /clawd │ ⎿ clawd-memory: Memory office: this session │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Clawd memory office
[ This session ] [ All sessions ] Stretching
README

clawd-memory

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"> &nbsp; <img src="docs/screenshots/control.png" alt="All sessions: one crab per session and a warning for a double write" width="400">

Why

  • Did it actually read that? When Claude repeats a mistake you wrote a memory about, the pane shows whether it ever opened that file in this session, or whether the session started in the wrong folder.
  • Two sessions, one file. If two sessions write the same memory file within two minutes, a red light flashes and you get a notice, since one write may silently overwrite the other (for example on a synced drive).
  • Memory housekeeping. Every read and write is logged per day, so you can see which memories are used and which never are.

What the crab does

StateIn the office
Reading memoryThe drawer for that folder opens and glows; a line runs from the file to the crab (paper floats up)
Writing memoryThe crab hops, a sticky note is added to the whiteboard, the file is marked updated
ThinkingPaces on the desk with a thought bubble (…, sometimes ?)
Running a toolTypes on the laptop; if one tool runs over 6 s, the printer starts printing
IdleStretches, 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.

How it works

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.

Setup

  1. Load the mod:
  2. Terminal: claude --plugin-dir /path/to/clawd-memory
  3. Desktop app: add the folder to CLAUDE_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.

  1. Open the pane with /clawd, or /clawd all for every session. The buttons above the office switch between the two views.
  2. Options (optional) go in ~/.claude/settings.json under pluginConfigs.clawd-memory.options:
OptionDefaultMeaning
languageautoauto (follows LANG), en, zh-TW
default_viewsessionsession 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.

Development

claude plugin validate .
claude plugin test .
FileWhat it holds
.claude-plugin/plugin.jsonManifest and options
hooks/register.tsxHooks, the shared event file, the scene and the pane
hooks/office.tsThe drawings: office, crab sprites, props, the whole card
hooks/memory.tsMemory path matching, event parsing, double-write detection
hooks/i18n.tsEnglish and Traditional Chinese text
types/index.d.ts$.state contract
tests/clawd-memory.test.tsPlugin tests

License

MIT

Source 5 files
hooks/register.tsx 464 lines
1import { 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}
464
hooks/memory.ts 132 lines
1// 記憶路徑的辨識與整理:純函式,不碰 $,方便測試。
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}
132
hooks/office.ts 493 lines
1// 一般辦公室舞台:給定時間與狀態,畫出整張 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
493
hooks/i18n.ts 197 lines
1// 介面文字:英文與繁體中文。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}
197
types/index.d.ts 48 lines
1/** 讀(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