hippo-memory 1.52.8 → 1.52.9

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 (74) hide show
  1. package/README.md +158 -98
  2. package/dist/api.d.ts +51 -18
  3. package/dist/api.js +121 -76
  4. package/dist/audit.d.ts +2 -1
  5. package/dist/audit.js +63 -0
  6. package/dist/capture.d.ts +37 -0
  7. package/dist/capture.js +111 -81
  8. package/dist/cli.js +627 -654
  9. package/dist/codex-patch.d.ts +12 -0
  10. package/dist/codex-patch.js +71 -0
  11. package/dist/config.d.ts +0 -1
  12. package/dist/config.js +0 -4
  13. package/dist/connectors/slack/types.d.ts +0 -1
  14. package/dist/consolidate.js +85 -32
  15. package/dist/context-render.d.ts +36 -0
  16. package/dist/context-render.js +154 -0
  17. package/dist/dag.js +3 -2
  18. package/dist/db.js +6 -6
  19. package/dist/dedupe.d.ts +6 -6
  20. package/dist/dedupe.js +10 -9
  21. package/dist/doctor.d.ts +1 -1
  22. package/dist/doctor.js +35 -2
  23. package/dist/dormant.d.ts +4 -0
  24. package/dist/dormant.js +17 -2
  25. package/dist/embedding-provider.d.ts +2 -1
  26. package/dist/embedding-provider.js +2 -1
  27. package/dist/embeddings.js +23 -3
  28. package/dist/extract.js +5 -1
  29. package/dist/forward-claim-detector.d.ts +1 -1
  30. package/dist/forward-claim-detector.js +1 -1
  31. package/dist/graph-recall.d.ts +3 -1
  32. package/dist/graph-recall.js +5 -3
  33. package/dist/hooks.d.ts +15 -1
  34. package/dist/hooks.js +122 -27
  35. package/dist/importers.js +5 -12
  36. package/dist/judgment.d.ts +30 -0
  37. package/dist/judgment.js +122 -0
  38. package/dist/mcp/server.js +171 -210
  39. package/dist/merged-row.d.ts +6 -0
  40. package/dist/merged-row.js +35 -0
  41. package/dist/multihop.d.ts +2 -1
  42. package/dist/multihop.js +7 -4
  43. package/dist/physics-state.d.ts +0 -4
  44. package/dist/physics-state.js +0 -6
  45. package/dist/predictions.d.ts +2 -17
  46. package/dist/predictions.js +2 -15
  47. package/dist/reject-flow.d.ts +7 -5
  48. package/dist/reject-flow.js +41 -12
  49. package/dist/salience.js +12 -5
  50. package/dist/same-text.d.ts +17 -0
  51. package/dist/same-text.js +38 -0
  52. package/dist/scheduler.d.ts +4 -0
  53. package/dist/scheduler.js +8 -0
  54. package/dist/search.d.ts +7 -0
  55. package/dist/search.js +16 -32
  56. package/dist/secret-detect.d.ts +2 -0
  57. package/dist/secret-detect.js +6 -0
  58. package/dist/server-detect.js +9 -33
  59. package/dist/server.js +6 -62
  60. package/dist/session-digest.d.ts +79 -0
  61. package/dist/session-digest.js +528 -0
  62. package/dist/shared.d.ts +10 -2
  63. package/dist/shared.js +35 -30
  64. package/dist/store.d.ts +1 -0
  65. package/dist/store.js +4 -0
  66. package/dist/token-ledger.d.ts +46 -8
  67. package/dist/token-ledger.js +140 -21
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/extensions/openclaw-plugin/README.md +4 -4
  71. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  72. package/extensions/openclaw-plugin/package.json +1 -1
  73. package/openclaw.plugin.json +2 -2
  74. package/package.json +2 -2
