hippo-memory 1.45.0 → 1.46.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 +57 -15
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +60 -1
- package/dist/api.js +189 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +20 -0
- package/dist/capture-error.js +82 -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 +381 -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 +57 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +174 -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/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
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a store's memories to a new default half-life.
|
|
3
|
+
*
|
|
4
|
+
* Each memory stores its own `half_life_days`, set at write from the
|
|
5
|
+
* default base and a few write-time multipliers (`deriveHalfLife`). Changing
|
|
6
|
+
* the default therefore reaches only new memories; without this migration a
|
|
7
|
+
* store would mix old-base and new-base memories, a state the decay
|
|
8
|
+
* evaluation never tested (docs/evals/2026-09-24-decay-default-prereg.md,
|
|
9
|
+
* Migration). The rule, declared there before any run:
|
|
10
|
+
*
|
|
11
|
+
* - only a memory still on the old base is rescaled: its half-life is
|
|
12
|
+
* `deriveHalfLife(from, entry)` plus its recall bonus. A memory hippo shortened since
|
|
13
|
+
* (invalidated, superseded, a merge source, marked bad) or one with its own
|
|
14
|
+
* fixed half-life (decisions, incidents, customer notes) keeps its value;
|
|
15
|
+
* - every rescale is written to the audit log with the ids, so it can be
|
|
16
|
+
* undone, and the store records the base it is on (`meta`), so the
|
|
17
|
+
* migration runs once.
|
|
18
|
+
*
|
|
19
|
+
* `hippo sleep` runs it before its decay pass, from the base the store is on
|
|
20
|
+
* (7 days when never recorded) to the configured `defaultHalfLifeDays`.
|
|
21
|
+
*/
|
|
22
|
+
import { deriveHalfLife } from './memory.js';
|
|
23
|
+
import { initStore, selectAllEntries, HALF_LIFE_BASE_META_KEY } from './store.js';
|
|
24
|
+
import { openHippoDb, closeHippoDb, getMeta, setMeta } from './db.js';
|
|
25
|
+
import { appendAuditEvent } from './audit.js';
|
|
26
|
+
/** The base every store used before the base was recorded. */
|
|
27
|
+
export const LEGACY_HALF_LIFE_BASE = 7;
|
|
28
|
+
export { HALF_LIFE_BASE_META_KEY };
|
|
29
|
+
/** Recall bonus over what `base` gave `entry`, or null when off that base; pre-1.46 recalls each added 2 days. */
|
|
30
|
+
export function halfLifeRecallBonus(entry, base) {
|
|
31
|
+
if (entry.superseded_by || entry.tags.includes('invalidated') || entry.tags.includes('superseded'))
|
|
32
|
+
return null;
|
|
33
|
+
const bonus = entry.half_life_days - deriveHalfLife(base, entry);
|
|
34
|
+
const k = Math.round(bonus / 2);
|
|
35
|
+
return Math.abs(bonus - 2 * k) < 1e-9 && k >= 0 && k <= entry.retrieval_count ? bonus : null;
|
|
36
|
+
}
|
|
37
|
+
/** The entries to rescale from `from` to `to`, as copies that keep their recall bonus. Pure. */
|
|
38
|
+
export function planHalfLifeMigration(entries, from, to) {
|
|
39
|
+
if (from === to)
|
|
40
|
+
return [];
|
|
41
|
+
return entries.flatMap((e) => {
|
|
42
|
+
const bonus = halfLifeRecallBonus(e, from);
|
|
43
|
+
return bonus === null ? [] : [{ ...e, half_life_days: deriveHalfLife(to, e) + bonus }];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** The base this store's memories are on. */
|
|
47
|
+
export function storeHalfLifeBase(hippoRoot) {
|
|
48
|
+
const db = openHippoDb(hippoRoot);
|
|
49
|
+
try {
|
|
50
|
+
return readBase(db);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
closeHippoDb(db);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function readBase(db) {
|
|
57
|
+
const raw = Number(getMeta(db, HALF_LIFE_BASE_META_KEY, String(LEGACY_HALF_LIFE_BASE)));
|
|
58
|
+
return Number.isFinite(raw) && raw > 0 ? raw : LEGACY_HALF_LIFE_BASE;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Move the store's memories from the base they are on to `to`. A no-op when
|
|
62
|
+
* they are already on it. Under `dryRun` nothing is written, the recorded
|
|
63
|
+
* base included.
|
|
64
|
+
*/
|
|
65
|
+
export function migrateDefaultHalfLife(hippoRoot, to, opts = {}) {
|
|
66
|
+
const dryRun = opts.dryRun ?? false;
|
|
67
|
+
const noop = (from) => ({ from, to, rescaled: 0, kept: 0, dryRun, halfLives: new Map() });
|
|
68
|
+
initStore(hippoRoot);
|
|
69
|
+
const db = openHippoDb(hippoRoot);
|
|
70
|
+
try {
|
|
71
|
+
// Plan, write, audit and record the base under one write lock, so a concurrent write or sleep cannot interleave.
|
|
72
|
+
if (!dryRun)
|
|
73
|
+
db.exec('BEGIN IMMEDIATE');
|
|
74
|
+
try {
|
|
75
|
+
const from = readBase(db);
|
|
76
|
+
if (!(Number.isFinite(to) && to > 0) || from === to) {
|
|
77
|
+
if (!dryRun)
|
|
78
|
+
db.exec('COMMIT');
|
|
79
|
+
return noop(from);
|
|
80
|
+
}
|
|
81
|
+
const all = selectAllEntries(db);
|
|
82
|
+
const plan = planHalfLifeMigration(all, from, to);
|
|
83
|
+
const halfLives = new Map(plan.map((e) => [e.id, e.half_life_days]));
|
|
84
|
+
const result = { from, to, rescaled: plan.length, kept: all.length - plan.length, dryRun, halfLives };
|
|
85
|
+
if (dryRun)
|
|
86
|
+
return result;
|
|
87
|
+
const old = new Map(all.map((e) => [e.id, e.half_life_days]));
|
|
88
|
+
const update = db.prepare('UPDATE memories SET half_life_days = ? WHERE id = ?');
|
|
89
|
+
const byTenant = new Map();
|
|
90
|
+
for (const e of plan) {
|
|
91
|
+
update.run(e.half_life_days, e.id);
|
|
92
|
+
byTenant.set(e.tenantId, { ...byTenant.get(e.tenantId), [e.id]: old.get(e.id) });
|
|
93
|
+
}
|
|
94
|
+
for (const [tenantId, oldHalfLives] of byTenant) {
|
|
95
|
+
appendAuditEvent(db, { tenantId, actor: opts.actor ?? 'system', op: 'half_life_migrate', metadata: { from, to, ids: Object.keys(oldHalfLives), oldHalfLives } });
|
|
96
|
+
}
|
|
97
|
+
setMeta(db, HALF_LIFE_BASE_META_KEY, String(to));
|
|
98
|
+
db.exec('COMMIT');
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
if (!dryRun)
|
|
103
|
+
db.exec('ROLLBACK');
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
closeHippoDb(db);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=half-life-migration.js.map
|
package/dist/hooks.d.ts
CHANGED
|
@@ -85,6 +85,10 @@ export interface InstallResult {
|
|
|
85
85
|
installedUserPromptSubmit: boolean;
|
|
86
86
|
installedPreCompact: boolean;
|
|
87
87
|
installedCompactResume: boolean;
|
|
88
|
+
/** PostCompact -> `hippo post-compact` (tells the user what compaction saved). */
|
|
89
|
+
installedPostCompact: boolean;
|
|
90
|
+
/** PostToolUseFailure -> `hippo capture-error` (failed tool calls become error memories). */
|
|
91
|
+
installedCaptureError: boolean;
|
|
88
92
|
migratedPinnedInjectRecent: boolean;
|
|
89
93
|
migratedFromStop: boolean;
|
|
90
94
|
migratedLegacySessionEnd: boolean;
|
package/dist/hooks.js
CHANGED
|
@@ -52,6 +52,8 @@ const HIPPO_PINNED_INJECT_COMMAND = 'hippo context --pinned-only --include-recen
|
|
|
52
52
|
const HIPPO_CODEX_WRAPPER_MARKER = 'hippo codex wrapper';
|
|
53
53
|
const HIPPO_PRE_COMPACT_MARKER = 'hippo pre-compact';
|
|
54
54
|
const HIPPO_COMPACT_RESUME_MARKER = 'hippo compact-resume';
|
|
55
|
+
const HIPPO_CAPTURE_ERROR_MARKER = 'hippo capture-error';
|
|
56
|
+
const HIPPO_POST_COMPACT_MARKER = 'hippo post-compact';
|
|
55
57
|
const HIPPO_OPENCODE_PLUGIN_MARKER = 'HIPPO_OPENCODE_PLUGIN_V1';
|
|
56
58
|
/**
|
|
57
59
|
* The opencode plugin file we install at ~/.config/opencode/plugins/hippo.ts.
|
|
@@ -571,6 +573,8 @@ export function installJsonHooks(target) {
|
|
|
571
573
|
installedUserPromptSubmit: false,
|
|
572
574
|
installedPreCompact: false,
|
|
573
575
|
installedCompactResume: false,
|
|
576
|
+
installedPostCompact: false,
|
|
577
|
+
installedCaptureError: false,
|
|
574
578
|
migratedPinnedInjectRecent: false,
|
|
575
579
|
migratedFromStop: false,
|
|
576
580
|
migratedLegacySessionEnd: false,
|
|
@@ -703,11 +707,50 @@ export function installJsonHooks(target) {
|
|
|
703
707
|
});
|
|
704
708
|
installedCompactResume = true;
|
|
705
709
|
}
|
|
710
|
+
// PostCompact: tells the user what pre-compact saved. PreCompact itself
|
|
711
|
+
// must stay silent, because Claude Code hands PreCompact stdout to the
|
|
712
|
+
// summarising model as instructions; PostCompact stdout is only shown.
|
|
713
|
+
let installedPostCompact = false;
|
|
714
|
+
if (!hookArrayContains(hooks.PostCompact, HIPPO_POST_COMPACT_MARKER)) {
|
|
715
|
+
if (!Array.isArray(hooks.PostCompact))
|
|
716
|
+
hooks.PostCompact = [];
|
|
717
|
+
hooks.PostCompact.push({
|
|
718
|
+
hooks: [
|
|
719
|
+
{
|
|
720
|
+
type: 'command',
|
|
721
|
+
command: `hippo post-compact --log-file "${defaultPreCompactLogPath()}"`,
|
|
722
|
+
timeout: 10,
|
|
723
|
+
},
|
|
724
|
+
],
|
|
725
|
+
});
|
|
726
|
+
installedPostCompact = true;
|
|
727
|
+
}
|
|
728
|
+
// PostToolUseFailure: a failed tool call becomes an error memory, after
|
|
729
|
+
// `hippo capture-error` drops routine failures (interrupts, declined
|
|
730
|
+
// permissions, empty searches) and repeats. Same hook the plugin ships.
|
|
731
|
+
let installedCaptureError = false;
|
|
732
|
+
if (!hookArrayContains(hooks.PostToolUseFailure, HIPPO_CAPTURE_ERROR_MARKER)) {
|
|
733
|
+
if (!Array.isArray(hooks.PostToolUseFailure))
|
|
734
|
+
hooks.PostToolUseFailure = [];
|
|
735
|
+
hooks.PostToolUseFailure.push({
|
|
736
|
+
matcher: '.*',
|
|
737
|
+
hooks: [
|
|
738
|
+
{
|
|
739
|
+
type: 'command',
|
|
740
|
+
command: 'hippo capture-error',
|
|
741
|
+
timeout: 10,
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
});
|
|
745
|
+
installedCaptureError = true;
|
|
746
|
+
}
|
|
706
747
|
if (installedSessionEnd ||
|
|
707
748
|
installedSessionStart ||
|
|
708
749
|
installedUserPromptSubmit ||
|
|
709
750
|
installedPreCompact ||
|
|
710
751
|
installedCompactResume ||
|
|
752
|
+
installedPostCompact ||
|
|
753
|
+
installedCaptureError ||
|
|
711
754
|
migratedPinnedInjectRecent ||
|
|
712
755
|
migratedFromStop ||
|
|
713
756
|
migratedLegacySessionEnd ||
|
|
@@ -722,6 +765,8 @@ export function installJsonHooks(target) {
|
|
|
722
765
|
installedUserPromptSubmit,
|
|
723
766
|
installedPreCompact,
|
|
724
767
|
installedCompactResume,
|
|
768
|
+
installedPostCompact,
|
|
769
|
+
installedCaptureError,
|
|
725
770
|
migratedPinnedInjectRecent,
|
|
726
771
|
migratedFromStop,
|
|
727
772
|
migratedLegacySessionEnd,
|
|
@@ -753,6 +798,8 @@ export function uninstallJsonHooks(target) {
|
|
|
753
798
|
SessionStart: [HIPPO_LAST_SLEEP_MARKER, HIPPO_COMPACT_RESUME_MARKER],
|
|
754
799
|
UserPromptSubmit: [HIPPO_PINNED_INJECT_MARKER],
|
|
755
800
|
PreCompact: [HIPPO_PRE_COMPACT_MARKER],
|
|
801
|
+
PostCompact: [HIPPO_POST_COMPACT_MARKER],
|
|
802
|
+
PostToolUseFailure: [HIPPO_CAPTURE_ERROR_MARKER],
|
|
756
803
|
Stop: [HIPPO_SLEEP_MARKER],
|
|
757
804
|
};
|
|
758
805
|
for (const [key, markers] of Object.entries(markersByKey)) {
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -42,6 +42,12 @@ export interface McpContext {
|
|
|
42
42
|
hippoRoot: string;
|
|
43
43
|
tenantId: string;
|
|
44
44
|
actor: string;
|
|
45
|
+
/**
|
|
46
|
+
* The caller's role from the HTTP transport's auth. Absent for stdio, which
|
|
47
|
+
* is the local operator and runs as admin. Tools must use this rather than
|
|
48
|
+
* assuming admin, or a member key over HTTP-MCP would act as admin.
|
|
49
|
+
*/
|
|
50
|
+
role?: 'admin' | 'member';
|
|
45
51
|
/**
|
|
46
52
|
* Per-client key for state isolation under HTTP-MCP. For stdio: 'stdio-${pid}'
|
|
47
53
|
* (one process = one client). For HTTP-SSE / HTTP MCP: hash(bearer + remoteAddr)
|
package/dist/mcp/server.js
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import { createMemory, Layer, calculateStrength, } from '../memory.js';
|
|
13
|
-
import { hybridSearch, physicsSearch } from '../search.js';
|
|
13
|
+
import { hybridSearch, physicsSearch, estimateTokens } from '../search.js';
|
|
14
14
|
import { evalNow } from '../ablation.js';
|
|
15
15
|
import { loadAllEntries, writeEntry, strengthenRetrieved, readEntry, loadFreshActiveTaskSnapshot, listMemoryConflicts, resolveConflict, RECALL_DEFAULT_DENY_SCOPES, countCreatedSinceLastSleep } from '../store.js';
|
|
16
|
-
import { shareMemory, listPeers, getGlobalRoot } from '../shared.js';
|
|
16
|
+
import { shareMemory, listPeers, getGlobalRoot, initGlobal } from '../shared.js';
|
|
17
17
|
import { consolidate } from '../consolidate.js';
|
|
18
18
|
import { execSync } from 'child_process';
|
|
19
19
|
import { fetchGitLog, extractLessons, partitionLessons, deduplicateLesson, isGitRepo } from '../autolearn.js';
|
|
20
20
|
import { loadConfig } from '../config.js';
|
|
21
21
|
import { confidenceLabel } from '../memory.js';
|
|
22
22
|
import { resolveTenantId } from '../tenant.js';
|
|
23
|
-
import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, isPrivateScope, passesScopeFilterForRecall,
|
|
23
|
+
import { recall as apiRecall, remember as apiRemember, outcome as apiOutcome, drillDown as apiDrillDown, assemble as apiAssemble, isPrivateScope, passesScopeFilterForRecall, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
|
|
24
|
+
import { assertScopeRequestAllowed } from '../recall-scope.js';
|
|
24
25
|
import { resolveProjectIdentity, classifyOriginProject, findHippoStoreDir } from '../project-identity.js';
|
|
25
26
|
import { computePredictionBaserate } from '../predictions.js';
|
|
26
27
|
import { appendAuditEvent } from '../audit.js';
|
|
@@ -38,6 +39,7 @@ export function __resetSessionRecallHistoryMcp() {
|
|
|
38
39
|
}
|
|
39
40
|
import { applyGoalStackBoost } from '../goals.js';
|
|
40
41
|
import { openHippoDb, closeHippoDb } from '../db.js';
|
|
42
|
+
import { recordTokenUse } from '../token-ledger.js';
|
|
41
43
|
import { PACKAGE_VERSION } from '../version.js';
|
|
42
44
|
// ── Find hippo root ──
|
|
43
45
|
/** Same bounded walk as the CLI (ends at home, so HIPPO_HOME wins over ~/.hippo); cwd/opts are the test seam. */
|
|
@@ -49,6 +51,14 @@ export function findHippoRoot(cwd = process.cwd(), opts) {
|
|
|
49
51
|
const global = getGlobalRoot();
|
|
50
52
|
return fs.existsSync(global) ? global : null;
|
|
51
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The api-layer actor for a tool call. Stdio (no ctx) is the local operator
|
|
56
|
+
* and runs as admin; over HTTP the transport's authenticated role is used, so
|
|
57
|
+
* a member key never acts as admin through MCP.
|
|
58
|
+
*/
|
|
59
|
+
function mcpActor(ctx) {
|
|
60
|
+
return { subject: ctx?.actor ?? 'mcp', role: ctx?.role ?? 'admin' };
|
|
61
|
+
}
|
|
52
62
|
// MCP stdio transport spec: messages are newline-delimited JSON-RPC, no embedded newlines.
|
|
53
63
|
// https://modelcontextprotocol.io/specification/.../basic/transports#stdio
|
|
54
64
|
function send(msg) {
|
|
@@ -136,7 +146,7 @@ const TOOLS = [
|
|
|
136
146
|
type: 'object',
|
|
137
147
|
properties: {
|
|
138
148
|
query: { type: 'string', description: 'What to search for in memory (natural language)' },
|
|
139
|
-
budget: { type: 'number', description: 'Max tokens to return (default:
|
|
149
|
+
budget: { type: 'number', description: 'Max tokens to return (default: config.defaultBudget, 4000)' },
|
|
140
150
|
include_continuity: {
|
|
141
151
|
type: 'boolean',
|
|
142
152
|
description: 'Append continuity context (active snapshot + handoff + last 5 session events) below the memory results. Useful at session boot.',
|
|
@@ -265,7 +275,7 @@ const TOOLS = [
|
|
|
265
275
|
inputSchema: {
|
|
266
276
|
type: 'object',
|
|
267
277
|
properties: {
|
|
268
|
-
budget: { type: 'number', minimum: 0, description: 'Max tokens (default:
|
|
278
|
+
budget: { type: 'number', minimum: 0, description: 'Max tokens (default: config.defaultContextBudget, 3000)' },
|
|
269
279
|
scope: {
|
|
270
280
|
type: 'string',
|
|
271
281
|
description: 'Restrict memories and snapshot to this scope exactly. When omitted, default-deny applies to ANY <source>:private:* (slack, github, ...) and unknown-legacy rows.',
|
|
@@ -367,6 +377,53 @@ function resolveClientKey(ctx) {
|
|
|
367
377
|
return `stdio-${process.pid}:${ctx.tenantId}`;
|
|
368
378
|
return `stdio-${process.pid}:default`;
|
|
369
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Zero-install first run (`npx -y hippo-memory mcp` with no store anywhere):
|
|
382
|
+
* create the global store instead of failing every tool call, and say so on
|
|
383
|
+
* stderr (stdout carries the protocol). `hippo init` in a project later adds
|
|
384
|
+
* a project store, which then takes precedence.
|
|
385
|
+
*/
|
|
386
|
+
function createGlobalStoreOnFirstRun() {
|
|
387
|
+
initGlobal();
|
|
388
|
+
const root = getGlobalRoot();
|
|
389
|
+
console.error(`hippo: no memory store found; created the global store at ${root}. Run \`hippo init\` in a project for a project store.`);
|
|
390
|
+
return root;
|
|
391
|
+
}
|
|
392
|
+
// ── Token ledger (ROADMAP TE0) ──
|
|
393
|
+
const MCP_TOKEN_SURFACES = new Map([
|
|
394
|
+
['hippo_recall', 'mcp_recall'],
|
|
395
|
+
['hippo_context', 'mcp_context'],
|
|
396
|
+
]);
|
|
397
|
+
/**
|
|
398
|
+
* Record the memory text a recall or context tool returned. Best-effort: a
|
|
399
|
+
* ledger failure never fails the tool call. Other tools are not recorded.
|
|
400
|
+
*/
|
|
401
|
+
function recordMcpTokens(toolName, output, ctx) {
|
|
402
|
+
const surface = MCP_TOKEN_SURFACES.get(toolName);
|
|
403
|
+
if (!surface || !output)
|
|
404
|
+
return;
|
|
405
|
+
try {
|
|
406
|
+
const hippoRoot = ctx?.hippoRoot ?? findHippoRoot();
|
|
407
|
+
if (!hippoRoot)
|
|
408
|
+
return;
|
|
409
|
+
const db = openHippoDb(hippoRoot);
|
|
410
|
+
try {
|
|
411
|
+
recordTokenUse(db, {
|
|
412
|
+
tenantId: ctx?.tenantId ?? resolveTenantId({}),
|
|
413
|
+
surface,
|
|
414
|
+
event: 'inject',
|
|
415
|
+
items: 0,
|
|
416
|
+
tokens: estimateTokens(output),
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
closeHippoDb(db);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Ledger is best-effort.
|
|
425
|
+
}
|
|
426
|
+
}
|
|
370
427
|
// ── Tool execution ──
|
|
371
428
|
async function executeTool(name, args, ctx) {
|
|
372
429
|
// When a transport hands us a context (HTTP path), trust it: the HTTP
|
|
@@ -374,9 +431,7 @@ async function executeTool(name, args, ctx) {
|
|
|
374
431
|
// from the Bearer token (or the loopback fallback). The stdio path
|
|
375
432
|
// continues to walk from cwd / fall back to the global root, and to
|
|
376
433
|
// resolve tenant from HIPPO_TENANT.
|
|
377
|
-
const hippoRoot = ctx?.hippoRoot ?? findHippoRoot();
|
|
378
|
-
if (!hippoRoot)
|
|
379
|
-
return 'No .hippo/ store found. Run: hippo init';
|
|
434
|
+
const hippoRoot = ctx?.hippoRoot ?? findHippoRoot() ?? createGlobalStoreOnFirstRun();
|
|
380
435
|
const config = loadConfig(hippoRoot);
|
|
381
436
|
// A5: every loadAllEntries() in this server returns to the caller and is
|
|
382
437
|
// tenant-isolated. Resolved once per tool call: prefer the transport's
|
|
@@ -423,7 +478,7 @@ async function executeTool(name, args, ctx) {
|
|
|
423
478
|
const apiCtx = {
|
|
424
479
|
hippoRoot,
|
|
425
480
|
tenantId,
|
|
426
|
-
actor:
|
|
481
|
+
actor: mcpActor(ctx),
|
|
427
482
|
};
|
|
428
483
|
// Route through api.recall for audit + (when requested) continuity block.
|
|
429
484
|
// api.recall already applies the same default-deny / exact-match rules
|
|
@@ -771,7 +826,7 @@ async function executeTool(name, args, ctx) {
|
|
|
771
826
|
const apiCtx = {
|
|
772
827
|
hippoRoot,
|
|
773
828
|
tenantId,
|
|
774
|
-
actor:
|
|
829
|
+
actor: mcpActor(ctx),
|
|
775
830
|
};
|
|
776
831
|
const explicitScope = isJsonString(args.scope) && args.scope.length > 0
|
|
777
832
|
? args.scope
|
|
@@ -815,7 +870,7 @@ async function executeTool(name, args, ctx) {
|
|
|
815
870
|
const apiCtx = {
|
|
816
871
|
hippoRoot,
|
|
817
872
|
tenantId,
|
|
818
|
-
actor:
|
|
873
|
+
actor: mcpActor(ctx),
|
|
819
874
|
};
|
|
820
875
|
const drillExtra = {};
|
|
821
876
|
if (Number.isFinite(limit) && limit > 0)
|
|
@@ -890,7 +945,7 @@ async function executeTool(name, args, ctx) {
|
|
|
890
945
|
const apiCtx = {
|
|
891
946
|
hippoRoot,
|
|
892
947
|
tenantId,
|
|
893
|
-
actor:
|
|
948
|
+
actor: mcpActor(ctx),
|
|
894
949
|
};
|
|
895
950
|
const result = apiRemember(apiCtx, {
|
|
896
951
|
content: text,
|
|
@@ -926,7 +981,7 @@ async function executeTool(name, args, ctx) {
|
|
|
926
981
|
const apiCtx = {
|
|
927
982
|
hippoRoot,
|
|
928
983
|
tenantId,
|
|
929
|
-
actor:
|
|
984
|
+
actor: mcpActor(ctx),
|
|
930
985
|
};
|
|
931
986
|
const { applied } = apiOutcome(apiCtx, ids, good);
|
|
932
987
|
return `Applied ${good ? 'positive' : 'negative'} outcome to ${applied} memories`;
|
|
@@ -957,6 +1012,7 @@ async function executeTool(name, args, ctx) {
|
|
|
957
1012
|
// results and the snapshot. Pre-v1.2 this surface returned all memories
|
|
958
1013
|
// and the snapshot unfiltered, which would have leaked private-channel
|
|
959
1014
|
// content to no-scope MCP callers once scope writers shipped.
|
|
1015
|
+
assertScopeRequestAllowed(mcpActor(ctx).role, explicitScope);
|
|
960
1016
|
const allEntries = loadAllEntries(hippoRoot, tenantId);
|
|
961
1017
|
// v39 memory scope isolation: this surface reads the LOCAL store only,
|
|
962
1018
|
// but synced-down or legacy rows can still carry another project's
|
|
@@ -1184,6 +1240,7 @@ export async function handleMcpRequest(req, ctx) {
|
|
|
1184
1240
|
const argumentsValue = params?.arguments;
|
|
1185
1241
|
const toolArgs = isJsonObjectRecord(argumentsValue) ? argumentsValue : {};
|
|
1186
1242
|
const output = await executeTool(toolName, toolArgs, ctx);
|
|
1243
|
+
recordMcpTokens(toolName, output, ctx);
|
|
1187
1244
|
return {
|
|
1188
1245
|
jsonrpc: '2.0',
|
|
1189
1246
|
id,
|
package/dist/memory.d.ts
CHANGED
|
@@ -141,6 +141,12 @@ export declare function _resetLossAversionRatioCacheForTests(): void;
|
|
|
141
141
|
* decay slower; consistent negative outcomes decay faster.
|
|
142
142
|
*/
|
|
143
143
|
export declare function calculateRewardFactor(entry: MemoryEntry): number;
|
|
144
|
+
/**
|
|
145
|
+
* Net wrongness: bad outcome marks past good ones, never below zero.
|
|
146
|
+
* Strength halves per unit (capped at 3) and recall stops strengthening
|
|
147
|
+
* the memory, so a correction outranks pinning, error tags and heavy recall.
|
|
148
|
+
*/
|
|
149
|
+
export declare function netWrong(entry: MemoryEntry): number;
|
|
144
150
|
/**
|
|
145
151
|
* Options for decay basis.
|
|
146
152
|
* - clock: wall-clock time (default pre-v0.15)
|
|
@@ -163,7 +169,7 @@ export interface DecayOptions {
|
|
|
163
169
|
* - session: decay by sleep cycles instead of days (sessionsSince / halfLife)
|
|
164
170
|
* - adaptive: wall-clock decay with half-life scaled by session frequency
|
|
165
171
|
*
|
|
166
|
-
* Pinned memories
|
|
172
|
+
* Pinned memories skip time decay; being marked wrong still fades them (netWrong).
|
|
167
173
|
*/
|
|
168
174
|
export declare function calculateStrength(entry: MemoryEntry, now?: Date, options?: DecayOptions): number;
|
|
169
175
|
/**
|
|
@@ -203,7 +209,15 @@ export declare function confidenceLabel(entry: MemoryEntry, now?: Date): {
|
|
|
203
209
|
* returns 'stale'. Otherwise returns the stored confidence value.
|
|
204
210
|
*/
|
|
205
211
|
export declare function resolveConfidence(entry: MemoryEntry, now?: Date): ConfidenceLevel;
|
|
206
|
-
|
|
212
|
+
/**
|
|
213
|
+
* Base half-life for a new memory, in days, before `deriveHalfLife`'s
|
|
214
|
+
* write-time multipliers. 365 since 1.46.0: the pre-registered E1 decision
|
|
215
|
+
* (docs/evals/2026-09-24-decay-default-result.md and prereg-2) found 7 days
|
|
216
|
+
* lost the current fact far more often (29% vs 75% in the top five), and
|
|
217
|
+
* 730 days and decay off tied with 365. `hippo sleep` moves memories still
|
|
218
|
+
* on an older base (src/half-life-migration.ts).
|
|
219
|
+
*/
|
|
220
|
+
export declare const DEFAULT_HALF_LIFE_DAYS = 365;
|
|
207
221
|
export declare const AUTO_DELETABLE_SQL = "pinned = 0 AND kind != 'raw'";
|
|
208
222
|
export declare function canAutoDelete(entry: Pick<MemoryEntry, 'pinned' | 'kind'>): boolean;
|
|
209
223
|
/**
|
package/dist/memory.js
CHANGED
|
@@ -143,6 +143,18 @@ export function calculateRewardFactor(entry) {
|
|
|
143
143
|
const ratio = (pos - neg) / (pos + neg + 1);
|
|
144
144
|
return 1 + 0.5 * ratio;
|
|
145
145
|
}
|
|
146
|
+
/** Bad marks alone never push a memory under sleep's 0.05 retire line; supersede is the hard correction. */
|
|
147
|
+
const MAX_WRONG_HALVINGS = 3;
|
|
148
|
+
/**
|
|
149
|
+
* Net wrongness: bad outcome marks past good ones, never below zero.
|
|
150
|
+
* Strength halves per unit (capped at 3) and recall stops strengthening
|
|
151
|
+
* the memory, so a correction outranks pinning, error tags and heavy recall.
|
|
152
|
+
*/
|
|
153
|
+
export function netWrong(entry) {
|
|
154
|
+
if (isOutcomeSlowAblated() || isDecayAblated())
|
|
155
|
+
return 0;
|
|
156
|
+
return Math.max(0, (entry.outcome_negative ?? 0) - (entry.outcome_positive ?? 0));
|
|
157
|
+
}
|
|
146
158
|
/**
|
|
147
159
|
* Calculate current strength at a given time.
|
|
148
160
|
* strength(t) = base_strength * decay * retrieval_boost * emotional_multiplier
|
|
@@ -152,14 +164,16 @@ export function calculateRewardFactor(entry) {
|
|
|
152
164
|
* - session: decay by sleep cycles instead of days (sessionsSince / halfLife)
|
|
153
165
|
* - adaptive: wall-clock decay with half-life scaled by session frequency
|
|
154
166
|
*
|
|
155
|
-
* Pinned memories
|
|
167
|
+
* Pinned memories skip time decay; being marked wrong still fades them (netWrong).
|
|
156
168
|
*/
|
|
157
169
|
export function calculateStrength(entry,
|
|
158
170
|
// evalNow(): the real clock unless HIPPO_FAKE_NOW is set (eval-only,
|
|
159
171
|
// simulated-time protocols; see ablation.ts). Explicit `now` always wins.
|
|
160
172
|
now = evalNow(), options = {}) {
|
|
173
|
+
// Being marked wrong outranks every shield: pinning, error tags, heavy recall.
|
|
174
|
+
const wrongPenalty = Math.pow(0.5, Math.min(netWrong(entry), MAX_WRONG_HALVINGS));
|
|
161
175
|
if (entry.pinned)
|
|
162
|
-
return
|
|
176
|
+
return wrongPenalty;
|
|
163
177
|
// EVAL-ONLY ablation (see ablation.ts): with recall-strengthening ablated,
|
|
164
178
|
// anchor decay at CREATION, not last_retrieved. A never-strengthened memory
|
|
165
179
|
// decays from when it was made; using last_retrieved would let clock resets
|
|
@@ -205,7 +219,7 @@ now = evalNow(), options = {}) {
|
|
|
205
219
|
// the READ side too, so a store with PRIOR retrieval history (counts > 0
|
|
206
220
|
// written before the flag was set) does not leak strengthening into an
|
|
207
221
|
// ablated arm's rankings (codex P2).
|
|
208
|
-
const retrievalBoost = isRecallBoostAblated()
|
|
222
|
+
const retrievalBoost = isRecallBoostAblated() || netWrong(entry) > 0
|
|
209
223
|
? 1.0
|
|
210
224
|
: 1 + 0.1 * Math.log2(entry.retrieval_count + 1);
|
|
211
225
|
// Emotional multiplier
|
|
@@ -218,7 +232,7 @@ now = evalNow(), options = {}) {
|
|
|
218
232
|
const raw = decay * retrievalBoost * emotionalMultiplier;
|
|
219
233
|
// Clamp to [0, 1] with NaN guard
|
|
220
234
|
const clamped = Math.min(1.0, Math.max(0.0, raw));
|
|
221
|
-
return Number.isFinite(clamped) ? clamped : 0.0;
|
|
235
|
+
return Number.isFinite(clamped) ? clamped * wrongPenalty : 0.0;
|
|
222
236
|
}
|
|
223
237
|
/**
|
|
224
238
|
* Derive half-life based on signals, as per PLAN.md table.
|
|
@@ -296,7 +310,15 @@ export function confidenceLabel(entry, now = evalNow()) {
|
|
|
296
310
|
export function resolveConfidence(entry, now = evalNow()) {
|
|
297
311
|
return isAgedOut(entry, now) ? 'stale' : entry.confidence;
|
|
298
312
|
}
|
|
299
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Base half-life for a new memory, in days, before `deriveHalfLife`'s
|
|
315
|
+
* write-time multipliers. 365 since 1.46.0: the pre-registered E1 decision
|
|
316
|
+
* (docs/evals/2026-09-24-decay-default-result.md and prereg-2) found 7 days
|
|
317
|
+
* lost the current fact far more often (29% vs 75% in the top five), and
|
|
318
|
+
* 730 days and decay off tied with 365. `hippo sleep` moves memories still
|
|
319
|
+
* on an older base (src/half-life-migration.ts).
|
|
320
|
+
*/
|
|
321
|
+
export const DEFAULT_HALF_LIFE_DAYS = 365;
|
|
300
322
|
// Pinned means keep; raw rows leave only through archiveRawMemory. The SQL twin guards the DELETE itself.
|
|
301
323
|
export const AUTO_DELETABLE_SQL = "pinned = 0 AND kind != 'raw'";
|
|
302
324
|
export function canAutoDelete(entry) {
|
package/dist/physics-config.js
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* All constants are tunable; defaults calibrated for ~500 memory corpus.
|
|
4
4
|
*/
|
|
5
5
|
export const DEFAULT_PHYSICS_CONFIG = {
|
|
6
|
-
|
|
6
|
+
// Off by default: the paired ablation (benchmarks/physics-ablation/) found
|
|
7
|
+
// physics worse than classic hybrid on every metric, CI excluding zero
|
|
8
|
+
// (MRR 0.68 vs 0.84, R@5 74% vs 84%). Opt in with physics.enabled 'auto'
|
|
9
|
+
// or true.
|
|
10
|
+
enabled: false,
|
|
7
11
|
G_query: 2.0,
|
|
8
12
|
G_memory: 0.01,
|
|
9
13
|
K_repulsion: 0.5,
|
package/dist/recall-scope.d.ts
CHANGED
|
@@ -57,4 +57,28 @@ export declare function passesScopeFilterForRecall(scope: string | null, request
|
|
|
57
57
|
* denied.
|
|
58
58
|
*/
|
|
59
59
|
export declare function passesCliRecallScopeFilter(scope: string | null, requested: string | undefined): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Thrown when a caller requests a scope its role may not read. The HTTP layer
|
|
62
|
+
* maps it to 403.
|
|
63
|
+
*/
|
|
64
|
+
export declare class ScopeForbiddenError extends Error {
|
|
65
|
+
readonly scope: string;
|
|
66
|
+
constructor(scope: string);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* True for scopes that default-deny hides: `<source>:private:*` and the
|
|
70
|
+
* quarantine buckets. Naming one explicitly is what unlocks it, so naming one
|
|
71
|
+
* is the act that needs authorization.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isRestrictedScope(scope: string | null | undefined): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Authorize an explicitly requested scope before any read honours it.
|
|
76
|
+
*
|
|
77
|
+
* An admin (tenant owner, the local CLI, loopback without a key) may unlock
|
|
78
|
+
* any scope in its tenant. A member key may not unlock a restricted scope by
|
|
79
|
+
* naming it: before this check, any key in a tenant could read every private
|
|
80
|
+
* channel or repo by passing its scope string. Per-scope grants for member
|
|
81
|
+
* keys belong to ROADMAP Part VIII EI2 (permission-aware recall).
|
|
82
|
+
*/
|
|
83
|
+
export declare function assertScopeRequestAllowed(role: 'admin' | 'member', requested: string | undefined): void;
|
|
60
84
|
//# sourceMappingURL=recall-scope.d.ts.map
|
package/dist/recall-scope.js
CHANGED
|
@@ -86,4 +86,45 @@ export function passesCliRecallScopeFilter(scope, requested) {
|
|
|
86
86
|
}
|
|
87
87
|
return passesScopeFilterForRecall(scope, undefined);
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Thrown when a caller requests a scope its role may not read. The HTTP layer
|
|
91
|
+
* maps it to 403.
|
|
92
|
+
*/
|
|
93
|
+
export class ScopeForbiddenError extends Error {
|
|
94
|
+
scope;
|
|
95
|
+
constructor(scope) {
|
|
96
|
+
super(`scope ${scope} requires admin role`);
|
|
97
|
+
this.name = 'ScopeForbiddenError';
|
|
98
|
+
this.scope = scope;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* True for scopes that default-deny hides: `<source>:private:*` and the
|
|
103
|
+
* quarantine buckets. Naming one explicitly is what unlocks it, so naming one
|
|
104
|
+
* is the act that needs authorization.
|
|
105
|
+
*/
|
|
106
|
+
export function isRestrictedScope(scope) {
|
|
107
|
+
if (!isScopeString(scope))
|
|
108
|
+
return false;
|
|
109
|
+
// SAFETY: RECALL_DEFAULT_DENY_SCOPES is a readonly tuple of string
|
|
110
|
+
// literals; widening the array (not the input) lets .includes() take any scope.
|
|
111
|
+
return isPrivateScope(scope) || RECALL_DEFAULT_DENY_SCOPES.includes(scope);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Authorize an explicitly requested scope before any read honours it.
|
|
115
|
+
*
|
|
116
|
+
* An admin (tenant owner, the local CLI, loopback without a key) may unlock
|
|
117
|
+
* any scope in its tenant. A member key may not unlock a restricted scope by
|
|
118
|
+
* naming it: before this check, any key in a tenant could read every private
|
|
119
|
+
* channel or repo by passing its scope string. Per-scope grants for member
|
|
120
|
+
* keys belong to ROADMAP Part VIII EI2 (permission-aware recall).
|
|
121
|
+
*/
|
|
122
|
+
export function assertScopeRequestAllowed(role, requested) {
|
|
123
|
+
if (requested === undefined || requested === '')
|
|
124
|
+
return;
|
|
125
|
+
if (role === 'admin')
|
|
126
|
+
return;
|
|
127
|
+
if (isRestrictedScope(requested))
|
|
128
|
+
throw new ScopeForbiddenError(requested);
|
|
129
|
+
}
|
|
89
130
|
//# sourceMappingURL=recall-scope.js.map
|
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,
|