SLOPSHOPPER

segmem

Segmented, scoped memory for agents: one file, SQLite, no daemon. Wake at session start, recall on every prompt, staleness nag at stop.

newguardtoaststatusprompttool
★ 1v0.13.1no licenseupdated 2026-09-30mahuebel/segmem
A shopper browsing a rack in a slop shop
README

segmem

Long-term memory for coding agents that knows the difference between who you are, how this repo works, and what happened on Tuesday.

One Python file. SQLite. No server, no daemon, no API key. Works with any agent that can run a shell command. On Claude Code and Codex CLI, hooks make the startup read and per-prompt recall automatic.

The problem

An agent forgets everything when the session ends. Bolt-on memory tools fix that by appending every note to one timeline, and two things go wrong:

  • Noise. "PR #412 is awaiting review" and "you prefer rebase over merge" land in the same list, and the first kind outnumbers the second fifty to one.
  • Bleed. A convention from one repo ("uses npm") gets read as a preference and followed in the next repo, where it's wrong.

Both come from the same mistake: storing facts with different lifetimes on one axis. segmem gives each fact a kind (how it decays) and a scope (where it applies), then loads only what the current project needs.

What the agent sees

At the start of a session, in the segmem repo:

## Memory (project: segmem)

### Identity
#3 prefers short commits, one concern each (global)

### Procedural
#7 prefers pnpm (global) OVERRIDDEN by #12 uses npm, Lambda runtime needs it (segmem)
#9 tests: python3 test_segmem.py (segmem)

### People known: alice
Run `segmem recall <name>` before acting on or about them.

### Episodic (segmem), oldest first
#0-3 2026-08-21 chose SQLite over DuckDB; stdlib, WAL, FTS5
#4 2026-08-21 naps come due only for blocks the wake cover prints
#5 2026-08-22 wake runs from a SessionStart hook; the agent skipped it once

You are awake.

Global preferences load whole. The project's conventions load whole and win over global ones, with both shown so the agent knows why. History loads with detail that decays: yesterday verbatim, last month as one line. People load as names only; the agent looks them up when they come up.

Install

Requires Python 3.8 or later and git. Nothing else.

As a Claude Code plugin

/plugin marketplace add mahuebel/segmem
/plugin install segmem@segmem

That's the whole install: the three hooks register automatically, and the doctrine (what to record and when) is injected at session start, so there is no CLAUDE.md paste and no settings.json merge. Skip the manual steps below. The hooks they print claim each session with --once, so running both no longer doubles wake and recall, but it still starts a second process per event.

Any harness

git clone https://github.com/mahuebel/segmem ~/.segmem/src
~/.segmem/src/segmem init

init prints two things:

  1. A ## Memory block. Paste it into ~/.claude/CLAUDE.md (or your agent's AGENTS.md). It tells the agent what to record and when.
  2. A hooks block. Merge it into ~/.claude/settings.json (Claude Code) or save it as ~/.codex/hooks.json (Codex CLI); both use the same shape. It makes the harness run wake at every session start and search memory on every prompt, so neither depends on the agent remembering.

On a harness without hooks (Cursor, Aider, your own), skip step 2. The prompt block alone carries it: the agent runs wake and recall itself. That works, but it relies on the agent following instructions. If your harness can run a command at session start or pipe each prompt to a command, point it at segmem wake and segmem hook; hook accepts the JSON that Claude Code and Codex send, or plain text, on stdin.

Start a new session. The first wake prints an empty header; the agent fills it in as you work.

Kinds

KindHoldsAt wakeReplaced by newer facts?
identitywho you are, how you like to work: "prefers"loaded wholeyes
proceduralhow this project works and why: "uses"loaded wholeyes
episodicdecisions with reasons, root causes, handoffsdecaying windowno, it's history
peoplewho someone isnames onlyyes

The word choice is the classification. Prefers is about you and goes global. Uses is about a repo and stays there.

Tags

--entities=a,b tags a fact with its subjects: people, components, files. Tags are one vocabulary across projects. When you write one that matches an existing tag ignoring case, the stored spelling wins; when it's new but close to an existing one, note says so (new tag: github_actions (similar: github-actions)), and you use the suggestion next time. A tag that looks like a name with no people record gets a nudge to create one. recall prints tags in brackets so you can see what's in use.

