hippo-memory 1.52.9 → 1.53.1

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 (76) hide show
  1. package/README.md +47 -13
  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 +28 -29
  42. package/dist/audit.d.ts +4 -3
  43. package/dist/audit.js +10 -6
  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 +133 -170
  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 +573 -0
  52. package/dist/config.d.ts +4 -0
  53. package/dist/config.js +13 -0
  54. package/dist/consolidate.d.ts +0 -2
  55. package/dist/consolidate.js +3 -36
  56. package/dist/db.d.ts +5 -1
  57. package/dist/db.js +40 -8
  58. package/dist/dedupe.js +3 -2
  59. package/dist/doctor.js +34 -2
  60. package/dist/dormant.d.ts +5 -3
  61. package/dist/dormant.js +9 -0
  62. package/dist/gated-write.d.ts +9 -0
  63. package/dist/gated-write.js +24 -0
  64. package/dist/hooks.d.ts +4 -2
  65. package/dist/hooks.js +8 -7
  66. package/dist/memory.d.ts +19 -2
  67. package/dist/memory.js +32 -3
  68. package/dist/shared.js +10 -7
  69. package/dist/store.d.ts +14 -2
  70. package/dist/store.js +75 -19
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  74. package/extensions/openclaw-plugin/package.json +1 -1
  75. package/openclaw.plugin.json +1 -1
  76. package/package.json +1 -1
