hippo-memory 1.55.0 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/api.d.ts +3 -0
- package/dist/api.js +55 -11
- package/dist/cli.js +90 -17
- package/dist/config.d.ts +5 -0
- package/dist/config.js +21 -0
- package/dist/db.js +67 -1
- package/dist/delivery-recorder.d.ts +127 -0
- package/dist/delivery-recorder.js +218 -0
- package/dist/recall-trace.d.ts +69 -0
- package/dist/recall-trace.js +136 -0
- package/dist/token-ledger.d.ts +2 -0
- package/dist/token-ledger.js +5 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 +2 -1
package/README.md
CHANGED
|
@@ -548,6 +548,17 @@ crashed, shows what was sent only. Re-reads usually bill at the provider's cache
|
|
|
548
548
|
a fraction of the full input price. Counts are estimates (characters / 4), the same estimate
|
|
549
549
|
every budget uses. Rows older than 90 days are pruned.
|
|
550
550
|
|
|
551
|
+
**See why a memory did or did not reach the agent.** Turn on the delivery ledger with
|
|
552
|
+
`{"deliveryLedger":{"enabled":true}}` in `.hippo/config.json` (off by default). The flag is
|
|
553
|
+
read from the store the token ledger writes to: the project's local store when it has one,
|
|
554
|
+
else the global store. Each per-prompt hook call then records one event (session, turn
|
|
555
|
+
number, whether the block was sent, reused, empty or disabled, counts and token totals) and
|
|
556
|
+
one row per candidate memory: emitted, reused or rejected, with the stage and the
|
|
557
|
+
reason it was dropped. With prompt recall on, recent memories dropped by the quality filter
|
|
558
|
+
are not recorded yet. It holds ids, hashes, counts and reasons only, never prompt or memory
|
|
559
|
+
text; the prompt hash is unsalted, so a very short prompt can be guessed. A ledger failure prints one stderr line and never changes what the hook prints. Rows
|
|
560
|
+
older than 90 days are pruned; at a heavy 300 prompts a day that is about 190 MB per store.
|
|
561
|
+
|
|
551
562
|
---
|
|
552
563
|
|
|
553
564
|
### Outcome feedback
|
package/dist/api.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { type SessionHandoff } from './handoff.js';
|
|
|
17
17
|
import { type MemoryKind, type MemoryEntry } from './memory.js';
|
|
18
18
|
import { auditMemories, type AuditEvent, type AuditOp } from './audit.js';
|
|
19
19
|
import { autoShare } from './shared.js';
|
|
20
|
+
import type { DeliveryObserver } from './delivery-recorder.js';
|
|
20
21
|
import { type ApiKeyListItem } from './auth.js';
|
|
21
22
|
import { type RerankStep } from './search.js';
|
|
22
23
|
import { consolidate } from './consolidate.js';
|
|
@@ -974,6 +975,8 @@ export interface ContextOpts {
|
|
|
974
975
|
prompt?: string;
|
|
975
976
|
/** What the budget pays for, from the caller that renders the block. Absent = the memory text alone. */
|
|
976
977
|
cost?: ContextCost;
|
|
978
|
+
/** @internal The CLI's delivery-ledger observer; it only reads, so selection is the same with or without it. */
|
|
979
|
+
deliveryObserver?: DeliveryObserver;
|
|
977
980
|
}
|
|
978
981
|
/** Budget prices in the text a caller prints, so the budget bounds what reaches the model. */
|
|
979
982
|
export interface ContextCost {
|
package/dist/api.js
CHANGED
|
@@ -135,12 +135,19 @@ export function ambientSecretAdmit(e, currentProjectName) {
|
|
|
135
135
|
return origin === currentProjectName;
|
|
136
136
|
}
|
|
137
137
|
// The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
|
|
138
|
-
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall) {
|
|
138
|
+
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop) {
|
|
139
139
|
if (!pinnedOnly)
|
|
140
140
|
return { entries: loadAllEntries(hippoRoot, tenantId).filter(admit) };
|
|
141
141
|
// DF3's quality floor runs on the recent-N slice AFTER this load, so the load
|
|
142
142
|
// counts by it too, or it stops short of a store whose newest rows are junk.
|
|
143
|
-
const admitAmbient = (e) =>
|
|
143
|
+
const admitAmbient = (e) => {
|
|
144
|
+
if (!admit(e))
|
|
145
|
+
return false;
|
|
146
|
+
if (e.pinned || isContentWorthStoring(e.content))
|
|
147
|
+
return true;
|
|
148
|
+
onQualityDrop?.(e);
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
144
151
|
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
|
|
145
152
|
}
|
|
146
153
|
// Share and promote copy a memory to the global store under a new id, so equal content is the only link.
|
|
@@ -1579,6 +1586,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1579
1586
|
? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
|
|
1580
1587
|
: estimateTokens(entry.content);
|
|
1581
1588
|
const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
|
|
1589
|
+
const obs = opts.deliveryObserver;
|
|
1590
|
+
obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
|
|
1591
|
+
if (pinnedOnly && !config.pinnedInject.enabled)
|
|
1592
|
+
obs?.disabled();
|
|
1582
1593
|
let left = cost
|
|
1583
1594
|
? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
|
|
1584
1595
|
: blockBudget;
|
|
@@ -1630,6 +1641,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1630
1641
|
const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
|
|
1631
1642
|
const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
|
|
1632
1643
|
const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
|
|
1644
|
+
obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
|
|
1633
1645
|
const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
|
|
1634
1646
|
let digestHiddenForHandoff = false;
|
|
1635
1647
|
const ambientAdmit = (e) => {
|
|
@@ -1647,12 +1659,14 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1647
1659
|
e.tags.includes(COMPACTION_MEMORY_TAG);
|
|
1648
1660
|
// Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
|
|
1649
1661
|
const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
|
|
1662
|
+
const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
|
|
1663
|
+
const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
|
|
1650
1664
|
// Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
|
|
1651
1665
|
const localLoad = hasLocal
|
|
1652
|
-
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent,
|
|
1666
|
+
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal))
|
|
1653
1667
|
: { entries: [] };
|
|
1654
1668
|
const globalLoad = hasGlobal && !primaryIsGlobal
|
|
1655
|
-
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent,
|
|
1669
|
+
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true))
|
|
1656
1670
|
: { entries: [] };
|
|
1657
1671
|
let localEntries = localLoad.entries;
|
|
1658
1672
|
let globalEntries = globalLoad.entries;
|
|
@@ -1677,7 +1691,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1677
1691
|
// Effective budget: explicit opts.budget wins over config, less what the sections took.
|
|
1678
1692
|
const effBudget = left;
|
|
1679
1693
|
const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
1694
|
+
obs?.offer(localEntries, primaryIsGlobal);
|
|
1695
|
+
obs?.offer(globalEntries, true);
|
|
1680
1696
|
const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
|
|
1697
|
+
obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
|
|
1681
1698
|
const selectedIds = new Set();
|
|
1682
1699
|
let usedP = 0;
|
|
1683
1700
|
// Pinned entries are explicit user intent, the recent-N list an automatic
|
|
@@ -1743,8 +1760,23 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1743
1760
|
// Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
|
|
1744
1761
|
// A candidate carrying a pin's text would inject that memory a second time.
|
|
1745
1762
|
const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1763
|
+
const ineligibleReason = (e) => !admit(e) ? 'scope'
|
|
1764
|
+
: e.pinned ? 'pinned'
|
|
1765
|
+
: !isContentWorthStoring(e.content) ? 'quality'
|
|
1766
|
+
: pinnedText.has(e.content) ? 'duplicate'
|
|
1767
|
+
: null;
|
|
1768
|
+
const eligible = (e) => {
|
|
1769
|
+
const why = ineligibleReason(e);
|
|
1770
|
+
if (why !== null && why !== 'pinned')
|
|
1771
|
+
obs?.reject(e, 'eligible', why);
|
|
1772
|
+
return why === null;
|
|
1773
|
+
};
|
|
1774
|
+
obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
|
|
1775
|
+
obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
|
|
1776
|
+
const localEligible = (localLoad.recall ?? []).filter(eligible);
|
|
1777
|
+
const globalEligible = (globalLoad.recall ?? []).filter(eligible);
|
|
1778
|
+
const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
|
|
1779
|
+
obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
|
|
1748
1780
|
const seenCandidateIds = new Set();
|
|
1749
1781
|
const candidateItems = [];
|
|
1750
1782
|
// Local wins the id collision (a global row synced into the local store).
|
|
@@ -1761,12 +1793,15 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1761
1793
|
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
|
|
1762
1794
|
}
|
|
1763
1795
|
const gated = gatePromptRecall(p, candidateItems, gate);
|
|
1796
|
+
obs?.gated(p, candidateItems, gate, gated);
|
|
1764
1797
|
for (const g of gated) {
|
|
1765
1798
|
if (selectedIds.has(g.item.id))
|
|
1766
1799
|
continue;
|
|
1767
1800
|
const tokens = price(g.item.entry, g.item.isGlobal, true);
|
|
1768
|
-
if (usedP + tokens > recentBudget)
|
|
1801
|
+
if (usedP + tokens > recentBudget) {
|
|
1802
|
+
obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
|
|
1769
1803
|
continue;
|
|
1804
|
+
}
|
|
1770
1805
|
selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
|
|
1771
1806
|
selectedIds.add(g.item.id);
|
|
1772
1807
|
usedP += tokens;
|
|
@@ -1818,8 +1853,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1818
1853
|
for (const r of recent) {
|
|
1819
1854
|
if (selectedIds.has(r.entry.id))
|
|
1820
1855
|
continue;
|
|
1821
|
-
if (usedP + r.tokens > recentBudget)
|
|
1856
|
+
if (usedP + r.tokens > recentBudget) {
|
|
1857
|
+
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
1822
1858
|
continue;
|
|
1859
|
+
}
|
|
1823
1860
|
selectedItems.push(r);
|
|
1824
1861
|
selectedIds.add(r.entry.id);
|
|
1825
1862
|
usedP += r.tokens;
|
|
@@ -1834,8 +1871,10 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1834
1871
|
for (const r of rankedPinned) {
|
|
1835
1872
|
if (selectedIds.has(r.entry.id))
|
|
1836
1873
|
continue;
|
|
1837
|
-
if (usedP + r.tokens > effBudget)
|
|
1874
|
+
if (usedP + r.tokens > effBudget) {
|
|
1875
|
+
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
1838
1876
|
continue;
|
|
1877
|
+
}
|
|
1839
1878
|
selectedItems.push(r);
|
|
1840
1879
|
selectedIds.add(r.entry.id);
|
|
1841
1880
|
usedP += r.tokens;
|
|
@@ -1968,9 +2007,13 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1968
2007
|
}
|
|
1969
2008
|
}
|
|
1970
2009
|
if (limit < selectedItems.length) {
|
|
1971
|
-
|
|
2010
|
+
const cut = selectedItems.slice(0, limit);
|
|
2011
|
+
obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
|
|
2012
|
+
selectedItems = cut;
|
|
1972
2013
|
}
|
|
1973
|
-
|
|
2014
|
+
const heldDropped = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
|
|
2015
|
+
obs?.dropMissing(selectedItems.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
|
|
2016
|
+
selectedItems = heldDropped;
|
|
1974
2017
|
totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
|
|
1975
2018
|
// v39: annotate every returned entry with its origin and how it relates to
|
|
1976
2019
|
// the active project, so renderers can demarcate cross-project inclusions.
|
|
@@ -1979,6 +2022,7 @@ export async function getContext(ctx, opts = {}) {
|
|
|
1979
2022
|
origin: r.entry.origin_project ?? null,
|
|
1980
2023
|
category: classifyOriginProject(r.entry.origin_project, currentProjectName),
|
|
1981
2024
|
}));
|
|
2025
|
+
obs?.selected(selectedItems);
|
|
1982
2026
|
if (selectedItems.length === 0 &&
|
|
1983
2027
|
!shownSnapshot &&
|
|
1984
2028
|
!shownHandoff &&
|
package/dist/cli.js
CHANGED
|
@@ -51,7 +51,8 @@ import { passesScopeFilterForRecall } from './recall-scope.js';
|
|
|
51
51
|
import { estimateTokens, fitBudget, hybridSearch, physicsSearch, explainMatch, textOverlap, tokenize as tokenizeQuery } from './search.js';
|
|
52
52
|
import { compareEntryIdentity } from './compare.js';
|
|
53
53
|
import { renderTraceContent, parseSteps } from './trace.js';
|
|
54
|
-
import { writeRecallTraceAtRoot } from './recall-trace.js';
|
|
54
|
+
import { writeDeliveryEventAtRoot, writeDeliveryEventOnHandle, writeRecallTraceAtRoot } from './recall-trace.js';
|
|
55
|
+
import { createDeliveryRecorder } from './delivery-recorder.js';
|
|
55
56
|
import { deduplicateStore } from './dedupe.js';
|
|
56
57
|
import { isEmbeddingAvailable, embedAll, embedMemory, loadEmbeddingIndex, resolveEmbeddingModel, embeddingModelRequiresReindex, } from './embeddings.js';
|
|
57
58
|
import { resolveEmbeddingProvider } from './embedding-provider.js';
|
|
@@ -6291,6 +6292,46 @@ function hostSessionId() {
|
|
|
6291
6292
|
return process.env.HIPPO_SESSION_ID?.trim() || process.env.CLAUDE_CODE_SESSION_ID?.trim() || undefined;
|
|
6292
6293
|
}
|
|
6293
6294
|
async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
6295
|
+
const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
|
|
6296
|
+
// No try/finally: a render throw keeps its own exit code and writes no event.
|
|
6297
|
+
await renderContext(hippoRoot, args, flags, stdinText, rec);
|
|
6298
|
+
flushDeliveryRecorder(rec);
|
|
6299
|
+
}
|
|
6300
|
+
/** A delivery recorder for a pinned-only call when its ledger store enables one, else null; never throws. */
|
|
6301
|
+
function startDeliveryRecorder(hippoRoot, flags, stdinText) {
|
|
6302
|
+
if (flags['pinned-only'] !== true)
|
|
6303
|
+
return null;
|
|
6304
|
+
try {
|
|
6305
|
+
// The same store withLedgerDb writes the token ledger to, so its config governs both.
|
|
6306
|
+
const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
|
|
6307
|
+
if (root === null || !loadConfig(root).deliveryLedger.enabled)
|
|
6308
|
+
return null;
|
|
6309
|
+
return createDeliveryRecorder({
|
|
6310
|
+
root,
|
|
6311
|
+
storeHash: blockHash(path.resolve(root)),
|
|
6312
|
+
writeStore: isGlobalStoreRoot(root) ? 'global' : 'local',
|
|
6313
|
+
tenantId: resolveTenantId({}),
|
|
6314
|
+
stdinText,
|
|
6315
|
+
envSessionId: hostSessionId(),
|
|
6316
|
+
});
|
|
6317
|
+
}
|
|
6318
|
+
catch (error) {
|
|
6319
|
+
console.error(`[hippo] delivery ledger skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
6320
|
+
return null;
|
|
6321
|
+
}
|
|
6322
|
+
}
|
|
6323
|
+
/** With `db`, writes on the token ledger's handle (same store); without it, opens its own. A second flush is a no-op. */
|
|
6324
|
+
function flushDeliveryRecorder(rec, db) {
|
|
6325
|
+
if (rec === null)
|
|
6326
|
+
return;
|
|
6327
|
+
try {
|
|
6328
|
+
rec.flush((input) => (db ? writeDeliveryEventOnHandle(db, input) : writeDeliveryEventAtRoot(rec.root, input)));
|
|
6329
|
+
}
|
|
6330
|
+
catch (error) {
|
|
6331
|
+
console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
async function renderContext(hippoRoot, args, flags, stdinText, rec) {
|
|
6294
6335
|
// --pinned-only fires on every UserPromptSubmit — including in directories
|
|
6295
6336
|
// that don't have a local .hippo. Skip requireInit for that path and fall
|
|
6296
6337
|
// back to global-only inside api.getContext. The non-pinned path still
|
|
@@ -6300,8 +6341,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6300
6341
|
requireInit(hippoRoot);
|
|
6301
6342
|
}
|
|
6302
6343
|
const budget = parseBudgetFlag(flags['budget'], 1500);
|
|
6303
|
-
if (budget <= 0)
|
|
6344
|
+
if (budget <= 0) {
|
|
6345
|
+
rec?.disabled();
|
|
6304
6346
|
return;
|
|
6347
|
+
}
|
|
6305
6348
|
// Resolve query: explicit args, --auto (git diff via CLI-side helper), or
|
|
6306
6349
|
// fall through to api.getContext's '*' fallback. api.getContext is host-
|
|
6307
6350
|
// agnostic so the auto-detect (which shells out to git) stays CLI-side.
|
|
@@ -6362,6 +6405,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6362
6405
|
prompt: payloadPrompt,
|
|
6363
6406
|
// JSON is budgeted as the markdown it stands for, so one budget picks the same memories in every format.
|
|
6364
6407
|
cost: contextCost(format === 'additional-context' ? 'additional-context' : 'markdown', framing),
|
|
6408
|
+
deliveryObserver: rec ?? undefined,
|
|
6365
6409
|
};
|
|
6366
6410
|
const result = await api.getContext(ctx, opts);
|
|
6367
6411
|
// Early exit when there's nothing to render (matches pre-extraction behavior).
|
|
@@ -6369,8 +6413,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6369
6413
|
result.activeSnapshot ||
|
|
6370
6414
|
result.sessionHandoff ||
|
|
6371
6415
|
(result.recentEvents && result.recentEvents.length > 0);
|
|
6372
|
-
if (!hasContextData)
|
|
6416
|
+
if (!hasContextData) {
|
|
6417
|
+
rec?.delivered({ state: 'empty' });
|
|
6373
6418
|
return;
|
|
6419
|
+
}
|
|
6374
6420
|
// Adapter: ContextResultEntry -> the print-helper input shape. v39:
|
|
6375
6421
|
// cross-project inclusions (only present under --cross-project or with
|
|
6376
6422
|
// isolation disabled via crossProject) render in their own demarcated
|
|
@@ -6404,10 +6450,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6404
6450
|
tokens: result.tokens,
|
|
6405
6451
|
});
|
|
6406
6452
|
console.log(jsonText);
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6453
|
+
rec?.delivered({ state: 'sent', emittedText: `${jsonText}\n` });
|
|
6454
|
+
withLedgerDb(hippoRoot, (db) => {
|
|
6455
|
+
recordTokenUse(db, {
|
|
6456
|
+
tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
|
|
6457
|
+
event: 'inject', items: output.length, tokens: estimateTokens(jsonText),
|
|
6458
|
+
});
|
|
6459
|
+
flushDeliveryRecorder(rec, db);
|
|
6460
|
+
});
|
|
6411
6461
|
}
|
|
6412
6462
|
else if (format === 'additional-context') {
|
|
6413
6463
|
// Z1: split into a static block (snapshot/handoff/events/pins/recent-N,
|
|
@@ -6433,8 +6483,10 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6433
6483
|
const recallBlock = recallItems.length > 0
|
|
6434
6484
|
? settleTokens((t) => captureConsole(() => printContextMarkdown(recallItems, t, framing, { showStrength: false, heading: 'Prompt-Relevant Memory' })))
|
|
6435
6485
|
: '';
|
|
6436
|
-
if (!staticBlock.trim() && !recallBlock.trim())
|
|
6486
|
+
if (!staticBlock.trim() && !recallBlock.trim()) {
|
|
6487
|
+
rec?.delivered({ state: 'empty' });
|
|
6437
6488
|
return;
|
|
6489
|
+
}
|
|
6438
6490
|
const surface = pinnedOnly ? 'hook' : 'context';
|
|
6439
6491
|
let sendStatic = staticBlock.trim().length > 0;
|
|
6440
6492
|
// TE2: the per-prompt hook skips a static block identical to the one this
|
|
@@ -6449,10 +6501,16 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6449
6501
|
const staticHash = blockHash(staticBlock);
|
|
6450
6502
|
const last = withLedgerDb(hippoRoot, (db) => lastSentState(db, ctx.tenantId, payloadSessionId, surface));
|
|
6451
6503
|
if (shouldSkipUnchanged(last ?? null, staticHash, refreshTurns)) {
|
|
6452
|
-
withLedgerDb(hippoRoot, (db) =>
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6504
|
+
withLedgerDb(hippoRoot, (db) => {
|
|
6505
|
+
recordTokenUse(db, {
|
|
6506
|
+
tenantId: ctx.tenantId, sessionId: payloadSessionId, surface, event: 'skip',
|
|
6507
|
+
items: staticItems.length, tokens: estimateTokens(staticBlock), hash: staticHash,
|
|
6508
|
+
});
|
|
6509
|
+
if (recallBlock.trim())
|
|
6510
|
+
return;
|
|
6511
|
+
rec?.delivered({ state: 'reused', staticHash, staticReused: true });
|
|
6512
|
+
flushDeliveryRecorder(rec, db);
|
|
6513
|
+
});
|
|
6456
6514
|
sendStatic = false;
|
|
6457
6515
|
}
|
|
6458
6516
|
}
|
|
@@ -6461,8 +6519,11 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6461
6519
|
const additionalContext = finalStatic && recallBlock
|
|
6462
6520
|
? `${finalStatic}\n\n${recallBlock}`
|
|
6463
6521
|
: finalStatic || recallBlock;
|
|
6464
|
-
|
|
6522
|
+
const staticReused = !sendStatic && staticBlock.trim().length > 0;
|
|
6523
|
+
if (!additionalContext.trim()) {
|
|
6524
|
+
rec?.delivered({ state: 'reused', staticHash: blockHash(staticBlock), staticReused });
|
|
6465
6525
|
return;
|
|
6526
|
+
}
|
|
6466
6527
|
const payload = {
|
|
6467
6528
|
hookSpecificOutput: {
|
|
6468
6529
|
hookEventName: 'UserPromptSubmit',
|
|
@@ -6470,6 +6531,13 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6470
6531
|
},
|
|
6471
6532
|
};
|
|
6472
6533
|
process.stdout.write(JSON.stringify(payload));
|
|
6534
|
+
rec?.delivered({
|
|
6535
|
+
state: staticReused ? 'reused-recall-sent' : 'sent',
|
|
6536
|
+
staticHash: staticBlock.trim() ? blockHash(staticBlock) : null,
|
|
6537
|
+
recallHash: recallBlock ? blockHash(recallBlock) : null,
|
|
6538
|
+
emittedText: additionalContext,
|
|
6539
|
+
staticReused,
|
|
6540
|
+
});
|
|
6473
6541
|
if (finalStatic || recallBlock) {
|
|
6474
6542
|
// One connection for both rows; each insert in its own try so one failing doesn't skip the other.
|
|
6475
6543
|
withLedgerDb(hippoRoot, (db) => {
|
|
@@ -6491,6 +6559,7 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6491
6559
|
}
|
|
6492
6560
|
catch { /* best effort; see withLedgerDb doc comment */ }
|
|
6493
6561
|
}
|
|
6562
|
+
flushDeliveryRecorder(rec, db);
|
|
6494
6563
|
});
|
|
6495
6564
|
}
|
|
6496
6565
|
}
|
|
@@ -6515,10 +6584,14 @@ async function cmdContext(hippoRoot, args, flags, stdinText) {
|
|
|
6515
6584
|
}));
|
|
6516
6585
|
if (text.length > 0)
|
|
6517
6586
|
console.log(text);
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6587
|
+
rec?.delivered(text.length > 0 ? { state: 'sent', emittedText: `${text}\n` } : { state: 'empty' });
|
|
6588
|
+
withLedgerDb(hippoRoot, (db) => {
|
|
6589
|
+
recordTokenUse(db, {
|
|
6590
|
+
tenantId: ctx.tenantId, sessionId: ledgerSessionId, surface: pinnedOnly ? 'hook' : 'context',
|
|
6591
|
+
event: 'inject', items: renderItems.length, tokens: estimateTokens(text),
|
|
6592
|
+
});
|
|
6593
|
+
flushDeliveryRecorder(rec, db);
|
|
6594
|
+
});
|
|
6522
6595
|
}
|
|
6523
6596
|
}
|
|
6524
6597
|
/**
|
package/dist/config.d.ts
CHANGED
|
@@ -140,6 +140,11 @@ export interface HippoConfig {
|
|
|
140
140
|
agentMemories: {
|
|
141
141
|
tools: string[] | null;
|
|
142
142
|
};
|
|
143
|
+
/** Per-turn delivery ledger (src/recall-trace.ts): hashes, ids, counts and rejection reasons for each
|
|
144
|
+
* pinned-only context call. Default off; read from the store the token ledger writes to. */
|
|
145
|
+
deliveryLedger: {
|
|
146
|
+
enabled: boolean;
|
|
147
|
+
};
|
|
143
148
|
}
|
|
144
149
|
export declare function loadConfig(hippoRoot: string): HippoConfig;
|
|
145
150
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/config.js
CHANGED
|
@@ -85,6 +85,9 @@ const DEFAULT_CONFIG = {
|
|
|
85
85
|
agentMemories: {
|
|
86
86
|
tools: null,
|
|
87
87
|
},
|
|
88
|
+
deliveryLedger: {
|
|
89
|
+
enabled: false,
|
|
90
|
+
},
|
|
88
91
|
};
|
|
89
92
|
function isMemoryValueConfig(value) {
|
|
90
93
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -95,6 +98,23 @@ function isDormantConfig(value) {
|
|
|
95
98
|
function isChurnStalenessConfig(value) {
|
|
96
99
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
97
100
|
}
|
|
101
|
+
function isDeliveryLedgerConfig(value) {
|
|
102
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
103
|
+
}
|
|
104
|
+
// Only `{"enabled": true}` turns it on; anything malformed warns and stays off.
|
|
105
|
+
function deliveryLedgerEnabled(value) {
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return false;
|
|
108
|
+
const isObject = isDeliveryLedgerConfig(value);
|
|
109
|
+
const enabled = isObject ? value.enabled : undefined;
|
|
110
|
+
if (enabled === true || enabled === false)
|
|
111
|
+
return enabled;
|
|
112
|
+
if (isObject && enabled === undefined)
|
|
113
|
+
return false;
|
|
114
|
+
console.error(`Warning: config.json's "deliveryLedger" must be an object like {"enabled": true} ` +
|
|
115
|
+
`(got ${JSON.stringify(value)}) - using false.`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
98
118
|
function agentMemoryTools(value) {
|
|
99
119
|
if (value === undefined || value === null)
|
|
100
120
|
return null;
|
|
@@ -205,6 +225,7 @@ export function loadConfig(hippoRoot) {
|
|
|
205
225
|
enabled: churnStalenessEnabled,
|
|
206
226
|
},
|
|
207
227
|
agentMemories: { tools: agentMemoryTools(raw.agentMemories?.tools) },
|
|
228
|
+
deliveryLedger: { enabled: deliveryLedgerEnabled(raw.deliveryLedger) },
|
|
208
229
|
};
|
|
209
230
|
}
|
|
210
231
|
catch (err) {
|
package/dist/db.js
CHANGED
|
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
|
|
|
11
11
|
// runtime (Node's built-in synchronous SQLite module); there are no bundled
|
|
12
12
|
// types for it here, so this require + cast is the module's documented boundary.
|
|
13
13
|
const { DatabaseSync } = require('node:sqlite');
|
|
14
|
-
const CURRENT_SCHEMA_VERSION =
|
|
14
|
+
const CURRENT_SCHEMA_VERSION = 50;
|
|
15
15
|
const MIGRATIONS = [
|
|
16
16
|
{
|
|
17
17
|
version: 1,
|
|
@@ -2501,6 +2501,72 @@ const MIGRATIONS = [
|
|
|
2501
2501
|
`);
|
|
2502
2502
|
},
|
|
2503
2503
|
},
|
|
2504
|
+
{
|
|
2505
|
+
version: 50,
|
|
2506
|
+
up: (db) => {
|
|
2507
|
+
// Per-turn delivery events (src/recall-trace.ts). Additive only: no min_compatible_binary bump; rollback drops both tables
|
|
2508
|
+
// and sets schema_version back to 49. No CHECK on enum columns since SQLite cannot alter one; delivery-recorder.ts unions are the allowlist.
|
|
2509
|
+
db.exec(`
|
|
2510
|
+
CREATE TABLE IF NOT EXISTS delivery_events (
|
|
2511
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2512
|
+
ts TEXT NOT NULL,
|
|
2513
|
+
ledger_version INTEGER NOT NULL,
|
|
2514
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
2515
|
+
runtime TEXT NOT NULL,
|
|
2516
|
+
event_type TEXT NOT NULL,
|
|
2517
|
+
surface TEXT NOT NULL,
|
|
2518
|
+
store_hash TEXT NOT NULL,
|
|
2519
|
+
write_store TEXT NOT NULL,
|
|
2520
|
+
project_hash TEXT,
|
|
2521
|
+
session_id TEXT,
|
|
2522
|
+
session_state TEXT NOT NULL,
|
|
2523
|
+
host_turn_id TEXT,
|
|
2524
|
+
turn_seq INTEGER,
|
|
2525
|
+
duplicate_of INTEGER,
|
|
2526
|
+
prompt_hash TEXT,
|
|
2527
|
+
prompt_length INTEGER NOT NULL DEFAULT 0,
|
|
2528
|
+
query_hash TEXT,
|
|
2529
|
+
recall_trace_id INTEGER,
|
|
2530
|
+
block_state TEXT NOT NULL,
|
|
2531
|
+
prompt_recall INTEGER NOT NULL DEFAULT 0,
|
|
2532
|
+
considered_count INTEGER NOT NULL DEFAULT 0,
|
|
2533
|
+
filtered_count INTEGER NOT NULL DEFAULT 0,
|
|
2534
|
+
selected_count INTEGER NOT NULL DEFAULT 0,
|
|
2535
|
+
emitted_count INTEGER NOT NULL DEFAULT 0,
|
|
2536
|
+
rejected_count INTEGER NOT NULL DEFAULT 0,
|
|
2537
|
+
rejected_unlisted INTEGER NOT NULL DEFAULT 0,
|
|
2538
|
+
sections_shown INTEGER NOT NULL DEFAULT 0,
|
|
2539
|
+
sections_dropped INTEGER NOT NULL DEFAULT 0,
|
|
2540
|
+
budget_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2541
|
+
selected_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2542
|
+
injected_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2543
|
+
static_hash TEXT,
|
|
2544
|
+
recall_hash TEXT,
|
|
2545
|
+
emitted_hash TEXT,
|
|
2546
|
+
elapsed_ms INTEGER NOT NULL DEFAULT 0
|
|
2547
|
+
);
|
|
2548
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_events_session ON delivery_events(tenant_id, session_id, id);
|
|
2549
|
+
CREATE INDEX IF NOT EXISTS idx_delivery_events_ts ON delivery_events(ts);
|
|
2550
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_delivery_events_turn
|
|
2551
|
+
ON delivery_events(tenant_id, session_id, event_type, turn_seq) WHERE turn_seq IS NOT NULL;
|
|
2552
|
+
|
|
2553
|
+
CREATE TABLE IF NOT EXISTS delivery_candidates (
|
|
2554
|
+
event_id INTEGER NOT NULL REFERENCES delivery_events(id) ON DELETE CASCADE,
|
|
2555
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
2556
|
+
memory_id TEXT NOT NULL, -- no FK: events outlive forgotten memories, as recall traces do
|
|
2557
|
+
source_store TEXT NOT NULL,
|
|
2558
|
+
pool TEXT NOT NULL,
|
|
2559
|
+
stage TEXT NOT NULL,
|
|
2560
|
+
outcome TEXT NOT NULL,
|
|
2561
|
+
reason TEXT,
|
|
2562
|
+
cand_rank INTEGER,
|
|
2563
|
+
score REAL,
|
|
2564
|
+
tokens INTEGER,
|
|
2565
|
+
PRIMARY KEY (event_id, memory_id)
|
|
2566
|
+
) WITHOUT ROWID;
|
|
2567
|
+
`);
|
|
2568
|
+
},
|
|
2569
|
+
},
|
|
2504
2570
|
];
|
|
2505
2571
|
function tableHasColumn(db, tableName, columnName) {
|
|
2506
2572
|
if (!/^[a-z_]+$/i.test(tableName))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { MemoryEntry } from './memory.js';
|
|
2
|
+
import { type PromptRecallGate } from './prompt-recall.js';
|
|
3
|
+
export type DeliveryRuntime = 'claude-code' | 'codex' | 'unknown';
|
|
4
|
+
export type DeliveryEventType = 'prompt-submit' | 'pinned-manual';
|
|
5
|
+
export type DeliverySurface = 'hook' | 'context';
|
|
6
|
+
export type DeliveryWriteStore = 'local' | 'global';
|
|
7
|
+
export type DeliverySessionState = 'payload' | 'env' | 'missing' | 'subagent';
|
|
8
|
+
export type DeliveryBlockState = 'sent' | 'reused' | 'reused-recall-sent' | 'empty' | 'disabled';
|
|
9
|
+
export type DeliveryPool = 'pin' | 'recent' | 'prompt-recall' | 'strength' | 'search';
|
|
10
|
+
export type DeliveryStage = 'load' | 'eligible' | 'gate' | 'budget' | 'limit' | 'final';
|
|
11
|
+
export type DeliveryOutcome = 'emitted' | 'reused' | 'rejected';
|
|
12
|
+
export type DeliveryRejectReason = 'budget' | 'gate-below-threshold' | 'gate-max-items' | 'duplicate' | 'scope' | 'quality' | 'limit';
|
|
13
|
+
/** Row format version written to `delivery_events.ledger_version`. */
|
|
14
|
+
export declare const DELIVERY_LEDGER_VERSION = 1;
|
|
15
|
+
/** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
|
|
16
|
+
export declare const DELIVERY_REJECTED_ROW_CAP = 16;
|
|
17
|
+
export interface DeliveryCandidateInput {
|
|
18
|
+
memoryId: string;
|
|
19
|
+
sourceStore: DeliveryWriteStore;
|
|
20
|
+
pool: DeliveryPool;
|
|
21
|
+
stage: DeliveryStage;
|
|
22
|
+
outcome: DeliveryOutcome;
|
|
23
|
+
reason: DeliveryRejectReason | null;
|
|
24
|
+
rank: number | null;
|
|
25
|
+
score: number | null;
|
|
26
|
+
tokens: number | null;
|
|
27
|
+
}
|
|
28
|
+
/** One event as the writer stores it; the writer adds `turn_seq`, `duplicate_of` and the format version. */
|
|
29
|
+
export interface DeliveryEventInput {
|
|
30
|
+
ts: string;
|
|
31
|
+
tenantId: string;
|
|
32
|
+
runtime: DeliveryRuntime;
|
|
33
|
+
eventType: DeliveryEventType;
|
|
34
|
+
surface: DeliverySurface;
|
|
35
|
+
storeHash: string;
|
|
36
|
+
writeStore: DeliveryWriteStore;
|
|
37
|
+
projectHash: string | null;
|
|
38
|
+
sessionId: string | null;
|
|
39
|
+
sessionState: DeliverySessionState;
|
|
40
|
+
hostTurnId: string | null;
|
|
41
|
+
promptHash: string | null;
|
|
42
|
+
promptLength: number;
|
|
43
|
+
queryHash: string | null;
|
|
44
|
+
recallTraceId: number | null;
|
|
45
|
+
blockState: DeliveryBlockState;
|
|
46
|
+
promptRecall: boolean;
|
|
47
|
+
consideredCount: number;
|
|
48
|
+
filteredCount: number;
|
|
49
|
+
selectedCount: number;
|
|
50
|
+
emittedCount: number;
|
|
51
|
+
rejectedCount: number;
|
|
52
|
+
rejectedUnlisted: number;
|
|
53
|
+
sectionsShown: number;
|
|
54
|
+
sectionsDropped: number;
|
|
55
|
+
budgetTokens: number;
|
|
56
|
+
selectedTokens: number;
|
|
57
|
+
injectedTokens: number;
|
|
58
|
+
staticHash: string | null;
|
|
59
|
+
recallHash: string | null;
|
|
60
|
+
emittedHash: string | null;
|
|
61
|
+
elapsedMs: number;
|
|
62
|
+
candidates: readonly DeliveryCandidateInput[];
|
|
63
|
+
}
|
|
64
|
+
export interface DeliveryFacts {
|
|
65
|
+
projectName: string;
|
|
66
|
+
budgetTokens: number;
|
|
67
|
+
promptRecall: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** The shape of a returned context entry the observer reads. */
|
|
70
|
+
export interface DeliverySelected {
|
|
71
|
+
entry: MemoryEntry;
|
|
72
|
+
score: number;
|
|
73
|
+
tokens: number;
|
|
74
|
+
isGlobal?: boolean;
|
|
75
|
+
promptRecall?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** What getContext reports while it selects. Every method only reads; none changes what is selected. */
|
|
78
|
+
export interface DeliveryObserver {
|
|
79
|
+
facts(facts: DeliveryFacts): void;
|
|
80
|
+
sections(shown: number, dropped: number): void;
|
|
81
|
+
/** Returns `admit`'s own answer unchanged and lets its throws through. */
|
|
82
|
+
watchAdmit(admit: (e: MemoryEntry) => boolean): (e: MemoryEntry) => boolean;
|
|
83
|
+
/** The loader's quality floor dropped a row admit let through; with prompt recall on, eligibility reports it instead. */
|
|
84
|
+
qualityDropped(entry: MemoryEntry, isGlobal: boolean): void;
|
|
85
|
+
disabled(): void;
|
|
86
|
+
/** No `pool` means pin or recent by the entry's own flag. */
|
|
87
|
+
offer(entries: readonly MemoryEntry[], isGlobal: boolean, pool?: DeliveryPool): void;
|
|
88
|
+
reject(entry: MemoryEntry, stage: DeliveryStage, reason: DeliveryRejectReason, score?: number, tokens?: number): void;
|
|
89
|
+
dropMissing(before: readonly MemoryEntry[], after: readonly MemoryEntry[], stage: DeliveryStage, reason: DeliveryRejectReason): void;
|
|
90
|
+
gated(prompt: ReadonlySet<string>, candidates: readonly {
|
|
91
|
+
id: string;
|
|
92
|
+
tokens: ReadonlySet<string>;
|
|
93
|
+
}[], gate: PromptRecallGate, kept: readonly {
|
|
94
|
+
item: {
|
|
95
|
+
id: string;
|
|
96
|
+
};
|
|
97
|
+
}[]): void;
|
|
98
|
+
selected(items: readonly DeliverySelected[]): void;
|
|
99
|
+
}
|
|
100
|
+
/** What the renderer sent, reported once at its exit. */
|
|
101
|
+
export interface DeliveryOutcomeInput {
|
|
102
|
+
state: DeliveryBlockState;
|
|
103
|
+
staticHash?: string | null;
|
|
104
|
+
recallHash?: string | null;
|
|
105
|
+
/** The exact text the agent receives: the hook's additionalContext, or every stdout byte, newline included. */
|
|
106
|
+
emittedText?: string | null;
|
|
107
|
+
/** The static block was skipped as unchanged, so its entries are reused, not sent. */
|
|
108
|
+
staticReused?: boolean;
|
|
109
|
+
}
|
|
110
|
+
export interface DeliveryRecorder extends DeliveryObserver {
|
|
111
|
+
readonly root: string;
|
|
112
|
+
delivered(outcome: DeliveryOutcomeInput): void;
|
|
113
|
+
/** Builds the event and hands it to `write` once per call; throws on an injected fault, and writes nothing once broken. */
|
|
114
|
+
flush(write: (input: DeliveryEventInput) => number | null): void;
|
|
115
|
+
}
|
|
116
|
+
export interface DeliveryRecorderInit {
|
|
117
|
+
/** The store the event is written to. */
|
|
118
|
+
root: string;
|
|
119
|
+
storeHash: string;
|
|
120
|
+
writeStore: DeliveryWriteStore;
|
|
121
|
+
tenantId: string;
|
|
122
|
+
stdinText?: string;
|
|
123
|
+
envSessionId?: string;
|
|
124
|
+
}
|
|
125
|
+
/** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
|
|
126
|
+
export declare function createDeliveryRecorder(init: DeliveryRecorderInit): DeliveryRecorder;
|
|
127
|
+
//# sourceMappingURL=delivery-recorder.d.ts.map
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { evalNow } from './ablation.js';
|
|
2
|
+
import { scoreOverlap } from './prompt-recall.js';
|
|
3
|
+
import { blockHash, estimateTokens, hookPayloadSessionId, hookPayloadString, isSubagentPayload } from './token-ledger.js';
|
|
4
|
+
/** Row format version written to `delivery_events.ledger_version`. */
|
|
5
|
+
export const DELIVERY_LEDGER_VERSION = 1;
|
|
6
|
+
/** Rejected candidate rows kept per event; the rest only add to `rejected_unlisted`. */
|
|
7
|
+
export const DELIVERY_REJECTED_ROW_CAP = 16;
|
|
8
|
+
// Deeper stages were closer to being sent, so the row cap keeps them first.
|
|
9
|
+
const STAGE_DEPTH = new Map([
|
|
10
|
+
['load', 0], ['eligible', 1], ['gate', 2], ['budget', 3], ['limit', 4], ['final', 5],
|
|
11
|
+
]);
|
|
12
|
+
function storeOf(isGlobal) {
|
|
13
|
+
return isGlobal === true ? 'global' : 'local';
|
|
14
|
+
}
|
|
15
|
+
function byDepthThenScore(a, b) {
|
|
16
|
+
const depth = (STAGE_DEPTH.get(b.stage ?? 'load') ?? 0) - (STAGE_DEPTH.get(a.stage ?? 'load') ?? 0);
|
|
17
|
+
if (depth !== 0)
|
|
18
|
+
return depth;
|
|
19
|
+
const score = (b.score ?? Number.NEGATIVE_INFINITY) - (a.score ?? Number.NEGATIVE_INFINITY);
|
|
20
|
+
if (score !== 0 && !Number.isNaN(score))
|
|
21
|
+
return score;
|
|
22
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
23
|
+
}
|
|
24
|
+
/** A recorder for one call; every observer method is guarded, and a throw marks it broken instead of escaping. */
|
|
25
|
+
export function createDeliveryRecorder(init) {
|
|
26
|
+
const startedMs = Date.now();
|
|
27
|
+
const ts = evalNow().toISOString();
|
|
28
|
+
// Test-only fault injection, as HIPPO_FAKE_NOW is for time.
|
|
29
|
+
const fault = process.env.HIPPO_TEST_DELIVERY_FAULT ?? '';
|
|
30
|
+
const payloadSession = hookPayloadSessionId(init.stdinText);
|
|
31
|
+
const subagent = isSubagentPayload(init.stdinText);
|
|
32
|
+
const envSession = init.envSessionId !== undefined && init.envSessionId !== '' ? init.envSessionId : null;
|
|
33
|
+
const prompt = hookPayloadString(init.stdinText, 'prompt');
|
|
34
|
+
const rawTurnId = hookPayloadString(init.stdinText, 'turn_id');
|
|
35
|
+
const hostTurnId = rawTurnId !== null && rawTurnId.trim() !== '' ? rawTurnId : null;
|
|
36
|
+
const hookEvent = hookPayloadString(init.stdinText, 'hook_event_name');
|
|
37
|
+
const sessionState = subagent
|
|
38
|
+
? 'subagent'
|
|
39
|
+
: payloadSession !== null ? 'payload' : envSession !== null ? 'env' : 'missing';
|
|
40
|
+
const candidates = new Map();
|
|
41
|
+
const picked = new Map();
|
|
42
|
+
const filtered = new Set();
|
|
43
|
+
let facts = null;
|
|
44
|
+
let shown = 0;
|
|
45
|
+
let dropped = 0;
|
|
46
|
+
let disabledSeen = false;
|
|
47
|
+
let outcome = { state: 'empty' };
|
|
48
|
+
let broken = null;
|
|
49
|
+
let flushed = false;
|
|
50
|
+
const guard = (fn) => {
|
|
51
|
+
if (broken !== null)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
if (fault === 'observe')
|
|
55
|
+
throw new Error('injected observe fault');
|
|
56
|
+
fn();
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
broken = error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const rejectId = (id, stage, reason, score, tokens) => {
|
|
63
|
+
const held = candidates.get(id);
|
|
64
|
+
// First rejection wins; an id never offered is not a candidate of this call.
|
|
65
|
+
if (!held || held.reason !== null)
|
|
66
|
+
return;
|
|
67
|
+
candidates.set(id, { ...held, stage, reason, score, tokens });
|
|
68
|
+
};
|
|
69
|
+
const build = () => {
|
|
70
|
+
if (fault === 'build')
|
|
71
|
+
throw new Error('injected build fault');
|
|
72
|
+
const staticReused = outcome.staticReused === true;
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (const p of picked.values()) {
|
|
75
|
+
const held = candidates.get(p.entry.id);
|
|
76
|
+
rows.push({
|
|
77
|
+
memoryId: p.entry.id,
|
|
78
|
+
sourceStore: p.sourceStore,
|
|
79
|
+
pool: p.promptRecall ? 'prompt-recall' : held?.pool === 'pin' || p.entry.pinned ? 'pin' : 'recent',
|
|
80
|
+
stage: 'final',
|
|
81
|
+
outcome: staticReused && !p.promptRecall ? 'reused' : 'emitted',
|
|
82
|
+
reason: null,
|
|
83
|
+
rank: p.rank,
|
|
84
|
+
score: p.score,
|
|
85
|
+
tokens: p.tokens,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const rejected = [];
|
|
89
|
+
let undecided = 0;
|
|
90
|
+
for (const c of candidates.values()) {
|
|
91
|
+
if (picked.has(c.id))
|
|
92
|
+
continue;
|
|
93
|
+
if (c.reason === null)
|
|
94
|
+
undecided += 1;
|
|
95
|
+
else
|
|
96
|
+
rejected.push(c);
|
|
97
|
+
}
|
|
98
|
+
rejected.sort(byDepthThenScore);
|
|
99
|
+
for (const c of rejected.slice(0, DELIVERY_REJECTED_ROW_CAP)) {
|
|
100
|
+
rows.push({
|
|
101
|
+
memoryId: c.id, sourceStore: c.sourceStore, pool: c.pool, stage: c.stage ?? 'load', outcome: 'rejected',
|
|
102
|
+
reason: c.reason, rank: null, score: c.score, tokens: c.tokens,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const overflow = Math.max(0, rejected.length - DELIVERY_REJECTED_ROW_CAP);
|
|
106
|
+
const emitted = outcome.emittedText ?? null;
|
|
107
|
+
return {
|
|
108
|
+
ts,
|
|
109
|
+
tenantId: init.tenantId,
|
|
110
|
+
runtime: hostTurnId !== null ? 'codex' : hookEvent !== null ? 'claude-code' : 'unknown',
|
|
111
|
+
eventType: hookEvent === 'UserPromptSubmit' ? 'prompt-submit' : 'pinned-manual',
|
|
112
|
+
surface: 'hook',
|
|
113
|
+
storeHash: init.storeHash,
|
|
114
|
+
writeStore: init.writeStore,
|
|
115
|
+
projectHash: facts !== null && facts.projectName !== '' ? blockHash(facts.projectName) : null,
|
|
116
|
+
sessionId: payloadSession ?? envSession,
|
|
117
|
+
sessionState,
|
|
118
|
+
hostTurnId,
|
|
119
|
+
promptHash: prompt !== null ? blockHash(prompt) : null,
|
|
120
|
+
promptLength: prompt?.length ?? 0,
|
|
121
|
+
queryHash: null,
|
|
122
|
+
recallTraceId: null,
|
|
123
|
+
blockState: disabledSeen ? 'disabled' : outcome.state,
|
|
124
|
+
promptRecall: facts?.promptRecall === true,
|
|
125
|
+
consideredCount: new Set([...candidates.keys(), ...picked.keys()]).size,
|
|
126
|
+
filteredCount: filtered.size,
|
|
127
|
+
selectedCount: picked.size,
|
|
128
|
+
emittedCount: rows.filter((r) => r.outcome === 'emitted').length,
|
|
129
|
+
rejectedCount: rejected.length + undecided,
|
|
130
|
+
rejectedUnlisted: overflow + undecided,
|
|
131
|
+
sectionsShown: shown,
|
|
132
|
+
sectionsDropped: dropped,
|
|
133
|
+
budgetTokens: facts?.budgetTokens ?? 0,
|
|
134
|
+
selectedTokens: [...picked.values()].reduce((sum, p) => sum + p.tokens, 0),
|
|
135
|
+
injectedTokens: emitted !== null ? estimateTokens(emitted) : 0,
|
|
136
|
+
staticHash: outcome.staticHash ?? null,
|
|
137
|
+
recallHash: outcome.recallHash ?? null,
|
|
138
|
+
emittedHash: emitted !== null ? blockHash(emitted) : null,
|
|
139
|
+
elapsedMs: Math.max(0, Date.now() - startedMs),
|
|
140
|
+
candidates: rows,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
root: init.root,
|
|
145
|
+
facts: (f) => guard(() => { facts = { ...f }; }),
|
|
146
|
+
sections: (s, d) => guard(() => { shown = s; dropped = d; }),
|
|
147
|
+
watchAdmit: (admit) => (e) => {
|
|
148
|
+
const ok = admit(e);
|
|
149
|
+
if (!ok)
|
|
150
|
+
guard(() => { filtered.add(e.id); });
|
|
151
|
+
return ok;
|
|
152
|
+
},
|
|
153
|
+
qualityDropped: (e, isGlobal) => guard(() => {
|
|
154
|
+
filtered.add(e.id);
|
|
155
|
+
if (!candidates.has(e.id)) {
|
|
156
|
+
candidates.set(e.id, {
|
|
157
|
+
id: e.id, sourceStore: storeOf(isGlobal), pool: 'recent', stage: null, reason: null, score: null, tokens: null,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
rejectId(e.id, 'load', 'quality', null, null);
|
|
161
|
+
}),
|
|
162
|
+
disabled: () => guard(() => { disabledSeen = true; }),
|
|
163
|
+
offer: (entries, isGlobal, pool) => guard(() => {
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
const held = candidates.get(e.id);
|
|
166
|
+
const wanted = pool ?? (e.pinned ? 'pin' : 'recent');
|
|
167
|
+
// A loaded recent row the prompt-recall gate then judges belongs to that pool.
|
|
168
|
+
const relabel = held !== undefined && held.reason === null && held.pool === 'recent' && wanted === 'prompt-recall';
|
|
169
|
+
if (held !== undefined && !relabel)
|
|
170
|
+
continue;
|
|
171
|
+
candidates.set(e.id, {
|
|
172
|
+
id: e.id, sourceStore: storeOf(isGlobal), pool: wanted, stage: null, reason: null, score: null, tokens: null,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}),
|
|
176
|
+
reject: (e, stage, reason, score, tokens) => guard(() => rejectId(e.id, stage, reason, score ?? null, tokens ?? null)),
|
|
177
|
+
dropMissing: (before, after, stage, reason) => guard(() => {
|
|
178
|
+
const kept = new Set(after.map((e) => e.id));
|
|
179
|
+
for (const e of before)
|
|
180
|
+
if (!kept.has(e.id))
|
|
181
|
+
rejectId(e.id, stage, reason, null, null);
|
|
182
|
+
}),
|
|
183
|
+
gated: (prompt, items, gate, kept) => guard(() => {
|
|
184
|
+
const keptIds = new Set(kept.map((g) => g.item.id));
|
|
185
|
+
for (const c of items) {
|
|
186
|
+
if (keptIds.has(c.id))
|
|
187
|
+
continue;
|
|
188
|
+
const { score, shared } = scoreOverlap(prompt, c.tokens, gate.metric);
|
|
189
|
+
const cleared = score >= gate.threshold && shared >= gate.minShared;
|
|
190
|
+
rejectId(c.id, 'gate', cleared ? 'gate-max-items' : 'gate-below-threshold', score, null);
|
|
191
|
+
}
|
|
192
|
+
}),
|
|
193
|
+
selected: (items) => guard(() => {
|
|
194
|
+
picked.clear();
|
|
195
|
+
items.forEach((r, i) => {
|
|
196
|
+
picked.set(r.entry.id, {
|
|
197
|
+
entry: r.entry, rank: i + 1, score: r.score, tokens: r.tokens,
|
|
198
|
+
sourceStore: storeOf(r.isGlobal), promptRecall: r.promptRecall === true,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}),
|
|
202
|
+
delivered: (o) => guard(() => { outcome = { ...o }; }),
|
|
203
|
+
flush: (write) => {
|
|
204
|
+
if (flushed)
|
|
205
|
+
return;
|
|
206
|
+
flushed = true;
|
|
207
|
+
if (broken !== null) {
|
|
208
|
+
console.error(`[hippo] delivery ledger skipped: recorder failed: ${broken}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const input = build();
|
|
212
|
+
if (fault === 'flush')
|
|
213
|
+
throw new Error('injected flush fault');
|
|
214
|
+
write(input);
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=delivery-recorder.js.map
|
package/dist/recall-trace.d.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { type DatabaseSyncLike } from './db.js';
|
|
19
19
|
import type { RerankStep } from './search.js';
|
|
20
|
+
import { type DeliveryEventInput } from './delivery-recorder.js';
|
|
20
21
|
/** One ranked result to persist alongside its trace row. */
|
|
21
22
|
export interface RecallTraceResultInput {
|
|
22
23
|
memoryId: string;
|
|
@@ -114,4 +115,72 @@ export interface RecordTraceOutcomeInput {
|
|
|
114
115
|
* Fail-soft: never throws.
|
|
115
116
|
*/
|
|
116
117
|
export declare function recordTraceOutcome(db: DatabaseSyncLike, input: RecordTraceOutcomeInput): void;
|
|
118
|
+
/** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
|
|
119
|
+
export declare const DELIVERY_LEDGER_RETENTION_DAYS = 90;
|
|
120
|
+
/** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
|
|
121
|
+
export declare const DELIVERY_LEDGER_WAIT_MS = 50;
|
|
122
|
+
/** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
|
|
123
|
+
export declare const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
|
|
124
|
+
/** One stored `delivery_candidates` row. */
|
|
125
|
+
export interface DeliveryCandidateRow {
|
|
126
|
+
event_id: number;
|
|
127
|
+
tenant_id: string;
|
|
128
|
+
memory_id: string;
|
|
129
|
+
source_store: string;
|
|
130
|
+
pool: string;
|
|
131
|
+
stage: string;
|
|
132
|
+
outcome: string;
|
|
133
|
+
reason: string | null;
|
|
134
|
+
cand_rank: number | null;
|
|
135
|
+
score: number | null;
|
|
136
|
+
tokens: number | null;
|
|
137
|
+
}
|
|
138
|
+
/** One stored `delivery_events` row with its candidate rows. */
|
|
139
|
+
export interface DeliveryEventRow {
|
|
140
|
+
id: number;
|
|
141
|
+
ts: string;
|
|
142
|
+
ledger_version: number;
|
|
143
|
+
tenant_id: string;
|
|
144
|
+
runtime: string;
|
|
145
|
+
event_type: string;
|
|
146
|
+
surface: string;
|
|
147
|
+
store_hash: string;
|
|
148
|
+
write_store: string;
|
|
149
|
+
project_hash: string | null;
|
|
150
|
+
session_id: string | null;
|
|
151
|
+
session_state: string;
|
|
152
|
+
host_turn_id: string | null;
|
|
153
|
+
turn_seq: number | null;
|
|
154
|
+
duplicate_of: number | null;
|
|
155
|
+
prompt_hash: string | null;
|
|
156
|
+
prompt_length: number;
|
|
157
|
+
query_hash: string | null;
|
|
158
|
+
recall_trace_id: number | null;
|
|
159
|
+
block_state: string;
|
|
160
|
+
prompt_recall: number;
|
|
161
|
+
considered_count: number;
|
|
162
|
+
filtered_count: number;
|
|
163
|
+
selected_count: number;
|
|
164
|
+
emitted_count: number;
|
|
165
|
+
rejected_count: number;
|
|
166
|
+
rejected_unlisted: number;
|
|
167
|
+
sections_shown: number;
|
|
168
|
+
sections_dropped: number;
|
|
169
|
+
budget_tokens: number;
|
|
170
|
+
selected_tokens: number;
|
|
171
|
+
injected_tokens: number;
|
|
172
|
+
static_hash: string | null;
|
|
173
|
+
recall_hash: string | null;
|
|
174
|
+
emitted_hash: string | null;
|
|
175
|
+
elapsed_ms: number;
|
|
176
|
+
candidates: DeliveryCandidateRow[];
|
|
177
|
+
}
|
|
178
|
+
/** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
|
|
179
|
+
export declare function writeDeliveryEvent(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
|
|
180
|
+
/** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
|
|
181
|
+
export declare function writeDeliveryEventAtRoot(root: string, input: DeliveryEventInput): number | null;
|
|
182
|
+
/** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
|
|
183
|
+
export declare function writeDeliveryEventOnHandle(db: DatabaseSyncLike, input: DeliveryEventInput): number | null;
|
|
184
|
+
/** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
|
|
185
|
+
export declare function readDeliveryEvents(db: DatabaseSyncLike, tenantId: string, sessionId: string | null): DeliveryEventRow[];
|
|
117
186
|
//# sourceMappingURL=recall-trace.d.ts.map
|
package/dist/recall-trace.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
20
|
+
import { DELIVERY_LEDGER_VERSION } from './delivery-recorder.js';
|
|
20
21
|
/**
|
|
21
22
|
* Strip a RerankStep down to {stage, multiplier, scoreBefore, scoreAfter}
|
|
22
23
|
* before persisting (F3 privacy fix, codex cross-model finding). `note` is
|
|
@@ -183,4 +184,139 @@ export function recordTraceOutcome(db, input) {
|
|
|
183
184
|
console.error(`[hippo] recall trace outcome write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
184
185
|
}
|
|
185
186
|
}
|
|
187
|
+
/** Pruned on write, counted back from the event's ts capped at the real clock, so a far-future fake time spares real rows. */
|
|
188
|
+
export const DELIVERY_LEDGER_RETENTION_DAYS = 90;
|
|
189
|
+
/** Lock wait for the ledger's own connection: a busy store drops the row rather than slow the hook. */
|
|
190
|
+
export const DELIVERY_LEDGER_WAIT_MS = 50;
|
|
191
|
+
/** Two prompt-identical events without a host turn id this close together are one turn fired twice. */
|
|
192
|
+
export const DELIVERY_DUPLICATE_WINDOW_MS = 2000;
|
|
193
|
+
const DELIVERY_EVENT_COLUMNS = [
|
|
194
|
+
'ts', 'ledger_version', 'tenant_id', 'runtime', 'event_type', 'surface', 'store_hash', 'write_store', 'project_hash',
|
|
195
|
+
'session_id', 'session_state', 'host_turn_id', 'turn_seq', 'duplicate_of', 'prompt_hash', 'prompt_length', 'query_hash',
|
|
196
|
+
'recall_trace_id', 'block_state', 'prompt_recall', 'considered_count', 'filtered_count', 'selected_count', 'emitted_count',
|
|
197
|
+
'rejected_count', 'rejected_unlisted', 'sections_shown', 'sections_dropped', 'budget_tokens', 'selected_tokens',
|
|
198
|
+
'injected_tokens', 'static_hash', 'recall_hash', 'emitted_hash', 'elapsed_ms',
|
|
199
|
+
];
|
|
200
|
+
function findDuplicateTurn(db, input) {
|
|
201
|
+
if (input.hostTurnId !== null) {
|
|
202
|
+
// SAFETY: a single `id` column, undefined when no row matches.
|
|
203
|
+
const row = db.prepare(`
|
|
204
|
+
SELECT id FROM delivery_events
|
|
205
|
+
WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND host_turn_id = ? AND turn_seq IS NOT NULL
|
|
206
|
+
ORDER BY id LIMIT 1
|
|
207
|
+
`).get(input.tenantId, input.sessionId, input.eventType, input.hostTurnId);
|
|
208
|
+
return row?.id ?? null;
|
|
209
|
+
}
|
|
210
|
+
if (input.promptHash === null)
|
|
211
|
+
return null;
|
|
212
|
+
// SAFETY: rows carry exactly the `id` and `ts` columns selected.
|
|
213
|
+
const rows = db.prepare(`
|
|
214
|
+
SELECT id, ts FROM delivery_events
|
|
215
|
+
WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND prompt_hash = ? AND host_turn_id IS NULL AND turn_seq IS NOT NULL
|
|
216
|
+
ORDER BY id
|
|
217
|
+
`).all(input.tenantId, input.sessionId, input.eventType, input.promptHash);
|
|
218
|
+
const at = Date.parse(input.ts);
|
|
219
|
+
// Absolute difference: two hook processes can commit out of ts order.
|
|
220
|
+
return rows.find((r) => Math.abs(Date.parse(r.ts) - at) <= DELIVERY_DUPLICATE_WINDOW_MS)?.id ?? null;
|
|
221
|
+
}
|
|
222
|
+
function nextTurnSeq(db, input) {
|
|
223
|
+
// SAFETY: a single MAX aggregate aliased `m`, NULL when the session has no turns yet.
|
|
224
|
+
const row = db.prepare(`
|
|
225
|
+
SELECT MAX(turn_seq) AS m FROM delivery_events
|
|
226
|
+
WHERE tenant_id = ? AND session_id = ? AND event_type = ? AND turn_seq IS NOT NULL
|
|
227
|
+
`).get(input.tenantId, input.sessionId, input.eventType);
|
|
228
|
+
return (row.m ?? 0) + 1;
|
|
229
|
+
}
|
|
230
|
+
/** One event plus its candidates in one write transaction, then prune; fail-soft. The caller must not hold a transaction on `db`. */
|
|
231
|
+
export function writeDeliveryEvent(db, input) {
|
|
232
|
+
try {
|
|
233
|
+
db.exec('BEGIN IMMEDIATE');
|
|
234
|
+
try {
|
|
235
|
+
// Missing-session and sub-agent events are not turns of a session, so they get no number and no duplicate check.
|
|
236
|
+
const isTurn = input.sessionId !== null && (input.sessionState === 'payload' || input.sessionState === 'env');
|
|
237
|
+
const duplicateOf = isTurn ? findDuplicateTurn(db, input) : null;
|
|
238
|
+
const turnSeq = isTurn && duplicateOf === null ? nextTurnSeq(db, input) : null;
|
|
239
|
+
const values = [
|
|
240
|
+
input.ts, DELIVERY_LEDGER_VERSION, input.tenantId, input.runtime, input.eventType, input.surface, input.storeHash,
|
|
241
|
+
input.writeStore, input.projectHash, input.sessionId, input.sessionState, input.hostTurnId, turnSeq, duplicateOf,
|
|
242
|
+
input.promptHash, input.promptLength, input.queryHash, input.recallTraceId, input.blockState, input.promptRecall ? 1 : 0,
|
|
243
|
+
input.consideredCount, input.filteredCount, input.selectedCount, input.emittedCount, input.rejectedCount,
|
|
244
|
+
input.rejectedUnlisted, input.sectionsShown, input.sectionsDropped, input.budgetTokens, input.selectedTokens,
|
|
245
|
+
input.injectedTokens, input.staticHash, input.recallHash, input.emittedHash, Math.round(input.elapsedMs),
|
|
246
|
+
];
|
|
247
|
+
const eventId = Number(db.prepare(`
|
|
248
|
+
INSERT INTO delivery_events (${DELIVERY_EVENT_COLUMNS.join(', ')})
|
|
249
|
+
VALUES (${DELIVERY_EVENT_COLUMNS.map(() => '?').join(', ')})
|
|
250
|
+
`).run(...values).lastInsertRowid);
|
|
251
|
+
const insertCandidate = db.prepare(`
|
|
252
|
+
INSERT INTO delivery_candidates (event_id, tenant_id, memory_id, source_store, pool, stage, outcome, reason, cand_rank, score, tokens)
|
|
253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
254
|
+
`);
|
|
255
|
+
for (const c of input.candidates) {
|
|
256
|
+
insertCandidate.run(eventId, input.tenantId, c.memoryId, c.sourceStore, c.pool, c.stage, c.outcome, c.reason, c.rank, c.score, c.tokens);
|
|
257
|
+
}
|
|
258
|
+
const pruneFrom = Math.min(Date.parse(input.ts), Date.now());
|
|
259
|
+
const cutoff = new Date(pruneFrom - DELIVERY_LEDGER_RETENTION_DAYS * 86_400_000).toISOString();
|
|
260
|
+
db.prepare(`DELETE FROM delivery_events WHERE ts < ?`).run(cutoff);
|
|
261
|
+
db.exec('COMMIT');
|
|
262
|
+
return eventId;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
try {
|
|
266
|
+
db.exec('ROLLBACK');
|
|
267
|
+
}
|
|
268
|
+
catch { /* SQLite may already have rolled back (SQLITE_FULL, IOERR); keep the original error */ }
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// eslint-disable-next-line no-console
|
|
274
|
+
console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Write on a short-lived connection that waits at most {@link DELIVERY_LEDGER_WAIT_MS} for the lock. Fail-soft. */
|
|
279
|
+
export function writeDeliveryEventAtRoot(root, input) {
|
|
280
|
+
let db;
|
|
281
|
+
try {
|
|
282
|
+
db = openHippoDb(root, { busyWaitMs: DELIVERY_LEDGER_WAIT_MS });
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
return writeDeliveryEvent(db, input);
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
closeHippoDb(db);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** On a caller's open handle, which saves a second open and close per turn; the handle's own lock wait comes back after. */
|
|
297
|
+
export function writeDeliveryEventOnHandle(db, input) {
|
|
298
|
+
const prior = Math.trunc(Number(db.prepare('PRAGMA busy_timeout').get().timeout));
|
|
299
|
+
db.exec(`PRAGMA busy_timeout = ${DELIVERY_LEDGER_WAIT_MS}`);
|
|
300
|
+
try {
|
|
301
|
+
return writeDeliveryEvent(db, input);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
db.exec(`PRAGMA busy_timeout = ${prior}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/** A session's delivery events in write order, each with its candidate rows; `sessionId` null reads session-less events. */
|
|
308
|
+
export function readDeliveryEvents(db, tenantId, sessionId) {
|
|
309
|
+
// SAFETY: SELECT * over delivery_events returns exactly the columns DeliveryEventRow names, less `candidates`.
|
|
310
|
+
const events = db.prepare(`SELECT * FROM delivery_events WHERE tenant_id = ? AND session_id IS ? ORDER BY id`)
|
|
311
|
+
.all(tenantId, sessionId);
|
|
312
|
+
const candidates = db.prepare(`
|
|
313
|
+
SELECT * FROM delivery_candidates WHERE event_id = ?
|
|
314
|
+
ORDER BY outcome = 'rejected', cand_rank, memory_id
|
|
315
|
+
`);
|
|
316
|
+
return events.map((e) => ({
|
|
317
|
+
...e,
|
|
318
|
+
// SAFETY: SELECT * over delivery_candidates returns exactly the columns DeliveryCandidateRow names.
|
|
319
|
+
candidates: candidates.all(e.id),
|
|
320
|
+
}));
|
|
321
|
+
}
|
|
186
322
|
//# sourceMappingURL=recall-trace.js.map
|
package/dist/token-ledger.d.ts
CHANGED
|
@@ -116,6 +116,8 @@ interface ModelTagged {
|
|
|
116
116
|
export declare function isSyntheticMessage(message: ModelTagged): boolean;
|
|
117
117
|
/** A hook payload's non-empty `session_id`, or null; with `requiredSource`, also null when its `source` differs. */
|
|
118
118
|
export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
|
|
119
|
+
/** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
|
|
120
|
+
export declare function hookPayloadString(stdinText: string | undefined, field: string): string | null;
|
|
119
121
|
/** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
|
|
120
122
|
* Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
|
|
121
123
|
export declare function isSubagentPayload(stdinText: string | undefined): boolean;
|
package/dist/token-ledger.js
CHANGED
|
@@ -174,6 +174,11 @@ export function hookPayloadSessionId(stdinText, requiredSource = null) {
|
|
|
174
174
|
return null;
|
|
175
175
|
return sessionId;
|
|
176
176
|
}
|
|
177
|
+
/** A hook payload's string `field` as sent, or null when the payload or the field is missing or not a string. */
|
|
178
|
+
export function hookPayloadString(stdinText, field) {
|
|
179
|
+
const value = parseHookPayload(stdinText)?.[field];
|
|
180
|
+
return isJsonString(value) ? value : null;
|
|
181
|
+
}
|
|
177
182
|
/** Whether a hook fired inside a sub-agent, the only payload with `agent_id` (https://code.claude.com/docs/en/hooks#common-input-fields).
|
|
178
183
|
* Its `session_id` is the parent's, so a sub-agent's blocks and compactions must not count as the parent's. */
|
|
179
184
|
export function isSubagentPayload(stdinText) {
|
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.
|
|
19
|
+
export declare const PACKAGE_VERSION = "1.56.0";
|
|
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.
|
|
19
|
+
export const PACKAGE_VERSION = '1.56.0';
|
|
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": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.56.0",
|
|
6
6
|
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hippo-memory",
|
|
3
3
|
"name": "Hippo Memory",
|
|
4
4
|
"description": "Memory for AI agents that learns what is wrong and ranks it down. Injects context at session start and captures errors.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.56.0",
|
|
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.
|
|
3
|
+
"version": "1.56.0",
|
|
4
4
|
"description": "Memory for AI agents that learns what is wrong and ranks it down. MCP server, hooks for Claude Code, OpenCode and Codex, AGENTS.md instructions for Codex, Cursor, OpenClaw and Pi. SQLite, zero runtime deps.",
|
|
5
5
|
"mcpName": "io.github.kitfunso/hippo-memory",
|
|
6
6
|
"type": "module",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"pretest": "npm run build",
|
|
35
35
|
"test": "vitest run",
|
|
36
36
|
"test:watch": "vitest",
|
|
37
|
+
"test:delivery-ledger": "vitest run tests/delivery-ledger",
|
|
37
38
|
"lint": "oxlint",
|
|
38
39
|
"sbom": "node scripts/sbom.mjs",
|
|
39
40
|
"audit:security": "npm audit --audit-level=high && npm --prefix ui audit --audit-level=high",
|