Tags earn their keep in three places: they pair project facts with the global facts they override, they let promote match the same fact across projects, and they're what makes a capitalized word in a prompt count as something worth looking up.

Scope

A fact is global or belongs to one project, keyed by the git main repo path so every worktree shares it. identity and people default to global; procedural and episodic default to the current project.

Project wins. When a project fact and a global fact share a subject (an entity tag), wake prints OVERRIDDEN with both.

Promotion needs evidence. A project fact becomes global only when the same statement is live in three projects. One observation is a convention; three is a preference.

Altitudes

Memory is a supply chain, not an archive; ARCHITECTURE.md holds the full design. A fact lives at the narrowest altitude whose audience covers everyone who needs it: the device (this store), the project repo (where the export verdict sends stable, hot facts, strongest form first: enforcement, a skill, CLAUDE.md, then docs), and an org layer for the cross-project residue.

The org layer is a cloned knowledge repo of one-fact-per-file markdown. Point SEGMEM_ORG_DIR at it and segmem indexes it read-only, reindexing when its git HEAD moves. recall and the prompt hook search it, hits marked (org). Wake never loads it whole; it surfaces three things only: one summary line, conflicts (a local fact overriding an org fact, which is the org layer's staleness signal: open an issue on the fact's file), and co-sign nudges when a local fact matches an open candidate.

Upward writes are always a PR a human approves. segmem contribute <id> prints the candidate file and the commands; facts tagged with a person, and every identity, people, or raw episodic note, never leave the device. segmem org-init <dir> scaffolds a new knowledge repo with the witnessing convention: candidates merge to facts at three witnesses, and CODEOWNERS names the human on the other end of every staleness signal.

Pressure

A stored claim is a claim under test, and the store tracks the evidence arriving against it. Every time an entity is tagged in a new note (weight 3), served by recall (2), or mentioned in a prompt (1), that's a touch. Touches are telemetry, not memory: wake never prints them as facts. Each touch carries the project it came from: a project fact feels only its own project's attention, a global fact feels all of it.

A session nobody is watching (a builder fleet, a scheduled run) should read memory without pressing on it. Set SEGMEM_QUIET=1 in its environment: the prompt hook still recalls but records no touches and asks for no upkeep.

People dossiers. Only a people note or an explicit review resets the clock; an episodic note about a person raises pressure on their dossier, it never relieves it. When the weighted touches since the last revision reach the threshold (6):

  • segmem stale lists the dossiers under pressure, with counts and dates.
  • wake flags them under the people list: alice: dossier from 2026-08-24, 3 notes since.
  • The prompt hook asks the agent, once per session, to supersede the dossier with what changed or confirm it unchanged with segmem touch <name>.

Procedural facts. The same clock runs per note, against the touches on its entities, with a higher threshold (12), because busy components accrue touches fast. The first time a note comes under pressure, the verdict is verify: check the claim against the repo, then supersede what changed or confirm with segmem touch <id>, which prints the exact claim back so a blind reset is at least a visible one.

A confirmed fact that comes under pressure again has proven two things: it's stable, and it's load-bearing. The verdict changes to export: its home is the repo. Write it into README, CLAUDE.md, or the file it governs (~/.claude/CLAUDE.md for a global fact), then supersede the note with a pointer to where it landed. Memory is the staging ground, not the archive; a fact everyone should see belongs where everyone looks. segmem touch <id> --keep is the escape for a fact the repo can't hold (private context, another team's repo); it stops export suggestions while verify cycles continue. segmem never writes the repo itself: the agent does, and decides.

touch is the honest way out: it records "reviewed, no change needed" without writing a fake supersede that would pollute history. Nothing ever rewrites a note without an agent deciding to.

Commands