@@ -0,0 +1,336 @@
1
+ // Runs the adapters and routes each container to its store: the project pass, the user pass and their call sites (plan designs 2, 8, 11).
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { errorMessage } from '../capture.js';
6
+ import { loadConfig } from '../config.js';
7
+ import { closeHippoDb, isSqliteBusy, openHippoDb } from '../db.js';
8
+ import { deriveOriginProject, isGlobalStoreRoot, resolveGlobalRootDir } from '../project-identity.js';
9
+ import { duplicateKey, heldTextKeys } from '../same-text.js';
10
+ import { initStore, isInitialized, removeEntryMirrors, selectLiveEntriesBySourcePrefix, updateStats, writeEntryMirrors } from '../store.js';
11
+ import { resolveTenantId } from '../tenant.js';
12
+ import { setAsideRow, syncContainer } from './apply.js';
13
+ import { claudeCodeAdapter, claudeTranscriptListing } from './claude-code.js';
14
+ import { codexAdapter } from './codex.js';
15
+ import { copilotAdapter } from './copilot.js';
16
+ import { geminiAdapter } from './gemini.js';
17
+ import { legacyWork } from './legacy.js';
18
+ import { openclawAdapter } from './openclaw.js';
19
+ import { qwenCodeAdapter } from './qwen-code.js';
20
+ import { addTally, emptyReport, mergeReports, toolReport } from './report.js';
21
+ import { containerId, containerPrefix, splitSource } from './source.js';
22
+ import { AGENT_MEMORY_SOURCE_PREFIX, AGENT_MEMORY_TOOLS, isToolId, toolSourcePrefix } from './tools.js';
23
+ export const ADAPTERS = [claudeCodeAdapter, codexAdapter, geminiAdapter, copilotAdapter, openclawAdapter, qwenCodeAdapter];
24
+ /** Overrides config because `hippo init` creates the store in the same command that imports (plan design 11). */
25
+ export const TOOLS_ENV = 'HIPPO_AGENT_MEMORY_TOOLS';
26
+ export function currentMachine() {
27
+ return { home: os.homedir(), env: process.env, platform: process.platform };
28
+ }
29
+ /** init, sleep, `import --agents` and the daily runner: a local store gets its project pass then the user pass; the global store the user pass only. */
30
+ export function importForStore(hippoRoot, opts) {
31
+ if (isGlobalStoreRoot(hippoRoot))
32
+ return importUserMemories(hippoRoot, opts);
33
+ const report = importProjectMemories(hippoRoot, opts);
34
+ mergeReports(report, importUserMemories(hippoRoot, opts));
35
+ return report;
36
+ }
37
+ /** A local store's project pass with legacy adoption; `init --scan` runs it per repository and the user pass once. */
38
+ export function importProjectMemories(hippoRoot, opts) {
39
+ const ctx = context(opts.machine, { projectRoot: path.dirname(hippoRoot) });
40
+ return runPass({
41
+ scope: 'project', target: hippoRoot, invoking: hippoRoot, list: (a) => a.list(ctx, 'project'), legacy: true, originProject: undefined, handover: true,
42
+ }, opts);
43
+ }
44
+ /** Every tool's user-level memory into the global store, created on demand, with no origin. */
45
+ export function importUserMemories(invokingRoot, opts) {
46
+ const ctx = context(opts.machine, {});
47
+ return runPass({
48
+ scope: 'user', target: resolveGlobalRootDir(), invoking: invokingRoot, list: (a) => a.list(ctx, 'user'), legacy: false, originProject: '', handover: false,
49
+ }, opts);
50
+ }
51
+ /** Session end in a folder with no store of its own: the session's project into the global store with its origin, then the user pass. */
52
+ export function importAtSessionEnd(cwd, transcriptPath, opts) {
53
+ const globalRoot = resolveGlobalRootDir();
54
+ const ctx = context(opts.machine, { projectRoot: cwd, transcriptPath });
55
+ const report = runPass({
56
+ scope: 'project', target: globalRoot, invoking: globalRoot, list: (a) => a.list(ctx, 'project'), legacy: false, originProject: deriveOriginProject(cwd), handover: false,
57
+ }, opts);
58
+ mergeReports(report, importUserMemories(globalRoot, opts));
59
+ return report;
60
+ }
61
+ /** Post-compact: the transcript folder's notes only, with no git call, no legacy adoption and no user pass, as the hook has 10 seconds. */
62
+ export function importAtCompaction(hippoRoot, transcriptPath, originProject, opts) {
63
+ const ctx = context(opts.machine, {});
64
+ return runPass({
65
+ scope: 'project', target: hippoRoot, invoking: hippoRoot, legacy: false, originProject, handover: false,
66
+ list: (a) => (a.tool === 'claude-code' ? claudeTranscriptListing(ctx, transcriptPath) : null),
67
+ }, opts);
68
+ }
69
+ function context(machine, extra) {
70
+ return { home: machine.home, env: machine.env, platform: machine.platform, ...extra };
71
+ }
72
+ /** The variable when set (empty or `none` is off); else the tools both the invoking and the target store's config allow. */
73
+ export function allowedTools(env, invoking, target, warnings) {
74
+ const fromEnv = env[TOOLS_ENV];
75
+ if (fromEnv !== undefined) {
76
+ const listed = fromEnv.trim().toLowerCase() === 'none' ? [] : fromEnv.split(',').map((t) => t.trim()).filter((t) => t !== '');
77
+ return knownTools(listed, TOOLS_ENV, warnings);
78
+ }
79
+ const mine = configuredTools(invoking, warnings);
80
+ const theirs = configuredTools(target, warnings);
81
+ return new Set(AGENT_MEMORY_TOOLS.map((t) => t.id).filter((id) => (mine === null || mine.has(id)) && (theirs === null || theirs.has(id))));
82
+ }
83
+ function configuredTools(root, warnings) {
84
+ const tools = loadConfig(root).agentMemories.tools;
85
+ return tools === null ? null : knownTools(tools, 'config agentMemories.tools', warnings);
86
+ }
87
+ function knownTools(ids, where, warnings) {
88
+ const out = new Set();
89
+ for (const id of ids) {
90
+ if (isToolId(id))
91
+ out.add(id);
92
+ else
93
+ warnings.push(`${where}: unknown agent memory tool "${id}" ignored (known: ${AGENT_MEMORY_TOOLS.map((t) => t.id).join(', ')})`);
94
+ }
95
+ return out;
96
+ }
97
+ function runPass(pass, opts) {
98
+ const report = emptyReport();
99
+ const allowed = allowedTools(opts.machine.env, pass.invoking, pass.target, report.warnings);
100
+ const listings = ADAPTERS.filter((a) => allowed.has(a.tool)).flatMap((a) => listSafely(a, pass, report));
101
+ const hasItems = listings.some((l) => l.containers.some((c) => c.items.length > 0));
102
+ let store = null;
103
+ let synced = [];
104
+ try {
105
+ store = openTarget(pass.target, hasItems, opts);
106
+ if (store !== null)
107
+ synced = syncStore(pass, listings, store, opts, report);
108
+ }
109
+ catch (err) {
110
+ report.warnings.push(`agent memories not synced into ${pass.target}: ${isSqliteBusy(err) ? 'the store was busy' : errorMessage(err)}`);
111
+ }
112
+ finally {
113
+ store?.close();
114
+ }
115
+ if (pass.handover && !opts.dryRun)
116
+ handOver(synced, path.dirname(pass.target), opts, report);
117
+ return report;
118
+ }
119
+ function listSafely(adapter, pass, report) {
120
+ const tool = toolReport(report, adapter.tool);
121
+ let listing;
122
+ try {
123
+ listing = pass.list(adapter);
124
+ }
125
+ catch (err) {
126
+ report.warnings.push(`${tool.label}: ${errorMessage(err)}`);
127
+ return [];
128
+ }
129
+ if (listing === null)
130
+ return [];
131
+ if (!tool.homes.includes(listing.home))
132
+ tool.homes.push(listing.home);
133
+ report.warnings.push(...listing.warnings.map((w) => `${tool.label}: ${w}`));
134
+ for (const c of listing.containers) {
135
+ tool.containers.push({ scope: c.scope, path: c.path, store: pass.target, items: c.items.length, readable: c.readable });
136
+ report.warnings.push(...c.warnings.map((w) => `${tool.label}: ${w}`));
137
+ }
138
+ return [listing];
139
+ }
140
+ /** A store that does not exist yet is created only when there is something to put in it; a dry run plans against an empty stand-in. */
141
+ function openTarget(target, hasItems, opts) {
142
+ const global = isGlobalStoreRoot(target);
143
+ if (!isInitialized(target)) {
144
+ if (opts.dryRun)
145
+ return emptyStandIn(global);
146
+ if (!hasItems)
147
+ return null;
148
+ initStore(target);
149
+ }
150
+ const db = openHippoDb(target, { busyWaitMs: opts.busyWaitMs });
151
+ return { db, root: target, global, close: () => closeHippoDb(db) };
152
+ }
153
+ function emptyStandIn(global) {
154
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hippo-agent-memories-'));
155
+ const db = openHippoDb(root);
156
+ return {
157
+ db, root, global,
158
+ close: () => {
159
+ closeHippoDb(db);
160
+ fs.rmSync(root, { recursive: true, force: true });
161
+ },
162
+ };
163
+ }
164
+ function syncStore(pass, listings, store, opts, report) {
165
+ const tenantId = resolveTenantId({});
166
+ const legacy = pass.legacy ? legacyWork(store.db, tenantId, listings) : null;
167
+ const session = {
168
+ db: store.db,
169
+ hippoRoot: store.root,
170
+ tenantId,
171
+ baseHalfLifeDays: loadConfig(store.root).defaultHalfLifeDays,
172
+ originProject: pass.originProject,
173
+ isDuplicate: duplicateCheck(store, tenantId, pass.originProject, legacy?.adopted ?? new Set()),
174
+ dryRun: opts.dryRun === true,
175
+ };
176
+ // A project's rows in the global store are parted by origin: a worktree and its main checkout share a Claude folder.
177
+ const partition = store.global && pass.scope === 'project' ? pass.originProject ?? '' : null;
178
+ const synced = [];
179
+ let remembered = 0;
180
+ for (const listing of listings) {
181
+ const tool = toolOf(listing.tool);
182
+ const out = toolReport(report, tool.id);
183
+ for (const container of listing.containers) {
184
+ if (!container.readable) {
185
+ out.tally.unreadable++;
186
+ continue;
187
+ }
188
+ const work = containerWork(tool, container, opts.machine.platform, legacy, partition ?? '');
189
+ const outcome = syncOne(session, work, out, report);
190
+ if (outcome === null)
191
+ continue;
192
+ synced.push(work);
193
+ remembered += outcome.tally.imported + outcome.tally.replaced;
194
+ if (!session.dryRun)
195
+ afterCommit(store.root, outcome, report);
196
+ }
197
+ if (session.dryRun)
198
+ out.unlisted += unlistedRows(store.db, tenantId, tool, pass.scope, listing, opts.machine.platform, partition);
199
+ }
200
+ if (remembered > 0 && !session.dryRun)
201
+ bumpRemembered(store.root, remembered, report);
202
+ return synced;
203
+ }
204
+ function containerWork(tool, container, platform, legacy, origin) {
205
+ const none = new Map();
206
+ const own = tool.id === 'claude-code' ? legacy?.byContainer.get(container.path) : undefined;
207
+ return {
208
+ tool,
209
+ container,
210
+ prefix: containerPrefix(tool.id, containerId(container.path, container.scope, platform, origin)),
211
+ adopt: own?.adopt ?? none,
212
+ replace: own?.replace ?? none,
213
+ };
214
+ }
215
+ /** Null when the container was skipped: a busy store waits for the next sync, any other failure is a warning and the sync goes on. */
216
+ function syncOne(session, work, out, report) {
217
+ try {
218
+ const outcome = syncContainer(session, work);
219
+ addTally(out.tally, outcome.tally);
220
+ return outcome;
221
+ }
222
+ catch (err) {
223
+ if (isSqliteBusy(err)) {
224
+ out.tally.busy++;
225
+ report.warnings.push(`${out.label}: ${work.container.path} skipped, the store was busy`);
226
+ }
227
+ else {
228
+ report.warnings.push(`${out.label}: ${work.container.path} not synced: ${errorMessage(err)}`);
229
+ }
230
+ return null;
231
+ }
232
+ }
233
+ function afterCommit(root, outcome, report) {
234
+ for (const entry of outcome.mirror)
235
+ writeEntryMirrors(root, entry);
236
+ for (const id of outcome.purge) {
237
+ try {
238
+ removeEntryMirrors(root, id);
239
+ }
240
+ catch (err) {
241
+ // rebuildIndex would bring the row back live, and the next sync sets it aside again.
242
+ report.warnings.push(`mirror of ${id} not removed: ${errorMessage(err)}`);
243
+ }
244
+ }
245
+ }
246
+ function bumpRemembered(root, remembered, report) {
247
+ try {
248
+ updateStats(root, { remembered });
249
+ }
250
+ catch (err) {
251
+ report.warnings.push(`remembered counter not updated: ${errorMessage(err)}`);
252
+ }
253
+ }
254
+ /** Design 6: only text stored by another path counts, and in the global store only rows visible where the new row goes. */
255
+ function duplicateCheck(store, tenantId, origin, adopted) {
256
+ let keys = null;
257
+ return (text) => {
258
+ keys ??= otherPathKeys(store, tenantId, origin ?? '', adopted);
259
+ return keys.has(duplicateKey(text));
260
+ };
261
+ }
262
+ function otherPathKeys(store, tenantId, origin, adopted) {
263
+ const visible = store.global ? ` AND (origin_project = '' OR origin_project = ?)` : '';
264
+ const params = store.global ? [tenantId, AGENT_MEMORY_SOURCE_PREFIX, origin] : [tenantId, AGENT_MEMORY_SOURCE_PREFIX];
265
+ // SAFETY: the SELECT names the three columns of the row type.
266
+ const rows = store.db.prepare(`SELECT id, content, source FROM memories
267
+ WHERE tenant_id = ? AND superseded_by IS NULL AND substr(source, 1, ${AGENT_MEMORY_SOURCE_PREFIX.length}) != ?${visible}`).all(...params);
268
+ return new Set(rows.filter((r) => !adopted.has(r.id)).flatMap(heldTextKeys));
269
+ }
270
+ /** Dry run only: kept rows of this scope in containers this run did not list (a moved project's old folder); `partition` limits it to one origin. */
271
+ function unlistedRows(db, tenantId, tool, scope, listing, platform, partition) {
272
+ const listed = listing.containers.map((c) => containerPrefix(tool.id, containerId(c.path, c.scope, platform, partition ?? '')));
273
+ return selectLiveEntriesBySourcePrefix(db, tenantId, `${toolSourcePrefix(tool.id)}${scope === 'project' ? 'p' : 'u'}-`)
274
+ .filter((row) => row.tags.includes(tool.tag) && !listed.some((p) => row.source.startsWith(p)))
275
+ .filter((row) => partition === null || (row.origin_project ?? '') === partition).length;
276
+ }
277
+ /** Design 2's handover: rows the store-less hook path left in the global store, under this project's origin, for containers its store now syncs. */
278
+ function handOver(synced, projectRoot, opts, report) {
279
+ const globalRoot = resolveGlobalRootDir();
280
+ if (synced.length === 0 || !isInitialized(globalRoot))
281
+ return;
282
+ let db;
283
+ try {
284
+ db = openHippoDb(globalRoot, { busyWaitMs: opts.busyWaitMs });
285
+ const tenantId = resolveTenantId({});
286
+ // A folder with no git and no marker wrote as '' before its store existed, and as its own name after.
287
+ const origins = [...new Set([deriveOriginProject(projectRoot), ''])];
288
+ for (const work of synced)
289
+ handOverContainer(db, globalRoot, tenantId, work, origins, opts.machine.platform, report);
290
+ }
291
+ catch (err) {
292
+ report.warnings.push(`global copies not handed over: ${isSqliteBusy(err) ? 'the global store was busy' : errorMessage(err)}`);
293
+ }
294
+ finally {
295
+ if (db)
296
+ closeHippoDb(db);
297
+ }
298
+ }
299
+ function handOverContainer(db, root, tenantId, work, origins, platform, report) {
300
+ // A note the local pass could not read has no local row yet, so its global copy stays until it does.
301
+ const unread = new Set(work.container.skipped);
302
+ const mirror = [];
303
+ const purge = [];
304
+ db.exec('BEGIN IMMEDIATE');
305
+ try {
306
+ for (const origin of origins) {
307
+ const prefix = containerPrefix(work.tool.id, containerId(work.container.path, work.container.scope, platform, origin));
308
+ for (const row of selectLiveEntriesBySourcePrefix(db, tenantId, prefix)) {
309
+ if (!row.tags.includes(work.tool.tag) || unread.has(splitSource(row.source, prefix).key))
310
+ continue;
311
+ const result = setAsideRow(db, work.tool.tag, row, 'handover');
312
+ if (result.kind === 'untagged')
313
+ mirror.push(result.entry);
314
+ else
315
+ purge.push(result.id);
316
+ }
317
+ }
318
+ db.exec('COMMIT');
319
+ }
320
+ catch (err) {
321
+ try {
322
+ db.exec('ROLLBACK');
323
+ }
324
+ catch { /* already rolled back; keep the original error */ }
325
+ throw err;
326
+ }
327
+ toolReport(report, work.tool.id).tally.handedOver += mirror.length + purge.length;
328
+ afterCommit(root, { mirror, purge }, report);
329
+ }
330
+ function toolOf(id) {
331
+ const tool = AGENT_MEMORY_TOOLS.find((t) => t.id === id);
332
+ if (tool === undefined)
333
+ throw new Error(`agent memory sync: no tool ${id}`);
334
+ return tool;
335
+ }
336
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1,33 @@
1
+ export declare const AGENT_MEMORY_TOOLS: readonly [{
2
+ readonly id: "claude-code";
3
+ readonly tag: "claude-code-memory";
4
+ readonly label: "Claude Code";
5
+ }, {
6
+ readonly id: "codex";
7
+ readonly tag: "codex-memory";
8
+ readonly label: "Codex";
9
+ }, {
10
+ readonly id: "gemini";
11
+ readonly tag: "gemini-memory";
12
+ readonly label: "Gemini CLI";
13
+ }, {
14
+ readonly id: "copilot";
15
+ readonly tag: "copilot-memory";
16
+ readonly label: "Copilot";
17
+ }, {
18
+ readonly id: "openclaw";
19
+ readonly tag: "openclaw-memory";
20
+ readonly label: "OpenClaw";
21
+ }, {
22
+ readonly id: "qwen-code";
23
+ readonly tag: "qwen-code-memory";
24
+ readonly label: "Qwen Code";
25
+ }];
26
+ export type AgentMemoryTool = (typeof AGENT_MEMORY_TOOLS)[number];
27
+ export type ToolId = AgentMemoryTool['id'];
28
+ export declare const AGENT_MEMORY_SOURCE_PREFIX = "agent-memory:";
29
+ /** Every imported row of a tool has a source starting with this. */
30
+ export declare function toolSourcePrefix(id: ToolId): string;
31
+ export declare const AGENT_MEMORY_TAGS: readonly string[];
32
+ export declare function isToolId(value: string): value is ToolId;
33
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1,19 @@
1
+ // The agents whose own memories hippo imports. A leaf module: the keep rule, merge and share lists are built from it.
2
+ export const AGENT_MEMORY_TOOLS = [
3
+ { id: 'claude-code', tag: 'claude-code-memory', label: 'Claude Code' },
4
+ { id: 'codex', tag: 'codex-memory', label: 'Codex' },
5
+ { id: 'gemini', tag: 'gemini-memory', label: 'Gemini CLI' },
6
+ { id: 'copilot', tag: 'copilot-memory', label: 'Copilot' },
7
+ { id: 'openclaw', tag: 'openclaw-memory', label: 'OpenClaw' },
8
+ { id: 'qwen-code', tag: 'qwen-code-memory', label: 'Qwen Code' },
9
+ ];
10
+ export const AGENT_MEMORY_SOURCE_PREFIX = 'agent-memory:';
11
+ /** Every imported row of a tool has a source starting with this. */
12
+ export function toolSourcePrefix(id) {
13
+ return `${AGENT_MEMORY_SOURCE_PREFIX}${id}:`;
14
+ }
15
+ export const AGENT_MEMORY_TAGS = AGENT_MEMORY_TOOLS.map((t) => t.tag);
16
+ export function isToolId(value) {
17
+ return AGENT_MEMORY_TOOLS.some((t) => t.id === value);
18
+ }
19
+ //# sourceMappingURL=tools.js.map
@@ -0,0 +1,42 @@
1
+ import type { ToolId } from './tools.js';
2
+ export type Scope = 'project' | 'user';
3
+ /** All an adapter may know about the machine; it reads nothing else from the process, so tests can fake it all. */
4
+ export interface AdapterContext {
5
+ readonly home: string;
6
+ readonly env: Readonly<Record<string, string | undefined>>;
7
+ readonly platform: NodeJS.Platform;
8
+ readonly projectRoot?: string;
9
+ /** Claude Code's transcript at a hook: its folder's `memory/` holds that session's own notes. */
10
+ readonly transcriptPath?: string;
11
+ }
12
+ export interface MemoryItem {
13
+ /** A path inside the container with '/' separators, or `<heading slug>/<text hash>` in a single-file store. */
14
+ readonly key: string;
15
+ readonly text: string;
16
+ readonly updatedAt: number;
17
+ }
18
+ /** A folder, or one file's memory section, that exists on disk. */
19
+ export interface Container {
20
+ readonly scope: Scope;
21
+ readonly path: string;
22
+ /** False when it exists but could not be read or failed its shape check, so nothing in it is set aside. */
23
+ readonly readable: boolean;
24
+ readonly items: readonly MemoryItem[];
25
+ /** Keys of items on disk that were not read (too big, not text, a failed read); their rows are left alone. */
26
+ readonly skipped: readonly string[];
27
+ readonly warnings: readonly string[];
28
+ /** Keys made from text, so an edit is matched by heading rather than by key. */
29
+ readonly textKeyed: boolean;
30
+ }
31
+ export interface Listing {
32
+ readonly tool: ToolId;
33
+ readonly home: string;
34
+ readonly containers: readonly Container[];
35
+ /** Problems finding containers, such as a malformed index file; an unlisted container is left alone. */
36
+ readonly warnings: readonly string[];
37
+ }
38
+ export interface Adapter {
39
+ readonly tool: ToolId;
40
+ list(ctx: AdapterContext, scope: Scope): Listing;
41
+ }
42
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
package/dist/api.d.ts CHANGED
@@ -1038,7 +1038,7 @@ export declare function getContext(ctx: Context, opts?: ContextOpts): Promise<Co
1038
1038
  * (consolidate + dedup + audit + share + ambient) and return structured counts.
