SLOPSHOPPER

remcycle

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.

newpaneguardcommandtoastprompt
★ 3v0.4.1Apache-2.0updated 2026-10-07lianmatsuo/remcycle/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · remcycle
│ ┃ remcycle ✕ › fix the failing auth test and add an audit log call │ ┃ No readable answer from the `dream` command │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Refresh ] ⎿ 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 │ │ › /remcycle │ ⎿ remcycle: remcycle pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · remcycle
No readable answer from the `dream` command yet. [ Refresh ]
README

The remcycle mod

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.

What it does

  • When a conversation starts, it hands Claude what you have said applies to all your work, what was learned about the project, and what earlier sessions left unfinished.
  • It gives Claude three tools: recall searches past sessions and reads them back, close_thread marks unfinished work done, and settle_memory applies a ruling you reached together.
  • It adds /remcycle, a panel that shows what is remembered and lets you rule on what is in doubt.
  • When a session ends, it files what was said into the archive.

What it needs

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.

Exactly what it runs, reads and sends

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.

  • When a session starts, it registers the three tools and /remcycle, and runs dream daily.
  • When a conversation starts (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.
  • For its own three tools (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.
  • When Claude reads a file (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.
  • When a session ends, it runs dream ingest.
  • For the panel (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.

Privacy and support

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.

More

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.

Source 2 files
hooks/register.tsx 1052 lines
1import { 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}
1052
types/index.d.ts 58 lines
1export 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