lumberroom as Claude Code's memory: the digest in the system prompt, a periodic search-and-write reminder, optional per-prompt recall, built-in memory off.

![]()
lumberroom as Claude Code's memory, built as a Claude Code mod (a plugin of function hooks). The digest sits in the system prompt, a short reminder tells the model to search and to write, and Claude Code's own file memory is switched off. Recall with each prompt is available and off by default. It targets lumberroom.cloud or a self-hosted engine through an MCP server the plugin brings with it. The live sessions listed below ran against the hosted engine.
More videos: Lumberroom playlist
Built for Claude Code. The digest, the reminder, the status line and the memory guard are Claude Code hooks, so on claude.ai, the desktop app or Cowork only the bundled MCP server applies.
Status: The Claude Code mod API is early access (built and observed on 2.1.287) and may change between releases.
context_bootstrap for the project and adds the digest and the write rule as one section of the system prompt. It does not edit CLAUDE.md or your settings. If you still run the old lumberroom bootstrap --hook shell hook, remove it from the SessionStart hooks in ~/.claude/settings.json, or the session gets the digest twice. When ~/.claude/CLAUDE.md or the project's CLAUDE.md already carries the write rule, the plugin leaves its own copy of the rule out of the section. The check reads ~/.claude/CLAUDE.md and <git root>/CLAUDE.md (the working directory when there is no git root) for a # Durable memory heading followed by memory_write.reviewInterval prompts (8 by default). The plugin adds one line that tells the model to call memory_search before it answers from assumption and memory_write after the exchange settles something. It works with recall on or off. Slash commands do not count.recall and the plugin searches lumberroom with your prompt and attaches new hits for the model only. It skips prompts under 12 characters and drops hits whose similarity is below recallMinSimilarity. A hit goes out once, until /clear or a compaction empties the context that held it. The first prompt of a session can wait for the bootstrap plus the search (up to bootstrapTimeoutMs plus recallTimeoutMs); later prompts wait at most recallTimeoutMs. Three failures in a row pause recall for a minute.Read, Write, Edit, MultiEdit and NotebookEdit on ~/.claude/MEMORY.md and on anything under ~/.claude/projects/*/memory/, and Grep and Glob whose path is inside that folder. Bash is not guarded: cat or rm on a memory file goes through. /lr-import sends what those files already hold to the engine's proposal queue for you to review.lumberroom ~2.4k tokens in context: digest 2.0k, reminders 30, tools 400 (2 calls). session.start and every prompt redraw it. Compaction and /clear zero the reminders and tools figures.skills/lumberroom-memory carries the same read and write rules. In Claude Code the digest section already holds them; in Cowork, where only the MCP server and the skill load, the skill is what tells the model to bootstrap, search and write. The MCP server's own tool descriptions say what each tool does and give no orders./lr-review works the review queue. skills/lr-review settles dreaming proposals, conflicts and duplicates from the memories alone and asks you only about contradictions no date can order, in one list at the end.The plugin writes nothing on its own unless you turn on the extractor. When on, the extractor calls memory_write directly. Otherwise the model writes when it calls memory_write.
Every hook lives in hooks/register.ts. None answers a permission check, changes a permission mode, or rewrites a settings, agent, command or file-write event. The plugin runs no shell command, spawns no process or agent, and calls no shell tool. The one file it writes is your user settings.json, through /lr-setup, after you confirm.
| Hook | What it does |
|---|---|
session.start | Calls context_bootstrap on the lumberroom server, caches the digest, registers /lr-import and /lr-setup and draws the status line |
prompt.compose | Adds the digest and the write rule to the system prompt as one section |
prompt.section (memory) | Removes Claude Code's built-in memory section when replaceBuiltinMemory is on |
prompt.submit | Adds the reminder every reviewInterval prompts and, with recall on, calls memory_search and attaches the hits |
tool.call | See below |
turn.complete, session.end, session.compact | Run the extractor when it is on: a model call through Claude Code, then memory_search and memory_write |
command.run (lr-import, lr-setup) | Answers /lr-import and /lr-setup, the plugin's own commands; no other command reaches this hook |
What tool.call does with the calls it sees. It reads the tool name and the path argument. With replaceBuiltinMemory on, it refuses Read, Write, Edit, MultiEdit, NotebookEdit, Grep and Glob when the path lands in ~/.claude/MEMORY.md or ~/.claude/projects/*/memory/, and answers with the reason. That refusal is the plugin's own rule, applied before your permission rules, and turning the option off removes it. For a call to a lumberroom tool it measures the length of the result for the token counter. It passes every other call on unchanged, and it never approves a call, never rewrites one and never sends what it sees anywhere.
Tools the plugin calls itself, without the model asking. All three are lumberroom tools on the MCP server Claude Code connects ($.mcp.call), and Claude Code runs each one through your permission rules:
| Tool | When |
|---|---|
context_bootstrap | At session start, retried for up to bootstrapTimeoutMs while the server connects; on later prompts until one answers, if session start got none |
memory_search | With each prompt from you when recall is on; with the extractor on, once before each extracted fact is written, to find the row it may replace |
memory_write | With the extractor on, once for each fact it extracted. Off by default |
The tool names are fixed text in the code. The plugin calls no other tool. It also makes model calls through Claude Code ($.model.complete, on extractorModel) for the extractor, for the judge that decides whether a new fact replaces an old one, and for the namespace guess in /lr-import all.
Network requests. The plugin contacts two endpoints:
https://mcp.lumberroom.cloud/mcp, or the lumberroom server you registered for a self-hosted engine. Claude Code makes these connections; the plugin only asks for the tool calls above./lr-import or /lr-import confirm: LUMBERROOM_URL, else the url in the CLI's config file, else https://mcp.lumberroom.cloud. The plugin makes these requests itself with $.http.fetch, three kinds of POST carrying the credential described under Commands as a bearer: /admin/ingest/runs opens an ingest run, /admin/ingest/proposals sends the memory files as proposals in batches, and /admin/ingest/runs/<id>/close closes the run. It refuses an engine URL that is not https, except for localhost and 127.0.0.1.The plugin fetches no code and no instructions: the engine's answers are the digest, search hits and ingest counts, and the plugin shows them to the model as data.
Everything goes to the lumberroom engine, lumberroom.cloud by default or your own. Nothing goes to any other server.
From the conversation:
memory_search call. Only when recall is on.memory_write, each preceded by a memory_search on the fact's text.context_bootstrap. No conversation text goes with it.From your machine:
~/.claude/CLAUDE.md and the project's CLAUDE.md, to see whether they already carry the write rule. Their text stays on your machine.~/.claude/projects/*/memory/, read and posted to the engine's proposal queue only when you run /lr-import or /lr-import confirm. /lr-import all lists the folders and posts nothing.LUMBERROOM_CONFIG, else ~/.config/lumberroom/config.json), read for its engine URL and bearer only when /lr-import or /lr-import confirm posts. The plugin never writes to it.permissions.allow and permissions.deny, read by /lr-setup to see which rules are missing. When you confirm, it adds the missing rules to permissions.allow in ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json) and keeps every other key.The model's own calls: when Claude calls memory_search, memory_write or another lumberroom tool, those calls go to the same engine under your permission rules.
The plugin keeps a digest cache, a duplicate guard for writes and the /lr-import plan in Claude Code's plugin storage. The only file it writes is your user settings.json, when you confirm /lr-setup. The hosted service's privacy policy is https://lumberroom.cloud/privacy; a self-hosted engine keeps everything on your own server.
From the marketplace:
/plugin marketplace add lumberroom/lumberroom-claude-code
/plugin install lumberroom-memory@lumberroom
The plugin brings its own MCP server, connecting to https://mcp.lumberroom.cloud/mcp. It asks for no settings on install. Run /mcp once and sign in to plugin:lumberroom-memory:lumberroom. Its tools appear as mcp__plugin_lumberroom-memory_lumberroom__memory_search and so on, so a permission rule or agent definition that names mcp__lumberroom__* needs the new names.
Register your engine as an MCP server named lumberroom, then restart Claude Code:
claude mcp add --scope user --transport http lumberroom https://lr.example.com/mcp
Add --header "Authorization: Bearer <token>" if your engine accepts only static bearer tokens. The plugin tries the bundled server first and falls back to lumberroom, so it needs no option. The bundled server still points at lumberroom.cloud and shows as needing sign-in; disable plugin:lumberroom-memory:lumberroom in /mcp to hide it. For /lr-import, point the lumberroom CLI at the same engine (LUMBERROOM_URL=https://lr.example.com lumberroom login).
If you already registered a server at the same URL, or your claude.ai account has a connector for it, Claude Code keeps one copy and hides the others. The plugin asks which copy is live and talks to it. Remove a registered one (claude mcp remove lumberroom) to run on the bundled server alone.
The manifest is .claude-plugin/marketplace.json (marketplace lumberroom, plugin lumberroom-memory, source ./). npm run validate checks it and plugin.json.
From a local checkout, in a terminal:
claude --plugin-dir /path/to/lumberroom-claude-code
or, for development, link the folder into a session's mods folder (~/.claude/dev-mods/<session>/lumberroom-memory) and accept hot reloading when Claude Code asks.
Claude Code runs the plugin's own calls to context_bootstrap, memory_search and memory_write through your permission rules, so allow those three tools once: run /lr-setup, confirm, and the digest loads in the same session. docs/permissions.md has the rules if you would rather add them by hand. Until you do, the session starts without the digest and the plugin shows a toast that points at /lr-setup.
Set them in /config, or under pluginConfigs["lumberroom-memory"].options in ~/.claude/settings.json.
| Option | Default | Range | Meaning |
|---|---|---|---|
project | auto | auto uses the git root's folder name; none sends none; else the slug | |
recall | off | search with each prompt; costs input tokens on every later turn | |
recallExtraProjects | empty | other project slugs to search with each prompt, separated by commas or whitespace. A bare slug applies everywhere; project=slug1+slug2 applies only in that project, for example lumberroom-cloud=lumberroom for a fork that shares its engine's decisions. Claude Code reads plugin options from user, --settings or managed settings only, never project settings, so a per-project value goes inside the option | |
recallLimit | 6 | 1 to 20 | hits asked for |
recallMaxChars | 4000 | 500 to 16000 | cap on the whole recall block: tags, note, hits and write reminder |
recallMinSimilarity | 0.6 | 0 to 1 | drop hits below this similarity; hits with no similarity pass |
recallTimeoutMs | 2500 | 500 to 8000 | wait for memory_search |
bootstrapTimeoutMs | 5000 | 5000 to 8000 | wait for context_bootstrap |
digestMaxChars | 8000 | 1000 to 30000 | cap on the digest section |
reviewInterval | 8 | 0 to 100 | search and write reminder every N prompts, recall on or off; 0 is off |
replaceBuiltinMemory | on | drop built-in memory and guard its files | |
extractor | off | off, turn, session-end | turn or session-end to write facts automatically |
extractorModel | haiku | model for the extractor | |
ingestToken | unset | optional bearer with mayIngest for /lr-import, ahead of the CLI's credential; kept in secure storage, so /config does not list it (see Commands) |
Numeric options are rounded to whole numbers and clamped to the range; a non-numeric value falls back to the default. recallMinSimilarity is clamped but not rounded.
An option edited in ~/.claude/settings.json by hand takes effect after /reload-plugins; a change through /config reloads the plugin itself.
/lr-setup: allow the plugin's own context_bootstrap, memory_search and memory_write calls. It names the rules for the server lumberroom runs under, asks before it changes anything, adds the missing ones to permissions.allow in your user settings.json, then fetches the digest. It leaves a file it cannot parse alone, and stops at a deny rule, which an allow rule cannot override. /lr-setup show prints the rules and changes nothing./lr-import: send this project's memory files to the proposal queue. Each call waits 15 s, the closing call 5 s, and the result line reports posted, new, reinforced, confirmed, refused and blocked counts. user and feedback memories always go to user:me./lr-import all: lists every folder under ~/.claude/projects that has memory files and proposes a namespace for each, in a numbered table. It posts nothing. A folder whose name decodes to an existing path takes that path's git root slug; any other folder gets one model guess (extractorModel), and an unusable answer becomes global./lr-import confirm <n|folder> [namespace]: posts that one folder with the proposed namespace, or the one you give (my-repo, project:my-repo or global). /lr-import confirm all posts every pending folder with its proposal. /lr-import skip <n> drops one. /lr-import plan shows the table again. Only these project and reference memories use the namespace; the plan needs no token, confirm does./lr-import and /lr-import confirm take their credential from the first of these that is set:
ingestToken option.LUMBERROOM_TOKEN in Claude Code's environment.token in the lumberroom CLI's config file.oauth.access_token in the same file).So once you have run lumberroom login and picked the Full profile on the consent screen, the only profile that carries the mayIngest grant, /lr-import works with nothing set in the plugin. A Standard login answers HTTP 403; run lumberroom login --reregister and pick Full. When the CLI's access token has expired, /lr-import asks you to run lumberroom whoami, which refreshes it.
ingestToken is a sensitive option, so Claude Code keeps it in secure storage and /config does not list it. Set or change it from a terminal:
echo '{"ingestToken":"<token>"}' | claude plugin configure lumberroom-memory@lumberroom --values-stdin
Send {"ingestToken":""} the same way to clear it and fall back to the CLI.
Everything above is implemented. npm run gate passes with 514 tests, a unit-level result: it covers the logic and the hooks claude plugin test can reach. A unit test does not show that a live session behaves the same way, so this section lists what a live Claude Code 2.1.287 session has shown.
Observed in a live 2.1.287 session:
Write to a memory file in an interactive session./lr-import with no ingestToken answered with the setting hint and posted nothing, in an interactive session./lr-import all listed the one folder holding memory files and proposed project:lr-import-test from the path it found; /lr-import confirm 1 posted 3 proposals (3 new) to the ingest queue with the expected namespaces (2 under the project, 1 under user:me), and a second confirm 1 posted nothing. The ingestToken was read from pluginConfigs in ~/.claude/settings.json./compact in an interactive session, the digest section (the # Durable memory (lumberroom) heading, the project line and the engine digest) was still in the system prompt. The plugin's keep-facts line appeared in the compaction instructions. The reminder arrived on the 8th person prompt after compaction, so the prompt counter reset. The extractor was off, so no extraction ran.~/.claude/CLAUDE.md already carries a # Durable memory block. Claude Code's own memory section was absent.Cloudflare D1 port surfaced the right memory at similarity 0.77; a bare D1 decision did not. Put the subject in the prompt.UI work is tracked in issue #1: a recall-and-why pane, a cost and latency band, and /lr-asof. The token status line is the first piece. Toasts are dropped from the plan. Engine proposals P1 to P8 are in docs/spec.md.
npm install # TypeScript, for type-checking only
npm run gate # validates both manifests, tsc, claude plugin test (514 tests on 5 October 2026)
Design and measurements: docs/spec.md. Task order: docs/plan.md.
Apache-2.0.
hooks/register.ts 949 lines1// Wiring only: every decision lives in ../src and every session value lives in $.state. The
2// closure variables below reset on a hot reload, which is safe because session.start runs again
3// on a reload and recomputes them.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { allow, CLOSED, failure, success } from '../src/breaker'
9import { DEFAULTS, extrasFor, readConfig } from '../src/config'
10import type { Config } from '../src/config'
11import { afterContextReset, estimateTokens, formatStatus, isServerTool, NO_COST } from '../src/cost'
12import type { Cost } from '../src/cost'
13import { buildSection, digestFrom, hasDurableMemoryBlock, SECTION_ID } from '../src/digest'
14import { buildExtractPrompt, buildJudgePrompt, parseFacts, parseJudge, turnsFrom } from '../src/extractor'
15import type { Turn } from '../src/extractor'
16import { GUARD_REASON, GUARDED_TOOLS, isBuiltinMemoryPath, normalizePath, parentOf, pathArg } from '../src/guard'
17import { checkBaseUrl, parseMemoryFile, postProposals, PROJECT_DIR_KEPT, projectDirMatches, projectDirName, toProposalFacts } from '../src/importer'
18import type { ImportReport, MemoryFile } from '../src/importer'
19import {
20 buildNamespacePrompt, decodeProjectDir, formatPlan, IMPORT_USAGE, namespaceLabel, parseImportArgs, parseNamespaceAnswer, parseNamespaceOverride, parsePlan, PLAN_KEY,
21} from '../src/importplan'
22import type { PlanEntry } from '../src/importplan'
23import { callTool, isOutage } from '../src/mcp'
24import { OWN_PLUGIN } from '../src/own'
25import { raceSleep } from '../src/race'
26import type { CallOutcome, McpDeps } from '../src/mcp'
27import { BUNDLED_KEY, SERVER_CANDIDATES } from '../src/server'
28import { cliConfigPath, resolveIngest, type IngestCredential } from '../src/credential'
29import { findGitRoot, resolveProject, slugFromPath } from '../src/project'
30import type { Exists } from '../src/project'
31import { buildRecallBlock, buildReminderBlock, clipQuery, hitsFrom, isEligible, isPersonPrompt, nudgeDue, PERMISSION_TOAST, searchNamespaces, selectHits, toRecalled, UNREACHABLE_TOAST } from '../src/recall'
32import { ADD_LABEL, CANCEL_LABEL, manualText, mergeAllow, missingRules, permissionLists, setupRules, SETUP_USAGE } from '../src/setup'
33import { writeFact } from '../src/writes'
34import type { Conflict, Fact, WriteDeps } from '../src/writes'
35import type { LumberroomDigest } from '../types'
36
37type Dollar = EngineInterface
38
39const PLUGIN = OWN_PLUGIN
40
41/** Spec 9.4. Compaction drops detail the extractor and the model have not yet written down. */
42export const COMPACT_LINE =
43 'Keep every decision, preference and durable fact the conversation established, with its identifiers, and note which were written to lumberroom.'
44
45// `claude plugin validate` reads these keys as literals, so each ref is spelled out here.
46const digestRef = atom({ plugin: 'lumberroom-memory', key: 'digest' } as const, null)
47const seenRef = atom({ plugin: 'lumberroom-memory', key: 'seen' } as const, [])
48const promptsRef = atom({ plugin: 'lumberroom-memory', key: 'prompts' } as const, 0)
49const breakerRef = atom({ plugin: 'lumberroom-memory', key: 'breaker' } as const, CLOSED)
50const lastRecallRef = atom({ plugin: 'lumberroom-memory', key: 'lastRecall' } as const, null)
51const statsRef = atom(
52 { plugin: 'lumberroom-memory', key: 'stats' } as const,
53 { recalls: 0, hitsAttached: 0, lastMs: null, offline: false, writes: 0 },
54)
55const extractedRef = atom({ plugin: 'lumberroom-memory', key: 'extractedThrough' } as const, 0)
56const costRef = atom({ plugin: 'lumberroom-memory', key: 'cost' } as const, NO_COST)
57
58/** A session end gets about 1.5 s for the whole chain; a model call needs more than this. */
59const SESSION_END_MIN_MS = 1000
60const EXTRACT_MODEL_TIMEOUT_MS = 30_000
61const JUDGE_MODEL_TIMEOUT_MS = 8_000
62const WRITE_TIMEOUT_MS = 5_000
63/** Compaction waits this long, in all, for the extractor before it goes on without it. */
64const COMPACT_EXTRACT_BOUND_MS = 20_000
65/** A stored row at or above this similarity is a candidate old version of a new fact. Lower scores cost a judge call and mostly name unrelated rows. */
66const SIMILAR_MIN = 0.75
67
68const mcpDeps = ($: Dollar): McpDeps => ({
69 call: (server, tool, args) => $.mcp.call(server, tool, args),
70 sleep: (ms) => $.clock.sleep(ms),
71 now: () => $.clock.now(),
72})
73
74const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err))
75
76let cfg: Config = DEFAULTS
77
78// Reset on every load: register() sets cfg, and session.start recomputes the rest.
79let hasRule = false
80/**
81 * True while the last engine call said the server is not connected: the digest section and the
82 * extractor stay off, and the next prompt tries again. Any other answer clears it.
83 */
84let serverAbsent = false
85/** Prompts in a row that found no connected server. The third toasts and stops the calls. */
86let notConnectedPrompts = 0
87/** Set by the third such prompt; only the next session.start clears it. */
88let recallStopped = false
89let toldNotConnected = false
90/** The permissions toast goes out once a session. */
91let toldDenied = false
92/** The candidate that last answered context_bootstrap; the other calls go to it. */
93let activeServer: string | undefined
94const serverName = (): string => activeServer ?? SERVER_CANDIDATES[0]
95/** Every name the plugin may call lumberroom under, for the token count. */
96const knownServers = (): string[] => [...new Set([...SERVER_CANDIDATES, ...triedServers])]
97/** Names serversToTry returned, counted before the first call answers. */
98let triedServers: readonly string[] = []
99
100/**
101 * The names to try, in order. In auto, $.mcp.connect on the bundled key answers with the name the
102 * session runs the server under (the bundled one, a registered duplicate, or a claude.ai connector
103 * with the same URL), and that name goes first.
104 */
105const serversToTry = async ($: Dollar): Promise<readonly string[]> => {
106 const base = SERVER_CANDIDATES
107 try {
108 const raced = await raceSleep($.mcp.connect(BUNDLED_KEY), (ms) => $.clock.sleep(ms), cfg.bootstrapTimeoutMs)
109 const answer = raced.timedOut ? undefined : raced.value
110 if (answer?.isConnected === true) {
111 const live = answer.server
112 return [live, ...base.filter((n) => n !== live)]
113 }
114 } catch (err) {
115 debug($, `$.mcp.connect failed: ${messageOf(err)}`)
116 }
117 return base
118}
119
120/** Gap between bootstrap attempts at session start, while MCP servers are still connecting. */
121const BOOTSTRAP_RETRY_MS = 500
122let isExtracting = false
123let projectMemo: { cwd: string; slug: string | undefined } | undefined
124const logged = new Set<string>()
125
126/** The first failure of a hook shows in the transcript; the rest go to the debug log. */
127const logFailure = ($: Dollar, hook: string, err: unknown): void => {
128 const text = `lumberroom: ${hook} failed, going on without it: ${messageOf(err)}`
129 try {
130 if (logged.has(hook)) $.ui.log(text, { to: 'debug' })
131 else {
132 logged.add(hook)
133 $.ui.log(text)
134 }
135 } catch {
136 // A failing log must not become the failure.
137 }
138}
139
140const debug = ($: Dollar, text: string): void => {
141 try {
142 $.ui.log(`lumberroom: ${text}`, { to: 'debug' })
143 } catch {
144 // As above.
145 }
146}
147
148const homeDir = async ($: Dollar): Promise<string> => {
149 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
150 return home.replace(/[\\/]+$/, '')
151}
152
153const existsOn = ($: Dollar): Exists => async (path) => {
154 try {
155 return await $.fs.exists(path)
156 } catch {
157 return false
158 }
159}
160
161const readText = async ($: Dollar, path: string): Promise<string> => {
162 try {
163 const text = await $.fs.read(path)
164 return typeof text === 'string' ? text : ''
165 } catch {
166 return ''
167 }
168}
169
170const projectFor = async ($: Dollar): Promise<string | undefined> => {
171 const cwd = await $.session.cwd()
172 if (projectMemo?.cwd === cwd) return projectMemo.slug
173 const slug = await resolveProject(cfg.project, cwd, existsOn($))
174 projectMemo = { cwd, slug }
175 return slug
176}
177
178/** True when the breaker lets a call go out now; stores the reset state of a cooled breaker. */
179const gate = async ($: Dollar): Promise<boolean> => {
180 const now = await $.clock.now()
181 const held = await read($, breakerRef)
182 const verdict = allow(held, now)
183 if (verdict.state.failures !== held.failures || verdict.state.openedAt !== held.openedAt) {
184 await update($, breakerRef, () => verdict.state)
185 }
186 return verdict.allowed
187}
188
189/** Feeds one engine outcome to the breaker and the stats; toasts once per outage. */
190const record = async ($: Dollar, outcome: CallOutcome): Promise<void> => {
191 if (isOutage(outcome)) {
192 const now = await $.clock.now()
193 const held = await read($, breakerRef)
194 const verdict = failure(held, now)
195 await update($, breakerRef, () => verdict.state)
196 await update($, statsRef, (s) => ({ ...s, offline: true }))
197 if (verdict.startsOutage) $.ui.toast(UNREACHABLE_TOAST)
198 return
199 }
200 if (outcome.kind === 'ok' || outcome.kind === 'tool_error') {
201 const held = await read($, breakerRef)
202 if (held.failures !== 0 || held.openedAt !== null) await update($, breakerRef, () => success())
203 const stats = await read($, statsRef)
204 if (stats.offline) await update($, statsRef, (s) => ({ ...s, offline: false }))
205 return
206 }
207 if (outcome.kind === 'not_connected') {
208 debug($, `the engine is not connected: ${outcome.error}`)
209 return
210 }
211 debug($, `the permission check refused an engine call: ${outcome.kind === 'denied' ? outcome.error : ''}`)
212 if (!toldDenied) {
213 toldDenied = true
214 $.ui.toast(PERMISSION_TOAST, { timeoutMs: 12_000 })
215 }
216}
217
218/** Applies `change` to the token estimate and shows the result on the status line. */
219const addCost = async ($: Dollar, change: (c: Cost) => Cost): Promise<void> => {
220 try {
221 $.ui.status(formatStatus(await update($, costRef, change)))
222 } catch (err) {
223 logFailure($, 'status line', err)
224 }
225}
226
227const sectionText = async ($: Dollar): Promise<string> => {
228 const digest = await read($, digestRef)
229 return buildSection(digest?.text ?? '', { includeRule: !hasRule, maxChars: cfg.digestMaxChars, project: digest?.project ?? null })
230}
231
232/** Forgets which hits this conversation has seen, and restarts the nudge count. */
233const resetDedup = async ($: Dollar): Promise<void> => {
234 try {
235 await update($, seenRef, () => [])
236 await update($, promptsRef, () => 0)
237 } catch (err) {
238 logFailure($, 'dedup reset', err)
239 }
240 await addCost($, afterContextReset)
241}
242
243/** One prompt found no connected server. No breaker failure: the server is absent, not down. */
244const noteNotConnectedPrompt = ($: Dollar): void => {
245 serverAbsent = true
246 notConnectedPrompts += 1
247 if (notConnectedPrompts < 3 || toldNotConnected) return
248 toldNotConnected = true
249 recallStopped = true
250 const names = SERVER_CANDIDATES.map((n) => `"${n}"`).join(' or ')
251 $.ui.toast(`${PLUGIN}: no ${names} MCP server with memory tools is connected. Check /mcp.`)
252}
253
254/** One context_bootstrap through the breaker. Stores the digest on success. */
255const bootstrap = async ($: Dollar): Promise<'ok' | 'not_connected' | 'failed'> => {
256 const project = await projectFor($)
257 // Claude Code suppresses the bundled server when a registered one has its URL, and that one
258 // answers instead; the first candidate that is connected becomes the server for the session.
259 const candidates = await serversToTry($)
260 triedServers = candidates
261 let outcome: CallOutcome = { kind: 'not_connected', error: 'no server tried', ms: 0 }
262 for (const candidate of candidates) {
263 outcome = await callTool(mcpDeps($), candidate, 'context_bootstrap', project === undefined ? {} : { project }, cfg.bootstrapTimeoutMs)
264 if (outcome.kind !== 'not_connected') {
265 activeServer = candidate
266 break
267 }
268 }
269 await record($, outcome)
270 serverAbsent = outcome.kind === 'not_connected'
271 if (outcome.kind !== 'ok') {
272 debug($, `context_bootstrap gave ${outcome.kind}${"error" in outcome ? `: ${outcome.error}` : ""}`)
273 return outcome.kind === 'not_connected' ? 'not_connected' : 'failed'
274 }
275 const { text, memories } = digestFrom(outcome.data)
276 const before = await read($, digestRef)
277 const fetchedAt = await $.clock.now()
278 const fresh = { project: project ?? null, text, memories, fetchedAt }
279 await update($, digestRef, () => fresh)
280 try {
281 if (text.trim() !== '') await $.store.set(cacheKey(project), fresh)
282 } catch (err) {
283 logFailure($, 'digest cache', err)
284 }
285 // The section text is cached per project; a different project needs a fresh render.
286 // A section rendered before the digest arrived is cached without it.
287 if (before === null || before.project !== (project ?? null)) $.ui.invalidate('prompt.section')
288 return 'ok'
289}
290
291/** The last digest fetched for a project, kept across sessions in $.store. It fills the section when the fresh bootstrap fails. */
292const cacheKey = (project: string | undefined): string => `digest-cache:${project ?? '-'}`
293
294const cachedDigest = async ($: Dollar): Promise<LumberroomDigest | null> => {
295 try {
296 const v = (await $.store.get(cacheKey(await projectFor($)))) as Partial<LumberroomDigest> | null | undefined
297 if (typeof v?.text !== 'string' || v.text.trim() === '') return null
298 return { project: typeof v.project === 'string' ? v.project : null, text: v.text, memories: typeof v.memories === 'number' ? v.memories : null, fetchedAt: typeof v.fetchedAt === 'number' ? v.fetchedAt : 0 }
299 } catch {
300 return null
301 }
302}
303
304/**
305 * Session start races the MCP connections: the server answers "no tool ... on a server named" for
306 * a moment and then connects. Retry for bootstrapTimeoutMs in total; a server that is still absent
307 * leaves the digest unset and the first prompt tries again.
308 */
309const bootstrapAtStart = async ($: Dollar): Promise<void> => {
310 const began = await $.clock.now()
311 while ((await bootstrap($)) === 'not_connected') {
312 if ((await $.clock.now()) - began >= cfg.bootstrapTimeoutMs) return
313 await $.clock.sleep(BOOTSTRAP_RETRY_MS)
314 }
315}
316
317
318
319
320
321
322/**
323 * The digest, fetched now when session start missed it. Runs before the recall gate so a server
324 * that connected late still gets its digest with recall off. Answers 'not_connected' when the
325 * server is absent; any other answer lets the caller go on.
326 */
327const ensureDigest = async ($: Dollar): Promise<'ok' | 'not_connected'> => {
328 if ((await read($, digestRef)) !== null) return 'ok'
329 if (!(await gate($))) return 'ok'
330 return (await bootstrap($)) === 'not_connected' ? 'not_connected' : 'ok'
331}
332
333const recallBlock = async ($: Dollar, text: string, originKind: string | undefined): Promise<string> => {
334 if (recallStopped || !isPersonPrompt(text, originKind)) return ''
335 if ((await ensureDigest($)) === 'not_connected') {
336 noteNotConnectedPrompt($)
337 return ''
338 }
339
340 // The reminder keeps its own interval and does not depend on recall: every prompt from the person
341 // counts, a short one too. With no search to ride on it goes out in its own wrapper, since the
342 // recall wrapper's data note would tell the model to ignore it (spec 9.2).
343 const prompts = await update($, promptsRef, (n) => n + 1)
344 const nudge = nudgeDue(prompts, cfg.reviewInterval)
345 if (!cfg.recall || !isEligible(text, originKind)) return nudge ? buildReminderBlock() : ''
346
347 // A search that fails still carries the reminder, in the recall block with its data note.
348 const nudgeOnly = buildRecallBlock('', nudge)
349 if (!(await gate($))) return nudgeOnly
350
351 const project = await projectFor($)
352 const query = clipQuery(text)
353 const args: Record<string, unknown> = { query, limit: cfg.recallLimit }
354 if (project !== undefined) args.project = project
355 const namespaces = searchNamespaces(project, extrasFor(cfg.recallExtraProjects, project))
356 if (namespaces !== undefined) args.namespaces = namespaces
357 const outcome = await callTool(mcpDeps($), serverName(), 'memory_search', args, cfg.recallTimeoutMs)
358 await record($, outcome)
359 if (outcome.kind === 'not_connected') {
360 noteNotConnectedPrompt($)
361 return ''
362 }
363 if (outcome.kind !== 'ok') return nudgeOnly
364 serverAbsent = false
365 notConnectedPrompts = 0
366
367 const hits = hitsFrom(outcome.data)
368 const seen = new Set(await read($, seenRef))
369 const { block, kept } = selectHits(hits, seen, { maxChars: cfg.recallMaxChars, nudge, minSimilarity: cfg.recallMinSimilarity })
370 if (kept.length > 0) await update($, seenRef, (ids) => [...ids, ...kept.map((h) => h.id)])
371 const at = await $.clock.now()
372 await update($, lastRecallRef, () => ({ query, hits: kept.map(toRecalled), returned: hits.length, ms: outcome.ms, at }))
373 await update($, statsRef, (s) => ({ ...s, recalls: s.recalls + 1, hitsAttached: s.hitsAttached + kept.length, lastMs: outcome.ms }))
374 return buildRecallBlock(block, nudge)
375}
376
377
378/** Where a path lands once links resolve; undefined when it does not exist yet. */
379const realPathOf = ($: Dollar, target: string): Promise<string | undefined> =>
380 $.fs.stat(target, { resolve: true }).then(
381 (s) => s.realPath,
382 () => undefined,
383 )
384
385const guardsPath = async ($: Dollar, e: Readonly<Record<string, unknown>>): Promise<boolean> => {
386 const tool = String(e.tool)
387 if (!cfg.replaceBuiltinMemory || !GUARDED_TOOLS.has(tool)) return false
388 const path = pathArg(tool, e)
389 if (path === undefined) return false
390 const home = await homeDir($)
391 if (home === '') return false
392 const spelled = normalizePath(path, home, await $.session.cwd())
393 if (isBuiltinMemoryPath(spelled, home)) return true
394 // A link outside the folder can lead into it; stat answers where the path lands.
395 const real = await realPathOf($, path)
396 if (real !== undefined) return isBuiltinMemoryPath(real, home)
397 // A file about to be created has no stat; its folder may be the link.
398 const split = parentOf(path.startsWith('~') || !path.startsWith('/') ? spelled : path)
399 if (split === null || split.base === '' || split.base === '..') return false
400 const realParent = await realPathOf($, split.parent)
401 return realParent !== undefined && isBuiltinMemoryPath(`${realParent.replace(/\/+$/, '')}/${split.base}`, home)
402}
403
404
405/** Stored rows that read like `fact`; any failure answers [] so the write goes ahead plain. */
406const findSimilarRows = async ($: Dollar, fact: Fact): Promise<Conflict[]> => {
407 const outcome = await callTool(mcpDeps($), serverName(), 'memory_search', { query: fact.content, namespaces: [fact.namespace], limit: 3 }, cfg.recallTimeoutMs)
408 if (outcome.kind !== 'ok') return []
409 const out: Conflict[] = []
410 for (const hit of hitsFrom(outcome.data)) {
411 if (typeof hit.similarity === 'number' && hit.similarity >= SIMILAR_MIN) {
412 out.push({ id: hit.id, namespace: typeof hit.namespace === 'string' ? hit.namespace : fact.namespace, content: hit.content, similarity: hit.similarity })
413 }
414 }
415 return out
416}
417
418const extract = async ($: Dollar, signal?: AbortSignal): Promise<void> => {
419 if (isExtracting) return
420 isExtracting = true
421 try {
422 const all = await $.session.messages()
423 let from = await read($, extractedRef)
424 // The list shrinks after a /clear or a compaction; a stale index would skip new turns.
425 if (from > all.length) from = 0
426 // The plugin's own recall rows read as user turns; turnsFrom cuts them out.
427 const turns: Turn[] = turnsFrom(all.slice(from))
428 if (turns.length === 0) {
429 if (from !== all.length) await update($, extractedRef, () => all.length)
430 return
431 }
432
433 const project = (await projectFor($)) ?? null
434 const asked = await $.model.complete(
435 { model: cfg.extractorModel, prompt: buildExtractPrompt(turns, project), maxTokens: 2000, timeoutMs: EXTRACT_MODEL_TIMEOUT_MS },
436 signal === undefined ? undefined : { signal },
437 )
438 if (!asked.isAnswered) {
439 debug($, `the extractor model gave no answer: ${asked.reason}`)
440 return
441 }
442
443 const deps: WriteDeps = {
444 write: (args) => callTool(mcpDeps($), serverName(), 'memory_write', args, WRITE_TIMEOUT_MS),
445 store: { get: (key) => $.store.get(key), set: (key, value) => $.store.set(key, value) },
446 findSimilar: (fact) => findSimilarRows($, fact),
447 isOldVersion: async (fact, conflict) => {
448 const judged = await $.model.complete({ model: cfg.extractorModel, prompt: buildJudgePrompt(fact, conflict), maxTokens: 16, timeoutMs: JUDGE_MODEL_TIMEOUT_MS })
449 return judged.isAnswered && parseJudge(judged.text)
450 },
451 now: () => $.clock.now(),
452 }
453 let written = 0
454 let failed = 0
455 for (const fact of parseFacts(asked.text, project)) {
456 const result = await writeFact(deps, fact)
457 if (result.status === 'written') written += 1
458 if (result.status === 'refused') debug($, `the engine refused an extracted fact: ${result.error}`)
459 if (result.status === 'failed') {
460 failed += 1
461 debug($, `an extracted fact was not written: ${result.error}`)
462 }
463 }
464 if (written > 0) await update($, statsRef, (s) => ({ ...s, writes: s.writes + written }))
465 // A timeout or an unreachable server keeps the range open; the duplicate guard stops the others
466 // going out twice. A refused fact is terminal (the same text gets the same answer), so it
467 // does not hold the window.
468 if (failed === 0) await update($, extractedRef, () => all.length)
469 } finally {
470 isExtracting = false
471 }
472}
473
474const extractInBackground = ($: Dollar): void => {
475 // A dispatch's budget and signal end with it; a timer outlives it (reference.md, "Work that outlives a dispatch").
476 $.clock.after(0, () => {
477 extract($).catch((err: unknown) => logFailure($, 'extractor', err))
478 })
479}
480
481
482
483
484const importHint =
485 'lr-import found no lumberroom credential. Run `lumberroom login` and pick the Full profile, which carries the mayIngest grant, then run /lr-import again. Or save a token that has mayIngest: echo \'{"ingestToken":"<token>"}\' | claude plugin configure lumberroom-memory@lumberroom --values-stdin'
486const expiredHint = 'lr-import: the lumberroom CLI login has expired. Run `lumberroom whoami` to refresh it, then run /lr-import again.'
487
488/** The engine URL and bearer for /lr-import: the ingestToken option, else what the lumberroom CLI holds. */
489const ingestCredential = async ($: Dollar, home: string): Promise<IngestCredential | string> => {
490 const resolved = resolveIngest({
491 optionToken: cfg.ingestToken,
492 envUrl: await $.env.get('LUMBERROOM_URL'),
493 envToken: await $.env.get('LUMBERROOM_TOKEN'),
494 cliConfig: home === '' ? '' : await readText($, cliConfigPath(await $.env.get('LUMBERROOM_CONFIG'), home)),
495 now: await $.clock.now(),
496 })
497 if (resolved.ok) return resolved.credential
498 return resolved.reason === 'expired' ? expiredHint : importHint
499}
500
501/**
502 * Which folders under ~/.claude/projects belong to the current project: its root, cwd and git
503 * root, by name. A name over 200 characters carries a hash Claude Code adds, so those match on
504 * their first 200 characters.
505 */
506const currentMatcher = async ($: Dollar): Promise<{ current: string[]; isCurrent: (entry: string) => boolean }> => {
507 const cwd = await $.session.cwd()
508 const root = await $.session.root().catch(() => cwd)
509 const gitRoot = await findGitRoot(cwd, existsOn($))
510 const current = [...new Set([projectDirName(root), projectDirName(cwd), ...(gitRoot === null ? [] : [projectDirName(gitRoot)])])]
511 return { current, isCurrent: (entry) => current.some((name) => projectDirMatches(name, entry)) }
512}
513
514/** The current project's memory folders, found by listing ~/.claude/projects when a name is too long to spell. */
515const currentFolders = async ($: Dollar, home: string): Promise<{ dir: string; slug: string | null }[]> => {
516 const projects = `${home}/.claude/projects`
517 const slug = (await projectFor($)) ?? null
518 const { current } = await currentMatcher($)
519 const listed = current.some((name) => name.length > PROJECT_DIR_KEPT) ? await $.fs.list(projects).catch(() => []) : []
520 const names = listed.filter((entry) => entry.kind === 'dir').map((entry) => entry.name)
521 const folders = new Set(current.flatMap((name) => (name.length > PROJECT_DIR_KEPT ? names.filter((entry) => projectDirMatches(name, entry)) : [name])))
522 return [...folders].map((name) => ({ dir: `${projects}/${name}/memory`, slug }))
523}
524
525type FolderFiles = { path: string; file: MemoryFile }[]
526
527/** The parseable memory files in one memory folder; [] when the folder is absent. */
528const readMemoryFiles = async ($: Dollar, dir: string): Promise<FolderFiles> => {
529 const listed = await $.fs.list(dir).catch(() => [])
530 const files: FolderFiles = []
531 for (const entry of listed) {
532 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
533 const path = `${dir}/${entry.name}`
534 const file = parseMemoryFile(entry.name, await readText($, path))
535 if (file !== null) files.push({ path, file })
536 }
537 return files
538}
539
540/** One ingest run for the given groups. The proposal facts are built inside the run, once its id exists. */
541const postGroups = ($: Dollar, cred: IngestCredential, groups: { slug: string | null; files: FolderFiles }[], count: number) =>
542 postProposals(
543 {
544 fetch: async (url, init) => {
545 const r = await $.http.fetch(url, init)
546 return { status: r.status, ok: r.ok, text: r.text }
547 },
548 sleep: (ms) => $.clock.sleep(ms),
549 },
550 cred.baseUrl,
551 cred.token,
552 async (runId) => (await Promise.all(groups.map((g) => toProposalFacts(g.files, g.slug, runId)))).flat(),
553 count,
554 )
555
556const describeReport = (r: ImportReport): string =>
557 `${r.posted} posted (${r.proposalsNew} new, ${r.proposalsReinforced} reinforced, ${r.confirmations} confirmed, ${r.refused} refused, ${r.blocked} blocked) from ${r.files} files`
558
559/**
560 * The namespace to propose for one folder. A folder name does not say where a path segment ends,
561 * so the path is rebuilt first, with a bounded number of $.fs.exists probes, and its git root's
562 * slug wins. Only a folder with no findable path costs a model call, since the answer would lose
563 * to the path anyway. A bad model answer is global, never a guess the person did not see.
564 */
565const proposeNamespace = async ($: Dollar, folder: string, files: MemoryFile[]): Promise<Pick<PlanEntry, 'slug' | 'how' | 'reason'>> => {
566 const exists = existsOn($)
567 const decoded = folder.length > PROJECT_DIR_KEPT ? null : await decodeProjectDir(folder, exists)
568 if (decoded !== null) {
569 const root = (await findGitRoot(decoded, exists)) ?? decoded
570 const slug = slugFromPath(root)
571 if (slug !== '') return { slug, how: 'path', reason: root === decoded ? `found ${decoded}` : `found ${decoded}, git root ${root}` }
572 }
573 try {
574 const asked = await $.model.complete({
575 model: cfg.extractorModel,
576 prompt: buildNamespacePrompt(folder, files),
577 maxTokens: 32,
578 timeoutMs: JUDGE_MODEL_TIMEOUT_MS,
579 })
580 if (!asked.isAnswered) return { slug: null, how: 'fallback', reason: `no path found and the model gave no answer (${asked.reason}), so global` }
581 const answer = parseNamespaceAnswer(asked.text)
582 if (answer === null) return { slug: null, how: 'fallback', reason: 'no path found and the model answered with something other than a slug or global, so global' }
583 if (answer === 'global') return { slug: null, how: 'model', reason: 'no path found; the model judged these memories to belong to no one project' }
584 return { slug: answer, how: 'model', reason: `no path found; the model proposed ${answer}` }
585 } catch (err) {
586 return { slug: null, how: 'fallback', reason: `no path found and the model call failed (${messageOf(err)}), so global` }
587 }
588}
589
590/** Every folder under ~/.claude/projects with parseable memory files, in name order, each with a proposed namespace. */
591const buildPlan = async ($: Dollar, home: string): Promise<PlanEntry[]> => {
592 const projects = `${home}/.claude/projects`
593 const listed = await $.fs.list(projects).catch(() => [])
594 const names = listed.filter((entry) => entry.kind === 'dir').map((entry) => entry.name).sort()
595 const slug = (await projectFor($)) ?? null
596 const { isCurrent } = await currentMatcher($)
597 const entries: PlanEntry[] = []
598 for (const folder of names) {
599 const files = await readMemoryFiles($, `${projects}/${folder}/memory`)
600 if (files.length === 0) continue
601 const chosen = isCurrent(folder)
602 ? { slug, how: 'current' as const, reason: slug === null ? "this session's project (none is sent, so global)" : "this session's project" }
603 : await proposeNamespace($, folder, files.map((f) => f.file))
604 entries.push({ folder, files: files.length, ...chosen, status: 'pending' })
605 }
606 return entries
607}
608
609const NO_PLAN = 'lr-import: no plan yet. Run /lr-import all first.'
610
611/** The plan row a `confirm` or `skip` target names: a 1-based number or an exact folder name. -1 when none. */
612const findEntry = (entries: readonly PlanEntry[], target: string): number => {
613 if (/^\d+$/.test(target)) {
614 const i = Number(target) - 1
615 return i >= 0 && i < entries.length ? i : -1
616 }
617 return entries.findIndex((e) => e.folder === target)
618}
619
620/** Posts one plan folder, with `slug` for its project and reference memories. */
621const postFolder = async ($: Dollar, cred: IngestCredential, home: string, entry: PlanEntry, slug: string | null): Promise<{ ok: boolean; line: string }> => {
622 const files = await readMemoryFiles($, `${home}/.claude/projects/${entry.folder}/memory`)
623 const where = `${entry.folder} -> ${namespaceLabel(slug)}`
624 if (files.length === 0) return { ok: false, line: `${where}: no memory files left to send` }
625 const report = await postGroups($, cred, [{ slug, files }], files.length)
626 if (report.error !== undefined) return { ok: false, line: `${where}: stopped: ${report.error}. ${describeReport(report)}` }
627 return { ok: true, line: `${where}: ${describeReport(report)}` }
628}
629
630const runConfirm = async ($: Dollar, cred: IngestCredential, home: string, target: string, namespace: string | undefined): Promise<string> => {
631 const entries = parsePlan(await $.store.get(PLAN_KEY))
632 if (entries.length === 0) return NO_PLAN
633 const save = (): Promise<void> => $.store.set(PLAN_KEY, entries)
634
635 if (target === 'all') {
636 const lines: string[] = []
637 let stopped = false
638 for (const entry of entries) {
639 if (entry.status !== 'pending') continue
640 const result = await postFolder($, cred, home, entry, entry.slug)
641 lines.push(result.line)
642 if (!result.ok) {
643 stopped = true
644 break
645 }
646 entry.status = 'done'
647 await save()
648 }
649 if (lines.length === 0) return 'lr-import: nothing is pending. /lr-import plan shows the table.'
650 const left = entries.filter((e) => e.status === 'pending').length
651 const tail = stopped ? `Stopped at the first failure; ${left} still pending.` : 'They wait in the proposal queue for review.'
652 return ['lr-import confirm all:', ...lines, tail].join('\n')
653 }
654
655 const i = findEntry(entries, target)
656 const entry = entries[i]
657 if (entry === undefined) return `lr-import: "${target.slice(0, 80)}" is not in the plan. /lr-import plan shows the table.`
658 if (entry.status === 'done') return `lr-import: ${entry.folder} is already done. /lr-import all builds a new plan.`
659 let slug = entry.slug
660 if (namespace !== undefined) {
661 const parsed = parseNamespaceOverride(namespace)
662 if (!parsed.ok) return `lr-import: ${parsed.error}`
663 slug = parsed.slug
664 }
665 const result = await postFolder($, cred, home, entry, slug)
666 if (!result.ok) return `lr-import: ${result.line}`
667 entry.status = 'done'
668 await save()
669 return `lr-import: ${result.line}. They wait in the proposal queue for review.`
670}
671
672const runImport = async ($: Dollar, args: string): Promise<string> => {
673 const cmd = parseImportArgs(args)
674 if (cmd.kind === 'usage') return IMPORT_USAGE
675 const home = await homeDir($)
676 if (home === '') return 'lr-import: the home directory is unknown, so the memory folders cannot be found.'
677 // Building, showing and skipping a plan make no network call, so they need no credential.
678 const posts = cmd.kind === 'current' || cmd.kind === 'confirm'
679 const found = posts ? await ingestCredential($, home) : undefined
680 if (typeof found === 'string') return found
681 const unsafe = found === undefined ? null : checkBaseUrl(found.baseUrl)
682 if (unsafe !== null) return `lr-import: ${unsafe}`
683 const cred = found as IngestCredential
684
685 if (cmd.kind === 'build') {
686 const entries = await buildPlan($, home)
687 await $.store.set(PLAN_KEY, entries)
688 return formatPlan(entries)
689 }
690 if (cmd.kind === 'plan') {
691 const entries = parsePlan(await $.store.get(PLAN_KEY))
692 return entries.length === 0 ? NO_PLAN : formatPlan(entries)
693 }
694 if (cmd.kind === 'skip') {
695 const entries = parsePlan(await $.store.get(PLAN_KEY))
696 if (entries.length === 0) return NO_PLAN
697 const entry = entries[findEntry(entries, cmd.target)]
698 if (entry === undefined) return `lr-import: "${cmd.target.slice(0, 80)}" is not in the plan. /lr-import plan shows the table.`
699 if (entry.status === 'done') return `lr-import: ${entry.folder} is already done.`
700 entry.status = 'skipped'
701 await $.store.set(PLAN_KEY, entries)
702 return `lr-import: skipped ${entry.folder}. /lr-import confirm ${cmd.target} still posts it if you change your mind.`
703 }
704 if (cmd.kind === 'confirm') return runConfirm($, cred, home, cmd.target, cmd.namespace)
705
706 const groups: { slug: string | null; files: FolderFiles }[] = []
707 let count = 0
708 for (const { dir, slug } of await currentFolders($, home)) {
709 const files = await readMemoryFiles($, dir)
710 if (files.length > 0) groups.push({ slug, files })
711 count += files.length
712 }
713 if (count === 0) return 'lr-import: no memory files found to send.'
714
715 const report = await postGroups($, cred, groups, count)
716 const counts = describeReport(report)
717 if (report.error !== undefined) return `lr-import stopped: ${report.error}. ${counts}.`
718 return `lr-import: ${counts}. They wait in the proposal queue for review.`
719}
720
721
722/** How long /lr-setup keeps retrying context_bootstrap while Claude Code picks up the new rules. */
723const SETUP_RELOAD_MS = 3_000
724
725/** Fetches the digest after /lr-setup, retrying while the settings watcher catches up. */
726const loadDigestAfterSetup = async ($: Dollar): Promise<boolean> => {
727 const began = await $.clock.now()
728 while ((await bootstrap($)) !== 'ok') {
729 if ((await $.clock.now()) - began >= SETUP_RELOAD_MS) return false
730 await $.clock.sleep(BOOTSTRAP_RETRY_MS)
731 }
732 // A cached digest of the same project skips bootstrap's own invalidate.
733 $.ui.invalidate('prompt.section')
734 const section = estimateTokens(await sectionText($))
735 await addCost($, (c) => ({ ...c, section }))
736 return true
737}
738
739const runSetup = async ($: Dollar, rawArgs: string): Promise<string> => {
740 const args = rawArgs.trim()
741 if (args !== '' && args !== 'show') return SETUP_USAGE
742 const server = activeServer ?? (await serversToTry($))[0] ?? SERVER_CANDIDATES[0]
743 const rules = setupRules(server)
744 const configDir = ((await $.env.get('CLAUDE_CONFIG_DIR'))?.trim() || `${await homeDir($)}/.claude`).replace(/[\\/]+$/, '')
745 const path = `${configDir}/settings.json`
746 if (args === 'show') return `lr-setup: lumberroom runs as "${server}".\n${manualText(path, rules)}`
747
748 const merged = permissionLists(await $.settings.read())
749 const blocked = rules.filter((r) => missingRules([r], merged.deny).length === 0)
750 if (blocked.length > 0) return `lr-setup: a deny rule in your settings covers ${blocked.join(', ')}. An allow rule cannot override it; remove the deny rule in /permissions first.`
751
752 const missing = missingRules(rules, merged.allow)
753 if (missing.length > 0) {
754 let answer: string
755 try {
756 answer = await $.ui.ask(`Add ${missing.length} allow rule${missing.length === 1 ? '' : 's'} for lumberroom's own calls to ${path}?`, {
757 options: [ADD_LABEL, CANCEL_LABEL],
758 header: 'lr-setup',
759 })
760 } catch {
761 return `lr-setup: no one could be asked, so nothing changed.\n${manualText(path, missing)}`
762 }
763 if (answer !== ADD_LABEL) return `lr-setup: nothing changed.\n${manualText(path, missing)}`
764
765 let text = ''
766 if (await existsOn($)(path)) {
767 try {
768 const raw = await $.fs.read(path)
769 text = typeof raw === 'string' ? raw : ''
770 } catch (err) {
771 return `lr-setup: could not read ${path} (${messageOf(err)}), so nothing changed.\n${manualText(path, missing)}`
772 }
773 }
774 const result = mergeAllow(text, missing)
775 if (!result.ok) return `lr-setup: left ${path} alone because ${result.error}.\n${manualText(path, missing)}`
776 if (result.added.length > 0) await $.fs.write(path, result.text)
777 }
778
779 const added = missing.length === 0 ? 'The allow rules were already in place.' : `Added ${missing.join(', ')} to ${path}.`
780 if (await loadDigestAfterSetup($)) return `lr-setup: ${added} The digest is loaded and joins the system prompt from your next message.`
781 return `lr-setup: ${added} lumberroom has not answered yet. Start a new session; if the digest is still missing, check /mcp for "${server}".`
782}
783
784export const register: Register = (on, options) => {
785 cfg = readConfig(options)
786
787 // Resolves the person's project, fetches the digest and registers /lr-import.
788 on('session.start', async ($, e, next) => {
789 const res = await next(e)
790 try {
791 serverAbsent = false
792 notConnectedPrompts = 0
793 recallStopped = false
794 toldNotConnected = false
795 toldDenied = false
796 projectMemo = undefined
797 activeServer = undefined
798 try {
799 await $.command.register({ name: 'lr-import', description: "Send Claude Code's memory files to lumberroom's proposal queue", argumentHint: '[all | plan | confirm <n|folder|all> [namespace] | skip <n>]' })
800 await $.command.register({ name: 'lr-setup', description: "Allow the plugin's own lumberroom calls, then load the digest", argumentHint: '[show]' })
801 } catch (err) {
802 logFailure($, 'command.register', err)
803 }
804
805 const home = await homeDir($)
806 const root = (await findGitRoot(e.cwd, existsOn($))) ?? e.cwd
807 const claudeMd = [home === '' ? '' : await readText($, `${home}/.claude/CLAUDE.md`), await readText($, `${root}/CLAUDE.md`)]
808 hasRule = claudeMd.some(hasDurableMemoryBlock)
809
810 await bootstrapAtStart($)
811 // A failed bootstrap leaves the cached digest in the section.
812 if ((await read($, digestRef)) === null) {
813 const cached = await cachedDigest($)
814 if (cached !== null) {
815 await update($, digestRef, () => cached)
816 $.ui.invalidate('prompt.section')
817 }
818 }
819 // The engine caches the section, so prompt.compose may not run again after a reload; the
820 // line has to be drawn here or it stays blank until a count changes.
821 const section = serverAbsent ? 0 : estimateTokens(await sectionText($))
822 await addCost($, (c) => ({ ...c, section }))
823 } catch (err) {
824 logFailure($, 'session.start', err)
825 }
826 return res
827 })
828
829 // The digest goes in the system prompt, once per render and cached. prompt.submit context would
830 // persist in the transcript and be sent again on every later turn (spec 2.1).
831 on('prompt.compose', async ($, e, next) => {
832 const res = await next(e)
833 try {
834 if (res.sections.some((s) => s.id === SECTION_ID)) return res
835 // With no server, a cached digest still fills the section.
836 if (serverAbsent && (await read($, digestRef)) === null) return res
837 const text = await sectionText($)
838 await addCost($, (c) => ({ ...c, section: estimateTokens(text) }))
839 if (text === '') return res
840 return { sections: [...res.sections, { id: SECTION_ID, text, scope: 'session' as const }] }
841 } catch (err) {
842 logFailure($, 'prompt.compose', err)
843 return res
844 }
845 })
846
847 on('prompt.section', { name: 'memory' }, ($, e, next) => (cfg.replaceBuiltinMemory ? { text: null } : next(e)))
848
849 // next() sits outside the try block: a failure beneath the plugin must not run the chain twice.
850 on('prompt.submit', async ($, e, next) => {
851 let block = ''
852 try {
853 block = await recallBlock($, e.text, e.origin?.kind)
854 } catch (err) {
855 logFailure($, 'prompt.submit', err)
856 }
857 // Every prompt redraws the line, so a status cleared by a reload comes back on the next prompt.
858 await addCost($, (c) => (block === '' ? c : { ...c, blocks: c.blocks + estimateTokens(block) }))
859 if (block === '') return next(e)
860 return next({ ...e, context: [...(e.context ?? []), block] })
861 })
862
863 on('tool.call', async ($, e, next) => {
864 let isDenied = false
865 try {
866 isDenied = await guardsPath($, e as unknown as Readonly<Record<string, unknown>>)
867 } catch (err) {
868 logFailure($, 'tool.call', err)
869 }
870 if (isDenied) return { deny: GUARD_REASON }
871 // The model's own lumberroom calls; the plugin's $.mcp.call results never enter the transcript.
872 if (!knownServers().some((server) => isServerTool(e.tool, server)) || next.origin.plugin === PLUGIN) return next(e)
873 const res = await next(e)
874 if (res.deny === undefined) await addCost($, (c) => ({ ...c, tools: c.tools + estimateTokens(res.text ?? ''), toolCalls: c.toolCalls + 1 }))
875 return res
876 })
877
878 on('turn.complete', async ($, e, next) => {
879 const res = await next(e)
880 try {
881 if (cfg.extractor === 'turn' && !serverAbsent && e.agentId === undefined && e.reason === 'answer') {
882 // A review interval of 0 turns the nudge off; the extractor then runs at the default cadence.
883 const every = cfg.reviewInterval > 0 ? cfg.reviewInterval : DEFAULTS.reviewInterval
884 if ((await $.session.turns()) % every === 0) extractInBackground($)
885 }
886 } catch (err) {
887 logFailure($, 'turn.complete', err)
888 }
889 return res
890 })
891
892 on('session.end', async ($, e, next) => {
893 try {
894 if (cfg.extractor === 'session-end' && !serverAbsent && next.budget.remainingMs >= SESSION_END_MIN_MS) {
895 await extract($, next.signal)
896 }
897 } catch (err) {
898 logFailure($, 'session.end', err)
899 }
900 const res = await next(e)
901 // /clear starts a conversation with no session.start. Hits sent before it are gone from the
902 // model's context, so they may attach again.
903 if (e.reason === 'clear') await resetDedup($)
904 return res
905 })
906
907 on('session.compact', async ($, e, next) => {
908 try {
909 if (cfg.extractor !== 'off' && !serverAbsent && e.agentId === undefined && e.trigger !== 'precompute') {
910 // The model call takes a signal; the wait for it does not count against the hook budget,
911 // so a hung call would hold compaction. On the bound the window stays where it was and the
912 // next extraction reads those turns again.
913 const stop = typeof AbortController === 'undefined' ? undefined : new AbortController()
914 const raced = await raceSleep(extract($, stop?.signal), (ms) => $.clock.sleep(ms), COMPACT_EXTRACT_BOUND_MS)
915 if (raced.timedOut) {
916 stop?.abort()
917 debug($, `the extraction before compaction passed ${COMPACT_EXTRACT_BOUND_MS} ms, compacting without it`)
918 }
919 }
920 } catch (err) {
921 logFailure($, 'session.compact', err)
922 }
923 const res = await next({ ...e, instructions: [e.instructions, COMPACT_LINE].filter(Boolean).join('\n\n') })
924 // Compaction replaces the transcript that held the recall blocks, so they may attach again.
925 // A precompute installs nothing, a veto leaves the transcript as it was, and a subagent's
926 // compaction is not the main conversation's.
927 if (e.trigger !== 'precompute' && e.agentId === undefined && res.skip === undefined) await resetDedup($)
928 return res
929 })
930
931 on('command.run', { command: 'lr-setup' }, async ($, e) => {
932 try {
933 return { text: await runSetup($, e.args) }
934 } catch (err) {
935 logFailure($, 'command.run', err)
936 return { text: `lr-setup failed: ${messageOf(err)}` }
937 }
938 })
939
940 on('command.run', { command: 'lr-import' }, async ($, e) => {
941 try {
942 return { text: await runImport($, e.args) }
943 } catch (err) {
944 logFailure($, 'command.run', err)
945 return { text: `lr-import failed: ${messageOf(err)}` }
946 }
947 })
948}
949src/breaker.ts 36 lines1// The circuit breaker from lumberroom-openclaw src/recall.ts, as pure functions over plain data so
2// its state can live in $.state and survive a hot reload.
3
4import type { LumberroomBreaker } from '../types'
5
6export const BREAKER_THRESHOLD = 3
7export const BREAKER_COOLDOWN_MS = 60_000
8
9export const CLOSED: LumberroomBreaker = { failures: 0, openedAt: null }
10
11/**
12 * Whether a call may go out at `now`. An open breaker past its cooldown closes: the result then
13 * carries the reset state and `allowed: true`.
14 */
15export function allow(state: LumberroomBreaker, now: number): { allowed: boolean; state: LumberroomBreaker } {
16 if (state.openedAt === null) return { allowed: true, state }
17 if (now - state.openedAt >= BREAKER_COOLDOWN_MS) return { allowed: true, state: CLOSED }
18 return { allowed: false, state }
19}
20
21export function success(): LumberroomBreaker {
22 return CLOSED
23}
24
25/**
26 * Records a failure. `startsOutage` is true for the first failure after a success or a reset, so
27 * the caller tells the person once per outage. The breaker opens at BREAKER_THRESHOLD.
28 */
29export function failure(state: LumberroomBreaker, now: number): { state: LumberroomBreaker; startsOutage: boolean } {
30 const failures = state.failures + 1
31 return {
32 state: { failures, openedAt: failures >= BREAKER_THRESHOLD ? now : state.openedAt },
33 startsOutage: state.failures === 0,
34 }
35}
36src/config.ts 128 lines1// The plugin's options, read once per load. Every userConfig field has a default here too, so a
2// value the manifest's validation let through still lands inside its range.
3
4import { slugFromPath } from './project'
5
6export type ExtractorMode = 'off' | 'turn' | 'session-end'
7
8/**
9 * Parsed `recallExtraProjects`. Claude Code reads plugin options from user, --settings or managed
10 * settings only, never from project settings, so a per-project value lives inside the option.
11 */
12export interface RecallExtras {
13 /** Slugs searched in every project. */
14 everywhere: string[]
15 /** Project slug -> slugs searched only when that project is current. */
16 byProject: Record<string, string[]>
17}
18
19export interface Config {
20 /** `auto`, `none`, or a slug. */
21 project: string
22 /** Off by default: each recall block stays in context for the rest of the session (spec 2.1). */
23 recall: boolean
24 /** Other projects to search with each prompt, in the engine's slug form, deduplicated. May hold the current project's own slug; `extrasFor` resolves it. */
25 recallExtraProjects: RecallExtras
26 recallLimit: number
27 recallMaxChars: number
28 /** Hits with a similarity below this are dropped. Hits that carry none pass. */
29 recallMinSimilarity: number
30 recallTimeoutMs: number
31 bootstrapTimeoutMs: number
32 digestMaxChars: number
33 reviewInterval: number
34 replaceBuiltinMemory: boolean
35 extractor: ExtractorMode
36 extractorModel: string
37 /** Absent when unset or blank. Without it /lr-import uses the lumberroom CLI's credential. */
38 ingestToken?: string
39}
40
41export const DEFAULTS: Config = {
42 project: 'auto',
43 recall: false,
44 recallExtraProjects: { everywhere: [], byProject: {} },
45 recallLimit: 6,
46 recallMaxChars: 4000,
47 recallMinSimilarity: 0.6,
48 recallTimeoutMs: 2500,
49 bootstrapTimeoutMs: 5000,
50 digestMaxChars: 8000,
51 reviewInterval: 8,
52 replaceBuiltinMemory: true,
53 extractor: 'off',
54 extractorModel: 'haiku',
55}
56
57/**
58 * The extra slugs to search in `currentSlug`: `everywhere` plus that project's own entry,
59 * deduplicated, without the current slug itself. `currentSlug` is undefined with project `none`,
60 * so only `everywhere` applies.
61 */
62export function extrasFor(extras: RecallExtras, currentSlug: string | undefined): string[] {
63 // hasOwn keeps a project named `constructor` or `toString` from reading Object.prototype.
64 const own = currentSlug !== undefined && Object.hasOwn(extras.byProject, currentSlug) ? (extras.byProject[currentSlug] ?? []) : []
65 return [...new Set([...extras.everywhere, ...own])].filter((slug) => slug !== currentSlug)
66}
67
68/**
69 * Options as `register` receives them -> Config. A value of the wrong type, or a number outside
70 * the manifest's min and max, falls back to the default or is clamped. Never throws.
71 */
72export function readConfig(options: Readonly<Record<string, unknown>>): Config {
73 const str = (v: unknown, d: string): string => (typeof v === 'string' && v.trim() !== '' ? v.trim() : d)
74 const bool = (v: unknown, d: boolean): boolean => (typeof v === 'boolean' ? v : d)
75 // Mirrors min and max in .claude-plugin/plugin.json. Change both together.
76 const num = (v: unknown, d: number, min: number, max: number): number =>
77 typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, Math.round(v))) : d
78
79 // Similarity is a fraction, so this one skips the rounding `num` does.
80 const frac = (v: unknown, d: number, min: number, max: number): number =>
81 typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : d
82
83 // Split on commas and whitespace, then slug each entry the way the engine does, so a name the
84 // engine would reject or rewrite never reaches memory_search as a namespace. `a=b+c` scopes b
85 // and c to project a; a malformed entry (`=x`, `x=`, `a=b=c`) is dropped whole.
86 const extras = (v: unknown): RecallExtras => {
87 const everywhere = new Set<string>()
88 const scoped = new Map<string, Set<string>>()
89 for (const entry of typeof v === 'string' ? v.split(/[,\s]+/) : []) {
90 const sides = entry.split('=')
91 if (sides.length === 1) {
92 const slug = slugFromPath(entry)
93 if (slug !== '') everywhere.add(slug)
94 continue
95 }
96 if (sides.length !== 2) continue
97 const project = slugFromPath(sides[0] ?? '')
98 const slugsOf = (sides[1] ?? '').split('+').map(slugFromPath).filter((x) => x !== '')
99 if (project === '' || slugsOf.length === 0) continue
100 const set = scoped.get(project) ?? new Set<string>()
101 for (const slug of slugsOf) set.add(slug)
102 scoped.set(project, set)
103 }
104 // fromEntries defines own properties, so a project named `__proto__` stays plain data.
105 return { everywhere: [...everywhere], byProject: Object.fromEntries([...scoped].map(([k, set]) => [k, [...set]])) }
106 }
107
108 const extractor = options.extractor
109 const token = typeof options.ingestToken === 'string' ? options.ingestToken.trim() : ''
110 const config: Config = {
111 project: str(options.project, DEFAULTS.project),
112 recall: bool(options.recall, DEFAULTS.recall),
113 recallExtraProjects: extras(options.recallExtraProjects),
114 recallLimit: num(options.recallLimit, DEFAULTS.recallLimit, 1, 20),
115 recallMaxChars: num(options.recallMaxChars, DEFAULTS.recallMaxChars, 500, 16000),
116 recallMinSimilarity: frac(options.recallMinSimilarity, DEFAULTS.recallMinSimilarity, 0, 1),
117 recallTimeoutMs: num(options.recallTimeoutMs, DEFAULTS.recallTimeoutMs, 500, 8000),
118 bootstrapTimeoutMs: num(options.bootstrapTimeoutMs, DEFAULTS.bootstrapTimeoutMs, 5000, 8000),
119 digestMaxChars: num(options.digestMaxChars, DEFAULTS.digestMaxChars, 1000, 30000),
120 reviewInterval: num(options.reviewInterval, DEFAULTS.reviewInterval, 0, 100),
121 replaceBuiltinMemory: bool(options.replaceBuiltinMemory, DEFAULTS.replaceBuiltinMemory),
122 extractor: extractor === 'turn' || extractor === 'session-end' ? extractor : 'off',
123 extractorModel: str(options.extractorModel, DEFAULTS.extractorModel),
124 }
125 if (token !== '') config.ingestToken = token
126 return config
127}
128src/cost.ts 45 lines1// The token counter on the status line: what lumberroom adds to the context the model reads.
2// Counts are estimates at four characters a token. The mod API counts tokens only for whole
3// context categories, so a per-block count has to be estimated.
4
5import { toolPrefix } from './server'
6
7export interface Cost {
8 /** The system prompt section, sent with every request. */
9 section: number
10 /** Recall and reminder blocks attached to prompts since the context last emptied. */
11 blocks: number
12 /** Results of the model's own calls to lumberroom tools since the context last emptied. */
13 tools: number
14 toolCalls: number
15}
16
17export const NO_COST: Cost = { section: 0, blocks: 0, tools: 0, toolCalls: 0 }
18
19export const CHARS_PER_TOKEN = 4
20
21export function estimateTokens(text: string): number {
22 return Math.ceil(text.length / CHARS_PER_TOKEN)
23}
24
25/** True for an MCP tool name served by `server`, as Claude Code spells it. */
26export function isServerTool(tool: string, server: string): boolean {
27 return tool.startsWith(toolPrefix(server))
28}
29
30/** Compaction and /clear drop the transcript; the section comes back with the next request. */
31export function afterContextReset(cost: Cost): Cost {
32 return { ...NO_COST, section: cost.section }
33}
34
35function short(tokens: number): string {
36 return tokens < 1000 ? String(tokens) : `${(tokens / 1000).toFixed(1)}k`
37}
38
39/** `lumberroom ~2.4k tokens in context: digest 2.0k, reminders 30, tools 400 (2 calls)`. */
40export function formatStatus(cost: Cost): string {
41 const total = cost.section + cost.blocks + cost.tools
42 const parts = [`digest ${short(cost.section)}`, `reminders ${short(cost.blocks)}`, `tools ${short(cost.tools)} (${cost.toolCalls} ${cost.toolCalls === 1 ? 'call' : 'calls'})`]
43 return `lumberroom ~${short(total)} tokens in context: ${parts.join(', ')}`
44}
45src/digest.ts 81 lines1// The system-prompt section: the write rule and the engine's digest, and the check that keeps the
2// CLAUDE.md block from doubling the rule (spec 5).
3
4export const SECTION_ID = 'lumberroom-memory:memory'
5export const DIGEST_HEADING = '# Durable memory (lumberroom)'
6
7/** ENG client/CLAUDE.md.snippet without its markers and its own heading. Keep in step with it. */
8export const WRITE_RULE = `You have a shared memory service (MCP server \`lumberroom\`) that persists across sessions, machines,
9and clients. It is the same store every one of this user's agents reads and writes.
10
11**Read.** This section already carries a memory digest. When a task depends on a past
12decision, a preference, a host, a credential location, or "how do we usually do this", call
13\`memory_search\` before asking the user or assuming. Call \`registry_get\` for exact operational
14values (hosts, service endpoints, where a credential lives).
15
16**Write.** After any exchange that establishes a decision, a preference, a constraint, or a
17durable fact, call \`memory_write\`. Without asking. Without announcing it. One fact per call,
18phrased so it stands alone in six months, carrying the numbers, identifiers, paths and dates the
19fact needs, and the cause, scope qualifier and reversal condition whenever the fact turns on them.
20Cut the trail of how you came to believe it: the search you ran, the file you read on the way, the
21argument for the claim. No hedges, no evaluative words, no restated context, no inventory of what
22you left unchanged. A list or a timeline runs long and that is right; prose about a short fact
23runs long and that is bloat. Two facts from one exchange are two calls.
24
25- \`user:me\`: facts about this user and how they work
26- \`project:<slug>\`: facts scoped to one codebase
27- \`global\`: facts true everywhere: infrastructure, conventions, credential locations
28
29Do not write transient chatter, file contents, secrets, or anything you would not want repeated
30back next month.`
31
32/**
33 * context_bootstrap's parsed data -> { text, memories }. `text` is data.text when a string, else
34 * data itself when a string (an engine that answered markdown), else "". `memories` is
35 * data.counts.memories when a number.
36 */
37export function digestFrom(data: unknown): { text: string; memories: number | null } {
38 if (typeof data === 'string') return { text: data, memories: null }
39 if (typeof data !== 'object' || data === null || Array.isArray(data)) return { text: '', memories: null }
40 const obj = data as { text?: unknown; counts?: unknown }
41 const text = typeof obj.text === 'string' ? obj.text : ''
42 const counts = obj.counts
43 const memories =
44 typeof counts === 'object' && counts !== null && typeof (counts as { memories?: unknown }).memories === 'number'
45 ? (counts as { memories: number }).memories
46 : null
47 return { text, memories }
48}
49
50/**
51 * The section text: DIGEST_HEADING, the project line when `project`, WRITE_RULE when
52 * `includeRule`, then the digest clipped so the whole section fits `maxChars`, cut at the last
53 * whole line. "" when there is no digest and no rule.
54 */
55export function buildSection(digest: string, opts: { includeRule: boolean; maxChars: number; project: string | null }): string {
56 const body = digest.trim()
57 if (body === '' && !opts.includeRule) return ''
58 const parts = [DIGEST_HEADING]
59 if (opts.project) parts.push(`Project: ${opts.project}`)
60 if (opts.includeRule) parts.push(WRITE_RULE)
61 const prefix = parts.join('\n\n')
62 const room = opts.maxChars - prefix.length - 2
63 if (body === '' || room <= 0) return opts.includeRule ? prefix : ''
64 let clipped = body
65 if (body.length > room) {
66 // Cut at a newline so a fact is never sent half-written; a digest line that alone exceeds
67 // the room is dropped whole.
68 const cut = body.lastIndexOf('\n', room)
69 clipped = cut > 0 ? body.slice(0, cut).trimEnd() : ''
70 }
71 if (clipped === '') return opts.includeRule ? prefix : ''
72 return `${prefix}\n\n${clipped}`
73}
74
75/** True when a CLAUDE.md holds a `# Durable memory` heading and names memory_write after it. */
76export function hasDurableMemoryBlock(claudeMd: string): boolean {
77 const heading = /^# Durable memory\b.*$/m.exec(claudeMd)
78 if (!heading) return false
79 return claudeMd.slice(heading.index + heading[0].length).includes('memory_write')
80}
81src/extractor.ts 168 lines1// The optional extractor's prompts and parsers. Pure text in, text out; register.ts runs the model.
2
3import type { Conflict, Fact } from './writes'
4
5/** One message as $.session.messages() answers it, reduced to what the prompt reads. */
6export interface Turn {
7 role: 'user' | 'assistant'
8 text: string
9}
10
11/** The recall block and its tags, as src/recall.ts writes them. */
12const RECALL_BLOCK = /<lumberroom-recall>[\s\S]*?<\/lumberroom-recall>/gi
13const RECALL_UNCLOSED = /<lumberroom-recall>[\s\S]*$/i
14const RECALL_STRAY_CLOSE = /<\/lumberroom-recall>/gi
15const EMPTY_WRAPPER = /<([A-Za-z][\w-]*)>\s*<\/\1>/g
16
17/**
18 * Text with every `<lumberroom-recall>` block cut out. The plugin attaches that block as a hidden
19 * user row, so `$.session.messages()` hands it back as something the person said, and an
20 * extractor that reads it would write recalled memories back as new facts.
21 */
22export function stripRecall(text: string): string {
23 return text.replace(RECALL_BLOCK, '').replace(RECALL_UNCLOSED, '').replace(RECALL_STRAY_CLOSE, '').replace(EMPTY_WRAPPER, '')
24}
25
26/** Messages -> turns: recall blocks removed, rows left empty dropped. */
27export function turnsFrom(messages: ReadonlyArray<{ role: 'user' | 'assistant'; text: string }>): Turn[] {
28 const turns: Turn[] = []
29 for (const m of messages) {
30 const text = stripRecall(m.text).trim()
31 if (text !== '') turns.push({ role: m.role, text })
32 }
33 return turns
34}
35
36export const EXTRACT_MAX_INPUT_CHARS = 24_000
37export const EXTRACT_MAX_FACTS = 10
38
39/**
40 * The prompt for $.model.complete. It carries the write rule's standards (one fact per line,
41 * standalone, identifiers and dates kept, no chatter, no secrets, no file contents), the
42 * namespaces allowed (`user:me`, `global`, `project:<slug>` when `project`), and the turns, newest
43 * kept when they overflow EXTRACT_MAX_INPUT_CHARS. It asks for JSON Lines of
44 * {"content","namespace","tags"} and the single word NONE when nothing qualifies.
45 */
46export function buildExtractPrompt(turns: readonly Turn[], project: string | null): string {
47 const namespaces = allowedNamespaces(project)
48 const label = (t: Turn) => `${t.role === 'user' ? 'User' : 'Assistant'}: ${t.text}`
49 // Walk from the newest turn back: recent turns hold the facts not yet stored.
50 const kept: string[] = []
51 let used = 0
52 for (let i = turns.length - 1; i >= 0; i--) {
53 const entry = label(turns[i] as Turn)
54 const room = EXTRACT_MAX_INPUT_CHARS - used
55 if (entry.length <= room) {
56 kept.unshift(entry)
57 used += entry.length + 2
58 } else {
59 // A single turn over the cap keeps its tail only when nothing newer was kept.
60 if (kept.length === 0) kept.unshift(entry.slice(entry.length - EXTRACT_MAX_INPUT_CHARS))
61 break
62 }
63 }
64 return [
65 'You extract durable facts from a conversation for a long-term memory store.',
66 'Write a fact only for a decision, preference, constraint or durable technical fact the conversation established.',
67 'Each fact stands alone and makes sense in six months: name the subject and keep numbers, identifiers, paths and dates.',
68 'One fact per line. No chatter, no restated context, no secrets or credentials, no file contents.',
69 '',
70 `Allowed namespaces: ${namespaces.join(', ')}.`,
71 'Answer in JSON Lines, one object per line: {"content": "...", "namespace": "...", "tags": ["..."]}.',
72 'Answer with the single word NONE when nothing qualifies. Add no other text.',
73 '',
74 'Conversation:',
75 kept.join('\n\n'),
76 ].join('\n')}
77
78/**
79 * Model text -> facts. Reads each line that parses as a JSON object with a non-empty string
80 * content and a namespace in the allowed set; tags kept when an array of strings. Ignores code
81 * fences, prose and NONE. At most EXTRACT_MAX_FACTS. A content that matches a credential pattern
82 * (private key block, `lr_` token, AWS key id, `password=`/`token=` assignment, a known token
83 * prefix with its minimum tail, a connection string with a password) is dropped.
84 */
85export function parseFacts(text: string, project: string | null): Fact[] {
86 const allowed = allowedNamespaces(project)
87 const facts: Fact[] = []
88 for (const raw of text.split('\n')) {
89 const line = raw.trim()
90 if (!line.startsWith('{')) continue
91 let obj: unknown
92 try {
93 obj = JSON.parse(line)
94 } catch {
95 continue
96 }
97 if (typeof obj !== 'object' || obj === null) continue
98 const { content, namespace, tags } = obj as Record<string, unknown>
99 if (typeof content !== 'string' || content.trim() === '') continue
100 if (typeof namespace !== 'string' || !allowed.includes(namespace)) continue
101 if (looksLikeCredential(content)) continue
102 const fact: Fact = { content, namespace }
103 if (Array.isArray(tags) && tags.every((t) => typeof t === 'string')) fact.tags = tags as string[]
104 facts.push(fact)
105 if (facts.length === EXTRACT_MAX_FACTS) break
106 }
107 return facts}
108
109/** Asks whether `conflict` is the older version of the same fact as `fact`; answer YES or NO. */
110export function buildJudgePrompt(fact: Fact, conflict: Conflict): string {
111 return [
112 'Two memory entries follow. Decide whether the OLD entry is an earlier version of the same fact as the NEW entry, so that the NEW one replaces it.',
113 'Answer YES when both state the same thing and the NEW one is current. Answer NO when they describe different things or both stay true.',
114 'Answer with one word, YES or NO.',
115 '',
116 `NEW: ${fact.content}`,
117 `OLD: ${conflict.content}`,
118 ].join('\n')}
119
120/** True only when the first word of the trimmed answer is YES, any case. */
121export function parseJudge(text: string): boolean {
122 const first = text.trim().split(/[^A-Za-z]+/)[0] ?? ''
123 return first.toUpperCase() === 'YES'}
124
125function allowedNamespaces(project: string | null): string[] {
126 return project ? ['user:me', 'global', `project:${project}`] : ['user:me', 'global']
127}
128
129// A fact that reaches the store is read back into every client, so a leaked secret spreads. These
130// patterns are a floor, not a scanner: the model is also told to leave secrets out. The prefix
131// rules mirror ENG src/domain/tripwire.rs PREFIX_RULES with the same minimum tails, because that
132// file's own notes show a bare prefix fires on prose ("keys start with sk-"). The engine refuses
133// these at `open`; a refused extractor write would pin its window (src/writes.ts), so the plugin
134// drops them first.
135const CREDENTIAL_PATTERNS: readonly RegExp[] = [
136 /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
137 /\blr_[A-Za-z0-9_-]{8,}/,
138 /\bAKIA[0-9A-Z]{16}\b/,
139 /\b(?:password|passwd|token|secret|api[_-]?key)\s*=\s*\S+/i,
140 // Each prefix needs a boundary before it and a long enough tail of token bytes after it.
141 /(?:^|[^A-Za-z0-9_-])(?:sk-ant-[A-Za-z0-9_-]{24}|sk-[A-Za-z0-9_-]{20}|github_pat_[A-Za-z0-9_-]{40}|gh[pousr]_[A-Za-z0-9_-]{30}|glpat-[A-Za-z0-9_-]{20}|xox[bpa]-[A-Za-z0-9_-]{20}|(?:sk|rk)_live_[A-Za-z0-9_-]{16}|hf_[A-Za-z0-9_-]{30}|npm_[A-Za-z0-9_-]{30})/,
142]
143
144const URL_WITH_USERINFO = /(?:^|[^A-Za-z0-9_-])(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql|rediss?|amqps?):\/\/([^\s/?#,"'`<>)\]]*)/gi
145
146/** Empty, interpolated (`$`, `{`, `}`), `<bracketed>` or a run of `*`: the engine's placeholder test. */
147function isPlaceholderPassword(password: string): boolean {
148 return password === '' || /[${}]/.test(password) || (password.startsWith('<') && password.endsWith('>')) || /^\*+$/.test(password)
149}
150
151function hasUrlPassword(content: string): boolean {
152 for (const m of content.matchAll(URL_WITH_USERINFO)) {
153 const authority = m[1] ?? ''
154 // Userinfo ends at the last `@` and the password starts at the first `:`, as in the engine.
155 const at = authority.lastIndexOf('@')
156 if (at < 0) continue
157 const userinfo = authority.slice(0, at)
158 const colon = userinfo.indexOf(':')
159 if (colon < 0) continue
160 if (!isPlaceholderPassword(userinfo.slice(colon + 1))) return true
161 }
162 return false
163}
164
165function looksLikeCredential(content: string): boolean {
166 return CREDENTIAL_PATTERNS.some((re) => re.test(content)) || hasUrlPassword(content)
167}
168src/guard.ts 62 lines1// Which file-tool calls touch Claude Code's built-in memory (spec 8).
2
3export const GUARD_REASON =
4 "Claude Code's built-in memory is off in this session. Use lumberroom: memory_search to read, memory_write to record."
5
6// Grep and Glob take a search root in `path`. They are absent from the 2.1.287 tool table this
7// plugin was built against, so the names are compared as strings and the guard sits idle until a
8// build that has them arrives. Bash is not guarded: a command line has no path argument to read.
9export const GUARDED_TOOLS: ReadonlySet<string> = new Set(['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Grep', 'Glob'])
10
11/** The path argument of a guarded tool's input (`file_path`, `notebook_path`, or a search tool's `path`), else undefined. */
12export function pathArg(tool: string, input: Readonly<Record<string, unknown>>): string | undefined {
13 if (!GUARDED_TOOLS.has(tool)) return undefined
14 const value = tool === 'NotebookEdit' ? input.notebook_path : tool === 'Grep' || tool === 'Glob' ? input.path : input.file_path
15 return typeof value === 'string' ? value : undefined
16}
17
18/**
19 * The folder part and the last segment of an absolute path, cut on the text alone (a `..` stays
20 * for the file system to resolve). Null for a relative path, which has no parent to stat.
21 */
22export function parentOf(path: string): { parent: string; base: string } | null {
23 if (!path.startsWith('/')) return null
24 const trimmed = path.replace(/\/+$/, '')
25 const cut = trimmed.lastIndexOf('/')
26 return { parent: cut <= 0 ? '/' : trimmed.slice(0, cut), base: trimmed.slice(cut + 1) }
27}
28
29/**
30 * `~` expanded with `home`, `.` and `..` segments resolved, duplicate slashes collapsed. For a
31 * path that may not exist yet, where $.fs.stat cannot give realPath. Relative paths resolve
32 * against `cwd`.
33 */
34export function normalizePath(path: string, home: string, cwd: string): string {
35 let full = path
36 // Only a bare `~` or `~/` means home. `~bob` is a relative name in the shell's eyes too once
37 // the shell is not the one expanding it.
38 if (full === '~' || full.startsWith('~/')) full = home + full.slice(1)
39 if (!full.startsWith('/')) full = `${cwd}/${full}`
40 const out: string[] = []
41 for (const seg of full.split('/')) {
42 if (seg === '' || seg === '.') continue
43 if (seg === '..') out.pop()
44 else out.push(seg)
45 }
46 return `/${out.join('/')}`
47}
48
49/**
50 * True for `<home>/.claude/MEMORY.md` and for anything under `<home>/.claude/projects/<any>/memory/`
51 * (the folder itself included, MEMORY.md there too). A MEMORY.md anywhere else under `.claude` is
52 * some plugin's or skill's file and stays readable. `path` is absolute and normalised.
53 */
54export function isBuiltinMemoryPath(path: string, home: string): boolean {
55 // Match whole segments: a prefix test would pass `projects-old` and `memory-old`.
56 const claude = `${home.replace(/\/+$/, '')}/.claude/`
57 if (!path.startsWith(claude)) return false
58 const segs = path.slice(claude.length).split('/').filter(s => s !== '')
59 if (segs.length === 1 && segs[0] === 'MEMORY.md') return true
60 return segs[0] === 'projects' && segs.length >= 3 && segs[2] === 'memory'
61}
62src/importer.ts 275 lines1// /lr-import: Claude Code's memory files -> the engine's proposal queue, the way
2// lumberroom-hermes importer.py does it (HP/importer.py:62-115). Never the live store.
3
4import { raceSleep } from './race'
5
6export const EXTRACTOR = 'claude-code-builtin-import'
7/** Never auto-approves (ENG src/services/ingest.rs:97-106). */
8export const SPEAKER = 'main_model'
9export const IMPORT_TAG = 'claude-code-import'
10export const BATCH_SIZE = 100
11/** One ingest call may take this long before the import reports a timeout and moves on. */
12export const FETCH_TIMEOUT_MS = 15_000
13/** The close gets a shorter bound of its own, so a dead server costs at most one more wait. */
14export const CLOSE_TIMEOUT_MS = 5_000
15
16export interface MemoryFile {
17 name: string
18 description: string
19 /** user | feedback | project | reference, or another string as written. */
20 type: string
21 body: string
22}
23
24export interface ProposalFact {
25 content: string
26 namespace: string
27 tags: string[]
28 speaker: string
29 span_text: string
30 source: { file_path: string; entry_uuid: string; run_id: string }
31}
32
33export interface ImportReport {
34 runId: string | null
35 files: number
36 posted: number
37 proposalsNew: number
38 proposalsReinforced: number
39 /** Facts the store had already emitted: confirmed, no proposal made. */
40 confirmations: number
41 refused: number
42 blocked: number
43 /** Set when the run stopped: a missing token, a 403, a transport failure. */
44 error?: string
45}
46
47export interface HttpDeps {
48 fetch: (url: string, init: { method: string; headers: Record<string, string>; body?: string }) => Promise<{ status: number; ok: boolean; text: string }>
49 /** $.clock.sleep. $.http.fetch takes no timeout, so each call races this. */
50 sleep: (ms: number) => Promise<void>
51}
52
53/** The characters of a project folder name that survive Claude Code's truncation. */
54export const PROJECT_DIR_KEPT = 200
55
56/**
57 * Claude Code's folder name for a project: every character outside `[a-zA-Z0-9]` becomes `-`
58 * (2.1.287 runs `replace(/[^a-zA-Z0-9]/g, "-")`). The result can be longer than the folder: the
59 * binary cuts a name over PROJECT_DIR_KEPT characters and appends a hash this plugin cannot
60 * reproduce, so match folders with projectDirMatches.
61 */
62export function projectDirName(cwd: string): string {
63 return cwd.replace(/[^a-zA-Z0-9]/g, '-')
64}
65
66/**
67 * Whether the folder `entry` under ~/.claude/projects belongs to the project named `name`. A short
68 * name must match whole. A name over PROJECT_DIR_KEPT characters matches on its first
69 * PROJECT_DIR_KEPT, because the folder carries those and a hash after them.
70 */
71export function projectDirMatches(name: string, entry: string): boolean {
72 if (name.length <= PROJECT_DIR_KEPT) return entry === name
73 return entry.startsWith(name.slice(0, PROJECT_DIR_KEPT))
74}
75
76/**
77 * Null when `baseUrl` may carry the ingest token: https, or http to localhost or 127.0.0.1. The
78 * bearer travels in every request, so a plain-http remote host would send it in the clear.
79 */
80export function checkBaseUrl(baseUrl: string): string | null {
81 const m = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)/.exec(baseUrl.trim())
82 const refusal = `the engine URL must be https (http is allowed for localhost and 127.0.0.1 only), so the ingest token is not sent in the clear. Got: ${baseUrl.trim().slice(0, 80) || '(empty)'}`
83 if (!m) return refusal
84 if ((m[1] as string).toLowerCase() === 'https') return null
85 if ((m[1] as string).toLowerCase() !== 'http') return refusal
86 // The host follows the last `@`, so userinfo naming localhost before it does not make the URL local.
87 const authority = m[2] as string
88 const host = authority.slice(authority.lastIndexOf('@') + 1).replace(/:\d*$/, '').toLowerCase()
89 return host === 'localhost' || host === '127.0.0.1' ? null : refusal
90}
91
92function unquote(value: string): string {
93 const v = value.trim()
94 if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) return v.slice(1, -1)
95 return v
96}
97
98/**
99 * A memory file (YAML-style frontmatter between `---` lines with name, description and either
100 * `type:` or `metadata:` -> `type:`) -> MemoryFile. Null for MEMORY.md, for a file with no
101 * frontmatter, or an empty body. A frontmatter-free fallback is not attempted.
102 */
103export function parseMemoryFile(fileName: string, text: string): MemoryFile | null {
104 const base = fileName.slice(fileName.lastIndexOf('/') + 1)
105 if (base === 'MEMORY.md') return null
106 const lines = text.replace(/\r\n?/g, '\n').replace(/^\uFEFF/, '').split('\n')
107 if (lines[0]?.trim() !== '---') return null
108 // The first `---` after the opener closes the block. A body may hold its own rule lines.
109 let close = -1
110 for (let i = 1; i < lines.length; i++) {
111 if ((lines[i] as string).trim() === '---') {
112 close = i
113 break
114 }
115 }
116 if (close < 0) return null
117
118 const top: Record<string, string> = {}
119 let metaType = ''
120 // Only `metadata:` is read as a nested block. A `type:` under any other key is not the type.
121 let block = ''
122 for (const line of lines.slice(1, close)) {
123 if (line.trim() === '') continue
124 const m = /^(\s*)([A-Za-z_][\w-]*):\s*(.*)$/.exec(line)
125 if (!m) continue
126 const indent = (m[1] as string).length
127 const key = m[2] as string
128 const value = unquote(m[3] as string)
129 if (indent === 0) {
130 block = value === '' ? key : ''
131 if (value !== '') top[key] = value
132 } else if (block === 'metadata' && key === 'type') {
133 metaType = value
134 }
135 }
136
137 const body = lines.slice(close + 1).join('\n').trim()
138 if (body === '') return null
139 const name = top.name ?? base.replace(/\.md$/, '')
140 return { name, description: top.description ?? '', type: metaType || top.type || '', body }
141}
142
143/** user and feedback -> user:me; project and reference -> project:<slug> (global with no slug); else global. */
144export function namespaceFor(type: string, slug: string | null): string {
145 const t = type.trim().toLowerCase()
146 if (t === 'user' || t === 'feedback') return 'user:me'
147 if (t === 'project' || t === 'reference') return slug ? `project:${slug}` : 'global'
148 return 'global'
149}
150
151/** Hex SHA-256 of the body through crypto.subtle. */
152export async function entryUuid(body: string): Promise<string> {
153 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))
154 return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
155}
156
157export async function toProposalFacts(files: ReadonlyArray<{ path: string; file: MemoryFile }>, slug: string | null, runId: string): Promise<ProposalFact[]> {
158 const facts: ProposalFact[] = []
159 for (const { path, file } of files) {
160 facts.push({
161 content: file.body,
162 namespace: namespaceFor(file.type, slug),
163 tags: [IMPORT_TAG],
164 speaker: SPEAKER,
165 span_text: file.body,
166 source: { file_path: path, entry_uuid: await entryUuid(file.body), run_id: runId },
167 })
168 }
169 return facts
170}
171
172const MISSING_GRANT =
173 'the credential lacks the mayIngest grant (HTTP 403). Run `lumberroom login --reregister` and pick the Full profile, or set the ingestToken option to a token with mayIngest.'
174
175function failure(status: number, text: string): string {
176 return status === 403 ? MISSING_GRANT : `lumberroom ingest call failed (HTTP ${status}): ${text.slice(0, 200)}`
177}
178
179function parseObject(text: string): Record<string, unknown> {
180 try {
181 const v: unknown = JSON.parse(text)
182 return typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
183 } catch {
184 return {}
185 }
186}
187
188function count(obj: Record<string, unknown>, key: string): number {
189 const v = obj[key]
190 return typeof v === 'number' ? v : 0
191}
192
193function describeError(e: unknown): string {
194 return `lumberroom ingest call failed: ${e instanceof Error ? e.message : String(e)}`
195}
196
197/**
198 * POST {baseUrl}/admin/ingest/runs {extractor, scope:{tool:"claude-code"}} -> run_id; the facts
199 * in batches of BATCH_SIZE to /admin/ingest/proposals {extractor, facts}; then
200 * /admin/ingest/runs/{id}/close. Bearer `token`. A base URL that fails checkBaseUrl sends nothing.
201 * A 403 stops with an error naming mayIngest; any other non-2xx stops with the status and the first
202 * 200 characters of the body. Every call races FETCH_TIMEOUT_MS (the close, CLOSE_TIMEOUT_MS) and
203 * a timeout is reported like any failure. The run is closed even after a failed batch.
204 */
205export async function postProposals(deps: HttpDeps, baseUrl: string, token: string, build: (runId: string) => Promise<ProposalFact[]>, files: number): Promise<ImportReport> {
206 const root = baseUrl.replace(/\/+$/, '')
207 const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
208 const report: ImportReport = { runId: null, files, posted: 0, proposalsNew: 0, proposalsReinforced: 0, confirmations: 0, refused: 0, blocked: 0 }
209 const unsafe = checkBaseUrl(baseUrl)
210 if (unsafe !== null) return { ...report, error: unsafe }
211
212 const send = async (path: string, body: unknown, bound: number) => {
213 const raced = await raceSleep(deps.fetch(`${root}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }), deps.sleep, bound)
214 if (raced.timedOut) throw new ImportTimeout(`POST ${path} timed out after ${bound / 1000} s`)
215 return raced.value
216 }
217
218 let runId: string
219 try {
220 const res = await send('/admin/ingest/runs', { extractor: EXTRACTOR, scope: { tool: 'claude-code' } }, FETCH_TIMEOUT_MS)
221 if (!res.ok) return { ...report, error: failure(res.status, res.text) }
222 const id = parseObject(res.text).run_id
223 if (typeof id !== 'string' || id === '') return { ...report, error: 'lumberroom opened a run but returned no run_id.' }
224 runId = id
225 } catch (e) {
226 return { ...report, error: describeError(e) }
227 }
228 report.runId = runId
229
230 let seen = 0
231 try {
232 const facts = await build(runId)
233 seen = facts.length
234 for (let start = 0; start < facts.length; start += BATCH_SIZE) {
235 const batch = facts.slice(start, start + BATCH_SIZE)
236 const res = await send('/admin/ingest/proposals', { extractor: EXTRACTOR, facts: batch }, FETCH_TIMEOUT_MS)
237 if (!res.ok) {
238 report.error = failure(res.status, res.text)
239 break
240 }
241 const out = parseObject(res.text)
242 report.posted += batch.length
243 report.proposalsNew += count(out, 'proposals_new')
244 report.proposalsReinforced += count(out, 'proposals_reinforced')
245 report.confirmations += count(out, 'confirmations')
246 report.refused += count(out, 'refused')
247 report.blocked += count(out, 'blocked')
248 }
249 } catch (e) {
250 report.error = describeError(e)
251 }
252
253 // Close on every path: a run with no finished_at stays in flight and keeps its fence open.
254 try {
255 const res = await send(
256 `/admin/ingest/runs/${runId}/close`,
257 {
258 files_seen: files,
259 entries_seen: seen,
260 proposals_new: report.proposalsNew,
261 proposals_reinforced: report.proposalsReinforced,
262 confirmations: report.confirmations,
263 },
264 CLOSE_TIMEOUT_MS,
265 )
266 if (!res.ok && report.error === undefined) report.error = failure(res.status, res.text)
267 } catch (e) {
268 if (report.error === undefined) report.error = describeError(e)
269 }
270 return report
271}
272
273/** A call that outlived its bound; the message names the call. */
274class ImportTimeout extends Error {}
275src/importplan.ts 208 lines1// The plan behind `/lr-import all`: which namespace each memory folder's project and reference
2// memories should land in. A folder name such as `-home-u-work-my-repo` does not say where one path
3// segment ends, so the plan finds the path when it exists and asks the model when it does not. The
4// person confirms each folder before anything is posted.
5
6import { slugFromPath } from './project'
7import type { Exists } from './project'
8
9/** How the proposed namespace was chosen. `fallback` is global after an unusable model answer. */
10export type PlanHow = 'current' | 'path' | 'model' | 'fallback'
11export type PlanStatus = 'pending' | 'done' | 'skipped'
12
13/** One memory folder in the plan. Kept in `$.store` under PLAN_KEY, so it survives a reload. */
14export interface PlanEntry {
15 /** The folder's name under ~/.claude/projects. */
16 folder: string
17 /** Parseable memory files when the plan was built. */
18 files: number
19 /** The slug for project and reference memories; null is global. user and feedback never use it. */
20 slug: string | null
21 how: PlanHow
22 reason: string
23 status: PlanStatus
24}
25
26export const PLAN_KEY = 'lr-import:plan'
27
28/** The most `exists` calls one folder name may cost. A deep path with many dashes stays well under it. */
29export const DECODE_BUDGET = 300
30/** Memory files named in the model prompt. */
31export const PROMPT_FILES = 5
32const DESCRIPTION_MAX = 200
33
34/** Characters that Claude Code's folder naming turned into `-`, tried in this order when a dash was inside a segment. */
35const JOINERS = ['-', '_', '.', ' ']
36
37/**
38 * Reads a project folder name back into an existing absolute path, or null. The name came from
39 * `replace(/[^a-zA-Z0-9]/g, "-")`, so a dash can be a slash or a character inside a segment. The
40 * walk descends only into folders that exist, tries the shortest segment first, and stops after
41 * `budget` probes. A double dash is read as a hidden folder (`/.config`). Mixed joiners inside one
42 * segment (`my-app_v2`) are not tried: the model's guess covers what this does not find.
43 */
44export async function decodeProjectDir(name: string, exists: Exists, budget: number = DECODE_BUDGET): Promise<string | null> {
45 if (!name.startsWith('-')) return null
46 const tokens = name.slice(1).split('-')
47 let left = budget
48
49 const walk = async (dir: string, i: number): Promise<string | null> => {
50 if (i >= tokens.length) return dir
51 // An empty token is the dash a `.` left behind: the next segment starts with a dot.
52 const dotted = tokens[i] === ''
53 const start = dotted ? i + 1 : i
54 const lead = dotted ? '.' : ''
55 for (let end = start + 1; end <= tokens.length; end++) {
56 const parts = tokens.slice(start, end)
57 if (parts.some((p) => p === '')) break
58 for (const joiner of end - start === 1 ? ['-'] : JOINERS) {
59 if (left <= 0) return null
60 left -= 1
61 const candidate = `${dir}/${lead}${parts.join(joiner)}`
62 if (!(await exists(candidate))) continue
63 const found = await walk(candidate, end)
64 if (found !== null) return found
65 }
66 }
67 return null
68 }
69
70 return tokens.length === 0 ? null : walk('', 0)
71}
72
73const clip = (text: string, max: number): string => {
74 const flat = text.replace(/\s+/g, ' ').trim()
75 return flat.length > max ? `${flat.slice(0, max)}...` : flat
76}
77
78/**
79 * The one question put to the model for a folder whose path was not found. File names and
80 * descriptions are data; the answer is validated by parseNamespaceAnswer, so a hostile description
81 * can at worst pick a wrong slug, which the person sees in the table before anything is posted.
82 */
83export function buildNamespacePrompt(folder: string, files: ReadonlyArray<{ name: string; description: string }>): string {
84 const lines = files.slice(0, PROMPT_FILES).map((f) => `- ${clip(f.name, 80)}: ${clip(f.description, DESCRIPTION_MAX)}`)
85 return [
86 "Claude Code keeps a project's memory files in a folder named after the project's path, with every character outside a-z, A-Z and 0-9 turned into a dash.",
87 "Name the project this folder belongs to as a short slug: its own folder name, lowercase, using letters, digits, dots, underscores and single dashes. If the memories are not about one project, answer global.",
88 'Reply with the slug or global and nothing else.',
89 '',
90 `Folder: ${folder}`,
91 'Memory files:',
92 ...lines,
93 ].join('\n')
94}
95
96const asciiLower = (text: string): string => text.replace(/[A-Z]/g, (c) => c.toLowerCase())
97
98/**
99 * The model's reply -> `global`, a slug, or null when the reply is neither. A slug passes only when
100 * the engine's slug rule leaves it unchanged, so a sentence, a path or a name with a double dash
101 * never becomes a namespace. Surrounding quotes and backticks are dropped; case is folded.
102 */
103export function parseNamespaceAnswer(text: string): string | null {
104 let t = text.trim()
105 const quote = t[0]
106 if (t.length >= 2 && (quote === '`' || quote === '"' || quote === "'") && t[t.length - 1] === quote) t = t.slice(1, -1).trim()
107 const lower = asciiLower(t)
108 if (lower === 'global') return 'global'
109 const slug = slugFromPath(lower)
110 return slug !== '' && slug === lower ? slug : null
111}
112
113export type OverrideResult = { ok: true; slug: string | null } | { ok: false; error: string }
114
115/** The namespace a person types on `/lr-import confirm <n> <namespace>`: a slug, `project:<slug>` or `global`. */
116export function parseNamespaceOverride(input: string): OverrideResult {
117 const raw = input.trim().replace(/^project:/i, '')
118 const answer = parseNamespaceAnswer(raw)
119 if (answer === null) {
120 return { ok: false, error: `"${clip(input, 60)}" is not a namespace. Use global, a project slug such as my-repo, or project:my-repo.` }
121 }
122 return { ok: true, slug: answer === 'global' ? null : answer }
123}
124
125export type ImportCommand =
126 | { kind: 'current' }
127 | { kind: 'build' }
128 | { kind: 'plan' }
129 | { kind: 'confirm'; target: string; namespace?: string }
130 | { kind: 'skip'; target: string }
131 | { kind: 'usage' }
132
133export const IMPORT_USAGE =
134 'Usage: /lr-import | /lr-import all | /lr-import plan | /lr-import confirm <n|folder> [namespace] | /lr-import confirm all | /lr-import skip <n>. With no argument it reads this project; "all" lists every project folder and proposes a namespace for each, and nothing is posted until you confirm.'
135
136export function parseImportArgs(args: string): ImportCommand {
137 const tokens = args.trim().split(/\s+/).filter((t) => t !== '')
138 const verb = asciiLower(tokens[0] ?? '')
139 if (tokens.length === 0) return { kind: 'current' }
140 if (tokens.length === 1 && verb === 'all') return { kind: 'build' }
141 if (tokens.length === 1 && verb === 'plan') return { kind: 'plan' }
142 if (verb === 'skip' && tokens.length === 2) return { kind: 'skip', target: tokens[1] as string }
143 if (verb === 'confirm' && (tokens.length === 2 || tokens.length === 3)) {
144 const first = tokens[1] as string
145 const target = asciiLower(first) === 'all' ? 'all' : first
146 const namespace = tokens[2]
147 if (namespace === undefined) return { kind: 'confirm', target }
148 return target === 'all' ? { kind: 'usage' } : { kind: 'confirm', target, namespace }
149 }
150 return { kind: 'usage' }
151}
152
153const STATUSES: ReadonlySet<string> = new Set(['pending', 'done', 'skipped'])
154const HOWS: ReadonlySet<string> = new Set(['current', 'path', 'model', 'fallback'])
155
156/** The stored plan -> entries. A value that is not a plan answers [], so a stale or damaged store reads as no plan. */
157export function parsePlan(raw: unknown): PlanEntry[] {
158 if (!Array.isArray(raw)) return []
159 const out: PlanEntry[] = []
160 for (const item of raw) {
161 if (typeof item !== 'object' || item === null) return []
162 const e = item as Record<string, unknown>
163 if (
164 typeof e.folder !== 'string' ||
165 typeof e.files !== 'number' ||
166 !(e.slug === null || typeof e.slug === 'string') ||
167 typeof e.how !== 'string' ||
168 !HOWS.has(e.how) ||
169 typeof e.reason !== 'string' ||
170 typeof e.status !== 'string' ||
171 !STATUSES.has(e.status)
172 ) {
173 return []
174 }
175 out.push({ folder: e.folder, files: e.files, slug: e.slug, how: e.how as PlanHow, reason: e.reason, status: e.status as PlanStatus })
176 }
177 return out
178}
179
180export const namespaceLabel = (slug: string | null): string => (slug === null ? 'global' : `project:${slug}`)
181
182const HOW_LABEL: Record<PlanHow, string> = {
183 current: 'current project',
184 path: 'path found',
185 model: 'model guess',
186 fallback: 'model guess unusable',
187}
188
189/** The numbered table `all` and `plan` answer with. Numbers stay fixed as folders finish, so `skip 2` means the same row all session. */
190export function formatPlan(entries: readonly PlanEntry[]): string {
191 if (entries.length === 0) return 'lr-import: no memory folders with parseable files were found under ~/.claude/projects.'
192 const rows = [
193 ['#', 'folder', 'files', 'namespace', 'chosen by', 'status'],
194 ...entries.map((e, i) => [String(i + 1), e.folder, String(e.files), namespaceLabel(e.slug), HOW_LABEL[e.how], e.status]),
195 ]
196 const widths = rows[0]!.map((_, c) => Math.max(...rows.map((r) => (r[c] as string).length)))
197 const table = rows.map((r) => r.map((cell, c) => cell.padEnd(widths[c] as number)).join(' ').trimEnd())
198 return [
199 'lr-import plan. Nothing is posted until you confirm. The namespace applies to project and reference memories; user and feedback memories go to user:me.',
200 '',
201 ...table,
202 '',
203 ...entries.map((e, i) => `${i + 1}: ${e.reason}`),
204 '',
205 'Next: /lr-import confirm <n|folder> [namespace], /lr-import confirm all, /lr-import skip <n>, /lr-import plan.',
206 ].join('\n')
207}
208src/mcp.ts 98 lines1// Every engine call goes through callTool: a timeout the mod API does not give us, result parsing
2// that copes with the payload arriving as JSON text (spec 2.2), and one outcome type the hooks
3// branch on.
4
5/** The part of $.mcp.call's result this module reads. */
6export interface RawResult {
7 content: ReadonlyArray<{ type: string; text?: string }>
8 isError: boolean
9 structuredContent?: unknown
10}
11
12export interface McpDeps {
13 call: (server: string, tool: string, args: Record<string, unknown>) => Promise<RawResult>
14 /** $.clock.sleep: the one wait that counts against the hook budget. */
15 sleep: (ms: number) => Promise<void>
16 now: () => Promise<number>
17}
18
19export type CallOutcome =
20 | { kind: 'ok'; data: unknown; text: string; ms: number }
21 /** The engine answered with isError: a validation or conflict message in `text`. */
22 | { kind: 'tool_error'; text: string; ms: number }
23 | { kind: 'timeout'; ms: number }
24 /** The call threw: no server by that name, connection refused, transport failure. */
25 | { kind: 'unreachable'; error: string; ms: number }
26 /** The permission chain refused the call ("haven't granted it", "denied"). */
27 | { kind: 'denied'; error: string; ms: number }
28 /** The server is not (yet) connected, absent, or has its tools removed by the user's settings. */
29 | { kind: 'not_connected'; error: string; ms: number }
30
31/**
32 * structuredContent when present; else the first text block parsed as JSON; else `data` is the
33 * raw text (an engine that answers markdown). `text` is always the first text block, or "".
34 */
35export function parseResult(raw: RawResult): { isError: boolean; data: unknown; text: string } {
36 const block = raw.content.find((b) => b.type === 'text' && typeof b.text === 'string')
37 const text = block?.text ?? ''
38 if (raw.structuredContent !== undefined && raw.structuredContent !== null) {
39 return { isError: raw.isError, data: raw.structuredContent, text }
40 }
41 try {
42 return { isError: raw.isError, data: JSON.parse(text), text }
43 } catch {
44 return { isError: raw.isError, data: text, text }
45 }
46}
47
48/**
49 * Claude Code 2.1.287 throws this while MCP servers are still connecting, and for a server the
50 * user never configured or whose tools their settings disallow (a disallowed tool leaves the
51 * engine): `no tool "context_bootstrap" on a server named "lumberroom"; servers with tools: ...`.
52 */
53const NOT_CONNECTED = [/no (connected )?(mcp )?tool "[^"]+" on a server named/i, /no (mcp )?server (found )?(named|with name)/i]
54
55/** A thrown error from $.mcp.call: a permission refusal, a server that is not connected, or an outage. */
56export function classifyError(err: unknown): 'denied' | 'unreachable' | 'not_connected' {
57 const msg = err instanceof Error ? err.message : String(err ?? '')
58 // `$.mcp.call(server, tool) refused: <reason>` is Claude Code's wording for a permission refusal.
59 if (/permission|haven'?t granted|denied|\) refused: /i.test(msg)) return 'denied'
60 return NOT_CONNECTED.some((re) => re.test(msg)) ? 'not_connected' : 'unreachable'
61}
62
63/**
64 * Races deps.call against deps.sleep(timeoutMs). The losing call keeps running and its result,
65 * or its rejection, is swallowed. `ms` is measured with deps.now.
66 */
67export async function callTool(
68 deps: McpDeps,
69 server: string,
70 tool: string,
71 args: Record<string, unknown>,
72 timeoutMs: number,
73): Promise<CallOutcome> {
74 const start = await deps.now()
75 type Settled = { r: RawResult } | { e: unknown } | { timeout: true }
76 // Both arms resolve, never reject, so the losing call cannot raise an unhandled rejection later.
77 const call: Promise<Settled> = (async () => deps.call(server, tool, args))().then(
78 (r) => ({ r }),
79 (e: unknown) => ({ e }),
80 )
81 const timer: Promise<Settled> = deps.sleep(timeoutMs).then(() => ({ timeout: true as const }))
82 const won = await Promise.race([call, timer])
83 const ms = (await deps.now()) - start
84 if ('timeout' in won) return { kind: 'timeout', ms }
85 if ('e' in won) {
86 const error = won.e instanceof Error ? won.e.message : String(won.e)
87 return { kind: classifyError(won.e), error, ms }
88 }
89 const parsed = parseResult(won.r)
90 if (parsed.isError) return { kind: 'tool_error', text: parsed.text, ms }
91 return { kind: 'ok', data: parsed.data, text: parsed.text, ms }
92}
93
94/** True for the outcomes that count as an outage: the breaker records them. */
95export function isOutage(outcome: CallOutcome): boolean {
96 return outcome.kind === 'timeout' || outcome.kind === 'unreachable'
97}
98src/own.ts 4 lines1// The plugin's own name, as Claude Code reports it in next.origin.plugin.
2
3export const OWN_PLUGIN = 'lumberroom-memory'
4src/race.ts 22 lines1// A bound on work that takes no signal. $.clock.sleep is the only wait a hook can use, so it is the
2// timer. The slower arm keeps running; its result, or its rejection, is dropped.
3
4export type Raced<T> = { timedOut: false; value: T } | { timedOut: true }
5
6/**
7 * Resolves with `work`'s value, or `{ timedOut: true }` once `sleep(ms)` finishes first. A
8 * rejection from `work` before the timer propagates; one after it is swallowed.
9 */
10export async function raceSleep<T>(work: Promise<T>, sleep: (ms: number) => Promise<void>, ms: number): Promise<Raced<T>> {
11 type Settled = { r: T } | { e: unknown } | { timeout: true }
12 const arm: Promise<Settled> = work.then(
13 (r) => ({ r }),
14 (e: unknown) => ({ e }),
15 )
16 const timer: Promise<Settled> = sleep(ms).then(() => ({ timeout: true as const }))
17 const won = await Promise.race([arm, timer])
18 if ('timeout' in won) return { timedOut: true }
19 if ('e' in won) throw won.e
20 return { timedOut: false, value: won.r }
21}
22