hippo-memory 1.56.0 → 1.58.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/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/api-errors.d.ts +27 -0
- package/dist/api-errors.js +37 -0
- package/dist/api.d.ts +21 -14
- package/dist/api.js +97 -71
- package/dist/audit.d.ts +4 -0
- package/dist/audit.js +11 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +7 -5
- package/dist/capture-contract.d.ts +47 -0
- package/dist/capture-contract.js +49 -0
- package/dist/capture-error.js +2 -1
- package/dist/capture.d.ts +0 -13
- package/dist/capture.js +5 -66
- package/dist/card-detail.d.ts +1 -1
- package/dist/card-detail.js +1 -1
- package/dist/cli/shared.d.ts +137 -0
- package/dist/cli/shared.js +834 -0
- package/dist/cli/sleep.d.ts +10 -0
- package/dist/cli/sleep.js +171 -0
- package/dist/cli.d.ts +0 -7
- package/dist/cli.js +322 -1827
- package/dist/client.js +9 -0
- package/dist/codex-patch.js +1 -1
- package/dist/compaction-record.d.ts +1 -1
- package/dist/compaction-record.js +3 -2
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/github/dlq.js +5 -2
- package/dist/connectors/github/octokit-client.js +4 -2
- package/dist/connectors/github/webhook.d.ts +19 -0
- package/dist/connectors/github/webhook.js +313 -0
- package/dist/connectors/slack/dlq.js +6 -2
- package/dist/connectors/slack/web-client.js +7 -5
- package/dist/connectors/slack/webhook.d.ts +22 -0
- package/dist/connectors/slack/webhook.js +203 -0
- package/dist/consolidate.d.ts +10 -0
- package/dist/consolidate.js +38 -35
- package/dist/context-auto.d.ts +3 -0
- package/dist/context-auto.js +34 -0
- package/dist/customer-notes.js +16 -14
- package/dist/dag.js +3 -2
- package/dist/dashboard.js +3 -2
- package/dist/db.d.ts +12 -0
- package/dist/db.js +62 -1
- package/dist/decisions.js +11 -9
- package/dist/doctor.js +5 -0
- package/dist/embedding-provider.js +3 -3
- package/dist/embeddings.d.ts +4 -4
- package/dist/embeddings.js +72 -16
- package/dist/eval-stats.d.ts +58 -0
- package/dist/eval-stats.js +111 -0
- package/dist/extract.js +3 -2
- package/dist/goals.d.ts +49 -25
- package/dist/goals.js +39 -22
- package/dist/graph-extract.js +1 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +1 -1
- package/dist/graph.js +1 -1
- package/dist/hooks.d.ts +1 -3
- package/dist/hooks.js +2 -4
- package/dist/http-retry.d.ts +21 -0
- package/dist/http-retry.js +50 -0
- package/dist/http-util.d.ts +39 -0
- package/dist/http-util.js +56 -0
- package/dist/importers.d.ts +2 -0
- package/dist/importers.js +16 -5
- package/dist/incidents.js +13 -11
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/judgment.js +10 -17
- package/dist/log.d.ts +25 -0
- package/dist/log.js +48 -0
- package/dist/mcp/server.js +224 -308
- package/dist/mcp/tool-args.d.ts +21 -0
- package/dist/mcp/tool-args.js +80 -0
- package/dist/memory.d.ts +19 -0
- package/dist/memory.js +41 -2
- package/dist/overlap-index.d.ts +7 -0
- package/dist/overlap-index.js +38 -0
- package/dist/pilot-arm.d.ts +9 -0
- package/dist/pilot-arm.js +47 -0
- package/dist/policies.js +14 -12
- package/dist/predictions.js +11 -9
- package/dist/processes.js +16 -14
- package/dist/project-briefs.js +19 -16
- package/dist/project-identity.d.ts +1 -1
- package/dist/project-identity.js +25 -1
- package/dist/prompt-recall.js +1 -1
- package/dist/raw-archive.js +7 -6
- package/dist/recall-history.d.ts +5 -0
- package/dist/recall-history.js +9 -0
- package/dist/recall-pipeline.d.ts +101 -0
- package/dist/recall-pipeline.js +313 -0
- package/dist/recall-scope.d.ts +24 -1
- package/dist/recall-scope.js +29 -2
- package/dist/refine-llm.js +3 -2
- package/dist/reject-flow.js +6 -9
- package/dist/rejection.d.ts +2 -1
- package/dist/rejection.js +2 -1
- package/dist/search.d.ts +0 -20
- package/dist/search.js +16 -51
- package/dist/secret-detect.d.ts +13 -1
- package/dist/secret-detect.js +33 -1
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1854 -2566
- package/dist/session-digest.js +2 -1
- package/dist/shared.js +7 -6
- package/dist/skills.js +17 -15
- package/dist/store-cards.d.ts +53 -0
- package/dist/store-cards.js +512 -0
- package/dist/store.d.ts +2 -89
- package/dist/store.js +10 -566
- package/dist/tenant.d.ts +22 -0
- package/dist/tenant.js +26 -0
- package/dist/token-ledger.d.ts +4 -2
- package/dist/token-ledger.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +8 -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 +1 -1
- package/dist/connectors/slack/ratelimit.d.ts +0 -9
- package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/project-briefs.js
CHANGED
|
@@ -21,8 +21,11 @@
|
|
|
21
21
|
* Lifecycle: active -> superseded (a newer version replaces it) or active ->
|
|
22
22
|
* closed (retired).
|
|
23
23
|
*/
|
|
24
|
+
import { BadRequestError, ConflictError, NotFoundError } from './api-errors.js';
|
|
24
25
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
25
|
-
import { writeEntry
|
|
26
|
+
import { writeEntry } from './store.js';
|
|
27
|
+
import { assertTenantId } from './tenant.js';
|
|
28
|
+
import { RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
|
|
26
29
|
import { markGraphDirty, removeGraphEntitiesForObject } from './graph.js';
|
|
27
30
|
import { createMemory, Layer } from './memory.js';
|
|
28
31
|
import { appendAuditEvent } from './audit.js';
|
|
@@ -54,21 +57,21 @@ export const MAX_RECEIPT_HEADLINE_LEN = 200;
|
|
|
54
57
|
function validateBriefFields(repo, summary, changeSummary) {
|
|
55
58
|
const normalizedRepo = (repo ?? '').trim();
|
|
56
59
|
if (normalizedRepo.length === 0)
|
|
57
|
-
throw new
|
|
60
|
+
throw new BadRequestError('saveProjectBrief: repo is required');
|
|
58
61
|
if (/[\r\n]/.test(normalizedRepo)) {
|
|
59
|
-
throw new
|
|
62
|
+
throw new BadRequestError('saveProjectBrief: repo must be a single line (no newlines)');
|
|
60
63
|
}
|
|
61
64
|
if (normalizedRepo.length > MAX_REPO_LEN) {
|
|
62
|
-
throw new
|
|
65
|
+
throw new BadRequestError(`saveProjectBrief: repo exceeds the ${MAX_REPO_LEN}-char cap`);
|
|
63
66
|
}
|
|
64
67
|
if (!summary || summary.trim().length === 0) {
|
|
65
|
-
throw new
|
|
68
|
+
throw new BadRequestError('saveProjectBrief: summary is required');
|
|
66
69
|
}
|
|
67
70
|
if (summary.length > MAX_BRIEF_SUMMARY_LEN) {
|
|
68
|
-
throw new
|
|
71
|
+
throw new BadRequestError(`saveProjectBrief: summary exceeds the ${MAX_BRIEF_SUMMARY_LEN}-char cap`);
|
|
69
72
|
}
|
|
70
73
|
if (changeSummary !== undefined && changeSummary.length > MAX_CHANGE_SUMMARY_LEN) {
|
|
71
|
-
throw new
|
|
74
|
+
throw new BadRequestError(`saveProjectBrief: changeSummary exceeds the ${MAX_CHANGE_SUMMARY_LEN}-char cap`);
|
|
72
75
|
}
|
|
73
76
|
return { repo: normalizedRepo };
|
|
74
77
|
}
|
|
@@ -143,10 +146,10 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
143
146
|
// shape for the matching row, or undefined when no brief/tenant pair matches.
|
|
144
147
|
const pred = db.prepare(`SELECT status, version FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(opts.supersedesBriefId, tenantId);
|
|
145
148
|
if (!pred) {
|
|
146
|
-
throw new
|
|
149
|
+
throw new NotFoundError(`saveProjectBrief: brief ${opts.supersedesBriefId} to supersede not found for tenant ${tenantId}`);
|
|
147
150
|
}
|
|
148
151
|
if (pred.status !== 'active') {
|
|
149
|
-
throw new
|
|
152
|
+
throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} is not active (status='${pred.status}'); only active briefs can be superseded.`);
|
|
150
153
|
}
|
|
151
154
|
version = pred.version + 1;
|
|
152
155
|
}
|
|
@@ -164,7 +167,7 @@ export function saveProjectBrief(hippoRoot, tenantId, opts, actor = 'cli') {
|
|
|
164
167
|
WHERE id = ? AND tenant_id = ? AND status = 'active' AND id != ?
|
|
165
168
|
`).run(briefId, now, opts.supersedesBriefId, tenantId, briefId);
|
|
166
169
|
if (sup.changes === 0) {
|
|
167
|
-
throw new
|
|
170
|
+
throw new ConflictError(`saveProjectBrief: brief ${opts.supersedesBriefId} could not be superseded (no longer active or self-reference).`);
|
|
168
171
|
}
|
|
169
172
|
appendAuditEvent(db, {
|
|
170
173
|
tenantId,
|
|
@@ -232,9 +235,9 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
232
235
|
// shape, or undefined when the id/tenant pair doesn't exist.
|
|
233
236
|
const existing = db.prepare(`SELECT status FROM project_briefs WHERE id = ? AND tenant_id = ?`).get(id, tenantId);
|
|
234
237
|
if (!existing) {
|
|
235
|
-
throw new
|
|
238
|
+
throw new NotFoundError(`closeProjectBrief: brief ${id} not found for tenant ${tenantId}`);
|
|
236
239
|
}
|
|
237
|
-
throw new
|
|
240
|
+
throw new ConflictError(`closeProjectBrief: brief ${id} is not active (status='${existing.status}'); only active briefs can be closed.`);
|
|
238
241
|
}
|
|
239
242
|
// SAFETY: SELECT ${BRIEF_COLS} projects exactly the ProjectBriefRow
|
|
240
243
|
// columns; .get() returns that row for the just-updated id, or undefined
|
|
@@ -242,7 +245,7 @@ export function closeProjectBrief(hippoRoot, tenantId, id, actor = 'cli') {
|
|
|
242
245
|
const row = db.prepare(`SELECT ${BRIEF_COLS} FROM project_briefs WHERE id = ? AND tenant_id = ?`)
|
|
243
246
|
.get(id, tenantId);
|
|
244
247
|
if (!row)
|
|
245
|
-
throw new
|
|
248
|
+
throw new NotFoundError(`closeProjectBrief: brief ${id} not found after UPDATE`);
|
|
246
249
|
appendAuditEvent(db, {
|
|
247
250
|
tenantId,
|
|
248
251
|
actor,
|
|
@@ -296,7 +299,7 @@ export function loadProjectBriefs(hippoRoot, tenantId, opts = {}) {
|
|
|
296
299
|
assertTenantId('loadProjectBriefs', tenantId);
|
|
297
300
|
const limit = opts.limit ?? 100;
|
|
298
301
|
if (opts.status && !VALID_BRIEF_STATES.has(opts.status)) {
|
|
299
|
-
throw new
|
|
302
|
+
throw new BadRequestError(`loadProjectBriefs: status must be one of ${Array.from(VALID_BRIEF_STATES).join('|')}; got ${opts.status}`);
|
|
300
303
|
}
|
|
301
304
|
const db = openHippoDb(hippoRoot);
|
|
302
305
|
try {
|
|
@@ -383,7 +386,7 @@ export function assembleBriefFromReceipts(hippoRoot, tenantId, repo) {
|
|
|
383
386
|
assertTenantId('assembleBriefFromReceipts', tenantId);
|
|
384
387
|
const normalizedRepo = (repo ?? '').trim();
|
|
385
388
|
if (normalizedRepo.length === 0) {
|
|
386
|
-
throw new
|
|
389
|
+
throw new BadRequestError('assembleBriefFromReceipts: repo is required');
|
|
387
390
|
}
|
|
388
391
|
const tag = `path:${normalizedRepo.toLowerCase()}`;
|
|
389
392
|
const likeParam = `%"${escapeLike(tag)}"%`;
|
|
@@ -476,7 +479,7 @@ export function refreshBrief(hippoRoot, tenantId, repo, actor = 'cli') {
|
|
|
476
479
|
assertTenantId('refreshBrief', tenantId);
|
|
477
480
|
const normalizedRepo = (repo ?? '').trim();
|
|
478
481
|
if (normalizedRepo.length === 0)
|
|
479
|
-
throw new
|
|
482
|
+
throw new BadRequestError('refreshBrief: repo is required');
|
|
480
483
|
const { markdown, receiptCount } = assembleBriefFromReceipts(hippoRoot, tenantId, normalizedRepo);
|
|
481
484
|
const active = loadActiveBriefForRepo(hippoRoot, tenantId, normalizedRepo);
|
|
482
485
|
return saveProjectBrief(hippoRoot, tenantId, {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
export interface ProjectIdentity {
|
|
20
20
|
/** Realpath-resolved root directory of the project (the start dir when not in a project). */
|
|
21
21
|
root: string;
|
|
22
|
-
/** Lowercased basename of the project root
|
|
22
|
+
/** Lowercased basename of the project root, or of its repo for a linked worktree with no `.hippo` (a store keeps the name its rows carry); '' outside a project. */
|
|
23
23
|
name: string;
|
|
24
24
|
/** True when the directory resolves to the user home working set. */
|
|
25
25
|
isHome: boolean;
|
package/dist/project-identity.js
CHANGED
|
@@ -66,7 +66,8 @@ export function resolveProjectIdentity(cwd, opts) {
|
|
|
66
66
|
let identity;
|
|
67
67
|
const root = hippoRoot ?? gitRoot;
|
|
68
68
|
if (root !== null) {
|
|
69
|
-
|
|
69
|
+
const name = (hippoRoot === null ? linkedWorktreeRepoName(root, home) : null) ?? path.basename(root).toLowerCase();
|
|
70
|
+
identity = { root, name, isHome: false };
|
|
70
71
|
}
|
|
71
72
|
else if (reachedHome || isUnder(start, home)) {
|
|
72
73
|
identity = { root: home, name: '', isHome: true };
|
|
@@ -80,6 +81,29 @@ export function resolveProjectIdentity(cwd, opts) {
|
|
|
80
81
|
identityCache.set(startInput, identity);
|
|
81
82
|
return identity;
|
|
82
83
|
}
|
|
84
|
+
/** The repo name a linked worktree shares with its main checkout (`repo.git` or `repo/.bare` for a bare repo), so one repo is one project; null for any other checkout. */
|
|
85
|
+
function linkedWorktreeRepoName(gitRoot, home) {
|
|
86
|
+
const marker = path.join(gitRoot, '.git');
|
|
87
|
+
if (!fs.existsSync(marker) || isDirectoryAt(marker))
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
const gitDir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(marker, 'utf8'))?.[1]?.trim();
|
|
91
|
+
if (!gitDir)
|
|
92
|
+
return null;
|
|
93
|
+
const linkDir = path.resolve(gitRoot, gitDir);
|
|
94
|
+
const commondir = path.join(linkDir, 'commondir');
|
|
95
|
+
if (!fs.existsSync(commondir))
|
|
96
|
+
return null; // a submodule's or a separate git dir's own checkout
|
|
97
|
+
const common = realpathOrResolve(path.resolve(linkDir, fs.readFileSync(commondir, 'utf8').trim()));
|
|
98
|
+
const repo = ['.git', '.bare'].includes(path.basename(common)) ? path.dirname(common) : common;
|
|
99
|
+
if (samePath(repo, home))
|
|
100
|
+
return null; // a dotfiles repo at home must not make its worktrees user-global
|
|
101
|
+
return path.basename(repo).replace(/\.git$/i, '').toLowerCase() || null;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null; // an unreadable link is still a checkout, named after its own folder
|
|
105
|
+
}
|
|
106
|
+
}
|
|
83
107
|
/** Climb from start toward the root; home (never a project) and every stop dir end the walk unchecked. */
|
|
84
108
|
function walkProjectMarkers(start, home, stopDirs) {
|
|
85
109
|
let hippoRoot = null;
|
package/dist/prompt-recall.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Z1: recall gated on the hook prompt, not the five newest memories (pure, no I/O).
|
|
2
2
|
* See docs/plans/2026-09-26-z1-prompt-recall.md. */
|
|
3
|
-
import { tokenize } from './
|
|
3
|
+
import { tokenize } from './tokenize.js';
|
|
4
4
|
import { STOP_WORDS } from './audit.js';
|
|
5
5
|
// Latency bound, not tuned: fixed in the prereg regardless of gate config.
|
|
6
6
|
export const PROMPT_RECALL_MAX_CHARS = 4000;
|
package/dist/raw-archive.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { BadRequestError, NotFoundError } from './api-errors.js';
|
|
1
2
|
import { isFtsAvailable } from './db.js';
|
|
2
|
-
import { appendAuditEvent } from './audit.js';
|
|
3
|
+
import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
|
|
3
4
|
import { markSummaryDirtyInTx } from './store.js';
|
|
4
5
|
export function archiveRawMemory(db, id, opts) {
|
|
5
6
|
// SAFETY: SELECT * FROM memories returns every column of the memories table; only
|
|
@@ -7,9 +8,9 @@ export function archiveRawMemory(db, id, opts) {
|
|
|
7
8
|
// null) by the memories schema.
|
|
8
9
|
const row = db.prepare(`SELECT * FROM memories WHERE id = ?`).get(id);
|
|
9
10
|
if (!row)
|
|
10
|
-
throw new
|
|
11
|
+
throw new NotFoundError(`memory not found: ${id}`);
|
|
11
12
|
if (row.kind !== 'raw') {
|
|
12
|
-
throw new
|
|
13
|
+
throw new BadRequestError(`memory ${id} is not raw (kind=${String(row.kind)})`);
|
|
13
14
|
}
|
|
14
15
|
// SAVEPOINT (not BEGIN) so this works whether or not we're already inside a
|
|
15
16
|
// transaction. SQLite refuses BEGIN within a transaction; SAVEPOINT nests safely.
|
|
@@ -58,9 +59,9 @@ export function archiveRawMemory(db, id, opts) {
|
|
|
58
59
|
metadata: { reason: opts.reason },
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
catch {
|
|
62
|
-
//
|
|
63
|
-
|
|
62
|
+
catch (error) {
|
|
63
|
+
// The archive itself has already succeeded; an unwritable audit table must not undo it.
|
|
64
|
+
reportAuditWriteFailure('archive_raw', String(error), id);
|
|
64
65
|
}
|
|
65
66
|
// v0.30 / E2 — DAG live-coupling: archive of a child under a level-2
|
|
66
67
|
// summary marks parent dirty. Inside the SAVEPOINT so the dirty-mark
|
package/dist/recall-history.d.ts
CHANGED
|
@@ -124,4 +124,9 @@ export declare function getOrCreateRing(map: Map<string, RingBuffer>, key: strin
|
|
|
124
124
|
export declare function appendRecall(ring: RingBuffer, queryHash: number, topMemoryId: string | null, anchoredOn?: string): void;
|
|
125
125
|
/** Snapshot a ring as a readonly RecallHistorySnapshot. */
|
|
126
126
|
export declare function snapshotRing(ring: RingBuffer): RecallHistorySnapshot;
|
|
127
|
+
/**
|
|
128
|
+
* Whether a recall bias hint is enabled. Reads the env at call time, so
|
|
129
|
+
* `HIPPO_ANCHORING=off` or `HIPPO_AVAILABILITY=off` disables only that kind.
|
|
130
|
+
*/
|
|
131
|
+
export declare function biasHintEnabled(kind: 'anchoring' | 'availability'): boolean;
|
|
127
132
|
//# sourceMappingURL=recall-history.d.ts.map
|
package/dist/recall-history.js
CHANGED
|
@@ -232,4 +232,13 @@ export function appendRecall(ring, queryHash, topMemoryId, anchoredOn) {
|
|
|
232
232
|
export function snapshotRing(ring) {
|
|
233
233
|
return ring.snapshot();
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether a recall bias hint is enabled. Reads the env at call time, so
|
|
237
|
+
* `HIPPO_ANCHORING=off` or `HIPPO_AVAILABILITY=off` disables only that kind.
|
|
238
|
+
*/
|
|
239
|
+
export function biasHintEnabled(kind) {
|
|
240
|
+
return kind === 'anchoring'
|
|
241
|
+
? process.env.HIPPO_ANCHORING !== 'off'
|
|
242
|
+
: process.env.HIPPO_AVAILABILITY !== 'off';
|
|
243
|
+
}
|
|
235
244
|
//# sourceMappingURL=recall-history.js.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type GoalRecallLogRow } from './goals.js';
|
|
2
|
+
import { type MemoryEntry } from './memory.js';
|
|
3
|
+
import type { PhysicsConfig } from './physics-config.js';
|
|
4
|
+
import type { RerankerFn } from './rerankers/types.js';
|
|
5
|
+
import { type ResultCost, type SearchResult } from './search.js';
|
|
6
|
+
/** Stores rankRecall reads and where it sends operator notes. */
|
|
7
|
+
export interface RankRecallCtx {
|
|
8
|
+
hippoRoot: string;
|
|
9
|
+
/** A second store searched beside `hippoRoot`; leave undefined when there is none. */
|
|
10
|
+
globalRoot?: string;
|
|
11
|
+
tenantId: string;
|
|
12
|
+
/** Receives each operator note when the pipeline reaches it, so it interleaves with other stderr in order. */
|
|
13
|
+
note?: (line: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/** Search-engine choice and tuning. */
|
|
16
|
+
export interface RecallSearchOpts {
|
|
17
|
+
usePhysics: boolean;
|
|
18
|
+
physicsConfig: PhysicsConfig;
|
|
19
|
+
multihop: boolean;
|
|
20
|
+
/** Graph-stream rrf fusion over the local store; hop and seed counts fall back to the engine defaults. */
|
|
21
|
+
graphStream?: RecallGraphStream;
|
|
22
|
+
mmr: boolean;
|
|
23
|
+
mmrLambda: number;
|
|
24
|
+
localBump: number;
|
|
25
|
+
minResults?: number;
|
|
26
|
+
/** Score breakdowns for `hippo explain`; explain's physics call also leaves out the bi-temporal flags. */
|
|
27
|
+
explain: boolean;
|
|
28
|
+
}
|
|
29
|
+
/** `--graph-hops` and `--graph-seeds`. */
|
|
30
|
+
export interface RecallGraphStream {
|
|
31
|
+
hops?: number;
|
|
32
|
+
seeds?: number;
|
|
33
|
+
}
|
|
34
|
+
/** `--hops` and `--max-neighbors`. */
|
|
35
|
+
export interface RecallGraphHops {
|
|
36
|
+
hops: number;
|
|
37
|
+
maxNeighbors: number;
|
|
38
|
+
}
|
|
39
|
+
/** A reranker from the registry and how many head rows it sees. */
|
|
40
|
+
export interface RecallReranker {
|
|
41
|
+
fn: RerankerFn;
|
|
42
|
+
topK: number;
|
|
43
|
+
}
|
|
44
|
+
/** A stage rankRecall can stop before, in pipeline order. */
|
|
45
|
+
export type RankStage = 'expand' | 'rerank' | 'salience' | 'outcome' | 'layer';
|
|
46
|
+
/** Everything that shapes one ranking, already parsed and validated. */
|
|
47
|
+
export interface RankRecallOpts {
|
|
48
|
+
query: string;
|
|
49
|
+
/** Tokens the engines may spend, priced by `cost`. */
|
|
50
|
+
budget: number;
|
|
51
|
+
/** Price of one result, usually the tokens its printed line takes. */
|
|
52
|
+
cost: ResultCost;
|
|
53
|
+
limit: number;
|
|
54
|
+
/** Record a rerank trace step for each score change. */
|
|
55
|
+
why?: boolean;
|
|
56
|
+
includeSuperseded: boolean;
|
|
57
|
+
asOf?: string;
|
|
58
|
+
/** `--scope`: unlocks that envelope scope on top of the default-admitted set. */
|
|
59
|
+
explicitScope: string | null;
|
|
60
|
+
/** Scope used for boosting only, never for filtering. */
|
|
61
|
+
activeScope: string | null;
|
|
62
|
+
search: RecallSearchOpts;
|
|
63
|
+
graphHops?: RecallGraphHops;
|
|
64
|
+
evcAdaptive?: boolean;
|
|
65
|
+
filterConflicts?: boolean;
|
|
66
|
+
valueAware?: boolean;
|
|
67
|
+
rerankUtility?: boolean;
|
|
68
|
+
reranker?: RecallReranker;
|
|
69
|
+
/** Explicit goal tag; when set, the session goal stack is skipped. */
|
|
70
|
+
goalTag?: string;
|
|
71
|
+
/** Session whose active goals boost matching rows. */
|
|
72
|
+
sessionId?: string;
|
|
73
|
+
salienceThreshold?: number;
|
|
74
|
+
outcome?: string;
|
|
75
|
+
layer?: string;
|
|
76
|
+
/** The caller rejected a flag this stage reads; ranking stops before it so the notes and goal-log rows
|
|
77
|
+
* produced up to there match the CLI's error path. */
|
|
78
|
+
haltBefore?: RankStage;
|
|
79
|
+
}
|
|
80
|
+
/** Ranked results and what the caller needs to report and persist them. */
|
|
81
|
+
export interface RankRecallResult {
|
|
82
|
+
results: SearchResult[];
|
|
83
|
+
/** The candidate pools after the scope and bi-temporal filters. */
|
|
84
|
+
localEntries: MemoryEntry[];
|
|
85
|
+
globalEntries: MemoryEntry[];
|
|
86
|
+
/** Candidates loaded, before any filter. */
|
|
87
|
+
totalCandidates: number;
|
|
88
|
+
/** Rows dropped by a named filter (scope, bi-temporal, conflicts, outcome, layer). */
|
|
89
|
+
droppedPreRank: number;
|
|
90
|
+
/** Rows graph expansion surfaced that the lexical pool never held. */
|
|
91
|
+
graphAdded: number;
|
|
92
|
+
/** goal_recall_log rows the session goal boost earned; the caller writes them. */
|
|
93
|
+
goalRecallLog: GoalRecallLogRow[];
|
|
94
|
+
/** True when ranking stopped at `haltBefore`. */
|
|
95
|
+
halted: boolean;
|
|
96
|
+
}
|
|
97
|
+
/** Ranks memories for a query as `hippo recall` does, through the `limit` slice. Reads stores, embeddings and
|
|
98
|
+
* physics state; writes nothing and prints nothing (notes go to `ctx.note`). The caller supplies the cost
|
|
99
|
+
* function and reranker in `opts`, and persists the returned goal-log rows. */
|
|
100
|
+
export declare function rankRecall(ctx: RankRecallCtx, opts: RankRecallOpts): Promise<RankRecallResult>;
|
|
101
|
+
//# sourceMappingURL=recall-pipeline.d.ts.map
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Ranking core shared by `hippo recall` and `hippo explain`: load, search, expand, re-rank and filter, with no
|
|
2
|
+
// writes and no direct output. Callers own flag parsing, printing, budget fitting and persistence.
|
|
3
|
+
import { evalNow } from './ablation.js';
|
|
4
|
+
import { oneCopyPerMemory } from './api.js';
|
|
5
|
+
import { compareEntryIdentity } from './compare.js';
|
|
6
|
+
import { closeHippoDb, openHippoDb } from './db.js';
|
|
7
|
+
import { isEmbeddingAvailable } from './embeddings.js';
|
|
8
|
+
import { computeGoalStackBoost } from './goals.js';
|
|
9
|
+
import { graphExpandRecall } from './graph-recall.js';
|
|
10
|
+
import { DEFAULT_GRAPH_STREAM_WEIGHT } from './graph-stream.js';
|
|
11
|
+
import { Layer } from './memory.js';
|
|
12
|
+
import { multihopSearch } from './multihop.js';
|
|
13
|
+
import { passesCliRecallScopeFilter } from './recall-scope.js';
|
|
14
|
+
import { hybridSearch, physicsSearch, textOverlap } from './search.js';
|
|
15
|
+
import { searchBothHybrid } from './shared.js';
|
|
16
|
+
import { loadRecallSearchEntries } from './store.js';
|
|
17
|
+
import { tokenize as tokenizeQuery } from './tokenize.js';
|
|
18
|
+
/** Ranks memories for a query as `hippo recall` does, through the `limit` slice. Reads stores, embeddings and
|
|
19
|
+
* physics state; writes nothing and prints nothing (notes go to `ctx.note`). The caller supplies the cost
|
|
20
|
+
* function and reranker in `opts`, and persists the returned goal-log rows. */
|
|
21
|
+
export async function rankRecall(ctx, opts) {
|
|
22
|
+
const pool = loadRecallPool(ctx, opts);
|
|
23
|
+
const state = { results: [], droppedPreRank: pool.dropped, graphAdded: 0, goalRecallLog: [] };
|
|
24
|
+
const done = (halted) => ({
|
|
25
|
+
results: state.results,
|
|
26
|
+
localEntries: pool.local,
|
|
27
|
+
globalEntries: pool.global,
|
|
28
|
+
totalCandidates: pool.total,
|
|
29
|
+
droppedPreRank: state.droppedPreRank,
|
|
30
|
+
graphAdded: state.graphAdded,
|
|
31
|
+
goalRecallLog: state.goalRecallLog,
|
|
32
|
+
halted,
|
|
33
|
+
});
|
|
34
|
+
state.results = await searchPool(ctx, opts, pool);
|
|
35
|
+
if (opts.haltBefore === 'expand')
|
|
36
|
+
return done(true);
|
|
37
|
+
if (opts.graphHops)
|
|
38
|
+
expandGraph(ctx, opts, opts.graphHops, state);
|
|
39
|
+
applyPfcRerankers(opts, state);
|
|
40
|
+
if (opts.haltBefore === 'rerank')
|
|
41
|
+
return done(true);
|
|
42
|
+
if (opts.reranker)
|
|
43
|
+
state.results = await applyReranker(opts, opts.reranker, state.results);
|
|
44
|
+
applyGoalBoosts(ctx, opts, state);
|
|
45
|
+
if (opts.haltBefore === 'salience')
|
|
46
|
+
return done(true);
|
|
47
|
+
if (opts.salienceThreshold !== undefined)
|
|
48
|
+
state.results = applySalience(opts, opts.salienceThreshold, state.results);
|
|
49
|
+
if (opts.haltBefore === 'outcome')
|
|
50
|
+
return done(true);
|
|
51
|
+
if (opts.outcome)
|
|
52
|
+
dropUnless(state, (r) => r.entry.layer !== Layer.Trace || r.entry.trace_outcome === opts.outcome);
|
|
53
|
+
if (opts.haltBefore === 'layer')
|
|
54
|
+
return done(true);
|
|
55
|
+
if (opts.layer)
|
|
56
|
+
dropUnless(state, (r) => r.entry.layer === opts.layer);
|
|
57
|
+
if (opts.limit < state.results.length)
|
|
58
|
+
state.results = state.results.slice(0, opts.limit);
|
|
59
|
+
return done(false);
|
|
60
|
+
}
|
|
61
|
+
function loadRecallPool(ctx, opts) {
|
|
62
|
+
// An explicit --scope unlocks that envelope scope; the regex-only `<source>:private:*` deny is the JS half below.
|
|
63
|
+
const requested = opts.explicitScope || undefined;
|
|
64
|
+
const loadSuperseded = opts.includeSuperseded || Boolean(opts.asOf);
|
|
65
|
+
let local = loadRecallSearchEntries(ctx.hippoRoot, opts.query, undefined, ctx.tenantId, requested, 'additive', loadSuperseded);
|
|
66
|
+
let global = ctx.globalRoot
|
|
67
|
+
? loadRecallSearchEntries(ctx.globalRoot, opts.query, undefined, ctx.tenantId, requested, 'additive', loadSuperseded)
|
|
68
|
+
: [];
|
|
69
|
+
// SQL-excluded rows are pre-candidate, so the total is taken before the JS filters, which normally drop nothing.
|
|
70
|
+
const total = local.length + global.length;
|
|
71
|
+
const passes = (e) => passesCliRecallScopeFilter(e.scope ?? null, requested);
|
|
72
|
+
local = local.filter(passes);
|
|
73
|
+
global = global.filter(passes);
|
|
74
|
+
if (opts.asOf) {
|
|
75
|
+
local = currentAsOf(local, opts.asOf);
|
|
76
|
+
global = currentAsOf(global, opts.asOf);
|
|
77
|
+
}
|
|
78
|
+
else if (!opts.includeSuperseded) {
|
|
79
|
+
local = local.filter((e) => !e.superseded_by);
|
|
80
|
+
global = global.filter((e) => !e.superseded_by);
|
|
81
|
+
}
|
|
82
|
+
return { local, global, total, dropped: total - (local.length + global.length) };
|
|
83
|
+
}
|
|
84
|
+
/** Rows that were true at `asOf`: valid by then, and not yet replaced by a successor in the same pool. */
|
|
85
|
+
function currentAsOf(entries, asOf) {
|
|
86
|
+
const asOfDate = new Date(asOf);
|
|
87
|
+
const successorValidFrom = new Map();
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
if (e.superseded_by) {
|
|
90
|
+
const successor = entries.find(s => s.id === e.superseded_by);
|
|
91
|
+
if (successor)
|
|
92
|
+
successorValidFrom.set(e.id, successor.valid_from);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return entries.filter(e => {
|
|
96
|
+
if (new Date(e.valid_from) > asOfDate)
|
|
97
|
+
return false;
|
|
98
|
+
if (!e.superseded_by)
|
|
99
|
+
return true;
|
|
100
|
+
const succVf = successorValidFrom.get(e.id);
|
|
101
|
+
return succVf ? new Date(succVf) > asOfDate : true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
async function searchPool(ctx, opts, pool) {
|
|
105
|
+
const { query, budget, cost, includeSuperseded, asOf, activeScope: scope, search } = opts;
|
|
106
|
+
const { mmr, mmrLambda, minResults, explain } = search;
|
|
107
|
+
const globalRoot = pool.global.length > 0 ? ctx.globalRoot : undefined;
|
|
108
|
+
if (search.graphStream) {
|
|
109
|
+
// Without embeddings hybridSearch falls back to BM25-only and the stream is inert; say so rather than no-op.
|
|
110
|
+
if (!isEmbeddingAvailable()) {
|
|
111
|
+
ctx.note?.('[note] --graph-stream needs embeddings (rrf fusion); none available, so the graph stream is inert. Run `hippo embed` first.');
|
|
112
|
+
}
|
|
113
|
+
if (globalRoot)
|
|
114
|
+
ctx.note?.('[note] --graph-stream searches the local store only; global graph fusion is a follow-up.');
|
|
115
|
+
// With seedCount or fewer candidates every one is a seed and the stream degrades to the 2-list fusion.
|
|
116
|
+
return hybridSearch(query, pool.local, {
|
|
117
|
+
budget, cost, hippoRoot: ctx.hippoRoot, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf,
|
|
118
|
+
scoring: 'rrf',
|
|
119
|
+
graphStream: { weight: DEFAULT_GRAPH_STREAM_WEIGHT, tenantId: ctx.tenantId, hops: search.graphStream.hops, seedCount: search.graphStream.seeds },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (search.multihop) {
|
|
123
|
+
// Unlike searchBothHybrid below, multihop ranks one pooled list, so a shared memory's two copies both compete.
|
|
124
|
+
const allEntries = oneCopyPerMemory(pool.local, pool.global, evalNow()).flat();
|
|
125
|
+
return multihopSearch(query, allEntries, { budget, cost, hippoRoot: ctx.hippoRoot, minResults, includeSuperseded, asOf });
|
|
126
|
+
}
|
|
127
|
+
if (search.usePhysics && !globalRoot) {
|
|
128
|
+
// Explain has never passed the bi-temporal flags to physics; keeping that holds its output steady.
|
|
129
|
+
const temporal = explain ? {} : { minResults, includeSuperseded, asOf };
|
|
130
|
+
return physicsSearch(query, pool.local, { budget, cost, hippoRoot: ctx.hippoRoot, physicsConfig: search.physicsConfig, scope, explain, ...temporal });
|
|
131
|
+
}
|
|
132
|
+
if (globalRoot) {
|
|
133
|
+
// searchBothHybrid reloads candidates itself, so the scope rule is passed in rather than inherited from the pool.
|
|
134
|
+
return searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
|
|
135
|
+
budget, cost, explain, mmr, mmrLambda, localBump: search.localBump, minResults, scope, tenantId: ctx.tenantId,
|
|
136
|
+
includeSuperseded, asOf,
|
|
137
|
+
recallScope: opts.explicitScope ? { requested: opts.explicitScope, additive: true } : {},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return hybridSearch(query, pool.local, { budget, cost, hippoRoot: ctx.hippoRoot, explain, mmr, mmrLambda, minResults, scope, includeSuperseded, asOf });
|
|
141
|
+
}
|
|
142
|
+
/** Adds memories reached by walking the entity graph out from the lexical seeds. */
|
|
143
|
+
function expandGraph(ctx, opts, graph, state) {
|
|
144
|
+
if (graph.hops <= 0)
|
|
145
|
+
return;
|
|
146
|
+
// Expansion both adds neighbours and evicts weak base rows, so compare id sets: a net count would hide both.
|
|
147
|
+
const beforeGraphIds = new Set(state.results.map((r) => r.entry.id));
|
|
148
|
+
state.results = graphExpandRecall(state.results, {
|
|
149
|
+
hops: graph.hops,
|
|
150
|
+
maxNeighbors: graph.maxNeighbors,
|
|
151
|
+
hippoRoot: ctx.hippoRoot,
|
|
152
|
+
globalRoot: ctx.globalRoot !== ctx.hippoRoot ? ctx.globalRoot : undefined,
|
|
153
|
+
tenantId: ctx.tenantId,
|
|
154
|
+
includeSuperseded: opts.includeSuperseded,
|
|
155
|
+
asOf: opts.asOf,
|
|
156
|
+
budget: opts.budget,
|
|
157
|
+
cost: opts.cost,
|
|
158
|
+
minResults: opts.search.minResults ?? 1,
|
|
159
|
+
recallScope: opts.explicitScope ? { requested: opts.explicitScope, additive: true } : {},
|
|
160
|
+
});
|
|
161
|
+
for (const r of state.results) {
|
|
162
|
+
if (!beforeGraphIds.has(r.entry.id))
|
|
163
|
+
state.graphAdded++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Appends one rerank step to a re-scored row when `why` is on. */
|
|
167
|
+
function traced(opts, prev, next, step) {
|
|
168
|
+
if (opts.why)
|
|
169
|
+
next.rerankTrace = [...(prev.rerankTrace ?? []), step];
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
/** Re-sort after a score change. Plain and stable on purpose: ties keep the prior rank, not a content order. */
|
|
173
|
+
function byScore(results) {
|
|
174
|
+
return results.sort((a, b) => b.score - a.score);
|
|
175
|
+
}
|
|
176
|
+
function applyPfcRerankers(opts, state) {
|
|
177
|
+
if (opts.evcAdaptive && state.results.length >= 2)
|
|
178
|
+
state.results = evcAdaptive(opts.query, state.results);
|
|
179
|
+
if (opts.filterConflicts) {
|
|
180
|
+
dropUnless(state, (r) => !r.entry.superseded_by);
|
|
181
|
+
// Recorded conflicts only: a lexical-overlap gate once wrecked benchmark recall. Down-rank, never delete.
|
|
182
|
+
const presentIds = new Set(state.results.map((r) => r.entry.id));
|
|
183
|
+
state.results = byScore(state.results.map((r) => {
|
|
184
|
+
const hasPeerInResults = (r.entry.conflicts_with || []).some((peerId) => presentIds.has(peerId));
|
|
185
|
+
if (!hasPeerInResults)
|
|
186
|
+
return r;
|
|
187
|
+
const next = { ...r, score: r.score * 0.3 };
|
|
188
|
+
return traced(opts, r, next, { stage: 'interference', multiplier: 0.3, scoreBefore: r.score, scoreAfter: next.score });
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
if (opts.valueAware && state.results.length >= 1) {
|
|
192
|
+
// Wider clamp than the always-on outcome boost, so outcome history can decide the order.
|
|
193
|
+
state.results = byScore(state.results.map((r) => {
|
|
194
|
+
const pos = r.entry.outcome_positive ?? 0;
|
|
195
|
+
const neg = r.entry.outcome_negative ?? 0;
|
|
196
|
+
if (pos === 0 && neg === 0)
|
|
197
|
+
return r;
|
|
198
|
+
const valueMult = Math.max(0.7, Math.min(1.3, 1 + 0.3 * Math.tanh(pos - neg)));
|
|
199
|
+
const next = { ...r, score: r.score * valueMult };
|
|
200
|
+
return traced(opts, r, next, { stage: 'value', multiplier: valueMult, scoreBefore: r.score, scoreAfter: next.score });
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
if (opts.rerankUtility) {
|
|
204
|
+
// utility = score * (0.5 + 0.5 * strength) * (1 - min(0.3, tokens / 10000)); long evidence-rich rows pay for length.
|
|
205
|
+
state.results = byScore(state.results.map((r) => {
|
|
206
|
+
const strength = typeof r.entry.strength === 'number' ? r.entry.strength : 1.0;
|
|
207
|
+
const utilityMult = (0.5 + 0.5 * strength) * (1 - Math.min(0.3, (r.tokens || 0) / 10000));
|
|
208
|
+
const utility = r.score * utilityMult;
|
|
209
|
+
return traced(opts, r, { ...r, score: utility }, { stage: 'utility', multiplier: utilityMult, scoreBefore: r.score, scoreAfter: utility });
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** When the top hits are near-duplicates (same topic, different facts), surface the newest on-topic row first. */
|
|
214
|
+
function evcAdaptive(query, results) {
|
|
215
|
+
const slice = results.slice(0, Math.min(3, results.length));
|
|
216
|
+
let pairs = 0;
|
|
217
|
+
let overlapSum = 0;
|
|
218
|
+
for (let i = 0; i < slice.length; i++) {
|
|
219
|
+
for (let j = i + 1; j < slice.length; j++) {
|
|
220
|
+
overlapSum += textOverlap(slice[i].entry.content, slice[j].entry.content);
|
|
221
|
+
pairs++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if ((pairs > 0 ? overlapSum / pairs : 0) < 0.4)
|
|
225
|
+
return results;
|
|
226
|
+
const poolSize = Math.min(results.length, Math.max(slice.length * 3, 9));
|
|
227
|
+
const pool = results.slice(0, poolSize);
|
|
228
|
+
const scoreFloor = pool.reduce((m, r) => Math.max(m, r.score), 0) * 0.5;
|
|
229
|
+
// Query coverage catches the differently phrased update a score floor alone would miss.
|
|
230
|
+
const queryTokens = new Set(tokenizeQuery(query));
|
|
231
|
+
const onTopic = [];
|
|
232
|
+
const offTopic = [];
|
|
233
|
+
for (const r of pool) {
|
|
234
|
+
let hits = 0;
|
|
235
|
+
if (queryTokens.size > 0) {
|
|
236
|
+
const candTokens = new Set(tokenizeQuery(r.entry.content));
|
|
237
|
+
for (const t of queryTokens)
|
|
238
|
+
if (candTokens.has(t))
|
|
239
|
+
hits++;
|
|
240
|
+
}
|
|
241
|
+
const queryCoverage = queryTokens.size > 0 ? hits / queryTokens.size : 0;
|
|
242
|
+
(r.score >= scoreFloor || queryCoverage >= 0.6 ? onTopic : offTopic).push(r);
|
|
243
|
+
}
|
|
244
|
+
// Recency is the primary key; identity only breaks exact-timestamp ties.
|
|
245
|
+
onTopic.sort((a, b) => {
|
|
246
|
+
const ta = new Date(a.entry.created).getTime();
|
|
247
|
+
const tb = new Date(b.entry.created).getTime();
|
|
248
|
+
return tb !== ta ? tb - ta : compareEntryIdentity(a.entry, b.entry);
|
|
249
|
+
});
|
|
250
|
+
return [...onTopic, ...offTopic, ...results.slice(poolSize)];
|
|
251
|
+
}
|
|
252
|
+
async function applyReranker(opts, reranker, results) {
|
|
253
|
+
const { fn, topK } = reranker;
|
|
254
|
+
const rerankInput = results.slice(0, topK).map((r, i) => ({ ...r, preRerankRank: i + 1 }));
|
|
255
|
+
const reranked = await fn(opts.query, rerankInput, { topK });
|
|
256
|
+
// The reranker's score becomes `score` so later stages that sort by score keep its order.
|
|
257
|
+
const withPostRank = reranked.map((r, i) => traced(opts, r, { ...r, score: r.rerankScore, postRerankRank: i + 1 }, { stage: 'reranker', scoreBefore: r.score, scoreAfter: r.rerankScore }));
|
|
258
|
+
return [...withPostRank, ...results.slice(topK)];
|
|
259
|
+
}
|
|
260
|
+
function applyGoalBoosts(ctx, opts, state) {
|
|
261
|
+
const goalTag = opts.goalTag ?? '';
|
|
262
|
+
if (goalTag) {
|
|
263
|
+
// Its own trace stage: `goal` is the explicit flag, `goal-boost` the session stack it replaces.
|
|
264
|
+
state.results = byScore(state.results.map((r) => {
|
|
265
|
+
if (!r.entry.tags?.includes(goalTag))
|
|
266
|
+
return r;
|
|
267
|
+
const boosted = { ...r, score: r.score * 1.5 };
|
|
268
|
+
return traced(opts, r, boosted, { stage: 'goal', multiplier: 1.5, scoreBefore: r.score, scoreAfter: r.score * 1.5, note: `--goal ${goalTag}` });
|
|
269
|
+
}));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!opts.sessionId)
|
|
273
|
+
return;
|
|
274
|
+
const db = openHippoDb(ctx.hippoRoot);
|
|
275
|
+
// The helper re-spreads rows, so its steps come back in a map keyed by entry id.
|
|
276
|
+
const goalBoostTrace = opts.why ? new Map() : undefined;
|
|
277
|
+
try {
|
|
278
|
+
const boost = computeGoalStackBoost(db, state.results, {
|
|
279
|
+
sessionId: opts.sessionId,
|
|
280
|
+
tenantId: ctx.tenantId,
|
|
281
|
+
limit: opts.limit,
|
|
282
|
+
trace: goalBoostTrace,
|
|
283
|
+
});
|
|
284
|
+
state.results = boost.results;
|
|
285
|
+
state.goalRecallLog = boost.log;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
closeHippoDb(db);
|
|
289
|
+
}
|
|
290
|
+
if (goalBoostTrace && goalBoostTrace.size > 0) {
|
|
291
|
+
state.results = state.results.map((r) => {
|
|
292
|
+
const step = goalBoostTrace.get(r.entry.id);
|
|
293
|
+
return step ? { ...r, rerankTrace: [...(r.rerankTrace ?? []), step] } : r;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/** Soft-demotes rarely recalled rows: score *= max(0.5, retrieval_count / T); never drops one. */
|
|
298
|
+
function applySalience(opts, threshold, results) {
|
|
299
|
+
return byScore(results.map((r) => {
|
|
300
|
+
const count = r.entry.retrieval_count ?? 0;
|
|
301
|
+
if (count >= threshold)
|
|
302
|
+
return r;
|
|
303
|
+
const mult = Math.max(0.5, count / threshold);
|
|
304
|
+
const next = { ...r, score: r.score * mult };
|
|
305
|
+
return traced(opts, r, next, { stage: 'retrieval-count-downweight', multiplier: mult, scoreBefore: r.score, scoreAfter: next.score });
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
function dropUnless(state, keep) {
|
|
309
|
+
const before = state.results.length;
|
|
310
|
+
state.results = state.results.filter(keep);
|
|
311
|
+
state.droppedPreRank += before - state.results.length;
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=recall-pipeline.js.map
|