hippo-memory 1.52.9 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +34 -10
  2. package/dist/agent-memories/apply.d.ts +47 -0
  3. package/dist/agent-memories/apply.js +253 -0
  4. package/dist/agent-memories/claude-code.d.ts +11 -0
  5. package/dist/agent-memories/claude-code.js +113 -0
  6. package/dist/agent-memories/codex.d.ts +3 -0
  7. package/dist/agent-memories/codex.js +47 -0
  8. package/dist/agent-memories/copilot.d.ts +3 -0
  9. package/dist/agent-memories/copilot.js +125 -0
  10. package/dist/agent-memories/files.d.ts +37 -0
  11. package/dist/agent-memories/files.js +77 -0
  12. package/dist/agent-memories/folder-store.d.ts +17 -0
  13. package/dist/agent-memories/folder-store.js +44 -0
  14. package/dist/agent-memories/gemini.d.ts +3 -0
  15. package/dist/agent-memories/gemini.js +103 -0
  16. package/dist/agent-memories/git.d.ts +8 -0
  17. package/dist/agent-memories/git.js +11 -0
  18. package/dist/agent-memories/keys.d.ts +9 -0
  19. package/dist/agent-memories/keys.js +20 -0
  20. package/dist/agent-memories/legacy.d.ts +17 -0
  21. package/dist/agent-memories/legacy.js +45 -0
  22. package/dist/agent-memories/markdown.d.ts +13 -0
  23. package/dist/agent-memories/markdown.js +123 -0
  24. package/dist/agent-memories/openclaw.d.ts +3 -0
  25. package/dist/agent-memories/openclaw.js +42 -0
  26. package/dist/agent-memories/plan.d.ts +78 -0
  27. package/dist/agent-memories/plan.js +123 -0
  28. package/dist/agent-memories/qwen-code.d.ts +5 -0
  29. package/dist/agent-memories/qwen-code.js +50 -0
  30. package/dist/agent-memories/report.d.ts +52 -0
  31. package/dist/agent-memories/report.js +88 -0
  32. package/dist/agent-memories/source.d.ts +16 -0
  33. package/dist/agent-memories/source.js +32 -0
  34. package/dist/agent-memories/sync.d.ts +33 -0
  35. package/dist/agent-memories/sync.js +336 -0
  36. package/dist/agent-memories/tools.d.ts +33 -0
  37. package/dist/agent-memories/tools.js +19 -0
  38. package/dist/agent-memories/types.d.ts +42 -0
  39. package/dist/agent-memories/types.js +2 -0
  40. package/dist/api.d.ts +2 -2
  41. package/dist/api.js +26 -27
  42. package/dist/audit.d.ts +1 -1
  43. package/dist/audit.js +5 -2
  44. package/dist/capture.d.ts +11 -22
  45. package/dist/capture.js +76 -81
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +131 -151
  48. package/dist/compaction-items.d.ts +18 -0
  49. package/dist/compaction-items.js +60 -0
  50. package/dist/compaction-record.d.ts +94 -0
  51. package/dist/compaction-record.js +546 -0
  52. package/dist/config.d.ts +4 -0
  53. package/dist/config.js +13 -0
  54. package/dist/consolidate.js +2 -2
  55. package/dist/db.d.ts +5 -1
  56. package/dist/db.js +40 -8
  57. package/dist/doctor.js +34 -2
  58. package/dist/dormant.d.ts +5 -3
  59. package/dist/dormant.js +9 -0
  60. package/dist/gated-write.d.ts +9 -0
  61. package/dist/gated-write.js +24 -0
  62. package/dist/hooks.d.ts +4 -2
  63. package/dist/hooks.js +8 -7
  64. package/dist/memory.d.ts +19 -2
  65. package/dist/memory.js +35 -3
  66. package/dist/shared.js +10 -7
  67. package/dist/store.d.ts +8 -2
  68. package/dist/store.js +21 -2
  69. package/dist/version.d.ts +1 -1
  70. package/dist/version.js +1 -1
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +1 -1
  74. package/package.json +1 -1
package/README.md CHANGED
@@ -83,7 +83,7 @@ npm install -g hippo-memory
83
83
  hippo init
84
84
  ```
85
85
 
86
- **Optional: many repos at once.** Read what it changes first. `hippo init --scan <folder>` looks for git repos in the folder and up to three levels below it, skipping dot-folders and `node_modules`. Each repo gets a `.hippo/` store, seeded with lessons from the last 365 days of its commits, and is added to the daily run's list. For the agents it finds, it installs the same user-level hooks as `hippo init`: 7 Claude Code hook entries in `~/.claude/settings.json` and the OpenCode plugin. It also sets up the daily 6:15am run, a crontab line on Linux and macOS or a scheduled task on Windows. It adds no block to any repo's `CLAUDE.md` or `AGENTS.md`. `--no-hooks`, `--no-schedule` and `--no-learn` leave out the hooks, the daily run and the history import.
86
+ **Optional: many repos at once.** Read what it changes first. `hippo init --scan <folder>` looks for git repos in the folder and up to three levels below it, skipping dot-folders and `node_modules`. Each repo gets a `.hippo/` store, seeded with lessons from the last 365 days of its commits and with its [agent memories](#agent-memories), and is added to the daily run's list. For the agents it finds, it installs the same user-level hooks as `hippo init`: 7 Claude Code hook entries in `~/.claude/settings.json` and the OpenCode plugin. It also sets up the daily 6:15am run, a crontab line on Linux and macOS or a scheduled task on Windows. It adds no block to any repo's `CLAUDE.md` or `AGENTS.md`. `--no-hooks`, `--no-schedule` and `--no-learn` leave out the hooks, the daily run and the history import.
87
87
 
88
88
  ```bash
