hippo-memory 1.52.6 → 1.52.8

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 (55) hide show
  1. package/README.md +57 -34
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +36 -11
  4. package/dist/autolearn.d.ts +1 -4
  5. package/dist/autolearn.js +6 -7
  6. package/dist/capture.d.ts +3 -0
  7. package/dist/capture.js +51 -56
  8. package/dist/churn-git.js +2 -2
  9. package/dist/cli.d.ts +1 -4
  10. package/dist/cli.js +240 -150
  11. package/dist/config.js +8 -1
  12. package/dist/consolidate.d.ts +2 -0
  13. package/dist/consolidate.js +13 -6
  14. package/dist/customer-notes.js +3 -2
  15. package/dist/dag.js +5 -0
  16. package/dist/dashboard.js +4 -0
  17. package/dist/decisions.d.ts +1 -1
  18. package/dist/decisions.js +4 -3
  19. package/dist/extensions/openclaw-plugin/index.js +1 -0
  20. package/dist/extract.js +3 -0
  21. package/dist/graph-recall.js +8 -7
  22. package/dist/half-life-migration.d.ts +14 -5
  23. package/dist/half-life-migration.js +89 -21
  24. package/dist/handoff.d.ts +2 -0
  25. package/dist/hooks.d.ts +4 -4
  26. package/dist/hooks.js +10 -4
  27. package/dist/importers.d.ts +2 -1
  28. package/dist/importers.js +19 -7
  29. package/dist/incidents.d.ts +1 -1
  30. package/dist/incidents.js +4 -3
  31. package/dist/index.d.ts +4 -1
  32. package/dist/index.js +6 -1
  33. package/dist/mcp/server.js +3 -3
  34. package/dist/memory.d.ts +6 -13
  35. package/dist/memory.js +0 -10
  36. package/dist/policies.js +3 -2
  37. package/dist/predictions.js +2 -0
  38. package/dist/processes.js +3 -2
  39. package/dist/project-briefs.js +3 -2
  40. package/dist/secret-detect.js +3 -3
  41. package/dist/shared.d.ts +1 -1
  42. package/dist/shared.js +6 -1
  43. package/dist/skills.js +3 -2
  44. package/dist/store.d.ts +5 -5
  45. package/dist/store.js +25 -13
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/dist-ui/assets/index-BhT8RvO6.js +61 -0
  49. package/dist-ui/index.html +1 -1
  50. package/extensions/openclaw-plugin/index.ts +1 -0
  51. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  52. package/extensions/openclaw-plugin/package.json +1 -1
  53. package/openclaw.plugin.json +1 -1
  54. package/package.json +1 -1
  55. package/dist-ui/assets/index-BgmA7Hwe.js +0 -61
package/dist/config.js CHANGED
@@ -151,8 +151,15 @@ export function loadConfig(hippoRoot) {
151
151
  `(got ${JSON.stringify(churnStalenessEnabled)}) - using false.`);
152
152
  churnStalenessEnabled = false;
153
153
  }