CommandWhat it does
segmem wake [--all] [--conflicts]print the memory for the current project; --conflicts prints the overriding id pairs alone
`segmem note <kind> "<text>" [--entities=a,b] [--scope=global\project] [--supersedes=id]`record one fact, up to 280 bytes; superseding an episodic leaf drops the summaries over it, and the next nap rebuilds them
segmem recall <query>full-text search across every kind and scope
segmem nap <lo>-<hi> "<text>"answer a compression request; the range must be the pending block
segmem promote <id>lift a project fact to global, if three projects agree
segmem forget <id> ["why"]delete a note and record the rejection: the same line is refused after; episodic only when newest
segmem forget <lo>-<hi>drop a bad summary; it's rebuilt on request
segmem stale [--min=n] [--hook] [--count]list people notes and procedural facts under evidence pressure; --count prints the number alone
`segmem touch <entity\id> [--keep]`claim reviewed, unchanged; resets its pressure; --keep marks a procedural fact memory-resident
segmem contribute <id>print the org-repo candidate for a procedural fact, and the PR commands
segmem org-init <dir>scaffold a knowledge repo with the witnessing convention
`segmem hook [--once --session=id --served=command\function]`the prompt hook; reads JSON on stdin
segmem hook --tool [...]the PreToolUse hook: facts tagged with the program a Bash command runs, once per session
segmem check-noteread a shell command on stdin; run the note checks on it and write nothing
segmem serve [--port=7878] [--no-open]serve a live page over the store on loopback; Ctrl-C stops it
segmem html [file] [--no-open]write a self-contained snapshot page of the store, and open it
segmem mcprun as an MCP server over stdio
segmem prompt [--subagent]print the doctrine block; --subagent prints the read-only paragraph a subagent gets
segmem next-nap [--json]print the compression wake would ask for; --json for its range and prompt as data
segmem auditthe numbers a store review reads: kinds, wake cost per project, pressure with its sources, session bursts, duplicates, untagged facts
segmem projectprint the scope key for the current directory

Examples:

segmem note identity "prefers rebase over merge" --entities=git
segmem note procedural "uses npm, the Lambda runtime needs it" --entities=pkg
segmem note episodic "chose SQLite over DuckDB: stdlib, no install" --entities=sqlite
segmem note people "Alice owns deploys, ask before touching infra" --entities=alice
segmem note identity "lives in Lisbon" --supersedes=14
segmem recall lambda

How history decays

Episodic facts form a binary tree. Two adjacent facts compress into one line, two of those into another, and so on. wake prints a fixed budget of lines (16 per project, 8 for global), chosen greedily: a block may be as wide as it is old, so the newest facts appear verbatim and each doubling of age gets about the same number of lines. Picking the cover takes one pass and well under a millisecond at a million memories.

The agent writes each summary itself. When wake needs a summary that doesn't exist, it prints the block raw, never blocks, and asks for one line; the agent answers with nap. If the turn reads past the ask, the prompt hook repeats it once per session. Compression is requested only when wake would print the block, never ahead of time, and never in the background.

Raw facts are never edited. A misfiled note can be forgotten by id; anything else is superseded, not deleted. Summaries are a cache: drop one with forget <lo>-<hi> and the next request rebuilds it.

Hooks

The init output includes this block. For Claude Code, merge it into ~/.claude/settings.json; for Codex CLI, save it as ~/.codex/hooks.json:

