SLOPSHOPPER

Neo4j Memory

Persistent memory for Claude Code in a local SQLite file (node:sqlite; no server, no container). Injects standing facts and a subsystem map at session start…

panecommandprocess
★ 1v0.8.1no licenseupdated 2026-10-22troglodyte/claude-sqlite-mem
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neo4j-memory
│ ┃ Memory stats ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] [ All projects ] updated 8:53:20 │ ┃ ⏺ Read(src/auth.ts) │ ┃ No report yet. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memory-stats │ ⎿ neo4j-memory: Opened the memory stats pane. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Memory stats
[ Refresh ] [ All projects ] updated 8:53:20 AM No report yet.
README

neo4j-memory

A Claude Code plugin that gives Claude persistent memory in a local SQLite file, ~/.claude-neo4j/memory.sqlite, through Node's built-in node:sqlite: no server, no container, no embedding API. Facts, decisions and preferences are stored as entities, observations and relations, scoped per project (by git remote), and recalled across sessions. Session start injects the standing facts and a subsystem index, capture saves new facts at compaction and session end, and an MCP server exposes search and edit tools.

Despite the name, Neo4j is now only the place a machine's existing memory moves from, once. The plugin, its MCP server (neo4j-memory) and its folder (~/.claude-neo4j) keep their names so permission rules, tool references and config paths keep working.

Requirements

Node 22.13 or later in 22.x, 23.4 or later in 23.x, or any 24+, somewhere on the machine. It need not be the default node: every memory process starts through bin/memory-node, which uses the first suitable Node it finds, in this order:

  1. CLAUDE_NEO4J_NODE, if set.
  2. node on PATH.
  3. The newest under nvm, fnm and volta.
  4. Homebrew ($HOMEBREW_PREFIX, else /opt/homebrew and /usr/local).

If none qualifies, session start shows one line naming the versions it found, and the memory server fails visibly instead of hanging.

Install

claude plugin marketplace add troglodyte/claude-neo4j-mem
claude plugin install neo4j-memory@claude-neo4j-local --scope user

If the install path (installPath in ~/.claude/plugins/installed_plugins.json) has no node_modules, run this in it. All three dependencies are public packages, so it works whatever registry ~/.npmrc names:

npm install --omit=dev --registry=https://registry.npmjs.org/

First start

Session start decides what to do from the store file, the Neo4j settings and the last move's record:

  • A store exists: memory works as usual.
  • No store and no Neo4j settings (a new user): an empty store is created.
  • No store, and Neo4j settings (a password in ~/.claude-neo4j/config.json, or NEO4J_PASSWORD): session start moves the graph across in the background, which takes about a minute. Meanwhile the memory tools answer "memory is moving to SQLite", and captures wait for a later session start. Each move builds its own memory.sqlite.migrating-<run>, checks every row against Neo4j, and puts it in place only if the check passes, never over an existing store. If Neo4j could not be read, the next session start tries again.
  • Neo4j settings, but Neo4j is down: memory stays off. Start it once (scripts/setup-local.sh) so the move can run, or run npm run memory -- start-fresh to begin with an empty store and leave Neo4j untouched.
  • The last move failed: memory stays off, and session start names the reason and the log, ~/.claude-neo4j/state/migrate.log. The staged file and its report are kept. Fix the cause, then run npm run migrate-to-sqlite, which moves the graph the same careful way.

Sessions that were already open keep writing to Neo4j until they end. For 7 days after the move, each session start copies those new rows across in the background. It only ever inserts, so nothing deleted in SQLite comes back.