@@ -0,0 +1,12 @@
1
+ /** Codex prints this line only when a patch applied (apply-patch lib.rs). */
2
+ export declare const PATCH_SUCCESS_LINE = "Success. Updated the following files:";
3
+ /** Paths a patch body adds, deletes, updates or moves to, in patch order; [] when the text holds no patch. */
4
+ export declare function patchPaths(body: string): string[];
5
+ export interface ShellPatch {
6
+ body: string;
7
+ /** The `cd <dir> &&` target, relative to the call's working directory. */
8
+ cd: string | null;
9
+ }
10
+ /** The patch a shell call hands to apply_patch, or null for any other command. */
11
+ export declare function shellPatch(command: readonly string[] | string): ShellPatch | null;
12
+ //# sourceMappingURL=codex-patch.d.ts.map
@@ -0,0 +1,71 @@
1
+ // Files named by a Codex apply_patch call, read the way openai/codex codex-rs/apply-patch reads them
2
+ // (parser.rs for the body, invocation.rs for the shell forms it runs as apply_patch).
3
+ import * as path from 'path';
4
+ import { isStringValue } from './capture.js';
5
+ /** Codex prints this line only when a patch applied (apply-patch lib.rs). */
6
+ export const PATCH_SUCCESS_LINE = 'Success. Updated the following files:';
7
+ const BEGIN_PATCH = '*** Begin Patch';
8
+ const END_PATCH = '*** End Patch';
9
+ const MOVE_TO = '*** Move to: ';
10
+ const FILE_HEADER = /^\*\*\* (Add|Delete|Update) File: (.+)$/;
11
+ /** Paths a patch body adds, deletes, updates or moves to, in patch order; [] when the text holds no patch. */
12
+ export function patchPaths(body) {
13
+ const lines = body.split(/\r?\n/);
14
+ const start = lines.findIndex((l) => l.trim() === BEGIN_PATCH);
15
+ if (start < 0)
16
+ return [];
17
+ const paths = [];
18
+ let updating = false;
19
+ let canMove = false;
20
+ for (const line of lines.slice(start + 1)) {
21
+ // Inside an update hunk a leading space marks a context line, so headers match on trimEnd only there.
22
+ const head = updating ? line.trimEnd() : line.trim();
23
+ if (head === END_PATCH)
24
+ break;
25
+ const header = FILE_HEADER.exec(head);
26
+ if (header) {
27
+ paths.push(header[2].trim());
28
+ updating = header[1] === 'Update';
29
+ canMove = updating;
30
+ continue;
31
+ }
32
+ if (canMove && head.startsWith(MOVE_TO))
33
+ paths.push(head.slice(MOVE_TO.length).trim());
34
+ canMove = false;
35
+ }
36
+ return paths.filter(Boolean);
37
+ }
38
+ const HEREDOC = /^\s*(?:cd\s+(?<cd>'[^']*'|"[^"]*"|[^\s'"&|;]+)\s+&&\s+)?(?:apply_patch|applypatch)\s+<<\s*(?<q>['"]?)(?<tag>\w+)\k<q>[ \t]*\r?\n(?<body>[\s\S]*?)\r?\n\s*\k<tag>\s*$/;
39
+ /** The script a shell argv runs, for the shells Codex checks for an apply_patch heredoc. */
40
+ function shellScript(argv) {
41
+ const exe = path.basename(argv[0] ?? '').toLowerCase().replace(/\.exe$/, '');
42
+ if ((exe === 'bash' || exe === 'zsh' || exe === 'sh') && argv.length === 3 && (argv[1] === '-lc' || argv[1] === '-c'))
43
+ return argv[2];
44
+ if (exe === 'pwsh' || exe === 'powershell') {
45
+ const rest = argv.slice(1).filter((a) => a.toLowerCase() !== '-noprofile');
46
+ if (rest.length === 2 && rest[0].toLowerCase() === '-command')
47
+ return rest[1];
48
+ }
49
+ if (exe === 'cmd' && argv.length === 3 && argv[1].toLowerCase() === '/c')
50
+ return argv[2];
51
+ return null;
52
+ }
53
+ /** The patch a shell call hands to apply_patch, or null for any other command. */
54
+ export function shellPatch(command) {
55
+ let script;
56
+ if (isStringValue(command)) {
57
+ script = command;
58
+ }
59
+ else {
60
+ const exe = path.basename(command[0] ?? '').toLowerCase();
61
+ if (command.length === 2 && (exe === 'apply_patch' || exe === 'applypatch'))
62
+ return { body: command[1], cd: null };
63
+ script = shellScript(command);
64
+ }
65
+ const match = script === null ? null : HEREDOC.exec(script);
66
+ if (!match?.groups)
67
+ return null;
68
+ const cd = match.groups.cd ?? null;
69
+ return { body: match.groups.body, cd: cd === null ? null : cd.replace(/^(['"])(.*)\1$/, '$2') };
70
+ }
71
+ //# sourceMappingURL=codex-patch.js.map
package/dist/config.d.ts CHANGED
@@ -138,5 +138,4 @@ export interface HippoConfig {
138
138
  };
139
139
  }
140
140
  export declare function loadConfig(hippoRoot: string): HippoConfig;
141
- export declare function saveConfig(hippoRoot: string, config: HippoConfig): void;
142
141
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -201,8 +201,4 @@ export function loadConfig(hippoRoot) {
201
201
  return { ...DEFAULT_CONFIG };
202
202
  }
203
203
  }
204
- export function saveConfig(hippoRoot, config) {
205
- const configPath = path.join(hippoRoot, 'config.json');
206
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
207
- }
208
204
  //# sourceMappingURL=config.js.map
@@ -40,7 +40,6 @@ export interface SlackEventEnvelope {
40
40
  [k: string]: JsonValue;
41
41
  };
42
42
  }
43
- export type SlackInbound = SlackEventEnvelope | SlackUrlVerification;
44
43
  export declare function isSlackEventEnvelope(x: JsonValue): x is JsonValue & SlackEventEnvelope;
45
44
  export declare function isSlackMessageEvent(x: JsonValue | SlackEventEnvelope['event']): x is SlackMessageEvent;
46
45
  //# sourceMappingURL=types.d.ts.map
@@ -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
  }
@@ -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.js CHANGED
@@ -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,
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;