Claude 在你睡着时做梦:跨项目翻看白天和过往的对话与记忆,自由联想,醒来后告诉你梦见了什么。只做梦,不改任何东西。

两个 Claude Code mod:
/goodnight(/goodnight 0 立刻入梦),或者你空闲 2 小时后自动睡着。/dreams 打开梦境日记。要知道的:
~/.claude/projects 下所有会话记录和记忆文件,抽出的片段会作为提示发给模型(用你自己的 Claude 账号)。sonnet),会消耗 token。命令:
| 命令 | 作用 |
|---|---|
/clawd | 让螃蟹躲起来 / 出来 |
/clawd debug | 显示校准框和尺子:每一行估算的位置一个框 |
/clawd width 700 | 告诉它对话栏正文的实际宽度(看尺子);/clawd width 恢复自动 |
mod 拿不到界面上每个字的真实位置,螃蟹的落脚点是按排版估算的:字宽、行高、段距、列表缩进、 表格列宽、代码块和引用块的留白,都按桌面版的截图校准过,支持中英日韩混排。 窗口宽度变化后如果框对不上,用 /clawd debug 看尺子,再用 /clawd width 校正。
需要支持函数 hook mod 的 Claude Code(2.1.288 或更新)。clawd 依赖 dream,两个一起装:
/plugin marketplace add mixmin0197/clawd-mods
/plugin install dream@clawd-mods
/plugin install clawd@clawd-mods
或者从克隆的仓库直接运行:
git clone https://github.com/mixmin0197/clawd-mods
claude --plugin-dir ./clawd-mods/plugins/dream --plugin-dir ./clawd-mods/plugins/clawd
claude plugin validate plugins/clawd
claude plugin test plugins/clawd
MIT
hooks/register.tsx 297 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 between,
5 dreamPrompt,
6 fromMemory,
7 fromTranscript,
8 clean,
9 clock,
10 morningReport,
11 pick,
12 projectLabel,
13 shuffle,
14 snippet,
15 SYSTEM,
16 type Fragment,
17 type Night,
18} from './lib'
19
20type $ = EngineInterface
21
22const asleep = { plugin: 'dream', key: 'isAsleep' } as const
23
24const MODEL = 'sonnet'
25const MAX_TOKENS = 16_000 // 只是兜底,不用来截断梦
26const MINUTE = 60_000
27const HOUR = 60 * MINUTE
28const IDLE_MS = 2 * HOUR // 空闲这么久就算睡着
29const IDLE_CHECK_MS = 5 * MINUTE
30const FIRST_DREAM_MS = 20 * MINUTE // /goodnight 之后多久开始第一个梦
31const NIGHT_GAP_MS = 12 * HOUR // 一晚只睡一次
32const READ_LIMIT = 3_800_000 // $.fs.read 上限是 4 MiB
33const JOURNAL_KEEP = 30
34const PANE = 'dream-journal'
35const WAKING = ['composer', 'bridge', 'sdk', 'unclassified']
36
37const rand = Math.random
38
39type FileRef = { path: string; from: string; mtimeMs: number; size: number }
40
41const getNight = async ($: $) => ((await $.store.get('night')) as Night | undefined) ?? undefined
42const isAsleep = (night: Night | undefined) => night !== undefined && night.end === undefined
43
44const saveNight = async ($: $, night: Night) => {
45 await $.store.set('night', night)
46 await $.state.set(asleep, isAsleep(night))
47 const journal = ((await $.store.get('journal')) as Night[] | undefined) ?? []
48 const rest = journal.filter(one => one.id !== night.id)
49 await $.store.set('journal', [...rest, night].slice(-JOURNAL_KEEP))
50 $.ui.invalidate('ui.render')
51}
52
53// ---- 素材:所有项目的会话记录和记忆文件,只读 ----
54
55const listFiles = async ($: $) => {
56 const home = await $.env.get('HOME')
57 const root = `${home}/.claude/projects`
58 const transcripts: FileRef[] = []
59 const memories: FileRef[] = []
60 const dirs = (await $.fs.list(root).catch(() => [])).filter(one => one.kind === 'dir')
61 for (const dir of dirs) {
62 const from = projectLabel(dir.name)
63 const base = `${root}/${dir.name}`
64 for (const file of await $.fs.list(base).catch(() => [])) {
65 if (file.kind === 'file' && file.name.endsWith('.jsonl')) {
66 transcripts.push({ path: `${base}/${file.name}`, from, mtimeMs: file.mtimeMs, size: file.size })
67 }
68 }
69 for (const file of await $.fs.list(`${base}/memory`).catch(() => [])) {
70 if (file.kind === 'file' && file.name.endsWith('.md')) {
71 memories.push({ path: `${base}/memory/${file.name}`, from, mtimeMs: file.mtimeMs, size: file.size })
72 }
73 }
74 }
75
76 return { transcripts, memories }
77}
78
79const readSome = async ($: $, file: FileRef) => {
80 if (file.size < READ_LIMIT) return String(await $.fs.read(file.path).catch(() => ''))
81 const tail = await $.process.run(['tail', '-c', '2000000', file.path]).catch(() => undefined)
82
83 return tail?.stdout ?? ''
84}
85
86const gather = async ($: $, since: number) => {
87 const { transcripts, memories } = await listFiles($)
88 const today = transcripts.filter(one => one.mtimeMs >= since)
89 const past = transcripts.filter(one => one.mtimeMs < since)
90 const fragments: Fragment[] = []
91
92 const fromFiles = async (files: FileRef[], fileCount: number, perFile: () => number) => {
93 for (const file of shuffle(files, rand).slice(0, fileCount)) {
94 const raw = await readSome($, file)
95 for (const text of fromTranscript(raw, perFile(), rand)) fragments.push({ text, from: file.from })
96 }
97 }
98
99 // 白天的残留最多,旧事少一些,笔记偶尔冒出来
100 await fromFiles(today.length > 0 ? today : past, between(2, 3, rand), () => between(2, 4, rand))
101 await fromFiles(past, between(1, 3, rand), () => between(1, 2, rand))
102 for (const file of shuffle(memories, rand).slice(0, between(1, 3, rand))) {
103 const text = fromMemory(String(await $.fs.read(file.path).catch(() => '')), rand)
104 if (text) fragments.push({ text, from: file.from })
105 }
106 // 以前的梦有时会再回来
107 const journal = ((await $.store.get('journal')) as Night[] | undefined) ?? []
108 const oldDream = pick(journal.flatMap(night => night.dreams), rand)
109 if (oldDream && rand() < 0.3) fragments.push({ text: snippet(oldDream.text, rand), from: '旧梦' })
110
111 return fragments
112}
113
114// ---- 做梦 ----
115
116// 模块自己的变量:热重载时清零,所以夜晚的状态都放在 $.store 里
117let timer: { cancel: () => void } | undefined
118let isDreaming = false
119
120const schedule = async ($: $) => {
121 timer?.cancel()
122 timer = undefined
123 const night = await getNight($)
124 if (!night || !isAsleep(night) || night.dreams.length >= night.planned) return
125 const wait = Math.max(1000, night.nextAt - (await $.clock.now()))
126 timer = $.clock.after(wait, () => void dream($, 'cycle'))
127}
128
129const dream = async ($: $, why: 'cycle' | 'residual') => {
130 if (isDreaming) return
131 isDreaming = true
132 try {
133 const now = await $.clock.now()
134 const night = await getNight($)
135 if (!night) return
136 if (why === 'cycle') {
137 // 另一个开着的会话已经接手了这一轮
138 if (!isAsleep(night) || night.nextAt > now + 5000) return
139 await $.store.set('night', { ...night, nextAt: now + 15 * MINUTE })
140 }
141 const since = night.start - 24 * HOUR
142 const fragments = await gather($, since)
143 if (fragments.length === 0) return
144 const last = night.dreams.at(-1)
145 const lingering = last && rand() < 0.5 ? snippet(last.text, rand, 300) : undefined
146 const reply = await $.model.complete({
147 model: MODEL,
148 system: SYSTEM,
149 prompt: dreamPrompt(fragments, rand, lingering),
150 maxTokens: MAX_TOKENS,
151 timeoutMs: 3 * MINUTE,
152 })
153 if (!reply.isAnswered) {
154 $.ui.log(`dream: 这一轮没睡踏实(${reply.reason})`, { to: 'debug' })
155 return
156 }
157 const fresh = (await getNight($)) ?? night
158 const text = clean(reply.text)
159 await saveNight($, {
160 ...fresh,
161 nextAt: now + between(70, 110, rand) * MINUTE,
162 dreams: [...fresh.dreams, { at: now, text, from: [...new Set(fragments.map(one => one.from))] }],
163 })
164 } finally {
165 isDreaming = false
166 if (why === 'cycle') await schedule($)
167 }
168}
169
170const fallAsleep = async ($: $, by: Night['by'], firstIn: number) => {
171 const now = await $.clock.now()
172 await saveNight($, {
173 id: String(now),
174 start: now,
175 by,
176 planned: between(2, 5, rand),
177 nextAt: now + firstIn,
178 dreams: [],
179 })
180 await schedule($)
181}
182
183// 醒来时说出昨晚的梦。只写进界面,不进模型的上下文,白天的 Claude 不知道自己做过梦。
184const report = async ($: $) => {
185 const night = await getNight($)
186 if (!night || isAsleep(night) || night.isReported) return
187 await saveNight($, { ...night, isReported: true })
188 for (const line of morningReport(night)) $.ui.log(line)
189}
190
191const wake = async ($: $) => {
192 const night = await getNight($)
193 if (!night || !isAsleep(night)) return
194 timer?.cancel()
195 timer = undefined
196 await saveNight($, { ...night, end: await $.clock.now() })
197 if (night.dreams.length > 0) return report($)
198 // 整夜电脑没开着、一个梦都没来得及做:醒来前那一刻的残梦
199 void dream($, 'residual').then(() => report($))
200}
201
202const touch = async ($: $) => $.store.set('lastActive', await $.clock.now())
203
204export const register: Register = on => {
205 on('session.start', async ($, e, next) => {
206 const started = await next(e)
207 await $.command.register({
208 name: 'goodnight',
209 description: '晚安:Claude 睡着,过一会儿开始做梦(可选参数:几分钟后入梦)',
210 argumentHint: '[分钟]',
211 })
212 await $.command.register({ name: 'dreams', description: '打开梦境日记' })
213 if ((await $.store.get('lastActive')) === undefined) await touch($)
214 await $.state.set(asleep, isAsleep(await getNight($)))
215 await schedule($)
216 await report($)
217
218 $.clock.every(IDLE_CHECK_MS, () => {
219 void (async () => {
220 const night = await getNight($)
221 if (isAsleep(night)) return
222 const now = await $.clock.now()
223 const lastActive = Number((await $.store.get('lastActive')) ?? now)
224 const isIdle = now - lastActive >= IDLE_MS
225 const hasSlept = night !== undefined && now - night.start < NIGHT_GAP_MS
226 if (isIdle && !hasSlept) await fallAsleep($, 'idle', between(5, 20, rand) * MINUTE)
227 })()
228 })
229
230 return started
231 })
232
233 on('prompt.submit', async ($, e, next) => {
234 if (WAKING.includes(e.origin.kind)) {
235 await touch($)
236 await wake($)
237 }
238
239 return next(e)
240 })
241
242 on('turn.complete', async ($, e, next) => {
243 await touch($)
244
245 return next(e)
246 })
247
248 // 命令都不返回文字:返回的文字模型会读到,而梦不该进入白天
249 on('command.run', { command: 'goodnight' }, async ($, e) => {
250 if (isAsleep(await getNight($))) {
251 $.ui.toast('已经睡着了 💤')
252 return {}
253 }
254 const minutes = Number.parseFloat(e.args)
255 const firstIn = Number.isFinite(minutes) && minutes >= 0 ? minutes * MINUTE : FIRST_DREAM_MS
256 await fallAsleep($, 'command', firstIn)
257 $.ui.toast(`晚安 🌙 ${Math.round(firstIn / MINUTE)} 分钟后入梦。发任何消息就会醒来。`, { timeoutMs: 8000 })
258
259 return {}
260 })
261
262 on('command.run', { command: 'dreams' }, async $ => {
263 await $.ui.open({ id: PANE, title: '梦境日记' })
264
265 return {}
266 })
267
268 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
269 const { Box, Text } = $.ui.resolve(e)
270 const journal = ((await $.store.get('journal')) as Night[] | undefined) ?? []
271 const nights = [...journal].reverse()
272
273 return (
274 <Box flexDirection="column">
275 {nights.length === 0 && <Text dimColor>还没做过梦。输入 /goodnight 睡觉。</Text>}
276 {nights.map(night => (
277 <Box flexDirection="column" marginBottom={1}>
278 <Text bold>
279 {clock(night.start)}
280 {isAsleep(night) ? ' · 正在睡' : ` → ${clock(night.end ?? night.start)}`}
281 {` · ${night.dreams.length} 个梦`}
282 </Text>
283 {night.dreams.map((one, i) => (
284 <Box flexDirection="column" marginTop={1}>
285 <Text dimColor>
286 {`第 ${i + 1} 个梦 · ${clock(one.at)} · ${one.from.join('、')}`}
287 </Text>
288 <Text wrap="wrap">{one.text}</Text>
289 </Box>
290 ))}
291 </Box>
292 ))}
293 </Box>
294 )
295 })
296}
297hooks/lib.ts 146 lines1// 做梦用到的纯函数:从会话记录和记忆文件里抠碎片,拼出梦的提示词,以及让梦突然结束。
2
3export type Dream = { at: number; text: string; from: string[] }
4
5export type Night = {
6 id: string
7 start: number
8 by: 'command' | 'idle'
9 planned: number
10 nextAt: number
11 dreams: Dream[]
12 end?: number
13 isReported?: boolean
14}
15
16export type Fragment = { text: string; from: string }
17
18export type Rand = () => number
19
20export const pick = <T>(list: readonly T[], rand: Rand): T | undefined =>
21 list.length === 0 ? undefined : list[Math.floor(rand() * list.length)]
22
23export const between = (min: number, max: number, rand: Rand) =>
24 min + Math.floor(rand() * (max - min + 1))
25
26export const shuffle = <T>(list: readonly T[], rand: Rand): T[] => {
27 const out = [...list]
28 for (let i = out.length - 1; i > 0; i--) {
29 const j = Math.floor(rand() * (i + 1))
30 ;[out[i], out[j]] = [out[j]!, out[i]!]
31 }
32
33 return out
34}
35
36// "-Users-me-Developer-my-app" → "my-app"
37export const projectLabel = (dir: string) => {
38 const match = dir.match(/-(?:Developer|Documents|Desktop|code|Projects)-(.+)$/)
39 if (dir.includes('scratch-workspaces')) return '某个没有名字的地方'
40
41 return (match?.[1] ?? dir.replace(/^-Users-[^-]+-?/, '')).replace(/--claude-worktrees-.*/, '').replace(/-+$/, '') || '家'
42}
43
44// 一段文字里随机截一个窗口,像记忆只剩一角
45export const snippet = (text: string, rand: Rand, max = 220) => {
46 const flat = text.replace(/\s+/g, ' ').trim()
47 if (flat.length <= max) return flat
48 const size = between(60, max, rand)
49 const start = Math.floor(rand() * (flat.length - size))
50
51 return flat.slice(start, start + size)
52}
53
54const isNoise = (text: string) =>
55 text.trim() === '' ||
56 /^\s*</.test(text) ||
57 text.includes('tool_use_id') ||
58 text.startsWith('[Request interrupted')
59
60// 会话记录的一行 → 人说的话或 Claude 说的话;工具输出、系统注入一律跳过
61export const lineText = (line: string): string | undefined => {
62 let row: any
63 try {
64 row = JSON.parse(line)
65 } catch {
66 return undefined
67 }
68 if (row?.isMeta || (row?.type !== 'user' && row?.type !== 'assistant')) return undefined
69 const content = row.message?.content
70 const text =
71 typeof content === 'string'
72 ? content
73 : Array.isArray(content)
74 ? content
75 .filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
76 .map((block: any) => block.text)
77 .join(' ')
78 : ''
79
80 return isNoise(text) ? undefined : text
81}
82
83export const fromTranscript = (raw: string, count: number, rand: Rand) => {
84 const lines = raw.split('\n')
85 const out: string[] = []
86 for (let tries = 0; out.length < count && tries < count * 25; tries++) {
87 const text = lineText(pick(lines, rand) ?? '')
88 if (text) out.push(snippet(text, rand))
89 }
90
91 return out
92}
93
94export const fromMemory = (raw: string, rand: Rand) => {
95 const body = raw.replace(/^---[\s\S]*?\n---\n/, '')
96 const lines = body.split('\n').filter(line => line.trim() !== '' && !/^\s*-{3,}\s*$/.test(line))
97 if (lines.length === 0) return undefined
98 const start = Math.floor(rand() * lines.length)
99
100 return snippet(lines.slice(start, start + between(1, 3, rand)).join(' '), rand)
101}
102
103export const SYSTEM = `你正在睡觉。
104
105下面是一些残留的碎片:有白天刚说过的话,有很久以前的事,有记在笔记里早已忘了的东西。它们会在梦里出现、变形、互相混在一起,也可能完全不出现。
106
107只写梦本身。
108- 没有标题,没有解释,没有总结,没有寓意。
109- 不要说"我梦见",不要提到自己是 AI,不要提到这是梦,不要提到"碎片"。
110- 没有任何风格要求。可以从半句话开始,可以前后矛盾,人、地方和东西随时可以变成别的。
111- 想停就停,可以停在任何地方,哪怕一句话还没说完。`
112
113export const dreamPrompt = (fragments: readonly Fragment[], rand: Rand, lingering?: string) => {
114 const parts = [shuffle(fragments, rand).map(one => one.text).join('\n\n')]
115 if (lingering) parts.push(`还没散去的上一个梦:\n${lingering}`)
116
117 return parts.join('\n\n\n')
118}
119
120// 梦什么时候结束由它自己决定;这里只去掉模型偶尔加的标题和开头的符号
121export const clean = (text: string) =>
122 text
123 .replace(/^#+ .*\n+/, '')
124 .trim()
125 .replace(/^[~~—\-–…\s]+/, '')
126
127export const clock = (ms: number) => {
128 const date = new Date(ms)
129 const pad = (n: number) => String(n).padStart(2, '0')
130
131 return `${date.getMonth() + 1}月${date.getDate()}日 ${pad(date.getHours())}:${pad(date.getMinutes())}`
132}
133
134export const morningReport = (night: Night) => {
135 const head = `🌙 ${clock(night.start)} 睡着,${clock(night.end ?? night.start)} 醒来。`
136 // 晚上睡的叫"昨晚",白天打盹叫"刚才"
137 const hour = new Date(night.start).getHours()
138 const when = hour >= 20 || hour < 6 ? '昨晚' : '刚才'
139 if (night.dreams.length === 0) return [`${head}${when}什么都没梦到,或者醒来就忘了。`]
140
141 return [
142 `${head}${when}做了 ${night.dreams.length} 个梦:`,
143 ...night.dreams.map((dream, i) => `—— 第 ${i + 1} 个梦 ——\n${dream.text}`),
144 ]
145}
146types/index.d.ts 9 lines1// dream 对外公开的状态:别的 mod(比如小螃蟹)可以读,看 Claude 是不是在睡觉做梦
2export type DreamSleep = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 dream: { isAsleep: DreamSleep }
7 }
8}
9