hippo-memory 1.52.0 → 1.52.2
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 +4 -0
- package/dist/api.js +71 -13
- package/dist/audit.d.ts +1 -0
- package/dist/audit.js +1 -1
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +6 -1
- package/dist/capture-error.js +2 -2
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +71 -27
- package/dist/config.d.ts +15 -0
- package/dist/config.js +6 -0
- package/dist/db.js +4 -3
- package/dist/half-life-migration.js +2 -3
- package/dist/prompt-recall.d.ts +33 -0
- package/dist/prompt-recall.js +68 -0
- package/dist/reject-flow.js +5 -8
- package/dist/store.d.ts +16 -1
- package/dist/store.js +131 -133
- package/dist/token-ledger.d.ts +2 -1
- package/dist/token-ledger.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/working-memory.js +5 -8
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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
|
@@ -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';
|
|
@@ -132,14 +133,14 @@ export function ambientSecretAdmit(e, currentProjectName) {
|
|
|
132
133
|
return false;
|
|
133
134
|
return origin === currentProjectName;
|
|
134
135
|
}
|
|
135
|
-
// The pinned-only branch needs pins and recent-N candidates, not the corpus.
|
|
136
|
-
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit) {
|
|
136
|
+
// The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
|
|
137
|
+
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall) {
|
|
137
138
|
if (!pinnedOnly)
|
|
138
|
-
return loadAllEntries(hippoRoot, tenantId).filter(admit);
|
|
139
|
+
return { entries: loadAllEntries(hippoRoot, tenantId).filter(admit) };
|
|
139
140
|
// DF3's quality floor runs on the recent-N slice AFTER this load, so the load
|
|
140
141
|
// counts by it too, or it stops short of a store whose newest rows are junk.
|
|
141
142
|
const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
|
|
142
|
-
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient);
|
|
143
|
+
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
|
|
143
144
|
}
|
|
144
145
|
export function remember(ctx, opts) {
|
|
145
146
|
const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
|
|
@@ -1490,6 +1491,7 @@ export function auditList(ctx, opts) {
|
|
|
1490
1491
|
closeHippoDb(db);
|
|
1491
1492
|
}
|
|
1492
1493
|
}
|
|
1494
|
+
const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
|
|
1493
1495
|
/**
|
|
1494
1496
|
* Assemble a context bundle: recalled memories (pinned-only / strength-sorted
|
|
1495
1497
|
* fallback / hybrid search) + active task snapshot + session handoff + recent
|
|
@@ -1539,13 +1541,24 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1539
1541
|
// secrets, so WHICH rows reach this predicate is what loadAmbientEntries cares
|
|
1540
1542
|
// about below.
|
|
1541
1543
|
const admit = (e) => !e.superseded_by && ambientAdmit(e);
|
|
1542
|
-
//
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit)
|
|
1544
|
+
// Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
|
|
1545
|
+
// branch) can piggyback on that connection instead of opening its own.
|
|
1546
|
+
const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
|
|
1547
|
+
const promptRecallTerms = promptRecallPending && config.pinnedInject.enabled
|
|
1548
|
+
? Array.from(promptTokens(opts.prompt ?? ''))
|
|
1548
1549
|
: [];
|
|
1550
|
+
const recallRequest = promptRecallTerms.length > 0
|
|
1551
|
+
? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
|
|
1552
|
+
: undefined;
|
|
1553
|
+
// Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
|
|
1554
|
+
const localLoad = hasLocal
|
|
1555
|
+
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, recallRequest)
|
|
1556
|
+
: { entries: [] };
|
|
1557
|
+
const globalLoad = hasGlobal
|
|
1558
|
+
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit, !isGlobalStoreRoot(ctx.hippoRoot) ? recallRequest : undefined)
|
|
1559
|
+
: { entries: [] };
|
|
1560
|
+
let localEntries = localLoad.entries;
|
|
1561
|
+
let globalEntries = globalLoad.entries;
|
|
1549
1562
|
// Computed below, after markRetrieved runs, so avgStrength reflects the
|
|
1550
1563
|
// post-retrieval strengths rather than a stale pre-mutation snapshot.
|
|
1551
1564
|
let ambientState;
|
|
@@ -1587,7 +1600,8 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1587
1600
|
limit: 5,
|
|
1588
1601
|
}).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
|
|
1589
1602
|
: [];
|
|
1590
|
-
if (
|
|
1603
|
+
if (!promptRecallPending &&
|
|
1604
|
+
localEntries.length === 0 &&
|
|
1591
1605
|
globalEntries.length === 0 &&
|
|
1592
1606
|
!activeSnapshot &&
|
|
1593
1607
|
!sessionHandoff &&
|
|
@@ -1654,7 +1668,51 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1654
1668
|
// under-fills recents slightly -- it never displaces a pin -- so it is
|
|
1655
1669
|
// the safe direction and is not worth extra bookkeeping to recover.
|
|
1656
1670
|
const recentBudget = Math.max(0, effBudget - pinnedReserve);
|
|
1657
|
-
|
|
1671
|
+
// Z1: gate the backfill on the prompt instead of recency (docs/plans/2026-09-26-z1-prompt-recall.md).
|
|
1672
|
+
const promptRecallOn = promptRecallPending;
|
|
1673
|
+
if (promptRecallOn) {
|
|
1674
|
+
const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
|
|
1675
|
+
const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
|
|
1676
|
+
const gate = {
|
|
1677
|
+
metric,
|
|
1678
|
+
threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
|
|
1679
|
+
minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
|
|
1680
|
+
maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
|
|
1681
|
+
};
|
|
1682
|
+
const p = promptTokens(opts.prompt ?? '');
|
|
1683
|
+
if (p.size > 0) {
|
|
1684
|
+
// Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
|
|
1685
|
+
const localCandidates = localLoad.recall ?? [];
|
|
1686
|
+
const globalCandidates = globalLoad.recall ?? [];
|
|
1687
|
+
const seenCandidateIds = new Set();
|
|
1688
|
+
const candidateItems = [];
|
|
1689
|
+
// Local wins the id collision (a global row synced into the local store).
|
|
1690
|
+
for (const e of localCandidates) {
|
|
1691
|
+
if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
|
|
1692
|
+
continue;
|
|
1693
|
+
seenCandidateIds.add(e.id);
|
|
1694
|
+
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: false });
|
|
1695
|
+
}
|
|
1696
|
+
for (const e of globalCandidates) {
|
|
1697
|
+
if (!admit(e) || e.pinned || !isContentWorthStoring(e.content) || seenCandidateIds.has(e.id))
|
|
1698
|
+
continue;
|
|
1699
|
+
seenCandidateIds.add(e.id);
|
|
1700
|
+
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
|
|
1701
|
+
}
|
|
1702
|
+
const gated = gatePromptRecall(p, candidateItems, gate);
|
|
1703
|
+
for (const g of gated) {
|
|
1704
|
+
if (selectedIds.has(g.item.id))
|
|
1705
|
+
continue;
|
|
1706
|
+
const tokens = estimateTokens(g.item.entry.content);
|
|
1707
|
+
if (usedP + tokens > recentBudget)
|
|
1708
|
+
continue;
|
|
1709
|
+
selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
|
|
1710
|
+
selectedIds.add(g.item.id);
|
|
1711
|
+
usedP += tokens;
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
else if (includeRecent > 0) {
|
|
1658
1716
|
const recent = [
|
|
1659
1717
|
...localEntries.map((entry) => ({ entry, isGlobal: false })),
|
|
1660
1718
|
...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
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
|
-
|
|
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/capture-error.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// stored, because error memories decay slowly and would crowd out real lessons; what is stored stays `observed`
|
|
3
3
|
// until outcome feedback confirms it. Every failure, stored or not, goes to the failure log (ROADMAP CD13).
|
|
4
4
|
import { createMemory } from './memory.js';
|
|
5
|
-
import { writeEntry,
|
|
5
|
+
import { writeEntry, loadContentsWithTag } from './store.js';
|
|
6
6
|
import { loadConfig } from './config.js';
|
|
7
7
|
import { closeHippoDb, openHippoDb } from './db.js';
|
|
8
8
|
import { recordFailure } from './failure-log.js';
|
|
@@ -94,7 +94,7 @@ function logFailure(hippoRoot, tenantId, payload, lesson, outcome) {
|
|
|
94
94
|
}
|
|
95
95
|
function storeLesson(hippoRoot, tenantId, text) {
|
|
96
96
|
const sig = failureSignature(text);
|
|
97
|
-
const repeat =
|
|
97
|
+
const repeat = loadContentsWithTag(hippoRoot, tenantId, 'auto-captured').some((content) => failureSignature(content) === sig);
|
|
98
98
|
if (repeat)
|
|
99
99
|
return 'duplicate';
|
|
100
100
|
const entry = createMemory(text, {
|
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
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
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
|
-
//
|
|
6404
|
-
//
|
|
6405
|
-
const
|
|
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,77 @@ 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 (
|
|
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(
|
|
6430
|
+
printContextMarkdown(staticItems, staticHeaderTokens, framing, { showStrength: false });
|
|
6417
6431
|
}
|
|
6418
|
-
printCrossProjectSection(
|
|
6432
|
+
printCrossProjectSection(staticCrossEntries);
|
|
6419
6433
|
});
|
|
6420
|
-
|
|
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
|
-
|
|
6424
|
-
|
|
6425
|
-
//
|
|
6426
|
-
//
|
|
6427
|
-
|
|
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,
|
|
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:
|
|
6456
|
+
items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
|
|
6440
6457
|
}));
|
|
6441
|
-
|
|
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
|
|
6471
|
+
additionalContext,
|
|
6449
6472
|
},
|
|
6450
6473
|
};
|
|
6451
6474
|
process.stdout.write(JSON.stringify(payload));
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6475
|
+
if (finalStatic || recallBlock) {
|
|
6476
|
+
// One connection for both rows; each insert in its own try so one failing doesn't skip the other.
|
|
6477
|
+
withLedgerDb(hippoRoot, (db) => {
|
|
6478
|
+
if (finalStatic) {
|
|
6479
|
+
try {
|
|
6480
|
+
recordTokenUse(db, {
|
|
6481
|
+
tenantId: ctx.tenantId, sessionId: currentSessionId, surface, event: 'inject',
|
|
6482
|
+
items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
|
|
6483
|
+
});
|
|
6484
|
+
}
|
|
6485
|
+
catch { /* best effort; see withLedgerDb doc comment */ }
|
|
6486
|
+
}
|
|
6487
|
+
if (recallBlock) {
|
|
6488
|
+
try {
|
|
6489
|
+
recordTokenUse(db, {
|
|
6490
|
+
tenantId: ctx.tenantId, sessionId: currentSessionId, surface: 'hook_recall', event: 'inject',
|
|
6491
|
+
items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
|
|
6492
|
+
});
|
|
6493
|
+
}
|
|
6494
|
+
catch { /* best effort; see withLedgerDb doc comment */ }
|
|
6495
|
+
}
|
|
6496
|
+
});
|
|
6497
|
+
}
|
|
6456
6498
|
}
|
|
6457
6499
|
else {
|
|
6458
6500
|
// markdown (default)
|
|
@@ -6577,7 +6619,8 @@ function printCrossProjectSection(items) {
|
|
|
6577
6619
|
export function printContextMarkdown(items, totalTokens, framing = 'observe', opts = {}) {
|
|
6578
6620
|
const now = evalNow();
|
|
6579
6621
|
const showStrength = opts.showStrength !== false;
|
|
6580
|
-
|
|
6622
|
+
const heading = opts.heading ?? 'Project Memory';
|
|
6623
|
+
console.log(`## ${heading} (${items.length} entries, ${totalTokens} tokens)\n`);
|
|
6581
6624
|
for (const item of items) {
|
|
6582
6625
|
const e = item.entry;
|
|
6583
6626
|
const tagStr = e.tags.length > 0 ? ` [${e.tags.join(', ')}]` : '';
|
|
@@ -8744,6 +8787,7 @@ Commands:
|
|
|
8744
8787
|
--budget <n> Token budget (default: 1500)
|
|
8745
8788
|
--pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
|
|
8746
8789
|
--include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
|
|
8790
|
+
(the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
|
|
8747
8791
|
--format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
|
|
8748
8792
|
--framing <mode> Framing: observe (default), suggest, assert
|
|
8749
8793
|
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: {
|
package/dist/db.js
CHANGED
|
@@ -2819,9 +2819,10 @@ function backfillFtsIndex(db) {
|
|
|
2819
2819
|
// SAFETY: this get() result's shape matches the single aliased `c` column
|
|
2820
2820
|
// named in the SELECT above.
|
|
2821
2821
|
const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
|
|
2822
|
-
// SAFETY: this get() result's shape matches the single aliased `c` column
|
|
2823
|
-
//
|
|
2824
|
-
const
|
|
2822
|
+
// SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT above.
|
|
2823
|
+
// memories_fts_docsize is FTS5's cheaper one-row-per-document shadow table; fall back for a foreign-built index.
|
|
2824
|
+
const ftsTable = tableExists(db, 'memories_fts_docsize') ? 'memories_fts_docsize' : 'memories_fts';
|
|
2825
|
+
const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM ${ftsTable}`).get()?.c ?? 0;
|
|
2825
2826
|
if (memCount === ftsCount)
|
|
2826
2827
|
return;
|
|
2827
2828
|
db.exec(`
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* (7 days when never recorded) to the configured `defaultHalfLifeDays`.
|
|
21
21
|
*/
|
|
22
22
|
import { deriveHalfLife } from './memory.js';
|
|
23
|
-
import {
|
|
23
|
+
import { openStore, selectAllEntries, HALF_LIFE_BASE_META_KEY } from './store.js';
|
|
24
24
|
import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
|
|
25
25
|
import { appendAuditEvent } from './audit.js';
|
|
26
26
|
/** The base every store used before the base was recorded. */
|
|
@@ -65,8 +65,7 @@ function readBase(db) {
|
|
|
65
65
|
export function migrateDefaultHalfLife(hippoRoot, to, opts = {}) {
|
|
66
66
|
const dryRun = opts.dryRun ?? false;
|
|
67
67
|
const noop = (from) => ({ from, to, rescaled: 0, kept: 0, dryRun, halfLives: new Map() });
|
|
68
|
-
|
|
69
|
-
const db = openHippoDb(hippoRoot);
|
|
68
|
+
const db = openStore(hippoRoot);
|
|
70
69
|
try {
|
|
71
70
|
// Plan, write, audit and record the base under one write lock, so a concurrent write or sleep cannot interleave.
|
|
72
71
|
if (!dryRun)
|
|
@@ -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/reject-flow.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* raw-archive.ts and dormant.ts. Nothing imports FROM this file except
|
|
12
12
|
* cli.ts and api.ts, so it introduces no cycle.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import { closeHippoDb } from './db.js';
|
|
15
15
|
import { appendAuditEvent } from './audit.js';
|
|
16
16
|
import { archiveRawMemory } from './raw-archive.js';
|
|
17
17
|
import { purgeDormantByDigest } from './dormant.js';
|
|
18
|
-
import {
|
|
18
|
+
import { openStore, deleteEntryCore, purgeMirrorBestEffort, } from './store.js';
|
|
19
19
|
import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
|
|
20
20
|
/**
|
|
21
21
|
* `hippo reject` / `api.reject` core flow. ONE connection, one transaction:
|
|
@@ -46,8 +46,7 @@ export function rejectValue(opts) {
|
|
|
46
46
|
// and pollute the listing.
|
|
47
47
|
throw new Error('reject --value requires non-empty content.');
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
const db = openHippoDb(opts.hippoRoot);
|
|
49
|
+
const db = openStore(opts.hippoRoot);
|
|
51
50
|
try {
|
|
52
51
|
let content;
|
|
53
52
|
if (opts.memoryId !== undefined) {
|
|
@@ -170,8 +169,7 @@ export function unrejectValue(hippoRoot, tenantId, digestOrPrefix, actor) {
|
|
|
170
169
|
if (digestOrPrefix.trim().length === 0) {
|
|
171
170
|
return { status: 'not_found' };
|
|
172
171
|
}
|
|
173
|
-
|
|
174
|
-
const db = openHippoDb(hippoRoot);
|
|
172
|
+
const db = openStore(hippoRoot);
|
|
175
173
|
try {
|
|
176
174
|
const all = listRejectedValues(db, tenantId);
|
|
177
175
|
const matches = all.filter((r) => r.digest.startsWith(digestOrPrefix));
|
|
@@ -201,8 +199,7 @@ export function unrejectValue(hippoRoot, tenantId, digestOrPrefix, actor) {
|
|
|
201
199
|
}
|
|
202
200
|
/** `hippo rejections` / `api.listRejections` — list tombstones for a tenant. */
|
|
203
201
|
export function listRejectionsForTenant(hippoRoot, tenantId) {
|
|
204
|
-
|
|
205
|
-
const db = openHippoDb(hippoRoot);
|
|
202
|
+
const db = openStore(hippoRoot);
|
|
206
203
|
try {
|
|
207
204
|
return listRejectedValues(db, tenantId);
|
|
208
205
|
}
|