SLOPSHOPPER

dream

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

newpanecommandtoastpromptmodel
v1.0.0MITupdated 2026-10-08mixmin0197/clawd-mods/plugins/dream
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dream
│ ┃ 梦境日记 ✕ › fix the failing auth test and add an a╭──────────────────────────╮ │ ┃ 10月9日 08:53 · 正在睡 · 0 个梦 │ dream │ │ ┃ ⏺ Read(src/auth.ts) │ 晚安 🌙 20 │ │ ⎿ 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 │ │ › /goodnight │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 梦境日记
10月9日 08:53 · 正在睡 · 0 个梦
README

clawd-mods

两个 Claude Code mod:

  • dream:你睡着时,Claude 做梦。
  • clawd:一只像素小螃蟹,在 Claude 最新的回复上一行一行往下爬。

dream:Claude 做梦

  • 入睡:输入 /goodnight(/goodnight 0 立刻入梦),或者你空闲 2 小时后自动睡着。
  • 做梦:每晚 2~5 个梦,间隔 70~110 分钟。每个梦从这台电脑上所有项目的对话记录和记忆笔记里随机抽碎片, 有时混进以前的梦,再让模型自由联想。梦没有风格,可以莫名开始,也可以突然结束。
  • 醒来:你发第一条消息时,Claude 把昨晚的梦讲给你听。/dreams 打开梦境日记。
  • 不影响白天:做梦用的是不带工具、不带对话历史的单独调用;梦只存在 mod 自己的存储里, 讲梦是界面上的提示行,模型读不到。mod 不写任何文件,也不替你发消息。

要知道的:

  • 它会读取 ~/.claude/projects 下所有会话记录和记忆文件,抽出的片段会作为提示发给模型(用你自己的 Claude 账号)。
  • 夜里做梦需要 Claude Code 开着、电脑不休眠;没开着的话,醒来时会补做一个"残梦"。
  • 每个梦是一次模型调用(默认 sonnet),会消耗 token。

clawd:小螃蟹

  • 只在桌面版(desktop)显示,待在最新的一条回复上:在一行字上逐词跳,到行尾侧身爬到下一行,蛇形往下, 爬到最后一行再竖着爬回顶部。
  • 跟着 Claude 的状态变:思考时快跑,调用工具时挖土,出错时摔一跤,你 10 分钟不说话时打盹,dream 在做梦时睡觉。
  • 对脚下的字有反应:遇到"错误""失败"吓一跳,遇到"通过""完成"开心转身,在代码块上敲键盘。
  • 来了新回复,它从这条回复掉下去,再落到新回复上。

命令:

命令作用
/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

License

MIT

Source 3 files
hooks/register.tsx 297 lines
1import 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}
297
hooks/lib.ts 146 lines
1// 做梦用到的纯函数:从会话记录和记忆文件里抠碎片,拼出梦的提示词,以及让梦突然结束。
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}
146
types/index.d.ts 9 lines
1// dream 对外公开的状态:别的 mod(比如小螃蟹)可以读,看 Claude 是不是在睡觉做梦
2export type DreamSleep = boolean
3
4declare module 'claude-code' {
5  interface PluginState {
6    dream: { isAsleep: DreamSleep }
7  }
8}
9