hippo-memory 1.45.0 → 1.47.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 +58 -15
- package/bin/hippo.js +0 -0
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +66 -1
- package/dist/api.js +202 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +26 -0
- package/dist/capture-error.js +110 -0
- package/dist/capture.d.ts +25 -8
- package/dist/capture.js +100 -5
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +432 -48
- package/dist/config.d.ts +20 -0
- package/dist/config.js +35 -0
- package/dist/consolidate.d.ts +6 -0
- package/dist/consolidate.js +98 -13
- package/dist/db.js +81 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +183 -0
- package/dist/dormant.d.ts +91 -0
- package/dist/dormant.js +121 -0
- package/dist/eval-stats.d.ts +123 -0
- package/dist/eval-stats.js +187 -0
- package/dist/failure-log.d.ts +49 -0
- package/dist/failure-log.js +58 -0
- package/dist/half-life-migration.d.ts +55 -0
- package/dist/half-life-migration.js +111 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +47 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +70 -13
- package/dist/memory.d.ts +16 -2
- package/dist/memory.js +27 -5
- package/dist/physics-config.js +5 -1
- package/dist/recall-scope.d.ts +24 -0
- package/dist/recall-scope.js +41 -0
- package/dist/reject-flow.d.ts +3 -3
- package/dist/reject-flow.js +10 -3
- package/dist/search.d.ts +4 -4
- package/dist/search.js +23 -18
- package/dist/server.js +11 -1
- package/dist/store.d.ts +12 -1
- package/dist/store.js +58 -12
- package/dist/token-ledger.d.ts +119 -0
- package/dist/token-ledger.js +181 -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/dist/reject-flow.d.ts
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* SAME multi-step transaction + post-commit mirror-purge flow. Extracted
|
|
8
8
|
* here (leaf module) so neither duplicates it.
|
|
9
9
|
*
|
|
10
|
-
* Module direction: this file imports from store.ts, rejection.ts,
|
|
11
|
-
* raw-archive.ts. Nothing imports FROM this file except
|
|
12
|
-
* so it introduces no cycle.
|
|
10
|
+
* Module direction: this file imports from store.ts, rejection.ts,
|
|
11
|
+
* raw-archive.ts and dormant.ts. Nothing imports FROM this file except
|
|
12
|
+
* cli.ts and api.ts, so it introduces no cycle.
|
|
13
13
|
*/
|
|
14
14
|
import { type RejectedValueRow } from './rejection.js';
|
|
15
15
|
export interface RejectFlowOpts {
|
package/dist/reject-flow.js
CHANGED
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* SAME multi-step transaction + post-commit mirror-purge flow. Extracted
|
|
8
8
|
* here (leaf module) so neither duplicates it.
|
|
9
9
|
*
|
|
10
|
-
* Module direction: this file imports from store.ts, rejection.ts,
|
|
11
|
-
* raw-archive.ts. Nothing imports FROM this file except
|
|
12
|
-
* so it introduces no cycle.
|
|
10
|
+
* Module direction: this file imports from store.ts, rejection.ts,
|
|
11
|
+
* raw-archive.ts and dormant.ts. Nothing imports FROM this file except
|
|
12
|
+
* cli.ts and api.ts, so it introduces no cycle.
|
|
13
13
|
*/
|
|
14
14
|
import { openHippoDb, closeHippoDb } from './db.js';
|
|
15
15
|
import { appendAuditEvent } from './audit.js';
|
|
16
16
|
import { archiveRawMemory } from './raw-archive.js';
|
|
17
|
+
import { purgeDormantByDigest } from './dormant.js';
|
|
17
18
|
import { initStore, deleteEntryCore, purgeMirrorBestEffort, writeIndexMirror, buildIndexFromDb, } from './store.js';
|
|
18
19
|
import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
|
|
19
20
|
/**
|
|
@@ -102,6 +103,12 @@ export function rejectValue(opts) {
|
|
|
102
103
|
}
|
|
103
104
|
removedIds.push(row.id);
|
|
104
105
|
}
|
|
106
|
+
// Dormant copies (src/dormant.ts) go too, in the same transaction: a
|
|
107
|
+
// rejected value may not linger where `hippo dormant restore` could
|
|
108
|
+
// bring it back. They have no markdown mirror, so the post-commit
|
|
109
|
+
// mirror purge below is a no-op for them; they join removedIds for the
|
|
110
|
+
// audit trail and the caller's report.
|
|
111
|
+
removedIds.push(...purgeDormantByDigest(db, opts.tenantId, digest));
|
|
105
112
|
try {
|
|
106
113
|
appendAuditEvent(db, {
|
|
107
114
|
tenantId: opts.tenantId,
|
package/dist/search.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* BM25 search + optional embedding hybrid search for Hippo.
|
|
3
3
|
* Zero external dependencies when embeddings are not available.
|
|
4
4
|
*/
|
|
5
|
+
import { estimateTokens } from './token-ledger.js';
|
|
5
6
|
import { MemoryEntry } from './memory.js';
|
|
6
7
|
import type { PhysicsConfig } from './physics-config.js';
|
|
7
8
|
export declare function tokenize(text: string): string[];
|
|
@@ -17,10 +18,9 @@ export interface BM25Corpus {
|
|
|
17
18
|
N: number;
|
|
18
19
|
}
|
|
19
20
|
export declare function buildCorpus(texts: string[]): BM25Corpus;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export declare function estimateTokens(text: string): number;
|
|
21
|
+
export { estimateTokens };
|
|
22
|
+
/** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
|
|
23
|
+
export declare function outcomeMultiplier(entry: MemoryEntry): number;
|
|
24
24
|
export declare function detectTemporalDirection(query: string): 'recent' | 'oldest' | null;
|
|
25
25
|
export interface TemporalRange {
|
|
26
26
|
minTime: number;
|
package/dist/search.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* BM25 search + optional embedding hybrid search for Hippo.
|
|
3
3
|
* Zero external dependencies when embeddings are not available.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { estimateTokens } from './token-ledger.js';
|
|
6
|
+
import { calculateStrength, netWrong } from './memory.js';
|
|
7
|
+
import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
|
|
7
8
|
import { extractPathTags, pathBoostMultiplier } from './path-context.js';
|
|
8
9
|
import { detectScope, scopeMatch } from './scope.js';
|
|
9
10
|
import { cosineSimilarity, embeddingModelRequiresReindex, loadEmbeddingIndex, } from './embeddings.js';
|
|
@@ -68,20 +69,28 @@ function bm25Score(corpus, docIdx, queryTerms) {
|
|
|
68
69
|
// ---------------------------------------------------------------------------
|
|
69
70
|
// Token budget estimation
|
|
70
71
|
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
export function estimateTokens(text) {
|
|
75
|
-
return Math.ceil(text.length / 4);
|
|
76
|
-
}
|
|
72
|
+
// Rough token estimate (characters / 4). Defined once in token-ledger.ts and
|
|
73
|
+
// re-exported here, where callers have always imported it from.
|
|
74
|
+
export { estimateTokens };
|
|
77
75
|
// ---------------------------------------------------------------------------
|
|
78
76
|
// Recency boost
|
|
79
77
|
// ---------------------------------------------------------------------------
|
|
80
78
|
function recencyBoost(entry, now) {
|
|
79
|
+
if (isRecencyAblated())
|
|
80
|
+
return 1; // EVAL-ONLY ablation (see ablation.ts)
|
|
81
81
|
const created = new Date(entry.created);
|
|
82
82
|
const ageDays = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
83
83
|
// Exponential decay: memories < 1 day get boost ~1.0, older get less
|
|
84
|
-
return Math.exp(-ageDays / 30);
|
|
84
|
+
return Math.exp(-ageDays / (evalRecencyScaleDays() ?? 30));
|
|
85
|
+
}
|
|
86
|
+
/** Retrieval-time outcome nudge in [0.85, 1.15]; the E1 bm25-outcome baseline ranks with it too. */
|
|
87
|
+
export function outcomeMultiplier(entry) {
|
|
88
|
+
const pos = entry.outcome_positive ?? 0;
|
|
89
|
+
const neg = entry.outcome_negative ?? 0;
|
|
90
|
+
// EVAL-ONLY ablation (see ablation.ts): the fast outcome channel.
|
|
91
|
+
if (isOutcomeFastAblated() || (pos === 0 && neg === 0))
|
|
92
|
+
return 1.0;
|
|
93
|
+
return Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
|
|
85
94
|
}
|
|
86
95
|
// ---------------------------------------------------------------------------
|
|
87
96
|
// Temporal-aware scoring
|
|
@@ -364,12 +373,7 @@ export async function hybridSearch(query, entries, options = {}) {
|
|
|
364
373
|
compositeScore *= pathBoost;
|
|
365
374
|
// Retrieval-time outcome personalization: nudge up/down from user feedback.
|
|
366
375
|
// Distinct from reward-factor-via-strength (slow); this is immediate.
|
|
367
|
-
|
|
368
|
-
const pos = entries[i].outcome_positive ?? 0;
|
|
369
|
-
const neg = entries[i].outcome_negative ?? 0;
|
|
370
|
-
const outcomeBoost = isOutcomeFastAblated() || (pos === 0 && neg === 0)
|
|
371
|
-
? 1.0
|
|
372
|
-
: Math.max(0.85, Math.min(1.15, 1 + 0.15 * Math.tanh((pos - neg) / 2)));
|
|
376
|
+
const outcomeBoost = outcomeMultiplier(entries[i]);
|
|
373
377
|
compositeScore *= outcomeBoost;
|
|
374
378
|
// Scope boost: memories tagged with the active scope get 1.5x; mismatching scopes get 0.5x
|
|
375
379
|
const scopeSignal = scopeMatch(entries[i].tags, activeScope);
|
|
@@ -929,12 +933,13 @@ export function markRetrieved(entries, now = evalNow()) {
|
|
|
929
933
|
return entries.map((e) => {
|
|
930
934
|
if (e.superseded_by)
|
|
931
935
|
return e;
|
|
936
|
+
const wrong = netWrong(e) > 0;
|
|
932
937
|
const updated = {
|
|
933
938
|
...e,
|
|
934
939
|
retrieval_count: e.retrieval_count + 1,
|
|
935
|
-
last_retrieved: now.toISOString(),
|
|
936
|
-
//
|
|
937
|
-
half_life_days: e.half_life_days + 2,
|
|
940
|
+
last_retrieved: wrong ? e.last_retrieved : now.toISOString(),
|
|
941
|
+
// +2 days half-life per retrieval (PLAN.md); a wrong memory keeps both, since last_retrieved is the decay anchor
|
|
942
|
+
half_life_days: wrong ? e.half_life_days : e.half_life_days + 2,
|
|
938
943
|
};
|
|
939
944
|
updated.strength = calculateStrength(updated, now);
|
|
940
945
|
return updated;
|
package/dist/server.js
CHANGED
|
@@ -22,7 +22,7 @@ export function __resetSessionRecallHistoryHttp() {
|
|
|
22
22
|
import { PACKAGE_VERSION } from './version.js';
|
|
23
23
|
import { validateApiKey } from './auth.js';
|
|
24
24
|
import { createRateLimiter } from './rate-limit.js';
|
|
25
|
-
import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, } from './api.js';
|
|
25
|
+
import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, } from './api.js';
|
|
26
26
|
import { buildGraphModel } from './graph-view.js';
|
|
27
27
|
import { MAX_ENTITY_NAME_LEN } from './graph.js';
|
|
28
28
|
import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
|
|
@@ -114,6 +114,8 @@ const VALID_AUDIT_OPS = new Set([
|
|
|
114
114
|
'reject_refusal', // AT1 — emitted when the rejection guard refuses a write; lockstep
|
|
115
115
|
'unreject_value', // AT1 — emitted by `hippo unreject`; lockstep
|
|
116
116
|
'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
|
|
117
|
+
'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
|
|
118
|
+
'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
|
|
117
119
|
]);
|
|
118
120
|
// Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
|
|
119
121
|
// enough to dump a small deployment's full audit log without paginating, but
|
|
@@ -303,6 +305,9 @@ function mapApiError(err) {
|
|
|
303
305
|
if (/already superseded/.test(lower)) {
|
|
304
306
|
return { status: 409, message };
|
|
305
307
|
}
|
|
308
|
+
if (/requires admin role/.test(lower)) {
|
|
309
|
+
return { status: 403, message };
|
|
310
|
+
}
|
|
306
311
|
return { status: 400, message };
|
|
307
312
|
}
|
|
308
313
|
function parseRequest(req) {
|
|
@@ -801,6 +806,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
801
806
|
if (includeContinuity) {
|
|
802
807
|
res.setHeader('Cache-Control', 'no-store');
|
|
803
808
|
}
|
|
809
|
+
recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
|
|
804
810
|
sendJson(res, 200, result);
|
|
805
811
|
return;
|
|
806
812
|
}
|
|
@@ -842,6 +848,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
842
848
|
if (scope !== undefined)
|
|
843
849
|
assembleExtra.scope = scope;
|
|
844
850
|
const result = assemble(ctx, assembleMatch.id, assembleExtra);
|
|
851
|
+
recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
|
|
845
852
|
sendJson(res, 200, result);
|
|
846
853
|
return;
|
|
847
854
|
}
|
|
@@ -1042,6 +1049,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
1042
1049
|
crossProject,
|
|
1043
1050
|
currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
|
|
1044
1051
|
});
|
|
1052
|
+
recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
|
|
1045
1053
|
sendJson(res, 200, result);
|
|
1046
1054
|
return;
|
|
1047
1055
|
}
|
|
@@ -3011,6 +3019,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
|
|
|
3011
3019
|
tenantId: ctx.tenantId,
|
|
3012
3020
|
// v1.12.0: McpContext.actor stays string; extract subject at the boundary.
|
|
3013
3021
|
actor: ctx.actor.subject,
|
|
3022
|
+
// The caller's real role: MCP tools must not run a member key as admin.
|
|
3023
|
+
role: ctx.actor.role,
|
|
3014
3024
|
clientKey: buildMcpClientKey(req),
|
|
3015
3025
|
});
|
|
3016
3026
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { SessionHandoff, HandoffEvidence, HandoffOutcome } from './handoff.js';
|
|
|
10
10
|
import { Card, CardStatus, CardRun, CardComment } from './card.js';
|
|
11
11
|
import { type ResolveProjectIdentityOpts } from './project-identity.js';
|
|
12
12
|
import { RejectedValueError } from './rejection.js';
|
|
13
|
+
import { type DormantMove } from './dormant.js';
|
|
13
14
|
/** A value that round-trips through JSON.stringify/JSON.parse unchanged. */
|
|
14
15
|
type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
15
16
|
[key: string]: JsonValue;
|
|
@@ -108,6 +109,8 @@ export declare function assertNonEmpty<T>(arr: readonly T[], name: string): void
|
|
|
108
109
|
export declare function getHippoRoot(cwd?: string, opts?: ResolveProjectIdentityOpts): string;
|
|
109
110
|
export declare function isInitialized(hippoRoot: string): boolean;
|
|
110
111
|
export declare function initStore(hippoRoot: string): void;
|
|
112
|
+
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
113
|
+
export declare const HALF_LIFE_BASE_META_KEY = "default_half_life_base";
|
|
111
114
|
/**
|
|
112
115
|
* Serialize a MemoryEntry to markdown with YAML frontmatter.
|
|
113
116
|
*/
|
|
@@ -405,9 +408,15 @@ export declare function deleteEntry(hippoRoot: string, id: string, opts?: {
|
|
|
405
408
|
automatic?: boolean;
|
|
406
409
|
}): boolean;
|
|
407
410
|
/** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
|
|
408
|
-
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
411
|
+
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
412
|
+
*
|
|
413
|
+
* `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
|
|
414
|
+
* leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
|
|
415
|
+
* is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
|
|
416
|
+
* (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
|
|
409
417
|
export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEntry[], toDeleteIds: string[], opts?: {
|
|
410
418
|
snapshot?: ReadonlyMap<string, MemoryEntry>;
|
|
419
|
+
dormant?: DormantMove[];
|
|
411
420
|
}): string[];
|
|
412
421
|
/**
|
|
413
422
|
* Load all entries from SQLite.
|
|
@@ -417,6 +426,8 @@ export declare function batchWriteAndDelete(hippoRoot: string, toWrite: MemoryEn
|
|
|
417
426
|
* paths that surface results to a user MUST pass a resolved tenant.
|
|
418
427
|
*/
|
|
419
428
|
export declare function loadAllEntries(hippoRoot: string, tenantId?: string): MemoryEntry[];
|
|
429
|
+
/** Every memory row on an open connection, so a caller can read inside its own transaction. */
|
|
430
|
+
export declare function selectAllEntries(db: DatabaseSyncLike, tenantId?: string): MemoryEntry[];
|
|
420
431
|
export declare function loadAmbientCandidates(hippoRoot: string, tenantId: string, recentNeeded: number, admit: (e: MemoryEntry) => boolean): MemoryEntry[];
|
|
421
432
|
/**
|
|
422
433
|
* Load likely search candidates directly from SQLite.
|
package/dist/store.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'fs';
|
|
8
8
|
import * as path from 'path';
|
|
9
|
-
import { Layer, generateId, AUTO_DELETABLE_SQL } from './memory.js';
|
|
9
|
+
import { Layer, generateId, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
|
|
10
10
|
import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
|
|
11
11
|
import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
|
|
12
12
|
import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
|
|
@@ -23,6 +23,7 @@ import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValu
|
|
|
23
23
|
// inside function bodies (never at module-evaluation time), so the cycle
|
|
24
24
|
// is the standard safe mutual-function-reference shape under NodeNext ESM.
|
|
25
25
|
import { archiveRawMemory } from './raw-archive.js';
|
|
26
|
+
import { insertDormantRow } from './dormant.js';
|
|
26
27
|
/**
|
|
27
28
|
* Emit an audit event for a mutation against `db`. Wrapped so a broken audit
|
|
28
29
|
* log can never crash the surrounding mutation — the SQLite store is still the
|
|
@@ -119,11 +120,27 @@ export function initStore(hippoRoot) {
|
|
|
119
120
|
if (bootstrapped) {
|
|
120
121
|
syncMirrorFiles(hippoRoot, db);
|
|
121
122
|
}
|
|
123
|
+
recordHalfLifeBaseForNewStore(db);
|
|
122
124
|
}
|
|
123
125
|
finally {
|
|
124
126
|
closeHippoDb(db);
|
|
125
127
|
}
|
|
126
128
|
}
|
|
129
|
+
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
130
|
+
export const HALF_LIFE_BASE_META_KEY = 'default_half_life_base';
|
|
131
|
+
/**
|
|
132
|
+
* A store with no memories starts on the current default half-life base, so
|
|
133
|
+
* `hippo sleep` never migrates it. A store that already holds memories and
|
|
134
|
+
* no recorded base predates the record, and keeps reading as the legacy
|
|
135
|
+
* 7-day base until sleep migrates it.
|
|
136
|
+
*/
|
|
137
|
+
function recordHalfLifeBaseForNewStore(db) {
|
|
138
|
+
if (getMeta(db, HALF_LIFE_BASE_META_KEY, '') !== '')
|
|
139
|
+
return;
|
|
140
|
+
if (db.prepare(`SELECT 1 AS x FROM memories LIMIT 1`).get() !== undefined)
|
|
141
|
+
return;
|
|
142
|
+
setMeta(db, HALF_LIFE_BASE_META_KEY, String(DEFAULT_HALF_LIFE_DAYS));
|
|
143
|
+
}
|
|
127
144
|
function ensureMirrorDirectories(hippoRoot) {
|
|
128
145
|
const dirs = [
|
|
129
146
|
hippoRoot,
|
|
@@ -1613,9 +1630,15 @@ function mergeOwnChanges(base, ours, live) {
|
|
|
1613
1630
|
return row;
|
|
1614
1631
|
}
|
|
1615
1632
|
/** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
|
|
1616
|
-
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
1633
|
+
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
1634
|
+
*
|
|
1635
|
+
* `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
|
|
1636
|
+
* leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
|
|
1637
|
+
* is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
|
|
1638
|
+
* (pinned or raw since the caller decided). Returns the ids that left `memories`, deleted or moved. */
|
|
1617
1639
|
export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
1618
|
-
|
|
1640
|
+
const dormantMoves = opts?.dormant ?? [];
|
|
1641
|
+
if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
|
|
1619
1642
|
return [];
|
|
1620
1643
|
initStore(hippoRoot);
|
|
1621
1644
|
const db = openHippoDb(hippoRoot);
|
|
@@ -1722,7 +1745,26 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
|
1722
1745
|
tenantById.set(row.dag_parent_id, row.tenantId);
|
|
1723
1746
|
}
|
|
1724
1747
|
}
|
|
1725
|
-
|
|
1748
|
+
// Dormant moves: same eligibility and DAG bookkeeping as deletes.
|
|
1749
|
+
const movable = [];
|
|
1750
|
+
if (dormantMoves.length > 0) {
|
|
1751
|
+
const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
|
|
1752
|
+
const placeholders = dormantMoves.map(() => '?').join(',');
|
|
1753
|
+
// SAFETY: rows' shape matches the three columns named in the SELECT.
|
|
1754
|
+
const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTO_DELETABLE_SQL}`).all(...byId.keys());
|
|
1755
|
+
for (const row of rows) {
|
|
1756
|
+
movable.push(byId.get(row.id));
|
|
1757
|
+
if (row.dag_parent_id) {
|
|
1758
|
+
dirtyParents.add(row.dag_parent_id);
|
|
1759
|
+
tenantById.set(row.dag_parent_id, row.tenant_id ?? 'default');
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
for (const move of movable) {
|
|
1764
|
+
insertDormantRow(db, move);
|
|
1765
|
+
}
|
|
1766
|
+
const removedIds = [...deletableIds, ...movable.map((m) => m.entry.id)];
|
|
1767
|
+
for (const id of removedIds) {
|
|
1726
1768
|
db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
1727
1769
|
deleteFtsRow(db, id);
|
|
1728
1770
|
}
|
|
@@ -1742,10 +1784,10 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
|
1742
1784
|
for (const entry of written)
|
|
1743
1785
|
writeMarkdownMirror(hippoRoot, entry);
|
|
1744
1786
|
});
|
|
1745
|
-
for (const id of
|
|
1787
|
+
for (const id of removedIds)
|
|
1746
1788
|
purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
|
|
1747
1789
|
writeIndexMirror(hippoRoot, buildIndexFromDb(db));
|
|
1748
|
-
return
|
|
1790
|
+
return removedIds;
|
|
1749
1791
|
}
|
|
1750
1792
|
catch (error) {
|
|
1751
1793
|
try {
|
|
@@ -1769,17 +1811,21 @@ export function loadAllEntries(hippoRoot, tenantId) {
|
|
|
1769
1811
|
initStore(hippoRoot);
|
|
1770
1812
|
const db = openHippoDb(hippoRoot);
|
|
1771
1813
|
try {
|
|
1772
|
-
|
|
1773
|
-
// MemoryRow's field set.
|
|
1774
|
-
const rows = tenantId !== undefined
|
|
1775
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
1776
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1777
|
-
return rows.map(rowToEntry);
|
|
1814
|
+
return selectAllEntries(db, tenantId);
|
|
1778
1815
|
}
|
|
1779
1816
|
finally {
|
|
1780
1817
|
closeHippoDb(db);
|
|
1781
1818
|
}
|
|
1782
1819
|
}
|
|
1820
|
+
/** Every memory row on an open connection, so a caller can read inside its own transaction. */
|
|
1821
|
+
export function selectAllEntries(db, tenantId) {
|
|
1822
|
+
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1823
|
+
// MemoryRow's field set.
|
|
1824
|
+
const rows = tenantId !== undefined
|
|
1825
|
+
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
1826
|
+
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1827
|
+
return rows.map(rowToEntry);
|
|
1828
|
+
}
|
|
1783
1829
|
// The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
|
|
1784
1830
|
// injection. One connection: opening one costs ~5.8ms on a warm 1896-row store,
|
|
1785
1831
|
// so a second handle loses more than the narrower scan saves.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { DatabaseSyncLike } from './db.js';
|
|
2
|
+
/**
|
|
3
|
+
* Where a block of memory text was sent.
|
|
4
|
+
* - `hook`: the per-prompt `UserPromptSubmit` hook (`hippo context --pinned-only`).
|
|
5
|
+
* - `context`, `recall`: the CLI commands.
|
|
6
|
+
* - `mcp_recall`, `mcp_context`: the MCP tools.
|
|
7
|
+
* - `http_recall`, `http_context`, `http_assemble`: the HTTP API.
|
|
8
|
+
*/
|
|
9
|
+
export type TokenSurface = 'hook' | 'context' | 'recall' | 'mcp_recall' | 'mcp_context' | 'http_recall' | 'http_context' | 'http_assemble';
|
|
10
|
+
/** All surfaces, in report order. */
|
|
11
|
+
export declare const TOKEN_SURFACES: readonly TokenSurface[];
|
|
12
|
+
/**
|
|
13
|
+
* What happened to a block.
|
|
14
|
+
* - `inject`: sent to the agent.
|
|
15
|
+
* - `skip`: identical to the session's last injected block, so not sent again.
|
|
16
|
+
* - `reset`: the host compacted its context, so the next block must be sent.
|
|
17
|
+
*/
|
|
18
|
+
export type TokenEvent = 'inject' | 'skip' | 'reset';
|
|
19
|
+
/** Rows older than this are pruned on write. */
|
|
20
|
+
export declare const TOKEN_LEDGER_RETENTION_DAYS = 90;
|
|
21
|
+
/**
|
|
22
|
+
* Rough token estimate: characters / 4. The single estimate behind every
|
|
23
|
+
* token budget and ledger count in hippo.
|
|
24
|
+
*/
|
|
25
|
+
export declare function estimateTokens(text: string): number;
|
|
26
|
+
/** Stable 16-hex-char hash of a rendered block, for change detection. */
|
|
27
|
+
export declare function blockHash(text: string): string;
|
|
28
|
+
/** One ledger write. */
|
|
29
|
+
export interface TokenUse {
|
|
30
|
+
tenantId: string;
|
|
31
|
+
/** Host session id when known (hook payload, `HIPPO_SESSION_ID`); null otherwise. */
|
|
32
|
+
sessionId?: string | null;
|
|
33
|
+
surface: TokenSurface;
|
|
34
|
+
event: TokenEvent;
|
|
35
|
+
/** Memories (and continuity blocks) in the text. */
|
|
36
|
+
items: number;
|
|
37
|
+
/** Estimated tokens of the text; for a `skip`, the tokens not sent. */
|
|
38
|
+
tokens: number;
|
|
39
|
+
/** {@link blockHash} of the text, when the caller wants change detection. */
|
|
40
|
+
hash?: string | null;
|
|
41
|
+
/** Override the timestamp (tests). ISO string. */
|
|
42
|
+
now?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Append one ledger row and prune rows past {@link TOKEN_LEDGER_RETENTION_DAYS}.
|
|
46
|
+
*/
|
|
47
|
+
export declare function recordTokenUse(db: DatabaseSyncLike, use: TokenUse): void;
|
|
48
|
+
/** What a session last sent on a surface, for {@link lastSentState}. */
|
|
49
|
+
export interface LastSent {
|
|
50
|
+
/** {@link blockHash} of the last injected block. */
|
|
51
|
+
hash: string;
|
|
52
|
+
/** Consecutive `skip` rows since that injection. */
|
|
53
|
+
skipsSince: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The last block this session injected on `surface`, or null when the
|
|
57
|
+
* session has injected nothing, a `reset` came after it, or there is no
|
|
58
|
+
* session id (without one, the caller always injects).
|
|
59
|
+
*/
|
|
60
|
+
export declare function lastSentState(db: DatabaseSyncLike, tenantId: string, sessionId: string | null | undefined, surface: TokenSurface): LastSent | null;
|
|
61
|
+
/**
|
|
62
|
+
* Whether a block identical to the session's last injection should be
|
|
63
|
+
* skipped. `refreshTurns` resends an unchanged block after that many
|
|
64
|
+
* consecutive skips, so a long session still sees its pinned rules near the
|
|
65
|
+
* latest turn; 0 never resends an unchanged block.
|
|
66
|
+
*/
|
|
67
|
+
export declare function shouldSkipUnchanged(last: LastSent | null, hash: string, refreshTurns: number): boolean;
|
|
68
|
+
/** Per-surface totals for {@link summarizeTokenUse}. */
|
|
69
|
+
export interface TokenSurfaceSummary {
|
|
70
|
+
surface: TokenSurface;
|
|
71
|
+
/** Blocks sent. */
|
|
72
|
+
injected: number;
|
|
73
|
+
/** Tokens sent. */
|
|
74
|
+
tokens: number;
|
|
75
|
+
/** Blocks not sent because they were unchanged. */
|
|
76
|
+
skipped: number;
|
|
77
|
+
/** Tokens those skipped blocks would have cost. */
|
|
78
|
+
tokensAvoided: number;
|
|
79
|
+
/** Distinct session ids seen (rows without one are not counted). */
|
|
80
|
+
sessions: number;
|
|
81
|
+
}
|
|
82
|
+
/** Ledger totals over a window. */
|
|
83
|
+
export interface TokenSummary {
|
|
84
|
+
/** ISO start of the window (inclusive). */
|
|
85
|
+
since: string;
|
|
86
|
+
surfaces: TokenSurfaceSummary[];
|
|
87
|
+
totalTokens: number;
|
|
88
|
+
totalTokensAvoided: number;
|
|
89
|
+
/** Mean tokens sent per session, over rows that carry a session id. */
|
|
90
|
+
meanTokensPerSession: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Sum the ledger for one tenant since `sinceIso`. Surfaces with no rows are
|
|
94
|
+
* omitted.
|
|
95
|
+
*/
|
|
96
|
+
export declare function summarizeTokenUse(db: DatabaseSyncLike, tenantId: string, sinceIso: string): TokenSummary;
|
|
97
|
+
/**
|
|
98
|
+
* The `session_id` of a Claude Code hook payload on stdin, or null when the
|
|
99
|
+
* text is empty, malformed, has no non-empty session id, or (with
|
|
100
|
+
* `requiredSource`) a different `source`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function hookPayloadSessionId(stdinText: string | undefined, requiredSource?: string | null): string | null;
|
|
103
|
+
/** Tokens hippo sent and skipped in one session, for {@link tokensBySession}. */
|
|
104
|
+
export interface SessionTokens {
|
|
105
|
+
sessionId: string;
|
|
106
|
+
/** Tokens of memory text sent to the agent. */
|
|
107
|
+
sent: number;
|
|
108
|
+
/** Tokens of unchanged blocks not sent. */
|
|
109
|
+
skipped: number;
|
|
110
|
+
/** Blocks sent. */
|
|
111
|
+
injections: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Ledger totals per session id since `sinceIso`, across every surface.
|
|
115
|
+
* Claude Code hook rows carry the host's session id, which is also the
|
|
116
|
+
* transcript file name, so these join to the host's own usage records.
|
|
117
|
+
*/
|
|
118
|
+
export declare function tokensBySession(db: DatabaseSyncLike, tenantId: string, sinceIso: string): SessionTokens[];
|
|
119
|
+
//# sourceMappingURL=token-ledger.d.ts.map
|