154
+ // Every writer starts a memory on this, and a zero or negative half-life scores zero strength, so sleep would retire it.
155
+ let defaultHalfLifeDays = raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays;
156
+ if (!Number.isFinite(defaultHalfLifeDays) || defaultHalfLifeDays <= 0) {
157
+ console.error(`Warning: config.json's "defaultHalfLifeDays" must be a number of days above 0 ` +
158
+ `(got ${JSON.stringify(defaultHalfLifeDays)}) - using ${DEFAULT_CONFIG.defaultHalfLifeDays}.`);
159
+ defaultHalfLifeDays = DEFAULT_CONFIG.defaultHalfLifeDays;
160
+ }
154
161
  return {
155
- defaultHalfLifeDays: raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays,
162
+ defaultHalfLifeDays,
156
163
  defaultBudget: raw.defaultBudget ?? DEFAULT_CONFIG.defaultBudget,
157
164
  defaultContextBudget: raw.defaultContextBudget ?? DEFAULT_CONFIG.defaultContextBudget,
158
165
  decayBasis: validBasis ? basis : DEFAULT_CONFIG.decayBasis,
@@ -37,6 +37,8 @@ export interface ConsolidationResult {
37
37
  /** Ids the decay pass removes (or would remove, under dryRun). */
38
38
  removedIds?: string[];
39
39
  }
40
+ /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
41
+ export declare const MEMORY_BACKED_TABLES: readonly ["predictions", "decisions", "incidents", "processes", "policies", "skills", "project_briefs", "customer_notes"];
40
42
  /**
41
43
  * Run a full consolidation pass.
42
44
  */
@@ -24,7 +24,7 @@ import { resolveTenantId } from './tenant.js';
24
24
  import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
25
25
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
26
26
  import { appendAuditEvent } from './audit.js';
27
- import { migrateDefaultHalfLife } from './half-life-migration.js';
27
+ import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
28
28
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
29
29
  import { isQuarantineScope } from './quarantine.js';
30
30
  const DECAY_THRESHOLD = 0.05;
@@ -64,10 +64,10 @@ const REPLAY_COUNT_DEFAULT = 5;
64
64
  function isJsonString(value) {
65
65
  return typeof value === 'string';
66
66
  }
67
- /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL). */
68
- const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
67
+ /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
68
+ export const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'incidents', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
69
69
  /**
70
- * Ids of memories that back a first-class object (a decision, prediction,
70
+ * Ids of memories that back a first-class object (a decision, incident, prediction,
71
71
  * process, policy, skill, project brief or customer note). Sleep never
72
72
  * retires these: deleting or moving one to dormant storage fires the
73
73
  * object's ON DELETE SET NULL and a restore cannot repair the link. Their
@@ -85,8 +85,10 @@ function memoriesBackingObjects(hippoRoot) {
85
85
  for (const r of rows)
86
86
  ids.add(r.memory_id);
87
87
  }
88
- catch {
89
- // Table not present in this schema version.
88
+ catch (err) {
89
+ // A missing table is an older schema; any other error could hide a backing memory, so sleep stops.
90
+ if (!(err instanceof Error && err.message.includes('no such table')))
91
+ throw err;
90
92
  }
91
93
  }
92
94
  }
@@ -131,6 +133,9 @@ export async function consolidate(hippoRoot, options = {}) {
131
133
  if (halfLife.rescaled > 0) {
132
134
  result.details.push(` ⏳ ${dryRun ? 'would move' : 'moved'} ${halfLife.rescaled} memories from the ${halfLife.from}-day to the ${halfLife.to}-day half-life`);
133
135
  }
136
+ if (halfLife.typed > 0) {
137
+ result.details.push(` ⏳ ${dryRun ? 'would move' : 'moved'} ${halfLife.typed} memories of decisions, incidents and other objects from the ${LEGACY_TYPED_HALF_LIFE}-day to the ${halfLife.to}-day half-life`);
138
+ }
134
139
  // L9: host-wide by design. Consolidation runs across all tenants in one
135
140
  // pass — per-tenant filtering would create N consolidation runs per host
136
141
  // with no cross-tenant dedup. The api.sleep audit row tags this with the
@@ -410,6 +415,7 @@ export async function consolidate(hippoRoot, options = {}) {
410
415
  // consolidationTenant — for any non-default tenant that check never
411
416
  // hit, and the trace regenerated every sleep.
412
417
  tenantId: consolidationTenant,
418
+ baseHalfLifeDays: config.defaultHalfLifeDays,
413
419
  });
414
420
  // AT1 (same producer-side pattern as the merge pass below): traceExistsForSession
415
421
  // only sees rows CURRENTLY in the store — once a rejected trace is
@@ -737,6 +743,7 @@ export async function consolidate(hippoRoot, options = {}) {
737
743
  confidence: 'inferred',
738
744
  tenantId: mergeTenant,
739
745
  scope: mergeScope,
746
+ baseHalfLifeDays: config.defaultHalfLifeDays,
740
747
  });
741
748
  }
742
749
  // mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
@@ -22,8 +22,9 @@
22
22
  import { openHippoDb, closeHippoDb } from './db.js';
23
23
  import { writeEntry, assertTenantId } from './store.js';
24
24
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
25
- import { createMemory, Layer, CUSTOMER_NOTE_HALF_LIFE_DAYS } from './memory.js';
25
+ import { createMemory, Layer } from './memory.js';
26
26
  import { appendAuditEvent } from './audit.js';
27
+ import { objectHalfLifeDays } from './half-life-migration.js';
27
28
  export const VALID_NOTE_STATES = new Set([
28
29
  'active',
29
30
  'superseded',
@@ -118,9 +119,9 @@ export function saveCustomerNote(hippoRoot, tenantId, opts, actor = 'cli') {
118
119
  layer: Layer.Semantic,
119
120
  confidence: 'verified',
120
121
  source: 'customer_note',
122
+ baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
121
123
  tenantId,
122
124
  });
123
- mem.half_life_days = CUSTOMER_NOTE_HALF_LIFE_DAYS;
124
125
  let savedRow;
125
126
  writeEntry(hippoRoot, mem, {
126
127
  actor,
package/dist/dag.js CHANGED
@@ -3,6 +3,7 @@ import { writeEntry, loadAllDirtySummaries, loadChildrenOfSummary, applyRebuildR
3
3
  import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
5
  import { derivationScope, derivationPartitionKey } from './recall-scope.js';
6
+ import { loadConfig } from './config.js';
6
7
  export function clusterFacts(facts) {
7
8
  if (facts.length === 0)
8
9
  return [];
@@ -84,6 +85,7 @@ export async function generateDagSummary(label, factContents, opts) {
84
85
  }
85
86
  export async function buildDag(hippoRoot, facts, opts) {
86
87
  const result = { candidateClusters: 0, summariesCreated: 0, factsLinked: 0, rejected: 0 };
88
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
87
89
  const unparented = facts.filter((f) => f.dag_level === 1 && !f.dag_parent_id && f.tags.includes('extracted'));
88
90
  // Hardening follow-up (mirrors consolidate.ts's mergeCandidatesByTenant,
89
91
  // T1): partition unparented facts by tenantId BEFORE clustering so a
@@ -123,6 +125,7 @@ export async function buildDag(hippoRoot, facts, opts) {
123
125
  dag_level: 2,
124
126
  tenantId: factTenant,
125
127
  scope: factScope,
128
+ baseHalfLifeDays,
126
129
  });
127
130
  // Schema v25: cache descendant_count + earliest/latest_at on the summary
128
131
  // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
@@ -293,6 +296,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
293
296
  failed: 0,
294
297
  rejected: 0,
295
298
  };
299
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
296
300
  // Only L2 with no L3 parent yet (avoid re-clustering already-profiled L2s).
297
301
  const unparented = l2Summaries.filter((s) => s.dag_level === 2 && !s.dag_parent_id);
298
302
  // independent-review HIGH #1 fold: cluster ONLY within-tenant.
@@ -330,6 +334,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
330
334
  dag_level: 3,
331
335
  tenantId, // HIGH #1 fold: thread tenant explicitly
332
336
  scope,
337
+ baseHalfLifeDays,
333
338
  });
334
339
  profileEntry.descendant_count = cluster.members.length;
335
340
  profileEntry.earliest_at = memberCreatedAts[0];
package/dist/dashboard.js CHANGED
@@ -5,8 +5,10 @@
5
5
  * Usage: hippo dashboard [--port 3333]
6
6
  */