89
89
  hippo init --scan ~
@@ -92,7 +92,7 @@ hippo init --scan ~
92
92
  After setup, `hippo sleep` runs when a Claude Code or OpenCode session ends, and in the daily 6:15am job for every project. Codex runs it at session end only if you installed its wrapper. It does five things:
93
93
 
94
94
  1. **Learns** from today's git commits
95
- 2. **Imports** new entries from the project's Claude Code auto memory
95
+ 2. **Imports** what your coding agents remember, about this project and about you ([Agent memories](#agent-memories))
96
96
  3. **Consolidates** memories (decay, merge, prune)
97
97
  4. **Deduplicates** identical memories, keeping the stronger copy
98
98
  5. **Shares** high-value lessons to a global store so they surface in every project
@@ -118,7 +118,7 @@ Run `hippo init` inside one project. This is everything it writes, in the projec
118
118
  - **OpenCode,** when the project has `.opencode/` or `opencode.json`: a plugin at `~/.config/opencode/plugins/hippo.ts`.
119
119
  - **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus the five most recent ones with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
120
120
  - **A daily run at 6:15am,** one per machine: a crontab line on Linux and macOS, a scheduled task named `hippo-daily-runner` on Windows. It runs `hippo learn --git --days 1` and then `hippo sleep` in every project listed in `~/.hippo/workspaces.json`, and init adds this project to that list.
121
- - **Claude Code auto memory.** On the first run, the notes with YAML front matter in this project's own folder under `~/.claude/projects/` are imported into its store. A file that looks like it holds a secret is skipped.
121
+ - **Agent memories.** On every run, the notes your coding agents keep about this project go into its store, and the ones about you go into the global store. [Agent memories](#agent-memories) lists what is read.
122
122
 
123
123
  ```bash
124
124
  cd my-project
@@ -133,7 +133,28 @@ hippo init
133
133
  # Scheduled machine-level daily runner (6:15am) via crontab
134
134
  ```
135
135
 
136
- To leave parts out: `--no-hooks` skips the instruction files and hooks, `--no-schedule` the daily run, and `--no-learn` the git history and auto memory import. `HIPPO_SKIP_AUTO_INTEGRATIONS=1` skips the same files and hooks that `--no-hooks` does.
136
+ To leave parts out: `--no-hooks` skips the instruction files and hooks, `--no-schedule` the daily run, and `--no-learn` the git history and agent memory import. `HIPPO_SKIP_AUTO_INTEGRATIONS=1` skips the same files and hooks that `--no-hooks` does.
137
+
138
+ ### Agent memories
139
+
140
+ Most coding agents now keep their own notes between sessions. Hippo reads them, whatever the tool, so what one agent learned reaches the others. It reads files only and never writes to another tool's folders.
141
+
142
+ When: `hippo init` (every run), `init --scan`, `init --global`, `hippo setup`, every `hippo sleep` and the daily run. At session end a folder with its own store gets it through sleep; a folder without one sends its project's notes to the global store, marked with the project's name. After a Claude Code compaction, the session's own notes folder is read as well.
143
+
144
+ What is read, per tool (each tool's own environment variables and settings decide where its home is):
145
+
146
+ - **Claude Code:** the project's auto memory notes under `~/.claude/projects/<project>/memory/` (front matter required, `MEMORY.md` skipped), and the `autoMemoryDirectory` folder from your user settings.
147
+ - **Codex:** the User Profile, preferences and tips in `~/.codex/memories/memory_summary.md`.
148
+ - **Gemini CLI:** the "Gemini Added Memories" section of `~/.gemini/GEMINI.md`, and the project's auto memory folder when that feature is on.
149
+ - **GitHub Copilot Chat in VS Code:** the memory tool's user memories and the repository memories of this project's workspace.
150
+ - **OpenClaw:** the workspace's `MEMORY.md`.
151
+ - **Qwen Code:** the project's auto memory folder and your user memories.
152
+
153
+ Each imported memory follows its note. It stays while the note exists, is replaced when the note changes, and is set aside as dormant when the note is deleted (`hippo dormant` lists it and can restore it). A note shorter than 10 characters, one that looks like it holds a secret (an API key, a password, an auth header or a token), and one whose text you rejected with `hippo reject` are skipped. Email addresses are stored masked, and notes are cut at 1,500 characters.
154
+
155
+ Not read: Windsurf (the file format is not documented, and Cascade reached end of life on 1 July 2026); Cursor, Copilot CLI and GitHub's Copilot Memory (the memories live on the vendor's servers); Kiro (the local store is not documented); Cline and Roo memory banks (files in the repository, which `hippo import --markdown` covers); Amp, Aider, Continue, OpenCode and pi (no memory feature found).
156
+
157
+ `hippo import --agents` runs the import by hand; in a folder without a store it does what session end does there. With `--dry-run` it shows each tool's home, the folders found and what would change, and writes nothing. To choose tools, set `"agentMemories": { "tools": ["claude-code", "codex"] }` in `.hippo/config.json` (`[]` turns the import off), or `HIPPO_AGENT_MEMORY_TOOLS=claude-code,codex` in the environment (`none` turns it off), which wins over config.
137
158
 
138
159
  ---
139
160
 
@@ -508,8 +529,9 @@ detector flags is deleted, never kept dormant, and a dormant memory nobody resto
508
529
  `dormant.retentionDays` (default 180, `0` keeps them forever) is deleted for good. Rejecting
509
530
  a value (`hippo reject`) removes its dormant copies too. To delete faded memories straight
510
531
  away as before, set `{"dormant":{"enabled":false}}` in `.hippo/config.json`. Sleep never
511
- removes pinned memories or raw receipts (Slack, GitHub, vault imports) either way, and
512
- duplicate removal and junk cleanup still delete.
532
+ removes pinned memories, raw receipts (Slack, GitHub, vault imports) or the memories a
533
+ Claude Code compaction saved either way, and duplicate removal and junk cleanup still
534
+ delete other memories. `hippo forget` still deletes a compaction memory.
513
535
 
514
536
  **See what memory costs in tokens.** Every block of memory text hippo hands an agent (the
515
537
  per-prompt hook, the block `hippo compact-resume` restores after compaction, `hippo context`,
@@ -734,7 +756,7 @@ On `heartbeat`, `block`, `review` and `complete`, a given `--run` is checked aga
734
756
  | OpenCode | `.opencode/` or `opencode.json` | `AGENTS.md` + TS plugin at `~/.config/opencode/plugins/hippo.ts` (subscribes to `session.idle` + `session.created`) |
735
757
  | Pi | `.pi` or `.pi/agent` | `AGENTS.md`; copy the [Pi extension](https://github.com/kitfunso/hippo-memory/tree/master/extensions/pi-extension) for session hooks |
736
758
 
737
- Init patches an instruction file only if it already exists. It also sets up a daily run and imports the project's Claude Code auto memory; [What hippo init changes](#what-hippo-init-changes) lists everything.
759
+ Init patches an instruction file only if it already exists. It also sets up a daily run and imports your coding agents' own memories; [What hippo init changes](#what-hippo-init-changes) lists everything.
738
760
 
739
761
  ### Manual install
740
762
 
@@ -760,11 +782,13 @@ For Claude Code, it also adds 7 hook entries to `~/.claude/settings.json`:
760
782
  - a `SessionEnd` hook that runs `hippo sleep` and then `hippo capture` when the session exits. Capture matches the last 20 user and 10 assistant messages of the transcript against word patterns for decisions, rules, errors and preferences. It uses no model and does not read earlier turns, so record lessons with `hippo remember` as you go.
761
783
  - a `SessionStart` hook that prints the previous session's consolidation output
762
784
  - a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus the 5 newest memories in the store, so fresh same-session lessons appear on the next prompt before you pin them. It does not read your prompt unless you set `{"pinnedInject":{"promptRecall":true}}`, which swaps the 5 newest for memories that match the prompt. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. `{"pinnedInject":{"skipUnchanged":false}}` sends it every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
763
- - a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it; the `SessionEnd` hook still owns extracting durable memories.
785
+ - a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It records the compaction in the store, saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it, and asks the summariser to end its summary with a "Memories for hippo" list: the lessons, decisions and corrections from the session that should outlive it. The `SessionEnd` hook still owns extracting durable memories from the transcript.
764
786
  - a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot back into context right after compaction, if it is under 15 minutes old.
765
- - a `PostCompact` hook that runs `hippo post-compact`, which tells you what was saved ("Hippo saved your task snapshot before compacting."). It prints nothing when nothing was saved.
787
+ - a `PostCompact` hook that runs `hippo post-compact`. It keeps the summary in the store with secrets scrubbed, and saves each item of that list as a memory that sleep never deletes: at most 10 per compaction, skipping an item an earlier compaction already saved and any item that looks like a secret. An item over 500 characters stays in the compaction's record only. It then prints one line, such as "Hippo saved 3 memories from this compaction and restored your task snapshot." If the store is busy, the summary waits in the store's `compactions-spool/` folder and `hippo sleep` finishes the save; `hippo doctor` names any compaction left unfinished for over 10 minutes. The session that compacted does not have those memories injected back into its own prompts, since it just read them in the summary; `hippo recall` still finds them. The hook prints nothing when there is no store to save to.
766
788
  - a `PostToolUseFailure` hook that runs `hippo capture-error`, which stores a failed tool call as an error memory. It skips interrupts, declined permissions and searches that found nothing, and stores a repeated failure once. It also logs every failure, stored or not, for `hippo failures`: the session, the tool and hashes of the error, never its text. A hash is not anonymous, since anyone who guesses an error's text can check it against the hash. The log keeps 90 days.
767
789
 
790
+ Only Claude Code saves memories at a compaction: hippo installs no `PreCompact` or `PostCompact` hook for Codex, Cursor, OpenCode, OpenClaw or Pi.
791
+
768
792
  To remove: `hippo hook uninstall claude-code`
769
793
 
770
794
  For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.json`) and keeps every hook already there:
@@ -1030,7 +1054,7 @@ node run.mjs --adapter all
1030
1054
 
1031
1055
  ### How do I give Claude Code memory between sessions?
1032
1056
 
1033
- Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus the five most recent ones in context, save a task snapshot before compaction, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1057
+ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus the five most recent ones in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
1034
1058
 
1035
1059
  ### How do I give Cursor memory between sessions?
1036
1060
 
@@ -0,0 +1,47 @@
1
+ import type { DatabaseSyncLike } from '../db.js';
2
+ import { type MemoryEntry } from '../memory.js';
3
+ import { type Tally } from './report.js';
4
+ import type { AgentMemoryTool } from './tools.js';
5
+ import type { Container } from './types.js';
6
+ export declare const SYNC_ACTOR = "agent-memories";
7
+ export interface StoreSession {
8
+ readonly db: DatabaseSyncLike;
9
+ readonly hippoRoot: string;
10
+ readonly tenantId: string;
11
+ readonly baseHalfLifeDays: number;
12
+ /** Stamped on written rows; undefined lets the store stamp its own project. */
13
+ readonly originProject: string | undefined;
14
+ /** Whether the text is already stored live by another path, as seen where the new row goes. */
15
+ readonly isDuplicate: (text: string) => boolean;
16
+ readonly dryRun: boolean;
17
+ }
18
+ export interface ContainerWork {
19
+ readonly tool: AgentMemoryTool;
20
+ readonly container: Container;
21
+ /** `containerPrefix(tool, containerId)`: every row of the container has a source starting with it. */
22
+ readonly prefix: string;
23
+ /** Legacy rows that take an item's key as they are, by key (plan design 10, first round). */
24
+ readonly adopt: ReadonlyMap<string, readonly MemoryEntry[]>;
25
+ /** Legacy rows a new row of the key supersedes, by key (second round). */
26
+ readonly replace: ReadonlyMap<string, readonly MemoryEntry[]>;
27
+ }
28
+ export interface ContainerOutcome {
29
+ readonly tally: Tally;
30
+ /** Rows to mirror after commit, as they now stand. */
31
+ readonly mirror: readonly MemoryEntry[];
32
+ /** Rows set aside, whose mirrors are purged after commit. */
33
+ readonly purge: readonly string[];
34
+ }
35
+ /** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
36
+ export declare function syncContainer(s: StoreSession, work: ContainerWork): ContainerOutcome;
37
+ export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover';
38
+ export type SetAsideResult = {
39
+ readonly kind: 'untagged';
40
+ readonly entry: MemoryEntry;
41
+ } | {
42
+ readonly kind: 'dormant';
43
+ readonly id: string;
44
+ };
45
+ /** Design 6's set-aside on the caller's transaction: a pinned row only loses the tag, any other goes dormant, restorable. */
46
+ export declare function setAsideRow(db: DatabaseSyncLike, tag: string, row: MemoryEntry, why: SetAsideWhy): SetAsideResult;
47
+ //# sourceMappingURL=apply.d.ts.map
@@ -0,0 +1,253 @@
1
+ // One container's sync in one transaction on the caller's handle: lookup, plan, then every write (plan designs 6 to 8).
2
+ import { appendAuditEvent } from '../audit.js';
3
+ import { deleteDormantRow, dormantSnapshotsBySourcePrefix, insertDormantRow, readDormantSnapshot } from '../dormant.js';
4
+ import { gatedWrite } from '../gated-write.js';
5
+ import { Layer, calculateStrength, createMemory } from '../memory.js';
6
+ import { findRejectedValue, rejectionDigest } from '../rejection.js';
7
+ import { redactSecretsStrict } from '../secret-detect.js';
8
+ import { deleteEntryRowInTx, markSummaryDirtyInTx, selectLiveEntriesBySourcePrefix, setEntryTagsInTx, stampOriginProject } from '../store.js';
9
+ import { itemHash } from './keys.js';
10
+ import { planContainer } from './plan.js';
11
+ import { emptyTally } from './report.js';
12
+ import { MIN_ITEM_CHARS, itemSource, splitSource, storedText } from './source.js';
13
+ export const SYNC_ACTOR = 'agent-memories';
14
+ /** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
15
+ export function syncContainer(s, work) {
16
+ // A dry run takes no lock up front and rolls every write back.
17
+ s.db.exec(s.dryRun ? 'BEGIN' : 'BEGIN IMMEDIATE');
18
+ try {
19
+ const out = new ContainerRun(s, work).run();
20
+ s.db.exec(s.dryRun ? 'ROLLBACK' : 'COMMIT');
21
+ return out;
22
+ }
23
+ catch (err) {
24
+ try {
25
+ s.db.exec('ROLLBACK');
26
+ }
27
+ catch { /* already rolled back; keep the original error */ }
28
+ throw err;
29
+ }
30
+ }
31
+ /** Design 6's set-aside on the caller's transaction: a pinned row only loses the tag, any other goes dormant, restorable. */
32
+ export function setAsideRow(db, tag, row, why) {
33
+ const untagged = { ...row, tags: row.tags.filter((t) => t !== tag) };
34
+ const audit = (metadata) => appendAuditEvent(db, { tenantId: row.tenantId, actor: SYNC_ACTOR, op: 'agent_memory_set_aside', targetId: row.id, metadata });
35
+ if (row.pinned) {
36
+ setEntryTagsInTx(db, untagged);
37
+ audit({ why, untagged: true });
38
+ return { kind: 'untagged', entry: untagged };
39
+ }
40
+ const now = new Date();
41
+ // Sleep's dormant move skips kept rows, so the steps are written out here without its filter.
42
+ insertDormantRow(db, { entry: untagged, strength: calculateStrength(row, now), reason: 'source-deleted', dormantAt: now.toISOString() });
43
+ deleteEntryRowInTx(db, row, SYNC_ACTOR);
44
+ audit({ why });
45
+ return { kind: 'dormant', id: row.id };
46
+ }
47
+ class ContainerRun {
48
+ s;
49
+ w;
50
+ tally = emptyTally();
51
+ mirror = [];
52
+ purge = [];
53
+ rows = new Map();
54
+ items = new Map();
55
+ tag;
56
+ constructor(s, w) {
57
+ this.s = s;
58
+ this.w = w;
59
+ this.tag = w.tool.tag;
60
+ for (const item of w.container.items)
61
+ this.items.set(item.key, item);
62
+ }
63
+ run() {
64
+ this.adoptLegacy();
65
+ const live = selectLiveEntriesBySourcePrefix(this.s.db, this.s.tenantId, this.w.prefix);
66
+ for (const row of [...live, ...[...this.w.replace.values()].flat()])
67
+ this.rows.set(row.id, row);
68
+ const refusals = this.refusals();
69
+ const liveRows = live.map((row) => this.liveRow(row));
70
+ const plan = planContainer({
71
+ textKeyed: this.w.container.textKeyed,
72
+ items: this.w.container.items.map((item) => ({ key: item.key, hash: itemHash(item.text), refused: refusals.has(item.key) })),
73
+ skipped: this.w.container.skipped,
74
+ live: liveRows,
75
+ dormant: this.dormantRows(new Set(liveRows.map((r) => r.key)), refusals),
76
+ legacy: new Map([...this.w.replace].map(([key, rows]) => [key, rows.map((r) => r.id)])),
77
+ isDuplicate: (item) => this.s.isDuplicate(storedText(this.item(item.key).text)),
78
+ });
79
+ for (const reason of refusals.values())
80
+ this.tally[reason]++;
81
+ this.tally.unread += this.w.container.skipped.length;
82
+ this.apply(plan);
83
+ return { tally: this.tally, mirror: this.mirror, purge: this.purge };
84
+ }
85
+ apply(plan) {
86
+ this.tally.unchanged += plan.unchanged;
87
+ this.tally.duplicate += plan.duplicates;
88
+ for (const id of plan.retag)
89
+ this.retag(this.row(id));
90
+ for (const { id, by } of plan.collapse)
91
+ if (this.supersede(this.row(id), by))
92
+ this.tally.collapsed++;
93
+ for (const write of plan.writes)
94
+ this.write(write);
95
+ for (const { key, dormantId } of plan.restores)
96
+ this.restore(key, dormantId);
97
+ for (const id of plan.setAside)
98
+ this.setAside(this.row(id));
99
+ }
100
+ /** Same text as a current note: the legacy row takes the note's key, keeping its id, recall count and outcomes. */
101
+ adoptLegacy() {
102
+ for (const [key, rows] of this.w.adopt) {
103
+ const item = this.items.get(key);
104
+ if (item === undefined)
105
+ continue;
106
+ const source = itemSource(this.w.prefix, key, item.text);
107
+ for (const row of rows) {
108
+ const moved = this.s.db.prepare(`UPDATE memories SET source = ? WHERE id = ? AND tenant_id = ? AND source = ? AND superseded_by IS NULL`).run(source, row.id, row.tenantId, row.source);
109
+ if (Number(moved.changes ?? 0) === 0)
110
+ continue;
111
+ this.tally.adopted++;
112
+ this.mirror.push({ ...row, source });
113
+ }
114
+ }
115
+ }
116
+ refusals() {
117
+ const out = new Map();
118
+ for (const item of this.w.container.items) {
119
+ const reason = this.refusal(item);
120
+ if (reason !== null)
121
+ out.set(item.key, reason);
122
+ }
123
+ return out;
124
+ }
125
+ // The rejection lookup reads the capped text, as the write would store it, so a rejected note writes no audit row.
126
+ refusal(item) {
127
+ if (item.text.trim().length < MIN_ITEM_CHARS)
128
+ return 'short';
129
+ // Imported rows reach prompts, so the bar is text leaving the machine: Bearer headers and JWTs count too.
130
+ if (redactSecretsStrict(item.text) !== item.text)
131
+ return 'secret';
132
+ if (findRejectedValue(this.s.db, this.s.tenantId, rejectionDigest(storedText(item.text))) !== null)
133
+ return 'rejected';
134
+ return null;
135
+ }
136
+ liveRow(row) {
137
+ return { id: row.id, ...splitSource(row.source, this.w.prefix), tagged: row.tags.includes(this.tag), created: row.created };
138
+ }
139
+ /** Read only when a present key has no live row, which after the first import is rare. */
140
+ dormantRows(liveKeys, refusals) {
141
+ const needed = this.w.container.items.some((item) => !refusals.has(item.key) && !liveKeys.has(item.key));
142
+ if (!needed)
143
+ return [];
144
+ return dormantSnapshotsBySourcePrefix(this.s.db, this.s.tenantId, this.w.prefix)
145
+ .filter((snap) => !snap.entry.superseded_by)
146
+ .map((snap) => ({ id: snap.entry.id, ...splitSource(snap.entry.source, this.w.prefix), dormantAt: snap.dormantAt }));
147
+ }
148
+ retag(row) {
149
+ const tagged = { ...row, tags: [...row.tags, this.tag] };
150
+ setEntryTagsInTx(this.s.db, tagged);
151
+ this.tally.retagged++;
152
+ this.mirror.push(tagged);
153
+ }
154
+ /** api.supersede's steps on this transaction; false when another writer superseded the row first. */
155
+ supersede(old, newId) {
156
+ const result = this.s.db.prepare(`UPDATE memories SET superseded_by = ? WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL`)
157
+ .run(newId, old.id, old.tenantId);
158
+ if (Number(result.changes ?? 0) === 0)
159
+ return false;
160
+ if (old.dag_parent_id)
161
+ markSummaryDirtyInTx(this.s.db, old.dag_parent_id, old.tenantId, SYNC_ACTOR);
162
+ appendAuditEvent(this.s.db, { tenantId: old.tenantId, actor: SYNC_ACTOR, op: 'supersede', targetId: old.id, metadata: { newId } });
163
+ this.mirror.push({ ...old, superseded_by: newId });
164
+ return true;
165
+ }
166
+ write(planned) {
167
+ const entry = this.newRow(this.item(planned.key));
168
+ if (!this.gated(entry))
169
+ return;
170
+ this.tally[planned.supersedes.length > 0 ? 'replaced' : 'imported']++;
171
+ for (const id of planned.supersedes)
172
+ this.supersede(this.row(id), entry.id);
173
+ }
174
+ newRow(item) {
175
+ const base = createMemory(storedText(item.text), {
176
+ layer: Layer.Episodic,
177
+ tags: [this.tag],
178
+ source: itemSource(this.w.prefix, item.key, item.text),
179
+ confidence: 'observed',
180
+ kind: 'distilled',
181
+ tenantId: this.s.tenantId,
182
+ baseHalfLifeDays: this.s.baseHalfLifeDays,
183
+ });
184
+ // The note's own time when it is earlier than now; the loader reads any form but toISOString as drift.
185
+ const created = Number.isFinite(item.updatedAt)
186
+ ? new Date(Math.min(item.updatedAt, Date.parse(base.created))).toISOString()
187
+ : base.created;
188
+ const origin = this.s.originProject === undefined ? {} : { origin_project: this.s.originProject };
189
+ return stampOriginProject(this.s.hippoRoot, { ...base, created, valid_from: created, ...origin });
190
+ }
191
+ gated(entry) {
192
+ const result = gatedWrite(this.s.db, this.s.hippoRoot, entry, { actor: SYNC_ACTOR, worthCheck: false });
193
+ if (result === 'written') {
194
+ this.mirror.push(entry);
195
+ return true;
196
+ }
197
+ this.tally[result === 'skipped:rejected' ? 'rejected' : 'secret']++;
198
+ return false;
199
+ }
200
+ /** A deleted note came back unchanged: its old row returns with its id and history, under the sync's own audit op. */
201
+ restore(key, dormantId) {
202
+ const snap = readDormantSnapshot(this.s.db, this.s.tenantId, dormantId);
203
+ const taken = this.s.db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(dormantId) !== undefined;
204
+ if (snap === null || taken) {
205
+ this.write({ key, hash: '', supersedes: [] });
206
+ return;
207
+ }
208
+ const now = new Date();
209
+ const tags = Array.isArray(snap.entry.tags) ? snap.entry.tags.filter((t) => t !== this.tag) : [];
210
+ const revived = {
211
+ ...createMemory('dormant snapshot defaults', { baseHalfLifeDays: this.s.baseHalfLifeDays }),
212
+ ...snap.entry,
213
+ tags: [...tags, this.tag],
214
+ last_retrieved: now.toISOString(),
215
+ };
216
+ if (!this.gated(stampOriginProject(this.s.hippoRoot, { ...revived, strength: calculateStrength(revived, now) })))
217
+ return;
218
+ deleteDormantRow(this.s.db, this.s.tenantId, dormantId);
219
+ appendAuditEvent(this.s.db, {
220
+ tenantId: this.s.tenantId,
221
+ actor: SYNC_ACTOR,
222
+ op: 'agent_memory_restore',
223
+ targetId: dormantId,
224
+ metadata: { reason: snap.reason, dormantAt: snap.dormantAt },
225
+ });
226
+ this.tally.restored++;
227
+ }
228
+ setAside(row) {
229
+ const why = this.items.has(splitSource(row.source, this.w.prefix).key) ? 'note-changed' : 'note-gone';
230
+ const result = setAsideRow(this.s.db, this.tag, row, why);
231
+ if (result.kind === 'untagged') {
232
+ this.tally.untagged++;
233
+ this.mirror.push(result.entry);
234
+ }
235
+ else {
236
+ this.tally.setAside++;
237
+ this.purge.push(result.id);
238
+ }
239
+ }
240
+ row(id) {
241
+ const row = this.rows.get(id);
242
+ if (row === undefined)
243
+ throw new Error(`agent memory sync: planned row ${id} was not in the lookup`);
244
+ return row;
245
+ }
246
+ item(key) {
247
+ const item = this.items.get(key);
248
+ if (item === undefined)
249
+ throw new Error(`agent memory sync: planned key ${key} was not read`);
250
+ return item;
251
+ }
252
+ }
253
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1,11 @@
1
+ import type { Adapter, AdapterContext, Listing } from './types.js';
2
+ /** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
3
+ export declare function claudeMemoryFolderNames(projectRoot: string, platform: NodeJS.Platform): Set<string>;
4
+ /** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
5
+ export declare function claudeCheckoutRoot(top: string, gitDir: string, common: string): string;
6
+ /** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
7
+ export declare function claudeFolderName(root: string): string;
8
+ export declare const claudeCodeAdapter: Adapter;
9
+ /** Post-compact's read: the session's own notes folder and nothing else, so no git call runs inside the hook's time limit. */
10
+ export declare function claudeTranscriptListing(ctx: AdapterContext, transcriptPath: string): Listing;
11
+ //# sourceMappingURL=claude-code.d.ts.map
@@ -0,0 +1,113 @@
1
+ // Claude Code's auto memory: frontmatter `.md` notes in a per-project folder, plus the `autoMemoryDirectory` user folder.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { realpathOrResolve } from '../project-identity.js';
5
+ import { isStringValue } from '../capture.js';
6
+ import { isJsonObject } from '../hooks.js';
7
+ import { expandHome, frontmatterField, itemTime, readTextFile, splitFrontmatter } from './files.js';
8
+ import { markdownNotes, readFolderStore, uniqueFolders } from './folder-store.js';
9
+ import { gitLayout } from './git.js';
10
+ // Keeps a pinned name from carrying a separator or `..` out of the projects folder.
11
+ const PROJECT_DIR_NAME = /^[A-Za-z0-9_-]{1,64}$/;
12
+ /** Claude Code's auto memory folder names for a project: its checkout, which subfolders share, or the folder itself outside a repository. */
13
+ export function claudeMemoryFolderNames(projectRoot, platform) {
14
+ const roots = [projectRoot, realpathOrResolve(projectRoot)];
15
+ const layout = gitLayout(projectRoot);
16
+ if (layout)
17
+ roots.push(claudeCheckoutRoot(layout.top, layout.gitDir, layout.common));
18
+ return new Set(roots.map((root) => (platform === 'win32' ? claudeFolderName(root).toLowerCase() : claudeFolderName(root))));
19
+ }
20
+ /** Claude Code's rule: a linked worktree shares its main checkout's folder, or the git folder's when that sits outside a checkout (a bare repository, or --separate-git-dir); any other checkout, a submodule included, keeps its own. */
21
+ export function claudeCheckoutRoot(top, gitDir, common) {
22
+ if (gitDir === common)
23
+ return top;
24
+ if (path.basename(common) === '.git')
25
+ return path.dirname(common);
26
+ return fs.existsSync(path.join(common, '.git')) ? top : common;
27
+ }
28
+ /** Claude Code's folder name for a path: non-alphanumerics made '-', and a name over 200 characters cut to 200 plus a base-36 hash of the whole path. */
29
+ export function claudeFolderName(root) {
30
+ const full = path.resolve(root);
31
+ const name = full.replace(/[^a-zA-Z0-9]/g, '-');
32
+ if (name.length <= 200)
33
+ return name;
34
+ let hash = 0;
35
+ for (let i = 0; i < full.length; i++)
36
+ hash = ((hash << 5) - hash + full.charCodeAt(i)) | 0;
37
+ return `${name.slice(0, 200)}-${Math.abs(hash).toString(36)}`;
38
+ }
39
+ export const claudeCodeAdapter = {
40
+ tool: 'claude-code',
41
+ list(ctx, scope) {
42
+ const config = ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, '.claude');
43
+ const warnings = [];
44
+ const folders = scope === 'project' ? projectFolders(ctx, config) : userFolders(ctx, config, warnings);
45
+ return { tool: 'claude-code', home: config, containers: readFolders(folders, scope, ctx.platform), warnings };
46
+ },
47
+ };
48
+ /** Post-compact's read: the session's own notes folder and nothing else, so no git call runs inside the hook's time limit. */
49
+ export function claudeTranscriptListing(ctx, transcriptPath) {
50
+ const config = ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, '.claude');
51
+ const folder = path.join(path.dirname(transcriptPath), 'memory');
52
+ return { tool: 'claude-code', home: config, containers: readFolders([folder], 'project', ctx.platform), warnings: [] };
53
+ }
54
+ function projectFolders(ctx, config) {
55
+ const projects = path.join(config, 'projects');
56
+ const names = ctx.projectRoot === undefined ? [] : [...claudeMemoryFolderNames(ctx.projectRoot, ctx.platform)];
57
+ const pinned = ctx.env.CLAUDE_CODE_PROJECT_DIR_NAME;
58
+ // Claude reads the pinned name only alongside a pinned config folder.
59
+ if (ctx.env.CLAUDE_CONFIG_DIR && pinned !== undefined && PROJECT_DIR_NAME.test(pinned))
60
+ names.push(pinned);
61
+ const folders = names.map((name) => path.join(projects, name, 'memory'));
62
+ if (ctx.transcriptPath)
63
+ folders.push(path.join(path.dirname(ctx.transcriptPath), 'memory'));
64
+ return folders;
65
+ }
66
+ function userFolders(ctx, config, warnings) {
67
+ const dir = autoMemoryDirectory(path.join(config, 'settings.json'), ctx.home, warnings);
68
+ return dir === null ? [] : [dir];
69
+ }
70
+ // User settings only: a cloned repository's own settings must not point the import at another project's notes.
71
+ function autoMemoryDirectory(settings, home, warnings) {
72
+ if (!fs.existsSync(settings))
73
+ return null;
74
+ const file = readTextFile(settings);
75
+ if (!file.ok) {
76
+ warnings.push(file.reason);
77
+ return null;
78
+ }
79
+ let json;
80
+ try {
81
+ // SAFETY: JSON.parse yields JSON; the object and string checks below decide what is used.
82
+ json = JSON.parse(file.text);
83
+ }
84
+ catch (err) {
85
+ warnings.push(`${settings}: ${err instanceof Error ? err.message : String(err)}`);
86
+ return null;
87
+ }
88
+ const value = isJsonObject(json) ? json.autoMemoryDirectory : undefined;
89
+ if (!isStringValue(value) || value === '')
90
+ return null;
91
+ const dir = expandHome(value, home);
92
+ if (path.isAbsolute(dir))
93
+ return dir;
94
+ warnings.push(`${settings}: autoMemoryDirectory "${value}" is not absolute or under ~/, so it is ignored`);
95
+ return null;
96
+ }
97
+ // Claude counts a note only when it carries frontmatter; `modified` there beats the file time.
98
+ const NOTES = {
99
+ recursive: false,
100
+ include: markdownNotes,
101
+ item(text, mtimeMs) {
102
+ const { yaml, body } = splitFrontmatter(text);
103
+ if (yaml === null)
104
+ return null;
105
+ return { text: body.trim(), updatedAt: itemTime(frontmatterField(yaml, 'modified'), mtimeMs) };
106
+ },
107
+ };
108
+ function readFolders(folders, scope, platform) {
109
+ return uniqueFolders(folders, platform)
110
+ .map((dir) => readFolderStore(dir, scope, NOTES))
111
+ .filter((c) => c !== null);
112
+ }
113
+ //# sourceMappingURL=claude-code.js.map
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from './types.js';
2
+ export declare const codexAdapter: Adapter;
3
+ //# sourceMappingURL=codex.d.ts.map
@@ -0,0 +1,47 @@
1
+ // Codex CLI's memory_summary.md, the one memory file Codex puts in its prompt, read as a single text-keyed file.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { codexHomeDir } from '../hooks.js';
5
+ import { readTextFile } from './files.js';
6
+ import { textItemKeys } from './keys.js';
7
+ import { splitMarkdownItems } from './markdown.js';
8
+ // "What's in Memory" only indexes MEMORY.md, so it is left out.
9
+ const KEPT_HEADINGS = new Set(['user profile', 'user preferences', 'general tips']);
10
+ const PROFILE_HEADING = /^##[ \t]+User Profile[ \t]*$/im;
11
+ export const codexAdapter = {
12
+ tool: 'codex',
13
+ list(ctx, scope) {
14
+ const home = codexHomeDir(ctx.home, ctx.env);
15
+ const containers = [];
16
+ if (scope === 'user') {
17
+ const container = readSummary(path.join(home, 'memories', 'memory_summary.md'));
18
+ if (container !== null)
19
+ containers.push(container);
20
+ }
21
+ return { tool: 'codex', home, containers, warnings: [] };
22
+ },
23
+ };
24
+ function readSummary(file) {
25
+ if (!fs.existsSync(file))
26
+ return null;
27
+ const read = readTextFile(file);
28
+ if (!read.ok)
29
+ return unreadable(file, read.reason);
30
+ if (!isCodexSummary(read.text)) {
31
+ return unreadable(file, `${file}: not a Codex memory summary (needs "v1" first and a "## User Profile" heading)`);
32
+ }
33
+ const kept = splitMarkdownItems(read.text)
34
+ .filter((item) => KEPT_HEADINGS.has(item.heading.trim().toLowerCase()))
35
+ .map((item) => ({ headingSlug: item.headingSlug, text: `${item.heading}: ${item.text}` }));
36
+ const keys = textItemKeys(kept);
37
+ const items = kept.map(({ text }, i) => ({ key: keys[i], text, updatedAt: read.mtimeMs }));
38
+ return { scope: 'user', path: file, readable: true, items, skipped: [], warnings: [], textKeyed: true };
39
+ }
40
+ function isCodexSummary(text) {
41
+ const first = text.split(/\r?\n/).find((line) => line.trim() !== '');
42
+ return first?.trim() === 'v1' && PROFILE_HEADING.test(text);
43
+ }
44
+ function unreadable(file, warning) {
45
+ return { scope: 'user', path: file, readable: false, items: [], skipped: [], warnings: [warning], textKeyed: true };
46
+ }
47
+ //# sourceMappingURL=codex.js.map
@@ -0,0 +1,3 @@
1
+ import type { Adapter } from './types.js';
2
+ export declare const copilotAdapter: Adapter;
3
+ //# sourceMappingURL=copilot.d.ts.map