hippo-memory 1.52.6 → 1.52.7

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.
package/dist/cli.js CHANGED
@@ -484,6 +484,14 @@ function cmdInitScan(scanDir, flags) {
484
484
  (totalLowInfo > 0 ? `, ${totalLowInfo} low-information subject(s) dropped` : '') +
485
485
  '.');
486
486
  console.log(`Global store: ${globalRoot}`);
487
+ if (!flags['no-hooks']) {
488
+ // User-level hooks only: a hippo block in each repo's CLAUDE.md or AGENTS.md would leave a diff in every repo.
489
+ const agents = detectAgentHooks(repos);
490
+ if (agents.length === 0) {
491
+ console.log(' No agent config found in these repositories. Run `hippo setup` to add hooks for the agents on this machine.');
492
+ }
493
+ installUserLevelHooks(agents, true);
494
+ }
487
495
  if (!flags['no-schedule']) {
488
496
  setupDailySchedule(globalRoot);
489
497
  }
@@ -520,7 +528,7 @@ function cmdInit(hippoRoot, flags) {
520
528
  registerWorkspace(globalRoot, path.dirname(hippoRoot));
521
529
  // Auto-detect and install hooks (unless --no-hooks)
522
530
  if (!flags['no-hooks']) {
523
- autoInstallHooks(alreadyExists);
531
+ autoInstallHooks();
524
532
  }
525
533
  // Auto-setup daily schedule (unless --no-schedule)
526
534
  if (!flags['no-schedule'] && !flags['global']) {
@@ -546,57 +554,86 @@ function cmdInit(hippoRoot, flags) {
546
554
  }
547
555
  }
548
556
  }
549
- /**
550
- * Detect agent config files in cwd and auto-install hippo hooks.
551
- * Skips files that already have a <!-- hippo:start --> block.
552
- */
553
- function autoInstallHooks(quiet) {
557
+ /** Plain init: patch the detected agents' instruction files in cwd, then install their user-level hooks. */
558
+ function autoInstallHooks() {
554
559
  const cwd = process.cwd();
560
+ const agents = detectAgentHooks([cwd]);
561
+ const agentsMd = path.join(cwd, HOOKS.codex.file);
562
+ // Read before patching: a re-run of init finds its own block and skips the Codex hint.
563
+ const codexHint = !(fs.existsSync(agentsMd) && fs.readFileSync(agentsMd, 'utf8').includes(HOOK_MARKERS.start));
564
+ patchInstructionFiles(cwd, agents);
565
+ installUserLevelHooks(agents, codexHint);
566
+ }
567
+ /** HOOKS keys of the agents with a marker file in any of dirs, in detector order. */
568
+ function detectAgentHooks(dirs) {
555
569
  // Map: filename to check -> hook key(s) to install
556
570
  const detectors = [
557
571
  { files: ['CLAUDE.md', '.claude/settings.json'], hook: 'claude-code' },
558
572
  { files: ['AGENTS.md', '.codex'], hook: 'codex' },
559
- { files: ['.cursorrules', '.cursor/rules'], hook: 'cursor' },
573
+ // No Cursor row: Cursor reads the root AGENTS.md, which the rows either side patch.
560
574
  { files: ['.openclaw', 'AGENTS.md'], hook: 'openclaw' },
561
575
  { files: ['.opencode', 'opencode.json'], hook: 'opencode' },
562
576
  { files: ['.pi', '.pi/agent'], hook: 'pi' },
563
577
  ];
564
- // Track which hook files we've already touched to avoid double-patching AGENTS.md
565
- const installed = new Set();
566
- for (const { files, hook } of detectors) {
578
+ return detectors
579
+ .filter(({ files }) => dirs.some((dir) => files.some((f) => fs.existsSync(path.join(dir, f)))))
580
+ .map(({ hook }) => hook);
581
+ }
582
+ function patchInstructionFiles(dir, agents) {
583
+ // One block per file: several agents share AGENTS.md.
584
+ const seen = new Set();
585
+ for (const hook of agents) {
567
586
  const hookDef = HOOKS[hook];
568
587
  if (!hookDef)
569
588
  continue;
570
- // Check if any marker file exists
571
- const detected = files.some((f) => fs.existsSync(path.join(cwd, f)));
572
- if (!detected)
589
+ const targetPath = path.resolve(dir, hookDef.file);
590
+ // Never create the file: a marker such as .codex or .claude/settings.json does not ask for a new AGENTS.md or CLAUDE.md.
591
+ if (!fs.existsSync(targetPath) || seen.has(targetPath))
573
592
  continue;
574
- const targetPath = path.resolve(cwd, hookDef.file);
575
- // Skip if we already installed a hook into this file
576
- if (installed.has(targetPath))
593
+ seen.add(targetPath);
594
+ const existing = fs.readFileSync(targetPath, 'utf8');
595
+ if (existing.includes(HOOK_MARKERS.start)) {
596
+ refreshShippedBlock(targetPath, existing, hook);
577
597
  continue;
578
- // An instruction block already present is not rewritten, but the JSON
579
- // hooks and plugins below still run: they are idempotent, and skipping
580
- // them meant a store set up by an older hippo never got hooks added in
581
- // later releases (PreCompact, PostToolUseFailure) on a re-run of init.
582
- const blockPresent = fs.existsSync(targetPath)
583
- && fs.readFileSync(targetPath, 'utf8').includes(HOOK_MARKERS.start);
584
- // Only patch the agent-instructions file if it already exists.
585
- // Never create a new CLAUDE.md / AGENTS.md / etc. just because a sibling
586
- // marker file (.claude/settings.json, .codex, etc.) was detected — that
587
- // pollutes dirs the user didn't intend to configure.
588
- if (!blockPresent && fs.existsSync(targetPath)) {
589
- const block = `${HOOK_MARKERS.start}\n${hookDef.content}\n${HOOK_MARKERS.end}`;
590
- const existing = fs.readFileSync(targetPath, 'utf8');
591
- const sep = existing.endsWith('\n') ? '\n' : '\n\n';
592
- fs.writeFileSync(targetPath, existing + sep + block + '\n', 'utf8');
593
- installed.add(targetPath);
594
- console.log(` Auto-installed ${hook} hook in ${hookDef.file}`);
595
- }
596
- // The Codex session-capture wrapper swaps the codex launcher binary, so
597
- // init never installs it silently — it points at the explicit opt-in
598
- // command instead (issue #133).
599
- if (hook === 'codex' && !blockPresent && !isCodexWrapperInstalled()) {
598
+ }
599
+ const block = `${HOOK_MARKERS.start}\n${hookDef.content}\n${HOOK_MARKERS.end}`;
600
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
601
+ fs.writeFileSync(targetPath, existing + sep + block + '\n', 'utf8');
602
+ console.log(` Auto-installed ${hook} hook in ${hookDef.file}`);
603
+ }
604
+ }
605
+ /** The first hippo block in `text` and the agent whose current or shipped text it is; `owner` is undefined for an edited block. */
606
+ function hippoBlock(text) {
607
+ const at = text.indexOf(HOOK_MARKERS.start);
608
+ const start = at + HOOK_MARKERS.start.length;
609
+ const end = text.indexOf(HOOK_MARKERS.end, start);
610
+ if (at < 0 || end < 0)
611
+ return null;
612
+ // git autocrlf checks these files out with CRLF: match as LF, write back in the file's own ending.
613
+ const raw = text.slice(start, end);
614
+ const inner = raw.replace(/\r\n/g, '\n').trim();
615
+ const owner = Object.keys(HOOKS).find((k) => HOOKS[k].content === inner) ?? SHIPPED_HOOK_HASHES.get(createHash('sha256').update(inner).digest('hex'));
616
+ return { start, end, eol: raw.includes('\r\n') ? '\r\n' : '\n', inner, owner };
617
+ }
618
+ /** Swap an unedited block from an earlier hippo for the current one; an edited block stays, with a hint. */
619
+ function refreshShippedBlock(filePath, text, hook) {
620
+ const block = hippoBlock(text);
621
+ if (!block || (block.owner && HOOKS[block.owner].content === block.inner))
622
+ return;
623
+ const { start, end, eol, owner } = block;
624
+ const name = path.basename(filePath);
625
+ if (!owner) {
626
+ console.log(` Left the edited hippo block in ${name} as is; \`hippo hook install ${hook}\` replaces it.`);
627
+ return;
628
+ }
629
+ fs.writeFileSync(filePath, `${text.slice(0, start)}${eol}${HOOKS[owner].content.replace(/\n/g, eol)}${eol}${text.slice(end)}`, 'utf8');
630
+ console.log(` Refreshed the ${owner} hippo block in ${name}`);
631
+ }
632
+ /** Claude Code settings hooks and the OpenCode plugin, under the home directory; idempotent, so re-running init adds newer hooks. */
633
+ function installUserLevelHooks(agents, codexHint) {
634
+ for (const hook of agents) {
635
+ // The Codex capture wrapper swaps the codex launcher binary, so init only points at the opt-in (issue #133).
636
+ if (hook === 'codex' && codexHint && !isCodexWrapperInstalled()) {
600
637
  console.log(' Codex detected. To capture Codex sessions: hippo hook install codex');
601
638
  }
602
639
  // For Claude Code, also install SessionEnd+SessionStart entries in its
@@ -877,6 +914,7 @@ function cmdSupersede(hippoRoot, oldId, newContent, flags) {
877
914
  confidence: 'verified',
878
915
  tenantId: old.tenantId,
879
916
  scope: old.scope,
917
+ baseHalfLifeDays: loadConfig(hippoRoot).defaultHalfLifeDays,
880
918
  });
881
919
  // AT1: write the SUCCESSOR first. The rejection guard fires on the new
882
920
  // content — if it refuses, nothing has been mutated yet (the old ordering
@@ -2463,6 +2501,7 @@ function cmdTraceRecord(hippoRoot, flags) {
2463
2501
  trace_outcome: outcome,
2464
2502
  source_session_id: sessionId,
2465
2503
  tenantId: resolveTenantId({}),
2504
+ baseHalfLifeDays: loadConfig(hippoRoot).defaultHalfLifeDays,
2466
2505
  });
2467
2506
  writeEntry(hippoRoot, entry);
2468
2507
  console.log(`Recorded trace ${entry.id} (outcome=${outcome}, ${steps.length} steps)`);
@@ -2485,7 +2524,7 @@ function cmdTrace(hippoRoot, id, flags) {
2485
2524
  }
2486
2525
  const now = evalNow();
2487
2526
  const strength = calculateStrength(entry, now);
2488
- const halfLife = deriveHalfLife(7, entry);
2527
+ const halfLife = entry.half_life_days;
2489
2528
  const rewardFactor = calculateRewardFactor(entry);
2490
2529
  const effHalfLife = halfLife * rewardFactor;
2491
2530
  const createdMs = new Date(entry.created).getTime();
@@ -2548,7 +2587,7 @@ function cmdTrace(hippoRoot, id, flags) {
2548
2587
  console.log(` now: ${fmt(strength, 3)}`);
2549
2588
  console.log(` in 30 days: ${fmt(projectedAt(30), 3)}`);
2550
2589
  console.log(` in 90 days: ${fmt(projectedAt(90), 3)}`);
2551
- console.log(` half-life: ${fmt(halfLife, 1)}d (base) x ${fmt(rewardFactor, 2)} reward = ${fmt(effHalfLife, 1)}d effective`);
2590
+ console.log(` half-life: ${fmt(halfLife, 1)}d (stored) x ${fmt(rewardFactor, 2)} reward = ${fmt(effHalfLife, 1)}d effective`);
2552
2591
  console.log();
2553
2592
  console.log(`Retrieval:`);
2554
2593
  console.log(` count: ${entry.retrieval_count}`);
@@ -2628,6 +2667,7 @@ export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2628
2667
  if (memoryDirs.length === 0)
2629
2668
  return 0;
2630
2669
  const existing = loadAllEntries(hippoRoot, resolveTenantId({}));
2670
+ const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
2631
2671
  let imported = 0;
2632
2672
  let skippedSecret = 0;
2633
2673
  // AT1 (plan §3 containment): a rejection guard refusal is per-VALUE — one
@@ -2671,6 +2711,7 @@ export function learnFromMemoryMd(hippoRoot, homeDir = os.homedir()) {
2671
2711
  source: `claude-memory:${file}`,
2672
2712
  confidence: 'observed',
2673
2713
  tenantId: resolveTenantId({}),
2714
+ baseHalfLifeDays,
2674
2715
  });
2675
2716
  try {
2676
2717
  writeEntry(hippoRoot, entry);
@@ -6839,7 +6880,8 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6839
6880
  }
6840
6881
  // Same patterns as MCP hippo_learn: config.gitLearnPatterns (whose default
6841
6882
  // equals extractLessons' built-in list) so a custom list applies everywhere.
6842
- const parsedLessons = extractLessons(gitLog, loadConfig(hippoRoot).gitLearnPatterns);
6883
+ const config = loadConfig(hippoRoot);
6884
+ const parsedLessons = extractLessons(gitLog, config.gitLearnPatterns);
6843
6885
  if (parsedLessons.length === 0) {
6844
6886
  console.log(`${prefix}No fix/revert/bug commits found in the specified period.`);
6845
6887
  return { added: 0, skipped: 0, lowInfo: 0 };
@@ -6905,6 +6947,7 @@ function learnFromRepo(hippoRoot, repoPath, days, label) {
6905
6947
  confidence: 'observed',
6906
6948
  schema_fit: schemaFitVal,
6907
6949
  tenantId: resolveTenantId({}),
6950
+ baseHalfLifeDays: config.defaultHalfLifeDays,
6908
6951
  });
6909
6952
  // Auto-tag with path context from the repo being learned
6910
6953
  const learnPathTags = extractPathTags(repoPath);
@@ -7180,25 +7223,14 @@ run it if the hook is not installed:
7180
7223
  hippo context --auto --budget 1500
7181
7224
  \`\`\`
7182
7225
 
7183
- When you learn something important:
7184
- \`\`\`bash
7185
- hippo remember "<lesson>"
7186
- \`\`\`
7187
-
7188
- When you hit an error or discover a gotcha:
7226
+ When you find out why something failed, record it right then, while you
7227
+ work, never as a closing step:
7189
7228
  \`\`\`bash
7190
7229
  hippo remember "<what went wrong and why>" --error
7191
7230
  \`\`\`
7192
7231
 
7193
- After completing work successfully:
7194
- \`\`\`bash
7195
- hippo outcome --good
7196
- \`\`\`
7197
-
7198
- When the user ends the session, capture a brief summary:
7199
- \`\`\`bash
7200
- hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
7201
- \`\`\`
7232
+ The installed hooks store failed tool calls and capture the session when it
7233
+ ends, so there is nothing to run before you finish.
7202
7234
  `.trim(),
7203
7235
  },
7204
7236
  'codex': {
@@ -7213,34 +7245,41 @@ hippo context --auto --budget 1500
7213
7245
  \`\`\`
7214
7246
  Read the output before writing any code.
7215
7247
 
7216
- On errors or unexpected behaviour:
7248
+ On errors or unexpected behaviour, record it right then, while you work,
7249
+ never as a closing step:
7217
7250
  \`\`\`bash
7218
7251
  hippo remember "<description of what went wrong>" --error
7219
7252
  \`\`\`
7220
7253
 
7221
- On task completion:
7222
- \`\`\`bash
7223
- hippo outcome --good
7224
- \`\`\`
7225
-
7226
7254
  When Hippo's Codex wrapper is installed, session-end capture runs automatically.
7227
7255
  If the wrapper is not installed, capture a brief summary manually:
7228
7256
  \`\`\`bash
7229
- hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
7257
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7230
7258
  \`\`\`
7231
7259
  `.trim(),
7232
7260
  },
7233
7261
  'cursor': {
7234
- file: '.cursorrules',
7262
+ file: 'AGENTS.md',
7235
7263
  description: 'Cursor',
7236
7264
  content: `
7237
- # Project Memory (Hippo)
7238
- # Before each task, load context:
7239
- # hippo context --auto --budget 1500
7240
- # After errors:
7241
- # hippo remember "<error description>" --error
7242
- # After completing:
7243
- # hippo outcome --good
7265
+ ## Project Memory (Hippo)
7266
+
7267
+ At the start of every task, run:
7268
+ \`\`\`bash
7269
+ hippo context --auto --budget 1500
7270
+ \`\`\`
7271
+ Read the output before writing any code.
7272
+
7273
+ On errors or unexpected behaviour, record it right then, while you work,
7274
+ never as a closing step:
7275
+ \`\`\`bash
7276
+ hippo remember "<description of what went wrong>" --error
7277
+ \`\`\`
7278
+
7279
+ When ending a session, capture a brief summary:
7280
+ \`\`\`bash
7281
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7282
+ \`\`\`
7244
7283
  `.trim(),
7245
7284
  },
7246
7285
  'openclaw': {
@@ -7255,19 +7294,15 @@ hippo context --auto --budget 1500
7255
7294
  \`\`\`
7256
7295
  Read the output before writing any code.
7257
7296
 
7258
- On errors or unexpected behaviour:
7297
+ On errors or unexpected behaviour, record it right then, while you work,
7298
+ never as a closing step:
7259
7299
  \`\`\`bash
7260
7300
  hippo remember "<description of what went wrong>" --error
7261
7301
  \`\`\`
7262
7302
 
7263
- On task completion:
7264
- \`\`\`bash
7265
- hippo outcome --good
7266
- \`\`\`
7267
-
7268
7303
  When ending a session, capture a brief summary:
7269
7304
  \`\`\`bash
7270
- hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
7305
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7271
7306
  \`\`\`
7272
7307
  `.trim(),
7273
7308
  },
@@ -7283,7 +7318,8 @@ hippo context --auto --budget 1500
7283
7318
  \`\`\`
7284
7319
  Read the output before writing any code.
7285
7320
 
7286
- When you learn something important or hit an error:
7321
+ When you learn a non-obvious lesson or hit an error, record it right then,
7322
+ while you work, never as a closing step:
7287
7323
  \`\`\`bash
7288
7324
  hippo remember "<lesson>" --error
7289
7325
  \`\`\`
@@ -7293,14 +7329,9 @@ When stuck or repeating yourself, check if this happened before:
7293
7329
  hippo recall "<what's going wrong>" --budget 2000
7294
7330
  \`\`\`
7295
7331
 
7296
- On task completion:
7297
- \`\`\`bash
7298
- hippo outcome --good
7299
- \`\`\`
7300
-
7301
7332
  When ending a session, capture a brief summary:
7302
7333
  \`\`\`bash
7303
- hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
7334
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7304
7335
  \`\`\`
7305
7336
  `.trim(),
7306
7337
  },
@@ -7316,28 +7347,44 @@ hippo context --auto --budget 1500
7316
7347
  \`\`\`
7317
7348
  Read the output before writing any code.
7318
7349
 
7319
- On errors or unexpected behaviour:
7350
+ On errors or unexpected behaviour, record it right then, while you work,
7351
+ never as a closing step:
7320
7352
  \`\`\`bash
7321
7353
  hippo remember "<description of what went wrong>" --error
7322
7354
  \`\`\`
7323
7355
 
7324
- On task completion:
7325
- \`\`\`bash
7326
- hippo outcome --good
7327
- \`\`\`
7328
-
7329
7356
  When ending a session, capture a brief summary:
7330
7357
  \`\`\`bash
7331
- hippo capture --stdin <<< '<decisions, errors, lessons — 2-5 bullets>'
7358
+ hippo capture --stdin <<< '<decisions, errors, lessons: 2-5 bullets>'
7332
7359
  \`\`\`
7333
7360
 
7334
7361
  For full integration, copy the hippo-memory Pi extension to \`~/.pi/agent/extensions/hippo-memory/\`.
7335
7362
  `.trim(),
7336
7363
  },
7337
7364
  };
7365
+ // sha256 of each trimmed block an earlier hippo wrote, so init refreshes only blocks nobody edited. Add the old hash when a block changes.
7366
+ const SHIPPED_HOOK_HASHES = new Map([
7367
+ ['c04e48f2896a4fee9ae98f8f832e2d26a3910269df3beb5bcd6baee3cd9db68e', 'claude-code'],
7368
+ ['e6b12bd8983c032e5ca8e95a97aeff4178a5a05026d10acad5b2e1b25d5656dd', 'claude-code'],
7369
+ ['4c64e11d3e5be68fa547c9248d7553feb645a02f7cf13ba02f7275e1854baf44', 'claude-code'],
7370
+ ['293bd319bbc86225a0ee027490a3322a0336257f5832f7fade65e4ffb2530654', 'claude-code'],
7371
+ ['15abcece9712279fb4721f7a8f0ba117457400278977beb5cf5b5d7ba49f7b1a', 'codex'],
7372
+ ['0c81a6b2c21473313001f624b80ea870e661aecbfda9bfe8503febc0d5f34533', 'codex'],
7373
+ ['88e45358aba4f17912f113221c991dc758275991335d1daa4aa1974a69c46769', 'codex'],
7374
+ ['a1415ecda9b2f8f317c233738e4a5ac16e6b2cc385a017c0c8ecfbfacbcab6a3', 'cursor'],
7375
+ ['a38c428bbdfc14ec50f6f7b9183785170a4eae1ce9cde60257cca6efc7206b3a', 'cursor'],
7376
+ ['40524c3bd5a2eb04036567cc761451961d950995768bccd93a9900b0f75eafea', 'openclaw'],
7377
+ ['7b3518e8c0feaa7b8b454cde7743f7598ad14cd9979e1680d0954484e2464aae', 'openclaw'],
7378
+ ['4601c67c31f41cd5b1324cfccdb1afc66872b7fb0bc1e7c5789ecabb1f6bd942', 'opencode'],
7379
+ ['90d9e21d8d1ecbe99a0fc7b7f2d9f8af7b5315a6b4b0203df4f7a9bdc0699b98', 'opencode'],
7380
+ ['8b8f5986d7f7ed15f06e68720d8913c3cab23d94366b411935ca2bbaa334553b', 'pi'],
7381
+ ['37767b355e18beac726b05b9e2b898dab8c6135fd7b98f3aa52edc734d5dd283', 'pi'],
7382
+ ]);
7338
7383
  function cmdHook(args, flags) {
7339
7384
  const subcommand = args[0];
7340
7385
  const target = args[1];
7386
+ // Cursor has no init row, so the AGENTS.md block is usually another agent's, and Cursor reads that one as it is.
7387
+ const othersBlock = (text) => target === 'cursor' && hippoBlock(text)?.owner !== 'cursor';
7341
7388
  if (subcommand === 'list') {
7342
7389
  console.log('Available hooks:\n');
7343
7390
  for (const [name, hook] of Object.entries(HOOKS)) {
@@ -7359,7 +7406,10 @@ function cmdHook(args, flags) {
7359
7406
  let agentFileTouched = false;
7360
7407
  if (fs.existsSync(filepath)) {
7361
7408
  const existing = fs.readFileSync(filepath, 'utf8');
7362
- if (existing.includes(HOOK_MARKERS.start)) {
7409
+ if (existing.includes(HOOK_MARKERS.start) && othersBlock(existing)) {
7410
+ console.log(`${hook.file} already has a hippo block, which Cursor reads; left it as is.`);
7411
+ }
7412
+ else if (existing.includes(HOOK_MARKERS.start)) {
7363
7413
  const re = new RegExp(`${escapeRegex(HOOK_MARKERS.start)}[\\s\\S]*?${escapeRegex(HOOK_MARKERS.end)}`, 'g');
7364
7414
  const updated = existing.replace(re, block);
7365
7415
  fs.writeFileSync(filepath, updated, 'utf8');
@@ -7447,10 +7497,13 @@ function cmdHook(args, flags) {
7447
7497
  const filepath = path.resolve(process.cwd(), hook.file);
7448
7498
  if (fs.existsSync(filepath)) {
7449
7499
  const existing = fs.readFileSync(filepath, 'utf8');
7450
- if (existing.includes(HOOK_MARKERS.start)) {
7451
- const re = new RegExp(`\\n?${escapeRegex(HOOK_MARKERS.start)}[\\s\\S]*?${escapeRegex(HOOK_MARKERS.end)}\\n?`, 'g');
7452
- const cleaned = existing.replace(re, '\n').replace(/\n{3,}/g, '\n\n').trim();
7453
- fs.writeFileSync(filepath, cleaned + '\n', 'utf8');
7500
+ if (existing.includes(HOOK_MARKERS.start) && othersBlock(existing)) {
7501
+ const owner = hippoBlock(existing)?.owner;
7502
+ const whose = owner ? `hippo wrote it for ${owner}` : 'it has been edited, so hippo cannot tell whose it is';
7503
+ console.log(`Left the hippo block in ${hook.file}: ${whose}. Delete it by hand if no agent needs it.`);
7504
+ }
7505
+ else if (existing.includes(HOOK_MARKERS.start)) {
7506
+ fs.writeFileSync(filepath, withoutHookBlock(existing) + '\n', 'utf8');
7454
7507
  console.log(`Removed Hippo hook from ${hook.file}`);
7455
7508
  }
7456
7509
  else {
@@ -7479,11 +7532,28 @@ function cmdHook(args, flags) {
7479
7532
  console.log('Removed Codex wrapper integration');
7480
7533
  }
7481
7534
  }
7535
+ else if (target === 'cursor') {
7536
+ // Older hippo wrote Cursor's block to .cursorrules, creating the file when it was missing.
7537
+ const legacy = path.resolve(process.cwd(), '.cursorrules');
7538
+ const old = fs.existsSync(legacy) ? fs.readFileSync(legacy, 'utf8') : '';
7539
+ if (old.includes(HOOK_MARKERS.start)) {
7540
+ const left = withoutHookBlock(old);
7541
+ if (left)
7542
+ fs.writeFileSync(legacy, left + '\n', 'utf8');
7543
+ else
7544
+ fs.unlinkSync(legacy);
7545
+ console.log(left ? 'Removed the old Hippo hook from .cursorrules' : 'Deleted .cursorrules, which held only the old Hippo hook');
7546
+ }
7547
+ }
7482
7548
  return;
7483
7549
  }
7484
7550
  console.error('Usage: hippo hook <install|uninstall|list> [target]');
7485
7551
  process.exit(1);
7486
7552
  }
7553
+ function withoutHookBlock(text) {
7554
+ const re = new RegExp(`\\n?${escapeRegex(HOOK_MARKERS.start)}[\\s\\S]*?${escapeRegex(HOOK_MARKERS.end)}\\n?`, 'g');
7555
+ return text.replace(re, '\n').replace(/\n{3,}/g, '\n\n').trim();
7556
+ }
7487
7557
  function escapeRegex(s) {
7488
7558
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7489
7559
  }
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];
@@ -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;