7
7
  import * as http from 'http';
8
+ import * as os from 'os';
8
9
  import * as path from 'path';
9
10
  import * as fs from 'fs';
11
+ import { extractPathTags } from './path-context.js';
10
12
  import { loadAllEntries, listCards, listMemoryConflicts, readEntry, writeEntry } from './store.js';
11
13
  import { calculateStrength, confidenceFacets } from './memory.js';
12
14
  import { loadConfig } from './config.js';
@@ -110,6 +112,8 @@ function buildDashboardData(hippoRoot) {
110
112
  defaultHalfLifeDays: config.defaultHalfLifeDays,
111
113
  defaultBudget: config.defaultBudget,
112
114
  embeddingsEnabled: config.embeddings.enabled,
115
+ // Every memory captured in the home folder carries these, so they name no project.
116
+ homePathTags: extractPathTags(os.homedir()),
113
117
  },
114
118
  };
115
119
  }
@@ -64,7 +64,7 @@ export interface ListDecisionsOpts {
64
64
  *
65
65
  * The memory mirror preserves the legacy `hippo decide` shape: tags
66
66
  * ['decision', ...extraTags], source 'decision', confidence 'verified',
67
- * half_life DECISION_HALF_LIFE_DAYS, content = "<text>\n\nContext: <context>"
67
+ * the half-life objectHalfLifeDays picks, content = "<text>\n\nContext: <context>"
68
68
  * when context is given (so existing recall output is unchanged).
69
69
  */
70
70
  export declare function saveDecision(hippoRoot: string, tenantId: string, opts: SaveDecisionOpts, actor?: string): Decision;
package/dist/decisions.js CHANGED
@@ -26,8 +26,9 @@
26
26
  import { openHippoDb, closeHippoDb } from './db.js';
27
27
  import { writeEntry, assertTenantId } from './store.js';
28
28
  import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
29
- import { createMemory, Layer, DECISION_HALF_LIFE_DAYS } from './memory.js';
29
+ import { createMemory, Layer } from './memory.js';
30
30
  import { appendAuditEvent } from './audit.js';
31
+ import { objectHalfLifeDays } from './half-life-migration.js';
31
32
  export const VALID_DECISION_STATES = new Set([
32
33
  'active',
33
34
  'superseded',
@@ -65,7 +66,7 @@ const DECISION_COLS = `
65
66
  *
66
67
  * The memory mirror preserves the legacy `hippo decide` shape: tags
67
68
  * ['decision', ...extraTags], source 'decision', confidence 'verified',
68
- * half_life DECISION_HALF_LIFE_DAYS, content = "<text>\n\nContext: <context>"
69
+ * the half-life objectHalfLifeDays picks, content = "<text>\n\nContext: <context>"
69
70
  * when context is given (so existing recall output is unchanged).
70
71
  */
71
72
  export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
@@ -82,9 +83,9 @@ export function saveDecision(hippoRoot, tenantId, opts, actor = 'cli') {
82
83
  layer: Layer.Semantic,
83
84
  confidence: 'verified',
84
85
  source: 'decision',
86
+ baseHalfLifeDays: objectHalfLifeDays(hippoRoot),
85
87
  tenantId,
86
88
  });
87
- mem.half_life_days = DECISION_HALF_LIFE_DAYS;
88
89
  // Populated inside afterWrite so the INSERT, the supersede UPDATE, and the
89
90
  // memory write all share one SAVEPOINT.
90
91
  let savedRow;
@@ -172,6 +172,7 @@ function runHippo(args, cwd) {
172
172
  encoding: 'utf8',
173
173
  timeout: 30_000,
174
174
  stdio: ['pipe', 'pipe', 'pipe'],
175
+ windowsHide: true,
175
176
  });
176
177
  // `encoding: 'utf8'` above selects the ExecFileSyncOptionsWithStringEncoding
177
178
  // overload, so `result` is always a `string` here — no runtime check needed.
package/dist/extract.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Layer, createMemory } from './memory.js';
2
2
  import { writeEntry } from './store.js';
3
+ import { loadConfig } from './config.js';
3
4
  import { RejectedValueError } from './rejection.js';
4
5
  import { redactSecrets } from './secret-detect.js';
5
6
  function isJsonString(value) {
@@ -83,6 +84,7 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
83
84
  const inheritedTags = source.tags.filter((t) => INHERITABLE_PREFIXES.some((p) => t.startsWith(p)));
84
85
  const entries = [];
85
86
  let rejected = 0;
87
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
86
88
  for (const fact of facts) {
87
89
  const tags = ['extracted', ...inheritedTags, ...fact.tags];
88
90
  const entry = createMemory(fact.content, {
@@ -99,6 +101,7 @@ export function storeExtractedFacts(hippoRoot, source, facts) {
99
101
  // entry's own tenant. Thread it through so extracted facts land in
100
102
  // the same tenant as the episodic memory they were extracted from.
101
103
  tenantId: source.tenantId,
104
+ baseHalfLifeDays,
102
105
  });
103
106
  // AT1 containment: a refusal is per-VALUE — one rejected fact must not
104
107
  // drop the rest of this batch. writeEntry has already audited the
@@ -61,12 +61,9 @@ function loadByIdsChunked(root, tenantId, ids) {
61
61
  }
62
62
  return out;
63
63
  }
64
- /**
65
- * Traverse one store's graph from the seeds present in it and accumulate new graph hits
66
- * into `hitsByOrigin`. Mutates `seenMemoryIds` so a memory is surfaced at most once across
67
- * stores. Pure reads.
68
- */
69
- function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, opts) {
64
+ /** Traverse one store's graph from its seeds into `hitsByOrigin`. Pure reads; mutates `seenMemoryIds`
65
+ * and `seenContent` so a memory, or a share/promote copy of it, surfaces at most once across stores. */
66
+ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, opts) {
70
67
  const { hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope } = opts;
71
68
  // Seeds = graph entities (in THIS store) whose source memory is a base result.
72
69
  const seedEntities = loadEntitiesByMemoryId(root, tenantId, baseResults.map((r) => r.entry.id));
@@ -147,6 +144,8 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
147
144
  continue; // not found / wrong tenant / already in base
148
145
  if (seenMemoryIds.has(mem.id))
149
146
  continue; // another reached entity already added it
147
+ if (seenContent.has(mem.content))
148
+ continue; // share/promote copy: same text, another id
150
149
  const via = reached.get(ent.id);
151
150
  // A node reached as the `to` endpoint of a `supersedes` edge IS the superseded
152
151
  // (older) version — the graph is the authoritative signal (the memory mirror's
@@ -175,6 +174,7 @@ function produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds,
175
174
  const origin = originMemByEntityId.get(ent.id) ?? baseResults[0].entry.id;
176
175
  const originScore = baseScoreByMemId.get(origin) ?? baseResults[baseResults.length - 1].score;
177
176
  seenMemoryIds.add(mem.id);
177
+ seenContent.add(mem.content);
178
178
  const hit = {
179
179
  entry: mem,
180
180
  score: originScore * (1 - HOP_DISCOUNT * via.hops),
@@ -209,11 +209,12 @@ export function graphExpandRecall(baseResults, opts) {
209
209
  const recallScope = opts.recallScope ?? {};
210
210
  const baseScoreByMemId = new Map(baseResults.map((r) => [r.entry.id, r.score]));
211
211
  const seenMemoryIds = new Set(baseResults.map((r) => r.entry.id));
212
+ const seenContent = new Set(baseResults.map((r) => r.entry.content));
212
213
  const hitsByOrigin = new Map();
213
214
  // Expand against each distinct store the seeds may live in (local + global).
214
215
  const roots = globalRoot && globalRoot !== hippoRoot ? [hippoRoot, globalRoot] : [hippoRoot];
215
216
  for (const root of roots) {
216
- produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, hitsByOrigin, {
217
+ produceHitsForRoot(root, baseResults, baseScoreByMemId, seenMemoryIds, seenContent, hitsByOrigin, {
217
218
  hops, maxNeighbors, tenantId, includeSuperseded, asOfDate, recallScope,
218
219
  });
219
220
  }
@@ -17,12 +17,15 @@
17
17
  * migration runs once.
18
18
  *
19
19
  * `hippo sleep` runs it before its decay pass, from the base the store is on
20
- * (7 days when never recorded) to the configured `defaultHalfLifeDays`.
20
+ * (7 days when never recorded) to the configured `defaultHalfLifeDays`. Once per store it also
21
+ * moves memories of live decisions, incidents and other objects off the flat 90 days they used to get.
21
22
  */
22
23
  import { type MemoryEntry } from './memory.js';
23
24
  import { HALF_LIFE_BASE_META_KEY } from './store.js';
24
25
  /** The base every store used before the base was recorded. */
25
26
  export declare const LEGACY_HALF_LIFE_BASE = 7;
27
+ /** The flat half-life the decision, incident and other object writers gave their memories before they took the default. */
28
+ export declare const LEGACY_TYPED_HALF_LIFE = 90;
26
29
  export { HALF_LIFE_BASE_META_KEY };
27
30
  /** What {@link migrateDefaultHalfLife} did, or would do under `dryRun`. */
28
31
  export interface HalfLifeMigrationResult {
@@ -30,6 +33,8 @@ export interface HalfLifeMigrationResult {
30
33
  to: number;
31
34
  /** Memories moved to the new base. */
32
35
  rescaled: number;
36
+ /** Memories of decisions, incidents and other objects moved off the flat 90 days. */
37
+ typed: number;
33
38
  /** Memories left alone because they are not on the old base. */
34
39
  kept: number;
35
40
  dryRun: boolean;
@@ -37,15 +42,19 @@ export interface HalfLifeMigrationResult {
37
42
  halfLives: ReadonlyMap<string, number>;
38
43
  }
39
44
  type HalfLifeFields = Pick<MemoryEntry, 'half_life_days' | 'tags' | 'schema_fit' | 'retrieval_count' | 'superseded_by'>;
40
- /** Recall bonus over what `base` gave `entry`, or null when off that base; pre-1.46 recalls each added 2 days. */
41
- export declare function halfLifeRecallBonus(entry: HalfLifeFields, base: number): number | null;
45
+ /** Recall bonus over `written`, the half-life `entry` got at write, or null when off it; each recall added 2 days. */
46
+ export declare function halfLifeRecallBonus(entry: HalfLifeFields, written: number): number | null;
42
47
  /** The entries to rescale from `from` to `to`, as copies that keep their recall bonus. Pure. */
43
48
  export declare function planHalfLifeMigration(entries: readonly MemoryEntry[], from: number, to: number): MemoryEntry[];
49
+ /** Memories of decisions, incidents and other objects still on the flat 90 days, as copies on `to` that keep their recall bonus. Pure. */
50
+ export declare function planTypedHalfLifeMigration(entries: readonly MemoryEntry[], to: number): MemoryEntry[];
44
51
  /** The base this store's memories are on. */
45
52
  export declare function storeHalfLifeBase(hippoRoot: string): number;
53
+ /** The base an object writer gives its memory: the flat 90 days until this store's typed migration has run, which then moves them, and the default after. */
54
+ export declare function objectHalfLifeDays(hippoRoot: string): number;
46
55
  /**
47
- * Move the store's memories from the base they are on to `to`. A no-op when
48
- * they are already on it. Under `dryRun` nothing is written, the recorded
56
+ * Move the store's memories from the base they are on, and those of objects from
57
+ * the old flat 90 days, to `to`, once. Under `dryRun` nothing is written, the recorded
49
58
  * base included.
50
59
  */
51
60
  export declare function migrateDefaultHalfLife(hippoRoot: string, to: number, opts?: {
@@ -17,20 +17,26 @@
17
17
  * migration runs once.
18
18
  *
19
19
  * `hippo sleep` runs it before its decay pass, from the base the store is on
20
- * (7 days when never recorded) to the configured `defaultHalfLifeDays`.
20
+ * (7 days when never recorded) to the configured `defaultHalfLifeDays`. Once per store it also
21
+ * moves memories of live decisions, incidents and other objects off the flat 90 days they used to get.
21
22
  */
22
23
  import { deriveHalfLife } from './memory.js';
23
- import { openStore, selectAllEntries, HALF_LIFE_BASE_META_KEY } from './store.js';
24
+ import { openStore, selectAllEntries, HALF_LIFE_BASE_META_KEY, TYPED_HALF_LIFE_META_KEY } from './store.js';
24
25
  import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
25
26
  import { appendAuditEvent } from './audit.js';
27
+ import { loadConfig } from './config.js';
26
28
  /** The base every store used before the base was recorded. */
27
29
  export const LEGACY_HALF_LIFE_BASE = 7;
30
+ /** The flat half-life the decision, incident and other object writers gave their memories before they took the default. */
31
+ export const LEGACY_TYPED_HALF_LIFE = 90;
32
+ const TYPED_SOURCES = new Set(['decision', 'incident', 'process', 'policy', 'skill', 'project_brief', 'customer_note']);
33
+ const OBJECT_TABLES = ['decisions', 'incidents', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
28
34
  export { HALF_LIFE_BASE_META_KEY };
29
- /** Recall bonus over what `base` gave `entry`, or null when off that base; pre-1.46 recalls each added 2 days. */
30
- export function halfLifeRecallBonus(entry, base) {
35
+ /** Recall bonus over `written`, the half-life `entry` got at write, or null when off it; each recall added 2 days. */
36
+ export function halfLifeRecallBonus(entry, written) {
31
37
  if (entry.superseded_by || entry.tags.includes('invalidated') || entry.tags.includes('superseded'))
32
38
  return null;
33
- const bonus = entry.half_life_days - deriveHalfLife(base, entry);
39
+ const bonus = entry.half_life_days - written;
34
40
  const k = Math.round(bonus / 2);
35
41
  return Math.abs(bonus - 2 * k) < 1e-9 && k >= 0 && k <= entry.retrieval_count ? bonus : null;
36
42
  }
@@ -39,10 +45,18 @@ export function planHalfLifeMigration(entries, from, to) {
39
45
  if (from === to)
40
46
  return [];
41
47
  return entries.flatMap((e) => {
42
- const bonus = halfLifeRecallBonus(e, from);
48
+ const bonus = halfLifeRecallBonus(e, deriveHalfLife(from, e));
43
49
  return bonus === null ? [] : [{ ...e, half_life_days: deriveHalfLife(to, e) + bonus }];
44
50
  });
45
51
  }
52
+ /** Memories of decisions, incidents and other objects still on the flat 90 days, as copies on `to` that keep their recall bonus. Pure. */
53
+ export function planTypedHalfLifeMigration(entries, to) {
54
+ return entries.flatMap((e) => {
55
+ const bonus = TYPED_SOURCES.has(e.source) ? halfLifeRecallBonus(e, LEGACY_TYPED_HALF_LIFE) : null;
56
+ const next = bonus === null ? e.half_life_days : deriveHalfLife(to, e) + bonus;
57
+ return next === e.half_life_days ? [] : [{ ...e, half_life_days: next }];
58
+ });
59
+ }
46
60
  /** The base this store's memories are on. */
47
61
  export function storeHalfLifeBase(hippoRoot) {
48
62
  const db = openHippoDb(hippoRoot);
@@ -57,14 +71,25 @@ function readBase(db) {
57
71
  const raw = Number(getMeta(db, HALF_LIFE_BASE_META_KEY, String(LEGACY_HALF_LIFE_BASE)));
58
72
  return Number.isFinite(raw) && raw > 0 ? raw : LEGACY_HALF_LIFE_BASE;
59
73
  }
74
+ /** The base an object writer gives its memory: the flat 90 days until this store's typed migration has run, which then moves them, and the default after. */
75
+ export function objectHalfLifeDays(hippoRoot) {
76
+ // SHORTCUT: read outside the write's transaction, so a write racing the typed migration keeps 90; read inside writeEntry if that ever matters.
77
+ const db = openStore(hippoRoot);
78
+ try {
79
+ return getMeta(db, TYPED_HALF_LIFE_META_KEY, '') === '' ? LEGACY_TYPED_HALF_LIFE : loadConfig(hippoRoot).defaultHalfLifeDays;
80
+ }
81
+ finally {
82
+ closeHippoDb(db);
83
+ }
84
+ }
60
85
  /**
61
- * Move the store's memories from the base they are on to `to`. A no-op when
62
- * they are already on it. Under `dryRun` nothing is written, the recorded
86
+ * Move the store's memories from the base they are on, and those of objects from
87
+ * the old flat 90 days, to `to`, once. Under `dryRun` nothing is written, the recorded
63
88
  * base included.
64
89
  */
65
90
  export function migrateDefaultHalfLife(hippoRoot, to, opts = {}) {
66
91
  const dryRun = opts.dryRun ?? false;
67
- const noop = (from) => ({ from, to, rescaled: 0, kept: 0, dryRun, halfLives: new Map() });
92
+ const noop = (from) => ({ from, to, rescaled: 0, typed: 0, kept: 0, dryRun, halfLives: new Map() });
68
93
  const db = openStore(hippoRoot);
69
94
  try {
70
95
  // Plan, write, audit and record the base under one write lock, so a concurrent write or sleep cannot interleave.
@@ -72,28 +97,31 @@ export function migrateDefaultHalfLife(hippoRoot, to, opts = {}) {
72
97
  db.exec('BEGIN IMMEDIATE');
73
98
  try {
74
99
  const from = readBase(db);
75
- if (!(Number.isFinite(to) && to > 0) || from === to) {
100
+ const typedPending = getMeta(db, TYPED_HALF_LIFE_META_KEY, '') === '';
101
+ if (!(Number.isFinite(to) && to > 0) || (from === to && !typedPending)) {
76
102
  if (!dryRun)
77
103
  db.exec('COMMIT');
78
104
  return noop(from);
79
105
  }
80
106
  const all = selectAllEntries(db);
81
- const plan = planHalfLifeMigration(all, from, to);
107
+ const objects = typedPending ? objectMemoryIds(db) : { all: new Set(), retired: new Set() };
108
+ const losers = typedPending ? conflictLosers(db) : new Set();
109
+ const copies = new Set(all.flatMap((e) => (e.superseded_by ? [e.superseded_by] : [])));
110
+ // Provenance before shape: an object's memory came from its writer, a supersede copy from the base (it keeps the source). Shape decides the rest.
111
+ const objectWritten = (e) => objects.all.has(e.id) || (!copies.has(e.id) && TYPED_SOURCES.has(e.source) && halfLifeRecallBonus(e, LEGACY_TYPED_HALF_LIFE) !== null);
112
+ const typedPlan = typedPending ? planTypedHalfLifeMigration(all.filter((e) => objectWritten(e) && !objects.retired.has(e.id) && !losers.has(e.id)), to) : [];
113
+ const basePlan = planHalfLifeMigration(typedPending ? all.filter((e) => !objectWritten(e)) : all, from, to);
114
+ const plan = [...basePlan, ...typedPlan];
82
115
  const halfLives = new Map(plan.map((e) => [e.id, e.half_life_days]));
83
- const result = { from, to, rescaled: plan.length, kept: all.length - plan.length, dryRun, halfLives };
116
+ const result = { from, to, rescaled: basePlan.length, typed: typedPlan.length, kept: all.length - plan.length, dryRun, halfLives };
84
117
  if (dryRun)
85
118
  return result;
86
119
  const old = new Map(all.map((e) => [e.id, e.half_life_days]));
87
- const update = db.prepare('UPDATE memories SET half_life_days = ? WHERE id = ?');
88
- const byTenant = new Map();
89
- for (const e of plan) {
90
- update.run(e.half_life_days, e.id);
91
- byTenant.set(e.tenantId, { ...byTenant.get(e.tenantId), [e.id]: old.get(e.id) });
92
- }
93
- for (const [tenantId, oldHalfLives] of byTenant) {
94
- appendAuditEvent(db, { tenantId, actor: opts.actor ?? 'system', op: 'half_life_migrate', metadata: { from, to, ids: Object.keys(oldHalfLives), oldHalfLives } });
95
- }
120
+ const actor = opts.actor ?? 'system';
121
+ writePlan(db, basePlan, old, { from, to, actor });
122
+ writePlan(db, typedPlan, old, { from: LEGACY_TYPED_HALF_LIFE, to, actor });
96
123
  setMeta(db, HALF_LIFE_BASE_META_KEY, String(to));
124
+ setMeta(db, TYPED_HALF_LIFE_META_KEY, '1');
97
125
  db.exec('COMMIT');
98
126
  return result;
99
127
  }
@@ -107,4 +135,44 @@ export function migrateDefaultHalfLife(hippoRoot, to, opts = {}) {
107
135
  closeHippoDb(db);
108
136
  }
109
137
  }
138
+ /** Memories behind every object, and those behind a superseded or closed one. Retiring an object leaves its memory untouched, so only its table knows. */
139
+ function objectMemoryIds(db) {
140
+ const all = new Set();
141
+ const retired = new Set();
142
+ for (const table of OBJECT_TABLES) {
143
+ // SAFETY: SELECT of two TEXT columns, filtered to a non-null memory_id.
144
+ const rows = db.prepare(`SELECT memory_id, status FROM ${table} WHERE memory_id IS NOT NULL`).all();
145
+ for (const r of rows) {
146
+ all.add(r.memory_id);
147
+ if (r.status === 'superseded' || r.status === 'closed')
148
+ retired.add(r.memory_id);
149
+ }
150
+ }
151
+ return { all, retired };
152
+ }
153
+ /** Memories that lost a conflict, which resolveConflict halved untagged. A resolved conflict with no audit row (before v1.31.0, or found stale) names no winner, so both sides count. */
154
+ function conflictLosers(db) {
155
+ // SAFETY: SELECT of two fields every conflict_resolve audit row carries (ConflictResolveMeta in store.ts).
156
+ const audited = db.prepare(`SELECT json_extract(metadata_json, '$.conflictId') AS conflictId, json_extract(metadata_json, '$.loserId') AS loserId FROM audit_log WHERE op = 'conflict_resolve'`).all();
157
+ const losers = new Set(audited.map((a) => a.loserId));
158
+ const named = new Set(audited.map((a) => a.conflictId));
159
+ // SAFETY: SELECT of three columns of resolved conflicts.
160
+ const resolved = db.prepare(`SELECT id, memory_a_id, memory_b_id FROM memory_conflicts WHERE status = 'resolved'`).all();
161
+ for (const c of resolved)
162
+ if (!named.has(c.id))
163
+ losers.add(c.memory_a_id).add(c.memory_b_id);
164
+ return losers;
165
+ }
166
+ /** Writes `plan`, then one audit event per tenant with each id's old half-life, so the move can be undone. */
167
+ function writePlan(db, plan, old, move) {
168
+ const update = db.prepare('UPDATE memories SET half_life_days = ? WHERE id = ?');
169
+ const byTenant = new Map();
170
+ for (const e of plan) {
171
+ update.run(e.half_life_days, e.id);
172
+ byTenant.set(e.tenantId, { ...byTenant.get(e.tenantId), [e.id]: old.get(e.id) });
173
+ }
174
+ for (const [tenantId, oldHalfLives] of byTenant) {
175
+ appendAuditEvent(db, { tenantId, actor: move.actor, op: 'half_life_migrate', metadata: { from: move.from, to: move.to, ids: Object.keys(oldHalfLives), oldHalfLives } });
176
+ }
177
+ }
110
178
  //# sourceMappingURL=half-life-migration.js.map
package/dist/handoff.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface HandoffEvidence {
12
12
  gitRef?: string | null;
13
13
  dirtyTree?: boolean | null;
14
14
  testStatus?: 'pass' | 'fail' | 'unknown' | null;
15
+ /** 'transcript' when hippo read the handoff off the session's transcript at session end; a later exit may replace it. */
16
+ derivedFrom?: 'transcript';
15
17
  }
16
18
  /** Narrows an unvalidated value (e.g. CLI input or event content) to a HandoffOutcome. */
17
19
  export declare function isHandoffOutcome(v: string | boolean | string[] | null | undefined): v is HandoffOutcome;
package/dist/hooks.d.ts CHANGED
@@ -10,8 +10,8 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker and then clears it,
14
- * so the user actually sees what was consolidated.
13
+ * written by the previous session's detached worker to stderr, which
14
+ * keeps it out of the model's context, and then clears it.
15
15
  * Earlier Claude Code forms are detected and migrated automatically:
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
@@ -60,7 +60,7 @@ export interface CodexWrapperMetadata {
60
60
  installedAt: string;
61
61
  }
62
62
  export interface EnsureCodexWrapperResult {
63
- status: 'installed' | 'already-installed' | 'not-found';
63
+ status: 'installed' | 'already-installed' | 'not-found' | 'source-checkout';
64
64
  metadataPath?: string;
65
65
  realCodexPath?: string;
66
66
  commandPath?: string;
@@ -172,7 +172,7 @@ export declare function isCodexWrapperInstalled(): boolean;
172
172
  * doing it from postinstall or routine commands is a consent violation and
173
173
  * reads as binary hijacking to security scanners (issue #133).
174
174
  */
175
- export declare function repairCodexWrapperIfInstalled(): EnsureCodexWrapperResult;
175
+ export declare function repairCodexWrapperIfInstalled(hippoCliPath?: string): EnsureCodexWrapperResult;
176
176
  export declare function resolveCodexSessionTranscript(options: CodexSessionTranscriptOptions): string | null;
177
177
  export declare function resolveJsonHookPaths(target: JsonHookTarget): JsonHookPaths;
178
178
  export declare function installJsonHooks(target: JsonHookTarget): InstallResult;
package/dist/hooks.js CHANGED
@@ -10,8 +10,8 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker and then clears it,
14
- * so the user actually sees what was consolidated.
13
+ * written by the previous session's detached worker to stderr, which
14
+ * keeps it out of the model's context, and then clears it.
15
15
  * Earlier Claude Code forms are detected and migrated automatically:
16
16
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
17
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
@@ -412,10 +412,16 @@ export function isCodexWrapperInstalled() {
412
412
  * doing it from postinstall or routine commands is a consent violation and
413
413
  * reads as binary hijacking to security scanners (issue #133).
414
414
  */
415
- export function repairCodexWrapperIfInstalled() {
415
+ export function repairCodexWrapperIfInstalled(hippoCliPath = resolveHippoCliPath()) {
416
416
  if (readCodexWrapperMetadata() === null) {
417
417
  return { status: 'not-found' };
418
418
  }
419
+ // A checkout's own `npm install` or CLI would point the user's launcher at a folder that may be deleted.
420
+ // SHORTCUT: any node_modules copy (npx cache, a project dependency) still repairs; record the opted-in CLI path in the metadata to close that.
421
+ const packageDir = path.dirname(path.dirname(hippoCliPath));
422
+ if (path.basename(path.dirname(packageDir)).toLowerCase() !== 'node_modules') {
423
+ return { status: 'source-checkout' };
424
+ }
419
425
  return ensureCodexWrapperInstalled();
420
426
  }
421
427
  function collectFiles(dir) {
@@ -966,7 +972,7 @@ export function detectInstalledTools() {
966
972
  { name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
967
973
  { name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
968
974
  { name: 'codex', configDir: '~/.codex', detected: exists('.codex'), kind: 'wrapper', notes: 'wraps the detected codex launcher for session-end consolidation' },
969
- { name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches .cursorrules in the project' },
975
+ { name: 'cursor', configDir: '~/.cursor', detected: exists('.cursor'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
970
976
  { name: 'pi', configDir: '~/.pi', detected: exists('.pi'), kind: 'markdown-instruction', notes: 'no hook API - patches AGENTS.md in the project' },
971
977
  ];
972
978
  }
@@ -58,7 +58,8 @@ export interface ImportOptions {
58
58
  export declare function importEntries(chunks: string[], source: string, tags: string[], options: ImportOptions): ImportResult;
59
59
  export declare function importChatGPT(filePath: string, options: ImportOptions): ImportResult;
60
60
  export declare function importClaude(filePath: string, options: ImportOptions): ImportResult;
61
- export declare function importCursor(filePath: string, options: ImportOptions): ImportResult;
61
+ /** Import `.cursorrules`, one rule file, or a `.cursor/rules` tree of `.mdc` and `.md` rules. */
62
+ export declare function importCursor(sourcePath: string, options: ImportOptions): ImportResult;
62
63
  export declare function importGenericFile(filePath: string, options: ImportOptions): ImportResult;
63
64
  export declare function importMarkdown(filePath: string, options: ImportOptions): ImportResult;
64
65
  /**