Moving a machine off Neo4j

  1. Leave Neo4j running and install the plugin (above). If an older version is already installed, update it instead: run claude plugin marketplace update claude-neo4j-local, then claude plugin update neo4j-memory@claude-neo4j-local. Without the first, the update does not see the new version. Check the new install path for node_modules as above.
  2. Start one Claude Code session. Wait until ~/.claude-neo4j/state/migrate.log says memory moved. To move it now instead, run npm run migrate-to-sqlite in the install path; it ends with PASS and memory moved.
  3. In the install path, run npm run health. Every check should PASS, and migration should read last passed.
  4. Run npm run backup.
  5. Optionally remove the rows a test run wrote into the graph on 2026-10-09: bash bin/memory-node scripts/remove-test-rows.mjs lists them, and the same command with --apply deletes them from SQLite. Neo4j is never touched.
  6. Once the sessions that were open have ended, or the 7 days are up, stop the container. Run podman stop claude-neo4j-memory, or docker stop claude-neo4j-memory; podman update --restart=no claude-neo4j-memory keeps it from coming back with the Podman machine. Its volume is untouched.

How it works

  • SessionStart hook injects a compact, fixed-cost snapshot of the current project's memory: the standing facts that always apply (preferences and constraints, quoted verbatim, with near-identical repeats collapsed) followed by a one-line-per-subsystem index of everything else, so Claude knows what exists and can memory_search into it rather than being handed a recency dump. It also prints a short banner (🧠 Memory: 223 observation(s) across 6 subsystem(s) for <project>.).
  • MCP server (neo4j-memory) exposes eleven tools Claude can call mid-session: memory_search, memory_get_entity, memory_recent, memory_timeline, memory_status, memory_list_projects, memory_add_observations, memory_create_relation, memory_delete_observations, memory_delete_entity, memory_prune.
  • Observations carry a subsystem tag (e.g. capture, search, backup) inferred at write time. Cross-cutting facts carry none, because they reach Claude through the standing-facts block. memory_search, memory_recent and memory_timeline take an optional subsystem; memory_add_observations takes a call-level subsystem to tag a batch.
  • Search results list each relation with its direction: out when the result is the relation's start, in when it is the end, self when it is both.
  • The five write tools return a confirmation string Claude relays as a short line (🧠 remembered 2 observation(s) on ...). Mute with npm run memory -- mute (persistent) or CLAUDE_NEO4J_QUIET=1 (session-only); unmute reverses it.
  • PreCompact/SessionEnd hooks read the new part of the transcript since the last capture and shell out to a locked-down, one-shot headless claude -p call (Haiku, no tools, no MCP servers, no settings or CLAUDE.md inheritance, structured output via --json-schema) to extract durable facts, then write them into the store. It rides your logged-in session, so no ANTHROPIC_API_KEY is needed. The prompt is seeded with the project's known entity names and subsystem tags, and a lexical-similarity check (src/lib/dedup.js) catches names that still drift.
  • Nothing is silent. SessionEnd capture runs detached, so the next SessionStart banner reports what background capture saved, and says when it is retrying one that failed.
  • Recall uses SQLite full-text search (FTS5, bm25, Unicode word segmentation that keeps a:b, a.b and a_b whole) blended with recency, 1 / (1 + days / 30). A multi-word query whose best result holds only one of its words is marked weak.
  • memory_prune / npm run memory -- prune deletes observations past a given age (default 180 days), always keeping the most recent few per entity. Dry run by default. Nothing prunes automatically.

All hook scripts fail open: if the store cannot be opened they say why and exit 0 without blocking your session.

Storage

Tables entities, observations, relations, sessions and migrations, plus two FTS5 tables, in src/lib/sqliteDb.js. Entity identity is (name, project), so unrelated repos using the same entity name don't collide. project comes from git remote get-url origin (falling back to the cwd basename); an entity with project '' is global and shows up everywhere.

Automatic capture

On by default. It uses the claude CLI already on your PATH and Claude Haiku.

variableeffect
CLAUDE_NEO4J_DISABLE_CAPTURE=1turn capture off entirely
CLAUDE_NEO4J_CAPTURE_MODELoverride the extraction model
CLAUDE_NEO4J_CAPTURE_CLIoverride the CLI it shells out to
CLAUDE_NEO4J_CAPTURE_WINDOWcharacters of new transcript per window (default 50,000)
CLAUDE_NEO4J_CAPTURE_MAX_CHUNKSwindows per capture (default 3)

