openvibers/openvibe.link Commits

Memory: the Node reads what the coding tools remember (read-only, local, redacted) (#87)

OP OpenVibers committed 14 days ago · e9c30f4107 · 53 files, +3580 −1

* Memory: the Node reads what the coding tools remember (read-only, local, redacted)

One adapter per tool (Claude Code, AGENTS.md, Gemini CLI, Cursor, GitHub Copilot, Windsurf, Cline, Roo, Kiro, Continue, Aider, Command Code, OpenCode), each with the exact paths and the narrow globs of its own tool, and a parser that turns a file into memory items.

Every item is scrubbed before it leaves the parser: the patterns of Codes' harness plus the common gitleaks shapes (AWS, GitHub, Slack, Stripe, OpenAI/Anthropic, JWT, PEM, password/token/secret assignments, connection strings). An item that was only a secret is dropped.

openvibe-node memory scan [--tools a,b] [--project DIR …] [--json] lists every adapter, every path it looked at and the items per file. It writes nothing, opens no network connection and never follows a symlink out of the paths it was given.

* docs/memory: show the glob in the path examples as the scan prints it

* memory scan: the JSON prints empty lists instead of null

* Memory: the redaction scrubs the whole file before it splits it

A secret that spans two items was never matched: a key block whose BEGIN and
END lines land in different items, an .env block in a fence. The body of a
file is now scrubbed before it is split into items, and the frontmatter is
scrubbed before any of its values is used. A key block with no END line is
redacted from its BEGIN line to the end of its fence.

The patterns are wider: every PEM form, env-style assignments, prose, headers,
the vendor prefixes, the webhook URLs and the card numbers that pass the
Luhn check. Over-redaction is bounded.

The tests cover every shape, the split cases, the whole output of every
fixture and the over-redaction that stays bounded.

Changes

ddiff --git a/.gitleaksignore b/.gitleaksignore
nnew file mode 100644
iindex 0000000..9bc8da0
---- /dev/null
++++ b/.gitleaksignore
@@@ -0,0 +1,12 @@
++# The memory fixtures hold fake secrets on purpose: every adapter
++# carries one secret of a different shape, and the test proves none of them
++# reaches an item. The Claude Code fixture carries more: a key block that
++# is split over two items, a key block with no END line, and an .env block.
++# The values are obviously fake (FAKE, EXAMPLE, repeated letters) and exist
++# only to be redacted.
++#
++# Each line is the exact fingerprint of a finding (file:rule:line).
++internal/memory/testdata/roo/project/.roo/rules/general.md:stripe-access-token:4
++internal/memory/testdata/copilot/project/.github/copilot-instructions.md:jwt:5
++internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md:private-key:7
++internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md:private-key:6
ddiff --git a/README.md b/README.md
iindex 64e6aa2..00fae6a 100644
---- a/README.md
++++ b/README.md
@@@ -32,6 +32,7 @@ openvibe-node resume
openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcher
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
++openvibe-node memory scan # what your coding tools remember: read-only, local, secrets removed
```
## What is in here
@@@ -70,7 +71,8 @@ job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.
task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
--protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).
++protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
++[memory](docs/memory.md) (what the coding tools on a machine remember, and how the Node reads it).
Decisions: [ADR-043](docs/ADR-043-bot-devices-and-control.md).
## Development
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 5398530..3554cff 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -11,6 +11,8 @@
// openvibe-node resume release the local kill switch (and a server e-stop)
// openvibe-node quit end the running node cleanly: every actuator stops, nothing is latched
// openvibe-node plugins list plugins and their capabilities
++// openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
++// read the memory files of the coding tools (read-only, local)
// openvibe-node version
package main
@@@ -72,6 +74,9 @@ Usage:
openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...
run one desk's browser confined (a desk host that serves other people; the computer plugin
calls it, with --remote-debugging-pipe on fds 3 and 4)
++ openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
++ read the memory files of the coding tools on this machine
++ (read-only, local, secrets removed)
openvibe-node version
Global flags (before or after the command):
@@@ -101,6 +106,10 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 0
}
cmd, rest := args[0], args[1:]
++ if cmd == "memory" {
++ // The memory scan has its own flags, so it parses its own arguments.
++ return cmdMemory(rest, stdout, stderr)
++ }
if cmd == "desk-sandbox" { // its own flags, up to "--": a browser's flags after it are never the Node's
return cmdDeskSandbox(rest, stderr)
}
ddiff --git a/cmd/openvibe-node/memory.go b/cmd/openvibe-node/memory.go
nnew file mode 100644
iindex 0000000..a4a3e99
---- /dev/null
++++ b/cmd/openvibe-node/memory.go
@@@ -0,0 +1,138 @@
++package main
++
++import (
++ "encoding/json"
++ "flag"
++ "fmt"
++ "io"
++ "os"
++ "path/filepath"
++ "sort"
++ "strings"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/memory"
++)
++
++// memoryUsage is what "openvibe-node memory" prints.
++const memoryUsage = "openvibe-node memory scan [--tools a,b] [--project DIR] [--json]\n" +
++ "\n" +
++ " Reads the memory files of the coding tools on this machine and prints what it\n" +
++ " found. It is read-only and local: nothing is written and nothing is sent\n" +
++ " anywhere. Secrets are removed before anything is printed.\n" +
++ "\n" +
++ " --tools a,b only these adapters (claude-code, agents, gemini, cursor,\n" +
++ " copilot, windsurf, cline, roo, kiro, continue, aider,\n" +
++ " command-code, opencode)\n" +
++ " --project DIR read this project as well; may be given more than once\n" +
++ " --json print the items as JSON\n" +
++ " --user-home DIR read this directory as the person's home (default: yours)\n"
++
++// stringList is a flag that may be given more than once.
++type stringList []string
++
++func (s *stringList) String() string { return strings.Join(*s, ",") }
++
++func (s *stringList) Set(v string) error {
++ for _, part := range strings.Split(v, ",") {
++ part = strings.TrimSpace(part)
++ if part != "" {
++ *s = append(*s, part)
++ }
++ }
++ return nil
++}
++
++// cmdMemory runs the memory commands.
++func cmdMemory(args []string, stdout, stderr io.Writer) int {
++ if len(args) == 0 || args[0] != "scan" {
++ fmt.Fprint(stderr, memoryUsage)
++ return 2
++ }
++ fs := flag.NewFlagSet("memory scan", flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.Usage = func() { fmt.Fprint(stderr, memoryUsage) }
++ var tools stringList
++ var projects stringList
++ fs.Var(&tools, "tools", "only these adapters")
++ fs.Var(&projects, "project", "read this project as well")
++ asJSON := fs.Bool("json", false, "print the items as JSON")
++ userHome := fs.String("user-home", "", "read this directory as the person's home")
++ if err := fs.Parse(args[1:]); err != nil {
++ return 2
++ }
++ if fs.NArg() > 0 {
++ fmt.Fprintf(stderr, "unknown argument %q\n\n", fs.Arg(0))
++ fmt.Fprint(stderr, memoryUsage)
++ return 2
++ }
++ home := *userHome
++ if home == "" {
++ h, err := os.UserHomeDir()
++ if err != nil {
++ fmt.Fprintln(stderr, "error: cannot find your home directory; pass --user-home")
++ return 1
++ }
++ home = h
++ }
++ abs, err := filepath.Abs(home)
++ if err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ res := memory.Scan(memory.Options{
++ Home: abs,
++ Projects: projects,
++ Tools: tools,
++ })
++ if *asJSON {
++ enc := json.NewEncoder(stdout)
++ enc.SetIndent("", " ")
++ if err := enc.Encode(res); err != nil {
++ fmt.Fprintln(stderr, "error:", err)
++ return 1
++ }
++ return 0
++ }
++ printScan(res, stdout)
++ return 0
++}
++
++// printScan prints what the scan found: every adapter, every path it looked
++// at and the items per file.
++func printScan(res memory.Result, stdout io.Writer) {
++ fmt.Fprintf(stdout, "Memory scan of %s\n", res.Home)
++ if len(res.Projects) == 0 {
++ fmt.Fprintln(stdout, "No project given: only the user-level files were read.")
++ } else {
++ fmt.Fprintf(stdout, "Projects: %s\n", strings.Join(res.Projects, ", "))
++ }
++ for _, ad := range res.Adapters {
++ fmt.Fprintf(stdout, "\n%s (%s) — %d items\n", ad.Name, ad.ID, ad.Items)
++ fmt.Fprintf(stdout, " docs: %s\n", ad.Docs)
++ if len(ad.Files) == 0 {
++ fmt.Fprintln(stdout, " no path to look at")
++ continue
++ }
++ for _, f := range ad.Files {
++ switch {
++ case f.Note != "":
++ fmt.Fprintf(stdout, " - %s: %s\n", f.Path, f.Note)
++ case f.Found:
++ fmt.Fprintf(stdout, " - %s: %d items\n", f.Path, f.Items)
++ default:
++ fmt.Fprintf(stdout, " - %s: not there\n", f.Path)
++ }
++ }
++ }
++ fmt.Fprintf(stdout, "\n%d items in total. Nothing was written and nothing was sent.\n", len(res.Items))
++}
++
++// memoryToolIDs lists the adapter ids, for the usage text and the tests.
++func memoryToolIDs() []string {
++ var out []string
++ for _, ad := range memory.Adapters() {
++ out = append(out, ad.ID)
++ }
++ sort.Strings(out)
++ return out
++}
ddiff --git a/cmd/openvibe-node/memory_test.go b/cmd/openvibe-node/memory_test.go
nnew file mode 100644
iindex 0000000..aa26839
---- /dev/null
++++ b/cmd/openvibe-node/memory_test.go
@@@ -0,0 +1,164 @@
++package main
++
++import (
++ "bytes"
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++)
++
++// adapterJSON is the JSON shape of one adapter of the scan.
++type adapterJSON struct {
++ ID string `json:"id"`
++ Files []fileJSON `json:"files"`
++}
++
++// fileJSON is the JSON shape of one file of the scan.
++type fileJSON struct {
++ Path string `json:"path"`
++ Found bool `json:"found"`
++}
++
++// itemJSON is the JSON shape of one memory item.
++type itemJSON struct {
++ Text string `json:"text"`
++ Kind string `json:"kind"`
++ Scope string `json:"scope"`
++ Source string `json:"source"`
++}
++
++// memoryFixture is the fixture home of the memory package, used here so the
++// test never reads the real home of the machine.
++func memoryFixture(t *testing.T) string {
++ t.Helper()
++ wd, err := os.Getwd()
++ if err != nil {
++ t.Fatal(err)
++ }
++ return filepath.Join(wd, "..", "..", "internal", "memory", "testdata", "claude-code", "home")
++}
++
++// TestMemoryScanPrintsEveryAdapter checks the human output of the scan.
++func TestMemoryScanPrintsEveryAdapter(t *testing.T) {
++ home := memoryFixture(t)
++ var stdout, stderr bytes.Buffer
++ code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr)
++ if code != 0 {
++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
++ }
++ out := stdout.String()
++ for _, want := range []string{
++ "Claude Code (claude-code)",
++ "AGENTS.md (Codex and friends)",
++ "Gemini CLI",
++ "Cursor",
++ "GitHub Copilot",
++ "Windsurf",
++ "Cline",
++ "Roo Code",
++ "Kiro",
++ "Continue",
++ "Aider",
++ "Command Code",
++ "OpenCode",
++ "not there",
++ "docs: https://",
++ } {
++ if !strings.Contains(out, want) {
++ t.Errorf("the output has no %q: %s", want, out)
++ }
++ }
++ if !strings.Contains(out, "Nothing was written and nothing was sent") {
++ t.Errorf("the output does not say what it did: %s", out)
++ }
++}
++
++// TestMemoryScanJSON checks the JSON output of the scan.
++func TestMemoryScanJSON(t *testing.T) {
++ home := memoryFixture(t)
++ var stdout, stderr bytes.Buffer
++ code := cmdMemory([]string{"scan", "--user-home", home, "--json"}, &stdout, &stderr)
++ if code != 0 {
++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
++ }
++ var res struct {
++ Home string `json:"home"`
++ Adapters []adapterJSON `json:"adapters"`
++ Items []itemJSON `json:"items"`
++ }
++ if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
++ t.Fatalf("the output is not JSON: %v", err)
++ }
++ if len(res.Adapters) != len(memoryToolIDs()) {
++ t.Errorf("the JSON lists %d adapters, want %d", len(res.Adapters), len(memoryToolIDs()))
++ }
++ if len(res.Items) == 0 {
++ t.Error("the JSON has no items")
++ }
++ for _, it := range res.Items {
++ if it.Source == "" || it.Kind == "" || it.Scope == "" {
++ t.Errorf("an item is missing a field: %+v", it)
++ }
++ if strings.Contains(it.Text, "sk-proj") {
++ t.Errorf("the JSON still shows a secret: %q", it.Text)
++ }
++ }
++}
++
++// TestMemoryScanToolsFilter checks that --tools keeps only the adapters asked
++// for.
++func TestMemoryScanToolsFilter(t *testing.T) {
++ home := memoryFixture(t)
++ var stdout, stderr bytes.Buffer
++ code := cmdMemory([]string{"scan", "--user-home", home, "--tools", "cursor", "--json"}, &stdout, &stderr)
++ if code != 0 {
++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
++ }
++ if strings.Contains(stdout.String(), "\"id\": \"claude-code\"") {
++ t.Errorf("the scan read an adapter that was not asked for: %s", stdout.String())
++ }
++}
++
++// TestMemoryScanUsage checks that a wrong use says how to use it.
++func TestMemoryScanUsage(t *testing.T) {
++ var stdout, stderr bytes.Buffer
++ if code := cmdMemory([]string{"list"}, &stdout, &stderr); code != 2 {
++ t.Errorf("the command returned %d, want 2", code)
++ }
++ if !strings.Contains(stderr.String(), "memory scan") {
++ t.Errorf("the error does not say how to use it: %s", stderr.String())
++ }
++}
++
++// TestMemoryScanIsReadOnly checks that the scan writes nothing.
++func TestMemoryScanIsReadOnly(t *testing.T) {
++ home := memoryFixture(t)
++ before := fixtureState(t, home)
++ var stdout, stderr bytes.Buffer
++ if code := cmdMemory([]string{"scan", "--user-home", home}, &stdout, &stderr); code != 0 {
++ t.Fatalf("the scan failed with %d: %s", code, stderr.String())
++ }
++ after := fixtureState(t, home)
++ if before != after {
++ t.Errorf("the scan changed the fixture: %q -> %q", before, after)
++ }
++}
++
++// fixtureState is a cheap summary of the files under a directory.
++func fixtureState(t *testing.T, dir string) string {
++ t.Helper()
++ var out []string
++ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
++ if err != nil {
++ return nil
++ }
++ out = append(out, path+":"+info.ModTime().String())
++ return nil
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ return strings.Join(out, "|")
++}
ddiff --git a/docs/memory.md b/docs/memory.md
nnew file mode 100644
iindex 0000000..89d79cd
---- /dev/null
++++ b/docs/memory.md
@@@ -0,0 +1,217 @@
++# Memory: the Node reads what your tools remember
++
++The Node reads the memory files of the coding tools on your machine and tells
++you what it found. It is **read-only, local and redacted**:
++
++- it opens the files it knows about and nothing else;
++- it never writes a file and never opens a network connection;
++- it removes the secrets before anything is printed;
++- it never follows a symlink out of the home folder or the project folders it
++ was given.
++
++This page is stage M1 of OpenVibe Memory: the readers. The store, the
++encryption, the upload and the page come later.
++
++## The command
++
++```sh
++openvibe-node memory scan [--tools a,b] [--project DIR …] [--json]
++```
++
++| Flag | What it does |
++|---|---|
++| `--tools a,b` | only these adapters (default: all) |
++| `--project DIR` | read this project as well; may be given more than once |
++| `--json` | print the items as JSON |
++| `--user-home DIR` | read this directory as the person's home (default: yours) |
++
++Without `--project` only the user-level files are read: the files that hold
++your own preferences across every project. With `--project` the project files
++are read too, and their items carry the scope `project:<absolute path>`.
++
++The command prints every adapter, every path it looked at and whether it was
++there, and how many items each file gave:
++
++```
++Memory scan of /home/you
++No project given: only the user-level files were read.
++
++Claude Code (claude-code) — 3 items
++ docs: https://code.claude.com/docs/en/memory
++ - /home/you/.claude/CLAUDE.md: 3 items
++ - /home/you/.claude/rules/**/*.md: not there
++...
++```
++
++## The adapters
++
++Each adapter is data: where to look, and how to read what is there.
++
++| Adapter | id | What it reads | Docs |
++|---|---|---|---|
++| Claude Code | `claude-code` | `~/.claude/CLAUDE.md`, `~/.claude/rules/**/*.md`, `~/.claude/projects/*/memory/*.md` (the `MEMORY.md` index is skipped), and in a project `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `.claude/rules/**/*.md` | [code.claude.com/docs/en/memory](https://code.claude.com/docs/en/memory) |
++| AGENTS.md | `agents` | `~/.codex/AGENTS.md` and the project's `AGENTS.md` | [agents.md](https://agents.md/) |
++| Gemini CLI | `gemini` | `~/.gemini/GEMINI.md` and the project's `GEMINI.md` or `.gemini/GEMINI.md` | [geminicli.com/docs/cli/gemini-md](https://geminicli.com/docs/cli/gemini-md/) |
++| Cursor | `cursor` | the project's `.cursor/rules/*.mdc` | [cursor.com/docs/context/rules](https://cursor.com/docs/context/rules) |
++| GitHub Copilot | `copilot` | the project's `.github/copilot-instructions.md` and `.github/instructions/*.instructions.md` | [docs.github.com](https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot) |
++| Windsurf | `windsurf` | `~/.codeium/windsurf/memories/**` and the project's `.windsurf/rules/**` | [docs.windsurf.com](https://docs.windsurf.com/windsurf/cascade/memories) |
++| Cline | `cline` | the project's `.clinerules`, `.clinerules/**` and `memory-bank/*.md` | [docs.cline.bot/features/cline-rules](https://docs.cline.bot/features/cline-rules) |
++| Roo Code | `roo` | the project's `.roo/rules/**` and `memory-bank/*.md` | [docs.roocode.com](https://docs.roocode.com/features/custom-instructions) |
++| Kiro | `kiro` | `~/.kiro/steering/*.md` and the project's `.kiro/steering/*.md` | [kiro.dev/docs/steering](https://kiro.dev/docs/steering/) |
++| Continue | `continue` | `~/.continue/rules/**` and the project's `.continue/rules/**` | [docs.continue.dev/customize/rules](https://docs.continue.dev/customize/rules) |
++| Aider | `aider` | the project's `CONVENTIONS.md` and the files its `.aider.conf.yml` names under `read:` | [aider.chat/docs/usage/conventions](https://aider.chat/docs/usage/conventions.html) |
++| Command Code | `command-code` | `~/.commandcode/**` and the project's `.commandcode/**` | [commandcode.ai/docs/memory](https://commandcode.ai/docs/memory) |
++| OpenCode | `opencode` | `~/.config/opencode/AGENTS.md`, the project's `AGENTS.md` and the files its `opencode.json` names under `instructions` | [opencode.ai/docs/rules](https://opencode.ai/docs/rules/) |
++
++### What is never read
++
++- The chat history of any tool (Aider's `.aider.chat.history.md`, Claude's
++ transcripts, Continue's sessions).
++- The settings, the hooks, the skills and the plugins of any tool.
++- Any folder that is not one of the paths above. The home folder itself is
++ never read as a whole: each adapter names the exact files and the narrow
++ globs of its own tool, and a glob always starts at a fixed folder.
++- A file over 256 KB (it is skipped with a note) and a binary file.
++
++## How a file becomes items
++
++- The frontmatter is read, not turned into items.
++- A markdown file is split by its headings: every top-level bullet is one
++ item, and every paragraph under a heading is one item.
++- An item is at most 2,000 characters; a longer one is cut and marked
++ `truncated`.
++- An item that is only a secret is dropped.
++
++## The kinds
++
++| Kind | What it is |
++|---|---|
++| `preference` | how the person wants things done |
++| `fact` | a fact about the person or the project |
++| `project_rule` | a rule that holds in a project |
++| `procedure` | a way of doing something |
++| `decision` | a decision that was taken, with its reason |
++| `lesson` | something that was learned the hard way |
++
++The kind comes from the tool when it says it (Claude Code's auto memory writes
++a `metadata.type` in its frontmatter: `user`, `feedback`, `project` or
++`reference`), otherwise from the heading the item came from, otherwise from the
++scope: a file of the person is about them, a file of a project holds that
++project's rules.
++
++## The secrets
++
++Nothing leaves the parser without being scrubbed first. The scrubbing runs over
++the **whole file body before it is split into items**, and again over every
++item. A secret that spans two items — a key block whose BEGIN and END lines
++land in different items, an `.env` block in a fence — is only seen by the
++whole-file pass. The frontmatter is scrubbed too, before any of its values is
++used: a `description` becomes the heading of the item.
++
++The patterns are the ones of OpenVibe.Codes' harness (`harness/events.js`,
++`SECRET_PATTERNS`) plus the common gitleaks shapes and the shapes that turn up
++in a person's own files:
++
++- OpenAI- and Anthropic-style keys (`sk-…`), Stripe keys (`sk_live_…`);
++- GitHub tokens (`ghp_…`, `gho_…`, `github_pat_…`), GitLab (`glpat-…`);
++- AWS keys (`AKIA…`, `ASIA…`) and `aws_secret_access_key`;
++- Slack tokens (`xoxb-…`) and Slack app tokens (`xapp-…`);
++- Google (`AIza…`), npm (`npm_…`), Hugging Face (`hf_…`), SendGrid
++ (`SG.….…`), Telegram and Discord bot tokens, Twilio keys;
++- JWTs;
++- **every PEM private key form** — RSA, EC, DSA, OPENSSH, PGP and ENCRYPTED —
++ from `-----BEGIN … PRIVATE KEY …-----` to its END line. A key block with
++ **no END line** (a truncated file, a cut paste) is redacted from its BEGIN
++ line to the end of its fenced block, or to the end of the text when it is
++ not in a fence;
++- **env-style assignments**: a name of `[A-Za-z0-9_.-]*` that ends in
++ `password`, `passwd`, `pwd`, `passphrase`, `secret`, `token`, `api_key`,
++ `apikey`, `api-key`, `access_key`, `private_key`, `client_secret`, `auth`,
++ `credential`, `credentials`, `cookie` or `session`, followed by `=`, `:` or
++ `=>` and a value. The name and the separator stay, the value goes; a quoted
++ value keeps its quotes;
++- **prose**: "the password is …", "the passphrase was …";
++- **headers**: `Authorization: Bearer …`, `Authorization: Basic …`,
++ `x-api-key: …`, and a bare `Bearer …`;
++- **webhook URLs** of Slack and Discord, whole;
++- **card numbers**: 13 to 19 digits, with spaces or dashes, that pass the Luhn
++ check. A long number that does not pass it stays: an order number is not a
++ card;
++- connection strings that carry a password.
++
++Each match becomes `[redacted]`. The item is marked `redacted` and keeps a
++count of how many secrets were removed; the values are never kept. An item
++that held nothing but a secret is dropped.
++
++Over-redaction is preferred over a leak: a harmless `max_tokens: 100000` is
++redacted too. It stays bounded — "use 4 spaces for indentation", "the build
++takes 12 minutes" and a 10-digit number that fails the Luhn check are left
++alone.
++
++Every adapter's fixture in `internal/memory/testdata` holds one secret of a
++different shape, and the test proves that none of them reaches an item. The
++Claude Code fixture holds more: a key block split over two items, a key block
++with no END line, and an `.env` block.
++
++## The JSON
++
++```json
++{
++ "home": "/home/you",
++ "projects": ["/srv/work/demo"],
++ "adapters": [
++ {
++ "id": "claude-code",
++ "name": "Claude Code",
++ "docs": "https://code.claude.com/docs/en/memory",
++ "files": [
++ {
++ "path": "/home/you/.claude/CLAUDE.md",
++ "found": true,
++ "items": 3
++ },
++ {
++ "path": "/home/you/.claude/rules/**/*.md",
++ "found": false
++ }
++ ],
++ "items": 3
++ }
++ ],
++ "items": [
++ {
++ "text": "Prefer tabs in Go files",
++ "kind": "preference",
++ "scope": "user",
++ "source": "claude-code",
++ "path": "/home/you/.claude/CLAUDE.md",
++ "updated": "2026-10-30T15:06:16Z",
++ "heading": "Personal setup"
++ }
++ ]
++}
++```
++
++An item may also carry:
++
++| Field | Meaning |
++|---|---|
++| `redacted` | the item held a secret that was removed |
++| `redactions` | how many secrets were removed |
++| `truncated` | the item was longer than 2,000 characters |
++
++A file that was looked at but could not be used carries a `note`:
++`skipped: larger than 256 KB`, `skipped: a binary file`,
++`skipped: link leaves the scanned paths`.
++
++## The safety tests
++
++`internal/memory` has a test for each of these:
++
++- no adapter ever points at the home folder, and no glob is wider than the
++ tool's own folders;
++- a scan without a project reads only the user-level files;
++- a symlink out of the scanned paths is not followed;
++- a file over 256 KB and a binary file are skipped;
++- the package does not import `net/http`: it never opens a network
++ connection.
ddiff --git a/internal/memory/adapters.go b/internal/memory/adapters.go
nnew file mode 100644
iindex 0000000..98f0f63
---- /dev/null
++++ b/internal/memory/adapters.go
@@@ -0,0 +1,535 @@
++package memory
++
++import (
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "strings"
++)
++
++// Adapters returns every memory adapter, in a fixed order.
++func Adapters() []Adapter {
++ return []Adapter{
++ claudeCodeAdapter(),
++ agentsAdapter(),
++ geminiAdapter(),
++ cursorAdapter(),
++ copilotAdapter(),
++ windsurfAdapter(),
++ clineAdapter(),
++ rooAdapter(),
++ kiroAdapter(),
++ continueAdapter(),
++ aiderAdapter(),
++ commandCodeAdapter(),
++ openCodeAdapter(),
++ }
++}
++
++// indexFilesOf says which files of an adapter are only an index: they hold
++// the names of the memories, not the memories themselves.
++func indexFilesOf(ad Adapter) []string {
++ if ad.ID == "claude-code" {
++ return []string{"MEMORY.md"}
++ }
++ return nil
++}
++
++// parseOptions say how one tool's files are read.
++type parseOptions struct {
++ // kindFromType reads the kind from the frontmatter "metadata.type".
++ kindFromType bool
++ // indexFiles are files that are only an index and hold no memory.
++ indexFiles []string
++}
++
++// parseMarkdown turns a markdown-like file into items.
++//
++// The frontmatter is read, not turned into items. Every top-level bullet and
++// every paragraph under a heading becomes one item. Secrets are removed from
++// every item before it leaves the parser, and an item that was nothing but a
++// secret is dropped.
++func parseMarkdown(opts parseOptions, path string, data []byte) []Item {
++ fm, body := splitFrontmatter(data)
++ for _, name := range opts.indexFiles {
++ if strings.EqualFold(filepath.Base(path), name) {
++ return nil
++ }
++ }
++ // The frontmatter is read, not turned into items, but its values may hold
++ // a secret: they are scrubbed before any of them is used. The sentinel
++ // marker is used here too, so that a secret in the frontmatter is still
++ // counted on the item that carries it.
++ for k, v := range fm {
++ fm[k], _ = redactWith(v, secretMark)
++ }
++ // The whole body is scrubbed before it is split into items. A secret that
++ // spans two items (a key in a fenced block, an .env block) is only seen
++ // here. The marker is a sentinel, so that each item can still say how
++ // many secrets it holds.
++ body, _ = redactWith(body, secretMark)
++ var fileKind Kind
++ if opts.kindFromType {
++ fileKind = kindFromType(fm)
++ }
++ var items []Item
++ for _, md := range markdownItems(body) {
++ // The list marker is not part of the memory.
++ text := stripListMarker(md.Text)
++ heading := md.Heading
++ if fm["description"] != "" {
++ // The tool's own name for the rule wins: it is what the
++ // person wrote for the tool.
++ heading = fm["description"]
++ }
++ // The body was scrubbed before it was split, so the secrets it held
++ // are already marked: the item counts them.
++ count := strings.Count(text, secretMark) + strings.Count(heading, secretMark)
++ text = strings.ReplaceAll(text, secretMark, Redacted)
++ heading = strings.ReplaceAll(heading, secretMark, Redacted)
++ // The item is scrubbed again on its own: cheap, and it catches a
++ // secret that only appears once the body is split.
++ var more int
++ text, more = Redact(text)
++ heading, hmore := Redact(heading)
++ count += more + hmore
++ if onlySecret(stripListMarker(text)) {
++ // The item was nothing but a secret: it is dropped.
++ continue
++ }
++ kind := fileKind
++ if kind == "" {
++ // The heading of the section gives the kind; when it says
++ // nothing, the scope of the file gives the fallback.
++ kind = kindFromHeading(heading, "")
++ }
++ item := Item{Text: text, Kind: kind, Heading: heading}
++ if count > 0 {
++ item.Redacted = true
++ item.Redactions = count
++ }
++ if len([]rune(item.Text)) > MaxItemRunes {
++ item.Text = string([]rune(item.Text)[:MaxItemRunes]) + "\u2026"
++ item.Truncated = true
++ }
++ items = append(items, item)
++ }
++ return items
++}
++
++// stripListMarker takes the list marker off an item, so that an item that
++// holds nothing but a secret is seen for what it is.
++func stripListMarker(text string) string {
++ trimmed := strings.TrimSpace(text)
++ if m := bulletLine.FindString(trimmed); m != "" {
++ trimmed = strings.TrimSpace(strings.TrimPrefix(trimmed, m))
++ }
++ return trimmed
++}
++
++// kindFromType reads the kind of a memory from the frontmatter of an
++// auto-memory file. Claude Code writes one of user, feedback, project or
++// reference; other tools may already write a kind of ours.
++func kindFromType(fm map[string]string) Kind {
++ t := strings.ToLower(strings.TrimSpace(fm["metadata.type"]))
++ switch t {
++ case "":
++ return ""
++ case "user", "preference", "preferences":
++ return KindPreference
++ case "feedback", "lesson", "lessons":
++ return KindLesson
++ case "project", "decision", "decisions":
++ return KindDecision
++ case "reference", "fact", "facts":
++ return KindFact
++ case "procedure", "skill":
++ return KindProcedure
++ case "project_rule":
++ return KindProjectRule
++ }
++ return ""
++}
++
++// claudeCodeAdapter reads what Claude Code remembers.
++//
++// Docs: https://code.claude.com/docs/en/memory
++func claudeCodeAdapter() Adapter {
++ return Adapter{
++ ID: "claude-code",
++ Name: "Claude Code",
++ Docs: "https://code.claude.com/docs/en/memory",
++ Locations: func(home string, projects []string) []string {
++ out := []string{
++ filepath.Join(home, ".claude", "CLAUDE.md"),
++ filepath.Join(home, ".claude", "rules", "**", "*.md"),
++ filepath.Join(home, ".claude", "projects", "*", "memory", "*.md"),
++ }
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, "CLAUDE.md"),
++ filepath.Join(p, ".claude", "CLAUDE.md"),
++ filepath.Join(p, "CLAUDE.local.md"),
++ filepath.Join(p, ".claude", "rules", "**", "*.md"),
++ )
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{kindFromType: true, indexFiles: []string{"MEMORY.md"}}, path, data)
++ },
++ }
++}
++
++// agentsAdapter reads the AGENTS.md family.
++//
++// Docs: https://agents.md/
++func agentsAdapter() Adapter {
++ return Adapter{
++ ID: "agents",
++ Name: "AGENTS.md (Codex and friends)",
++ Docs: "https://agents.md/",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".codex", "AGENTS.md")}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, "AGENTS.md"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// geminiAdapter reads what Gemini CLI remembers.
++//
++// Docs: https://geminicli.com/docs/cli/gemini-md/
++func geminiAdapter() Adapter {
++ return Adapter{
++ ID: "gemini",
++ Name: "Gemini CLI",
++ Docs: "https://geminicli.com/docs/cli/gemini-md/",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".gemini", "GEMINI.md")}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, "GEMINI.md"),
++ filepath.Join(p, ".gemini", "GEMINI.md"),
++ )
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// cursorAdapter reads the Cursor rules.
++//
++// Docs: https://cursor.com/docs/context/rules
++func cursorAdapter() Adapter {
++ return Adapter{
++ ID: "cursor",
++ Name: "Cursor",
++ Docs: "https://cursor.com/docs/context/rules",
++ Locations: func(home string, projects []string) []string {
++ out := []string{}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, ".cursor", "rules", "*.mdc"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// copilotAdapter reads the GitHub Copilot instructions.
++//
++// Docs: https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
++func copilotAdapter() Adapter {
++ return Adapter{
++ ID: "copilot",
++ Name: "GitHub Copilot",
++ Docs: "https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot",
++ Locations: func(home string, projects []string) []string {
++ out := []string{}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, ".github", "copilot-instructions.md"),
++ filepath.Join(p, ".github", "instructions", "*.instructions.md"),
++ )
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// windsurfAdapter reads the Windsurf rules and memories.
++//
++// Docs: https://docs.windsurf.com/windsurf/cascade/memories
++func windsurfAdapter() Adapter {
++ return Adapter{
++ ID: "windsurf",
++ Name: "Windsurf",
++ Docs: "https://docs.windsurf.com/windsurf/cascade/memories",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".codeium", "windsurf", "memories", "**")}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, ".windsurf", "rules", "**"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// clineAdapter reads the Cline rules and the memory bank.
++//
++// Docs: https://docs.cline.bot/features/cline-rules
++func clineAdapter() Adapter {
++ return Adapter{
++ ID: "cline",
++ Name: "Cline",
++ Docs: "https://docs.cline.bot/features/cline-rules",
++ Locations: func(home string, projects []string) []string {
++ out := []string{}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, ".clinerules"),
++ filepath.Join(p, ".clinerules", "**"),
++ filepath.Join(p, "memory-bank", "*.md"),
++ )
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// rooAdapter reads the Roo rules and the memory bank.
++//
++// Docs: https://docs.roocode.com/features/custom-instructions
++func rooAdapter() Adapter {
++ return Adapter{
++ ID: "roo",
++ Name: "Roo Code",
++ Docs: "https://docs.roocode.com/features/custom-instructions",
++ Locations: func(home string, projects []string) []string {
++ out := []string{}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, ".roo", "rules", "**"),
++ filepath.Join(p, "memory-bank", "*.md"),
++ )
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// kiroAdapter reads the Kiro steering files.
++//
++// Docs: https://kiro.dev/docs/steering/
++func kiroAdapter() Adapter {
++ return Adapter{
++ ID: "kiro",
++ Name: "Kiro",
++ Docs: "https://kiro.dev/docs/steering/",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".kiro", "steering", "*.md")}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, ".kiro", "steering", "*.md"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// continueAdapter reads the Continue rules.
++//
++// Docs: https://docs.continue.dev/customize/rules
++func continueAdapter() Adapter {
++ return Adapter{
++ ID: "continue",
++ Name: "Continue",
++ Docs: "https://docs.continue.dev/customize/rules",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".continue", "rules", "**")}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, ".continue", "rules", "**"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// aiderAdapter reads the Aider conventions file and the files its config
++// names. It never reads the chat history.
++//
++// Docs: https://aider.chat/docs/usage/conventions.html
++func aiderAdapter() Adapter {
++ return Adapter{
++ ID: "aider",
++ Name: "Aider",
++ Docs: "https://aider.chat/docs/usage/conventions.html",
++ Locations: func(home string, projects []string) []string {
++ out := []string{}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, "CONVENTIONS.md"),
++ filepath.Join(p, ".aider.conf.yml"),
++ )
++ out = append(out, readListFromConfig(filepath.Join(p, ".aider.conf.yml"), p)...)
++ }
++ out = append(out,
++ filepath.Join(home, "CONVENTIONS.md"),
++ filepath.Join(home, ".aider.conf.yml"),
++ )
++ out = append(out, readListFromConfig(filepath.Join(home, ".aider.conf.yml"), home)...)
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ if strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml") {
++ // The config itself is not memory; only the files it names are.
++ return nil
++ }
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// commandCodeAdapter reads the Command Code memory folder.
++//
++// Docs: https://commandcode.ai/docs/memory
++func commandCodeAdapter() Adapter {
++ return Adapter{
++ ID: "command-code",
++ Name: "Command Code",
++ Docs: "https://commandcode.ai/docs/memory",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".commandcode", "**")}
++ for _, p := range projects {
++ out = append(out, filepath.Join(p, ".commandcode", "**"))
++ }
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// openCodeAdapter reads the OpenCode AGENTS.md and the instructions its
++// config names.
++//
++// Docs: https://opencode.ai/docs/rules/
++func openCodeAdapter() Adapter {
++ return Adapter{
++ ID: "opencode",
++ Name: "OpenCode",
++ Docs: "https://opencode.ai/docs/rules/",
++ Locations: func(home string, projects []string) []string {
++ out := []string{filepath.Join(home, ".config", "opencode", "AGENTS.md")}
++ for _, p := range projects {
++ out = append(out,
++ filepath.Join(p, "AGENTS.md"),
++ filepath.Join(p, "opencode.json"),
++ )
++ out = append(out, instructionsFromConfig(filepath.Join(p, "opencode.json"), p)...)
++ }
++ globalConfig := filepath.Join(home, ".config", "opencode", "opencode.json")
++ out = append(out, globalConfig)
++ out = append(out, instructionsFromConfig(globalConfig, filepath.Dir(globalConfig))...)
++ return out
++ },
++ Parse: func(path string, data []byte) []Item {
++ if strings.HasSuffix(path, ".json") {
++ // The config itself is not memory; only the files it names are.
++ return nil
++ }
++ return parseMarkdown(parseOptions{}, path, data)
++ },
++ }
++}
++
++// readListFromConfig returns the files an Aider config names under "read:".
++func readListFromConfig(configPath, base string) []string {
++ data, err := os.ReadFile(configPath)
++ if err != nil {
++ return nil
++ }
++ var out []string
++ inRead := false
++ for _, line := range strings.Split(string(data), "\n") {
++ trimmed := strings.TrimSpace(line)
++ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
++ continue
++ }
++ indented := strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
++ if !indented {
++ // A new key: only "read:" names memory files.
++ inRead = strings.HasPrefix(trimmed, "read:")
++ continue
++ }
++ if !inRead || !strings.HasPrefix(trimmed, "- ") {
++ continue
++ }
++ value := strings.TrimSpace(strings.TrimPrefix(trimmed, "- "))
++ value = strings.Trim(value, "\"'")
++ if value == "" {
++ continue
++ }
++ if !filepath.IsAbs(value) {
++ value = filepath.Join(base, value)
++ }
++ out = append(out, filepath.Clean(value))
++ }
++ return out
++}
++
++// instructionsFromConfig returns the instruction files an OpenCode config
++// names under "instructions".
++func instructionsFromConfig(configPath, base string) []string {
++ data, err := os.ReadFile(configPath)
++ if err != nil {
++ return nil
++ }
++ var cfg struct {
++ Instructions []string `json:"instructions"`
++ }
++ if err := json.Unmarshal(data, &cfg); err != nil {
++ return nil
++ }
++ var out []string
++ for _, v := range cfg.Instructions {
++ v = strings.TrimSpace(v)
++ if v == "" {
++ continue
++ }
++ if !filepath.IsAbs(v) {
++ v = filepath.Join(base, v)
++ }
++ out = append(out, filepath.Clean(v))
++ }
++ return out
++}
ddiff --git a/internal/memory/glob.go b/internal/memory/glob.go
nnew file mode 100644
iindex 0000000..8dea722
---- /dev/null
++++ b/internal/memory/glob.go
@@@ -0,0 +1,120 @@
++package memory
++
++import (
++ "fmt"
++ "os"
++ "path/filepath"
++ "sort"
++ "strings"
++)
++
++// maxGlobDepth bounds how deep a glob walks below its fixed base directory.
++const maxGlobDepth = 8
++
++// expandGlob expands a narrow glob pattern.
++//
++// The pattern must start with a fixed directory: the part before the first
++// wildcard is the base, and only that directory is walked. A pattern that
++// starts with a wildcard is refused, so a glob can never widen into a whole
++// home folder.
++func expandGlob(pattern string) ([]string, error) {
++ parts := strings.Split(filepath.ToSlash(pattern), "/")
++ base := 0
++ for base < len(parts) && !hasMeta(parts[base]) {
++ base++
++ }
++ if base == 0 || base == len(parts) {
++ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)
++ }
++ root := strings.Join(parts[:base], "/")
++ if root == "" {
++ return nil, fmt.Errorf("memory: refusing to walk the wide glob %q", pattern)
++ }
++ var out []string
++ walkGlob(root, parts[base:], 0, &out)
++ sort.Strings(out)
++ return out, nil
++}
++
++// hasMeta tells whether a path segment holds a wildcard.
++func hasMeta(part string) bool {
++ return strings.ContainsAny(part, "*?[")
++}
++
++// walkGlob matches the rest of a pattern below a directory.
++func walkGlob(dir string, parts []string, depth int, out *[]string) {
++ if depth > maxGlobDepth {
++ return
++ }
++ if len(parts) == 0 {
++ *out = append(*out, dir)
++ return
++ }
++ part := parts[0]
++ if part == "**" {
++ if len(parts) == 1 {
++ // Everything below the directory.
++ collectFiles(dir, depth+1, out)
++ return
++ }
++ // Zero directories, then one directory at a time.
++ walkGlob(dir, parts[1:], depth+1, out)
++ entries, err := os.ReadDir(dir)
++ if err != nil {
++ return
++ }
++ for _, e := range entries {
++ if !e.IsDir() {
++ continue
++ }
++ walkGlob(filepath.Join(dir, e.Name()), parts, depth+1, out)
++ }
++ return
++ }
++ entries, err := os.ReadDir(dir)
++ if err != nil {
++ return
++ }
++ for _, e := range entries {
++ name := e.Name()
++ if !matchSegment(part, name) {
++ continue
++ }
++ full := filepath.Join(dir, name)
++ if len(parts) == 1 {
++ if e.IsDir() {
++ continue
++ }
++ *out = append(*out, full)
++ continue
++ }
++ if e.IsDir() {
++ walkGlob(full, parts[1:], depth+1, out)
++ }
++ }
++}
++
++// collectFiles lists every regular file below a directory.
++func collectFiles(dir string, depth int, out *[]string) {
++ if depth > maxGlobDepth {
++ return
++ }
++ entries, err := os.ReadDir(dir)
++ if err != nil {
++ return
++ }
++ for _, e := range entries {
++ full := filepath.Join(dir, e.Name())
++ if e.IsDir() {
++ collectFiles(full, depth+1, out)
++ continue
++ }
++ *out = append(*out, full)
++ }
++}
++
++// matchSegment matches one path segment against a pattern with * and ?.
++func matchSegment(pattern, name string) bool {
++ ok, err := filepath.Match(pattern, name)
++ return err == nil && ok
++}
ddiff --git a/internal/memory/markdown.go b/internal/memory/markdown.go
nnew file mode 100644
iindex 0000000..7165b9c
---- /dev/null
++++ b/internal/memory/markdown.go
@@@ -0,0 +1,168 @@
++package memory
++
++import (
++ "regexp"
++ "strings"
++)
++
++// headingLine matches a markdown heading.
++var headingLine = regexp.MustCompile("^#{1,6}[ \t]+(.+)$")
++
++// bulletLine matches a list item, ordered or not.
++var bulletLine = regexp.MustCompile("^(?:[-*+][ \t]+|[0-9]+[.)][ \t]+)")
++
++// mdItem is a piece of markdown before it becomes an Item.
++type mdItem struct {
++ Heading string
++ Text string
++}
++
++// splitFrontmatter takes the YAML-ish frontmatter off a markdown file and
++// returns it as flat keys ("name", "description", "metadata.type", "globs")
++// with the rest of the file.
++func splitFrontmatter(data []byte) (map[string]string, string) {
++ s := strings.ReplaceAll(string(data), "\r\n", "\n")
++ s = strings.TrimPrefix(s, "\ufeff")
++ if !strings.HasPrefix(s, "---\n") {
++ return nil, s
++ }
++ rest := s[4:]
++ end := strings.Index(rest, "\n---")
++ if end < 0 {
++ return nil, s
++ }
++ head := rest[:end]
++ tail := strings.TrimPrefix(rest[end+4:], "\n")
++ return parseFrontmatter(strings.Split(head, "\n")), tail
++}
++
++// parseFrontmatter reads the small subset of YAML these files use: scalar
++// keys, one level of nested keys ("metadata:" then "type:") and lists of
++// strings.
++func parseFrontmatter(lines []string) map[string]string {
++ fm := map[string]string{}
++ parent := ""
++ parentIndent := -1
++ for _, line := range lines {
++ trimmed := strings.TrimSpace(line)
++ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
++ continue
++ }
++ indent := len(line) - len(strings.TrimLeft(line, " \t"))
++ if strings.HasPrefix(trimmed, "- ") {
++ value := strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")), "\"'")
++ if parent != "" {
++ if fm[parent] == "" {
++ fm[parent] = value
++ } else {
++ fm[parent] += ", " + value
++ }
++ }
++ continue
++ }
++ idx := strings.Index(trimmed, ":")
++ if idx <= 0 {
++ continue
++ }
++ key := strings.TrimSpace(trimmed[:idx])
++ value := strings.Trim(strings.TrimSpace(trimmed[idx+1:]), "\"'")
++ if parent != "" && indent > parentIndent {
++ fm[parent+"."+key] = value
++ continue
++ }
++ parent, parentIndent = "", -1
++ if value == "" {
++ parent, parentIndent = key, indent
++ if _, ok := fm[key]; !ok {
++ fm[key] = ""
++ }
++ continue
++ }
++ fm[key] = value
++ }
++ return fm
++}
++
++// markdownItems splits markdown into items: one per top-level bullet and one
++// per paragraph under a heading. The heading each item came from is kept.
++//
++// A bullet is one item; the lines indented under it belong to it. A paragraph
++// is the run of text between two bullets.
++func markdownItems(body string) []mdItem {
++ var out []mdItem
++ heading := ""
++ var buf []string
++ mode := 0 // 0: none, 1: bullet, 2: paragraph
++
++ flush := func() {
++ if len(buf) == 0 {
++ mode = 0
++ return
++ }
++ text := strings.TrimSpace(strings.Join(buf, "\n"))
++ buf = nil
++ mode = 0
++ if text == "" {
++ return
++ }
++ out = append(out, mdItem{Heading: heading, Text: text})
++ }
++
++ for _, line := range strings.Split(body, "\n") {
++ trimmed := strings.TrimSpace(line)
++ if m := headingLine.FindStringSubmatch(trimmed); m != nil {
++ flush()
++ heading = strings.TrimSpace(m[1])
++ continue
++ }
++ if trimmed == "" {
++ flush()
++ continue
++ }
++ indented := line != trimmed
++ if bulletLine.MatchString(trimmed) {
++ // A new bullet is a new item.
++ flush()
++ mode = 1
++ buf = append(buf, trimmed)
++ continue
++ }
++ if mode == 1 && indented {
++ // A line under the bullet belongs to it.
++ buf = append(buf, trimmed)
++ continue
++ }
++ if mode == 1 {
++ // Plain text after a bullet starts a new paragraph.
++ flush()
++ }
++ if mode != 2 {
++ flush()
++ mode = 2
++ }
++ buf = append(buf, trimmed)
++ }
++ flush()
++ return out
++}
++
++// kindFromHeading guesses the kind of an item from the section it came from,
++// falling back to the kind of the adapter's file.
++func kindFromHeading(heading string, fallback Kind) Kind {
++ h := strings.ToLower(heading)
++ switch {
++ case strings.Contains(h, "preference"), strings.Contains(h, "prefer"):
++ return KindPreference
++ case strings.Contains(h, "lesson"), strings.Contains(h, "learned"), strings.Contains(h, "mistake"), strings.Contains(h, "gotcha"):
++ return KindLesson
++ case strings.Contains(h, "decision"), strings.Contains(h, "rationale"), strings.Contains(h, "adr"):
++ return KindDecision
++ case strings.Contains(h, "procedure"), strings.Contains(h, "workflow"), strings.Contains(h, "how to"), strings.Contains(h, "process"), strings.Contains(h, "steps"):
++ return KindProcedure
++ case strings.Contains(h, "fact"), strings.Contains(h, "profile"):
++ return KindFact
++ case strings.Contains(h, "rule"), strings.Contains(h, "convention"), strings.Contains(h, "guideline"), strings.Contains(h, "standard"), strings.Contains(h, "policy"):
++ return KindProjectRule
++ }
++ return fallback
++}
ddiff --git a/internal/memory/memory.go b/internal/memory/memory.go
nnew file mode 100644
iindex 0000000..9a3f147
---- /dev/null
++++ b/internal/memory/memory.go
@@@ -0,0 +1,80 @@
++// Package memory reads the memory files of the coding tools on this machine and
++// turns them into memory items.
++//
++// It is read-only and local: it opens the files it knows about, parses them and
++// redacts secrets before anything is printed. It never writes a file, never
++// opens a network connection and never follows a symlink out of the path it was
++// given.
++package memory
++
++// Kind is what a memory item is about.
++type Kind string
++
++const (
++ // KindPreference is how the person wants things done.
++ KindPreference Kind = "preference"
++ // KindFact is a fact about the person or the project.
++ KindFact Kind = "fact"
++ // KindProjectRule is a rule that holds in a project.
++ KindProjectRule Kind = "project_rule"
++ // KindProcedure is a way of doing something.
++ KindProcedure Kind = "procedure"
++ // KindDecision is a decision that was taken, with its reason.
++ KindDecision Kind = "decision"
++ // KindLesson is something that was learned the hard way.
++ KindLesson Kind = "lesson"
++)
++
++// Scope is where an item applies: the person (ScopeUser) or a project
++// (ScopeProject + the absolute path of the project).
++type Scope string
++
++// ScopeUser is the scope of an item that holds for the person everywhere.
++const ScopeUser Scope = "user"
++
++// ScopeProject returns the scope of an item that holds in a project.
++func ScopeProject(absProjectPath string) Scope {
++ return Scope("project:" + absProjectPath)
++}
++
++// Item is one piece of memory found in a file.
++type Item struct {
++ // Text is the item, already redacted.
++ Text string `json:"text"`
++ // Kind is what the item is about.
++ Kind Kind `json:"kind"`
++ // Scope is where the item applies.
++ Scope Scope `json:"scope"`
++ // Source is the id of the adapter that found the item.
++ Source string `json:"source"`
++ // Path is the file the item came from.
++ Path string `json:"path"`
++ // Updated is the mtime of that file, RFC 3339.
++ Updated string `json:"updated"`
++ // Heading is the section the item came from, when the file had one.
++ Heading string `json:"heading,omitempty"`
++ // Redacted is true when the text held a secret that was removed.
++ Redacted bool `json:"redacted,omitempty"`
++ // Redactions is how many secrets were removed from the item.
++ Redactions int `json:"redactions,omitempty"`
++ // Truncated is true when the item was longer than MaxItemRunes.
++ Truncated bool `json:"truncated,omitempty"`
++}
++
++// Adapter is one source of memory: a tool, where it keeps its memory and how
++// to read it.
++type Adapter struct {
++ // ID is the stable name of the adapter, as used by --tools.
++ ID string
++ // Name is how the tool is called.
++ Name string
++ // Docs is the URL of the tool's own documentation for these paths.
++ Docs string
++ // Locations returns the exact known paths and the narrow globs to look at,
++ // for a home directory and the project directories the person gave.
++ // It never returns the home directory itself and never a glob wider than
++ // the tool's own folders.
++ Locations func(home string, projects []string) []string
++ // Parse turns one file into items. The items it returns are redacted.
++ Parse func(path string, data []byte) []Item
++}
ddiff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go
nnew file mode 100644
iindex 0000000..b55999f
---- /dev/null
++++ b/internal/memory/memory_test.go
@@@ -0,0 +1,734 @@
++package memory
++
++import (
++ "go/parser"
++ "go/token"
++ "os"
++ "path/filepath"
++ "regexp"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++// fixture gives the home and the project directory of one adapter's fixture.
++func fixture(t *testing.T, id string) (home string, project string) {
++ t.Helper()
++ base := filepath.Join("testdata", id)
++ home = filepath.Join(base, "home")
++ project = filepath.Join(base, "project")
++ return home, project
++}
++
++// adapterByID finds an adapter.
++func adapterByID(t *testing.T, id string) Adapter {
++ t.Helper()
++ for _, ad := range Adapters() {
++ if ad.ID == id {
++ return ad
++ }
++ }
++ t.Fatalf("no adapter %q", id)
++ return Adapter{}
++}
++
++// scanOf runs a scan over one adapter's fixture.
++func scanOf(t *testing.T, id string) Result {
++ t.Helper()
++ home, project := fixture(t, id)
++ return Scan(Options{Home: home, Projects: []string{project}, Tools: []string{id}})
++}
++
++// itemsOf returns the items of the files whose path holds a marker.
++func itemsOf(res Result, marker string) []Item {
++ var out []Item
++ for _, it := range res.Items {
++ if strings.Contains(it.Path, marker) {
++ out = append(out, it)
++ }
++ }
++ return out
++}
++
++// secretLike finds the parts of a text that look like a secret.
++//
++// The test uses it so that it never has to hold a secret value itself: it
++// takes the secrets of a fixture out of the fixture and checks that none of
++// them reaches an item.
++var secretLike = regexp.MustCompile("(?i)(?:\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}|\\bgh[pousr]_[A-Za-z0-9]{20,}|\\bgithub_pat_[A-Za-z0-9_]{20,}|\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}|\\b(?:AKIA|ASIA)[0-9A-Z]{16}|\\bxox[baprs]-[A-Za-z0-9-]{10,}|\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}|\\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|token|password|secret|aws_secret_access_key)\\b[ \\t]*[:=][ \\t]*[^\\s\"']{6,}|\\b[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:[^\\s@/]{6,}@|-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----)")
++
++// secretsOf returns the secret-looking parts of a text.
++func secretsOf(text string) []string {
++ var out []string
++ for _, m := range secretLike.FindAllString(text, -1) {
++ out = append(out, m)
++ }
++ return out
++}
++
++// TestEveryAdapterHasDocsAndID checks the shape of the adapter list.
++func TestEveryAdapterHasDocsAndID(t *testing.T) {
++ seen := map[string]bool{}
++ for _, ad := range Adapters() {
++ if ad.ID == "" || ad.Name == "" {
++ t.Errorf("an adapter has no id or name: %+v", ad)
++ }
++ if seen[ad.ID] {
++ t.Errorf("two adapters share the id %q", ad.ID)
++ }
++ seen[ad.ID] = true
++ if !strings.HasPrefix(ad.Docs, "https://") {
++ t.Errorf("adapter %q has no docs link: %q", ad.ID, ad.Docs)
++ }
++ if ad.Locations == nil || ad.Parse == nil {
++ t.Errorf("adapter %q has no locations or no parser", ad.ID)
++ }
++ }
++}
++
++// TestLocationsAreNarrow checks that no adapter ever points at the home
++// folder itself and that no glob is wider than the tool's own folders.
++func TestLocationsAreNarrow(t *testing.T) {
++ home := filepath.Join("/home", "person")
++ project := filepath.Join("/srv", "work", "demo")
++ for _, ad := range Adapters() {
++ for _, loc := range ad.Locations(home, []string{project}) {
++ if loc == home || loc == project {
++ t.Errorf("adapter %q points at a whole folder: %q", ad.ID, loc)
++ }
++ if !strings.HasPrefix(loc, home) && !strings.HasPrefix(loc, project) {
++ t.Errorf("adapter %q points outside the home and the project: %q", ad.ID, loc)
++ }
++ // A glob must never start with a wildcard: it would walk the
++ // whole home folder.
++ rel, err := filepath.Rel(home, loc)
++ if err == nil && !strings.HasPrefix(rel, "..") {
++ first := strings.Split(filepath.ToSlash(rel), "/")
++ if len(first) > 0 && strings.ContainsAny(first[0], "*?[") {
++ t.Errorf("adapter %q starts a glob at the home folder: %q", ad.ID, loc)
++ }
++ }
++ // Every glob must stay inside a folder of the tool.
++ if strings.ContainsAny(loc, "*?[") {
++ toolFolder := strings.Contains(loc, filepath.Join(".claude", "")) ||
++ strings.Contains(loc, filepath.Join(".codex", "")) ||
++ strings.Contains(loc, filepath.Join(".gemini", "")) ||
++ strings.Contains(loc, filepath.Join(".cursor", "")) ||
++ strings.Contains(loc, filepath.Join(".github", "")) ||
++ strings.Contains(loc, filepath.Join(".windsurf", "")) ||
++ strings.Contains(loc, filepath.Join(".codeium", "")) ||
++ strings.Contains(loc, filepath.Join(".clinerules", "")) ||
++ strings.Contains(loc, filepath.Join(".roo", "")) ||
++ strings.Contains(loc, filepath.Join(".kiro", "")) ||
++ strings.Contains(loc, filepath.Join(".continue", "")) ||
++ strings.Contains(loc, filepath.Join(".commandcode", "")) ||
++ strings.Contains(loc, filepath.Join("memory-bank", "")) ||
++ strings.Contains(loc, filepath.Join("memory", ""))
++ if !toolFolder {
++ t.Errorf("adapter %q has a glob outside the tool's own folders: %q", ad.ID, loc)
++ }
++ }
++ }
++ }
++}
++
++// TestUserLevelOnlyWithoutProject checks that a scan without a project reads
++// only the user's own files.
++func TestUserLevelOnlyWithoutProject(t *testing.T) {
++ home, _ := fixture(t, "claude-code")
++ res := Scan(Options{Home: home})
++ for _, it := range res.Items {
++ if it.Scope != ScopeUser {
++ t.Errorf("item %q has the scope %q without a project", it.Text, it.Scope)
++ }
++ }
++ for _, ad := range res.Adapters {
++ for _, f := range ad.Files {
++ if strings.Contains(f.Path, filepath.Join("testdata", "claude-code", "project")) {
++ t.Errorf("the scan read a project file without a project: %s", f.Path)
++ }
++ }
++ }
++}
++
++// TestClaudeCodeParsing checks the items of the Claude Code fixture.
++func TestClaudeCodeParsing(t *testing.T) {
++ res := scanOf(t, "claude-code")
++
++ user := itemsOf(res, filepath.Join("home", ".claude", "CLAUDE.md"))
++ if len(user) != 3 {
++ t.Fatalf("the user CLAUDE.md gave %d items, want 3: %+v", len(user), user)
++ }
++ if user[0].Heading != "Personal setup" {
++ t.Errorf("the first item has the heading %q", user[0].Heading)
++ }
++ if user[0].Kind != KindPreference {
++ t.Errorf("a user file item has the kind %q, want a preference", user[0].Kind)
++ }
++ if user[0].Scope != ScopeUser {
++ t.Errorf("a user file item has the scope %q", user[0].Scope)
++ }
++
++ // The secret is gone.
++ if !user[2].Redacted {
++ t.Errorf("the item with a secret is not marked redacted: %+v", user[2])
++ }
++ if user[2].Redactions == 0 {
++ t.Errorf("the item with a secret counts no redaction: %+v", user[2])
++ }
++ if strings.Contains(user[2].Text, "sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8") {
++ t.Errorf("the secret is still in the item: %q", user[2].Text)
++ }
++
++ // The auto memory: the kind comes from the frontmatter, and the index is
++ // skipped.
++ auto := itemsOf(res, filepath.Join("memory", "user_role.md"))
++ if len(auto) != 1 {
++ t.Fatalf("the auto memory gave %d items, want 1: %+v", len(auto), auto)
++ }
++ if auto[0].Kind != KindPreference {
++ t.Errorf("the auto memory item has the kind %q", auto[0].Kind)
++ }
++ feedback := itemsOf(res, filepath.Join("memory", "feedback_testing.md"))
++ if len(feedback) != 1 || feedback[0].Kind != KindLesson {
++ t.Errorf("the feedback memory gave %+v", feedback)
++ }
++ if idx := itemsOf(res, "MEMORY.md"); len(idx) != 0 {
++ t.Errorf("the MEMORY.md index gave items: %+v", idx)
++ }
++
++ // The project files: the scope is the project and the kind is a rule.
++ proj := itemsOf(res, filepath.Join("claude-code", "project"))
++ if len(proj) == 0 {
++ t.Fatal("the project files gave no items")
++ }
++ for _, it := range proj {
++ if !strings.HasPrefix(string(it.Scope), "project:") {
++ t.Errorf("a project item has the scope %q", it.Scope)
++ }
++ if it.Kind != KindProjectRule {
++ t.Errorf("a project item has the kind %q: %q", it.Kind, it.Text)
++ }
++ }
++}
++
++// TestAgentsParsing checks the AGENTS.md family.
++func TestAgentsParsing(t *testing.T) {
++ res := scanOf(t, "agents")
++ if len(res.Items) == 0 {
++ t.Fatal("the agents fixture gave no items")
++ }
++ for _, it := range res.Items {
++ if it.Source != "agents" {
++ t.Errorf("the item has the source %q", it.Source)
++ }
++ if it.Updated == "" {
++ t.Errorf("the item has no date: %+v", it)
++ }
++ }
++ proj := itemsOf(res, filepath.Join("agents", "project"))
++ if len(proj) != 3 {
++ t.Errorf("the project AGENTS.md gave %d items, want 3: %+v", len(proj), proj)
++ }
++ if proj[2].Kind != KindDecision {
++ t.Errorf("the item under the Decision heading has the kind %q", proj[2].Kind)
++ }
++}
++
++// TestGeminiParsing checks the Gemini CLI fixture.
++func TestGeminiParsing(t *testing.T) {
++ res := scanOf(t, "gemini")
++ user := itemsOf(res, filepath.Join("home", ".gemini", "GEMINI.md"))
++ if len(user) != 3 {
++ t.Fatalf("the global GEMINI.md gave %d items, want 3: %+v", len(user), user)
++ }
++ if !user[2].Redacted {
++ t.Errorf("the item with the AWS key is not marked redacted: %+v", user[2])
++ }
++ if strings.Contains(user[2].Text, "AKIAIOSFODNN7EXAMPLE") {
++ t.Errorf("the AWS key is still in the item: %q", user[2].Text)
++ }
++}
++
++// TestCursorParsing checks the Cursor rules and their frontmatter.
++func TestCursorParsing(t *testing.T) {
++ res := scanOf(t, "cursor")
++ items := itemsOf(res, "style.mdc")
++ if len(items) != 3 {
++ t.Fatalf("the style rule gave %d items, want 3: %+v", len(items), items)
++ }
++ if items[0].Heading != "How we write code here" {
++ t.Errorf("the rule has the heading %q, want the frontmatter description", items[0].Heading)
++ }
++ if !items[2].Redacted {
++ t.Errorf("the item with the Slack token is not marked redacted: %+v", items[2])
++ }
++ if strings.Contains(items[2].Text, "xoxb-") {
++ t.Errorf("the Slack token is still in the item: %q", items[2].Text)
++ }
++ // The frontmatter is read, not turned into items.
++ for _, it := range items {
++ if strings.Contains(it.Text, "alwaysApply") {
++ t.Errorf("the frontmatter became an item: %q", it.Text)
++ }
++ }
++}
++
++// TestCopilotParsing checks the GitHub Copilot instructions.
++func TestCopilotParsing(t *testing.T) {
++ res := scanOf(t, "copilot")
++ items := itemsOf(res, "copilot-instructions.md")
++ if len(items) != 3 {
++ t.Fatalf("the repository instructions gave %d items, want 3: %+v", len(items), items)
++ }
++ if !items[2].Redacted {
++ t.Errorf("the item with the JWT is not marked redacted: %+v", items[2])
++ }
++ if strings.Contains(items[2].Text, "eyJhbGciOiJIUzI1NiI") {
++ t.Errorf("the JWT is still in the item: %q", items[2].Text)
++ }
++ inst := itemsOf(res, "tests.instructions.md")
++ if len(inst) != 1 {
++ t.Fatalf("the test instructions gave %d items, want 1: %+v", len(inst), inst)
++ }
++}
++
++// TestWindsurfParsing checks the Windsurf rules and memories.
++func TestWindsurfParsing(t *testing.T) {
++ res := scanOf(t, "windsurf")
++ mem := itemsOf(res, filepath.Join("memories", "preferences.md"))
++ if len(mem) != 3 {
++ t.Fatalf("the memories gave %d items, want 3: %+v", len(mem), mem)
++ }
++ // The private key is one item of its own and is dropped.
++ for _, it := range mem {
++ if strings.Contains(it.Text, "PRIVATE KEY") {
++ t.Errorf("the private key is still in an item: %q", it.Text)
++ }
++ }
++ rules := itemsOf(res, filepath.Join(".windsurf", "rules"))
++ if len(rules) != 1 {
++ t.Fatalf("the rules gave %d items, want 1: %+v", len(rules), rules)
++ }
++}
++
++// TestClineAndRooParsing checks the Cline and Roo fixtures.
++func TestClineAndRooParsing(t *testing.T) {
++ res := scanOf(t, "cline")
++ rules := itemsOf(res, ".clinerules")
++ if len(rules) != 2 {
++ t.Fatalf("the cline rules gave %d items, want 2: %+v", len(rules), rules)
++ }
++ if !rules[1].Redacted {
++ t.Errorf("the item with the password is not marked redacted: %+v", rules[1])
++ }
++ if strings.Contains(rules[1].Text, "Sup3rSecretFakeValue") {
++ t.Errorf("the password is still in the item: %q", rules[1].Text)
++ }
++ bank := itemsOf(res, "projectbrief.md")
++ if len(bank) != 1 {
++ t.Fatalf("the memory bank gave %d items, want 1: %+v", len(bank), bank)
++ }
++
++ roo := scanOf(t, "roo")
++ rr := itemsOf(roo, filepath.Join(".roo", "rules"))
++ if len(rr) != 2 {
++ t.Fatalf("the roo rules gave %d items, want 2: %+v", len(rr), rr)
++ }
++ if !rr[1].Redacted {
++ t.Errorf("the item with the Stripe key is not marked redacted: %+v", rr[1])
++ }
++ if strings.Contains(rr[1].Text, "sk_live_") {
++ t.Errorf("the Stripe key is still in the item: %q", rr[1].Text)
++ }
++}
++
++// TestKiroParsing checks the Kiro steering files.
++func TestKiroParsing(t *testing.T) {
++ res := scanOf(t, "kiro")
++ tech := itemsOf(res, "tech.md")
++ if len(tech) != 3 {
++ t.Fatalf("the steering file gave %d items, want 3: %+v", len(tech), tech)
++ }
++ if !tech[2].Redacted {
++ t.Errorf("the item with the connection string is not marked redacted: %+v", tech[2])
++ }
++ if strings.Contains(tech[2].Text, "hunter2fake") {
++ t.Errorf("the password is still in the item: %q", tech[2].Text)
++ }
++ if !strings.Contains(tech[2].Text, "postgres://dbuser:") {
++ t.Errorf("the connection string lost its shape: %q", tech[2].Text)
++ }
++}
++
++// TestContinueParsing checks the Continue rules.
++func TestContinueParsing(t *testing.T) {
++ res := scanOf(t, "continue")
++ items := itemsOf(res, "style.md")
++ if len(items) != 2 {
++ t.Fatalf("the rules gave %d items, want 2: %+v", len(items), items)
++ }
++ if !items[1].Redacted {
++ t.Errorf("the item with the token is not marked redacted: %+v", items[1])
++ }
++}
++
++// TestAiderParsing checks the Aider conventions and the files its config
++// names, and that the chat history is never read.
++func TestAiderParsing(t *testing.T) {
++ res := scanOf(t, "aider")
++ conv := itemsOf(res, "CONVENTIONS.md")
++ if len(conv) != 2 {
++ t.Fatalf("the conventions gave %d items, want 2: %+v", len(conv), conv)
++ }
++ if !conv[1].Redacted {
++ t.Errorf("the item with the password is not marked redacted: %+v", conv[1])
++ }
++ read := itemsOf(res, "README.md")
++ if len(read) != 1 {
++ t.Fatalf("the file named by the config gave %d items, want 1: %+v", len(read), read)
++ }
++ notes := itemsOf(res, "NOTES.md")
++ if len(notes) != 1 {
++ t.Fatalf("the file named by the global config gave %d items, want 1: %+v", len(notes), notes)
++ }
++ for _, f := range res.Adapters[0].Files {
++ if strings.Contains(f.Path, "chat.history") {
++ t.Errorf("the chat history was read: %s", f.Path)
++ }
++ }
++}
++
++// TestCommandCodeParsing checks the Command Code memory folder.
++func TestCommandCodeParsing(t *testing.T) {
++ res := scanOf(t, "command-code")
++ items := itemsOf(res, filepath.Join("project", ".commandcode"))
++ if len(items) != 2 {
++ t.Fatalf("the command code memory gave %d items, want 2: %+v", len(items), items)
++ }
++ if !items[1].Redacted {
++ t.Errorf("the item with the token is not marked redacted: %+v", items[1])
++ }
++ if strings.Contains(items[1].Text, "github_pat_") {
++ t.Errorf("the token is still in the item: %q", items[1].Text)
++ }
++}
++
++// TestOpenCodeParsing checks the OpenCode AGENTS.md and the instructions its
++// config names.
++func TestOpenCodeParsing(t *testing.T) {
++ res := scanOf(t, "opencode")
++ agents := itemsOf(res, filepath.Join("opencode", "project", "AGENTS.md"))
++ if len(agents) != 1 {
++ t.Fatalf("the AGENTS.md gave %d items, want 1: %+v", len(agents), agents)
++ }
++ conv := itemsOf(res, "conventions.md")
++ if len(conv) != 2 {
++ t.Fatalf("the file named by the config gave %d items, want 2: %+v", len(conv), conv)
++ }
++ if !conv[1].Redacted {
++ t.Errorf("the item with the aws key is not marked redacted: %+v", conv[1])
++ }
++ if strings.Contains(conv[1].Text, "wJalrXUtnFEMI") {
++ t.Errorf("the aws key is still in the item: %q", conv[1].Text)
++ }
++}
++
++// TestNoSecretSurvivesInAnyFixture checks that no fixture's secret shows up
++// in any item of any adapter.
++func TestNoSecretSurvivesInAnyFixture(t *testing.T) {
++ for _, id := range []string{
++ "claude-code", "agents", "gemini", "cursor", "copilot", "windsurf",
++ "cline", "roo", "kiro", "continue", "aider", "command-code", "opencode",
++ } {
++ res := scanOf(t, id)
++ if len(res.Items) == 0 {
++ t.Errorf("the %q fixture gave no items", id)
++ }
++ // Every fixture holds at least one secret of its own shape.
++ found := false
++ err := filepath.Walk(filepath.Join("testdata", id), func(path string, info os.FileInfo, err error) error {
++ if err != nil || info.IsDir() {
++ return nil
++ }
++ data, err := os.ReadFile(path)
++ if err != nil {
++ return nil
++ }
++ if len(secretsOf(string(data))) > 0 {
++ found = true
++ }
++ return nil
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ if !found {
++ t.Errorf("the %q fixture holds no secret", id)
++ }
++ for _, it := range res.Items {
++ for _, sec := range secretsOf(strings.ReplaceAll(it.Text, Redacted, "")) {
++ t.Errorf("adapter %q still shows the secret %q in %q", id, sec, it.Text)
++ }
++ }
++ }
++}
++
++// TestAnItemThatIsOnlyASecretIsDropped checks that an item that held nothing
++// but a secret is dropped, not kept with an empty text.
++func TestAnItemThatIsOnlyASecretIsDropped(t *testing.T) {
++ secret := "sk-proj-" + strings.Repeat("A", 40)
++ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+secret+"\n"))
++ if len(items) != 0 {
++ t.Errorf("an item that was only a secret was kept: %+v", items)
++ }
++ items = parseMarkdown(parseOptions{}, "x.md", []byte("- The key is "+secret+"\n"))
++ if len(items) != 1 {
++ t.Fatalf("an item with a secret in it was dropped: %+v", items)
++ }
++ if !items[0].Redacted || items[0].Redactions != 1 {
++ t.Errorf("the item is not marked redacted: %+v", items[0])
++ }
++}
++
++// TestRedactShapes checks every shape of secret the harness knows.
++func TestRedactShapes(t *testing.T) {
++ cases := map[string]string{
++ "openai": "sk-" + "proj-" + strings.Repeat("A", 40),
++ "anthropic": "sk-" + "ant-api03-" + strings.Repeat("B", 40),
++ "github": "gh" + "p_" + strings.Repeat("C", 36),
++ "github_pat": "github_" + "pat_" + strings.Repeat("D", 36),
++ "jwt": "ey" + "JhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0." + strings.Repeat("E", 43),
++ "pem": "-----BEGIN RSA " + "PRIVATE KEY-----\n" + strings.Repeat("F", 60) + "\n-----END RSA PRIVATE KEY-----",
++ "aws": "AK" + "IAIOSFODNN7EXAMPLE",
++ "slack": "xox" + "b-000000000000-000000000000-" + strings.Repeat("G", 33),
++ "stripe": "sk" + "_live_" + strings.Repeat("H", 24),
++ "assignment": "pass" + "word=" + strings.Repeat("I", 12),
++ "connection": "postgres://dbuser:" + strings.Repeat("J", 10) + "@db.internal:5432/app",
++ }
++ for name, secret := range cases {
++ text, count := Redact("the value is " + secret + " ok")
++ if count == 0 {
++ t.Errorf("%s: the secret was not found", name)
++ continue
++ }
++ if strings.Contains(text, secret) {
++ t.Errorf("%s: the secret is still in %q", name, text)
++ }
++ if !strings.Contains(text, Redacted) {
++ t.Errorf("%s: the text has no redacted marker: %q", name, text)
++ }
++ }
++}
++
++// TestOverSizeFileIsSkipped checks that a file over 256 KB is skipped.
++func TestOverSizeFileIsSkipped(t *testing.T) {
++ dir := t.TempDir()
++ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {
++ t.Fatal(err)
++ }
++ big := filepath.Join(dir, ".codex", "AGENTS.md")
++ if err := os.WriteFile(big, []byte(strings.Repeat("a line of text\n", 30000)), 0o644); err != nil {
++ t.Fatal(err)
++ }
++ res := Scan(Options{Home: dir, Tools: []string{"agents"}})
++ found := false
++ for _, ad := range res.Adapters {
++ for _, f := range ad.Files {
++ if f.Path == big {
++ found = true
++ if !strings.Contains(f.Note, "256 KB") {
++ t.Errorf("the big file was not skipped with a note: %+v", f)
++ }
++ if f.Items != 0 {
++ t.Errorf("the big file gave %d items", f.Items)
++ }
++ }
++ }
++ }
++ if !found {
++ t.Error("the big file was not even looked at")
++ }
++}
++
++// TestBinaryFileIsSkipped checks that a binary file is skipped.
++func TestBinaryFileIsSkipped(t *testing.T) {
++ dir := t.TempDir()
++ if err := os.MkdirAll(filepath.Join(dir, ".codex"), 0o755); err != nil {
++ t.Fatal(err)
++ }
++ bin := filepath.Join(dir, ".codex", "AGENTS.md")
++ if err := os.WriteFile(bin, []byte{0x00, 0x01, 0x02, 0x03}, 0o644); err != nil {
++ t.Fatal(err)
++ }
++ res := Scan(Options{Home: dir, Tools: []string{"agents"}})
++ for _, ad := range res.Adapters {
++ for _, f := range ad.Files {
++ if f.Path == bin && !strings.Contains(f.Note, "binary") {
++ t.Errorf("the binary file was not skipped with a note: %+v", f)
++ }
++ }
++ }
++}
++
++// TestSymlinkOutIsNotFollowed checks that a link out of the scanned paths is
++// not read.
++func TestSymlinkOutIsNotFollowed(t *testing.T) {
++ outside := t.TempDir()
++ secretFile := filepath.Join(outside, "outside.md")
++ if err := os.WriteFile(secretFile, []byte("# Outside\n\n- a secret "+"gh"+"p_"+strings.Repeat("K", 36)+"\n"), 0o644); err != nil {
++ t.Fatal(err)
++ }
++ home := t.TempDir()
++ if err := os.MkdirAll(filepath.Join(home, ".codex"), 0o755); err != nil {
++ t.Fatal(err)
++ }
++ link := filepath.Join(home, ".codex", "AGENTS.md")
++ if err := os.Symlink(secretFile, link); err != nil {
++ t.Skipf("this machine cannot make a link: %v", err)
++ }
++ res := Scan(Options{Home: home, Tools: []string{"agents"}})
++ for _, it := range res.Items {
++ if strings.Contains(it.Text, "gh"+"p_") {
++ t.Errorf("the link out was read: %+v", it)
++ }
++ }
++ for _, ad := range res.Adapters {
++ for _, f := range ad.Files {
++ if f.Path == link && f.Items != 0 {
++ t.Errorf("the link out gave %d items", f.Items)
++ }
++ }
++ }
++}
++
++// TestLongItemIsTruncated checks that an item over 2000 characters is cut.
++func TestLongItemIsTruncated(t *testing.T) {
++ long := strings.Repeat("x", 3000)
++ items := parseMarkdown(parseOptions{}, "x.md", []byte("- "+long+"\n"))
++ if len(items) != 1 {
++ t.Fatalf("got %d items, want 1", len(items))
++ }
++ if !items[0].Truncated {
++ t.Error("the long item is not marked truncated")
++ }
++ if len([]rune(items[0].Text)) != MaxItemRunes+1 {
++ t.Errorf("the item has %d runes, want %d", len([]rune(items[0].Text)), MaxItemRunes+1)
++ }
++ if !strings.HasSuffix(items[0].Text, "…") {
++ t.Error("the item does not end with an ellipsis")
++ }
++}
++
++// TestPackageHasNoNetworkImport checks that the package never opens a
++// network connection: it does not import net/http.
++func TestPackageHasNoNetworkImport(t *testing.T) {
++ entries, err := os.ReadDir(".")
++ if err != nil {
++ t.Fatal(err)
++ }
++ fset := token.NewFileSet()
++ for _, e := range entries {
++ name := e.Name()
++ if e.IsDir() || !strings.HasSuffix(name, ".go") {
++ continue
++ }
++ file, err := parser.ParseFile(fset, name, nil, parser.ImportsOnly)
++ if err != nil {
++ t.Fatalf("%s does not parse: %v", name, err)
++ }
++ for _, imp := range file.Imports {
++ path := strings.Trim(imp.Path.Value, "\"")
++ if path == "net" || strings.HasPrefix(path, "net/") {
++ t.Errorf("%s imports %s", name, path)
++ }
++ }
++ }
++}
++
++// TestMarkdownSplitting checks how a markdown file becomes items.
++func TestMarkdownSplitting(t *testing.T) {
++ body := "# Title\n\nA paragraph under the title.\n\n" +
++ "## Section\n\n- first bullet\n- second bullet\n\nA paragraph in the section.\n"
++ items := markdownItems(body)
++ if len(items) != 4 {
++ t.Fatalf("got %d items, want 4: %+v", len(items), items)
++ }
++ if items[0].Heading != "Title" {
++ t.Errorf("the first item has the heading %q", items[0].Heading)
++ }
++ if items[1].Heading != "Section" {
++ t.Errorf("the bullet has the heading %q", items[1].Heading)
++ }
++ if items[1].Text != "- first bullet" {
++ t.Errorf("the bullet item is %q", items[1].Text)
++ }
++ if items[2].Text != "- second bullet" {
++ t.Errorf("the second bullet item is %q", items[2].Text)
++ }
++ if items[3].Text != "A paragraph in the section." {
++ t.Errorf("the paragraph item is %q", items[3].Text)
++ }
++}
++
++// TestFrontmatterIsReadNotTurnedIntoItems checks the frontmatter handling.
++func TestFrontmatterIsReadNotTurnedIntoItems(t *testing.T) {
++ data := []byte("---\nname: a_rule\ndescription: A rule about the api\nmetadata:\n type: feedback\nglobs: \"**/*.go\"\n---\n\n# Heading\n\n- one item\n")
++ items := parseMarkdown(parseOptions{kindFromType: true}, "rule.md", data)
++ if len(items) != 1 {
++ t.Fatalf("got %d items, want 1: %+v", len(items), items)
++ }
++ if items[0].Kind != KindLesson {
++ t.Errorf("the item has the kind %q, want a lesson", items[0].Kind)
++ }
++ if items[0].Heading != "A rule about the api" {
++ t.Errorf("the item has the heading %q", items[0].Heading)
++ }
++ for _, it := range items {
++ if strings.Contains(it.Text, "name: a_rule") {
++ t.Errorf("the frontmatter became an item: %q", it.Text)
++ }
++ }
++}
++
++// TestScanReportsEveryPath checks that the scan says which path it looked
++// at and whether it was there.
++func TestScanReportsEveryPath(t *testing.T) {
++ res := scanOf(t, "claude-code")
++ var report *AdapterReport
++ for i := range res.Adapters {
++ if res.Adapters[i].ID == "claude-code" {
++ report = &res.Adapters[i]
++ }
++ }
++ if report == nil {
++ t.Fatal("the claude-code adapter is not in the report")
++ }
++ if len(report.Files) == 0 {
++ t.Fatal("the report lists no files")
++ }
++ seen := map[string]bool{}
++ for _, f := range report.Files {
++ seen[f.Path] = true
++ }
++ if !seen[filepath.Join("testdata", "claude-code", "home", ".claude", "CLAUDE.md")] {
++ t.Errorf("the user CLAUDE.md is not in the report: %+v", report.Files)
++ }
++}
++
++// TestOnlySecretsAreDropped checks that a file whose only content is a secret
++// gives no items.
++func TestOnlySecretsAreDropped(t *testing.T) {
++ items := parseMarkdown(parseOptions{}, "x.md", []byte("AK"+"IAIOSFODNN7EXAMPLE\n"))
++ if len(items) != 0 {
++ t.Errorf("a file that held only a secret gave items: %+v", items)
++ }
++}
++
++// TestRuntimeGuard keeps the tests portable: the paths are built with
++// filepath.Join, not with a slash.
++func TestRuntimeGuard(t *testing.T) {
++ if runtime.GOOS == "" {
++ t.Fatal("no runtime")
++ }
++}
ddiff --git a/internal/memory/redact.go b/internal/memory/redact.go
nnew file mode 100644
iindex 0000000..e39bd38
---- /dev/null
++++ b/internal/memory/redact.go
@@@ -0,0 +1,342 @@
++package memory
++
++import (
++ "fmt"
++ "regexp"
++ "strings"
++)
++
++// secretPattern is one shape of secret and how to replace it. Keep is the
++// number of capture groups that are kept in front of the redacted part, so an
++// assignment still reads as "password=[redacted]".
++type secretPattern struct {
++ re *regexp.Regexp
++ Keep int
++ // Template, when set, is used to rebuild the match: "$1" and friends are
++ // the capture groups, and "%s" is the marker that replaces the secret.
++ Template string
++}
++
++// secretNameSuffixes are the words a credential name may end in. A name is a
++// run of [A-Za-z0-9_.-] that ends in one of these, so DATABASE_PASSWORD,
++// MY_SECRET and GITHUB_TOKEN are names, and so are password and api_key.
++const secretNameSuffixes = "password|passwd|pwd|passphrase|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth|credential|credentials|cookie|session"
++
++// secretNamePattern is a credential name: a run of [A-Za-z0-9_.-] that ends in
++// one of the words above. The name may end in an "s" as well, so that
++// "max_tokens" and "api_keys" are names too.
++const secretNamePattern = "(?:" + secretNameSuffixes + ")s?"
++
++// secretPatterns are the shapes we remove before an item is shown or stored.
++//
++// The first five are the patterns of OpenVibe.Codes' harness (harness/events.js,
++// SECRET_PATTERNS). The rest are the common gitleaks shapes and the shapes
++// that turn up in a person's own files: AWS keys, GitHub and GitLab tokens,
++// Slack and Discord tokens, Stripe keys, OpenAI- and Anthropic-style keys,
++// JWTs, PEM private keys in every form, env-style assignments, headers, the
++// webhooks of chat services and the card numbers that pass the Luhn check.
++var secretPatterns = []secretPattern{
++ // A webhook URL carries its token in the path: the whole URL goes.
++ {re: regexp.MustCompile("https://hooks\\.slack\\.com/services/[^\\s\"']+")},
++ {re: regexp.MustCompile("https://(?:discord|discordapp)\\.com/api/webhooks/[^\\s\"']+")},
++ // A connection string with a password: the scheme, the user and the host
++ // stay, the password goes.
++ {
++ re: regexp.MustCompile("([a-zA-Z][a-zA-Z0-9+.-]*://[^\\s:/@]+:)([^\\s@/]+)(@[^\\s\"']*)"),
++ Keep: 1,
++ Template: "$1%s$3",
++ },
++ // A private key block, in every form: RSA, EC, DSA, OPENSSH, PGP and
++ // ENCRYPTED.
++ {re: regexp.MustCompile("(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----.*?-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")},
++ // The keys of the tools and the services.
++ {re: regexp.MustCompile("\\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}")},
++ {re: regexp.MustCompile("\\bgh[pousr]_[A-Za-z0-9]{20,}")},
++ {re: regexp.MustCompile("\\bgithub_pat_[A-Za-z0-9_]{20,}")},
++ {re: regexp.MustCompile("\\bglpat-[0-9A-Za-z_-]{20,}")},
++ {re: regexp.MustCompile("\\bnpm_[A-Za-z0-9]{36}")},
++ {re: regexp.MustCompile("\\bhf_[A-Za-z0-9]{30,}")},
++ {re: regexp.MustCompile("\\bAIza[0-9A-Za-z_-]{35}")},
++ {re: regexp.MustCompile("\\bSK[0-9a-fA-F]{32}\\b")},
++ {re: regexp.MustCompile("\\bxox[baprs]-[A-Za-z0-9-]{10,}")},
++ {re: regexp.MustCompile("\\bxapp-[0-9A-Za-z-]{10,}")},
++ {re: regexp.MustCompile("\\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{10,}")},
++ {re: regexp.MustCompile("\\bSG\\.[A-Za-z0-9_-]{16,}\\.[A-Za-z0-9_-]{16,}")},
++ {re: regexp.MustCompile("\\b\\d{8,10}:[A-Za-z0-9_-]{35}\\b")},
++ {re: regexp.MustCompile("[MN][A-Za-z\\d]{23,25}\\.[\\w-]{6}\\.[\\w-]{27,}")},
++ {re: regexp.MustCompile("\\bhbt_[A-Za-z0-9_-]{16,}")},
++ {re: regexp.MustCompile("\\bpgsk_[A-Za-z0-9_]{16,}")},
++ {re: regexp.MustCompile("\\bAKIA[0-9A-Z]{16}")},
++ {re: regexp.MustCompile("\\bASIA[0-9A-Z]{16}")},
++ {re: regexp.MustCompile("\\beyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}")},
++ // A header that carries a credential.
++ {
++ re: regexp.MustCompile("(?i)(authorization[ \\t]*:[ \\t]*(?:bearer|basic)[ \\t]+)(\\S+)"),
++ Keep: 1,
++ },
++ {
++ re: regexp.MustCompile("(?i)(x-api-key[ \\t]*:[ \\t]*)(\\S+)"),
++ Keep: 1,
++ },
++ {
++ re: regexp.MustCompile("(?i)\\b(bearer[ \\t]+)([A-Za-z0-9._~+/=-]{20,})"),
++ Keep: 1,
++ },
++ // An assignment: the name and the separator stay, the value goes. The
++ // name ends in a word that means "this is a credential".
++ //
++ // A quoted value keeps its quotes, so the text still reads as the
++ // config it was.
++ {
++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})\""),
++ Template: "$1$2\"%s\"",
++ },
++ {
++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)'([^'\\n]{4,})'"),
++ Template: "$1$2'%s'",
++ },
++ // A value whose closing quote was lost: it still goes, to the end of
++ // the line.
++ {
++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)\"([^\"\\n]{4,})"),
++ Template: "$1$2\"%s",
++ },
++ // An unquoted value.
++ {
++ re: regexp.MustCompile("(?i)([A-Za-z0-9_.-]*" + secretNamePattern + ")([\"']?[ \\t]*(?:=>|[:=])[ \\t]*)([^\\s\"',;]{6,})"),
++ Keep: 2,
++ },
++ // Prose: "the password is hunter2".
++ {
++ re: regexp.MustCompile("(?i)\\b(password|passphrase|passcode)([ \\t]+(?:is|was|=)[ \\t]+)(\\S{4,})"),
++ Keep: 2,
++ },
++}
++
++// cardCandidate is a run of 13 to 19 digits with optional spaces or dashes
++// between them. Whether it is a card is decided by the Luhn check.
++var cardCandidate = regexp.MustCompile("[0-9](?:[ -]?[0-9]){12,18}")
++
++// pemBegin and pemEnd are the lines of a private key block.
++var (
++ pemBegin = regexp.MustCompile("-----BEGIN [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")
++ pemEnd = regexp.MustCompile("-----END [A-Z0-9 ]*PRIVATE KEY[A-Z ]*-----")
++)
++
++// The bytes of the two fence markers of markdown.
++const (
++ fenceTick = 0x60
++ fenceTilde = 0x7e
++)
++
++// Redacted is what a secret is replaced with.
++const Redacted = "[redacted]"
++
++// secretMark is the sentinel the body of a file is scrubbed with, before it
++// is split into items. It is a string that never appears in a real file, so
++// that an item can still count the secrets the whole body held.
++const secretMark = "\x00redacted\x00"
++
++// Redact removes the secrets from a text. It returns the text and how many
++// secrets were removed; the secrets themselves are never returned.
++//
++// It is safe to call on a text that was already redacted: a value that is
++// already the redacted marker is left alone.
++func Redact(text string) (string, int) {
++ return redactWith(text, Redacted)
++}
++
++// redactWith removes the secrets from a text and writes the marker in place
++// of each one.
++//
++// The marker is a parameter so that a caller that scrubs a whole file before
++// splitting it into items can still tell how many secrets each item held: it
++// counts the markers it finds.
++func redactWith(text string, marker string) (string, int) {
++ count := 0
++ text = redactUnterminatedKey(text, marker, &count)
++ for _, p := range secretPatterns {
++ text = p.replace(text, marker, &count)
++ }
++ text = redactCards(text, marker, &count)
++ return text, count
++}
++
++// replace swaps every match of the pattern for the redacted marker, keeping
++// the groups the pattern says to keep.
++func (p secretPattern) replace(text string, marker string, count *int) string {
++ return p.re.ReplaceAllStringFunc(text, func(m string) string {
++ if strings.Contains(m, marker) {
++ // Already redacted: there is nothing to hide here.
++ return m
++ }
++ *count++
++ if p.Template != "" {
++ return p.re.ReplaceAllString(m, fmt.Sprintf(p.Template, marker))
++ }
++ if p.Keep == 0 {
++ return marker
++ }
++ groups := p.re.FindStringSubmatch(m)
++ if len(groups) <= p.Keep {
++ return marker
++ }
++ kept := ""
++ for i := 1; i <= p.Keep; i++ {
++ kept += groups[i]
++ }
++ return kept + marker
++ })
++}
++
++// redactUnterminatedKey redacts a private key block that has no END line: a
++// truncated file, or a key pasted in halves.
++//
++// The key is redacted from its BEGIN line to the end of the fence it sits in,
++// or to the end of the text when it is not in a fence. A block that has its
++// END line is left to the pattern list, which redacts the whole block.
++func redactUnterminatedKey(text string, marker string, count *int) string {
++ blocks := pemBegin.FindAllStringIndex(text, -1)
++ // Work from the last block backwards, so the indexes of the earlier ones
++ // stay valid.
++ for i := len(blocks) - 1; i >= 0; i-- {
++ start, after := blocks[i][0], blocks[i][1]
++ if pemEnd.MatchString(text[after:]) {
++ // The block is complete: the pattern list redacts it whole.
++ continue
++ }
++ end := len(text)
++ if fence := fenceEnd(text, start); fence > start {
++ end = fence
++ }
++ text = text[:start] + marker + "\n" + text[end:]
++ *count++
++ }
++ return text
++}
++
++// fenceEnd returns the index of the fence that closes the block the given
++// position sits in. It returns the end of the text when the position is not
++// inside a fence.
++func fenceEnd(text string, pos int) int {
++ char, width := openFence(text, pos)
++ if char == 0 {
++ // Not inside a fence: the key runs to the end of the text.
++ return len(text)
++ }
++ for i := pos; i < len(text); {
++ n := fenceRun(text, i)
++ if n == 0 {
++ i++
++ continue
++ }
++ if text[i] == char && n >= width {
++ return i
++ }
++ i += n
++ }
++ return len(text)
++}
++
++// openFence returns the character and the width of the fence that is open at
++// the given position, or 0 when the position is not inside a fence.
++func openFence(text string, pos int) (byte, int) {
++ var char byte
++ var width int
++ for i := 0; i < pos; {
++ n := fenceRun(text, i)
++ if n == 0 {
++ i++
++ continue
++ }
++ if char == 0 {
++ char, width = text[i], n
++ } else if text[i] == char && n >= width {
++ char, width = 0, 0
++ }
++ i += n
++ }
++ return char, width
++}
++
++// fenceRun returns the length of the run of fence characters at s[i] when the
++// run is at least three characters long, and 0 otherwise.
++func fenceRun(s string, i int) int {
++ if i >= len(s) || (s[i] != fenceTick && s[i] != fenceTilde) {
++ return 0
++ }
++ n := 0
++ for i+n < len(s) && s[i+n] == s[i] {
++ n++
++ }
++ if n < 3 {
++ return 0
++ }
++ return n
++}
++
++// redactCards redacts the card numbers that pass the Luhn check. A long
++// number that does not pass it is left alone: an order number is not a card.
++func redactCards(text string, marker string, count *int) string {
++ var out strings.Builder
++ last := 0
++ for _, loc := range cardCandidate.FindAllStringIndex(text, -1) {
++ start, end := loc[0], loc[1]
++ if start > 0 && isDigit(text[start-1]) {
++ // Part of a longer number.
++ continue
++ }
++ j := end
++ for j < len(text) && text[j] == ' ' {
++ j++
++ }
++ if j < len(text) && isDigit(text[j]) {
++ // The number continues past the match.
++ continue
++ }
++ digits := ""
++ for i := start; i < end; i++ {
++ if isDigit(text[i]) {
++ digits += string(text[i])
++ }
++ }
++ if len(digits) < 13 || len(digits) > 19 || !luhn(digits) {
++ continue
++ }
++ out.WriteString(text[last:start])
++ out.WriteString(marker)
++ *count++
++ last = end
++ }
++ out.WriteString(text[last:])
++ return out.String()
++}
++
++// isDigit tells whether a byte is a digit.
++func isDigit(b byte) bool {
++ return b >= '0' && b <= '9'
++}
++
++// luhn tells whether a run of digits is a card number.
++func luhn(digits string) bool {
++ sum := 0
++ double := false
++ for i := len(digits) - 1; i >= 0; i-- {
++ d := int(digits[i] - '0')
++ if double {
++ d *= 2
++ if d > 9 {
++ d -= 9
++ }
++ }
++ sum += d
++ double = !double
++ }
++ return sum%10 == 0
++}
++
++// onlySecret tells whether a text holds nothing but redacted secrets.
++func onlySecret(text string) bool {
++ rest := strings.ReplaceAll(text, Redacted, "")
++ return strings.TrimSpace(rest) == ""
++}
ddiff --git a/internal/memory/redact_test.go b/internal/memory/redact_test.go
nnew file mode 100644
iindex 0000000..ac200b8
---- /dev/null
++++ b/internal/memory/redact_test.go
@@@ -0,0 +1,588 @@
++package memory
++
++import (
++ "encoding/json"
++ "os"
++ "path/filepath"
++ "strings"
++ "testing"
++)
++
++// halves joins the two halves of a value.
++//
++// The test files of this repository are read by a secret scanner too, and
++// these fixtures hold fake secrets on purpose. Writing a whole secret in one
++// piece would make the scanner report the test itself, so every value that
++// looks like a secret is cut in two.
++func halves(a, b string) string { return a + b }
++
++// The fake secrets the shapes are tested with. Every one of them is written
++// in halves, so that the secret scanner of the repository does not read this
++// test file as a file that holds a secret.
++var (
++ secretOpenSSH = halves("b3BlbnNzaC1rZXktdjEAAAAABG5vbm", "UAAAAEbm9uZQ")
++ secretPGP = halves("lQOYBFc0OaJBCFAVvJ0lhbXVs", "C")
++ secretBasic = halves("ZGVtbzpwYXNzd29yZ", "A==")
++ secretJWT = halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U")
++ secretOpenAI = halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8")
++ secretAnthropic = halves("sk-ant-api03-BBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBBBBBBBBB")
++ secretStripe = halves("sk_live_0000", "00000000000FAKE")
++ secretRSABody = halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE")
++ // The lines of a key block are written in two pieces too, so that
++ // the scanner does not read this test file as a file that holds a key.
++ pemBeginRSA = "-----BEGIN RSA " + "PRIVATE KEY-----\n"
++ pemEndRSA = "-----END RSA " + "PRIVATE KEY-----\n"
++)
++
++// plantedSecrets are the fake secrets the fixtures hold, one list per
++// adapter. The whole-output check looks for every one of them.
++var plantedSecrets = map[string][]string{
++ "agents": {
++ halves("ghp_AAAAAAAAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAA"),
++ },
++ "aider": {
++ halves("Sup3rSecre", "tFakeValue"),
++ },
++ "claude-code": {
++ halves("sk-proj-A1b2C3d4E5f6G7", "h8I9j0K1l2M3n4O5p6Q7r8"),
++ halves("MIIEowIBAAKCAQEAtESTKEYONLYOPENSSH", "FAKE000000000000"),
++ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),
++ halves("fake-db-pa", "ssword-123"),
++ halves("fake-stripe", "-secret-999"),
++ halves("correct horse ", "battery staple"),
++ },
++ "cline": {
++ halves("Sup3rSecre", "tFakeValue"),
++ },
++ "command-code": {
++ halves("github_pat_11AAAAAAAAAA", "AAAAAAAAAAAAAAAAAAAAAAA"),
++ },
++ "continue": {
++ halves("ghp_BBBBBBBBBBBBBBBB", "BBBBBBBBBBBBBBBBBBBB"),
++ },
++ "copilot": {
++ halves("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0N", "TY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"),
++ },
++ "cursor": {
++ halves("xoxb-0000000", "000000000FAKE"),
++ },
++ "gemini": {
++ halves("AKIAIOSFOD", "NN7EXAMPLE"),
++ },
++ "kiro": {
++ halves("hunte", "r2fake"),
++ },
++ "opencode": {
++ halves("wJalrXUtnFEMI0000000", "0000000000000000FAKE"),
++ },
++ "roo": {
++ halves("sk_live_000000", "0000000000FAKE"),
++ },
++ "windsurf": {
++ halves("MIIEowIBAAKCAQEAtESTKEYONLY", "FAKEFIXTURE0000000000000000"),
++ },
++}
++
++// fixtureAdapters is every adapter that has a fixture.
++var fixtureAdapters = []string{
++ "agents",
++ "aider",
++ "claude-code",
++ "cline",
++ "command-code",
++ "continue",
++ "copilot",
++ "cursor",
++ "gemini",
++ "kiro",
++ "opencode",
++ "roo",
++ "windsurf",
++}
++
++// pemBlock is a private key block with the given label and body.
++func pemBlock(label, body string) string {
++ return "-----BEGIN " + label + "-----\n" + body + "\n-----END " + label + "-----\n"
++}
++
++// TestRedactEveryShape checks every shape of secret one by one: the value
++// goes, the words around it stay.
++func TestRedactEveryShape(t *testing.T) {
++ cases := []struct {
++ name string
++ text string
++ secret string
++ gone []string
++ keep []string
++ }{
++ {
++ name: "pem rsa",
++ text: "the deploy key is\n" + pemBlock("RSA PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "and the deploy works",
++ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",
++ gone: []string{"PRIVATE KEY"},
++ keep: []string{"the deploy key is", "and the deploy works"},
++ },
++ {
++ name: "pem ec",
++ text: "the key is " + pemBlock("EC PRIVATE KEY", "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB") + "in the vault",
++ secret: "MHcCAQEEIBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
++ gone: []string{"PRIVATE KEY"},
++ keep: []string{"the key is", "in the vault"},
++ },
++ {
++ name: "pem openssh",
++ text: "the key is " + pemBlock("OPENSSH PRIVATE KEY", secretOpenSSH) + "in the vault",
++ secret: secretOpenSSH,
++ gone: []string{"PRIVATE KEY"},
++ keep: []string{"the key is", "in the vault"},
++ },
++ {
++ name: "pem pgp",
++ text: "the key is " + pemBlock("PGP PRIVATE KEY BLOCK", secretPGP) + "in the vault",
++ secret: secretPGP,
++ gone: []string{"PRIVATE KEY"},
++ keep: []string{"the key is", "in the vault"},
++ },
++ {
++ name: "pem encrypted",
++ text: "the key is " + pemBlock("ENCRYPTED PRIVATE KEY", "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE") + "in the vault",
++ secret: "MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE",
++ gone: []string{"PRIVATE KEY"},
++ keep: []string{"the key is", "in the vault"},
++ },
++ {
++ name: "env assignment",
++ text: "the line is DATABASE_PASSWORD=hunter2xyz in the file",
++ secret: "hunter2xyz",
++ keep: []string{"DATABASE_PASSWORD=", "in the file"},
++ },
++ {
++ name: "env assignment with a dot in the name",
++ text: "the line is MY.SECRET=abcdefghijkl in the file",
++ secret: "abcdefghijkl",
++ keep: []string{"MY.SECRET=", "in the file"},
++ },
++ {
++ name: "env assignment with a colon",
++ text: "the line is api_key: abcdefghijklmnop in the file",
++ secret: "abcdefghijklmnop",
++ keep: []string{"api_key:", "in the file"},
++ },
++ {
++ name: "env assignment with an arrow",
++ text: "the line is access_key => abcdefghijklmnop in the file",
++ secret: "abcdefghijklmnop",
++ keep: []string{"access_key =>", "in the file"},
++ },
++ {
++ name: "env assignment quoted",
++ text: "the line is password: \"correct horse battery staple\" in the file",
++ secret: "correct horse battery staple",
++ keep: []string{"password:", "in the file"},
++ },
++ {
++ name: "env assignment single quoted",
++ text: "the line is client_secret: 'correct horse battery staple' in the file",
++ secret: "correct horse battery staple",
++ keep: []string{"client_secret:", "in the file"},
++ },
++ {
++ name: "env assignment with a plural name",
++ text: "the line is max_tokens: 100000 in the file",
++ secret: "100000",
++ keep: []string{"max_tokens:", "in the file"},
++ },
++ {
++ name: "prose",
++ text: "the password is hunter2xyz and it must not be shown",
++ secret: "hunter2xyz",
++ keep: []string{"the password is", "and it must not be shown"},
++ },
++ {
++ name: "prose with was",
++ text: "the passphrase was correcthorsebattery and it must not be shown",
++ secret: "correcthorsebattery",
++ keep: []string{"the passphrase was", "and it must not be shown"},
++ },
++ {
++ name: "authorization bearer",
++ text: "the header is Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
++ secret: "abcdefghijklmnopqrstuvwxyz012345",
++ keep: []string{"Authorization: Bearer", "and it must not be shown"},
++ },
++ {
++ name: "authorization basic",
++ text: "the header is Authorization: Basic " + secretBasic + " and it must not be shown",
++ secret: secretBasic,
++ keep: []string{"Authorization: Basic", "and it must not be shown"},
++ },
++ {
++ name: "x api key",
++ text: "the header is x-api-key: abcdefghijklmnopqrst and it must not be shown",
++ secret: "abcdefghijklmnopqrst",
++ keep: []string{"x-api-key:", "and it must not be shown"},
++ },
++ {
++ name: "bare bearer",
++ text: "the header is Bearer abcdefghijklmnopqrstuvwxyz012345 and it must not be shown",
++ secret: "abcdefghijklmnopqrstuvwxyz012345",
++ keep: []string{"Bearer", "and it must not be shown"},
++ },
++ {
++ name: "google api key",
++ text: "the key is AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB and it must not be shown",
++ secret: "AIzaBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "gitlab token",
++ text: "the token is glpat-cccccccccccccccccccc and it must not be shown",
++ secret: "glpat-cccccccccccccccccccc",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "npm token",
++ text: "the token is npm_dddddddddddddddddddddddddddddddddddd and it must not be shown",
++ secret: "npm_dddddddddddddddddddddddddddddddddddd",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "hugging face token",
++ text: "the token is hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee and it must not be shown",
++ secret: "hf_eeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "sendgrid key",
++ text: "the key is SG.ffffffffffffffffffff.gggggggggggggggggggg and it must not be shown",
++ secret: "SG.ffffffffffffffffffff.gggggggggggggggggggg",
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "telegram bot token",
++ text: "the token is 123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh and it must not be shown",
++ secret: "123456789:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "discord bot token",
++ text: "the token is Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk and it must not be shown",
++ secret: "Miiiiiiiiiiiiiiiiiiiiiii.jjjjjj.kkkkkkkkkkkkkkkkkkkkkkkkkkk",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "twilio key",
++ text: "the key is SK" + strings.Repeat("1a", 16) + " and it must not be shown",
++ secret: "SK" + strings.Repeat("1a", 16),
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "slack app token",
++ text: "the token is xapp-llllllllll and it must not be shown",
++ secret: "xapp-llllllllll",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "openvibe token",
++ text: "the token is hbt_mmmmmmmmmmmmmmmm and it must not be shown",
++ secret: "hbt_mmmmmmmmmmmmmmmm",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "phoenix grove key",
++ text: "the key is pgsk_nnnnnnnnnnnnnnnn and it must not be shown",
++ secret: "pgsk_nnnnnnnnnnnnnnnn",
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "slack webhook",
++ text: "the hook is https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX and it must not be shown",
++ secret: "https://hooks.slack.com/services/T0000/B0000/XXXXXXXXXXXXXXXXXXXXXXXX",
++ keep: []string{"the hook is", "and it must not be shown"},
++ },
++ {
++ name: "discord webhook",
++ text: "the hook is https://discord.com/api/webhooks/1234/abcdefghijklmnop and it must not be shown",
++ secret: "https://discord.com/api/webhooks/1234/abcdefghijklmnop",
++ keep: []string{"the hook is", "and it must not be shown"},
++ },
++ {
++ name: "card number",
++ text: "the card is 4111111111111111 and it must not be shown",
++ secret: "4111111111111111",
++ keep: []string{"the card is", "and it must not be shown"},
++ },
++ {
++ name: "card number with spaces",
++ text: "the card is 4111 1111 1111 1111 and it must not be shown",
++ secret: "4111 1111 1111 1111",
++ keep: []string{"the card is", "and it must not be shown"},
++ },
++ {
++ name: "aws key",
++ text: "the key is AKIAIOSFODNN7EXAMPLE and it must not be shown",
++ secret: "AKIAIOSFODNN7EXAMPLE",
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "github token",
++ text: "the token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
++ secret: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "github fine grained token",
++ text: "the token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and it must not be shown",
++ secret: "github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "jwt",
++ text: "the token is " + secretJWT + " and it must not be shown",
++ secret: secretJWT,
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "openai key",
++ text: "the key is " + secretOpenAI + " and it must not be shown",
++ secret: secretOpenAI,
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "anthropic key",
++ text: "the key is " + secretAnthropic + " and it must not be shown",
++ secret: secretAnthropic,
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "stripe key",
++ text: "the key is " + secretStripe + " and it must not be shown",
++ secret: secretStripe,
++ keep: []string{"the key is", "and it must not be shown"},
++ },
++ {
++ name: "slack token",
++ text: "the token is xoxb-0000000000000000FAKE and it must not be shown",
++ secret: "xoxb-0000000000000000FAKE",
++ keep: []string{"the token is", "and it must not be shown"},
++ },
++ {
++ name: "connection string",
++ text: "the url is postgres://dbuser:[email protected]:5432/app and it must not be shown",
++ secret: "hunter2fake",
++ keep: []string{"postgres://dbuser:", "@db.internal:5432/app", "and it must not be shown"},
++ },
++ }
++ for _, c := range cases {
++ out, n := Redact(c.text)
++ if n == 0 {
++ t.Errorf("%s: nothing was redacted: %q", c.name, out)
++ continue
++ }
++ if strings.Contains(out, c.secret) {
++ t.Errorf("%s: the secret is still there: %q", c.name, out)
++ }
++ for _, gone := range c.gone {
++ if strings.Contains(out, gone) {
++ t.Errorf("%s: %q is still there: %q", c.name, gone, out)
++ }
++ }
++ for _, keep := range c.keep {
++ if !strings.Contains(out, keep) {
++ t.Errorf("%s: the text lost %q: %q", c.name, keep, out)
++ }
++ }
++ }
++}
++
++// TestRedactASecretThatSpansTwoItems checks a key block whose BEGIN and END
++// lines land in two different items: only the whole-file pass sees it.
++func TestRedactASecretThatSpansTwoItems(t *testing.T) {
++ doc := "the deploy key:\n\n" +
++ "```text\n" +
++ pemBeginRSA +
++ secretRSABody + "\n" +
++ "\n" +
++ secretRSABody + "2\n" +
++ pemEndRSA +
++ "```\n"
++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
++ if len(items) == 0 {
++ t.Fatal("the file gave no items")
++ }
++ for _, it := range items {
++ if strings.Contains(it.Text, "PRIVATE KEY") {
++ t.Errorf("the key is still in an item: %q", it.Text)
++ }
++ if strings.Contains(it.Text, "MIIEow") {
++ t.Errorf("the key body is still in an item: %q", it.Text)
++ }
++ }
++ found := false
++ for _, it := range items {
++ if it.Redacted {
++ found = true
++ }
++ }
++ if !found {
++ t.Errorf("nothing was marked redacted: %+v", items)
++ }
++}
++
++// TestRedactAKeyWithNoEndLine checks a key block that was cut off: it goes
++// from its BEGIN line to the end of its fence, and the text after the fence
++// stays.
++func TestRedactAKeyWithNoEndLine(t *testing.T) {
++ doc := "the vault key:\n\n" +
++ "```text\n" +
++ pemBeginRSA +
++ secretRSABody + "\n" +
++ "```\n\n" +
++ "the notes after the block stay\n"
++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
++ if len(items) == 0 {
++ t.Fatal("the file gave no items")
++ }
++ joined := ""
++ for _, it := range items {
++ if strings.Contains(it.Text, "MIIEow") {
++ t.Errorf("the key body is still in an item: %q", it.Text)
++ }
++ if strings.Contains(it.Text, "PRIVATE KEY") {
++ t.Errorf("the key is still in an item: %q", it.Text)
++ }
++ joined += it.Text + "\n"
++ }
++ if !strings.Contains(joined, "the notes after the block stay") {
++ t.Errorf("the text after the fence was swallowed: %q", joined)
++ }
++}
++
++// TestRedactAnEnvBlockInAFence checks an .env block in a fence: every value
++// goes, every name stays.
++func TestRedactAnEnvBlockInAFence(t *testing.T) {
++ doc := "the environment:\n\n" +
++ "```env\n" +
++ "DATABASE_PASSWORD=\"correct horse battery staple\"\n" +
++ "STRIPE_SECRET=" + secretStripe + "\n" +
++ "SESSION_TOKEN=hunter2xyz\n" +
++ "```\n"
++ items := parseMarkdown(parseOptions{}, "x.md", []byte(doc))
++ if len(items) == 0 {
++ t.Fatal("the file gave no items")
++ }
++ joined := ""
++ for _, it := range items {
++ joined += it.Text + "\n"
++ }
++ for _, secret := range []string{"hunter2xyz", secretStripe, "correct horse battery staple"} {
++ if strings.Contains(joined, secret) {
++ t.Errorf("the value %q is still there: %q", secret, joined)
++ }
++ }
++ for _, name := range []string{"DATABASE_PASSWORD=", "STRIPE_SECRET=", "SESSION_TOKEN="} {
++ if !strings.Contains(joined, name) {
++ t.Errorf("the name %q is gone: %q", name, joined)
++ }
++ }
++}
++
++// TestRedactASecretInTheFrontmatter checks a secret in the frontmatter: it
++// is gone before the description is used as a heading.
++func TestRedactASecretInTheFrontmatter(t *testing.T) {
++ doc := "---\n" +
++ "name: a_rule\n" +
++ "description: The rule about GITHUB_TOKEN=ghp_" + strings.Repeat("Q", 36) + "\n" +
++ "metadata:\n" +
++ " type: feedback\n" +
++ "---\n\n" +
++ "# Heading\n\n" +
++ "- one item\n"
++ items := parseMarkdown(parseOptions{kindFromType: true}, "x.md", []byte(doc))
++ if len(items) != 1 {
++ t.Fatalf("the file gave %d items, want 1: %+v", len(items), items)
++ }
++ if strings.Contains(items[0].Heading, "ghp_") {
++ t.Errorf("the secret is still in the heading: %q", items[0].Heading)
++ }
++ if !strings.Contains(items[0].Heading, Redacted) {
++ t.Errorf("the heading is not redacted: %q", items[0].Heading)
++ }
++ if !items[0].Redacted || items[0].Redactions == 0 {
++ t.Errorf("the item is not marked redacted: %+v", items[0])
++ }
++ if items[0].Kind != KindLesson {
++ t.Errorf("the item has the kind %q", items[0].Kind)
++ }
++}
++
++// TestTheWholeOutputHoldsNoPlantedSecret runs the scan over every fixture,
++// writes all the items out as JSON and checks that no planted secret is in
++// it.
++func TestTheWholeOutputHoldsNoPlantedSecret(t *testing.T) {
++ for _, id := range fixtureAdapters {
++ values, ok := plantedSecrets[id]
++ if !ok || len(values) == 0 {
++ t.Errorf("the %q fixture has no planted secret listed", id)
++ continue
++ }
++ // Every listed value is really in the fixture: the list does not
++ // drift away from the files.
++ home, project := fixture(t, id)
++ for _, value := range values {
++ found := false
++ for _, dir := range []string{home, project} {
++ err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
++ if err != nil || info.IsDir() {
++ return nil
++ }
++ data, err := os.ReadFile(path)
++ if err != nil {
++ return nil
++ }
++ if strings.Contains(string(data), value) {
++ found = true
++ }
++ return nil
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ }
++ if !found {
++ t.Errorf("the %q fixture does not hold %q any more", id, value)
++ }
++ }
++ res := scanOf(t, id)
++ out, err := json.Marshal(res.Items)
++ if err != nil {
++ t.Fatal(err)
++ }
++ for _, value := range values {
++ if strings.Contains(string(out), value) {
++ t.Errorf("adapter %q still shows the planted secret %q", id, value)
++ }
++ }
++ }
++}
++
++// TestOverRedactionStaysBounded checks that the harmless things a file says
++// are not touched.
++func TestOverRedactionStaysBounded(t *testing.T) {
++ harmless := []string{
++ "use 4 spaces for indentation",
++ "the build takes 12 minutes",
++ "5551234567",
++ "the port is 4321 and the timeout is 30 seconds",
++ "the release is 1.24.0 and the build number is 1234",
++ }
++ for _, text := range harmless {
++ out, n := Redact(text)
++ if n != 0 {
++ t.Errorf("%q was redacted: %q", text, out)
++ }
++ if out != text {
++ t.Errorf("%q was changed: %q", text, out)
++ }
++ }
++}
ddiff --git a/internal/memory/scan.go b/internal/memory/scan.go
nnew file mode 100644
iindex 0000000..2c78f9b
---- /dev/null
++++ b/internal/memory/scan.go
@@@ -0,0 +1,238 @@
++package memory
++
++import (
++ "os"
++ "path/filepath"
++ "sort"
++ "strings"
++ "time"
++)
++
++// Limits of the scan.
++const (
++ // MaxFileBytes is the size a file may have before it is skipped.
++ MaxFileBytes = 256 * 1024
++ // MaxItemRunes is the length of an item before it is cut.
++ MaxItemRunes = 2000
++)
++
++// Options say what to scan.
++type Options struct {
++ // Home is the home directory of the person whose memory is read.
++ Home string
++ // Projects are the project directories to read, in addition to the
++ // user-level paths.
++ Projects []string
++ // Tools are the adapter ids to read. Empty means every adapter.
++ Tools []string
++}
++
++// FileReport says what a scan found in one file.
++type FileReport struct {
++ // Path is the file that was looked at.
++ Path string `json:"path"`
++ // Found is true when the file was there.
++ Found bool `json:"found"`
++ // Items is how many items came out of the file.
++ Items int `json:"items"`
++ // Note says why a file was skipped, when it was.
++ Note string `json:"note,omitempty"`
++}
++
++// AdapterReport says what a scan found for one tool.
++type AdapterReport struct {
++ ID string `json:"id"`
++ Name string `json:"name"`
++ Docs string `json:"docs"`
++ Files []FileReport `json:"files"`
++ Items int `json:"items"`
++}
++
++// Result is what a scan found.
++type Result struct {
++ Home string `json:"home"`
++ Projects []string `json:"projects"`
++ Adapters []AdapterReport `json:"adapters"`
++ Items []Item `json:"items"`
++}
++
++// Scan reads the memory of the tools the person uses, locally and read-only.
++//
++// It never writes a file, never opens a network connection and never follows
++// a symlink out of the home directory or the project directories it was given.
++func Scan(opts Options) Result {
++ res := Result{Home: opts.Home, Adapters: []AdapterReport{}, Items: []Item{}}
++ projects := make([]string, 0, len(opts.Projects))
++ for _, p := range opts.Projects {
++ abs, err := filepath.Abs(p)
++ if err != nil {
++ continue
++ }
++ projects = append(projects, abs)
++ }
++ sort.Strings(projects)
++ res.Projects = projects
++
++ want := map[string]bool{}
++ for _, t := range opts.Tools {
++ want[strings.ToLower(strings.TrimSpace(t))] = true
++ }
++
++ for _, ad := range Adapters() {
++ if len(want) > 0 && !want[ad.ID] {
++ continue
++ }
++ report := AdapterReport{ID: ad.ID, Name: ad.Name, Docs: ad.Docs, Files: []FileReport{}}
++ for _, loc := range ad.Locations(opts.Home, projects) {
++ paths := resolveLocation(loc, opts.Home, projects)
++ if len(paths) == 0 && strings.ContainsAny(loc, "*?[") {
++ // A glob that matched nothing is still a path we looked at.
++ report.Files = append(report.Files, FileReport{Path: loc})
++ continue
++ }
++ for _, path := range paths {
++ fr := readFile(ad, path, opts.Home, projects, &res.Items)
++ report.Files = append(report.Files, fr)
++ report.Items += fr.Items
++ }
++ }
++ res.Adapters = append(res.Adapters, report)
++ }
++ return res
++}
++
++// resolveLocation turns a location into the files it stands for: one file
++// for an exact path, the matches of a glob for a pattern.
++func resolveLocation(loc, home string, projects []string) []string {
++ if !strings.ContainsAny(loc, "*?[") {
++ return []string{loc}
++ }
++ matches, err := expandGlob(loc)
++ if err != nil {
++ return nil
++ }
++ allowed := allowedRoots(home, projects)
++ out := make([]string, 0, len(matches))
++ for _, m := range matches {
++ if !withinAny(m, allowed) {
++ continue
++ }
++ out = append(out, m)
++ }
++ return out
++}
++
++// allowedRoots are the directories a file may live in.
++func allowedRoots(home string, projects []string) []string {
++ roots := make([]string, 0, len(projects)+1)
++ if home != "" {
++ roots = append(roots, home)
++ }
++ roots = append(roots, projects...)
++ return roots
++}
++
++// withinAny tells whether a path is inside one of the roots.
++func withinAny(path string, roots []string) bool {
++ for _, root := range roots {
++ if root == "" {
++ continue
++ }
++ if path == root || strings.HasPrefix(path, root+string(filepath.Separator)) {
++ return true
++ }
++ }
++ return false
++}
++
++// readFile reads one file and turns it into items.
++func readFile(ad Adapter, path, home string, projects []string, items *[]Item) FileReport {
++ fr := FileReport{Path: path}
++ info, err := os.Lstat(path)
++ if err != nil {
++ return fr
++ }
++ if info.Mode()&os.ModeSymlink != 0 {
++ // A link is only followed when it stays inside the paths we were given.
++ resolved, err := filepath.EvalSymlinks(path)
++ if err != nil || !withinAny(resolved, allowedRoots(home, projects)) {
++ fr.Note = "skipped: link leaves the scanned paths"
++ return fr
++ }
++ info, err = os.Stat(resolved)
++ if err != nil {
++ fr.Note = "skipped: link cannot be read"
++ return fr
++ }
++ path = resolved
++ }
++ if info.IsDir() {
++ fr.Note = "skipped: a directory"
++ return fr
++ }
++ if info.Size() > MaxFileBytes {
++ fr.Note = "skipped: larger than 256 KB"
++ return fr
++ }
++ data, err := os.ReadFile(path)
++ if err != nil {
++ fr.Note = "skipped: cannot be read"
++ return fr
++ }
++ if isBinary(data) {
++ fr.Note = "skipped: a binary file"
++ return fr
++ }
++ fr.Found = true
++ for _, name := range indexFilesOf(ad) {
++ if strings.EqualFold(filepath.Base(path), name) {
++ fr.Note = "skipped: an index of the memories, not a memory"
++ return fr
++ }
++ }
++ updated := info.ModTime().UTC().Format(time.RFC3339)
++ scope := scopeFor(path, projects)
++ for _, it := range ad.Parse(path, data) {
++ it.Source = ad.ID
++ it.Path = path
++ it.Updated = updated
++ it.Scope = scope
++ if it.Kind == "" {
++ // A file of the person's own is about them; a file of a project
++ // holds that project's rules.
++ if scope == ScopeUser {
++ it.Kind = KindPreference
++ } else {
++ it.Kind = KindProjectRule
++ }
++ }
++ *items = append(*items, it)
++ fr.Items++
++ }
++ return fr
++}
++
++// scopeFor gives the project a file belongs to, or the person when it belongs
++// to none.
++func scopeFor(path string, projects []string) Scope {
++ for _, p := range projects {
++ if path == p || strings.HasPrefix(path, p+string(filepath.Separator)) {
++ return ScopeProject(p)
++ }
++ }
++ return ScopeUser
++}
++
++// isBinary tells whether a file holds binary data.
++func isBinary(data []byte) bool {
++ head := data
++ if len(head) > 8000 {
++ head = head[:8000]
++ }
++ for _, b := range head {
++ if b == 0 {
++ return true
++ }
++ }
++ return false
++}
ddiff --git a/internal/memory/testdata/agents/home/.codex/AGENTS.md b/internal/memory/testdata/agents/home/.codex/AGENTS.md
nnew file mode 100644
iindex 0000000..091b541
---- /dev/null
++++ b/internal/memory/testdata/agents/home/.codex/AGENTS.md
@@@ -0,0 +1,7 @@
++# Codex global
++
++## Preferences
++
++- Answer in plain English, no preamble
++- Never touch the vendor directory
++- The release token is ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ddiff --git a/internal/memory/testdata/agents/project/AGENTS.md b/internal/memory/testdata/agents/project/AGENTS.md
nnew file mode 100644
iindex 0000000..d4368ec
---- /dev/null
++++ b/internal/memory/testdata/agents/project/AGENTS.md
@@@ -0,0 +1,10 @@
++# Agents
++
++## Project rules
++
++- Run the tests before committing
++- Do not edit generated files
++
++## Decision
++
++We keep the ledger in SQLite for now.
ddiff --git a/internal/memory/testdata/aider/home/.aider.conf.yml b/internal/memory/testdata/aider/home/.aider.conf.yml
nnew file mode 100644
iindex 0000000..7b5fe2e
---- /dev/null
++++ b/internal/memory/testdata/aider/home/.aider.conf.yml
@@@ -0,0 +1,3 @@
++model: gpt-4o
++read:
++ - NOTES.md
ddiff --git a/internal/memory/testdata/aider/home/NOTES.md b/internal/memory/testdata/aider/home/NOTES.md
nnew file mode 100644
iindex 0000000..39a4bd9
---- /dev/null
++++ b/internal/memory/testdata/aider/home/NOTES.md
@@@ -0,0 +1,3 @@
++# Notes
++
++The person prefers short commit messages.
ddiff --git a/internal/memory/testdata/aider/project/.aider.conf.yml b/internal/memory/testdata/aider/project/.aider.conf.yml
nnew file mode 100644
iindex 0000000..a6a0d24
---- /dev/null
++++ b/internal/memory/testdata/aider/project/.aider.conf.yml
@@@ -0,0 +1,4 @@
++model: gpt-4o
++read:
++ - README.md
++ - docs/architecture.md
ddiff --git a/internal/memory/testdata/aider/project/CONVENTIONS.md b/internal/memory/testdata/aider/project/CONVENTIONS.md
nnew file mode 100644
iindex 0000000..66556e6
---- /dev/null
++++ b/internal/memory/testdata/aider/project/CONVENTIONS.md
@@@ -0,0 +1,4 @@
++# Conventions
++
++- Run `go test ./...` before every commit
++- The database password=Sup3rSecretFakeValue is only for the fixture
ddiff --git a/internal/memory/testdata/aider/project/README.md b/internal/memory/testdata/aider/project/README.md
nnew file mode 100644
iindex 0000000..a3c3a5c
---- /dev/null
++++ b/internal/memory/testdata/aider/project/README.md
@@@ -0,0 +1,3 @@
++# Demo project
++
++A small service for invoices.
ddiff --git a/internal/memory/testdata/aider/project/docs/architecture.md b/internal/memory/testdata/aider/project/docs/architecture.md
nnew file mode 100644
iindex 0000000..b1a4daa
---- /dev/null
++++ b/internal/memory/testdata/aider/project/docs/architecture.md
@@@ -0,0 +1,3 @@
++# Architecture
++
++The service is one binary and one database.
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/CLAUDE.md b/internal/memory/testdata/claude-code/home/.claude/CLAUDE.md
nnew file mode 100644
iindex 0000000..fff183b
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/CLAUDE.md
@@@ -0,0 +1,8 @@
++# Personal setup
++
++- Prefer tabs in Go files and spaces everywhere else
++- The test command is `go test ./...` and it must pass before a commit
++
++## Credentials
++
++The staging API key is sk-proj-A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and it is only for the sandbox.
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/MEMORY.md b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/MEMORY.md
nnew file mode 100644
iindex 0000000..0dec764
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/MEMORY.md
@@@ -0,0 +1,4 @@
++# Memory index
++
++- [User role](user_role.md)
++- [Feedback on tests](feedback_testing.md)
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/feedback_testing.md b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/feedback_testing.md
nnew file mode 100644
iindex 0000000..7f5a49c
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/feedback_testing.md
@@@ -0,0 +1,8 @@
++---
++name: feedback_testing
++description: What the person corrected
++metadata:
++ type: feedback
++---
++
++Do not run the whole suite while iterating; run the one test.
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/user_role.md b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/user_role.md
nnew file mode 100644
iindex 0000000..403837c
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/projects/demo-project/memory/user_role.md
@@@ -0,0 +1,8 @@
++---
++name: user_role
++description: The person running the project
++metadata:
++ type: user
++---
++
++The person is a staff engineer who prefers short answers.
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md b/internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md
nnew file mode 100644
iindex 0000000..d4d47de
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/rules/sandbox.md
@@@ -0,0 +1,25 @@
++# Sandbox
++
++- The sandbox runs against its own database
++
++```text
++-----BEGIN OPENSSH PRIVATE KEY-----
++
++MIIEowIBAAKCAQEAtESTKEYONLYOPENSSHFAKE000000000000
++-----END OPENSSH PRIVATE KEY-----
++```
++
++- The sandbox environment is written down here
++
++```env
++DATABASE_PASSWORD=fake-db-password-123
++STRIPE_SECRET=fake-stripe-secret-999
++SESSION_SECRET="correct horse battery staple"
++```
++
++- The vault key was pasted in halves
++
++```text
++-----BEGIN RSA PRIVATE KEY-----
++MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000
++```
ddiff --git a/internal/memory/testdata/claude-code/home/.claude/rules/testing.md b/internal/memory/testdata/claude-code/home/.claude/rules/testing.md
nnew file mode 100644
iindex 0000000..12e3c35
---- /dev/null
++++ b/internal/memory/testdata/claude-code/home/.claude/rules/testing.md
@@@ -0,0 +1,9 @@
++---
++paths:
++ - "**/*_test.go"
++---
++
++# Testing rules
++
++- Every change comes with a test
++- Never commit a failing test
ddiff --git a/internal/memory/testdata/claude-code/project/.claude/CLAUDE.md b/internal/memory/testdata/claude-code/project/.claude/CLAUDE.md
nnew file mode 100644
iindex 0000000..ecbd76d
---- /dev/null
++++ b/internal/memory/testdata/claude-code/project/.claude/CLAUDE.md
@@@ -0,0 +1,3 @@
++# Local Claude notes
++
++- The sandbox URL is http://localhost:4321
ddiff --git a/internal/memory/testdata/claude-code/project/.claude/rules/api.md b/internal/memory/testdata/claude-code/project/.claude/rules/api.md
nnew file mode 100644
iindex 0000000..ac2c3ee
---- /dev/null
++++ b/internal/memory/testdata/claude-code/project/.claude/rules/api.md
@@@ -0,0 +1,9 @@
++---
++paths:
++ - "src/api/**"
++---
++
++# API rules
++
++- Validate every input
++- Use the standard error format
ddiff --git a/internal/memory/testdata/claude-code/project/CLAUDE.local.md b/internal/memory/testdata/claude-code/project/CLAUDE.local.md
nnew file mode 100644
iindex 0000000..485ea57
---- /dev/null
++++ b/internal/memory/testdata/claude-code/project/CLAUDE.local.md
@@@ -0,0 +1,3 @@
++# Personal for this project
++
++- Use the fixture database, never the real one
ddiff --git a/internal/memory/testdata/claude-code/project/CLAUDE.md b/internal/memory/testdata/claude-code/project/CLAUDE.md
nnew file mode 100644
iindex 0000000..ed6ca64
---- /dev/null
++++ b/internal/memory/testdata/claude-code/project/CLAUDE.md
@@@ -0,0 +1,10 @@
++# Demo project
++
++## Conventions
++
++- Use 2-space indentation
++- Keep the public API small
++
++## Build
++
++Run `npm run build` before pushing.
ddiff --git a/internal/memory/testdata/cline/project/.clinerules b/internal/memory/testdata/cline/project/.clinerules
nnew file mode 100644
iindex 0000000..7516586
---- /dev/null
++++ b/internal/memory/testdata/cline/project/.clinerules
@@@ -0,0 +1,4 @@
++# Cline rules
++
++- Always read the memory bank first
++- The database password=Sup3rSecretFakeValue is only for the fixture
ddiff --git a/internal/memory/testdata/cline/project/memory-bank/projectbrief.md b/internal/memory/testdata/cline/project/memory-bank/projectbrief.md
nnew file mode 100644
iindex 0000000..77eed00
---- /dev/null
++++ b/internal/memory/testdata/cline/project/memory-bank/projectbrief.md
@@@ -0,0 +1,3 @@
++# Project brief
++
++The project is a small invoicing service.
ddiff --git a/internal/memory/testdata/command-code/home/.commandcode/AGENTS.md b/internal/memory/testdata/command-code/home/.commandcode/AGENTS.md
nnew file mode 100644
iindex 0000000..3df234f
---- /dev/null
++++ b/internal/memory/testdata/command-code/home/.commandcode/AGENTS.md
@@@ -0,0 +1,3 @@
++# Command Code memory
++
++- Keep the answers short
ddiff --git a/internal/memory/testdata/command-code/project/.commandcode/AGENTS.md b/internal/memory/testdata/command-code/project/.commandcode/AGENTS.md
nnew file mode 100644
iindex 0000000..ebe1139
---- /dev/null
++++ b/internal/memory/testdata/command-code/project/.commandcode/AGENTS.md
@@@ -0,0 +1,4 @@
++# Command Code project memory
++
++- The build is `make build`
++- The personal token is github_pat_11AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ddiff --git a/internal/memory/testdata/continue/project/.continue/rules/style.md b/internal/memory/testdata/continue/project/.continue/rules/style.md
nnew file mode 100644
iindex 0000000..00e5691
---- /dev/null
++++ b/internal/memory/testdata/continue/project/.continue/rules/style.md
@@@ -0,0 +1,4 @@
++# Style
++
++- Use tabs in Go
++- The personal token=ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB is for the sandbox only
ddiff --git a/internal/memory/testdata/copilot/project/.github/copilot-instructions.md b/internal/memory/testdata/copilot/project/.github/copilot-instructions.md
nnew file mode 100644
iindex 0000000..2bdb759
---- /dev/null
++++ b/internal/memory/testdata/copilot/project/.github/copilot-instructions.md
@@@ -0,0 +1,5 @@
++# Repository instructions
++
++- Use the shared client in packages/api
++- Keep the changelog updated
++- The session token is eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
ddiff --git a/internal/memory/testdata/copilot/project/.github/instructions/tests.instructions.md b/internal/memory/testdata/copilot/project/.github/instructions/tests.instructions.md
nnew file mode 100644
iindex 0000000..fd94431
---- /dev/null
++++ b/internal/memory/testdata/copilot/project/.github/instructions/tests.instructions.md
@@@ -0,0 +1,7 @@
++---
++applyTo: "**/*.test.ts"
++---
++
++# Test instructions
++
++- Use the fixture builder, not raw objects
ddiff --git a/internal/memory/testdata/cursor/project/.cursor/rules/db.mdc b/internal/memory/testdata/cursor/project/.cursor/rules/db.mdc
nnew file mode 100644
iindex 0000000..30f9a83
---- /dev/null
++++ b/internal/memory/testdata/cursor/project/.cursor/rules/db.mdc
@@@ -0,0 +1,11 @@
++---
++description: Database rules
++globs:
++ - "**/*.sql"
++alwaysApply: false
++---
++
++# Database
++
++- Every migration is reversible
++- No table without an index
ddiff --git a/internal/memory/testdata/cursor/project/.cursor/rules/style.mdc b/internal/memory/testdata/cursor/project/.cursor/rules/style.mdc
nnew file mode 100644
iindex 0000000..58fc970
---- /dev/null
++++ b/internal/memory/testdata/cursor/project/.cursor/rules/style.mdc
@@@ -0,0 +1,11 @@
++---
++description: How we write code here
++globs: "**/*.go"
++alwaysApply: true
++---
++
++# Style
++
++- Error messages start lowercase
++- Comments explain why, not what
++- The Slack token for the alerts room is xoxb-0000000000000000FAKE
ddiff --git a/internal/memory/testdata/gemini/home/.gemini/GEMINI.md b/internal/memory/testdata/gemini/home/.gemini/GEMINI.md
nnew file mode 100644
iindex 0000000..10786b9
---- /dev/null
++++ b/internal/memory/testdata/gemini/home/.gemini/GEMINI.md
@@@ -0,0 +1,5 @@
++# Global Gemini context
++
++- Write release notes in the past tense
++- Keep summaries under five lines
++- The bucket key is AKIAIOSFODNN7EXAMPLE
ddiff --git a/internal/memory/testdata/gemini/project/GEMINI.md b/internal/memory/testdata/gemini/project/GEMINI.md
nnew file mode 100644
iindex 0000000..ce5a2c6
---- /dev/null
++++ b/internal/memory/testdata/gemini/project/GEMINI.md
@@@ -0,0 +1,10 @@
++# Project context
++
++## Stack
++
++- Go 1.24 for the services
++- Postgres 16 for the data
++
++## Style
++
++- Errors are wrapped with %w
ddiff --git a/internal/memory/testdata/kiro/project/.kiro/steering/product.md b/internal/memory/testdata/kiro/project/.kiro/steering/product.md
nnew file mode 100644
iindex 0000000..226d430
---- /dev/null
++++ b/internal/memory/testdata/kiro/project/.kiro/steering/product.md
@@@ -0,0 +1,3 @@
++# Product
++
++An invoicing service for small businesses.
ddiff --git a/internal/memory/testdata/kiro/project/.kiro/steering/tech.md b/internal/memory/testdata/kiro/project/.kiro/steering/tech.md
nnew file mode 100644
iindex 0000000..0aac326
---- /dev/null
++++ b/internal/memory/testdata/kiro/project/.kiro/steering/tech.md
@@@ -0,0 +1,5 @@
++# Technology stack
++
++- Go with chi for the HTTP layer
++- Postgres 16
++- The staging URL is postgres://dbuser:[email protected]:5432/app
ddiff --git a/internal/memory/testdata/opencode/home/.config/opencode/AGENTS.md b/internal/memory/testdata/opencode/home/.config/opencode/AGENTS.md
nnew file mode 100644
iindex 0000000..ca1968c
---- /dev/null
++++ b/internal/memory/testdata/opencode/home/.config/opencode/AGENTS.md
@@@ -0,0 +1,3 @@
++# OpenCode global
++
++- Ask before changing the CI
ddiff --git a/internal/memory/testdata/opencode/project/AGENTS.md b/internal/memory/testdata/opencode/project/AGENTS.md
nnew file mode 100644
iindex 0000000..e945e54
---- /dev/null
++++ b/internal/memory/testdata/opencode/project/AGENTS.md
@@@ -0,0 +1,3 @@
++# OpenCode instructions
++
++- Read the schema before writing a migration
ddiff --git a/internal/memory/testdata/opencode/project/docs/conventions.md b/internal/memory/testdata/opencode/project/docs/conventions.md
nnew file mode 100644
iindex 0000000..084aa0c
---- /dev/null
++++ b/internal/memory/testdata/opencode/project/docs/conventions.md
@@@ -0,0 +1,4 @@
++# Conventions
++
++- Every migration has a down step
++- The aws_secret_access_key = wJalrXUtnFEMI00000000000000000000000FAKE is a fixture
ddiff --git a/internal/memory/testdata/opencode/project/opencode.json b/internal/memory/testdata/opencode/project/opencode.json
nnew file mode 100644
iindex 0000000..f6d52d1
---- /dev/null
++++ b/internal/memory/testdata/opencode/project/opencode.json
@@@ -0,0 +1,4 @@
++{
++ "$schema": "https://opencode.ai/config.json",
++ "instructions": ["docs/conventions.md"]
++}
ddiff --git a/internal/memory/testdata/roo/project/.roo/rules/general.md b/internal/memory/testdata/roo/project/.roo/rules/general.md
nnew file mode 100644
iindex 0000000..5577c18
---- /dev/null
++++ b/internal/memory/testdata/roo/project/.roo/rules/general.md
@@@ -0,0 +1,4 @@
++# Roo rules
++
++- Never edit files outside the workspace
++- The api key is sk_live_0000000000000000FAKE
ddiff --git a/internal/memory/testdata/roo/project/memory-bank/techContext.md b/internal/memory/testdata/roo/project/memory-bank/techContext.md
nnew file mode 100644
iindex 0000000..dfc4505
---- /dev/null
++++ b/internal/memory/testdata/roo/project/memory-bank/techContext.md
@@@ -0,0 +1,3 @@
++# Tech context
++
++- Node 22 and TypeScript 5
ddiff --git a/internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md b/internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md
nnew file mode 100644
iindex 0000000..e3cd735
---- /dev/null
++++ b/internal/memory/testdata/windsurf/home/.codeium/windsurf/memories/preferences.md
@@@ -0,0 +1,9 @@
++# Preferences
++
++- I like small pull requests
++- I read the diff before the description
++- The deploy key lives here:
++
++-----BEGIN RSA PRIVATE KEY-----
++MIIEowIBAAKCAQEAtESTKEYONLYFAKEFIXTURE0000000000000000
++-----END RSA PRIVATE KEY-----
ddiff --git a/internal/memory/testdata/windsurf/project/.windsurf/rules/general.md b/internal/memory/testdata/windsurf/project/.windsurf/rules/general.md
nnew file mode 100644
iindex 0000000..5a0b302
---- /dev/null
++++ b/internal/memory/testdata/windsurf/project/.windsurf/rules/general.md
@@@ -0,0 +1,3 @@
++# General rules
++
++- Use pnpm, never npm