hippo-memory 1.61.0 → 1.62.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 (57) hide show
  1. package/README.md +28 -53
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.d.ts +3 -1
  4. package/dist/agent-memories/claude-code.js +53 -9
  5. package/dist/agent-memories/sync.d.ts +3 -3
  6. package/dist/agent-memories/sync.js +14 -6
  7. package/dist/agent-memories/types.d.ts +0 -2
  8. package/dist/api/assemble.js +55 -54
  9. package/dist/api/context-select.d.ts +49 -0
  10. package/dist/api/context-select.js +344 -0
  11. package/dist/api/context.d.ts +2 -2
  12. package/dist/api/context.js +195 -522
  13. package/dist/api/drill-down.js +36 -33
  14. package/dist/api/promote.js +55 -66
  15. package/dist/api/recall.js +303 -438
  16. package/dist/api/sleep.js +203 -218
  17. package/dist/capture/compact.d.ts +1 -1
  18. package/dist/capture/compact.js +2 -2
  19. package/dist/cli/briefs.js +324 -306
  20. package/dist/cli/context.js +44 -34
  21. package/dist/cli/continuity.js +283 -271
  22. package/dist/cli/curate.js +35 -34
  23. package/dist/cli/decisions.js +333 -333
  24. package/dist/cli/explain.js +66 -60
  25. package/dist/cli/maintenance.js +62 -51
  26. package/dist/cli/playbooks.js +387 -370
  27. package/dist/cli/projects.js +8 -5
  28. package/dist/cli/recall.js +28 -43
  29. package/dist/cli/remember.js +113 -70
  30. package/dist/cli/session-hooks.js +100 -90
  31. package/dist/cli/setup.js +267 -246
  32. package/dist/cli/status.js +73 -64
  33. package/dist/cli/transfer.js +85 -99
  34. package/dist/compaction-record.d.ts +0 -2
  35. package/dist/compaction-record.js +1 -1
  36. package/dist/customer-notes.js +77 -68
  37. package/dist/dag.js +222 -186
  38. package/dist/decisions.js +93 -76
  39. package/dist/doctor.js +11 -7
  40. package/dist/goals.js +99 -86
  41. package/dist/incidents.js +45 -38
  42. package/dist/policies.js +85 -68
  43. package/dist/processes.js +87 -71
  44. package/dist/project-briefs.js +135 -108
  45. package/dist/project-merge.d.ts +12 -4
  46. package/dist/project-merge.js +130 -45
  47. package/dist/shared.d.ts +9 -0
  48. package/dist/shared.js +10 -8
  49. package/dist/skills.js +81 -65
  50. package/dist/store/search-rows.d.ts +2 -2
  51. package/dist/store/search-rows.js +15 -9
  52. package/dist/version.d.ts +1 -1
  53. package/dist/version.js +1 -1
  54. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  55. package/extensions/openclaw-plugin/package.json +1 -1
  56. package/openclaw.plugin.json +1 -1
  57. package/package.json +1 -1
@@ -42,16 +42,40 @@ export function drillDown(ctx, summaryId, opts = {}) {
42
42
  // already preventing. Match the HTTP behaviour at the API level.
43
43
  return { failure: 'not_found' };
44
44
  }