Windows are taken from the end, so a session past the ceiling loses its oldest content rather than its most recent, and ~/.claude-neo4j/capture.log records how much was dropped.

A capture that fails (memory moving to SQLite, a timed-out extraction) keeps its input and retries at the next session start, up to 3 attempts.

Usage

  • "What do you remember about X": the memory-search skill / memory_search.
  • "Is memory working?": memory-status / memory_status.
  • "Give me a timeline report": the timeline-report skill / memory_timeline.
  • Tell Claude to forget something: memory_delete_entity / memory_delete_observations.
  • /memory-stats [--all | --project ID]: shows or hides a side pane with npm run stats's report for the project you are in, refreshed when a turn ends; r refreshes, a switches between this project and all of them. It is the plugin's mod (hooks/stats-pane.tsx), so it needs Claude Code 2.1.287 or later; with no surface to draw a pane (claude -p) it prints the report instead.

Outside a Claude session

  • npm run memory -- status (or search, recent, get, add, relate, timeline, forget-obs, forget, prune, projects, dupes, start-fresh, mute, unmute): query and edit the store from a terminal. Run with no arguments for usage. dupes [--min 0.3] lists likely repeated standing facts, most similar first.
  • npm run health: the launcher's Node, the store file, its schema and integrity, its contents and the last migration, PASS/FAIL per check.
  • npm run stats [-- --project ID | --all]: one project's memory over time: weekly growth, observations per session, observation length, subsystem spread and relations. Defaults to the repo you run it from, so npm --prefix <plugin dir> run stats works from any checkout.
  • npm run token-cost [-- --all]: see Token cost.
  • npm run telemetry [-- --days N]: how memory is used: reads and writes per project, characters served, misses, and the searches that found nothing. Reads ~/.claude-neo4j/telemetry.jsonl; opt out with CLAUDE_NEO4J_DISABLE_TELEMETRY=1.
  • npm run search-parity: replays logged searches through Neo4j and through a fresh copy in SQLite, and reports how often they agree. Needs Neo4j running.
  • scripts/statusline.mjs: a statusline showing <model> · 🧠 <entities>e/<observations>o.

Token cost

Reads are bounded by a character budget, not just a row count (src/lib/budget.js). Trimmed content is marked in-band (…[+N chars]), and memory_timeline returns {events, total, returned, truncated} so a partial history is never mistaken for a complete one. npm run token-cost [-- --all] measures every read path and exits non-zero if one exceeds its per-call ceiling.

Backup, restore, health

npm run backup                         # online copy to ~/.claude-neo4j/backups/memory-<UTC stamp>.sqlite
npm run backup -- --keep 7             # then keep only the 7 newest memory-*.sqlite backups
npm run restore -- --latest            # put the newest backup back (or: npm run restore -- <file>)
npm run health                         # PASS/FAIL per check

A backup is an online VACUUM INTO, safe while sessions write, readable only by you (mode 0600). Restore checks the backup's integrity first, refuses while any process has the store open (close your Claude Code sessions, or pass --force), and moves the current store aside to memory.sqlite.pre-restore-<stamp> rather than deleting it.

Legacy: Neo4j

These reach the old Neo4j graph and stay until it is retired: scripts/setup-local.sh and npm run configure (start and configure the container), scripts/backup.sh, scripts/restore.sh and scripts/check-health.sh (Neo4j dumps and checks), npm run migrate-to-podman, scripts/cypher.sh, scripts/fingerprint.sh, npm run migrate-claude-mem, and docker/. The container is claude-neo4j-memory, under Podman or Docker. Its credentials live in docker/.env and the Neo4j fields of ~/.claude-neo4j/config.json. The full Neo4j setup guide is in this README as of 0.6.0, in git history.

Development

scripts/claude-with-memory.sh starts Claude Code with the plugin loaded from this checkout (claude --plugin-dir); run /reload-plugins after changing plugin code.

env -u CLAUDE_NEO4J_DISABLE_CAPTURE HOME="$(mktemp -d)" npm test

The temporary HOME keeps tests away from your real store and settings, and the env -u matters if capture is switched off in your settings, which would fail the capture tests. On macOS, engine-resolve.test.sh's pty case fails because it needs util-linux script -qec.

The mod (hooks/stats-pane.tsx, named by modules in hooks/hooks.json) is TypeScript that Claude Code loads directly, so npm test does not reach it. npm run test:mod runs claude plugin validate and claude plugin test on it; tsc -p . type-checks it once Claude Code has written .claude-plugin/types/ (it does on a --plugin-dir load).

Project layout

.claude-plugin/          plugin and marketplace manifests
bin/memory-node          launcher: finds a Node with node:sqlite for every memory process
hooks/hooks.json         SessionStart / PreCompact / SessionEnd wiring
.claude-plugin/plugin.json  also registers the MCP server (no root .mcp.json; see CLAUDE.md)
skills/                  memory-search, memory-status, timeline-report
src/mcp/server.js        MCP tools
src/hooks/               session-start.js, capture.js
src/lib/                 config, memoryStore (+ firstStart), sqliteDb (schema), graph, search,
                         segment, budgets, injection, subsystem tags, dedup, extraction,
                         catchUp, backup, health, migrate/ (Neo4j to SQLite)
scripts/                 memory CLI, migrate-to-sqlite, first-start-migrate, catch-up,
                         backup / restore / check-health (.mjs), statusline, token-cost,
                         telemetry, search-parity, remove-test-rows, legacy Neo4j scripts
docker/                  legacy: local Neo4j credentials
tests/                   node --test suites and bash suites
docs/                    design specs and plans

Notes

  • ~/.claude-neo4j/config.json and docker/.env can hold the Neo4j password; never commit either.
  • memory.sqlite is the only copy of what the plugin has learned since the move; back it up.
  • Marketplace installs compare the version in .claude-plugin/plugin.json, so a release that doesn't move it never reaches other machines. See CLAUDE.md.

Changelog

See CHANGELOG.md.

