hippo-memory 1.52.0 → 1.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -952,6 +952,8 @@ export interface ContextOpts {
952
952
  * it just means every snapshot goes through the age check. Host-resolved
953
953
  * (stdin payload, HIPPO_SESSION_ID, else the host's session var) so this stays host-agnostic. */
954
954
  currentSessionId?: string | null;
955
+ /** Z1: raw hook-payload prompt; only the pinned-only branch reads it, gated on `pinnedInject.promptRecall`. */
956
+ prompt?: string;
955
957
  }
956
958
  export interface ContextResultEntry {
957
959
  entry: MemoryEntry;
@@ -959,6 +961,8 @@ export interface ContextResultEntry {
959
961
  tokens: number;
960
962
  isGlobal?: boolean;
961
963
  isFreshTail?: boolean;
964
+ /** Z1: admitted by the prompt-recall gate, not the recent-N backfill or a pin. */
965
+ promptRecall?: boolean;
962
966
  /** v39: the entry's owning project ('' = user-global, null = legacy row). */
963
967
  origin?: string | null;
964
968
  /** v39: how the origin relates to the active project. 'cross-project'
package/dist/api.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { createHash } from 'node:crypto';
10
10
  import { openHippoDb, closeHippoDb } from './db.js';
11
- import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, } from './store.js';
11
+ import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadRecallSearchEntriesFromDb, pickRarestFtsQuery, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, } from './store.js';
12
12
  import { RejectedValueError } from './rejection.js';
13
13
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
14
14
  import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
@@ -30,7 +30,8 @@ import { compareEntryIdentity, compareScoredResults } from './compare.js';
30
30
  import { scopeMatch } from './scope.js';
31
31
  import { consolidate } from './consolidate.js';
32
32
  import { loadConfig } from './config.js';
33
- import { resolveProjectIdentity, classifyOriginProject } from './project-identity.js';
33
+ import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from './project-identity.js';
34
+ import { promptTokens, contentTokens, gatePromptRecall, } from './prompt-recall.js';
34
35
  import { detectSecret } from './secret-detect.js';
35
36
  import { deduplicateStore } from './dedupe.js';
36
37
  import { computeAmbientState } from './ambient.js';
@@ -1587,7 +1588,10 @@ export async function getContext(ctx, opts = {}) {
1587
1588
  limit: 5,
1588
1589
  }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1589
1590
  : [];
1590
- if (localEntries.length === 0 &&
1591
+ const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim())
1592
+ && loadConfig(ctx.hippoRoot).pinnedInject.promptRecall === true;
1593
+ if (!promptRecallPending &&
1594
+ localEntries.length === 0 &&
1591
1595
  globalEntries.length === 0 &&
1592
1596
  !activeSnapshot &&
1593
1597
  !sessionHandoff &&
@@ -1654,7 +1658,79 @@ export async function getContext(ctx, opts = {}) {
1654
1658
  // under-fills recents slightly -- it never displaces a pin -- so it is
1655
1659
  // the safe direction and is not worth extra bookkeeping to recover.
1656
1660
  const recentBudget = Math.max(0, effBudget - pinnedReserve);
1657
- if (includeRecent > 0) {
1661
+ // Z1: gate the backfill on the prompt instead of recency (docs/plans/2026-09-26-z1-prompt-recall.md).
1662
+ const promptRecallOn = Boolean(opts.prompt?.trim()) && pinnedCfg.pinnedInject.promptRecall === true;
1663
+ if (promptRecallOn) {
1664
+ const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
1665
+ const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
1666
+ // Config values come from JSON with no runtime type check; Number.isFinite also rejects a string there.
1667
+ const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
1668
+ const gate = {
1669
+ metric,
1670
+ threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
1671
+ minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
1672
+ maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
1673
+ };
1674
+ const candidateLimit = Math.floor(finiteOr(pinnedCfg.pinnedInject.promptRecallCandidates, 100, 1));
1675
+ const p = promptTokens(opts.prompt ?? '');
1676
+ if (p.size > 0) {
1677
+ const promptTermList = Array.from(p);
1678
+ // One open connection per store instead of loadRecallSearchEntries's own
1679
+ // initStore+open/close per call: store is already initialized (hasLocal/hasGlobal).
1680
+ let localCandidates = [];
1681
+ if (hasLocal) {
1682
+ const localDb = openHippoDb(ctx.hippoRoot);
1683
+ try {
1684
+ const ftsQuery = pickRarestFtsQuery(localDb, promptTermList);
1685
+ // An empty query would load the oldest rows, not matches.
1686
+ if (ftsQuery)
1687
+ localCandidates = loadRecallSearchEntriesFromDb(localDb, ftsQuery, candidateLimit, ctx.tenantId, undefined, 'exact', false);
1688
+ }
1689
+ finally {
1690
+ closeHippoDb(localDb);
1691
+ }
1692
+ }
1693
+ let globalCandidates = [];
1694
+ if (hasGlobal && !isGlobalStoreRoot(ctx.hippoRoot)) {
1695
+ const globalDb = openHippoDb(globalRoot);
1696
+ try {
1697
+ const ftsQuery = pickRarestFtsQuery(globalDb, promptTermList);
1698
+ if (ftsQuery)
1699
+ globalCandidates = loadRecallSearchEntriesFromDb(globalDb, ftsQuery, candidateLimit, ctx.tenantId, undefined, 'exact', false);
1700
+ }
1701
+ finally {
1702
+ closeHippoDb(globalDb);
1703
+ }
1704
+ }
1705
+ const seenCandidateIds = new Set();
1706
+ const candidateItems = [];
1707
+ // Local wins the id collision (a global row synced into the local store).
1708
+ for (const e of localCandidates) {
1709
+ if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1710
+ continue;
1711
+ seenCandidateIds.add(e.id);
1712
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
1713
+ }
1714
+ for (const e of globalCandidates) {
1715
+ if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
1716
+ continue;
1717
+ seenCandidateIds.add(e.id);
1718
+ candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
1719
+ }
1720
+ const gated = gatePromptRecall(p, candidateItems, gate);
1721
+ for (const g of gated) {
1722
+ if (selectedIds.has(g.item.id))
1723
+ continue;
1724
+ const tokens = estimateTokens(g.item.entry.content);
1725
+ if (usedP + tokens > recentBudget)
1726
+ continue;
1727
+ selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
1728
+ selectedIds.add(g.item.id);
1729
+ usedP += tokens;
1730
+ }
1731
+ }
1732
+ }
1733
+ else if (includeRecent > 0) {
1658
1734
  const recent = [
1659
1735
  ...localEntries.map((entry) => ({ entry, isGlobal: false })),
1660
1736
  ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
package/dist/audit.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface AuditResult {
13
13
  issues: AuditIssue[];
14
14
  clean: number;
15
15
  }
16
+ export declare const STOP_WORDS: Set<string>;
16
17
  export declare function auditMemory(entry: MemoryEntry): AuditIssue | null;
17
18
  export declare function auditMemories(entries: MemoryEntry[]): AuditResult;
18
19
  export declare function isContentWorthStoring(content: string): boolean;
package/dist/audit.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { canAutoDelete } from './memory.js';
2
- const STOP_WORDS = new Set([
2
+ export const STOP_WORDS = new Set([
3
3
  'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
4
4
  'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'it',
5
5
  'this', 'that', 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'do',
package/dist/auth.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSyncLike } from './db.js';
2
+ export declare function _dummyHashForTests(): string;
2
3
  export interface CreateApiKeyOpts {
3
4
  tenantId: string;
4
5
  label?: string;
package/dist/auth.js CHANGED
@@ -24,7 +24,12 @@ function hashKey(plaintext) {
24
24
  // rate limit on /v1/* to bound key-id enumeration. Stored format identical to
25
25
  // real hashes: scrypt$saltHex$hashHex.
26
26
  const DUMMY_PLAINTEXT = 'hk_dummy_constant_padding_for_timing.dummy_secret_padding_for_timing_x';
27
- const DUMMY_HASH = hashKey(DUMMY_PLAINTEXT);
27
+ // Precomputed `hashKey(DUMMY_PLAINTEXT)`: computing it at module load put one scrypt on every CLI start.
28
+ const DUMMY_HASH = 'scrypt$5b2117156f1ad78738fd8b6a5bace454$92e098fa28003b7c38ba8f1451bd8618260a4a3c5a64b87cc2b471567c0960e7';
29
+ // Not a secret (it's padding, not a real key hash): exposed only so a test can pin its shape.
30
+ export function _dummyHashForTests() {
31
+ return DUMMY_HASH;
32
+ }
28
33
  function verifyKey(plaintext, stored) {
29
34
  const parts = stored.split('$');
30
35
  if (parts.length !== 3 || parts[0] !== 'scrypt')
package/dist/cli.d.ts CHANGED
@@ -63,6 +63,7 @@ export declare function printContextMarkdown(items: Array<{
63
63
  isGlobal: boolean;
64
64
  }>, totalTokens: number, framing?: string, opts?: {
65
65
  showStrength?: boolean;
66
+ heading?: string;
66
67
  }): void;
67
68
  export declare function runCli(argv?: string[]): Promise<void>;
68
69
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -6328,12 +6328,16 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6328
6328
  // hostSessionId(); absent both, undefined -- api.getContext then applies
6329
6329
  // the pure freshness bound with no owner-match short-circuit.
6330
6330
  let payloadSessionId;
6331
+ // Z1: the hook payload's raw prompt, read beside session_id (docs/plans/2026-09-26-z1-prompt-recall.md).
6332
+ let payloadPrompt;
6331
6333
  if (stdinText && stdinText.trim() !== '') {
6332
6334
  try {
6333
- const payload = JSON.parse(stdinText.trim());
6334
- if (payload && typeof payload === 'object' && typeof payload.session_id === 'string' && payload.session_id.trim() !== '') {
6335
- payloadSessionId = payload.session_id;
6336
- }
6335
+ // SAFETY: both fields are type-checked below before use; `?? {}` covers a JSON null payload.
6336
+ const { session_id: sid, prompt } = (JSON.parse(stdinText.trim()) ?? {});
6337
+ if (typeof sid === 'string' && sid.trim() !== '')
6338
+ payloadSessionId = sid;
6339
+ if (typeof prompt === 'string')
6340
+ payloadPrompt = prompt;
6337
6341
  }
6338
6342
  catch {
6339
6343
  // Malformed/non-JSON stdin: fall through to the env fallback below.
@@ -6349,6 +6353,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6349
6353
  includeRecent: parseCountFlag(flags['include-recent']),
6350
6354
  crossProject,
6351
6355
  currentSessionId,
6356
+ prompt: payloadPrompt,
6352
6357
  };
6353
6358
  const result = await api.getContext(ctx, opts);
6354
6359
  // Early exit when there's nothing to render (matches pre-extraction behavior).
@@ -6400,9 +6405,18 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6400
6405
  }));
6401
6406
  }
6402
6407
  else if (format === 'additional-context') {
6403
- // Claude Code UserPromptSubmit hook JSON shape. Capture print* helpers'
6404
- // output into a string buffer and wrap as `additionalContext`.
6405
- const textBlock = captureConsole(() => {
6408
+ // Z1: split into a static block (snapshot/handoff/events/pins/recent-N,
6409
+ // TE2-skippable) and a prompt-recall block (never skipped, own heading).
6410
+ const staticEntries = mainEntries.filter((r) => !r.promptRecall);
6411
+ const staticCrossEntries = crossEntries.filter((r) => !r.promptRecall);
6412
+ const recallEntries = result.entries.filter((r) => r.promptRecall);
6413
+ const staticItems = staticEntries.map((r) => ({ entry: r.entry, score: r.score, tokens: r.tokens, isGlobal: r.isGlobal ?? false }));
6414
+ const recallItems = recallEntries.map((r) => ({ entry: r.entry, score: r.score, tokens: r.tokens, isGlobal: r.isGlobal ?? false }));
6415
+ // Header total is static-only once a recall section exists; otherwise byte-identical to today.
6416
+ const staticHeaderTokens = recallItems.length > 0
6417
+ ? staticItems.reduce((sum, r) => sum + r.tokens, 0)
6418
+ : result.tokens;
6419
+ const staticBlock = captureConsole(() => {
6406
6420
  if (result.activeSnapshot)
6407
6421
  printActiveTaskSnapshot(result.activeSnapshot);
6408
6422
  if (result.sessionHandoff)
@@ -6410,49 +6424,66 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
6410
6424
  if (result.recentEvents && result.recentEvents.length > 0) {
6411
6425
  printSessionEvents(result.recentEvents);
6412
6426
  }
6413
- if (renderItems.length > 0) {
6427
+ if (staticItems.length > 0) {
6414
6428
  // TE1: no live strength percentage, so an unchanged set of memories
6415
6429
  // renders byte-identically turn after turn.
6416
- printContextMarkdown(renderItems, result.tokens, framing, { showStrength: false });
6430
+ printContextMarkdown(staticItems, staticHeaderTokens, framing, { showStrength: false });
6417
6431
  }
6418
- printCrossProjectSection(crossEntries);
6432
+ printCrossProjectSection(staticCrossEntries);
6419
6433
  });
6420
- if (!textBlock.trim())
6434
+ const recallTokens = recallItems.reduce((sum, r) => sum + r.tokens, 0);
6435
+ const recallBlock = recallItems.length > 0
6436
+ ? captureConsole(() => printContextMarkdown(recallItems, recallTokens, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' }))
6437
+ : '';
6438
+ if (!staticBlock.trim() && !recallBlock.trim())
6421
6439
  return;
6422
6440
  const surface = pinnedOnly ? 'hook' : 'context';
6423
- const hash = blockHash(textBlock);
6424
- const tokens = estimateTokens(textBlock);
6425
- // TE2: the per-prompt hook skips a block identical to the one this
6426
- // session already has, and resends it every refreshTurns skips. Only
6427
- // with a session id from the hook payload itself: an inherited env id
6428
- // (a manual run inside an agent's shell) must never suppress output.
6429
- if (pinnedOnly && payloadSessionId !== undefined) {
6441
+ let sendStatic = staticBlock.trim().length > 0;
6442
+ // TE2: the per-prompt hook skips a static block identical to the one this
6443
+ // session already has, resent every refreshTurns skips. Hashed on the
6444
+ // static text alone so an unchanged pin set still skips while recall varies.
6445
+ if (sendStatic && pinnedOnly && payloadSessionId !== undefined) {
6430
6446
  const injectCfg = loadConfig(hippoRoot).pinnedInject;
6431
6447
  if (injectCfg.skipUnchanged !== false) {
6432
6448
  const refreshTurns = Number.isFinite(injectCfg.refreshTurns) && injectCfg.refreshTurns >= 0
6433
6449
  ? injectCfg.refreshTurns
6434
6450
  : 10;
6451
+ const staticHash = blockHash(staticBlock);
6435
6452
  const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
6436
- if (shouldSkipUnchanged(last ?? null, hash, refreshTurns)) {
6453
+ if (shouldSkipUnchanged(last ?? null, staticHash, refreshTurns)) {
6437
6454
  withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6438
6455
  tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
6439
- items: renderItems.length, tokens, hash,
6456
+ items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
6440
6457
  }));
6441
- return;
6458
+ sendStatic = false;
6442
6459
  }
6443
6460
  }
6444
6461
  }
6462
+ const finalStatic = sendStatic ? staticBlock : '';
6463
+ const additionalContext = finalStatic && recallBlock
6464
+ ? `${finalStatic}\n\n${recallBlock}`
6465
+ : finalStatic || recallBlock;
6466
+ if (!additionalContext.trim())
6467
+ return;
6445
6468
  const payload = {
6446
6469
  hookSpecificOutput: {
6447
6470
  hookEventName: 'UserPromptSubmit',
6448
- additionalContext: textBlock,
6471
+ additionalContext,
6449
6472
  },
6450
6473
  };
6451
6474
  process.stdout.write(JSON.stringify(payload));
6452
- withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6453
- tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
6454
- items: renderItems.length, tokens, hash,
6455
- }));
6475
+ if (finalStatic) {
6476
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6477
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
6478
+ items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
6479
+ }));
6480
+ }
6481
+ if (recallBlock) {
6482
+ withLedgerDb(hippoRoot, (db) => recordTokenUse(db, {
6483
+ tenantId: ctx.tenantId, sessionId: currentSessionId, surface: 'hook_recall', event: 'inject',
6484
+ items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
6485
+ }));
6486
+ }
6456
6487
  }
6457
6488
  else {
6458
6489
  // markdown (default)
@@ -6577,7 +6608,8 @@ function printCrossProjectSection(items) {
6577
6608
  export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
6578
6609
  const now = evalNow();
6579
6610
  const showStrength = opts.showStrength !== false;
6580
- console.log(`## Project Memory (${items.length} entries, ${totalTokens} tokens)\n`);
6611
+ const heading = opts.heading ?? 'Project Memory';
6612
+ console.log(`## ${heading} (${items.length} entries, ${totalTokens} tokens)\n`);
6581
6613
  for (const item of items) {
6582
6614
  const e = item.entry;
6583
6615
  const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
@@ -8744,6 +8776,7 @@ Commands:
8744
8776
  --budget <n> Token budget (default: 1500)
8745
8777
  --pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
8746
8778
  --include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
8779
+ (the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
8747
8780
  --format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
8748
8781
  --framing <mode> Framing: observe (default), suggest, assert
8749
8782
  sleep Run consolidation pass (auto-learns + dedup + auto-shares)
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * Config support for Hippo: reads .hippo/config.json with sane defaults.
3
3
  */
4
4
  import { type PhysicsConfig } from './physics-config.js';
5
+ import type { PromptRecallMetric } from './prompt-recall.js';
5
6
  export type DecayBasis = 'clock' | 'session' | 'adaptive';
6
7
  export interface HippoConfig {
7
8
  defaultHalfLifeDays: number;
@@ -69,6 +70,20 @@ export interface HippoConfig {
69
70
  * sessions still see pinned rules near the latest turn. Default 10; 0
70
71
  * never resends an unchanged block. */
71
72
  refreshTurns: number;
73
+ /** Z1: gate the hook's backfill on the prompt's own content instead of
74
+ * the five newest memories. Default false: the eval failed its overlap gate
75
+ * (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
76
+ promptRecall: boolean;
77
+ /** Z1: overlap metric for the prompt-recall gate. Default 'jaccard' (tuned, docs/evals/2026-09-26-z1-prompt-recall-result.md). */
78
+ promptRecallMetric: PromptRecallMetric;
79
+ /** Z1: minimum overlap score to admit a candidate. Default 0.04 (tuned). */
80
+ promptRecallThreshold: number;
81
+ /** Z1: minimum shared tokens to admit a candidate. Default 2. */
82
+ promptRecallMinShared: number;
83
+ /** Z1: max prompt-recall entries injected per prompt. Default 5 (tuned). */
84
+ promptRecallMaxItems: number;
85
+ /** Z1: FTS candidate pool size per store before gating. Default 100. */
86
+ promptRecallCandidates: number;
72
87
  };
73
88
  /** Memory scope isolation (v39): when true (default), ambient context
74
89
  * (`hippo context`, the UserPromptSubmit hook, /v1/context, MCP
package/dist/config.js CHANGED
@@ -47,6 +47,12 @@ const DEFAULT_CONFIG = {
47
47
  budget: 1500,
48
48
  skipUnchanged: true,
49
49
  refreshTurns: 10,
50
+ promptRecall: false,
51
+ promptRecallMetric: 'jaccard',
52
+ promptRecallThreshold: 0.04,
53
+ promptRecallMinShared: 2,
54
+ promptRecallMaxItems: 5,
55
+ promptRecallCandidates: 100,
50
56
  },
51
57
  contextProjectIsolation: true,
52
58
  extraction: {
@@ -0,0 +1,33 @@
1
+ export type PromptRecallMetric = 'jaccard' | 'cosine';
2
+ export interface PromptRecallGate {
3
+ metric: PromptRecallMetric;
4
+ threshold: number;
5
+ minShared: number;
6
+ maxItems: number;
7
+ }
8
+ export declare const PROMPT_RECALL_MAX_CHARS = 4000;
9
+ /** Distinct content tokens: longer than 2 chars, not a stop word. */
10
+ export declare function contentTokens(text: string): Set<string>;
11
+ export declare function promptTokens(prompt: string): Set<string>;
12
+ export interface OverlapScore {
13
+ score: number;
14
+ shared: number;
15
+ }
16
+ /** Overlap of two token sets under `metric`. 0 when either set is empty. */
17
+ export declare function scoreOverlap(p: ReadonlySet<string>, m: ReadonlySet<string>, metric: PromptRecallMetric): OverlapScore;
18
+ /** Candidates that clear the gate, sorted score desc then id asc, capped at `gate.maxItems`. */
19
+ export declare function gatePromptRecall<T extends {
20
+ id: string;
21
+ tokens: ReadonlySet<string>;
22
+ }>(p: ReadonlySet<string>, candidates: readonly T[], gate: PromptRecallGate): Array<{
23
+ item: T;
24
+ score: number;
25
+ shared: number;
26
+ }>;
27
+ /** The prompt's content tokens as an FTS pre-select query, capped at `maxTerms`. */
28
+ export declare function promptRecallFtsQuery(p: ReadonlySet<string>, maxTerms?: number): string;
29
+ export declare const RAREST_TERM_COUNT = 8;
30
+ /** Terms sorted by ascending FTS doc count (rarest first), zero-count terms dropped, ties by term.
31
+ * Pure: `docCount` (an FTS lookup in the caller) does the only I/O. */
32
+ export declare function rarestPromptTerms(terms: Iterable<string>, docCount: (term: string) => number, maxTerms?: number): string[];
33
+ //# sourceMappingURL=prompt-recall.d.ts.map
@@ -0,0 +1,68 @@
1
+ /** Z1: recall gated on the hook prompt, not the five newest memories (pure, no I/O).
2
+ * See docs/plans/2026-09-26-z1-prompt-recall.md. */
3
+ import { tokenize } from './search.js';
4
+ import { STOP_WORDS } from './audit.js';
5
+ // Latency bound, not tuned: fixed in the prereg regardless of gate config.
6
+ export const PROMPT_RECALL_MAX_CHARS = 4000;
7
+ /** Distinct content tokens: longer than 2 chars, not a stop word. */
8
+ export function contentTokens(text) {
9
+ const out = new Set();
10
+ for (const t of tokenize(text)) {
11
+ if (t.length > 2 && !STOP_WORDS.has(t))
12
+ out.add(t);
13
+ }
14
+ return out;
15
+ }
16
+ export function promptTokens(prompt) {
17
+ return contentTokens(prompt.slice(0, PROMPT_RECALL_MAX_CHARS));
18
+ }
19
+ /** Overlap of two token sets under `metric`. 0 when either set is empty. */
20
+ export function scoreOverlap(p, m, metric) {
21
+ if (p.size === 0 || m.size === 0)
22
+ return { score: 0, shared: 0 };
23
+ const [small, large] = p.size <= m.size ? [p, m] : [m, p];
24
+ let shared = 0;
25
+ for (const t of small)
26
+ if (large.has(t))
27
+ shared++;
28
+ const score = metric === 'jaccard'
29
+ ? shared / (p.size + m.size - shared)
30
+ : shared / Math.sqrt(p.size * m.size);
31
+ return { score, shared };
32
+ }
33
+ /** Candidates that clear the gate, sorted score desc then id asc, capped at `gate.maxItems`. */
34
+ export function gatePromptRecall(p, candidates, gate) {
35
+ if (p.size === 0)
36
+ return [];
37
+ const kept = [];
38
+ for (const item of candidates) {
39
+ const { score, shared } = scoreOverlap(p, item.tokens, gate.metric);
40
+ if (score >= gate.threshold && shared >= gate.minShared)
41
+ kept.push({ item, score, shared });
42
+ }
43
+ kept.sort((a, b) => (b.score - a.score) || (a.item.id < b.item.id ? -1 : a.item.id > b.item.id ? 1 : 0));
44
+ return kept.slice(0, gate.maxItems);
45
+ }
46
+ /** The prompt's content tokens as an FTS pre-select query, capped at `maxTerms`. */
47
+ export function promptRecallFtsQuery(p, maxTerms = 32) {
48
+ const terms = [];
49
+ for (const t of p) {
50
+ if (terms.length >= maxTerms)
51
+ break;
52
+ terms.push(t);
53
+ }
54
+ return terms.join(' ');
55
+ }
56
+ // bm25 ranks every row matching any term, so fewer and rarer terms bound latency; 8 is a bound, not tuned.
57
+ export const RAREST_TERM_COUNT = 8;
58
+ /** Terms sorted by ascending FTS doc count (rarest first), zero-count terms dropped, ties by term.
59
+ * Pure: `docCount` (an FTS lookup in the caller) does the only I/O. */
60
+ export function rarestPromptTerms(terms, docCount, maxTerms = RAREST_TERM_COUNT) {
61
+ return Array.from(terms)
62
+ .map((t) => ({ t, c: docCount(t) }))
63
+ .filter((x) => x.c > 0)
64
+ .sort((a, b) => (a.c - b.c) || (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
65
+ .slice(0, maxTerms)
66
+ .map((x) => x.t);
67
+ }
68
+ //# sourceMappingURL=prompt-recall.js.map
package/dist/store.d.ts CHANGED
@@ -445,6 +445,10 @@ export declare function loadSearchEntries(hippoRoot: string, query: string, limi
445
445
  * undefined as "no tenant filter" for legacy callers.
446
446
  */
447
447
  export declare function loadRecallSearchEntries(hippoRoot: string, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean): MemoryEntry[];
448
+ export declare function loadRecallSearchEntriesFromDb(db: DatabaseSyncLike, query: string, limit?: number, tenantId?: string, requestedScope?: string, explicitScopeMode?: 'exact' | 'additive', includeSuperseded?: boolean): MemoryEntry[];
449
+ /** Rarest-K prompt terms for this connection's FTS index, as a space-joined query string.
450
+ * Without FTS, returns the first 32 terms, as before rarest-term selection. */
451
+ export declare function pickRarestFtsQuery(db: DatabaseSyncLike, terms: readonly string[], maxTerms?: number): string;
448
452
  /**
449
453
  * Rebuild mirrors from SQLite, importing any legacy markdown files not already present.
450
454
  */
package/dist/store.js CHANGED
@@ -13,6 +13,7 @@ import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
13
13
  import { CARD_TRANSITIONS, CARD_LEASE_MS } from './card.js';
14
14
  import { tokenize, markRetrieved } from './search.js';
15
15
  import { isRecallBoostAblated } from './ablation.js';
16
+ import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
16
17
  import { appendAuditEvent } from './audit.js';
17
18
  import { resolveTenantId } from './tenant.js';
18
19
  import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
@@ -1896,21 +1897,46 @@ export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH
1896
1897
  initStore(hippoRoot);
1897
1898
  const db = openHippoDb(hippoRoot);
1898
1899
  try {
1899
- // explicitScopeMode only matters when requestedScope is set:
1900
- // 'exact' — api.recall semantics: narrow to m.scope = requested.
1901
- // 'additive' — CLI --scope semantics (v1.25.0): default-admitted set
1902
- // PLUS the requested scope; see RecallScopeFilter docs.
1903
- const scopeFilter = requestedScope && requestedScope !== ''
1904
- ? explicitScopeMode === 'additive'
1905
- ? { mode: 'default-deny-or-exact', value: requestedScope }
1906
- : { mode: 'exact', value: requestedScope }
1907
- : { mode: 'default-deny' };
1908
- return loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded).map(rowToEntry);
1900
+ return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded);
1909
1901
  }
1910
1902
  finally {
1911
1903
  closeHippoDb(db);
1912
1904
  }
1913
1905
  }
1906
+ // Split out so callers with an already-open db (Z1 prompt-recall path) skip
1907
+ // the initStore+open/close cycle per store per call.
1908
+ export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true) {
1909
+ // 'exact' narrows to requestedScope; 'additive' adds it to the default-admitted set.
1910
+ const scopeFilter = requestedScope && requestedScope !== ''
1911
+ ? explicitScopeMode === 'additive'
1912
+ ? { mode: 'default-deny-or-exact', value: requestedScope }
1913
+ : { mode: 'exact', value: requestedScope }
1914
+ : { mode: 'default-deny' };
1915
+ return loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded).map(rowToEntry);
1916
+ }
1917
+ /** Rarest-K prompt terms for this connection's FTS index, as a space-joined query string.
1918
+ * Without FTS, returns the first 32 terms, as before rarest-term selection. */
1919
+ export function pickRarestFtsQuery(db, terms, maxTerms = RAREST_TERM_COUNT) {
1920
+ // The LIKE path has no bm25 ranking to bound, so it keeps the pre-rarest 32-term query.
1921
+ if (!isFtsAvailable(db))
1922
+ return terms.slice(0, 32).join(' ');
1923
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.z1_rarest_vocab USING fts5vocab(main, 'memories_fts', 'row')`);
1924
+ // unicode61 splits `journal_mode` into two vocab terms; a term's count is its rarest part's (an upper bound).
1925
+ const partsOf = (t) => t.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
1926
+ const vocab = Array.from(new Set(terms.flatMap(partsOf)));
1927
+ if (vocab.length === 0)
1928
+ return '';
1929
+ // SAFETY: rows' shape matches the two columns named in the SELECT.
1930
+ const rows = db
1931
+ .prepare(`SELECT term, doc FROM temp.z1_rarest_vocab WHERE term IN (${vocab.map(() => '?').join(', ')})`)
1932
+ .all(...vocab);
1933
+ const counts = new Map(rows.map((r) => [r.term, r.doc]));
1934
+ const docCount = (t) => {
1935
+ const parts = partsOf(t);
1936
+ return parts.length === 0 ? 0 : Math.min(...parts.map((x) => counts.get(x) ?? 0));
1937
+ };
1938
+ return rarestPromptTerms(terms, docCount, maxTerms).join(' ');
1939
+ }
1914
1940
  /**
1915
1941
  * Rebuild mirrors from SQLite, importing any legacy markdown files not already present.
1916
1942
  */
@@ -2,11 +2,12 @@ import type { DatabaseSyncLike } from './db.js';
2
2
  /**
3
3
  * Where a block of memory text was sent.
4
4
  * - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
5
+ * - `hook_recall`: the same hook's Z1 prompt-recall section (docs/plans/2026-09-26-z1-prompt-recall.md).
5
6
  * - `context`, `recall`: the CLI commands.
6
7
  * - `mcp_recall`, `mcp_context`: the MCP tools.
7
8
  * - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
8
9
  */
9
- export type TokenSurface = 'hook' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
10
+ export type TokenSurface = 'hook' | 'hook_recall' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
10
11
  /** All surfaces, in report order. */
11
12
  export declare const TOKEN_SURFACES: readonly TokenSurface[];
12
13
  /**
@@ -21,7 +21,7 @@
21
21
  import { createHash } from 'node:crypto';
22
22
  /** All surfaces, in report order. */
23
23
  export const TOKEN_SURFACES = [
24
- 'hook', 'context', 'recall', 'mcp_recall', 'mcp_context',
24
+ 'hook', 'hook_recall', 'context', 'recall', 'mcp_recall', 'mcp_context',
25
25
  'http_recall', 'http_context', 'http_assemble',
26
26
  ];
27
27
  /** Rows older than this are pruned on write. */
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.52.0";
19
+ export declare const PACKAGE_VERSION = "1.52.1";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.52.0';
19
+ export const PACKAGE_VERSION = '1.52.1';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.52.0",
5
+ "version": "1.52.1",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.52.0",
3
+ "version": "1.52.1",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.52.0",
5
+ "version": "1.52.1",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.52.0",
3
+ "version": "1.52.1",
4
4
  "description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",