hippo-memory 1.54.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 +16 -5
- package/dist/api.d.ts +3 -0
- package/dist/api.js +55 -11
- package/dist/cli.js +91 -18
- package/dist/config.d.ts +7 -2
- package/dist/config.js +22 -1
- package/dist/db.js +67 -1
- package/dist/delivery-recorder.d.ts +127 -0
- package/dist/delivery-recorder.js +218 -0
- package/dist/hooks.d.ts +1 -1
- package/dist/hooks.js +1 -1
- package/dist/recall-trace.d.ts +69 -0
- package/dist/recall-trace.js +136 -0
- package/dist/session-digest.js +1 -5
- 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
|
@@ -118,7 +118,7 @@ Run `hippo init` inside one project. This is everything it writes, in the projec
|
|
|
118
118
|
- **Instruction files.** A block between `<!-- hippo:start -->` and `<!-- hippo:end -->` in the project's `CLAUDE.md` or `AGENTS.md`, only if that file already exists. Codex, Cursor, OpenClaw, OpenCode and Pi read `AGENTS.md`.
|
|
119
119
|
- **Claude Code,** when the project has `CLAUDE.md` or `.claude/settings.json`: 7 hook entries in `~/.claude/settings.json`, one each on SessionEnd, UserPromptSubmit, PreCompact, PostCompact and PostToolUseFailure and two on SessionStart. [Framework Integrations](#framework-integrations) says what each one runs.
|
|
120
120
|
- **OpenCode,** when the project has `.opencode/` or `opencode.json`: a plugin at `~/.config/opencode/plugins/hippo.ts`.
|
|
121
|
-
- **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus
|
|
121
|
+
- **Codex,** when the project has `AGENTS.md` or `.codex` and Codex is installed (`$CODEX_HOME`, else `~/.codex`, exists): 2 hook entries in Codex's `hooks.json`, one on UserPromptSubmit that sends your pinned memories plus up to 5 that match the prompt with every prompt and one on SessionStart after a compaction. **Codex runs them only after you trust them once in `/hooks`.** Init also prints `hippo hook install codex`, the opt-in that wraps the Codex launcher to capture sessions; `hippo hook uninstall codex` removes hippo's hooks and the wrapper.
|
|
122
122
|
- **A daily run at 6:15am,** one per machine: a crontab line on Linux and macOS, a scheduled task named `hippo-daily-runner` on Windows. It runs `hippo learn --git --days 1` and then `hippo sleep` in every project listed in `~/.hippo/workspaces.json`, and init adds this project to that list.
|
|
123
123
|
- **Agent memories.** On every run, the notes your coding agents keep about this project go into its store, and the ones about you go into the global store. [Agent memories](#agent-memories) lists what is read.
|
|
124
124
|
|
|
@@ -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
|
|
@@ -783,7 +794,7 @@ The block asks for nothing a hook already does, because each extra tool call re-
|
|
|
783
794
|
For Claude Code, it also adds 7 hook entries to `~/.claude/settings.json`:
|
|
784
795
|
- a `SessionEnd` hook that runs `hippo sleep` and then `hippo capture` when the session exits. Capture matches the last 20 user and 10 assistant messages of the transcript against word patterns for decisions, rules, errors and preferences. It uses no model and does not read earlier turns, so record lessons with `hippo remember` as you go.
|
|
785
796
|
- a `SessionStart` hook that prints the previous session's consolidation output
|
|
786
|
-
- a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus
|
|
797
|
+
- a `UserPromptSubmit` hook that runs `hippo context --pinned-only --include-recent 5 --format additional-context` every turn. It re-injects pinned memories (`hippo remember <text> --pin`) plus up to 5 memories that share words with your prompt. When nothing matches, it adds only the pinned ones. Since 1.55.0 this replaces the 5 newest memories, which cut the median block from 847 to 533 tokens in our eval. `{"pinnedInject":{"promptRecall":false}}` brings back the 5 newest, so fresh same-session lessons appear on the next prompt whatever you ask. The block is rendered without live strength percentages, so it stays byte-identical while its memories do not change, and it is sent only when it changed since the session's last prompt: an unchanged block is skipped, resent every 10 skips (`pinnedInject.refreshTurns`, `0` never resends) and resent after compaction. The prompt-matched memories go in a separate block that is never skipped, so they are sent on every prompt they match. `{"pinnedInject":{"skipUnchanged":false}}` sends the pinned block every turn as before. Opt out entirely with `{"pinnedInject":{"enabled":false}}` in `.hippo/config.json`.
|
|
787
798
|
- a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It records the compaction in the store, saves a working-state snapshot (task/summary/next step) so mid-session compaction can't drop it, and asks the summariser to end its summary with a "Memories for hippo" list: the lessons, decisions and corrections from the session that should outlive it. The `SessionEnd` hook still owns extracting durable memories from the transcript.
|
|
788
799
|
- a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot back into context right after compaction, if it is under 15 minutes old.
|
|
789
800
|
- a `PostCompact` hook that runs `hippo post-compact`. It keeps the summary in the store with secrets scrubbed, and saves each item of that list as a memory that sleep never deletes: at most 10 per compaction, skipping an item an earlier compaction already saved and any item that looks like a secret. An item over 500 characters stays in the compaction's record only. It then prints one line, such as "Hippo saved 3 memories from this compaction and restored your task snapshot." If the store is busy, the summary waits in the store's `compactions-spool/` folder and `hippo sleep` finishes the save; `hippo doctor` names any compaction left unfinished for over 10 minutes. The session that compacted does not have those memories injected back into its own prompts, since it just read them in the summary; `hippo recall` still finds them. The hook prints nothing when there is no store to save to.
|
|
@@ -794,7 +805,7 @@ Only Claude Code saves memories at a compaction: hippo installs no `PreCompact`
|
|
|
794
805
|
To remove: `hippo hook uninstall claude-code`
|
|
795
806
|
|
|
796
807
|
For Codex, it adds two hooks to `$CODEX_HOME/hooks.json` (else `~/.codex/hooks.json`) and keeps every hook already there:
|
|
797
|
-
- a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus
|
|
808
|
+
- a `UserPromptSubmit` hook that runs the same `hippo context --pinned-only` command as Claude Code's, so your pinned memories plus up to 5 that match the prompt reach every prompt as developer context
|
|
798
809
|
- a `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume` after a compaction, so the next prompt sends that block again. Codex gets no `PreCompact` hook from hippo, so it restores a task snapshot only if one was saved with `hippo snapshot save` in the last 15 minutes
|
|
799
810
|
|
|
800
811
|
**Codex runs a new or changed hook only after you trust it, so open `/hooks` in Codex once and trust both;** `hippo doctor` reminds you. The per-prompt hook was checked against a real Codex request; the compaction hook follows Codex's documented `compact` start source and has not been watched end to end in Codex. Each hook also carries a `commandWindows` form (`hippo.cmd ...`), because Codex runs hooks through PowerShell on Windows, where the execution policy can block npm's `hippo.ps1`. hippo only ever appends these two entries and never rewrites one, since Codex treats a changed command as a new hook to trust. To remove: `hippo hook uninstall codex`, which takes out only hippo's exact commands and leaves every other hook, including one of yours that runs hippo.
|
|
@@ -1056,7 +1067,7 @@ node run.mjs --adapter all
|
|
|
1056
1067
|
|
|
1057
1068
|
### How do I give Claude Code memory between sessions?
|
|
1058
1069
|
|
|
1059
|
-
Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus
|
|
1070
|
+
Run `npm install -g hippo-memory`, then `hippo init` in the project. If the project has a `CLAUDE.md`, init adds a short block telling Claude to run `hippo context --auto` when a session starts. It also adds 7 hook entries to Claude Code's settings that keep your pinned memories plus up to 5 that match the prompt in context, save a task snapshot and the memories a compaction summary lists, store failed tool calls as lessons, and run `hippo sleep` when the session ends, and it sets up a daily 6:15am run. [What hippo init changes](#what-hippo-init-changes) lists everything. The [Claude Code plugin](https://github.com/kitfunso/hippo-memory/tree/master/extensions/claude-code-plugin) is the alternative to these hooks; use one, not both. To set up every git repo up to three folders below your home directory at once, know what the scan changes first: each repo gets its own store, seeded from a year of its git history, the same hooks go in when one of those repos uses Claude Code, and the daily run is set up, but no block goes into any repo's `CLAUDE.md`. The command is `hippo init --scan ~`; run `hippo init` in the projects where you want the block.
|
|
1060
1071
|
|
|
1061
1072
|
### How do I give Cursor memory between sessions?
|
|
1062
1073
|
|
|
@@ -1064,7 +1075,7 @@ Run `npm install -g hippo-memory`, then `hippo init` in the project. If the proj
|
|
|
1064
1075
|
|
|
1065
1076
|
### How do I give Codex memory across sessions?
|
|
1066
1077
|
|
|
1067
|
-
`hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus
|
|
1078
|
+
`hippo init` adds its instructions to your `AGENTS.md`, which Codex reads before it starts work. When Codex is installed, init also adds two hooks to Codex's `hooks.json`: one puts your pinned memories plus up to 5 that match the prompt into every prompt, the other makes the next prompt send them again after a compaction. Codex asks you to trust each new hook once in `/hooks`, and skips it until you do. Capturing Codex sessions is opt-in: `hippo hook install codex` wraps the Codex launcher, and `hippo hook uninstall codex` removes the wrapper and the hooks.
|
|
1068
1079
|
|
|
1069
1080
|
### Which agents does hippo work with?
|
|
1070
1081
|
|
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
|
/**
|
|
@@ -8773,7 +8846,7 @@ Commands:
|
|
|
8773
8846
|
--budget <n> Token budget for the whole printed block (default: 1500)
|
|
8774
8847
|
--pinned-only Only inject pinned memories (used by UserPromptSubmit hook)
|
|
8775
8848
|
--include-recent <n> With --pinned-only, also inject the last N writes regardless of pinning
|
|
8776
|
-
(the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on)
|
|
8849
|
+
(the hook payload's "prompt" drives prompt recall instead of --include-recent when pinnedInject.promptRecall is on, the default)
|
|
8777
8850
|
--format <fmt> Output format: markdown (default), json, or additional-context (Claude Code hook JSON)
|
|
8778
8851
|
--framing <mode> Framing: observe (default), suggest, assert
|
|
8779
8852
|
sleep Run consolidation pass (auto-learns + dedup + auto-shares)
|
package/dist/config.d.ts
CHANGED
|
@@ -71,8 +71,8 @@ export interface HippoConfig {
|
|
|
71
71
|
* never resends an unchanged block. */
|
|
72
72
|
refreshTurns: number;
|
|
73
73
|
/** Z1: gate the hook's backfill on the prompt's own content instead of
|
|
74
|
-
* the five newest memories. Default
|
|
75
|
-
* (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
74
|
+
* the five newest memories. Default true since 1.55.0: overlap tied but median
|
|
75
|
+
* tokens fell 847 to 533 (docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
76
76
|
promptRecall: boolean;
|
|
77
77
|
/** Z1: overlap metric for the prompt-recall gate. Default 'jaccard' (tuned, docs/evals/2026-09-26-z1-prompt-recall-result.md). */
|
|
78
78
|
promptRecallMetric: PromptRecallMetric;
|
|
@@ -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
|
@@ -47,7 +47,7 @@ const DEFAULT_CONFIG = {
|
|
|
47
47
|
budget: 1500,
|
|
48
48
|
skipUnchanged: true,
|
|
49
49
|
refreshTurns: 10,
|
|
50
|
-
promptRecall:
|
|
50
|
+
promptRecall: true,
|
|
51
51
|
promptRecallMetric: 'jaccard',
|
|
52
52
|
promptRecallThreshold: 0.04,
|
|
53
53
|
promptRecallMinShared: 2,
|
|
@@ -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))
|