Source 2 files
hooks/stats-pane.tsx 135 lines
1// The plugin's mod: /memory-stats shows or hides a pane with `npm run stats`'s report for the
2// project the session is in. The hooks module runs with no Node and no node:sqlite, so the
3// report comes from the plugin's own script, started through bin/memory-node like every other
4// memory process. Tests: tests/stats-pane.test.ts, under `npm run test:mod`.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { StatsView } from '../types'
9
10const PANE = 'memory-stats'
11const TITLE = 'Memory stats'
12const SCRIPT = 'scripts/memory-stats.mjs'
13const RUN_TIMEOUT_MS = 15_000
14
15const view = atom({ plugin: 'neo4j-memory', key: 'statsView' } as const, {
16  args: [],
17  text: '',
18  error: null,
19  updatedAt: null,
20  isLoading: false,
21})
22
23/**
24 * Runs the stats script in the session's working directory, which is how it reports the project
25 * being worked on. A failed run keeps the last good report and shows why beside it; it never
26 * rejects, so a caller that does not wait on it leaves nothing unhandled.
27 */
28async function refresh($: EngineInterface, args: string[]) {
29  try {
30    await update($, view, current => ({ ...current, args, isLoading: true }))
31    const run = await $.process.run(
32      ['bash', `${$.plugin.root}/bin/memory-node`, `${$.plugin.root}/${SCRIPT}`, ...args],
33      { timeoutMs: RUN_TIMEOUT_MS },
34    )
35    const isOk = run.exitCode === 0
36    const updatedAt = await $.clock.now()
37    await update($, view, current => ({
38      ...current,
39      text: isOk ? run.stdout : current.text,
40      error: isOk ? null : (run.stderr || run.stdout).trim() || `exited ${run.exitCode}`,
41      updatedAt,
42      isLoading: false,
43    }))
44  } catch (error) {
45    const updatedAt = await $.clock.now()
46    await update($, view, current => ({ ...current, error: String(error), updatedAt, isLoading: false }))
47  }
48}
49
50// Press handlers read the scope from state rather than the drawing, so a press after a change
51// that has not been redrawn yet still runs the scope on screen.
52async function rerun($: EngineInterface) {
53  const { args } = await read($, view)
54  await refresh($, args)
55}
56
57async function toggleScope($: EngineInterface) {
58  const { args } = await read($, view)
59  await refresh($, args[0] === '--all' ? [] : ['--all'])
60}
61
62function stamp(view: StatsView): string {
63  if (view.isLoading) {
64    return 'refreshing…'
65  }
66  return view.updatedAt === null ? '' : `updated ${new Date(view.updatedAt).toLocaleTimeString()}`
67}
68
69export const register: Register = on => {
70  on('session.start', async ($, e, next) => {
71    await $.command.register({
72      name: 'memory-stats',
73      description: 'Show or hide a pane with memory stats for this project',
74      argumentHint: '[--all | --project ID]',
75    })
76    return next(e)
77  })
78
79  // A bare /memory-stats toggles: it closes the pane when that is what the person is looking at,
80  // and otherwise opens it or brings it in front. With flags it always opens, on that scope.
81  on('command.run', { command: 'memory-stats' }, async ($, e) => {
82    const args = e.args.trim().split(/\s+/).filter(Boolean)
83    const panes = await $.ui.panes()
84    if (args.length === 0 && panes.some(pane => pane.id === PANE && pane.isShown)) {
85      await $.ui.close({ id: PANE })
86      return { text: 'Closed the memory stats pane.' }
87    }
88    await refresh($, args)
89    const opened = await $.ui.open({ id: PANE, title: TITLE })
90    if (opened.isPlaced) {
91      return { text: 'Opened the memory stats pane.' }
92    }
93    // Nowhere to draw a pane (no surface attached): print the report instead.
94    const { text, error } = await read($, view)
95    return { text: error ?? text }
96  })
97
98  // Memory tools write during a turn, so a report on screen is stale once one ends. The run is
99  // awaited so it finishes inside this dispatch; its time is the script's, not the hook's budget.
100  on('turn.complete', async ($, e, next) => {
101    const result = await next(e)
102    const panes = await $.ui.panes()
103    if (panes.some(pane => pane.id === PANE)) {
104      await rerun($)
105    }
106    return result
107  })
108
109  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
110    const { Box, Text, Button, Code } = $.ui.resolve(e)
111    const current = await read($, view)
112    const isAll = current.args[0] === '--all'
113    return (
114      <Box flexDirection="column" gap={1}>
115        <Box flexDirection="row" gap={1}>
116          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void rerun($)} />
117          <Button
118            key="scope"
119            label={isAll ? 'This project' : 'All projects'}
120            hotkey="a"
121            onPress={() => void toggleScope($)}
122          />
123          <Text dimColor>{stamp(current)}</Text>
124        </Box>
125        {current.error !== null && <Text color="red">{current.error}</Text>}
126        {current.text === '' ? (
127          <Text dimColor>{current.isLoading ? 'Loading…' : 'No report yet.'}</Text>
128        ) : (
129          <Code source={current.text} language="text" />
130        )}
131      </Box>
132    )
133  })
134}
135
types/index.d.ts 21 lines
1// The plugin's mod contract: the session state the stats pane (hooks/stats-pane.tsx) keeps.
2
3/** What the stats pane draws: the last run of the stats script and the scope it ran for. */
4export type StatsView = {
5  /** Passed through to memory-stats.mjs: [] for this project, ['--all'], or ['--project', id]. */
6  args: string[]
7  /** The report as the script printed it. */
8  text: string
9  /** Why the last run failed, from its stderr; null when it succeeded. */
10  error: string | null
11  /** When the last run finished, in epoch milliseconds; null before the first. */
12  updatedAt: number | null
13  isLoading: boolean
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    'neo4j-memory': { statsView: StatsView }
19  }
20}
21