1039
1039
  *
1040
1040
  * Extracted from `cmdSleepCore` Phase 2-6 in Episode A. NOT covered by api.sleep:
1041
- * the cli-only auto-learn phase (Phase 1: learnFromRepo + learnFromMemoryMd),
1041
+ * the cli-only auto-learn phase (Phase 1: learnFromRepo + the agent memory import),
1042
1042
  * which is intrinsically host-bound (uses `process.cwd()` / `os.homedir()`).
1043
1043
  * Auto-learn stays in cli.ts cmdSleepCore as a pre-api block.
1044
1044
  *
@@ -1195,7 +1195,7 @@ export interface SleepResult {
1195
1195
  * api.sleep itself will need to scope dedup / audit / delete by ctx.tenantId.
1196
1196
  *
1197
1197
  * Dedup and audit deletes each log a `forget` row with the ctx actor and a
1198
- * `metadata.reason`. Pinned and raw rows are never auto-deleted (canAutoDelete).
1198
+ * `metadata.reason`. Pinned, raw, kept and object-backing rows are never auto-deleted (AUTOMATIC_DELETE_SQL).
1199
1199
  * dryRun previews consolidate, dedup and audit, then returns before share/ambient.
1200
1200
  */
1201
1201
  /**
package/dist/api.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
10
  import { openHippoDb, closeHippoDb } from './db.js';
11
- import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, } from './store.js';
11
+ import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
12
12
  import { RejectedValueError } from './rejection.js';
13
13
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
14
14
  import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
@@ -17,7 +17,7 @@ import { detectInstruction } from './instruction-detect.js';
17
17
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
18
18
  import { summarizeFailures } from './failure-log.js';
19
19
  import { formatHandoffEvidenceLine } from './handoff.js';
20
- import { createMemory, applyOutcome, calculateStrength, Layer, CHURN_STALE_TAG, } from './memory.js';
20
+ import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
21
21
  import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
22
22
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
23
23
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
@@ -1202,14 +1202,8 @@ export function supersede(ctx, oldId, newContent) {
1202
1202
  if (old.superseded_by) {
1203
1203
  throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
1204
1204
  }
1205
- const newEntry = createMemory(newContent, {
1206
- layer: old.layer ?? Layer.Episodic,
1207
- tags: [...old.tags],
1208
- pinned: old.pinned,
1209
- source: old.source,
1210
- confidence: 'verified',
1205
+ const newEntry = createSuccessor(old, newContent, {
1211
1206
  tenantId: ctx.tenantId,
1212
- scope: old.scope,
1213
1207
  baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
1214
1208
  });
1215
1209
  // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
@@ -1549,13 +1543,13 @@ export async function getContext(ctx, opts = {}) {
1549
1543
  if (budget <= 0) {
1550
1544
  return { entries: [], tokens: 0 };
1551
1545
  }
1552
- // Pinned-only path is allowed against an un-initialised local store (the
1553
- // UserPromptSubmit hook can run in directories without a .hippo). Non-pinned
1554
- // path requires an initialised local store; callers should check first.
1546
+ // Global memories do not establish a project boundary for task state.
1555
1547
  const hasLocal = isInitialized(ctx.hippoRoot);
1556
1548
  const query = (opts.q ?? '').trim() || '*';
1557
1549
  const globalRoot = getGlobalRoot();
1558
1550
  const hasGlobal = isInitialized(globalRoot);
1551
+ const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
1552
+ const hasLocalTaskState = hasLocal && !primaryIsGlobal;
1559
1553
  // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
1560
1554
  // S2: envelope-filter parity with api.recall for AMBIENT context - private
1561
1555
  // scopes and quarantine buckets never inject. `requested` is deliberately
@@ -1598,7 +1592,7 @@ export async function getContext(ctx, opts = {}) {
1598
1592
  // unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
1599
1593
  // exact null/empty-id matching rules.
1600
1594
  const rowScope = (r) => r?.scope ?? null;
1601
- const rawActiveSnapshot = hasLocal
1595
+ const rawActiveSnapshot = hasLocalTaskState
1602
1596
  ? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
1603
1597
  sessionId: opts.currentSessionId,
1604
1598
  })
@@ -1609,7 +1603,7 @@ export async function getContext(ctx, opts = {}) {
1609
1603
  ? rawActiveSnapshot
1610
1604
  : null;
1611
1605
  // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
1612
- const rawSessionHandoff = !hasLocal
1606
+ const rawSessionHandoff = !hasLocalTaskState
1613
1607
  ? null
1614
1608
  : rawActiveSnapshot?.session_id
1615
1609
  ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
@@ -1623,7 +1617,7 @@ export async function getContext(ctx, opts = {}) {
1623
1617
  ? rawSessionHandoff
1624
1618
  : null;
1625
1619
  // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
1626
- const recentSessionEvents = hasLocal && rawActiveSnapshot?.session_id
1620
+ const recentSessionEvents = hasLocalTaskState && rawActiveSnapshot?.session_id
1627
1621
  ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
1628
1622
  session_id: rawActiveSnapshot.session_id,
1629
1623
  limit: 5,
@@ -1642,14 +1636,19 @@ export async function getContext(ctx, opts = {}) {
1642
1636
  }
1643
1637
  return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1644
1638
  };
1639
+ const ownSessionId = opts.currentSessionId || '';
1640
+ // Inside admit, not after the load, so the loader's window widens past a session's own items.
1641
+ const isOwnCompactionItem = (e) => ownSessionId !== '' &&
1642
+ e.source_session_id === ownSessionId &&
1643
+ e.tags.includes(COMPACTION_MEMORY_TAG);
1645
1644
  // Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
1646
- const admit = (e) => !e.superseded_by && ambientAdmit(e);
1645
+ const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
1647
1646
  // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1648
1647
  const localLoad = hasLocal
1649
1648
  ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1650
1649
  : { entries: [] };
1651
- const globalLoad = hasGlobal
1652
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, !isGlobalStoreRoot(ctx.hippoRoot) ? recallRequest : undefined)
1650
+ const globalLoad = hasGlobal && !primaryIsGlobal
1651
+ ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
1653
1652
  : { entries: [] };
1654
1653
  let localEntries = localLoad.entries;
1655
1654
  let globalEntries = globalLoad.entries;
@@ -1683,7 +1682,7 @@ export async function getContext(ctx, opts = {}) {
1683
1682
  const pinnedLocal = localPool.filter((e) => e.pinned);
1684
1683
  const pinnedGlobal = globalPool.filter((e) => e.pinned);
1685
1684
  const rankedPinned = [
1686
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
1685
+ ...pinnedLocal.map((e) => ({ entry: e, isGlobal: primaryIsGlobal })),
1687
1686
  ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
1688
1687
  ]
1689
1688
  .map(({ entry, isGlobal }) => {
@@ -1749,7 +1748,7 @@ export async function getContext(ctx, opts = {}) {
1749
1748
  if (seenCandidateIds.has(e.id))
1750
1749
  continue;
1751
1750
  seenCandidateIds.add(e.id);
1752
- candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
1751
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
1753
1752
  }
1754
1753
  for (const e of globalCandidates) {
1755
1754
  if (seenCandidateIds.has(e.id))
@@ -1772,7 +1771,7 @@ export async function getContext(ctx, opts = {}) {
1772
1771
  }
1773
1772
  else if (includeRecent > 0) {
1774
1773
  const recent = [
1775
- ...localPool.map((entry) => ({ entry, isGlobal: false })),
1774
+ ...localPool.map((entry) => ({ entry, isGlobal: primaryIsGlobal })),
1776
1775
  ...globalPool.map((entry) => ({ entry, isGlobal: true })),
1777
1776
  ]
1778
1777
  // T2 (src/compare.ts) note: this already carries an explicit
@@ -1847,8 +1846,8 @@ export async function getContext(ctx, opts = {}) {
1847
1846
  .map((e) => ({
1848
1847
  entry: e,
1849
1848
  score: calculateStrength(e, now),
1850
- tokens: price(e, false),
1851
- isGlobal: false,
1849
+ tokens: price(e, primaryIsGlobal),
1850
+ isGlobal: primaryIsGlobal,
1852
1851
  }))
1853
1852
  .sort(compareScoredResults);
1854
1853
  const globalRanked = globalPool
@@ -1873,7 +1872,7 @@ export async function getContext(ctx, opts = {}) {
1873
1872
  // Real query: hybrid search (global + local) or physics+hybrid (local only).
1874
1873
  let results;
1875
1874
  const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
1876
- if (hasGlobal) {
1875
+ if (hasGlobal && !primaryIsGlobal) {
1877
1876
  // searchBothHybrid loads from the store roots itself, so the ambient
1878
1877
  // filter above never saw its candidates. Admission runs INSIDE the
1879
1878
  // search via the opt-in entryFilter, BEFORE ranking, cross-store
@@ -1901,7 +1900,7 @@ export async function getContext(ctx, opts = {}) {
1901
1900
  else {
1902
1901
  const ctxConfig = loadConfig(ctx.hippoRoot);
1903
1902
  const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
1904
- const localCost = cost && ((r) => price(r.entry, false));
1903
+ const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
1905
1904
  const ctxResults = usePhysicsCtx
1906
1905
  ? await physicsSearch(query, localEntries, {
1907
1906
  budget: left,
@@ -1921,8 +1920,8 @@ export async function getContext(ctx, opts = {}) {
1921
1920
  results = ctxResults.map((r) => ({
1922
1921
  entry: r.entry,
1923
1922
  score: r.score,
1924
- tokens: price(r.entry, false),
1925
- isGlobal: false,
1923
+ tokens: price(r.entry, primaryIsGlobal),
1924
+ isGlobal: primaryIsGlobal,
1926
1925
  }));
1927
1926
  }
1928
1927
  selectedItems = results;
@@ -1949,7 +1948,7 @@ export async function getContext(ctx, opts = {}) {
1949
1948
  closeHippoDb(localDb);
1950
1949
  }
1951
1950
  }
1952
- if (hasGlobal) {
1951
+ if (hasGlobal && !primaryIsGlobal) {
1953
1952
  const globalDb = openHippoDb(globalRoot);
1954
1953
  try {
1955
1954
  appendAuditEvent(globalDb, {
@@ -2444,7 +2443,7 @@ export async function sleep(ctx, opts = {}) {
2444
2443
  // Phase 3: Quality audit (remove junk, report warnings; a dry run skips rows earlier phases would remove).
2445
2444
  const planned = new Set(dryRun ? [...(consolidateResult.removedIds ?? []), ...dedupResult.pairs.map((p) => p.removed)] : []);
2446
2445
  const allEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !planned.has(e.id));
2447
- const auditOut = phases.auditMemories(allEntries);
2446
+ const auditOut = phases.auditMemories(allEntries, memoriesBackingObjects(ctx.hippoRoot));
2448
2447
  if (auditOut.issues.length > 0) {
2449
2448
  const errors = auditOut.issues.filter((i) => i.severity === 'error');
2450
2449
  const warnings = auditOut.issues.filter((i) => i.severity === 'warning');
package/dist/audit.d.ts CHANGED
@@ -14,10 +14,11 @@ export interface AuditResult {
14
14
  clean: number;
15
15
  }
16
16
  export declare const STOP_WORDS: Set<string>;
17
- export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
18
- export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
17
+ export declare function auditMemory(entry: MemoryEntry, backsObject?: boolean): AuditIssue | null;
18
+ /** `backing`: ids of memories that back an object (store.memoriesBackingObjects). */
19
+ export declare function auditMemories(entries: MemoryEntry[], backing: ReadonlySet<string>): AuditResult;
19
20
  export declare function isContentWorthStoring(content: string): boolean;
20
- export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject"];
21
+ export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject", "agent_memory_restore", "agent_memory_set_aside"];
21
22
  export type AuditOp = (typeof AUDIT_OPS)[number];
22
23
  export interface AppendAuditOpts {
23
24
  tenantId: string;