SLOPSHOPPER

memrove-chat

Drives memrove's chat memory from inside Claude Code: after each turn it compacts the chat log with Haiku on the session's own auth, and gives the model zoom…

newguardstatusprompttoolmodel
v0.5.0MITupdated 2026-10-13ecyx00/memrove/mods/memrove-chat
A shopper browsing a rack in a slop shop
README

memrove-chat

Claude Code function mod (early access, CC 2.1.288+). It is the live driver of memrove's chat memory: the transport only. All memory logic stays in the Python CLI.

  • After every main-loop turn that ended with an answer it runs, in the background and one run at a time: memrove chatlog ingest, then up to batches (3) times chatlog prepare --json --no-ingest -> $.model.complete({model:'haiku', system, prompt}) -> chatlog apply <batch_id> (raw reply on stdin). A failed model call is handed back with apply --error. After 3 failed runs it pauses for 10 minutes. The status line shows chat memory: building batch 1 · 3508 nodes · 1/60 calls today, or the error.
  • Registers the model tools zoom(id, n[, page]) and date(id) (mcp__memrove-chat__zoom, ...__date), each calling memrove zoom / memrove date. project([name]) calls memrove project: latest decisions (superseded marked), open loops and the last session's end state, pull only.
  • Adds the whole view (memrove wake --all, up to 32 KB by default) as a prompt.context block named memroveChat (startup, resume, /clear, after compaction). The memrove SessionStart instruction shrinks to one line when that worked.
  • fresh (default off, experimental): after a turn $.session.compact() runs and the session.compact handler returns the last user message and everything after it, verbatim; the view comes back as the prompt.context block. Interactive sessions only ($.session.compact() is unavailable in claude -p). Measured: each fresh turn writes ~44k cache tokens, against ~0.3k with fresh off, so leave it off.

Options (userConfig)

optiondefaultmeaning
memrovememroveentry point, split on spaces, no quoting. Example: python3 /path/scripts/memrove_v3.py --vault V --state S
batches3batches (Haiku calls) per turn
modelhaikucompactor model alias
freshfalsefresh context per turn

Develop

npm install
npx tsc -p . && npx eslint --max-warnings 0 .
claude plugin test .
claude --plugin-dir . 

Install from the directory marketplace in this repo (mods/.claude-plugin/marketplace.json, checked with claude plugin validate mods):

claude plugin marketplace add /path/to/memrove/mods
claude plugin install memrove-chat@memrove-mods

Types come from /plugin-types (.claude-plugin/types/, git-ignored).

