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
@@ -9,8 +9,10 @@
9
9
  import { evalNow, isRecallBoostAblated } from './ablation.js';
10
10
  import { Layer, calculateStrength, canAutoDelete, createMemory } from './memory.js';
11
11
  import { loadAllEntries, batchWriteAndDelete, appendConsolidationRun, replaceDetectedConflicts, loadSessionDecayContext, incrementSleepCount, findPromotableSessions, traceExistsForSession, listSessionEvents, } from './store.js';
12
- import { textOverlap, markRetrieved } from './search.js';
12
+ import { textOverlap, markRetrieved, tokenize } from './search.js';
13
13
  import { compareEntryIdentity } from './compare.js';
14
+ import { duplicateKey, mergedText } from './same-text.js';
15
+ import { successorAfterRetirement } from './merged-row.js';
14
16
  import { openHippoDb, closeHippoDb } from './db.js';
15
17
  import { rejectionDigest, findRejectedValue } from './rejection.js';
16
18
  import { countExpiredDormant, purgeExpiredDormant } from './dormant.js';
@@ -27,9 +29,12 @@ import { appendAuditEvent } from './audit.js';
27
29
  import { migrateDefaultHalfLife, LEGACY_TYPED_HALF_LIFE } from './half-life-migration.js';
28
30
  import { derivationScope, commonDerivationScope, derivationPartitionKey } from './recall-scope.js';
29
31
  import { isQuarantineScope } from './quarantine.js';
32
+ import { NO_MERGE_TAGS } from './shared.js';
30
33
  const DECAY_THRESHOLD = 0.05;
31
34
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
32
35
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
36
+ const MERGE_MAX_SOURCES = 5; // with MERGE_MAX_CHARS, keeps a merged row near 500 tokens, a third of the 1,500-token context budget
37
+ const MERGE_MAX_CHARS = 2000; // total source text; sources past either cap stay unmerged and keep their half-life
33
38
  // Half-life scale for merged source episodics. Demotion must go through
34
39
  // half_life_days: calculateStrength() recomputes live strength from
35
40
  // last_retrieved/half_life and never reads the stored strength field, so a
@@ -61,6 +66,9 @@ const CONFLICT_STOPWORDS = new Set([
61
66
  'more', 'most', 'less', 'least', 'other', 'such', 'same', 'new', 'old', 'one', 'two',
62
67
  ]);
63
68
  const REPLAY_COUNT_DEFAULT = 5;
69
+ function keptAsWritten(entry) {
70
+ return entry.tags.some((tag) => NO_MERGE_TAGS.has(tag));
71
+ }
64
72
  function isJsonString(value) {
65
73
  return typeof value === 'string';
66
74
  }
