Garbage collection for Claude Code's memory: gives a session what the dream accepted, recalls from the archive, and shows what waits for your ruling.

The part of remcycle that runs inside Claude Code. remcycle is garbage collection for Claude Code's memory: once a day it rereads your sessions, keeps what you decided, and questions the notes that have gone stale.
recall searches past sessions and reads them back, close_thread marks unfinished work done, and settle_memory applies a ruling you reached together./remcycle, a panel that shows what is remembered and lets you rule on what is in doubt.The mod does its work by running remcycle's dream command, so that has to be installed too:
uv tool install remcycle
Without it the mod loads and does nothing: a conversation starts unchanged, and recall answers that remcycle could not be reached.
It runs in Claude Code only, and needs 2.1.287 or later in the terminal or 2.1.286 in the desktop app. On claude.ai and in Cowork it does nothing.
The one program it runs. The mod starts one program, dream, remcycle's own command line, which you install separately as above, and nothing else. It always runs dream followed by one of these:
context, when a conversation starts, for what to hand the session.daily, when a session starts, which starts the day's dream in the background if one is due; and daily on, daily off or daily history ... from the panel's switches.status, when the panel is drawn or refreshed, and before settle_memory applies a ruling.search and show, when Claude uses the recall tool.close, reopen and resolve, when Claude uses close_thread or settle_memory, or you press one of the panel's buttons.note-read, after Claude reads one of your memory files, to count the read.ingest, when a session ends.What it reads. The mod does not read or keep your messages. It sees the session's id and folder, and the path of each file Claude reads, to notice memory files. The dream command reads Claude Code's session transcripts and memory folders on your disk: that is how it files sessions into its archive and how the daily dream learns from them.
What leaves your machine, and where. The mod makes no network requests. The dream command sends one thing out: when the dream runs, once a day unless you turn it off, it sends the prose of the sessions it reads to the model your Claude Code uses, through your own Claude Code (claude -p, signed in as you). For a project with unfinished work it also sends the messages of the repository's recent commits and the titles and descriptions of its merged pull requests, which it reads with git and, where it is installed and signed in, gh. Nothing is sent to remcycle's author or anyone else.
What each hook does.
/remcycle, and runs dream daily.prompt.context), it adds up to three blocks to what Claude is given: what you have said applies to all your work, what was learned about this project, and what earlier sessions left unfinished. It removes and changes nothing else.tool.call on recall, close_thread and settle_memory), it answers each one itself by running dream. It never answers any other tool in that tool's place.tool.call on Read), it lets the read go ahead unchanged. If the file is one of your memory files, it counts the read, and adds a line when that memory names a file that no longer exists.dream ingest.command.run on /remcycle, ui.render and ui.focus), it opens and draws remcycle's own panel, and in the desktop app counts a click on one of the panel's buttons as a press. These touch nothing outside the panel.claude plugin validate on this folder lists every event the mod handles and every call it makes.
remcycle collects nothing about you and has no server. PRIVACY.md says what it keeps on your machine and what leaves it.
Questions go to Discussions and problems to an issue. Report a security problem privately through the repository's Security tab.
The main README explains the whole of remcycle, and how it works has pictures.
Apache-2.0. remcycle is an independent project and is not affiliated with or endorsed by Anthropic.
hooks/register.tsx 1052 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Closure, Memory, Status, Thread, View } from '../types'
5
6const PANE = 'remcycle'
7const RECALL = 'mcp__remcycle__recall'
8const CLOSE = 'mcp__remcycle__close_thread'
9const SETTLE = 'mcp__remcycle__settle_memory'
10// A memory in Claude Code's own folder, memory/<name>.md, or in the copy the dream keeps, memory/<project>/<name>.md.
11// The path is one Claude Code was given, so on Windows its parts may be divided by either slash.
12const MEMORY_FILE = /[\\/]memory[\\/](?:[^\\/]+[\\/])?[^\\/]+\.md$/
13const LATELY = 3
14const PER_PAGE = 12
15// All in character cells. A pane this wide or wider is laid out in two columns, with a gutter between them.
16const TWO_COLUMNS_FROM = 120
17const GUTTER = 4
18// Past this a column's rows are too long to read across.
19const WIDEST = 96
20// About the width the tiles are drawn for. A surface stretches a drawing to the box it is in.
21const TILES_ACROSS = 62
22const BRIEF = 80
23
24const status = atom({ plugin: 'remcycle', key: 'status' } as const, null)
25// The project the pane shows when it is not the session's own.
26const project = atom({ plugin: 'remcycle', key: 'project' } as const, null)
27const view = atom({ plugin: 'remcycle', key: 'view' } as const, 'home')
28const at = atom({ plugin: 'remcycle', key: 'at' } as const, 0)
29// The change being saved, in words, and what the last one did. One change is saved at a time.
30const busy = atom({ plugin: 'remcycle', key: 'busy' } as const, null)
31const last = atom({ plugin: 'remcycle', key: 'last' } as const, null)
32const page = atom({ plugin: 'remcycle', key: 'page' } as const, 0)
33
34/** How each kind of source reads to the person it came from. */
35const SAID: Record<string, string> = {
36 human: 'You said this',
37 accepted: 'You agreed to this',
38 inferred: 'Guessed from a session, not confirmed by you',
39 observed: "Taken from a subagent's report",
40}
41const UNSOURCED = 'Written before remcycle'
42const CLOSED_BY: Record<string, string> = {
43 you: 'You closed it',
44 session: 'A session closed it',
45 dream: 'The dream closed it',
46}
47
48// The tiles are drawn as an image, which cannot see the surface's theme: each carries its own
49// dark ground and light text so it reads the same on a light surface as on a dark one.
50const TILE = { ground: '#2c2c2a', track: '#45443f', text: '#f5f4ef', quiet: '#a3a29b' }
51const FINE = '#1D9E75'
52const WATCH = '#EF9F27'
53const OVER = '#E24B4A'
54const SOURCES: [string | null, string, string][] = [
55 ['human', FINE, 'you said'],
56 ['accepted', '#7F77DD', 'you agreed'],
57 ['inferred', WATCH, 'guessed'],
58 ['observed', '#D85A30', 'from a subagent'],
59 [null, '#888780', 'written before remcycle'],
60]
61
62function sentence(text: string): string {
63 return text.charAt(0).toUpperCase() + text.slice(1)
64}
65
66/** The start of a statement, cut at a word. The first screen shows this, and the whole is a button away. */
67function brief(text: string): string {
68 if (text.length <= BRIEF) {
69 return text
70 }
71
72 const cut = text.slice(0, BRIEF)
73
74 return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 1)).replace(/[\s,;:.]+$/, '')}…`
75}
76
77function named(path: string): string {
78 return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path
79}
80
81/** A slot is a key such as `deploy-target`; this is how it reads as a heading. */
82function heading(slot: string): string {
83 return sentence(slot.replace(/[-_]+/g, ' ').trim())
84}
85
86/** Splits a reason such as "it looks dated: the date has passed" into a short tag and the rest. */
87function tagged(reason: string): [string | null, string] {
88 const cut = /^it ([^:]{1,40}): (.+)$/s.exec(reason)
89 const [tag, rest] = [cut?.[1], cut?.[2]]
90
91 return tag === undefined || rest === undefined ? [null, sentence(reason)] : [tag.replace(/`/g, ''), sentence(rest)]
92}
93
94/** How long ago `iso` was, as short as a column allows: `5m`, `6h`, `3d`. Empty when unknown. */
95function ago(iso: string | null, now: number): string {
96 const then = Date.parse(iso ?? '')
97
98 if (Number.isNaN(then)) {
99 return ''
100 }
101
102 const minutes = Math.max(0, Math.floor((now - then) / 60_000))
103 const days = Math.floor(minutes / 1440)
104
105 if (minutes < 60) {
106 return minutes < 1 ? 'now' : `${minutes}m`
107 }
108
109 if (minutes < 2880) {
110 return `${Math.floor(minutes / 60)}h`
111 }
112
113 return days < 60 ? `${days}d` : days < 730 ? `${Math.floor(days / 30)}mo` : `${Math.floor(days / 365)}y`
114}
115
116function newestFirst<T>(items: readonly T[], when: (item: T) => string | null): T[] {
117 const time = (item: T) => Date.parse(when(item) ?? '') || 0
118
119 return [...items].sort((a, b) => time(b) - time(a))
120}
121
122/** How full the index is against whichever of its two limits is nearer. */
123function fullness(index: Status['index']): { percent: number; of: string } {
124 const [lines, bytes] = [index.lines / index.line_limit, index.bytes / index.byte_limit]
125
126 return {
127 percent: Math.round(100 * Math.max(lines, bytes)),
128 of:
129 bytes > lines
130 ? `${(index.bytes / 1000).toFixed(1)} of ${index.byte_limit / 1000} KB`
131 : `${index.lines} of ${index.line_limit} lines`,
132 }
133}
134
135/** The three figures at the top of the pane as one SVG image. Every value drawn is a number. */
136function tiles(now: Status): string {
137 const [width, height, tile, pad] = [440, 92, 140, 12]
138 const bar = tile - 2 * pad
139 const total = now.memories.length
140 const learned = now.memories.filter(memory => memory.from !== null).length
141 const full = fullness(now.index)
142 const alarm = full.percent >= 100 ? OVER : full.percent >= 80 ? WATCH : FINE
143
144 const draw = (n: number, label: string, figure: string, color: string, parts: [number, string][], note: string) => {
145 const x = n * (tile + 10) + pad
146 let from = x
147 const filled = parts.map(([share, fill]) => {
148 const piece = `<rect x="${from.toFixed(1)}" y="60" width="${(share * bar).toFixed(1)}" height="6" fill="${fill}"/>`
149 from += share * bar
150
151 return piece
152 })
153
154 return (
155 `<rect x="${x - pad}" width="${tile}" height="${height}" rx="8" fill="${TILE.ground}"/>` +
156 `<text x="${x}" y="22" font-size="12" fill="${TILE.quiet}">${label}</text>` +
157 `<text x="${x}" y="50" font-size="22" font-weight="500" fill="${color}">${figure}</text>` +
158 `<clipPath id="bar${n}"><rect x="${x}" y="60" width="${bar}" height="6" rx="3"/></clipPath>` +
159 `<g clip-path="url(#bar${n})"><rect x="${x}" y="60" width="${bar}" height="6" fill="${TILE.track}"/>${filled.join('')}</g>` +
160 `<text x="${x}" y="82" font-size="11" fill="${TILE.quiet}">${note}</text>`
161 )
162 }
163
164 const bySource = SOURCES.map(
165 ([source, fill]): [number, string] => [
166 total === 0 ? 0 : now.memories.filter(memory => memory.from === source).length / total,
167 fill,
168 ],
169 )
170
171 return (
172 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" ` +
173 `font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif">` +
174 draw(
175 0,
176 'Needs you',
177 String(now.waiting.length),
178 now.waiting.length > 0 ? WATCH : TILE.text,
179 [[total === 0 ? 0 : Math.min(1, now.waiting.length / total), WATCH]],
180 now.waiting.length === 0 ? 'nothing to rule on' : now.waiting.length === 1 ? 'question to rule on' : 'questions to rule on',
181 ) +
182 draw(1, 'Memories', String(total), TILE.text, bySource, `${learned} learned · ${total - learned} older`) +
183 draw(2, 'Index used', `${full.percent}%`, full.percent >= 80 ? alarm : TILE.text, [[Math.min(1, full.percent / 100), alarm]], full.of) +
184 `</svg>`
185 )
186}
187
188/** The status as this version of the pane reads it, or null if `value` is not one. */
189function readable(value: unknown): Status | null {
190 const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
191 const isText = (v: unknown) => typeof v === 'string'
192 const isTextOrNull = (v: unknown) => v === null || isText(v)
193 const isListOf = (v: unknown, isOne: (item: Record<string, unknown>) => boolean) =>
194 Array.isArray(v) && v.every(item => isRecord(item) && isOne(item))
195
196 if (!isRecord(value)) {
197 return null
198 }
199
200 const index = value.index
201 const daily = value.daily
202 const waiting = isRecord(daily) ? daily.waiting : null
203 // A `dream` from before the daily dream sends no `daily`, and the pane then shows nothing of it.
204 const isDaily =
205 daily === undefined ||
206 (isRecord(daily) &&
207 typeof daily.on === 'boolean' &&
208 isTextOrNull(daily.history) &&
209 (daily.limit === null || typeof daily.limit === 'number') &&
210 isRecord(waiting) &&
211 ['new', 'week', 'all'].every(key => typeof waiting[key] === 'number') &&
212 isTextOrNull(daily.started) &&
213 isTextOrNull(daily.finished) &&
214 isTextOrNull(daily.failed))
215 const isStatus =
216 isDaily &&
217 isText(value.project) &&
218 typeof value.withheld === 'number' &&
219 isTextOrNull(value.last_dream) &&
220 isRecord(index) &&
221 ['lines', 'line_limit', 'bytes', 'byte_limit'].every(key => typeof index[key] === 'number') &&
222 isListOf(value.memories, m => isText(m.slot) && isText(m.statement) && isTextOrNull(m.from) && isTextOrNull(m.said_at)) &&
223 isListOf(
224 value.waiting,
225 w =>
226 isText(w.slot) &&
227 isTextOrNull(w.holds) &&
228 isTextOrNull(w.suggests) &&
229 Array.isArray(w.reasons) &&
230 w.reasons.every(isText) &&
231 isTextOrNull(w.from) &&
232 typeof w.withheld === 'boolean' &&
233 isTextOrNull(w.evidence) &&
234 isText(w.file),
235 ) &&
236 isListOf(value.open_threads, t => isText(t.slot) && isText(t.statement) && isText(t.seen_at)) &&
237 isListOf(value.elsewhere, o => isText(o.project) && typeof o.waiting === 'number') &&
238 isListOf(
239 value.closed_lately,
240 c => isText(c.slot) && isText(c.statement) && isText(c.at) && isText(c.by) && isText(c.why),
241 )
242
243 return isStatus ? (value as Status) : null
244}
245
246// The last finished drawing of the pane: where it was drawn, whether the pane held the keyboard, and
247// what each of its buttons does, by key. Closures cannot be kept in state. A drawing replaces this
248// whole only once it is finished, so an event that arrives while the next one is being made still
249// finds every button of the one on screen.
250type Drawing = { surface: string; isFocused: boolean; buttons: Map<string, () => unknown> }
251let drawn: Drawing | null = null
252type Report = 'press' | 'move'
253let fired: { key: string; at: number; as: Report } | null = null
254const SAME_CLICK_MS = 500
255
256/**
257 * Runs what a button does, once for one click.
258 *
259 * The desktop reports a click as a press, as the focus ring moving onto the button, or as both.
260 * A press and a move for the same button close together are one click. Two presses are two.
261 */
262async function fire($: EngineInterface, key: string, as: Report, run: (() => unknown) | undefined): Promise<void> {
263 const at = await $.clock.now()
264 const isOtherHalf = fired !== null && fired.key === key && fired.as !== as && at - fired.at < SAME_CLICK_MS
265
266 if (run === undefined || isOtherHalf) {
267 return
268 }
269
270 fired = { key, at, as }
271 await run()
272}
273
274/** What `out` holds as JSON, or null if it is not JSON. */
275function parsed(out: string | null): unknown {
276 try {
277 return out === null ? null : JSON.parse(out)
278 } catch {
279 return null
280 }
281}
282
283/** Runs remcycle's command line and returns what it printed, or null if it failed or could not be started. */
284async function dream($: EngineInterface, args: string[], timeoutMs = 30_000): Promise<string | null> {
285 return (await tried($, args, timeoutMs)).out
286}
287
288/** Runs remcycle's command line once: what it printed, or null and whether another dream command held memory. */
289async function tried($: EngineInterface, args: string[], timeoutMs = 30_000): Promise<{ out: string | null; isBusy: boolean }> {
290 try {
291 const ran = await $.process.run(['dream', ...args], { timeoutMs })
292
293 return ran.exitCode === 0
294 ? { out: ran.stdout, isBusy: false }
295 : { out: null, isBusy: ran.stderr.includes('another dream command is changing memory') }
296 } catch {
297 return { out: null, isBusy: false }
298 }
299}
300
301/** The arguments that point a command at the project the pane shows. */
302async function scope($: EngineInterface): Promise<string[]> {
303 const other = await read($, project)
304
305 return other === null ? [] : ['--project', other]
306}
307
308async function refresh($: EngineInterface): Promise<void> {
309 const now = readable(parsed(await dream($, ['status', ...(await scope($))])))
310 await update($, status, () => now)
311}
312
313/**
314 * Saves one change, saying so at once and saying afterwards what it did.
315 *
316 * A press that arrives while another change is being saved does nothing: the two would rewrite
317 * the same files, and the second would land on whatever the first put under the pointer.
318 */
319async function act($: EngineInterface, doing: string, done: string, change: () => Promise<boolean>): Promise<void> {
320 let isMine = false
321 await update($, busy, current => {
322 isMine = current === null
323
324 return current ?? doing
325 })
326
327 if (!isMine) {
328 return
329 }
330
331 let isSaved = false
332
333 try {
334 isSaved = await change()
335 } finally {
336 await update($, last, () => (isSaved ? `Done: ${done}` : `Not saved: ${doing}. Another dream command may be running.`))
337 await refresh($)
338 await update($, busy, () => null)
339 }
340}
341
342/** Puts a question in the prompt box as the start of a message, for the person to finish and send. */
343async function discuss($: EngineInterface, question: Status['waiting'][number], where: string): Promise<void> {
344 const lines = [
345 'Help me settle a memory that is in question.',
346 `Memory: ${question.slot}, in ${where}`,
347 ...(question.holds === null ? [] : [`It says: ${question.holds}`]),
348 ...(question.suggests === null ? [] : [`A session suggested instead: ${question.suggests}`]),
349 ...(question.reasons.length === 0 ? [] : [`In question because: ${question.reasons.join('; ')}`]),
350 `File: ${question.file}`,
351 ]
352 const filled = await $.prompt.fill({ text: `${lines.join('\n')}\n`, mode: 'append' })
353
354 await update($, last, () =>
355 filled.isFilled
356 ? `Done: added ${heading(question.slot)} to your message`
357 : 'Not added: the prompt box could not take it just now.',
358 )
359}
360
361/** What the Refresh button does: reads the status again, and lets go of a change that never finished saving. */
362async function reset($: EngineInterface): Promise<void> {
363 await update($, busy, () => null)
364 await refresh($)
365}
366
367async function rule($: EngineInterface, slot: string, isAccepted: boolean, hasSuggestion: boolean): Promise<void> {
368 const [doing, done] = !isAccepted
369 ? ['keep', 'kept']
370 : hasSuggestion
371 ? ['take the suggestion for', 'took the suggestion for']
372 : ['retire', 'retired']
373 const args = ['resolve', ...(await scope($)), slot, isAccepted ? '--accept' : '--keep']
374
375 await act($, `${doing} ${heading(slot)}`, `${done} ${heading(slot)}`, async () => (await dream($, args)) !== null)
376}
377
378async function close($: EngineInterface, slot: string): Promise<void> {
379 const args = ['close', ...(await scope($)), '--', slot]
380
381 await act($, `close ${heading(slot)}`, `closed ${heading(slot)}`, async () => (await dream($, args)) !== null)
382}
383
384/** Changes how the daily dream runs, through `dream daily`, which keeps the change in the settings file. */
385async function changeDaily($: EngineInterface, change: string[], doing: string, done: string): Promise<void> {
386 await act($, doing, done, async () => (await dream($, ['daily', ...change])) !== null)
387}
388
389async function reopen($: EngineInterface, slot: string): Promise<void> {
390 const args = ['reopen', ...(await scope($)), '--', slot]
391
392 await act($, `reopen ${heading(slot)}`, `reopened ${heading(slot)}`, async () => (await dream($, args)) !== null)
393}
394
395/** Points the pane at another project, or with null back at the session's own. */
396async function look($: EngineInterface, other: string | null): Promise<void> {
397 await update($, project, () => other)
398 await update($, at, () => 0)
399 await update($, page, () => 0)
400 await update($, view, () => 'home')
401 await refresh($)
402}
403
404async function show($: EngineInterface, next: View): Promise<void> {
405 await update($, page, () => 0)
406 await update($, view, () => next)
407}
408
409export const register: Register = on => {
410 on('session.start', async ($, e, next) => {
411 await update($, busy, () => null)
412 await $.tool.register({
413 name: 'recall',
414 description:
415 'Search memory and the archive of past Claude Code sessions for what was said or decided. ' +
416 'Give `query` to get matching memories and the best-matching turns, each labelled session#turn, ' +
417 'then what each session found was, in brief. ' +
418 'Give `also` with other ways of asking the same thing to widen the search. ' +
419 'Give `session` alone for the summary of that session. ' +
420 'Give `session` with `first` and `last` to read those turns back word for word. ' +
421 'Searches this project unless `everywhere` is true.',
422 inputSchema: {
423 type: 'object',
424 properties: {
425 query: { type: 'string', description: 'Words to search for' },
426 also: { type: 'array', items: { type: 'string' }, description: 'Other phrasings of the same question' },
427 everywhere: { type: 'boolean', description: 'Search every project' },
428 reports: { type: 'boolean', description: "Also search subagents' final reports" },
429 session: { type: 'string', description: 'A session id, or the start of one, to read back' },
430 first: { type: 'integer', description: 'First turn to read back' },
431 last: { type: 'integer', description: 'Last turn to read back' },
432 },
433 },
434 })
435 await $.tool.register({
436 name: 'close_thread',
437 description:
438 'Close a thread of unfinished work that this conversation has now finished. The threads are listed at the ' +
439 'start of the conversation under "Left open by earlier sessions", each under its name. Give `slot`, the ' +
440 "thread's name as listed, and `reason`, one line on what finished it. Call it only once the work is done, " +
441 'not when it was merely discussed. The person can reopen it.',
442 inputSchema: {
443 type: 'object',
444 properties: {
445 slot: { type: 'string', description: "The thread's name, as listed" },
446 reason: { type: 'string', description: 'One line on what finished it' },
447 },
448 required: ['slot', 'reason'],
449 },
450 })
451 await $.tool.register({
452 name: 'settle_memory',
453 description:
454 'Apply the ruling the person reached on a memory that is in question, after talking it through with them. ' +
455 'Give `slot`, the memory\'s name, `project`, the project folder named with it, and `ruling`: `keep` leaves ' +
456 'the memory as it is, `retire` takes it out of use, `take` replaces it with what a session suggested. ' +
457 'Call it only once the person has said which. To reword a memory instead, edit its file, then use `keep`.',
458 inputSchema: {
459 type: 'object',
460 properties: {
461 slot: { type: 'string', description: "The memory's name" },
462 ruling: { type: 'string', enum: ['keep', 'retire', 'take'] },
463 project: { type: 'string', description: 'The project folder the memory belongs to' },
464 },
465 required: ['slot', 'ruling'],
466 },
467 })
468 await $.command.register({
469 name: 'remcycle',
470 description: 'Show what the dream holds for this project and what waits for your ruling',
471 })
472
473 // The daily dream: started from here when one is due, so it needs no scheduler. It runs in the background.
474 const started = await dream($, ['daily'], 10_000)
475
476 if (started?.startsWith('the daily dream started')) {
477 // A second line says that earlier sessions wait for the person to choose a history. It asks for
478 // something, so it stays up longer.
479 const [said, asked] = started.trim().split(/\r?\n/)
480
481 $.ui.toast(`remcycle: ${said}`)
482
483 if (asked !== undefined) {
484 $.ui.toast(`remcycle: ${asked}`, { timeoutMs: 12_000 })
485 }
486 }
487
488 return next(e)
489 })
490
491 // Asked once per conversation, so the session sees one stable answer and the prompt cache holds.
492 on('prompt.context', async ($, e, next) => {
493 const out = await dream($, ['context'])
494
495 if (out === null) {
496 return next(e)
497 }
498
499 type Named = { slot: string; statement: string }
500 const given: {
501 everywhere: string[]
502 learned?: Named[]
503 learned_in?: string
504 learned_more?: number
505 threads?: Named[]
506 } = JSON.parse(out)
507 const learned = given.learned ?? []
508 const threads = given.threads ?? []
509 const blocks = [...e.blocks]
510
511 if (given.everywhere.length > 0) {
512 blocks.push({
513 name: 'remcycleEverywhere',
514 text:
515 'What this person has said applies to all their work:\n' +
516 given.everywhere.map(line => `- ${line}`).join('\n'),
517 })
518 }
519
520 if (learned.length > 0) {
521 const more = given.learned_more ?? 0
522
523 blocks.push({
524 name: 'remcycleLearned',
525 text:
526 'What earlier sessions established about this project, each said or agreed to by the person, under its ' +
527 'name:\n' +
528 learned.map(memory => `- ${memory.slot}: ${memory.statement}`).join('\n') +
529 (more > 0
530 ? `\n${more} older ${more === 1 ? 'one is' : 'ones are'} not listed here. The recall tool finds them.`
531 : '') +
532 `\nEach is a file named after it in ${given.learned_in ?? ''}, holding the reason and the session it came from.`,
533 })
534 }
535
536 if (threads.length > 0) {
537 blocks.push({
538 name: 'remcycleOpenThreads',
539 text:
540 'Left open by earlier sessions in this project, each under its name:\n' +
541 threads.map(thread => `- ${thread.slot}: ${thread.statement}`).join('\n') +
542 '\nWhen work in this conversation finishes one of them, call the close_thread tool with its name and one ' +
543 'line on what finished it. Do not close one that was only discussed.',
544 })
545 }
546
547 return next({ ...e, blocks })
548 })
549
550 on('tool.call', { tool: RECALL }, async ($, e) => {
551 const asked = e as Record<string, unknown>
552 const isWholeSession = asked.first === undefined && asked.last === undefined
553 const args =
554 typeof asked.session === 'string'
555 ? isWholeSession
556 ? ['show', asked.session, '--summary']
557 : [
558 'show',
559 asked.session,
560 '--first',
561 String(asked.first ?? 0),
562 ...(asked.last === undefined ? [] : ['--last', String(asked.last)]),
563 ]
564 : [
565 'search',
566 ...(asked.everywhere === true ? ['--all-projects'] : []),
567 ...(asked.reports === true ? ['--reports'] : []),
568 ...(Array.isArray(asked.also) ? asked.also.flatMap(phrasing => ['--also', String(phrasing)]) : []),
569 '--',
570 ...String(asked.query ?? '')
571 .split(/\s+/)
572 .filter(word => word !== ''),
573 ]
574 const out = await dream($, args)
575
576 return { result: out ?? 'remcycle could not be reached.' }
577 })
578
579 on('tool.call', { tool: CLOSE }, async ($, e) => {
580 const asked = e as Record<string, unknown>
581 const slot = String(asked.slot ?? '').trim()
582 const reason = String(asked.reason ?? '').trim()
583
584 if (slot === '' || reason === '') {
585 return { result: 'Not closed: give the thread\'s name as `slot` and what finished it as `reason`.' }
586 }
587
588 const { out, isBusy } = await tried($, ['close', `--why=${reason}`, `--session=${await $.session.id()}`, '--', slot])
589
590 return {
591 result:
592 out ??
593 (isBusy
594 ? 'Not closed yet: a dream is writing memory right now. Try again in a minute.'
595 : `Not closed: no open thread is named ${slot}, or remcycle could not be reached.`),
596 }
597 })
598
599 on('tool.call', { tool: SETTLE }, async ($, e) => {
600 const asked = e as Record<string, unknown>
601 const slot = String(asked.slot ?? '').trim()
602 const ruling = String(asked.ruling ?? '')
603 const where = typeof asked.project === 'string' && asked.project !== '' ? ['--project', asked.project] : []
604
605 if (slot === '' || !['keep', 'retire', 'take'].includes(ruling)) {
606 return { result: 'Not settled: give `slot` and a `ruling` of keep, retire or take.' }
607 }
608
609 const question = readable(parsed(await dream($, ['status', ...where])))?.waiting.find(one => one.slot === slot)
610
611 if (question === undefined) {
612 return { result: `Not settled: no question is waiting on ${slot}.` }
613 }
614
615 if (ruling === 'retire' && question.suggests !== null) {
616 return { result: `Not settled: ${slot} has a suggestion waiting, so the rulings open are take or keep.` }
617 }
618
619 if (ruling === 'take' && question.suggests === null) {
620 return { result: `Not settled: ${slot} has no suggestion waiting, so the rulings open are retire or keep.` }
621 }
622
623 const out = await dream($, ['resolve', ...where, slot, ruling === 'keep' ? '--keep' : '--accept'])
624 await refresh($)
625
626 return { result: out ?? 'Not settled: remcycle could not save it. Another dream command may be running.' }
627 })
628
629 // The desktop presses a button only while the pane's focus ring is on a button that is still drawn.
630 // With the pane out of focus, or the ringed button gone (as after every ruling), a click only moves
631 // the ring onto what was clicked and no press follows. So there a person's ring move is a press.
632 // The terminal moves the ring by Tab and the arrows, where it must stay a move.
633 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
634 const moved = await next(e)
635
636 if (e.origin.kind === 'person' && e.element !== undefined && drawn?.surface === 'desktop' && moved.deny === undefined) {
637 void fire($, e.element, 'move', drawn.buttons.get(e.element))
638 }
639
640 return moved
641 })
642
643 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
644 const ran = await next(e)
645
646 if (ran.deny !== undefined || !MEMORY_FILE.test(e.file_path)) {
647 return ran
648 }
649
650 const warning = (await dream($, ['note-read', e.file_path]))?.trim()
651
652 return warning ? { ...ran, context: [...(ran.context ?? []), warning] } : ran
653 })
654
655 on('session.end', async ($, e, next) => {
656 await dream($, ['ingest'], 120_000)
657
658 return next(e)
659 })
660
661 on('command.run', { command: 'remcycle' }, async $ => {
662 await refresh($)
663 await $.ui.open({ id: PANE, title: 'remcycle' })
664
665 return { text: 'remcycle pane opened.' }
666 })
667
668 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
669 const { Box, Button, Text } = $.ui.resolve(e)
670 const drawing: Drawing = { surface: e.surface, isFocused: e.props.isFocused, buttons: new Map() }
671 const press = (key: string, run: () => unknown) => {
672 drawing.buttons.set(key, run)
673
674 return () => fire($, key, 'press', run)
675 }
676 const finished = <Tree,>(tree: Tree): Tree => {
677 drawn = drawing
678
679 return tree
680 }
681 // The terminal has no Svg, and its table answers the name with an element that draws nothing.
682 const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e).Svg
683 const now = readable(await read($, status))
684 const isWide = e.props.bodyColumns >= TWO_COLUMNS_FROM
685 const column = isWide ? Math.min(WIDEST, Math.floor((e.props.bodyColumns - GUTTER) / 2)) : e.props.bodyColumns
686 const across = isWide ? 2 * column + GUTTER : column
687
688 if (now === null) {
689 return finished(
690 <Box key="body" flexDirection="column" rowGap={1} width={across}>
691 <Text dimColor>No readable answer from the `dream` command yet.</Text>
692 <Button key="refresh" label="Refresh" onPress={press('refresh', () => reset($))} />
693 </Box>
694 )
695 }
696
697 const clock = await $.clock.now()
698 const shown = await read($, view)
699 const viewing = await read($, project)
700 const saving = await read($, busy)
701 const did = await read($, last)
702 const learned = newestFirst(
703 now.memories.filter(memory => memory.from !== null),
704 memory => memory.said_at,
705 )
706 const unsourced = newestFirst(
707 now.memories.filter(memory => memory.from === null),
708 memory => memory.said_at,
709 )
710 const threads = newestFirst(now.open_threads, thread => thread.seen_at)
711 const color = (memory: Memory) => SOURCES.find(([source]) => source === memory.from)?.[1] ?? TILE.quiet
712
713 const title = (label: string, aside: RenderChildren) => (
714 <Box justifyContent="space-between" alignItems="center">
715 <Text dimColor>{label}</Text>
716 {aside}
717 </Box>
718 )
719
720 // A row's title shares its line with what sits at the right edge. The statement gets a line of
721 // its own, so however long it is it cannot push the age or a button out of the pane.
722 const memoryRow = (memory: Memory, isWhole: boolean) => (
723 <Box flexDirection="column">
724 <Box justifyContent="space-between" columnGap={1}>
725 <Box columnGap={1}>
726 <Text color={color(memory)}>●</Text>
727 <Text bold>{heading(memory.slot)}</Text>
728 </Box>
729 <Box flexShrink={0}>
730 <Text dimColor>{ago(memory.said_at, clock)}</Text>
731 </Box>
732 </Box>
733 <Box flexDirection="column" paddingLeft={2}>
734 {isWhole ? <Text>{memory.statement}</Text> : <Text dimColor>{brief(memory.statement)}</Text>}
735 {isWhole && <Text dimColor>{SAID[memory.from ?? ''] ?? UNSOURCED}</Text>}
736 </Box>
737 </Box>
738 )
739
740 const closureRow = (closure: Closure, isWhole: boolean) => {
741 const how = (CLOSED_BY[closure.by] ?? 'Closed') + (closure.why === '' ? '' : `: ${closure.why}`)
742
743 return (
744 <Box flexDirection="column">
745 <Box justifyContent="space-between" alignItems="center" columnGap={1}>
746 <Text bold>{heading(closure.slot)}</Text>
747 <Box columnGap={1} alignItems="center" flexShrink={0}>
748 <Text dimColor>{ago(closure.at, clock)}</Text>
749 <Button key={`reopen-${closure.slot}`} label="Reopen" onPress={press(`reopen-${closure.slot}`, () => reopen($, closure.slot))} />
750 </Box>
751 </Box>
752 <Text dimColor>{isWhole ? how : brief(how)}</Text>
753 </Box>
754 )
755 }
756
757 const threadRow = (thread: Thread, isWhole: boolean) => (
758 <Box flexDirection="column">
759 <Box justifyContent="space-between" alignItems="center" columnGap={1}>
760 <Text bold>{heading(thread.slot)}</Text>
761 <Box columnGap={1} alignItems="center" flexShrink={0}>
762 <Text dimColor>{ago(thread.seen_at, clock)}</Text>
763 <Button key={`close-${thread.slot}`} label="Done" onPress={press(`close-${thread.slot}`, () => close($, thread.slot))} />
764 </Box>
765 </Box>
766 {isWhole ? <Text>{thread.statement}</Text> : <Text dimColor>{brief(thread.statement)}</Text>}
767 </Box>
768 )
769
770 if (shown === 'memories' || shown === 'threads') {
771 const rows =
772 shown === 'memories'
773 ? [...learned, ...unsourced].map(memory => memoryRow(memory, true))
774 : [
775 ...threads.map(thread => threadRow(thread, true)),
776 ...(now.closed_lately.length > 0 ? [<Text dimColor>Closed lately</Text>] : []),
777 ...now.closed_lately.map(closure => closureRow(closure, true)),
778 ]
779 const first = Math.min(await read($, page), Math.max(0, Math.ceil(rows.length / PER_PAGE) - 1)) * PER_PAGE
780 const last = Math.min(rows.length, first + PER_PAGE)
781
782 return finished(
783 <Box key="body" flexDirection="column" rowGap={1} width={across}>
784 <Box justifyContent="space-between" alignItems="center">
785 <Text bold>{shown === 'memories' ? 'All memories' : 'Left open'}</Text>
786 <Button key="show-home" label="Back" onPress={press('show-home', () => show($, 'home'))} />
787 </Box>
788 <Text dimColor>
789 {shown === 'memories'
790 ? `${learned.length} learned from your sessions, ${unsourced.length} written before remcycle.`
791 : 'Unfinished work earlier sessions left behind. New sessions here are told about it.'}
792 </Text>
793 {isWide ? (
794 <Box key="columns" columnGap={GUTTER} alignItems="flex-start">
795 <Box flexDirection="column" rowGap={1} width={column}>
796 {rows.slice(first, first + Math.ceil((last - first) / 2))}
797 </Box>
798 <Box flexDirection="column" rowGap={1} width={column}>
799 {rows.slice(first + Math.ceil((last - first) / 2), last)}
800 </Box>
801 </Box>
802 ) : (
803 rows.slice(first, last)
804 )}
805 {rows.length > PER_PAGE && (
806 <Box columnGap={1} alignItems="center">
807 {first > 0 && <Button key="sooner" label="Previous" onPress={press('sooner', () => update($, page, n => Math.max(0, n - 1)))} />}
808 <Text dimColor>{`${first + 1} to ${last} of ${rows.length}`}</Text>
809 {last < rows.length && <Button key="later" label="Next" onPress={press('later', () => update($, page, n => n + 1))} />}
810 </Box>
811 )}
812 </Box>
813 )
814 }
815
816 const daySet = now.daily
817 const isRunning = daySet?.started != null && daySet.finished === null && daySet.failed === null
818 const lastStarted = ago(daySet?.started ?? null, clock)
819 const since = lastStarted === 'now' ? 'just now' : `${lastStarted} ago`
820 const dailyCard =
821 daySet === undefined ? null : daySet.on && daySet.history === null ? (
822 <Box key="daily-setup" flexDirection="column" rowGap={1} borderStyle="round" borderDimColor paddingX={1}>
823 <Text bold>The daily dream is on</Text>
824 <Text>
825 Once a day, when a session starts, it reads your new sessions in the background, through your own Claude
826 Code, so it uses your plan. Should it read the sessions from before it began as well?
827 </Text>
828 {saving === null && (
829 <Box columnGap={1} rowGap={1} flexWrap="wrap">
830 <Button
831 key="history-new"
832 label={`Only new ones (${daySet.waiting.new})`}
833 onPress={press('history-new', () =>
834 changeDaily($, ['history', 'new'], 'have the daily dream read only new sessions', 'the daily dream reads only new sessions'),
835 )}
836 />
837 <Button
838 key="history-week"
839 label={`The week before too (${daySet.waiting.week})`}
840 onPress={press('history-week', () =>
841 changeDaily($, ['history', 'week'], 'have the daily dream read the week before', 'the daily dream reads the week before too'),
842 )}
843 />
844 <Button
845 key="history-all"
846 label={`Everything (${daySet.waiting.all})`}
847 onPress={press('history-all', () =>
848 changeDaily($, ['history', 'all'], 'have the daily dream read everything', 'the daily dream reads every session'),
849 )}
850 />
851 <Button
852 key="daily-switch"
853 label="Turn it off"
854 onPress={press('daily-switch', () => changeDaily($, ['off'], 'turn the daily dream off', 'turned the daily dream off'))}
855 />
856 </Box>
857 )}
858 </Box>
859 ) : (
860 <Box key="daily" flexDirection="column">
861 <Box justifyContent="space-between" alignItems="center" columnGap={1}>
862 <Text dimColor>
863 {!daySet.on
864 ? 'Daily dream off'
865 : isRunning
866 ? `Daily dream running, started ${since}`
867 : daySet.started === null
868 ? 'Daily dream on, not run yet'
869 : `Daily dream on, last started ${since}`}
870 </Text>
871 {saving === null && (
872 <Button
873 key="daily-switch"
874 label={daySet.on ? 'Turn off' : 'Turn on'}
875 onPress={press('daily-switch', () =>
876 daySet.on
877 ? changeDaily($, ['off'], 'turn the daily dream off', 'turned the daily dream off')
878 : changeDaily($, ['on'], 'turn the daily dream on', 'turned the daily dream on'),
879 )}
880 />
881 )}
882 </Box>
883 {daySet.failed !== null && <Text color="warning">{`The last daily dream went wrong: ${daySet.failed}`}</Text>}
884 </Box>
885 )
886 const full = fullness(now.index)
887 const position = now.waiting.length === 0 ? 0 : (await read($, at)) % now.waiting.length
888 const question = now.waiting[position]
889 const [tag, why] = question === undefined ? [null, ''] : tagged(question.reasons[0] ?? '')
890 const findings = question?.reasons.length ?? 0
891 const changed = ago(now.last_dream, clock)
892
893 const attention = (
894 <Box key="attention" flexDirection="column" rowGap={1} width={column}>
895 {Svg === null ? (
896 <Box columnGap={3} flexWrap="wrap">
897 {now.waiting.length > 0 ? (
898 <Text bold color="warning">{`${now.waiting.length} need you`}</Text>
899 ) : (
900 <Text>0 need you</Text>
901 )}
902 <Text>{`${now.memories.length} memories`}</Text>
903 <Text>{`Index ${full.percent}% full`}</Text>
904 </Box>
905 ) : (
906 <Box key="tiles" width={Math.min(column, TILES_ACROSS)}>
907 <Svg
908 source={tiles(now)}
909 alt={
910 `${now.waiting.length} need you. ${now.memories.length} memories in use, ` +
911 `${learned.length} learned from sessions. The index is ${full.percent}% full: ${full.of}.`
912 }
913 />
914 </Box>
915 )}
916 {title('Needs you', question !== undefined && <Text dimColor>{`${now.waiting.length} left`}</Text>)}
917 {question === undefined ? (
918 <Text dimColor>Nothing needs you in this project.</Text>
919 ) : (
920 <Box flexDirection="column" rowGap={1} borderStyle="round" borderDimColor paddingX={1}>
921 <Box columnGap={2}>
922 <Text bold>{heading(question.slot)}</Text>
923 <Text color="warning">
924 {question.suggests !== null
925 ? 'a session disagrees'
926 : findings > 1
927 ? `${findings} findings`
928 : (tag ?? 'in question')}
929 </Text>
930 </Box>
931 {question.holds !== null && (
932 <Box flexDirection="column">
933 <Text dimColor>Memory says</Text>
934 <Text>{question.holds}</Text>
935 </Box>
936 )}
937 {question.suggests === null ? (
938 <Box flexDirection="column">
939 <Text dimColor>Why it is in question</Text>
940 {findings > 1 ? (
941 question.reasons.map(reason => <Text>{sentence(reason.replace(/`/g, ''))}</Text>)
942 ) : (
943 <Text>{why}</Text>
944 )}
945 </Box>
946 ) : (
947 <Box flexDirection="column">
948 <Text dimColor>
949 Suggested instead · {(SAID[question.from ?? ''] ?? 'from a session').toLowerCase()}
950 </Text>
951 <Text>{question.suggests}</Text>
952 {question.evidence !== null && <Text dimColor>To read the conversation: {question.evidence}</Text>}
953 </Box>
954 )}
955 {question.withheld && <Text color="warning">Kept out of sessions until you rule.</Text>}
956 {saving === null && (
957 <Box key="question-actions" justifyContent="space-between" flexWrap="wrap" columnGap={1} rowGap={1}>
958 <Box columnGap={1} rowGap={1} flexWrap="wrap">
959 <Button
960 key={`accept-${question.slot}`}
961 label={question.suggests === null ? 'Retire the memory' : 'Take the suggestion'}
962 onPress={press(`accept-${question.slot}`, () => rule($, question.slot, true, question.suggests !== null))}
963 />
964 <Button
965 key={`keep-${question.slot}`}
966 label="Keep the memory"
967 onPress={press(`keep-${question.slot}`, () => rule($, question.slot, false, question.suggests !== null))}
968 />
969 </Box>
970 <Box columnGap={1} rowGap={1} flexWrap="wrap">
971 <Button key="discuss" label="Add to chat" onPress={press('discuss', () => discuss($, question, now.project))} />
972 {now.waiting.length > 1 && (
973 <Button key="next" label="Skip" onPress={press('next', () => update($, at, n => n + 1))} />
974 )}
975 </Box>
976 </Box>
977 )}
978 </Box>
979 )}
980 {now.elsewhere.length > 0 && <Text dimColor>Waiting in other projects</Text>}
981 {now.elsewhere.slice(0, 4).map((other, n) => (
982 <Box justifyContent="space-between" alignItems="center" columnGap={1}>
983 <Text>{`${named(other.project)}: ${other.waiting} waiting`}</Text>
984 <Button key={`elsewhere-${n}`} label="Open" onPress={press(`elsewhere-${n}`, () => look($, other.project))} />
985 </Box>
986 ))}
987 </Box>
988 )
989 const record = (
990 <Box key="record" flexDirection="column" rowGap={1} width={column}>
991 {title(
992 'Learned lately',
993 <Button key="show-memories" label={`All ${now.memories.length}`} onPress={press('show-memories', () => show($, 'memories'))} />,
994 )}
995 {learned.length === 0 && <Text dimColor>The dream has learned nothing here yet.</Text>}
996 {learned.slice(0, LATELY).map(memory => memoryRow(memory, false))}
997 {now.memories.length > 0 && (
998 <Box columnGap={2} flexWrap="wrap">
999 {SOURCES.filter(([source]) => now.memories.some(memory => memory.from === source)).map(([, fill, label]) => (
1000 <Box columnGap={1}>
1001 <Text color={fill}>●</Text>
1002 <Text dimColor>{label}</Text>
1003 </Box>
1004 ))}
1005 </Box>
1006 )}
1007 {title(
1008 'Left open',
1009 threads.length + now.closed_lately.length > 0 && (
1010 <Button key="show-threads" label={`All ${threads.length}`} onPress={press('show-threads', () => show($, 'threads'))} />
1011 ),
1012 )}
1013 {threads.length === 0 && <Text dimColor>Earlier sessions left nothing open.</Text>}
1014 {threads.slice(0, LATELY).map(thread => threadRow(thread, false))}
1015 {now.closed_lately.length > 0 && title('Closed lately', <Text dimColor>{String(now.closed_lately.length)}</Text>)}
1016 {now.closed_lately.slice(0, LATELY).map(closure => closureRow(closure, false))}
1017 </Box>
1018 )
1019
1020 return finished(
1021 <Box key="body" flexDirection="column" rowGap={1} width={across}>
1022 <Box justifyContent="space-between" alignItems="center">
1023 <Box flexDirection="column">
1024 <Text bold>{named(now.project)}</Text>
1025 <Text dimColor>
1026 {changed === ''
1027 ? 'No dream has changed this yet'
1028 : `Changed by a dream ${changed === 'now' ? 'just now' : `${changed} ago`}`}
1029 </Text>
1030 </Box>
1031 <Box columnGap={1} flexShrink={0}>
1032 {viewing !== null && <Button key="home-project" label="Back" onPress={press('home-project', () => look($, null))} />}
1033 <Button key="refresh" label="Refresh" onPress={press('refresh', () => reset($))} />
1034 </Box>
1035 </Box>
1036 {saving !== null ? <Text color="warning">{`Saving: ${saving}…`}</Text> : did !== null && <Text dimColor>{did}</Text>}
1037 {dailyCard}
1038 <Box
1039 key="columns"
1040 flexDirection={isWide ? 'row' : 'column'}
1041 columnGap={GUTTER}
1042 rowGap={1}
1043 alignItems={isWide ? 'flex-start' : 'stretch'}
1044 >
1045 {attention}
1046 {record}
1047 </Box>
1048 </Box>
1049 )
1050 })
1051}
1052types/index.d.ts 58 lines1export type Memory = { slot: string; statement: string; from: string | null; said_at: string | null }
2
3export type Thread = { slot: string; statement: string; seen_at: string }
4
5export type Closure = { slot: string; statement: string; at: string; by: string; why: string }
6
7export type Elsewhere = { project: string; waiting: number }
8
9export type Waiting = {
10 slot: string
11 holds: string | null
12 suggests: string | null
13 reasons: string[]
14 from: string | null
15 withheld: boolean
16 evidence: string | null
17 file: string
18}
19
20export type Daily = {
21 on: boolean
22 history: string | null
23 limit: number | null
24 waiting: { new: number; week: number; all: number }
25 started: string | null
26 finished: string | null
27 failed: string | null
28}
29
30export type Status = {
31 daily?: Daily
32 project: string
33 withheld: number
34 last_dream: string | null
35 index: { lines: number; line_limit: number; bytes: number; byte_limit: number }
36 memories: Memory[]
37 waiting: Waiting[]
38 open_threads: Thread[]
39 elsewhere: Elsewhere[]
40 closed_lately: Closure[]
41}
42
43export type View = 'home' | 'memories' | 'threads'
44
45declare module 'claude-code' {
46 interface PluginState {
47 remcycle: {
48 status: Status | null
49 project: string | null
50 view: View
51 at: number
52 page: number
53 busy: string | null
54 last: string | null
55 }
56 }
57}
58