Source 2 files
hooks/register.ts 289 lines
1import type { EngineInterface, Register } from 'claude-code'
2import {
3  BACKOFF_MS,
4  configFrom,
5  failureText,
6  freshMessages,
7  inBackoff,
8  parseApplied,
9  parseBatch,
10  parseStatus,
11  shortenInstruction,
12  statusLine,
13  viewBlock,
14  withoutView,
15  type Config,
16} from './chat.ts'
17
18/** The mod is transport only: every memory decision lives in the memrove CLI. */
19type State = {
20  running: boolean
21  pending: boolean
22  failures: number
23  lastFailureAt: number
24  nodes?: number
25  calls?: number
26  cap?: number
27  /** The view SessionStart read, for the prompt.context right after it (one read per start). */
28  view?: { at: number; read: Promise<string | undefined> }
29}
30
31const ZOOM_TOOL = {
32  name: 'zoom',
33  description:
34    'Open line id+n of the chat memory view into the two lines of n/2 under it; n = 1 gives message id whole (a long message comes in pages). n is a power of 2 and id a multiple of n. Use it to find the latest mention of anything that may have come up before, before you act, guess or ask.',
35  inputSchema: {
36    type: 'object',
37    properties: {
38      id: { type: ['integer', 'string'], minimum: 0, description: 'The first message of the line (the number before the +); or `rec:<id>` of a saved record from a search hit, which opens the record with its why (n = 1).' },
39      n: { type: 'integer', minimum: 1, description: 'How many messages the line covers (the number after the +); a power of 2.' },
40      page: { type: 'integer', minimum: 1, description: 'Page of a long message (n = 1 only); default 1.' },
41    },
42    required: ['id', 'n'],
43  },
44} as const
45
46const DATE_TOOL = {
47  name: 'date',
48  description: 'The date and time of message id of the chat memory.',
49  inputSchema: { type: 'object', properties: { id: { type: 'integer', minimum: 0 } }, required: ['id'] },
50} as const
51
52const SEARCH_TOOL = {
53  name: 'search',
54  description: 'Search every message of the chat memory, and the saved decisions, lessons and notes, by their words. Returns the best hits as id|date|kind|snippet (a saved record is rec:<id>); a hit is only a snippet that drops detail, so open the message with zoom(id, 1) before you answer from it. Optional since/until limit it to a date range. Use it when the view shows a topic only as a word, or to find where something was said.',
55  inputSchema: {
56    type: 'object',
57    properties: {
58      query: { type: 'string', description: 'A few words the message would contain. May be empty when since or until is given: then the messages of that range are listed, newest first.' },
59      since: { type: 'string', description: 'Only messages from this date on: YYYY-MM-DD or an ISO datetime. Turn words like "last week" into dates yourself.' },
60      until: { type: 'string', description: 'Only messages up to this date, inclusive: YYYY-MM-DD or an ISO datetime.' },
61    },
62    required: ['query'],
63  },
64} as const
65
66const PROJECT_TOOL = {
67  name: 'project',
68  description:
69    'Where a project stands, in a few lines: its latest decisions (the ones a later decision replaced are marked SUPERSEDED), open loops, and the end state of its last session. Every line starts with an id that zoom opens (rec:<id>, th:<id>, ls:<id>). Call it when you start work on a project and need its current state, before you ask the owner to re-explain. Without a name it uses the project of the working directory.',
70  inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Project name; leave out to use the working directory.' } } },
71} as const
72
73const DOC_TOOL = {
74  name: 'doc',
75  description:
76    'Open a docs write-up that a search doc handle points to (the `doc|date|path|heading|snippet` lines under the hits), whole or one section. Only files under docs/state, docs/design, docs/research and docs/decisions of the owner\'s repos open.',
77  inputSchema: {
78    type: 'object',
79    properties: {
80      path: { type: 'string', description: 'The absolute path from the doc handle.' },
81      section: { type: 'string', description: 'Heading of one section; leave out for the whole file.' },
82    },
83    required: ['path'],
84  },
85} as const
86
87const MODEL_TOKENS = 16_000
88const MODEL_TIMEOUT_MS = 300_000
89const CLI_TIMEOUT_MS = 120_000
90const VIEW_HANDOFF_MS = 30_000
91
92function message(err: unknown): string {
93  return err instanceof Error ? err.message : String(err)
94}
95
96/** Runs the memrove CLI; a non-zero exit throws with the end of its stderr. */
97async function cli($: EngineInterface, cfg: Config, args: string[], stdin?: string): Promise<string> {
98  const r = await $.process.run([...cfg.memrove, ...args], { timeoutMs: CLI_TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
99  if (r.exitCode !== 0) throw new Error(`memrove ${args.join(' ')} exited ${r.exitCode}: ${(r.stderr || r.stdout).trim().slice(-300)}`)
100  return r.stdout
101}
102
103function show($: EngineInterface, text: string): void {
104  $.ui.status(text)
105}
106
107async function refreshStatus($: EngineInterface, state: State, cfg: Config): Promise<void> {
108  const s = parseStatus(await cli($, cfg, ['chatlog', 'status']))
109  state.nodes = s.nodes
110  state.calls = s.calls_today
111  state.cap = s.cap
112}
113
114/** One batch: prepare, one Haiku call on the session's own auth, apply. Returns true when the queue is empty. */
115async function oneBatch($: EngineInterface, state: State, cfg: Config, n: number): Promise<boolean> {
116  const batch = parseBatch(await cli($, cfg, ['chatlog', 'prepare', '--json', '--no-ingest']))
117  if (batch === undefined) return true
118  show($, statusLine({ building: true, batch: n, nodes: state.nodes, calls: state.calls, cap: state.cap }))
119  const r = await $.model.complete({ model: cfg.model, system: batch.system, prompt: batch.prompt, maxTokens: MODEL_TOKENS, timeoutMs: MODEL_TIMEOUT_MS })
120  if (!r.isAnswered) {
121    // Hands the claim back so it does not wait out its expiry; the CLI decides what an error means.
122    const why = failureText(r as { reason: string; status?: number | null; error?: string })
123    await cli($, cfg, ['chatlog', 'apply', batch.batch_id, '--error', why]).catch(() => undefined)
124    throw new Error(`the model gave no text (${why})`)
125  }
126  const done = parseApplied(await cli($, cfg, ['chatlog', 'apply', batch.batch_id], r.text))
127  if (done.error !== undefined) throw new Error(`apply: ${done.error}`)
128  return done.done
129}
130
131/** Ingest, then up to cfg.batches batches. */
132async function run($: EngineInterface, state: State, cfg: Config): Promise<void> {
133  show($, statusLine({ building: true, nodes: state.nodes, calls: state.calls, cap: state.cap }))
134  await cli($, cfg, ['chatlog', 'ingest'])
135  for (let n = 1; n <= cfg.batches; n++) {
136    if (await oneBatch($, state, cfg, n)) break
137    await refreshStatus($, state, cfg)
138  }
139  await refreshStatus($, state, cfg)
140}
141
142/** One run at a time; a turn that ends during a run asks for one more after it (as memory-save does). */
143async function drain($: EngineInterface, state: State, cfg: Config): Promise<void> {
144  if (state.running) {
145    state.pending = true
146    return
147  }
148  state.running = true
149  try {
150    do {
151      state.pending = false
152      if (inBackoff(state.failures, state.lastFailureAt, await $.clock.now())) {
153        show($, statusLine({ error: `paused ${Math.round(BACKOFF_MS / 60_000)} min after ${state.failures} failed runs` }))
154        break
155      }
156      try {
157        await run($, state, cfg)
158        state.failures = 0
159        show($, statusLine({ nodes: state.nodes, calls: state.calls, cap: state.cap }))
160      } catch (err) {
161        state.failures += 1
162        state.lastFailureAt = await $.clock.now()
163        show($, statusLine({ error: message(err) }))
164      }
165    } while (state.pending)
166  } finally {
167    state.running = false
168  }
169}
170
171/** The whole view as the CLI prints it, or undefined when there is no chat memory yet. */
172async function readView($: EngineInterface, cfg: Config): Promise<string | undefined> {
173  try {
174    const text = (await cli($, cfg, ['wake', '--all'])).trim()
175    return text === '' ? undefined : text
176  } catch (err) {
177    $.ui.log(`memrove-chat: the view was not read: ${message(err)}`, { to: 'debug' })
178    return undefined
179  }
180}
181
182async function toolText($: EngineInterface, cfg: Config, args: string[]): Promise<{ result: string }> {
183  const r = await $.process.run([...cfg.memrove, ...args], { timeoutMs: CLI_TIMEOUT_MS })
184  return { result: r.exitCode === 0 ? r.stdout.trim() : `error: ${(r.stderr || r.stdout).trim()}` }
185}
186
187function intOf(v: unknown): number | undefined {
188  return typeof v === 'number' && Number.isInteger(v) ? v : undefined
189}
190
191export const register: Register = (on, options) => {
192  const cfg = configFrom(options as Record<string, unknown>)
193  const state: State = { running: false, pending: false, failures: 0, lastFailureAt: 0 }
194
195  on('session.start', async ($, e, next) => {
196    const r = await next(e)
197    await $.tool.register(ZOOM_TOOL)
198    await $.tool.register(DATE_TOOL)
199    await $.tool.register(SEARCH_TOOL)
200    await $.tool.register(PROJECT_TOOL)
201    await $.tool.register(DOC_TOOL)
202    return r
203  })
204
205  // A plugin's tool waits behind ToolSearch by default; these are listed, so the model calls them at once.
206  on('tool.describe', { tool: /^mcp__memrove-chat__(zoom|date|search|project|doc)$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
207
208  on('tool.call', { tool: /^mcp__memrove-chat__zoom$/ }, async ($, e) => {
209    const input = e as Record<string, unknown>
210    if (typeof input.id === 'string' && /^(rec|th|ls):[\w:-]+$/.test(input.id.trim())) return toolText($, cfg, ['show', input.id.trim()])
211    const id = intOf(input.id)
212    const n = intOf(input.n)
213    if (id === undefined || n === undefined) return { deny: 'zoom needs integer id and n' }
214    const page = intOf(input.page)
215    return toolText($, cfg, ['zoom', String(id), String(n), ...(page === undefined ? [] : ['--page', String(page)])])
216  })
217
218  on('tool.call', { tool: /^mcp__memrove-chat__date$/ }, async ($, e) => {
219    const id = intOf((e as Record<string, unknown>).id)
220    return id === undefined ? { deny: 'date needs an integer id' } : toolText($, cfg, ['date', String(id)])
221  })
222
223  on('tool.call', { tool: /^mcp__memrove-chat__search$/ }, async ($, e) => {
224    const { query, since, until } = e as Record<string, unknown>
225    const range = [['--since', since], ['--until', until]].flatMap(([flag, v]) => (typeof v === 'string' && v.trim() !== '' ? [flag as string, v.trim()] : []))
226    const text = typeof query === 'string' ? query.trim() : ''
227    return text !== '' || range.length > 0 ? toolText($, cfg, ['chatlog', 'search', text, ...range]) : { deny: 'search needs a query or a since/until date' }
228  })
229
230  on('tool.call', { tool: /^mcp__memrove-chat__project$/ }, async ($, e) => {
231    const name = (e as Record<string, unknown>).name
232    return toolText($, cfg, ['project', ...(typeof name === 'string' && name.trim() !== '' ? [name.trim()] : [])])
233  })
234
235  on('tool.call', { tool: /^mcp__memrove-chat__doc$/ }, async ($, e) => {
236    const { path, section } = e as Record<string, unknown>
237    if (typeof path !== 'string' || path.trim() === '') return { deny: 'doc needs a path' }
238    return toolText($, cfg, ['doc', path.trim(), ...(typeof section === 'string' && section.trim() !== '' ? ['--section', section.trim()] : [])])
239  })
240
241  // The view joins the first user message as a context block of its own: at startup, resume, /clear and after a compaction.
242  on('prompt.context', async ($, e, next) => {
243    const r = await next(e)
244    // Takes the read SessionStart just made (a few seconds old at most); /clear and compaction have none and read again.
245    const held = state.view
246    state.view = undefined
247    const view = await (held !== undefined && (await $.clock.now()) - held.at < VIEW_HANDOFF_MS ? held.read : readView($, cfg))
248    return view === undefined ? r : { ...r, blocks: [...r.blocks, { name: 'memroveChat', text: viewBlock(view) }] }
249  })
250
251  // Subagents inherit the session's context sections (measured: +33k tokens each); the view stays with the main loop.
252  on('prompt.attachment', { type: 'context_sections' }, async (_, e, next) => {
253    const r = await next(e)
254    if (e.agentId === undefined || r.text === null || r.text === undefined) return r
255    return { ...r, text: withoutView(r.text) }
256  })
257
258  // The SessionStart instruction of the memrove hook shrinks to one line once the view is in the context.
259  on('classic.SessionStart', async ($, e, next) => {
260    const r = await next(e)
261    if (e.agent_id !== undefined || r.additionalContext === undefined) return r
262    // SessionStart runs before prompt.context, so it reads the view itself and hands the same read on to that block.
263    const read = readView($, cfg)
264    state.view = { at: await $.clock.now(), read }
265    if ((await read) === undefined) return r
266    return { ...r, additionalContext: shortenInstruction(r.additionalContext) }
267  })
268
269  // The fresh context: the view and the last exchange, nothing else. Off unless the `fresh` option is on.
270  on('session.compact', async ($, e, next) => {
271    if (!cfg.fresh || e.agentId !== undefined) return next(e)
272    const view = await readView($, cfg)
273    const messages = view === undefined ? undefined : freshMessages(view, e.messages)
274    return messages === undefined ? next(e) : { messages }
275  })
276
277  on('turn.complete', async ($, e, next) => {
278    const r = await next(e)
279    if (e.agentId !== undefined || e.reason !== 'answer') return r
280    // On a timer, not in the dispatch: a call still in flight when the dispatch closes is aborted.
281    $.clock.after(0, () => void drain($, state, cfg))
282    if (cfg.fresh) {
283      // A compaction started from a timer skips this plugin's own hook, so it starts here, not awaited.
284      void $.session.compact().catch((err: unknown) => $.ui.log(`memrove-chat: fresh compaction failed: ${message(err)}`))
285    }
286    return r
287  })
288}
289
hooks/chat.ts 144 lines
1/**
2 * The pure part of memrove-chat: the options, what the CLI prints, the status line and the message
3 * list of a fresh context. No `$` here, so the tests need no engine.
4 */
5import type { SessionMessage } from 'claude-code'
6
7export type Config = {
8  /** argv of the memrove entry point, split on spaces. */
9  memrove: string[]
10  /** Batches (one model call each) after a turn. */
11  batches: number
12  model: string
13  /** Compact to the view plus the last exchange after every turn. */
14  fresh: boolean
15}
16
17export const DEFAULTS = { memrove: 'memrove', batches: 3, model: 'haiku', fresh: false }
18
19export function splitCommand(text: string): string[] {
20  return text.split(/\s+/).filter(p => p !== '')
21}
22
23export function configFrom(options: Record<string, unknown>): Config {
24  const memrove = typeof options.memrove === 'string' && options.memrove.trim() !== '' ? options.memrove : DEFAULTS.memrove
25  const b = options.batches
26  const batches = typeof b === 'number' && Number.isInteger(b) && b >= 1 && b <= 20 ? b : DEFAULTS.batches
27  const model = typeof options.model === 'string' && options.model.trim() !== '' ? options.model.trim() : DEFAULTS.model
28  return { memrove: splitCommand(memrove), batches, model, fresh: options.fresh === true || options.fresh === 'true' }
29}
30
31export type Batch = { batch_id: string; system: string; prompt: string; node_ids: string[] }
32export type Applied = { applied: number; retry_ids: string[]; done: boolean; error?: string }
33export type Status = { nodes: number; calls_today: number; cap: number; messages: number }
34
35function parseObject(text: string): Record<string, unknown> | undefined {
36  try {
37    const v: unknown = JSON.parse(text)
38    return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
39  } catch {
40    return undefined
41  }
42}
43
44/** `chatlog prepare --json`: a batch, or undefined for `{}` (nothing ready, locked, or the cap is spent). */
45export function parseBatch(text: string): Batch | undefined {
46  const o = parseObject(text)
47  if (o === undefined) throw new Error(`prepare printed no JSON: ${text.slice(0, 120)}`)
48  if (typeof o.batch_id !== 'string') return undefined
49  if (typeof o.system !== 'string' || typeof o.prompt !== 'string') throw new Error('prepare printed a batch without system or prompt')
50  return { batch_id: o.batch_id, system: o.system, prompt: o.prompt, node_ids: Array.isArray(o.node_ids) ? o.node_ids.map(String) : [] }
51}
52
53export function parseApplied(text: string): Applied {
54  const o = parseObject(text)
55  if (o === undefined || typeof o.applied !== 'number') throw new Error(`apply printed no result: ${text.slice(0, 120)}`)
56  return {
57    applied: o.applied,
58    retry_ids: Array.isArray(o.retry_ids) ? o.retry_ids.map(String) : [],
59    done: o.done === true,
60    ...(typeof o.error === 'string' ? { error: o.error } : {}),
61  }
62}
63
64export function parseStatus(text: string): Status {
65  const o = parseObject(text)
66  const n = (k: string): number => (typeof o?.[k] === 'number' ? o[k] : 0)
67  if (o === undefined) throw new Error(`status printed no JSON: ${text.slice(0, 120)}`)
68  return { nodes: n('nodes'), calls_today: n('calls_today'), cap: n('cap'), messages: n('messages') }
69}
70
71/** Why a model call gave no text, as one short phrase for `apply --error` and the status line. */
72export function failureText(r: { reason: string; status?: number | null; error?: string }): string {
73  if (r.reason !== 'api-error') return r.reason
74  return `api-error${r.status == null ? '' : ` ${r.status}`}${r.error === undefined ? '' : ` ${r.error}`}`
75}
76
77/** The status line: building, nodes done and calls made today, or the error. */
78export function statusLine(s: { building?: boolean; nodes?: number; calls?: number; cap?: number; error?: string; batch?: number }): string {
79  if (s.error !== undefined) return `chat memory: ${s.error}`
80  const parts = [s.building ? `building${s.batch === undefined ? '' : ` batch ${s.batch}`}` : 'idle']
81  if (s.nodes !== undefined) parts.push(`${s.nodes} nodes`)
82  if (s.calls !== undefined) parts.push(`${s.calls}${s.cap === undefined ? '' : `/${s.cap}`} calls today`)
83  return `chat memory: ${parts.join(' · ')}`
84}
85
86/** After a run of failures the turns stop asking for a batch for a while, so a poisoned node does not cost 3 calls a turn. */
87export const BACKOFF_FAILURES = 3
88export const BACKOFF_MS = 10 * 60_000
89
90export function inBackoff(failures: number, lastFailureAt: number, now: number): boolean {
91  return failures >= BACKOFF_FAILURES && now - lastFailureAt < BACKOFF_MS
92}
93
94/** What the classic SessionStart instruction becomes when the view itself is in the context. */
95export const ONE_LINE = 'Before you answer or start a task that may build on earlier work, search first; a hit or a doc line is a lead, not evidence; open it with zoom or memrove doc and answer from what you opened. On one topic the newer statement wins, and the user\'s word beats an agent\'s. Tools: search, zoom (id, n), date.'
96const INSTRUCTION_MARK = 'Memrove chat memory is on'
97
98export function shortenInstruction(texts: readonly string[] | undefined): string[] | undefined {
99  if (texts === undefined) return undefined
100  const out: string[] = []
101  for (const t of texts) {
102    if (!t.includes(INSTRUCTION_MARK)) out.push(t)
103    else if (!out.includes(ONE_LINE)) out.push(ONE_LINE)
104  }
105  return out
106}
107
108export const VIEW_NOTE = 'This is an index of the whole chat so far, one line per run of messages (id+n|summary), not evidence: the newest lines are the most exact, older ones keep little. Open a line with the zoom tool before you rely on it.'
109
110/** The block text of the view. */
111export function viewBlock(view: string): string {
112  return `${VIEW_NOTE}\n${view}`
113}
114
115/**
116 * The text of a `context_sections` attachment without the view: subagents get the context blocks of the
117 * session, and the view (about 32k tokens) is for the main loop only. Sections are `# name` headers; the
118 * view's lines start with a number, so the next core header ends it. Empty text comes back as null.
119 */
120export function withoutView(text: string): string | null {
121  const head = '# memroveChat\n'
122  const at = text.startsWith(head) ? 0 : text.indexOf(`\n${head}`) + 1
123  if (at === 0 && !text.startsWith(head)) return text
124  const end = text.slice(at).search(/\n# (claudeMd|userEmail|attachedProject|currentDate)\n/)
125  const rest = end < 0 ? text.slice(0, at).trimEnd() : text.slice(0, at) + text.slice(at + end + 1)
126  return rest.trim() === '' ? null : rest
127}
128
129/**
130 * The messages of a fresh context: the last user message and everything after it verbatim (its tool
131 * calls and answer); the view comes back as the prompt.context block. Undefined when there is no view or
132 * user message with text, so the compaction falls back to the engine's.
133 */
134export function freshMessages(view: string, messages: readonly SessionMessage[]): SessionMessage[] | undefined {
135  let last = -1
136  messages.forEach((m, i) => {
137    if (m.role === 'user' && m.text.trim() !== '' && (m.toolResults ?? []).length === 0) last = i
138  })
139  const user = messages[last]
140  if (user === undefined || view.trim() === '') return undefined
141  // The view itself is not put in: prompt.context adds it again after every compaction (measured: both copies cost ~2x the view per turn).
142  return messages.slice(last)
143}
144