@@ -145,7 +153,7 @@ export async function consolidate(hippoRoot, options = {}) {
145
153
  for (const e of all)
146
154
  e.half_life_days = halfLife.halfLives.get(e.id) ?? e.half_life_days;
147
155
  const backingObjects = memoriesBackingObjects(hippoRoot);
148
- // Retirable: auto-deletable (never pinned, never raw) and not backing a first-class object.
156
+ // Retirable: auto-deletable (never pinned, raw or kept for good) and not backing a first-class object.
149
157
  const retirable = (entry) => canAutoDelete(entry) && !backingObjects.has(entry.id);
150
158
  const snapshot = new Map(structuredClone(all).map((e) => [e.id, e]));
151
159
  // Load decay options from config + session context
@@ -166,7 +174,7 @@ export async function consolidate(hippoRoot, options = {}) {
166
174
  // so it stays where it is (stored strength refreshed) but sits out the
167
175
  // rest of this cycle the way a deleted row would. Anything else goes
168
176
  // dormant when config.dormant is on, and is deleted otherwise.
169
- // Only called for rows `retirable` allows (never pinned, never raw, never backing a first-class object).
177
+ // Only called for rows `retirable` allows (never pinned, raw, kept for good or backing a first-class object).
170
178
  const retireFaded = (entry, strength) => {
171
179
  const why = `(strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`;
172
180
  // A faded secret is deleted, never kept dormant: keeping it would hold a
@@ -503,7 +511,7 @@ export async function consolidate(hippoRoot, options = {}) {
503
511
  // 1.6. Batch extraction — extract facts from episodic memories missing them
504
512
  // -------------------------------------------------------------------------
505
513
  const extractedFromIds = new Set(survivors.filter((e) => e.extracted_from).map((e) => e.extracted_from));
506
- const extractionCandidates = survivors.filter((e) => e.layer === Layer.Episodic && !e.superseded_by && !extractedFromIds.has(e.id));
514
+ const extractionCandidates = survivors.filter((e) => e.layer === Layer.Episodic && !e.superseded_by && !extractedFromIds.has(e.id) && !keptAsWritten(e));
507
515
  result.extractionCandidates = extractionCandidates.length;
508
516
  // extraction.enabled=false is the opt-out for every LLM phase below, key or no key.
509
517
  const apiKey = config.extraction.enabled !== false ? (process.env.ANTHROPIC_API_KEY ?? '') : '';
@@ -674,10 +682,35 @@ export async function consolidate(hippoRoot, options = {}) {
674
682
  result.details.push(` ⚠️ physics simulation skipped: ${error instanceof Error ? error.message : 'unknown error'}`);
675
683
  }
676
684
  }
685
+ const byId = new Map(all.map((e) => [e.id, e]));
686
+ const rejectedIn = (tenantId) => (text) => {
687
+ const db = getConsolidateDb();
688
+ return db !== null && findRejectedValue(db, tenantId, rejectionDigest(text)) !== null;
689
+ };
690
+ for (let i = survivors.length - 1; i >= 0; i--) {
691
+ const row = survivors[i];
692
+ const successor = retirable(row) ? successorAfterRetirement(row, byId, rejectedIn(row.tenantId)) : undefined;
693
+ if (successor === undefined)
694
+ continue;
695
+ result.details.push(` ✂️ ${row.id} held a retired text${successor ? `, ${successor.id} holds the rest` : ''}`);
696
+ if (dryRun)
697
+ continue;
698
+ pendingDeletes.push(row.id);
699
+ if (successor) {
700
+ pendingWrites.push(successor);
701
+ survivors[i] = successor;
702
+ }
703
+ else {
704
+ survivors.splice(i, 1);
705
+ }
706
+ }
677
707
  // -------------------------------------------------------------------------
678
708
  // 3. Merge pass - episodic entries only
679
709
  // -------------------------------------------------------------------------
680
- const mergeCandidates = survivors.filter((e) => e.layer === Layer.Episodic && !e.superseded_by && !e.tags.includes('extracted'));
710
+ const alreadyMergedIds = new Set(survivors.flatMap((e) => e.parents));
711
+ const mergeCandidates = survivors.filter((e) => e.layer === Layer.Episodic && !e.superseded_by && !keptAsWritten(e) && !alreadyMergedIds.has(e.id)
712
+ && !e.pinned // a pin merged with a look-alike would read as one of two values
713
+ && tokenize(e.content).length > 0);
681
714
  const used = new Set();
682
715
  // T1 fix (2026-08-15 hardening pass): partition by tenantId BEFORE the
683
716
  // overlap loop so a cluster can never span tenants. Previously textOverlap
@@ -708,17 +741,25 @@ export async function consolidate(hippoRoot, options = {}) {
708
741
  const mergeTenant = tenantCandidates[0].tenantId;
709
742
  const mergeScope = derivationScope(tenantCandidates[0].scope);
710
743
  for (let i = 0; i < tenantCandidates.length; i++) {
711
- if (used.has(tenantCandidates[i].id))
744
+ if (used.has(tenantCandidates[i].id) || tenantCandidates[i].content.length > MERGE_MAX_CHARS)
712
745
  continue;
713
- const cluster = [tenantCandidates[i]];
746
+ const related = [tenantCandidates[i]];
714
747
  for (let j = i + 1; j < tenantCandidates.length; j++) {
715
748
  if (used.has(tenantCandidates[j].id))
716
749
  continue;
717
750
  const overlap = textOverlap(tenantCandidates[i].content, tenantCandidates[j].content);
718
751
  if (overlap >= MERGE_OVERLAP_THRESHOLD) {
719
- cluster.push(tenantCandidates[j]);
752
+ related.push(tenantCandidates[j]);
720
753
  }
721
754
  }
755
+ const cluster = [];
756
+ let clusterChars = 0;
757
+ for (const e of related) {
758
+ if (cluster.length === MERGE_MAX_SOURCES || clusterChars + e.content.length > MERGE_MAX_CHARS)
759
+ continue;
760
+ cluster.push(e);
761
+ clusterChars += e.content.length;
762
+ }
722
763
  if (cluster.length < MERGE_MIN_CLUSTER)
723
764
  continue;
724
765
  // Create a semantic summary
@@ -734,17 +775,20 @@ export async function consolidate(hippoRoot, options = {}) {
734
775
  // always 'default'.
735
776
  let semantic = null;
736
777
  if (!dryRun) {
737
- semantic = createMemory(mergedContent, {
738
- layer: Layer.Semantic,
739
- tags: allTags,
740
- emotional_valence: maxValence,
741
- schema_fit: 0.7,
742
- source: 'consolidation',
743
- confidence: 'inferred',
744
- tenantId: mergeTenant,
745
- scope: mergeScope,
746
- baseHalfLifeDays: config.defaultHalfLifeDays,
747
- });
778
+ semantic = {
779
+ ...createMemory(mergedContent, {
780
+ layer: Layer.Semantic,
781
+ tags: allTags,
782
+ emotional_valence: maxValence,
783
+ schema_fit: 0.7,
784
+ source: 'consolidation',
785
+ confidence: 'inferred',
786
+ tenantId: mergeTenant,
787
+ scope: mergeScope,
788
+ baseHalfLifeDays: config.defaultHalfLifeDays,
789
+ }),
790
+ parents: cluster.map((e) => e.id),
791
+ };
748
792
  }
749
793
  // mergeContents is DETERMINISTIC CONCATENATION (not an LLM paraphrase)
750
794
  // — if a human rejected exactly this byte-identical rollup before, an
@@ -756,13 +800,17 @@ export async function consolidate(hippoRoot, options = {}) {
756
800
  // the tombstone is lifted.
757
801
  const consolidateDb = getConsolidateDb();
758
802
  if (consolidateDb && semantic) {
759
- const mergeDigest = rejectionDigest(semantic.content);
760
- const tombstone = findRejectedValue(consolidateDb, semantic.tenantId, mergeDigest);
803
+ const newDigest = rejectionDigest(semantic.content);
804
+ const oldDigest = rejectionDigest(legacyMergeContents(related)); // tombstones from older releases hold this format's digest
805
+ const newHit = findRejectedValue(consolidateDb, semantic.tenantId, newDigest);
806
+ const tombstone = newHit ?? findRejectedValue(consolidateDb, semantic.tenantId, oldDigest);
807
+ const mergeDigest = newHit ? newDigest : oldDigest;
761
808
  if (tombstone) {
762
809
  // Still mark used — these members are not re-tried against a
763
810
  // DIFFERENT cluster within this same pass; next sleep re-clusters
764
811
  // them fresh.
765
- for (const e of cluster)
812
+ const rejected = newHit ? cluster : related; // the old format digested the uncapped list, so rows past the cap were rejected too
813
+ for (const e of rejected)
766
814
  used.add(e.id);
767
815
  mergesSkippedRejected++;
768
816
  try {
@@ -773,7 +821,7 @@ export async function consolidate(hippoRoot, options = {}) {
773
821
  metadata: {
774
822
  digest: mergeDigest,
775
823
  reason: tombstone.reason,
776
- sourceIds: cluster.map((e) => e.id),
824
+ sourceIds: rejected.map((e) => e.id),
777
825
  },
778
826
  });
779
827
  }
@@ -922,17 +970,22 @@ export async function consolidate(hippoRoot, options = {}) {
922
970
  // Helpers
923
971
  // ---------------------------------------------------------------------------
924
972
  function mergeContents(entries) {
925
- // Simple merge: take the longest entry as the base, prepend a summary note.
926
- // Equal-length merge bases previously fell to cluster-assembly order;
927
- // compareEntryIdentity is a deterministic tie key (content asc -> metadata -> id asc),
928
- // a no-op when lengths differ (docs/plans/2026-07-16-dedupe-survivor-determinism.md T2).
929
- const sorted = [...entries].sort((a, b) => (b.content.length - a.content.length) || compareEntryIdentity(a, b));
930
- const base = sorted[0].content;
931
- if (entries.length === 2) {
932
- return `[Consolidated from ${entries.length} related memories]\n\n${base}`;
973
+ // Each distinct text goes in once and in full (the merge demotes every source), one bullet with its lines indented, so heldTextKeys can read it back.
974
+ // Newest first says which version is current; compareEntryIdentity settles ties, so the row and its rejection digest depend only on the sources.
975
+ const sorted = [...entries].sort((a, b) => (Date.parse(b.created) - Date.parse(a.created)) || compareEntryIdentity(a, b));
976
+ const texts = new Map();
977
+ for (const e of sorted) {
978
+ if (!texts.has(duplicateKey(e.content)))
979
+ texts.set(duplicateKey(e.content), e.content);
933
980
  }
934
- // Bullets follow the base order (not raw cluster order) so the merged row
935
- // and its rejection digest are byte-identical across ingest orders.
981
+ const header = entries.length === 2 ? '[Consolidated from 2 related memories, newest first]' : `[Consolidated pattern from ${entries.length} related memories, newest first]`;
982
+ return mergedText(header, [...texts.values()]);
983
+ }
984
+ function legacyMergeContents(entries) {
985
+ // The old format dropped text, so it is only ever digested to match rejections recorded against it, never written.
986
+ const sorted = [...entries].sort((a, b) => (b.content.length - a.content.length) || compareEntryIdentity(a, b));
987
+ if (entries.length === 2)
988
+ return `[Consolidated from ${entries.length} related memories]\n\n${sorted[0].content}`;
936
989
  const bullets = sorted.map((e) => `- ${e.content.split('\n')[0].slice(0, 120)}`).join('\n');
937
990
  return `[Consolidated pattern from ${entries.length} related memories]\n\n${bullets}`;
938
991
  }
@@ -966,7 +1019,7 @@ rescuedIds = new Set()) {
966
1019
  continue;
967
1020
  if (survivors[i].superseded_by || survivors[j].superseded_by)
968
1021
  continue;
969
- if (survivors[i].tags.includes('extracted') || survivors[j].tags.includes('extracted'))
1022
+ if ([survivors[i], survivors[j]].some((e) => e.tags.includes('extracted') || e.tags.includes('session-digest')))
970
1023
  continue;
971
1024
  const reasonAndScore = describeConflict(survivors[i], survivors[j]);
972
1025
  if (!reasonAndScore)
@@ -0,0 +1,36 @@
1
+ import { type MemoryEntry } from './memory.js';
2
+ import { type SessionHandoff } from './handoff.js';
3
+ import type { SessionEvent, TaskSnapshot } from './store.js';
4
+ import type { AssembleCost, AssembleResult, AssembledContextItem, ContextCost, ContextResultEntry, DrillDownChild, DrillDownCost, DrillDownResult } from './api.js';
5
+ export declare function snapshotText(s: TaskSnapshot): string;
6
+ export declare function handoffText(h: SessionHandoff): string;
7
+ /** Needs at least one event; the header reads the latest. */
8
+ export declare function sessionTrailText(events: SessionEvent[]): string;
9
+ export declare function contextHeading(heading: string, entries: number, tokens: number): string;
10
+ export declare function contextLine(item: {
11
+ entry: MemoryEntry;
12
+ isGlobal: boolean;
13
+ }, framing: string, showStrength: boolean, now: Date, strengthPct?: number): string;
14
+ export declare function crossProjectHeading(entries: number): string;
15
+ export declare function crossProjectLine(item: Pick<ContextResultEntry, 'entry' | 'origin'>): string;
16
+ /** A header's token figure is part of the text it counts, so render until the figure matches the text. */
17
+ export declare function settleTokens(render: (tokens: number) => string): string;
18
+ export declare function printedTokens(text: string): number;
19
+ export interface AssembleHeadingCounts {
20
+ sessionId: string;
21
+ items: number;
22
+ tokens: number;
23
+ totalRaw: number;
24
+ summarized: number;
25
+ evicted: number;
26
+ }
27
+ export declare function assembleHeading(c: AssembleHeadingCounts): string;
28
+ export declare function assembleLine(it: AssembledContextItem): string;
29
+ /** The window in full, as the MCP tool returns it; the header counts the whole block. */
30
+ export declare function assembleText(r: AssembleResult): string;
31
+ export declare function assembleCost(sessionId: string): AssembleCost;
32
+ export declare function drillLine(c: DrillDownChild): string;
33
+ export declare function drillText(r: DrillDownResult): string;
34
+ export declare const drillCost: DrillDownCost;
35
+ export declare function contextCost(format: 'markdown' | 'additional-context', framing: string): ContextCost;
36
+ //# sourceMappingURL=context-render.d.ts.map
@@ -0,0 +1,154 @@
1
+ // The strings a context block prints. The budget prices these same strings, so selection and print cannot drift.
2
+ import { calculateStrength, confidenceFacets, confidenceLabel } from './memory.js';
3
+ import { evalNow } from './ablation.js';
4
+ import { estimateTokens } from './search.js';
5
+ import { renderAmbientSummary } from './ambient.js';
6
+ import { formatHandoffEvidenceLine } from './handoff.js';
7
+ export function snapshotText(s) {
8
+ return [
9
+ '## Active Task Snapshot\n',
10
+ `- Task: ${s.task}`,
11
+ `- Status: ${s.status}`,
12
+ `- Updated: ${s.updated_at}`,
13
+ `- Source: ${s.source}`,
14
+ ...(s.session_id ? [`- Session: ${s.session_id}`] : []),
15
+ '', '### Summary', s.summary, '', '### Next step', s.next_step, '',
16
+ ].join('\n');
17
+ }
18
+ export function handoffText(h) {
19
+ const lines = ['## Session Handoff\n', `- Session: ${h.sessionId}`, `- Updated: ${h.updatedAt}`];
20
+ if (h.taskId)
21
+ lines.push(`- Task: ${h.taskId}`);
22
+ if (h.repoRoot)
23
+ lines.push(`- Repo: ${h.repoRoot}`);
24
+ if (h.outcome)
25
+ lines.push(`- Outcome: ${h.outcome}`);
26
+ if (h.targetRuntime)
27
+ lines.push(`- Target runtime: ${h.targetRuntime}`);
28
+ if (h.cardId)
29
+ lines.push(`- Card: ${h.cardId}`);
30
+ lines.push('', '### Summary', h.summary);
31
+ if (h.nextAction)
32
+ lines.push('', '### Next action', h.nextAction);
33
+ if (h.artifacts && h.artifacts.length > 0)
34
+ lines.push('', '### Artifacts', ...h.artifacts.map((a) => `- ${a}`));
35
+ if (h.constraints && h.constraints.length > 0)
36
+ lines.push('', '### Constraints', ...h.constraints.map((c) => `- ${c}`));
37
+ if (h.evidence)
38
+ lines.push('', '### Evidence', formatHandoffEvidenceLine(h.evidence));
39
+ lines.push('');
40
+ return lines.join('\n');
41
+ }
42
+ /** Needs at least one event; the header reads the latest. */
43
+ export function sessionTrailText(events) {
44
+ const latest = events[events.length - 1];
45
+ return [
46
+ '## Recent Session Trail\n',
47
+ `- Session: ${latest.session_id}`,
48
+ `- Task: ${latest.task ?? 'n/a'}`,
49
+ `- Updated: ${latest.created_at}`,
50
+ '',
51
+ ...events.map((e) => `- [${e.created_at}] (${e.event_type}) ${e.content}`),
52
+ '',
53
+ ].join('\n');
54
+ }
55
+ export function contextHeading(heading, entries, tokens) {
56
+ return `## ${heading} (${entries} entries, ${tokens} tokens)\n`;
57
+ }
58
+ export function contextLine(item, framing, showStrength, now, strengthPct = Math.round(calculateStrength(item.entry) * 100)) {
59
+ const e = item.entry;
60
+ const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
61
+ const strengthStr = showStrength ? ` (${strengthPct}%)` : '';
62
+ const globalPrefix = item.isGlobal ? '[global] ' : '';
63
+ const label = confidenceLabel(e, now);
64
+ const confTag = `[${label.text}]${label.warn ? ' ⚠️' : ''}`;
65
+ if (framing === 'observe') {
66
+ const dateStr = new Date(e.created).toISOString().slice(0, 10);
67
+ // Verified rules print without the date prefix.
68
+ if (confidenceFacets(e, now).tier === 'verified')
69
+ return `- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`;
70
+ return `- **${confTag} Previously observed (${dateStr}): ${globalPrefix}${e.content}**${tagStr}${strengthStr}`;
71
+ }
72
+ if (framing === 'suggest')
73
+ return `- **${confTag} Consider checking: ${globalPrefix}${e.content}**${tagStr}${strengthStr}`;
74
+ return `- **${confTag} ${globalPrefix}${e.content}**${tagStr}${strengthStr}`;
75
+ }
76
+ export function crossProjectHeading(entries) {
77
+ return `\n## Other-project memory (explicitly requested, ${entries} entries)\n`;
78
+ }
79
+ export function crossProjectLine(item) {
80
+ const originLabel = item.origin === null || item.origin === '' ? 'unknown-origin' : item.origin;
81
+ const tagStr = item.entry.tags.length > 0 ? ` [${item.entry.tags.join(', ')}]` : '';
82
+ return `- **[${originLabel}]** ${item.entry.content}${tagStr}`;
83
+ }
84
+ /** A header's token figure is part of the text it counts, so render until the figure matches the text. */
85
+ export function settleTokens(render) {
86
+ let t = 0;
87
+ let text = render(t);
88
+ // The figure only gains digits, so this settles in a few rounds; the cap guards a render that reads the clock.
89
+ for (let i = 0; i < 8 && estimateTokens(text) !== t; i++) {
90
+ t = estimateTokens(text);
91
+ text = render(t);
92
+ }
93
+ return text;
94
+ }
95
+ // Every summary slot at its longest wording, so the footer reserve covers whatever the summary says.
96
+ const WIDEST_AMBIENT = {
97
+ tagEntropy: 1, avgStrength: 0, recencyFreshness: 1, emotionalSkew: -1, schemaFitRatio: 0, errorDensity: 0,
98
+ consolidationRatio: 1, conflictIntensity: 1, extractionCoverage: 1,
99
+ dagDepth: Number.MAX_SAFE_INTEGER, totalMemories: Number.MAX_SAFE_INTEGER,
100
+ };
101
+ // Each piece is priced with the newline that follows it, so the pieces' sum bounds the joined block.
102
+ export function printedTokens(text) {
103
+ return estimateTokens(text + '\n');
104
+ }
105
+ export function assembleHeading(c) {
106
+ return `Session ${c.sessionId} \u2014 ${c.items} items, ${c.tokens} tokens (raw=${c.totalRaw}, summarized=${c.summarized}, evicted=${c.evicted})`;
107
+ }
108
+ export function assembleLine(it) {
109
+ const prefix = it.isSummary ? '[summary]' : it.isFreshTail ? '[tail]' : '[older]';
110
+ return ` ${prefix} ${it.createdAt} ${it.id} - ${it.content}`;
111
+ }
112
+ /** The window in full, as the MCP tool returns it; the header counts the whole block. */
113
+ export function assembleText(r) {
114
+ return settleTokens((t) => [assembleHeading({ ...r, items: r.items.length, tokens: t }), ...r.items.map(assembleLine)].join('\n'));
115
+ }
116
+ // Every format prices the full lines: JSON carries full content, and the CLI's previews are never longer.
117
+ export function assembleCost(sessionId) {
118
+ return {
119
+ item: (it) => printedTokens(assembleLine(it)),
120
+ fixed: (w) => printedTokens(assembleHeading({ sessionId, items: w, tokens: w, totalRaw: w, summarized: w, evicted: w })),
121
+ };
122
+ }
123
+ function drillHead(s, shown, total, truncated) {
124
+ const span = s.earliestAt ? ` (${s.earliestAt} -> ${s.latestAt})` : '';
125
+ return `Summary ${s.id} \u2014 ${s.descendantCount} descendants${span}\n ${s.content}\n\nChildren (${shown}/${total}${truncated ? ', truncated' : ''}):`;
126
+ }
127
+ export function drillLine(c) {
128
+ return ` [L${c.dagLevel}] ${c.id} - ${c.content}`;
129
+ }
130
+ export function drillText(r) {
131
+ return [drillHead(r.summary, r.children.length, r.totalChildren, r.truncated), ...r.children.map(drillLine)].join('\n');
132
+ }
133
+ export const drillCost = {
134
+ child: (c) => printedTokens(drillLine(c)),
135
+ fixed: (s, w) => printedTokens(drillHead(s, w, w, true)),
136
+ };
137
+ // Headers and footer are reserved at their widest (counts at the budget itself, strength at 100%) before any entry.
138
+ export function contextCost(format, framing) {
139
+ const hook = format === 'additional-context';
140
+ const price = printedTokens;
141
+ return {
142
+ entry: (item) => price(item.category === 'cross-project' && !(hook && item.promptRecall)
143
+ ? crossProjectLine(item)
144
+ : contextLine({ entry: item.entry, isGlobal: item.isGlobal ?? false }, framing, !hook, evalNow(), 100)),
145
+ fixed: (budget, can) => price(contextHeading('Project Memory', budget, budget))
146
+ + (can.cross ? price(crossProjectHeading(budget)) : 0)
147
+ + (hook && can.promptRecall ? price(contextHeading('Prompt-Relevant Memory', budget, budget)) : 0)
148
+ + (!hook && can.ambient ? price(`\n${renderAmbientSummary(WIDEST_AMBIENT)}`) : 0),
149
+ snapshot: (s) => price(snapshotText(s)),
150
+ handoff: (h) => price(handoffText(h)),
151
+ trail: (events) => price(sessionTrailText(events)),
152
+ };
153
+ }
154
+ //# sourceMappingURL=context-render.js.map
package/dist/dag.js CHANGED
@@ -4,6 +4,7 @@ import { RejectedValueError } from './rejection.js';
4
4
  import { redactSecrets } from './secret-detect.js';
5
5
  import { derivationScope, derivationPartitionKey } from './recall-scope.js';
6
6
  import { loadConfig } from './config.js';
7
+ import { neverAutoShareTags } from './shared.js';
7
8
  export function clusterFacts(facts) {
8
9
  if (facts.length === 0)
9
10
  return [];
@@ -120,7 +121,7 @@ export async function buildDag(hippoRoot, facts, opts) {
120
121
  // (memory.ts:535 defaults tenantId when the option is omitted).
121
122
  const summaryEntry = createMemory(summary, {
122
123
  layer: Layer.Semantic,
123
- tags: [...cluster.entityTags, 'dag-summary'],
124
+ tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-summary'],
124
125
  confidence: 'inferred',
125
126
  dag_level: 2,
126
127
  tenantId: factTenant,
@@ -329,7 +330,7 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
329
330
  const nowIso = new Date().toISOString();
330
331
  const profileEntry = createMemory(summary, {
331
332
  layer: Layer.Semantic,
332
- tags: [...cluster.entityTags, 'dag-entity-profile'],
333
+ tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-entity-profile'],
333
334
  confidence: 'inferred',
334
335
  dag_level: 3,
335
336
  tenantId, // HIGH #1 fold: thread tenant explicitly
package/dist/db.d.ts CHANGED
@@ -16,7 +16,11 @@ export declare function getCurrentSchemaVersion(): number;
16
16
  /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
17
17
  export declare class IncompatibleBinaryError extends Error {
18
18
  }
19
- export declare function openHippoDb(hippoRoot: string): DatabaseSyncLike;
19
+ export declare function isSqliteBusy(error: unknown): boolean;
20
+ /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
21
+ export declare function openHippoDb(hippoRoot: string, opts?: {
22
+ busyWaitMs?: number;
23
+ }): DatabaseSyncLike;
20
24
  /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
21
25
  export declare function openHippoDbReadOnly(hippoRoot: string): DatabaseSyncLike;
22
26
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 48;
14
+ const CURRENT_SCHEMA_VERSION = 49;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2398,12 +2398,12 @@ const MIGRATIONS = [
2398
2398
  version: 45,
2399
2399
  up: (db) => {
2400
2400
  // Token ledger (src/token-ledger.ts, ROADMAP TE0): one row per block of
2401
- // memory text hippo hands an agent (hook, CLI, MCP, HTTP). `event` is
2402
- // 'inject' (sent), 'skip' (unchanged since the session's last inject,
2403
- // not sent) or 'reset' (compaction dropped earlier injections, so the
2404
- // next one must be sent). block_hash lets the per-prompt hook skip an
2405
- // unchanged block. Rows older than the retention window are pruned on
2406
- // write. Additive only: no min_compatible_binary bump.
2401
+ // memory text hippo hands an agent (hook, CLI, MCP, HTTP). `event` is 'inject'
2402
+ // (sent), 'skip' (unchanged since the session's last inject, not sent), 'reset'
2403
+ // (compaction dropped earlier injections, so the next one must be sent) or
2404
+ // 'reread' (re-read by later calls, booked at session end per call day). block_hash
2405
+ // lets the per-prompt hook skip an unchanged block. Rows older than the retention
2406
+ // window are pruned on write. Additive only: no min_compatible_binary bump.
2407
2407
  db.exec(`
2408
2408
  CREATE TABLE IF NOT EXISTS token_ledger (
2409
2409
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2471,6 +2471,36 @@ const MIGRATIONS = [
2471
2471
  db.exec(MEMORY_QUARANTINE_DDL);
2472
2472
  },
2473
2473
  },
2474
+ {
2475
+ version: 49,
2476
+ up: (db) => {
2477
+ // Compaction record (src/compaction-record.ts): one row per Claude Code compaction, written before it (started)
2478
+ // and after it (summarised, done). Not a memory row. Additive only: no min_compatible_binary bump.
2479
+ db.exec(`
2480
+ CREATE TABLE IF NOT EXISTS compactions (
2481
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2482
+ id TEXT NOT NULL,
2483
+ session_id TEXT NOT NULL,
2484
+ origin_project TEXT NOT NULL DEFAULT '',
2485
+ compact_trigger TEXT,
2486
+ cwd TEXT,
2487
+ transcript_path TEXT,
2488
+ snapshot_saved INTEGER NOT NULL DEFAULT 0,
2489
+ started_at TEXT NOT NULL,
2490
+ summarised_at TEXT,
2491
+ summary TEXT,
2492
+ items_json TEXT,
2493
+ items_written INTEGER NOT NULL DEFAULT 0,
2494
+ status TEXT NOT NULL DEFAULT 'started' CHECK (status IN ('started','summarised','done','no-summary')),
2495
+ PRIMARY KEY (tenant_id, id)
2496
+ );
2497
+ CREATE INDEX IF NOT EXISTS idx_compactions_session
2498
+ ON compactions(tenant_id, session_id, started_at);
2499
+ CREATE INDEX IF NOT EXISTS idx_compactions_status
2500
+ ON compactions(tenant_id, status);
2501
+ `);
2502
+ },
2503
+ },
2474
2504
  ];
2475
2505
  function tableHasColumn(db, tableName, columnName) {
2476
2506
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2504,7 +2534,7 @@ function assertBinaryCompatible(db) {
2504
2534
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
2505
2535
  }
2506
2536
  }
2507
- function isSqliteBusy(error) {
2537
+ export function isSqliteBusy(error) {
2508
2538
  const code = error?.errcode;
2509
2539
  return code === 5 || code === 6 || code === 517;
2510
2540
  }
@@ -2526,16 +2556,18 @@ function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2526
2556
  }
2527
2557
  }
2528
2558
  }
2529
- export function openHippoDb(hippoRoot) {
2559
+ /** `busyWaitMs` shortens every lock wait of this open, for a hook that must finish inside its own timeout. */
2560
+ export function openHippoDb(hippoRoot, opts) {
2530
2561
  fs.mkdirSync(hippoRoot, { recursive: true });
2531
2562
  const db = new DatabaseSync(getHippoDbPath(hippoRoot));
2563
+ const busyWaitMs = opts?.busyWaitMs;
2532
2564
  try {
2533
- db.exec('PRAGMA busy_timeout = 5000');
2534
- execWithBusyRetry(db, 'PRAGMA journal_mode = WAL');
2565
+ db.exec(`PRAGMA busy_timeout = ${busyWaitMs ?? 5000}`);
2566
+ execWithBusyRetry(db, 'PRAGMA journal_mode = WAL', busyWaitMs);
2535
2567
  db.exec('PRAGMA synchronous = NORMAL');
2536
2568
  db.exec('PRAGMA wal_autocheckpoint = 100');
2537
2569
  db.exec('PRAGMA foreign_keys = ON');
2538
- runMigrations(db, hippoRoot);
2570
+ runMigrations(db, hippoRoot, busyWaitMs);
2539
2571
  // Path A backfill: delete any orphan markdown mirrors for already-archived
2540
2572
  // raw_archive rows. Idempotent via per-row raw_archive.mirror_cleaned_at
2541
2573
  // (v21). Wrapped in try/catch — a filesystem failure must not prevent DB open.
@@ -2576,7 +2608,7 @@ export function openHippoDbReadOnly(hippoRoot) {
2576
2608
  throw error;
2577
2609
  }
2578
2610
  }
2579
- function runMigrations(db, hippoRoot) {
2611
+ function runMigrations(db, hippoRoot, busyWaitMs) {
2580
2612
  ensureMetaTable(db);
2581
2613
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
2582
2614
  assertBinaryCompatible(db);
@@ -2586,7 +2618,7 @@ function runMigrations(db, hippoRoot) {
2586
2618
  for (const migration of MIGRATIONS) {
2587
2619
  if (migration.version <= currentVersion)
2588
2620
  continue;
2589
- execWithBusyRetry(db, 'BEGIN IMMEDIATE');
2621
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
2590
2622
  try {
2591
2623
  // A newer binary may have migrated and raised the minimum while we waited for the lock.
2592
2624
  assertBinaryCompatible(db);
package/dist/dedupe.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Store-level deduplication. Scans for near-duplicate memories by content
3
- * Jaccard overlap, keeps the stronger copy (by strength + retrieval count),
2
+ * Store-level deduplication. Scans for memories with the same text apart
3
+ * from spacing, keeps the stronger copy (by strength + retrieval count),
4
4
  * removes the rest.
5
5
  *
6
6
  * Extracted from cli.ts in Episode A (v1.11.3) so `api.sleep` can dedupe
@@ -59,13 +59,13 @@ export interface DedupResult {
59
59
  */
60
60
  export declare function strengthBucket(strength: number | null | undefined): number;
61
61
  /**
62
- * Scan the store for near-duplicate memories and remove the weaker copy.
63
- * Two memories are duplicates if their content has > threshold Jaccard
64
- * overlap AND they belong to the same tenant: the scan is partitioned by
62
+ * Scan the store for duplicates and remove the weaker copy: same text apart
63
+ * from spacing, since a near-duplicate can differ in a value (port, version,
64
+ * path, name), AND the same tenant: the scan is partitioned by
65
65
  * tenantId, so byte-identical content in two tenants is never a duplicate
66
66
  * pair (the tenant boundary is an isolation boundary; cross-tenant removal
67
67
  * was the v1.32.0 known-issue data-loss bug).
68
- * Keeps the one with higher strength (or more retrievals if tied).
68
+ * Keeps the one with higher strength (or more retrievals if tied). `threshold` is accepted for old callers and ignored.
69
69
  */
70
70
  export declare function deduplicateStore(hippoRoot: string, options?: {
71
71
  threshold?: number;