{"hooks": {
  "SessionStart": [{"hooks": [{"type": "command", "command": "~/.segmem/src/segmem wake --once --served=manual"}]}],
  "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.segmem/src/segmem hook --once --served=manual"}]}]
}}
  • SessionStart runs wake on every start, resume, and compaction and puts the output in context. A startup rule the agent has to remember is a rule it will sometimes skip; this removes the dependency.
  • UserPromptSubmit runs hook, which pulls identifiers out of your message (code spans, #123, paths, snake and kebab names, and capitalized words that are known tags), searches the current project plus global memory, and adds up to eight hits as a <segmem-recall> block. No identifiers or no hits means no output. An identifier that matches more than a sixth of the store (wake, in this repo) is too broad to mean anything and is dropped, so a prompt made only of such words stays quiet. It never blocks a prompt.
  • The same hook asks for memory upkeep in a <segmem-upkeep> block when a people note or a procedural fact has fallen behind the evidence: one subject per prompt, each once per session, done before the answer and not mentioned in it. With nothing under pressure, it asks once per session for the compression wake printed raw. It used to ask from a Stop hook, which put the upkeep after the answer, so the answer scrolled away. stale --hook still works for installs that wire it, but init no longer prints it.

Function hooks

Claude Code 2.1.261 carries an early-access hook type behind CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1: a TypeScript module the engine runs in-process, with typed events instead of stdin JSON. The plugin ships one, hooks/hooks.ts. It answers prompt.context with the wake output as a context block named segmem, and prompt.submit with the recall hits as typed context beside the prompt. The command hooks stay in hooks.json, so a session without the flag (Desktop, cloud, Codex, an older build) loses nothing.

With the flag on, both paths run and neither knows the other's order, so each output path calls the same command with --once: wake --once for the memory at start, hook --once for the recall on every prompt. The first to insert a row in the claims table owns it, and only the owner prints. The key is the session, the command, and the source: startup for wake, so a compaction is a new source and wakes again, and session for recall, so the first prompt decides who serves every prompt after it. Without a session id, --once does nothing. The served column records which path spoke, so a transcript that looks wrong can be traced.

The recall path takes every prompt the command hook takes, whatever its origin: a scheduled prompt, a peer session's message and one you typed all get the same memories.

At agent.spawn the module appends the read-only rule to the subagent's prompt: read with wake and recall, never note, nap, or promote, and report what you learned so the parent records it. It never refuses a spawn. The rule's text comes from segmem prompt --subagent, so it is written once, in Python. Without the flag the parent is still told to pass the rule on, which is the line that has always been in the doctrine block. This path needs no claim: it writes the subagent's prompt, which no command hook can reach, so there is nothing to double.

At tool.call the module checks a Bash command that mentions segmem before the shell runs it. segmem check-note reads the whole command on stdin, finds the note arguments, and runs the same length and tag checks the real note runs without writing anything: a line over 280 bytes or a wrong kind is refused with the trim mark, and a near-miss tag or a missing people record comes back as a hint beside the result. Every other command passes untouched. Without the flag the model learns the same thing from the note that failed; with it, the note is never sent.

Also at tool.call, the module runs segmem hook --tool on every Bash command: a fact tagged with the program about to run (sqlite, git, a tag that is a prefix of the program name) prints before the shell runs it, once per session. Only tags match, never text, so git does not print half the store. The command hook in hooks.json does the same as a PreToolUse hook and answers as JSON, because plain stdout there reaches only the debug log. The two paths share the prompt hook's claim, so one of them speaks.

After wake, the module counts what is under pressure with segmem stale --count and pins segmem: N under pressure beneath the prompt, or clears the line when nothing is due. It is a notice, not a prompt: the prompt hook's <segmem-upkeep> block is what asks the model to verify a fact against the repo. A headless run has nowhere to draw it, and the engine says so in the debug log rather than failing.

At session.start the module registers one tool, recall, which the model calls as mcp__segmem__recall with a query. It runs segmem recall and returns what it printed, so a call presses the entities it finds exactly as a Bash recall does. Bash recall keeps working in every session, flagged or not. note is not registered: it stays a shell command, which is what keeps "subagents never note" true without anything extra to enforce it.

The module makes no model call. Version 0.8.0 drafted the pending compression with the session's model at start; that cost one completion on the first-prompt path and, in the one project with a backlog, never once turned into a nap. The prompt hook asks for the compression instead, and the plugin still never runs nap: a summary that turns an unknown cause into a cause is worse than no summary, and only the model reading the originals can tell the two apart.

When a project fact overrides a global one, or a local fact overrides an org fact, the module toasts the pair of ids: segmem: #12 overrides #7, or segmem: #9 overrides org:pkg-standard. A contradiction is the point of the altitudes and the easiest thing to miss inside a long wake, so the user sees one without reading it. segmem wake --conflicts answers with the id pairs alone and takes no claim: showing them is not serving the session's memory, and the command hook owns that.

A compaction is served by the command hook, not the module. prompt.context fires only when a conversation computes the context its first message carries, and a compaction is not a new conversation: it does not fire again, so the typed blocks go with the context that was compacted. What does fire is SessionStart with source compact, which runs wake --once. Because the claim is keyed on the source, a startup claim held by the module does not silence it, and memory comes back.

To type-check the module after a Claude Code update, run /plugin-types in a session (it writes .claude/types/), then tsc -p tsconfig.json. claude plugin validate .claude-plugin/plugin.json shows what the engine reads from the module.

Seeing what it knows

segmem serve

opens a live page at http://127.0.0.1:7878/. It runs in the foreground while you look and stops on Ctrl-C; nothing in the hooks depends on it, so the no-daemon promise holds. Stdlib only, loopback only, read-only: every route is a SELECT, and searching from the page never writes a touch, so browsing doesn't press on a fact. A spine across the top shows every memory as one tick in id order, colored by kind, superseded ones hollow. Seven tabs, reachable with g then a letter (o c e h t n l); / jumps to search:

  • overview: what the store holds and what needs a hand. Live count, pressure, superseded, summaries, scopes, untagged; a kind × scope matrix (click a cell to browse it); notes per day; what each scope's wake costs in tokens, from the real command; and the hygiene list: facts that are stable and hot or under pressure, dossiers behind the evidence, compressions due per scope, untagged facts, duplicates, overrides.
  • console: what the agent sees at wake, byte for byte, for any scope. Click a line and the right pane explains why it prints: overrides, touches and pressure since its last review, the verdict stale would give, the export target, its supersede history, and the commands to copy. Episodic lines show the wake seq in the gutter and the global id at the right, since wake numbers by seq and recall by id.
  • ledger: full-text search with the same FTS5 syntax as recall, facets by kind, scope, and entity, an expandable row per hit, and a live feed of touches and notes as they land.
  • hook: why recall did, or did not, fire. Type a prompt and see which words the four rules keep (code spans, #123, path-like words, capitalized words that are already tags), which they drop and why, the FTS5 query, and the exact <segmem-recall> block the agent would get. It runs identifiers() and the hook's query in-process without the touch, so trying phrasings presses on nothing.
  • tree: how history decays. The episodic merge tree as an icicle, with summarized blocks filled, the wake cover outlined, and the block nap would ask for first dashed. Scrub the budget and the stream length to see what wake looks like at 200 or 1,000 notes and where compressions land.
  • entities: what the store is about. A co-occurrence graph of tags (size is facts, line is shared facts, tags that share nothing sit on the outer ring), every tag with its kinds and scopes, and vocabulary hygiene: near-duplicate spellings, name-like tags with no people record, untagged.
  • lineage: how a fact changed. Every supersede chain as a timeline with word-level diffs between versions.

The page re-renders whenever any other connection commits: it holds one event stream and the server polls PRAGMA data_version twice a second. Where an action is implied it offers a command to copy; the CLI stays the only path that writes.

segmem html

writes one self-contained HTML file (no libraries, no server, no network) to ~/.segmem/segmem.html and opens it: a snapshot you can send to someone. Five views:

  • overview: counts by kind and scope
  • facts: every memory, filtered by kind, scope, entity, or text, with superseded ones hidden unless you ask
  • overrides: each project fact that beats a global one, and the shared subject that links them
  • tree: the episodic merge tree for a scope, with summarised blocks shaded and the current wake cover outlined; a budget slider shows how the wake preview changes, and what compression would come due
  • entities: every tag, how often it appears, and what it co-occurs wit
Source 1 files
hooks/hooks.ts 236 lines
1// segmem as a function hook: wake and recall as typed results, no stdout
2// parsing. Loads only when CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is on (or the
3// engine's flag is). The command hooks in hooks.json keep running either
4// way; every path here calls the same command with `--once`, and the claims
5// table lets exactly one of the two speak.
6//
7// Wake runs in prompt.context, not session.start: prompt.context fires first.
8//
9// prompt.context is dispatched more than once and the dispatches OVERLAP;
10// the engine keeps whichever answer settles first. So the once-guard caches
11// the promise, not the value: a guard that sets its sentinel before the
12// await lets the second dispatch through with nothing, and that empty
13// answer, being the fast one, is the one the engine keeps.
14//
15// Ordering. The UserPromptSubmit command hook runs *inside* next(e), so a
16// hook that waited for next(e) before claiming could never win. Each hook
17// below claims first and holds its output before it calls next, so a claim
18// it wins is a claim it can deliver. On wake the race is lost anyway: the
19// SessionStart command hook and this module start at the same moment and
20// the command hook's process reaches the insert first, every time measured.
21// That is fine, because both paths print the same wake. Anything only this
22// module can produce must therefore not be gated on winning: the status line
23// and the toast take no claim.
24//
25// The module makes no model call. A nap draft lived here from S6 to 0.8.0:
26// it cost one session-model completion on the first-prompt path and, in the
27// one project with a compression backlog, never once converted. The prompt
28// hook now asks for the nap, one subject per prompt, before the work: at
29// Stop the ask landed after the answer and buried it.
30import type { EngineInterface, Register } from "claude-code";
31
32const TIMEOUT = 10_000;
33
34/**
35 * Wake, the pressure count, and the contradiction toast, once per module
36 * life. The three runs are independent, so they run at once: the first
37 * prompt waits on the slowest, not the sum.
38 *
39 * Wake is claimed with `--once`, so it prints only if this path owns the
40 * session. The other two are not behind that claim: the two paths race on
41 * it and the command hook wins, so anything gated on winning would never
42 * ship. Neither doubles anything a command hook prints.
43 */
44async function gather($: EngineInterface): Promise<string> {
45  const root = $.plugin.root + "/segmem";
46  const cwd = await $.session.cwd();
47  const id = await $.session.id();
48  const run = (args: string[]) =>
49    $.process.run([root, ...args], { cwd, timeoutMs: TIMEOUT });
50  const [wake, stale, conflicts] = await Promise.allSettled([
51    run(["wake", "--once", "--session=" + id, "--served=function"]),
52    run(["stale", "--count"]),
53    run(["wake", "--conflicts"]),
54  ]);
55
56  let out = "";
57  if (wake.status === "fulfilled" && wake.value.exitCode === 0) out = wake.value.stdout.trim();
58  else $.ui.log("segmem wake failed: " + (wake.status === "fulfilled"
59    ? wake.value.stderr.trim() : String(wake.reason)));
60
61  // What the prompt hook will ask the model to review, as a line the user
62  // can see. The <segmem-upkeep> block stays the model's trigger; this is a
63  // notice.
64  if (stale.status === "fulfilled" && stale.value.exitCode === 0) {
65    const n = Number(stale.value.stdout.trim());
66    $.ui.status(n > 0 ? "segmem: " + n + " under pressure" : undefined);
67  } else $.ui.log("segmem stale failed: " + (stale.status === "fulfilled"
68    ? stale.value.stderr.trim() : String(stale.reason)));
69
70  // A contradiction is the whole point of the altitudes, and it is easy to
71  // miss inside a long wake. Toast the ids so the user sees one without
72  // reading it. `wake --conflicts` answers as data and takes no claim: the
73  // command hook owns wake's text, and this is not serving that text.
74  if (conflicts.status === "fulfilled" && conflicts.value.exitCode === 0) {
75    const ids = conflicts.value.stdout.trim().split("\n").filter(Boolean);
76    if (ids.length) $.ui.toast("segmem: " + ids.join("; "), { timeoutMs: 8000 });
77  } else $.ui.log("segmem wake --conflicts failed: " + (conflicts.status === "fulfilled"
78    ? conflicts.value.stderr.trim() : String(conflicts.reason)));
79  return out;
80}
81
82export const register: Register = (on) => {
83  let once: Promise<string> | undefined;
84
85  on("prompt.context", async ($, e, next) => {
86    let wake: string;
87    try {
88      once ??= gather($);
89      wake = await once;
90    } catch (err) {
91      $.ui.log("segmem gather failed: " + String(err));
92      return next(e);
93    }
94    if (!wake) return next(e);
95    return next({ ...e, blocks: [...e.blocks, { name: "segmem", text: wake }] });
96  });
97
98  // Recall on every prompt, as the UserPromptSubmit command hook does. No
99  // origin filtering: parity with that hook is the default, so a scheduled
100  // or peer prompt gets the same memories a typed one does.
101  on("prompt.submit", async ($, e, next) => {
102    let found = "";
103    try {
104      const id = await $.session.id();
105      const r = await $.process.run(
106        [$.plugin.root + "/segmem", "hook", "--once",
107         "--session=" + id, "--served=function"],
108        {
109          cwd: await $.session.cwd(),
110          timeoutMs: TIMEOUT,
111          stdin: JSON.stringify({ prompt: e.text, session_id: id }),
112        },
113      );
114      if (r.exitCode === 0) found = r.stdout.trim();
115      else $.ui.log("segmem hook failed: " + r.stderr.trim());
116    } catch (err) {
117      $.ui.log("segmem hook failed: " + String(err));
118    }
119    const r = await next(e);
120    if (!found || r.drop !== undefined) return r;
121    return { ...r, context: [...(r.context ?? []), found] };
122  });
123
124  // The rule the parent is told to pass on, passed on by the harness instead.
125  // No claim: this writes the subagent's prompt, which no command hook can
126  // reach, so there is nothing to double. Never denies a spawn.
127  let doctrine: string | undefined;
128
129  on("agent.spawn", async ($, e, next) => {
130    // Cached only on success: one timeout must not strip the rule from
131    // every later spawn in this module's life.
132    if (doctrine === undefined) {
133      try {
134        const r = await $.process.run(
135          [$.plugin.root + "/segmem", "prompt", "--subagent"],
136          { cwd: await $.session.cwd(), timeoutMs: TIMEOUT },
137        );
138        if (r.exitCode === 0) doctrine = r.stdout.trim();
139        else $.ui.log("segmem prompt --subagent failed: " + r.stderr.trim());
140      } catch (err) {
141        $.ui.log("segmem prompt --subagent failed: " + String(err));
142      }
143    }
144    if (!doctrine) return next(e);
145    return next({ ...e, prompt: e.prompt + "\n\n" + doctrine });
146  });
147
148  // The note rules, before the shell runs rather than after it failed. The
149  // check writes nothing: Python re-runs its own length and tag checks on
150  // the command text and says refuse or warn. Any other Bash command passes
151  // untouched, and the substring test keeps the shell-out off that path.
152  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
153    // check-note reads only `note`, so `cd ~/Node/segmem && ...` pays no spawn.
154    if (!e.command.includes("segmem") || !e.command.includes("note")) return next(e);
155    let hint = "";
156    try {
157      const r = await $.process.run(
158        [$.plugin.root + "/segmem", "check-note"],
159        { cwd: await $.session.cwd(), timeoutMs: TIMEOUT, stdin: e.command },
160      );
161      if (r.exitCode !== 0) return { deny: r.stderr.trim() || r.stdout.trim() };
162      hint = r.stdout.trim();
163    } catch (err) {
164      $.ui.log("segmem check-note failed: " + String(err));
165    }
166    const r = await next(e);
167    if (!hint || r.deny !== undefined) return r;
168    return { ...r, context: [...(r.context ?? []), hint] };
169  });
170
171  // Recall keyed on the action, not the prompt: a fact tagged with the
172  // program a Bash command runs prints as that command is about to run.
173  // Same claim as the prompt hook, so the two paths never both speak.
174  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
175    let found = "";
176    try {
177      const id = await $.session.id();
178      const r = await $.process.run(
179        [$.plugin.root + "/segmem", "hook", "--tool", "--once",
180         "--session=" + id, "--served=function"],
181        {
182          cwd: await $.session.cwd(),
183          timeoutMs: TIMEOUT,
184          stdin: JSON.stringify({ tool_input: { command: e.command }, session_id: id }),
185        },
186      );
187      if (r.exitCode === 0) found = r.stdout.trim();
188      else $.ui.log("segmem hook --tool failed: " + r.stderr.trim());
189    } catch (err) {
190      $.ui.log("segmem hook --tool failed: " + String(err));
191    }
192    const r = await next(e);
193    if (!found || r.deny !== undefined) return r;
194    return { ...r, context: [...(r.context ?? []), found] };
195  });
196
197  // Recall as a tool the model can call directly. Bash recall keeps working
198  // everywhere, so an unflagged session loses nothing. `note` is not
199  // registered: it stays a Bash command, which is what keeps "subagents
200  // never note" true with no extra enforcement.
201  on("session.start", async ($, e, next) => {
202    try {
203      const t = await $.tool.register({
204        name: "recall",
205        description:
206          "Search segmem, the project's memory, across every kind and scope. " +
207          "Use it when a prompt names a person, component, branch, issue, or " +
208          "past decision.",
209        inputSchema: {
210          type: "object",
211          required: ["query"],
212          properties: { query: { type: "string", description: "words to search for" } },
213        },
214      });
215      $.ui.log("segmem registered " + t.tool);
216    } catch (err) {
217      $.ui.log("segmem tool.register failed: " + String(err));
218    }
219    return next(e);
220  });
221
222  on("tool.call", { tool: "mcp__segmem__recall" }, async ($, e, next) => {
223    try {
224      const r = await $.process.run(
225        [$.plugin.root + "/segmem", "recall", String(e.query ?? "")],
226        { cwd: await $.session.cwd(), timeoutMs: TIMEOUT },
227      );
228      if (r.exitCode === 0) return { result: r.stdout.trim() };
229      return { deny: r.stderr.trim() || "segmem recall failed" };
230    } catch (err) {
231      $.ui.log("segmem recall failed: " + String(err));
232      return next(e);
233    }
234  });
235};
236