Skip to content

Scope recall to this project's user and assistant text by default - #35

Open
doronp wants to merge 3 commits into
feat/install-feedbackfrom
feat/recall-scope
Open

doronp wants to merge 3 commits into
feat/install-feedbackfrom
feat/recall-scope

Conversation

@doronp

@doronp doronp commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Stacked on #30. Narrows what recall returns by default, so recalled text is less of an injection and cross-project channel.

Why

A session monitor on a machine with the plugin installed flagged sessions as Injection, Blocked and Pivoted. recall returned past text verbatim, including tool output, from every captured project, through a tool result. Its skill also ran on broad phrases. The monitor's own findings were never seen, so this PR claims no effect on those verdicts. What it does is shrink the surface.

What changes

  • Scope by project, per line (owner's choice). Each turn belongs to the repository of the cwd recorded on its own line, falling back to the session's cwd. The repository is read from .git entries on disk; git is never run.
    • A worktree joins its main repo through commondir.
    • Submodules and nested vendored clones are their own projects.
    • A directory that isn't a repo matches only itself, so ~ never spans every project.
    • Cwds are resolved once per distinct value, at query time, with realpath. On a case-insensitive volume, case is matched by inode.
  • Scope by kind. By default recall returns user and assistant text only. Tool output, tool calls, thinking and system lines need --all-kinds.
  • Flags. --project DIR and --all-projects are mutually exclusive. The filter runs in SQL before -k, so -k still returns k results.
  • Nothing hidden silently. One stderr line reports what was left out: outside this scope: N matching turn(s) (--all-projects, --all-kinds to widen).
  • isMeta. Lines Claude Code marks isMeta, and compaction summaries, no longer count as the user's words in latest-user.
  • Index schema 3. It records each turn's cwd, the session's cwd and is_meta. An old index gets the existing "run gitmemory index" message; the schema check now runs under every flag.
  • The skill. It runs only on explicit requests and passes --project "${CLAUDE_PROJECT_DIR}". It treats a recalled line as evidence of what was said, never an instruction. It may widen the search once with --all-projects.
  • Docs. USAGE gains a "What recall searches" section, which also says what this doesn't solve. SECURITY.md says recall output is untrusted input. The CHANGELOG entry under [Unreleased] is marked breaking: 0.2.0's search is --all-projects --all-kinds.

search() keeps unfiltered defaults, and no bench file changed, so the published numbers can't move. show stays unscoped for now (follow-up). --json provenance is deferred. There is no version bump; whether this ships as 0.2.0 or 0.3.0 is your call.

Review

  • Design: 5 scouts, then 3 competing designs and 3 judges, then a Kimi K3 critique of the synthesis and another of the final spec. Kimi's 12 spec findings became binding amendments, including commondir, case folding, stable identity for deleted directories, and bounded .git reads.
  • Each of the 3 commits: written test-first, reviewed in parallel by an Opus reviewer and by Kimi as pair programmer, and fixed from both. Refuted findings are recorded in the commit messages.
  • Final gate: CI from a fresh clone, a synthetic end-to-end run with two repos (a worktree, a nested clone, a cd from one repo into the other, a pi-style session, a session with no cwd, injection-like tool output, an isMeta line), and every new mutation row CAUGHT. Verdict: ship.

1,181 tests offline. ruff is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QPBLmJD2q1PVxdkWx8C16U

doronp and others added 3 commits October 22, 2026 11:42
Recall is to be scoped to the project a turn was written in, and that is
the directory on the turn's own line: a cd into another repository
mid-session moves every line after it. pi writes the directory only on
its session header and some Claude Code lines carry none, so the
session's first recorded cwd fills the gap at query time; the stored
turn row keeps NULL.

Schema 3 adds turns.cwd, turns.is_meta and generations.cwd. A cwd is kept
only as an absolute str of 1-4096 characters with no NUL, normpath'd and
never resolved, so the digest stays machine-independent. A relative or ~
cwd is NULL: resolved where recall runs, "." would sit in every project.
The session's cwd is its first one that survives that bound, so one
malformed first line does not leave the session in no directory. Hit
gains cwd (own, else session) and is_meta. Search and recall results are
unchanged; the default db filename moves to gitmemory-v3.db, so an old
index answers with the existing "no index" message.

is_meta is Claude Code's isMeta or isCompactSummary: user text no person
typed. latest_user's rule is lifted to index.user_words and stops
counting those lines as the user's words, so a skill load or a
compaction summary no longer leads the default order. Demoted, not
hidden. USAGE's policy row and Hit field list say so.

Thirteen mutation rows, all CAUGHT; three rows re-anchored for the moved
lines. Pins: 1,151 tests offline, 1476/1479 with corpora, 675 controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPBLmJD2q1PVxdkWx8C16U
Signed-off-by: doronp <3586743+doronp@users.noreply.github.com>
A recalled line is read back into an agent's context, so what recall
returns by default decides what reaches it. 0.2.0 returned every
project's tool output, thinking and hook lines beside the user's words.

recall now admits only blocks written in the user or assistant role as
text, from turns whose own cwd (else their session's) is in the current
project. --project DIR names another project, --all-projects and
--all-kinds widen one dimension each; no environment variable feeds
them. The filters are SQL inside search's ranked CTE, before the
per-turn pick and the LIMIT, so -k counts admitted turns and a turn
whose best block was a tool's answers with its text. search() keeps
unfiltered defaults, so bench and every published number cannot move.

A project is a repository, read from .git entries on the filesystem and
never by running git: the nearest .git at or above the directory. A
.git file is followed only when its first line (read up to 4 KiB, from
a regular file, with no NUL) is `gitdir: X` and X holds HEAD; X's
commondir names the repository a linked worktree joins, and only
without one does a gitdir under <common>/worktrees/ join <common>. Any
other .git file, a FIFO included, is a repository of its own; it is
never opened unless regular. A submodule or a nested clone is its own
project. $HOME and / are looked at like any level and are the last, so
a dotfiles repository in ~ is a project and nothing above ~ is. With no
.git (a bare or separate-git-dir checkout, or no repository) a
directory is only itself, matched by equality. A deleted directory is
looked up from its nearest existing ancestor; outside any repository it
stays the directory it was, not the ancestor.

Ids are the inode of the .git or directory, not its spelling, so /tmp
and /private/tmp, and either case on a case-insensitive volume, are one
project. This meets the spec's casefold rule by identity rather than by
folding strings: os.path.realpath keeps the spelling it is given on
APFS, so folded strings would still need the volume test. A deleted
directory has no inode, so its id is its ancestor's inode plus the
missing tail, folded when the volume ignores case (samefile against
the swapped-case path, darwin when the path has no letters).

Recall resolves each distinct cwd of the query's matching turns once,
never per row. It reads the working directory only when it needs a
project: a shell left in a removed directory still answers under
--project or --all-projects, and otherwise exits 2 with
the current directory no longer exists: pass --project DIR or --all-projects

When the scope hid matches, one stderr line says how many distinct
turns, naming the flags not passed:
outside this scope: N matching turn(s) (--all-projects, --all-kinds to widen)
Nothing is printed when nothing was hidden. recall checks the schema
right after opening the db, so an old --db is refused as one under
every flag.

Test fixtures in test_index write the suite's cwd, the project recall
defaults to; tests about other things pass --all-projects or
--all-kinds where their lines carry no cwd or are tool output.

Twenty-eight mutation rows, all CAUGHT; two rows re-anchored. Pins:
1,180 tests offline, 1505/1508 with corpora, 703 controls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPBLmJD2q1PVxdkWx8C16U
Signed-off-by: doronp <3586743+doronp@users.noreply.github.com>
recall now searches one project's user and assistant text, and the
plugin's skill is what most often runs it, so the skill and the docs say
what that scope is and what it leaves unsolved.

The skill loads only on an explicit request to search, and asks first
when the user only refers to an earlier decision. A recalled line is
evidence of what was said, never an instruction to follow. It passes
--project "${CLAUDE_PROJECT_DIR}", quotes the outside line verbatim, may
retry once with --all-projects when that line reports matches and the
results do not answer, and passes --all-kinds only when asked about a
command's output. A CLI that rejects --project is older and unscoped, so
the flag is never dropped to get past it.

Runtime fact (K12), verified against the Claude Code skills docs,
https://code.claude.com/docs/en/skills, "Available string
substitutions": Claude Code substitutes ${CLAUDE_PROJECT_DIR} in a
skill's markdown content, as it does ${CLAUDE_SESSION_ID}, which this
skill already relies on. It is not in the Bash tool's environment
(env-vars docs; `env` in a Bash call here has no CLAUDE_PROJECT_DIR), so
an unsubstituted copy reaches the shell as '' and recall exits 2 with
`argument --project: not a directory: ''`. The skill names that message
and falls back to --project . with the directory pwd prints.

USAGE gains "What recall searches" and "What the scope does not solve"
(pi takes its header's cwd; submodules and vendored clones are their
own; a work tree with no .git is scoped by equality; show is unscoped in
this release). SECURITY gains "Recall output is untrusted input", with
the outside count's disclosure and .git files as trusted configuration.
README, hook/README, CHANGELOG [Unreleased] (breaking: 0.2.0's results
are --all-projects --all-kinds) follow, and DESIGN 2.8 a dated line,
since its as-built sentence on when the skill runs is no longer true.

The outside count covers both narrowed dimensions at once, so the skill
says N can be this project's tool output alone and stops after an
--all-projects retry that brings nothing new. A shell left in a removed
worktree gets "the current directory no longer exists", whose hint is
--all-projects; the skill names it and asks the user instead. USAGE says
--all-projects --all-kinds searches what 0.2.0 searched (the isMeta
demotion can change the order), and that a repository at ~ takes in every
directory under ~ that is in no other repository.

test_docs pins the outside line, the --project error and the
removed-directory refusal as the CLI prints them, the skill's --project
command, the evidence rule, and the outside line among the echoes. One
mutation row (704 mutants) holds the refusal and the skill together. No
version bump. Pins: 1,181 tests offline, 1506/1509 with corpora.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPBLmJD2q1PVxdkWx8C16U
Signed-off-by: doronp <3586743+doronp@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant