hippo-memory 1.52.8 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +185 -101
  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 +53 -20
  41. package/dist/api.js +141 -97
  42. package/dist/audit.d.ts +2 -1
  43. package/dist/audit.js +68 -2
  44. package/dist/capture.d.ts +48 -22
  45. package/dist/capture.js +186 -161
  46. package/dist/cli.d.ts +0 -2
  47. package/dist/cli.js +750 -797
  48. package/dist/codex-patch.d.ts +12 -0
  49. package/dist/codex-patch.js +71 -0
  50. package/dist/compaction-items.d.ts +18 -0
  51. package/dist/compaction-items.js +60 -0
  52. package/dist/compaction-record.d.ts +94 -0
  53. package/dist/compaction-record.js +546 -0
  54. package/dist/config.d.ts +4 -1
  55. package/dist/config.js +13 -4
  56. package/dist/connectors/slack/types.d.ts +0 -1
  57. package/dist/consolidate.js +87 -34
  58. package/dist/context-render.d.ts +36 -0
  59. package/dist/context-render.js +154 -0
  60. package/dist/dag.js +3 -2
  61. package/dist/db.d.ts +5 -1
  62. package/dist/db.js +46 -14
  63. package/dist/dedupe.d.ts +6 -6
  64. package/dist/dedupe.js +10 -9
  65. package/dist/doctor.d.ts +1 -1
  66. package/dist/doctor.js +69 -4
  67. package/dist/dormant.d.ts +9 -3
  68. package/dist/dormant.js +26 -2
  69. package/dist/embedding-provider.d.ts +2 -1
  70. package/dist/embedding-provider.js +2 -1
  71. package/dist/embeddings.js +23 -3
  72. package/dist/extract.js +5 -1
  73. package/dist/forward-claim-detector.d.ts +1 -1
  74. package/dist/forward-claim-detector.js +1 -1
  75. package/dist/gated-write.d.ts +9 -0
  76. package/dist/gated-write.js +24 -0
  77. package/dist/graph-recall.d.ts +3 -1
  78. package/dist/graph-recall.js +5 -3
  79. package/dist/hooks.d.ts +18 -2
  80. package/dist/hooks.js +128 -32
  81. package/dist/importers.js +5 -12
  82. package/dist/judgment.d.ts +30 -0
  83. package/dist/judgment.js +122 -0
  84. package/dist/mcp/server.js +171 -210
  85. package/dist/memory.d.ts +19 -2
  86. package/dist/memory.js +35 -3
  87. package/dist/merged-row.d.ts +6 -0
  88. package/dist/merged-row.js +35 -0
  89. package/dist/multihop.d.ts +2 -1
  90. package/dist/multihop.js +7 -4
  91. package/dist/physics-state.d.ts +0 -4
  92. package/dist/physics-state.js +0 -6
  93. package/dist/predictions.d.ts +2 -17
  94. package/dist/predictions.js +2 -15
  95. package/dist/reject-flow.d.ts +7 -5
  96. package/dist/reject-flow.js +41 -12
  97. package/dist/salience.js +12 -5
  98. package/dist/same-text.d.ts +17 -0
  99. package/dist/same-text.js +38 -0
  100. package/dist/scheduler.d.ts +4 -0
  101. package/dist/scheduler.js +8 -0
  102. package/dist/search.d.ts +7 -0
  103. package/dist/search.js +16 -32
  104. package/dist/secret-detect.d.ts +2 -0
  105. package/dist/secret-detect.js +6 -0
  106. package/dist/server-detect.js +9 -33
  107. package/dist/server.js +6 -62
  108. package/dist/session-digest.d.ts +79 -0
  109. package/dist/session-digest.js +528 -0
  110. package/dist/shared.d.ts +10 -2
  111. package/dist/shared.js +44 -36
  112. package/dist/store.d.ts +9 -2
  113. package/dist/store.js +25 -2
  114. package/dist/token-ledger.d.ts +46 -8
  115. package/dist/token-ledger.js +140 -21
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/extensions/openclaw-plugin/README.md +4 -4
  119. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  120. package/extensions/openclaw-plugin/package.json +1 -1
  121. package/openclaw.plugin.json +2 -2
  122. package/package.json +2 -2
package/dist/capture.js CHANGED
@@ -11,17 +11,20 @@
11
11
  import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { createMemory, Layer } from './memory.js';
14
+ import { duplicateKey, storedTextKeys } from './same-text.js';
14
15
  import { isContentWorthStoring } from './audit.js';
15
- import { isInitialized, writeEntry, loadAllEntries, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, } from './store.js';
16
+ import { isInitialized, openStore, stampOriginProject, writeEntryMirrors, loadAllEntries, updateStats, saveActiveTaskSnapshot, loadActiveTaskSnapshot, } from './store.js';
17
+ import { gatedWrite } from './gated-write.js';
18
+ import { PRE_COMPACT_INSTRUCTION, compactionOrigin, parsePostCompactPayload, postCompactLine, recordCompactionStart, recordSnapshotSaved, saveCompaction, replayCompactionsAt, COMPACTION_DB_WAIT_MS, } from './compaction-record.js';
16
19
  import { getGlobalRoot, initGlobal } from './shared.js';
17
20
  import { embedMemory } from './embeddings.js';
18
- import { isEmbeddingConfigured } from './embedding-provider.js';
19
21
  import { resolveTenantId } from './tenant.js';
20
22
  import { defaultPreCompactLogPath } from './hooks.js';
21
- import { redactSecretsStrict } from './secret-detect.js';
23
+ import { maskEmails, redactSecretsStrict } from './secret-detect.js';
22
24
  import { RejectedValueError, checkRejectionGuard } from './rejection.js';
23
25
  import { openHippoDb, closeHippoDb } from './db.js';
24
26
  import { loadConfig } from './config.js';
27
+ import { classifyOriginProject } from './project-identity.js';
25
28
  // Sentence-level patterns
26
29
  //
27
30
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -50,7 +53,7 @@ const DECISION_PATTERNS = [
50
53
  /(decision:\s*)(.{1,500})/i,
51
54
  ];
52
55
  const RULE_PATTERNS = [
53
- /((?:never|always|must(?:\s+not)?|do(?:n't| not)\s+ever)\s+)(.{1,500})/i,
56
+ /\b((?:never|always|must(?:\s+not)?|do(?:n't| not)\s+ever)\s+)(.{1,500})/i,
54
57
  /((?:the rule is|rule:)\s*)(.{1,500})/i,
55
58
  /((?:important|critical|remember):\s*)(.{1,500})/i,
56
59
  /((?:make sure|ensure)\s+(?:to\s+)?)(.{1,500})/i,
@@ -77,7 +80,7 @@ const SPEC_HEADING_PATTERNS = [
77
80
  // ---------------------------------------------------------------------------
78
81
  // Extraction engine
79
82
  // ---------------------------------------------------------------------------
80
- function splitSentences(text) {
83
+ export function splitSentences(text) {
81
84
  // Split on sentence boundaries, keeping reasonable chunks
82
85
  return text
83
86
  .split(/(?<=[.!?])\s+|\n/)
@@ -300,6 +303,21 @@ function cleanExtract(raw) {
300
303
  }
301
304
  return content;
302
305
  }
306
+ /** The 500-char write gate minus the 200-char bound counted from the keyword. */
307
+ const RULE_LEAD_CAP = 300;
308
+ /** A modal rule keeps its subject ("We must never ..."); null keeps the keyword-start form when the lead is long or the keyword sits inside a bracket. */
309
+ function ruleLead(sentence, keywordStart, contentStart) {
310
+ if (keywordStart < 0 || contentStart < 0 || contentStart > RULE_LEAD_CAP)
311
+ return null;
312
+ let depth = 0;
313
+ for (const ch of sentence.slice(0, keywordStart)) {
314
+ if (ch === '(' || ch === '[' || ch === '{')
315
+ depth++;
316
+ else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0)
317
+ depth--;
318
+ }
319
+ return depth > 0 ? null : sentence.slice(0, contentStart);
320
+ }
303
321
  function extractFromPatterns(sentence, patterns, category, tag) {
304
322
  for (const pat of patterns) {
305
323
  // `d` gives per-group offsets; see the contentStart comment below.
@@ -355,7 +373,11 @@ function extractFromPatterns(sentence, patterns, category, tag) {
355
373
  // capture, shipped in the previous commit. Codex P1, r6.
356
374
  const contentStart = match.indices?.[2]?.[0] ?? -1;
357
375
  const afterKeyword = contentStart >= 0 ? sentence.slice(contentStart) : (match[2] ?? match[0]);
358
- bounded = boundToClause(keywordPrefix + afterKeyword, keywordPrefix.length);
376
+ const keywordStart = match.indices?.[1]?.[0] ?? -1;
377
+ const lead = pat === RULE_PATTERNS[0] ? ruleLead(sentence, keywordStart, contentStart) : null;
378
+ bounded = lead !== null
379
+ ? boundToClause(lead + afterKeyword, lead.length, keywordStart + 200)
380
+ : boundToClause(keywordPrefix + afterKeyword, keywordPrefix.length);
359
381
  }
360
382
  const content = cleanExtract(bounded);
361
383
  if (content.length >= 8 && content.length <= 500) {
@@ -410,7 +432,7 @@ export function extractFromText(text) {
410
432
  const items = [];
411
433
  const seen = new Set();
412
434
  const addIfNew = (item) => {
413
- const norm = item.content.toLowerCase().replace(/\s+/g, ' ').trim();
435
+ const norm = duplicateKey(item.content);
414
436
  if (seen.has(norm))
415
437
  return;
416
438
  if (!isContentWorthStoring(item.content))
@@ -449,26 +471,6 @@ export function extractFromText(text) {
449
471
  }
450
472
  return items;
451
473
  }
452
- // ---------------------------------------------------------------------------
453
- // Normalisation for deduplication (mirrors import.ts)
454
- // ---------------------------------------------------------------------------
455
- function normalise(text) {
456
- return text
457
- .toLowerCase()
458
- .replace(/[^\w\s]/g, '')
459
- .replace(/\s+/g, ' ')
460
- .trim();
461
- }
462
- function isDuplicate(content, existing) {
463
- const norm = normalise(content);
464
- if (!norm)
465
- return true;
466
- for (const e of existing) {
467
- if (normalise(e.content) === norm)
468
- return true;
469
- }
470
- return false;
471
- }
472
474
  /**
473
475
  * Runtime shape guards used throughout this file wherever a value arrives
474
476
  * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
@@ -480,14 +482,14 @@ function isDuplicate(content, existing) {
480
482
  * anything `JSON.parse` can produce (the only divergence is boxed
481
483
  * primitives, which JSON.parse never yields).
482
484
  */
483
- function isStringValue(value) {
485
+ export function isStringValue(value) {
484
486
  return String(value) === value;
485
487
  }
486
- function isObjectLike(value) {
488
+ export function isObjectLike(value) {
487
489
  return value !== null && value instanceof Object;
488
490
  }
489
491
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
490
- function errorMessage(cause) {
492
+ export function errorMessage(cause) {
491
493
  return cause instanceof Error ? cause.message : String(cause);
492
494
  }
493
495
  /**
@@ -506,10 +508,29 @@ function isNonHumanUserLine(entry, content) {
506
508
  const head = content.trimStart();
507
509
  return CLAUDE_CODE_COMMAND_PREFIXES.some((p) => head.startsWith(p));
508
510
  }
509
- export function summariseTranscript(jsonl) {
511
+ const NON_HUMAN_BLOCK_PREFIXES = ['<ide_', '[Request interrupted by user'];
512
+ /** The human's words on a Claude Code user line, '' when none; VS Code stores prompts as text blocks, beside its own open-file, selection and interrupt blocks. */
513
+ function humanUserText(entry, message) {
514
+ const content = message.content;
515
+ let text = '';
516
+ if (isStringValue(content)) {
517
+ text = content;
518
+ }
519
+ else if (Array.isArray(content) && !content.some((b) => isObjectLike(b) && 'type' in b && b.type === 'tool_result')) {
520
+ const parts = [];
521
+ for (const block of content) {
522
+ const blockText = isObjectLike(block) && 'type' in block && block.type === 'text' && 'text' in block ? block.text : undefined;
523
+ if (isStringValue(blockText) && !NON_HUMAN_BLOCK_PREFIXES.some((p) => blockText.trimStart().startsWith(p)))
524
+ parts.push(blockText);
525
+ }
526
+ text = parts.join('\n');
527
+ }
528
+ text = text.trim();
529
+ return text && !isNonHumanUserLine(entry, text) ? text : '';
530
+ }
531
+ export function collectSessionTurns(jsonl, visit) {
510
532
  const lines = jsonl.split('\n').filter((l) => l.trim());
511
- const userMessages = [];
512
- const assistantTexts = [];
533
+ const turns = [];
513
534
  for (const line of lines) {
514
535
  let entry;
515
536
  try {
@@ -518,19 +539,18 @@ export function summariseTranscript(jsonl) {
518
539
  catch {
519
540
  continue;
520
541
  }
521
- if (!isObjectLike(entry))
542
+ if (!isObjectLike(entry) || !('type' in entry))
522
543
  continue;
523
- if (('type' in entry) && (entry.type === 'user' || entry.type === 'assistant')) {
544
+ visit?.(entry);
545
+ if (entry.type === 'user' || entry.type === 'assistant') {
524
546
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
525
547
  if (!message)
526
548
  continue;
527
549
  const content = 'content' in message ? message.content : undefined;
528
550
  if (entry.type === 'user') {
529
- // Plain text user messages only (skip tool_result arrays), and only
530
- // ones the human wrote (see isNonHumanUserLine).
531
- if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content)) {
532
- userMessages.push(content.trim());
533
- }
551
+ const text = humanUserText(entry, message);
552
+ if (text)
553
+ turns.push({ role: 'user', text });
534
554
  }
535
555
  else if (Array.isArray(content)) {
536
556
  // Keep assistant text blocks; drop thinking + tool_use
@@ -544,13 +564,13 @@ export function summariseTranscript(jsonl) {
544
564
  }
545
565
  }
546
566
  if (chunks.length > 0) {
547
- assistantTexts.push(chunks.join('\n'));
567
+ turns.push({ role: 'assistant', text: chunks.join('\n') });
548
568
  }
549
569
  }
550
570
  continue;
551
571
  }
552
572
  // Codex rollout transcript shape: response_item -> payload.message
553
- if ('type' in entry && entry.type === 'response_item') {
573
+ if (entry.type === 'response_item') {
554
574
  const payload = 'payload' in entry && isObjectLike(entry.payload) ? entry.payload : undefined;
555
575
  if (!payload || !('type' in payload) || payload.type !== 'message')
556
576
  continue;
@@ -574,11 +594,19 @@ export function summariseTranscript(jsonl) {
574
594
  if (chunks.length === 0)
575
595
  continue;
576
596
  if (role === 'user')
577
- userMessages.push(chunks.join('\n'));
597
+ turns.push({ role: 'user', text: chunks.join('\n') });
578
598
  if (role === 'assistant')
579
- assistantTexts.push(chunks.join('\n'));
599
+ turns.push({ role: 'assistant', text: chunks.join('\n') });
580
600
  }
581
601
  }
602
+ return turns;
603
+ }
604
+ export function summariseTranscript(jsonl) {
605
+ return summariseSessionTurns(collectSessionTurns(jsonl));
606
+ }
607
+ function summariseSessionTurns(turns) {
608
+ const userMessages = turns.filter((t) => t.role === 'user').map((t) => t.text);
609
+ const assistantTexts = turns.filter((t) => t.role === 'assistant').map((t) => t.text);
582
610
  if (userMessages.length === 0 && assistantTexts.length === 0)
583
611
  return '';
584
612
  // Keep the tail: last ~20 user turns and last ~10 assistant replies.
@@ -601,11 +629,11 @@ export function summariseTranscript(jsonl) {
601
629
  * Priority, where the first source present is the only one tried:
602
630
  * 1. Explicit `transcriptPath` option (from `--transcript <path>`)
603
631
  * 2. Stdin JSON payload (Claude Code / OpenCode SessionEnd hook shape)
604
- * 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only on a proven manual run (no path, no stdin text, no timed-out read), because this scan spans every project on the box
632
+ * 3. Most recent `.jsonl` under `~/.claude/projects/<any>/`, only when the caller passes `mayScan` (only the caller knows it is not a hook) and there is no path and no stdin text, because this scan spans every project on the box
605
633
  *
606
634
  * Returns null when nothing resolves, a named transcript or payload whose file is missing included. Never throws.
607
635
  */
608
- export function resolveLastSessionTranscript(explicit, stdinText, stdinTimedOut = false) {
636
+ export function resolveLastSessionTranscript(explicit, stdinText, opts) {
609
637
  if (explicit)
610
638
  return fs.existsSync(explicit) ? explicit : null;
611
639
  if (stdinText && stdinText.trim() !== '') {
@@ -622,7 +650,7 @@ export function resolveLastSessionTranscript(explicit, stdinText, stdinTimedOut
622
650
  }
623
651
  return null;
624
652
  }
625
- if (stdinTimedOut)
653
+ if (!opts.mayScan)
626
654
  return null;
627
655
  const home = process.env.HOME || process.env.USERPROFILE;
628
656
  if (!home)
@@ -760,13 +788,16 @@ function cmdCaptureCore(hippoRoot, options) {
760
788
  break;
761
789
  }
762
790
  case 'last-session': {
763
- const resolved = resolveLastSessionTranscript(options.transcriptPath, options.stdinText, options.stdinTimedOut);
764
- if (!resolved) {
765
- console.log('No transcript found. Pass --transcript <path> or run from a SessionEnd hook.');
766
- return;
791
+ let turns = options.sessionTurns;
792
+ if (!turns) {
793
+ const resolved = resolveLastSessionTranscript(options.transcriptPath, options.stdinText, { mayScan: !options.stdinTimedOut });
794
+ if (!resolved) {
795
+ console.log('No transcript found. Pass --transcript <path> or run from a SessionEnd hook.');
796
+ return;
797
+ }
798
+ turns = collectSessionTurns(fs.readFileSync(resolved, 'utf8'));
767
799
  }
768
- const jsonl = fs.readFileSync(resolved, 'utf8');
769
- text = summariseTranscript(jsonl);
800
+ text = summariseSessionTurns(turns);
770
801
  if (!text) {
771
802
  console.log('Transcript had no user/assistant messages to summarise.');
772
803
  return;
@@ -778,16 +809,19 @@ function cmdCaptureCore(hippoRoot, options) {
778
809
  console.log('No text to capture from.');
779
810
  return;
780
811
  }
781
- // Extract items
782
- const extracted = extractFromText(text);
812
+ // Scrub once here, as the snapshot fields are: every source can carry a pasted token (AGENTS.md: no secrets in memories).
813
+ const extracted = extractFromText(maskEmails(redactSecretsStrict(text)));
783
814
  if (extracted.length === 0) {
784
815
  console.log('No actionable items found in the input.');
785
816
  return;
786
817
  }
787
- // Load existing for dedup. L9: when options.tenantId is set on a non-global
788
- // capture, scope the dedup read so tenant A's captures don't get suppressed
789
- // by tenant B's existing content. Undefined preserves host-wide behaviour.
790
- const existing = loadAllEntries(targetRoot, useGlobal ? undefined : options.tenantId);
818
+ // Dedup only against rows this capture's reader sees: another tenant's rows (L9), or another
819
+ // project's, are hidden from it, so they must not stop its own copy.
820
+ const stored = loadAllEntries(targetRoot, useGlobal ? undefined : options.tenantId);
821
+ const origin = options.originProject;
822
+ const keys = storedTextKeys(origin === undefined
823
+ ? stored
824
+ : stored.filter((e) => classifyOriginProject(e.origin_project, origin) !== 'cross-project'));
791
825
  let captured = 0;
792
826
  let skipped = 0;
793
827
  let rejected = 0;
@@ -800,9 +834,10 @@ function cmdCaptureCore(hippoRoot, options) {
800
834
  // once, run the same checkRejectionGuard the real write path uses via
801
835
  // writeEntry, never write anything.
802
836
  const dryRunDb = options.dryRun ? openHippoDb(targetRoot) : null;
837
+ const writeDb = options.dryRun ? null : openStore(targetRoot);
803
838
  try {
804
839
  for (const item of extracted) {
805
- if (isDuplicate(item.content, existing)) {
840
+ if (keys.has(duplicateKey(item.content))) {
806
841
  skipped++;
807
842
  if (options.dryRun) {
808
843
  console.log(` [skip] (${item.category}) ${item.content.slice(0, 80)}`);
@@ -819,7 +854,7 @@ function cmdCaptureCore(hippoRoot, options) {
819
854
  // global: true, the global store is host-wide and tenant is irrelevant
820
855
  // (createMemory's default 'default' applies). When global: false,
821
856
  // options.tenantId scopes the write to the same tenant as the dedup.
822
- const entry = createMemory(item.content, {
857
+ const created = createMemory(item.content, {
823
858
  layer: Layer.Episodic,
824
859
  tags: item.tags,
825
860
  source: 'capture',
@@ -827,6 +862,7 @@ function cmdCaptureCore(hippoRoot, options) {
827
862
  tenantId: useGlobal ? undefined : options.tenantId,
828
863
  baseHalfLifeDays,
829
864
  });
865
+ const entry = options.originProject === undefined ? created : { ...created, origin_project: options.originProject };
830
866
  if (options.dryRun) {
831
867
  if (dryRunDb) {
832
868
  try {
@@ -843,24 +879,23 @@ function cmdCaptureCore(hippoRoot, options) {
843
879
  }
844
880
  console.log(` [capture] (${item.category}) ${item.content}`);
845
881
  }
846
- else {
882
+ else if (writeDb !== null) {
847
883
  // AT1 (plan §3 containment): one rejected item must not abort the
848
884
  // rest of this capture's items.
849
- try {
850
- writeEntry(targetRoot, entry);
885
+ const stamped = stampOriginProject(targetRoot, entry);
886
+ const outcome = gatedWrite(writeDb, targetRoot, stamped);
887
+ if (outcome === 'skipped:rejected') {
888
+ rejected++;
889
+ continue;
851
890
  }
852
- catch (err) {
853
- if (err instanceof RejectedValueError) {
854
- rejected++;
855
- continue;
856
- }
857
- throw err;
891
+ if (outcome !== 'written') {
892
+ skipped++;
893
+ continue;
858
894
  }
895
+ writeEntryMirrors(targetRoot, stamped);
859
896
  updateStats(targetRoot, { remembered: 1 });
860
- existing.push(entry); // within-batch dedup
861
- if (isEmbeddingConfigured(targetRoot)) {
862
- embedMemory(targetRoot, entry).catch(() => { });
863
- }
897
+ keys.add(duplicateKey(item.content)); // within-batch dedup
898
+ void embedMemory(targetRoot, entry);
864
899
  }
865
900
  captured++;
866
901
  }
@@ -868,6 +903,8 @@ function cmdCaptureCore(hippoRoot, options) {
868
903
  finally {
869
904
  if (dryRunDb)
870
905
  closeHippoDb(dryRunDb);
906
+ if (writeDb)
907
+ closeHippoDb(writeDb);
871
908
  }
872
909
  const prefix = options.dryRun ? '[dry-run] ' : '';
873
910
  const globalPrefix = useGlobal ? '[global] ' : '';
@@ -987,9 +1024,9 @@ function lastPlainUserMessage(jsonl) {
987
1024
  const message = 'message' in entry && isObjectLike(entry.message) ? entry.message : undefined;
988
1025
  if (!message)
989
1026
  continue;
990
- const content = 'content' in message ? message.content : undefined;
991
- if (isStringValue(content) && content.trim() && !isNonHumanUserLine(entry, content))
992
- return content.trim();
1027
+ const text = humanUserText(entry, message);
1028
+ if (text)
1029
+ return text;
993
1030
  }
994
1031
  return '';
995
1032
  }
@@ -1067,35 +1104,32 @@ function isReadableFile(filePath) {
1067
1104
  return false;
1068
1105
  }
1069
1106
  }
1070
- /** Line shown after compaction, or null when nothing was saved (Claude Code's own generic message is the fallback). */
1071
- export function preCompactMessage(report) {
1072
- if (!report.snapshotSaved)
1073
- return null;
1074
- return 'Hippo saved your task snapshot before compacting. The snapshot is restored into the new context.';
1075
- }
1076
- /**
1077
- * Where pre-compact leaves its report for `hippo post-compact`: next to the
1078
- * pre-compact log, so both hooks find it from the same `--log-file`.
1079
- */
1080
- export function preCompactReportPath(logFile) {
1081
- return path.join(path.dirname(logFile), 'pre-compact-last.json');
1107
+ /** PreCompact stdout is the summariser's instructions; sent before the snapshot work because a locked store can run the hook past its 30 s limit, and via writeSync because process.exit drops buffered pipe output. */
1108
+ function printPreCompactInstruction(logFile) {
1109
+ try {
1110
+ fs.writeSync(1, `${PRE_COMPACT_INSTRUCTION}\n`);
1111
+ }
1112
+ catch (err) {
1113
+ appendPreCompactLog(logFile, `instruction not printed: ${errorMessage(err)}`);
1114
+ }
1082
1115
  }
1083
1116
  /** A session's task, summary and next step from its transcript tail, secrets scrubbed and capped, '' where none; null with a logged reason when nothing is derivable. */
1084
1117
  export function transcriptWorkingState(transcriptPath, log) {
1085
- let tail;
1118
+ let tail = '';
1119
+ let rawTask = '';
1120
+ let rawNextStep = '';
1086
1121
  try {
1087
- // CX7 (codex round 2): a final record bigger than the window leaves an empty tail, as when a huge tool_result
1088
- // triggered compaction, so grow the window a bounded number of times until one complete line survives.
1089
- tail = readTranscriptTail(transcriptPath, PRE_COMPACT_TAIL_BYTES);
1090
- let prevCap = PRE_COMPACT_TAIL_BYTES;
1091
- for (const grownCap of [PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
1092
- if (tail.trim() !== '')
1122
+ // Compaction fires when a big tool_result lands (CX7), so the last human and assistant turns can sit
1123
+ // megabytes back: grow the window a bounded number of times until both turns are in it.
1124
+ const size = fs.statSync(transcriptPath).size;
1125
+ for (const cap of [PRE_COMPACT_TAIL_BYTES, PRE_COMPACT_TAIL_BYTES * 4, PRE_COMPACT_TAIL_BYTES * 16]) {
1126
+ if (cap > PRE_COMPACT_TAIL_BYTES)
1127
+ log(`tail window grown to ${cap} bytes (last ${rawTask ? 'assistant' : 'user'} turn is further back)`);
1128
+ tail = readTranscriptTail(transcriptPath, cap);
1129
+ rawTask = lastPlainUserMessage(tail);
1130
+ rawNextStep = lastAssistantTextBlock(tail);
1131
+ if ((rawTask && rawNextStep) || size <= cap)
1093
1132
  break;
1094
- if (fs.statSync(transcriptPath).size <= prevCap)
1095
- break; // already read the whole file
1096
- log(`tail window grown to ${grownCap} bytes (oversized final record)`);
1097
- tail = readTranscriptTail(transcriptPath, grownCap);
1098
- prevCap = grownCap;
1099
1133
  }
1100
1134
  }
1101
1135
  catch (err) {
@@ -1103,25 +1137,23 @@ export function transcriptWorkingState(transcriptPath, log) {
1103
1137
  return null;
1104
1138
  }
1105
1139
  const rawSummary = summariseTranscript(tail);
1106
- const rawTask = lastPlainUserMessage(tail);
1107
- const rawNextStep = lastAssistantTextBlock(tail);
1108
1140
  if (!rawTask.trim() && !rawSummary.trim() && !rawNextStep.trim()) {
1109
1141
  log('skip: empty summary');
1110
1142
  return null;
1111
1143
  }
1112
1144
  // X9: these fields skip the capture content gate and reach a prompt, so the strict scrub runs. The caps protect the
1113
1145
  // re-injection token budget and never split a surrogate pair (X2); `hippo snapshot save` stays uncapped.
1114
- const task = redactSecretsStrict(rawTask);
1115
- const summary = redactSecretsStrict(rawSummary);
1116
- const nextStep = redactSecretsStrict(rawNextStep);
1146
+ const task = maskEmails(redactSecretsStrict(rawTask));
1147
+ const summary = maskEmails(redactSecretsStrict(rawSummary));
1148
+ const nextStep = maskEmails(redactSecretsStrict(rawNextStep));
1117
1149
  return {
1118
1150
  task: task.trim() ? truncateCodePointSafe(task, PRE_COMPACT_TASK_CAP) : '',
1119
1151
  summary: summary.trim() ? truncateKeepNewest(summary, PRE_COMPACT_SUMMARY_CAP) : '',
1120
1152
  next_step: nextStep.trim() ? truncateCodePointSafe(nextStep, PRE_COMPACT_NEXT_STEP_CAP) : '',
1121
1153
  };
1122
1154
  }
1123
- /** Runs the PreCompact producer: saves a working-state snapshot into `report`. Never extracts memories; SessionEnd capture owns that. */
1124
- function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1155
+ /** Runs the PreCompact producer: records the compaction, asks the summariser for memories, saves a working-state snapshot. Never extracts memories itself; SessionEnd capture owns that. */
1156
+ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1125
1157
  // X3: the PreCompact hook fires in every Claude Code project, including
1126
1158
  // ones that never ran `hippo init`, so gate before any store-opening call
1127
1159
  // (saveActiveTaskSnapshot etc. call initStore, which would create one).
@@ -1145,6 +1177,8 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1145
1177
  const manualInvocation = !stdinText || stdinText.trim() === '';
1146
1178
  let sessionId = null;
1147
1179
  let payloadTranscriptPath = null;
1180
+ let payloadCwd = null;
1181
+ let payloadTrigger = null;
1148
1182
  if (!manualInvocation) {
1149
1183
  let payload;
1150
1184
  try {
@@ -1163,8 +1197,9 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1163
1197
  }
1164
1198
  if ('session_id' in payload && isStringValue(payload.session_id))
1165
1199
  sessionId = payload.session_id;
1166
- report.sessionId = sessionId;
1167
1200
  payloadTranscriptPath = payload.transcript_path;
1201
+ payloadCwd = 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null;
1202
+ payloadTrigger = 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null;
1168
1203
  }
1169
1204
  // X11: payload transcript_path must end .jsonl. No directory-containment
1170
1205
  // check is applied on top of this — CLAUDE_CONFIG_DIR can relocate the
@@ -1177,6 +1212,12 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1177
1212
  appendPreCompactLog(logFile, `skip: payload transcript_path is not a .jsonl file: ${payloadTranscriptPath}`);
1178
1213
  return;
1179
1214
  }
1215
+ // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
1216
+ let recordId = null;
1217
+ if (sessionId !== null && sessionId !== '') {
1218
+ recordId = recordCompactionStart(hippoRoot, { sessionId, trigger: payloadTrigger, cwd: payloadCwd, transcriptPath: payloadTranscriptPath }, (message) => appendPreCompactLog(logFile, message));
1219
+ printPreCompactInstruction(logFile);
1220
+ }
1180
1221
  // A payload transcript_path is EXCLUSIVE: never fall back to
1181
1222
  // newest-transcript auto-discovery when it's missing/unreadable. That
1182
1223
  // fallback would snapshot a DIFFERENT session's transcript under THIS
@@ -1194,7 +1235,7 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1194
1235
  }
1195
1236
  }
1196
1237
  else {
1197
- transcriptPath = resolveLastSessionTranscript(undefined, stdinText);
1238
+ transcriptPath = resolveLastSessionTranscript(undefined, stdinText, { mayScan: true });
1198
1239
  }
1199
1240
  if (!transcriptPath) {
1200
1241
  appendPreCompactLog(logFile, 'skip: no transcript resolved');
@@ -1245,7 +1286,8 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1245
1286
  session_id: sessionId,
1246
1287
  });
1247
1288
  appendPreCompactLog(logFile, 'snapshot saved');
1248
- report.snapshotSaved = true;
1289
+ if (recordId !== null)
1290
+ recordSnapshotSaved(hippoRoot, recordId, (message) => appendPreCompactLog(logFile, message));
1249
1291
  }
1250
1292
  catch (err) {
1251
1293
  appendPreCompactLog(logFile, `snapshot save failed: ${errorMessage(err)}`);
@@ -1261,68 +1303,51 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile, report) {
1261
1303
  */
1262
1304
  export async function cmdPreCompact(hippoRoot, options) {
1263
1305
  const logFile = options.logFile ?? defaultPreCompactLogPath();
1264
- const report = { snapshotSaved: false, sessionId: null };
1265
1306
  try {
1266
- runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile, report);
1307
+ runPreCompact(hippoRoot, options.stdinText, options.stdinTimedOut ?? false, logFile);
1267
1308
  }
1268
1309
  catch (err) {
1269
1310
  appendPreCompactLog(logFile, `pre-compact failed: ${errorMessage(err)}`);
1270
1311
  }
1271
- // Nothing goes to stdout: Claude Code passes PreCompact stdout to the
1272
- // summarising model as extra instructions. The PostCompact hook
1273
- // (`hippo post-compact`) tells the user instead, from this report.
1274
- if (report.snapshotSaved) {
1275
- try {
1276
- fs.writeFileSync(preCompactReportPath(logFile), JSON.stringify({ ...report, at: new Date().toISOString() }));
1277
- }
1278
- catch {
1279
- // Losing the message must never fail the hook.
1280
- }
1281
- }
1282
1312
  process.exit(0);
1283
1313
  }
1284
- /** How old a pre-compact report may be and still describe this compaction. */
1285
- const POST_COMPACT_REPORT_MAX_AGE_MS = 10 * 60_000;
1286
- /**
1287
- * The message for the PostCompact hook, from the report pre-compact left,
1288
- * or null when there is nothing to say. Consumes the report, so the message
1289
- * shows once. A report from another session, or an old one, is dropped.
1290
- * Never throws.
1291
- */
1292
- export function postCompactMessage(stdinText, logFile = defaultPreCompactLogPath(), now = new Date()) {
1293
- const reportFile = preCompactReportPath(logFile);
1294
- let raw;
1295
- try {
1296
- raw = fs.readFileSync(reportFile, 'utf8');
1297
- fs.rmSync(reportFile, { force: true });
1298
- }
1299
- catch {
1300
- return null;
1301
- }
1314
+ /** A PostCompact hook has 10 s in all; replay stops starting new records after this. */
1315
+ const POST_COMPACT_REPLAY_BUDGET_MS = 6000;
1316
+ /** PostCompact entry point: saves the summary and its items, replays earlier leftovers, returns the one line to show. Never throws, so the hook exits 0. */
1317
+ export function cmdPostCompact(hippoRoot, options) {
1318
+ const logFile = options.logFile ?? defaultPreCompactLogPath();
1319
+ const log = (message) => appendPreCompactLog(logFile, `post-compact: ${message}`);
1320
+ const deadline = Date.now() + POST_COMPACT_REPLAY_BUDGET_MS;
1302
1321
  try {
1303
- const report = JSON.parse(raw);
1304
- if (!isObjectLike(report))
1305
- return null;
1306
- const at = 'at' in report && isStringValue(report.at) ? Date.parse(report.at) : Number.NaN;
1307
- if (Number.isNaN(at) || now.getTime() - at > POST_COMPACT_REPORT_MAX_AGE_MS)
1322
+ if (!isInitialized(hippoRoot)) {
1323
+ log('skip: no hippo store');
1308
1324
  return null;
1309
- let payloadSession = null;
1310
- try {
1311
- const payload = JSON.parse((stdinText ?? '').trim() || 'null');
1312
- if (isObjectLike(payload) && 'session_id' in payload && isStringValue(payload.session_id))
1313
- payloadSession = payload.session_id;
1314
1325
  }
1315
- catch {
1316
- // No usable payload: fall back to the age check alone.
1326
+ const payload = parsePostCompactPayload(options.stdinText);
1327
+ let line = null;
1328
+ let storeBusy = false;
1329
+ if (payload === null) {
1330
+ log('skip: no PostCompact payload naming a session');
1317
1331
  }
1318
- const reportSession = 'sessionId' in report && isStringValue(report.sessionId) ? report.sessionId : null;
1319
- if (payloadSession !== null && reportSession !== null && payloadSession !== reportSession)
1320
- return null;
1321
- return preCompactMessage({
1322
- snapshotSaved: 'snapshotSaved' in report && report.snapshotSaved === true,
1323
- });
1332
+ else {
1333
+ const saved = saveCompaction(hippoRoot, payload, log);
1334
+ line = postCompactLine(saved);
1335
+ storeBusy = saved.deferred;
1336
+ if (!storeBusy && payload.transcriptPath !== null && options.afterSave) {
1337
+ try {
1338
+ options.afterSave(payload.transcriptPath, compactionOrigin(hippoRoot, payload.cwd), log);
1339
+ }
1340
+ catch (err) {
1341
+ log(`agent memory import failed: ${errorMessage(err)}`);
1342
+ }
1343
+ }
1344
+ }
1345
+ if (!storeBusy)
1346
+ replayCompactionsAt(hippoRoot, log, { busyWaitMs: COMPACTION_DB_WAIT_MS, deadline });
1347
+ return line;
1324
1348
  }
1325
- catch {
1349
+ catch (err) {
1350
+ log(`failed: ${errorMessage(err)}`);
1326
1351
  return null;
1327
1352
  }
1328
1353
  }
package/dist/cli.d.ts CHANGED
@@ -43,8 +43,6 @@ export declare function parseArgs(argv: string[]): {
43
43
  args: string[];
44
44
  flags: Record<string, string | boolean | string[]>;
45
45
  };
46
- /** Import new entries from the Claude Code auto memory of the store's project, the frontmatter .md files in ~/.claude/projects/<project>/memory/. */
47
- export declare function learnFromMemoryMd(hippoRoot: string, homeDir?: string): number;
48
46
  /**
49
47
  * Render an api.sleep result as console output, byte-identical to the
50
48
  * pre-extraction inline implementation in cmdSleepCore.