45
- // v0.30 / E5: BFS walk levels 1..depth with visited-Set dedup. Defensive
46
- // against shared-child data anomalies (dag_parent_id has no uniqueness
47
- // constraint, so a misconfigured tree could double-emit at depth > 1).
48
- // Each level uses loadChildrenOf which is tenant-scoped via ctx.tenantId.
45
+ const { collected, level0DirectCount } = collectDescendants(ctx, summaryId, depth);
46
+ const summaryOut = {
47
+ id: summary.id,
48
+ content: summary.content,
49
+ // v0.30 / E5: the STORED direct-child count; the legacy fallback counts
50
+ // level-0 children, never the BFS-depth-N total (independent-review MED #4).
51
+ descendantCount: summary.descendant_count ?? level0DirectCount,
52
+ earliestAt: summary.earliest_at ?? null,
53
+ latestAt: summary.latest_at ?? null,
54
+ };
55
+ const all = collected.map((c) => ({
56
+ id: c.id,
57
+ content: c.content,
58
+ layer: c.layer,
59
+ dagLevel: c.dag_level ?? 0,
60
+ created: c.created,
61
+ }));
62
+ const { children, truncated } = capChildren(all, summaryOut, opts, limit);
63
+ return {
64
+ summary: summaryOut,
65
+ children,
66
+ // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
67
+ // depth=1 this equals the eligible direct-children count (backward
68
+ // compat). For depth>1 it is the cumulative count across levels.
69
+ totalChildren: collected.length,
70
+ truncated,
71
+ };
72
+ }
73
+ // BFS with a visited set: dag_parent_id is not unique, so a misconfigured tree could emit a child twice past depth 1.
74
+ // The level-0 count is kept apart so a legacy summary's descendantCount fallback counts direct children only.
75
+ function collectDescendants(ctx, summaryId, depth) {
49
76
  const collected = [];
50
77
  const visited = new Set([summaryId]);
51
78
  let frontier = [summaryId];
52
- // independent-review MED #4 fold: track level-0 direct-children count
53
- // separately so the descendantCount fallback (for legacy summaries with
54
- // null descendant_count) reflects DIRECT children, not BFS-collected total.
55
79
  let level0DirectCount = 0;
56
80
  for (let level = 0; level < depth; level++) {
57
81
  const nextFrontier = [];
@@ -72,23 +96,10 @@ export function drillDown(ctx, summaryId, opts = {}) {
72
96
  break;
73
97
  frontier = nextFrontier;
74
98
  }
75
- const summaryOut = {
76
- id: summary.id,
77
- content: summary.content,
78
- // v0.30 / E5: the STORED direct-child count; the legacy fallback counts
79
- // level-0 children, never the BFS-depth-N total (independent-review MED #4).
80
- descendantCount: summary.descendant_count ?? level0DirectCount,
81
- earliestAt: summary.earliest_at ?? null,
82
- latestAt: summary.latest_at ?? null,
83
- };
84
- const all = collected.map((c) => ({
85
- id: c.id,
86
- content: c.content,
87
- layer: c.layer,
88
- dagLevel: c.dag_level ?? 0,
89
- created: c.created,
90
- }));
91
- // Apply global cumulative token budget + limit cap on collected.
99
+ return { collected, level0DirectCount };
100
+ }
101
+ /** Global cumulative token budget first, then the `limit` cap. */
102
+ function capChildren(all, summaryOut, opts, limit) {
92
103
  let children = all;
93
104
  let truncated = false;
94
105
  if (opts.budget !== undefined) {
@@ -110,14 +121,6 @@ export function drillDown(ctx, summaryId, opts = {}) {
110
121
  children = children.slice(0, limit);
111
122
  truncated = true;
112
123
  }
113
- return {
114
- summary: summaryOut,
115
- children,
116
- // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
117
- // depth=1 this equals the eligible direct-children count (backward
118
- // compat). For depth>1 it is the cumulative count across levels.
119
- totalChildren: collected.length,
120
- truncated,
121
- };
124
+ return { children, truncated };
122
125
  }
123
126
  //# sourceMappingURL=drill-down.js.map
@@ -47,19 +47,7 @@ export function promote(ctx, id) {
47
47
  return { ok: true, sourceId: id, globalId: globalEntry.id };
48
48
  }
49
49
  export function supersede(ctx, oldId, newContent) {
50
- // Read old (tenant-scoped). readEntry filters by tenantId, so a Bearer for
51
- // tenant A on tenant B's id throws "Memory not found" here without any
52
- // info leak.
53
- const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
54
- if (!old) {
55
- throw new NotFoundError(`Memory not found: ${oldId}`);
56
- }
57
- // Guard: not already superseded. The CAS UPDATE below race-safely closes
58
- // the window between this read and the write; this check just produces a
59
- // clearer error in the common single-writer case.
60
- if (old.superseded_by) {
61
- throw new ConflictError(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
62
- }
50
+ const old = readSupersedable(ctx, oldId);
63
51
  const newEntry = createSuccessor(old, newContent, {
64
52
  tenantId: ctx.tenantId,
65
53
  baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
@@ -72,59 +60,7 @@ export function supersede(ctx, oldId, newContent) {
72
60
  // the old.superseded_by pointer.
73
61
  const db = openHippoDb(ctx.hippoRoot);
74
62
  try {
75
- db.exec('BEGIN IMMEDIATE');
76
- try {
77
- // 1. CAS update: only succeed if old.superseded_by IS NULL AND the
78
- // row still belongs to ctx.tenantId. Tenant filter is belt-and-
79
- // braces with the readEntry above — it costs nothing and closes
80
- // a hypothetical window where ownership changes between read and
81
- // update.
82
- const result = db.prepare(`
83
- UPDATE memories
84
- SET superseded_by = ?
85
- WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
86
- `).run(newEntry.id, oldId, ctx.tenantId);
87
- if ((result.changes ?? 0) === 0) {
88
- db.exec('ROLLBACK');
89
- throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
90
- }
91
- // v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
92
- // superseded. Its parent (if any) needs rebuild. Lands strictly
93
- // between the rollback guard above and the writeEntryDbOnly(NEW)
94
- // below so a failed CAS hits throw before this hook. The NEW
95
- // entry's parent (typically same parent) is auto-marked by the
96
- // writeEntryDbOnly hook (same parent → idempotent, audits once).
97
- if (old.dag_parent_id) {
98
- markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
99
- }
100
- // 2. Write new memory inside same tx via writeEntryDbOnly (DB-only
101
- // path). This emits its OWN 'remember' audit row for the new
102
- // memory inside the SAVEPOINT — atomic with the row INSERT.
103
- writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
104
- // 3. User-facing 'supersede' audit row inside the same tx so the
105
- // chain pointer + audit trail commit atomically.
106
- appendAuditEvent(db, {
107
- tenantId: ctx.tenantId,
108
- actor: ctx.actor.subject,
109
- op: 'supersede',
110
- targetId: oldId,
111
- metadata: { newId: newEntry.id },
112
- });
113
- db.exec('COMMIT');
114
- }
115
- catch (err) {
116
- try {
117
- db.exec('ROLLBACK');
118
- }
119
- catch { /* already rolled back */ }
120
- // AT1 (plan §3): refusal audit lands post-ROLLBACK, in a fresh
121
- // implicit transaction the aborted outer one cannot claw back — then
122
- // rethrow so the caller sees the refusal.
123
- if (err instanceof RejectedValueError) {
124
- auditRejectionRefusal(db, err, ctx.actor.subject);
125
- }
126
- throw err;
127
- }
63
+ commitSupersede(db, ctx, oldId, old, newEntry);
128
64
  // Mirrors after COMMIT, while the db handle is still open. Same
129
65
  // invariant as the original writeEntry: a mirror failure leaves disk
130
66
  // MISSING the markdown for the new memory (rebuildIndex rewrites every
@@ -142,6 +78,59 @@ export function supersede(ctx, oldId, newContent) {
142
78
  }
143
79
  return { ok: true, oldId, newId: newEntry.id };
144
80
  }
81
+ /** The tenant-scoped row to supersede; readEntry's tenant filter makes another tenant's id read as not found. */
82
+ function readSupersedable(ctx, oldId) {
83
+ const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
84
+ if (!old) {
85
+ throw new NotFoundError(`Memory not found: ${oldId}`);
86
+ }
87
+ // The CAS UPDATE closes the race; this check only gives a clearer error in the common single-writer case.
88
+ if (old.superseded_by) {
89
+ throw new ConflictError(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
90
+ }
91
+ return old;
92
+ }
93
+ /** CAS on the old row, the successor's insert and the 'supersede' audit row, in one BEGIN IMMEDIATE transaction. */
94
+ function commitSupersede(db, ctx, oldId, old, newEntry) {
95
+ db.exec('BEGIN IMMEDIATE');
96
+ try {
97
+ // The tenant filter repeats readEntry's check at no cost, closing an ownership change between read and update.
98
+ const result = db.prepare(`
99
+ UPDATE memories
100
+ SET superseded_by = ?
101
+ WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
102
+ `).run(newEntry.id, oldId, ctx.tenantId);
103
+ if ((result.changes ?? 0) === 0) {
104
+ db.exec('ROLLBACK');
105
+ throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
106
+ }
107
+ // After the CAS guard, so a lost race throws before the old parent is marked for rebuild.
108
+ if (old.dag_parent_id) {
109
+ markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
110
+ }
111
+ // Emits its own 'remember' audit row inside the same transaction.
112
+ writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
113
+ appendAuditEvent(db, {
114
+ tenantId: ctx.tenantId,
115
+ actor: ctx.actor.subject,
116
+ op: 'supersede',
117
+ targetId: oldId,
118
+ metadata: { newId: newEntry.id },
119
+ });
120
+ db.exec('COMMIT');
121
+ }
122
+ catch (err) {
123
+ try {
124
+ db.exec('ROLLBACK');
125
+ }
126
+ catch { /* already rolled back */ }
127
+ // The refusal audit lands after ROLLBACK, in a fresh implicit transaction the aborted one cannot undo.
128
+ if (err instanceof RejectedValueError) {
129
+ auditRejectionRefusal(db, err, ctx.actor.subject);
130
+ }
131
+ throw err;
132
+ }
133
+ }
145
134
  export function archiveRaw(ctx, id, reason, opts = {}) {
146
135
  const db = openHippoDb(ctx.hippoRoot);
147
136
  let mirrorOk = false;