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.
Files changed (49) hide show
  1. package/README.md +57 -15
  2. package/dist/ablation.d.ts +10 -1
  3. package/dist/ablation.js +17 -1
  4. package/dist/api.d.ts +60 -1
  5. package/dist/api.js +189 -7
  6. package/dist/audit.d.ts +1 -1
  7. package/dist/capture-error.d.ts +20 -0
  8. package/dist/capture-error.js +82 -0
  9. package/dist/capture.d.ts +25 -8
  10. package/dist/capture.js +100 -5
  11. package/dist/cli.d.ts +6 -1
  12. package/dist/cli.js +381 -48
  13. package/dist/config.d.ts +20 -0
  14. package/dist/config.js +35 -0
  15. package/dist/consolidate.d.ts +6 -0
  16. package/dist/consolidate.js +98 -13
  17. package/dist/db.js +57 -1
  18. package/dist/doctor.d.ts +34 -0
  19. package/dist/doctor.js +174 -0
  20. package/dist/dormant.d.ts +91 -0
  21. package/dist/dormant.js +121 -0
  22. package/dist/eval-stats.d.ts +123 -0
  23. package/dist/eval-stats.js +187 -0
  24. package/dist/half-life-migration.d.ts +55 -0
  25. package/dist/half-life-migration.js +111 -0
  26. package/dist/hooks.d.ts +4 -0
  27. package/dist/hooks.js +47 -0
  28. package/dist/mcp/server.d.ts +6 -0
  29. package/dist/mcp/server.js +70 -13
  30. package/dist/memory.d.ts +16 -2
  31. package/dist/memory.js +27 -5
  32. package/dist/physics-config.js +5 -1
  33. package/dist/recall-scope.d.ts +24 -0
  34. package/dist/recall-scope.js +41 -0
  35. package/dist/reject-flow.d.ts +3 -3
  36. package/dist/reject-flow.js +10 -3
  37. package/dist/search.d.ts +4 -4
  38. package/dist/search.js +23 -18
  39. package/dist/server.js +11 -1
  40. package/dist/store.d.ts +12 -1
  41. package/dist/store.js +58 -12
  42. package/dist/token-ledger.d.ts +119 -0
  43. package/dist/token-ledger.js +181 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  47. package/extensions/openclaw-plugin/package.json +1 -1
  48. package/openclaw.plugin.json +1 -1
  49. 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)) {
@@ -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)
@@ -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, adminActor, buildSuppressionSummary, ambientSecretAdmit } from '../api.js';
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: 1500)' },
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: 1500)' },
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: adminActor(ctx?.actor ?? 'mcp'),
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: adminActor(ctx?.actor ?? 'mcp'),
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: adminActor(ctx?.actor ?? 'mcp'),
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: adminActor(ctx?.actor ?? 'mcp'),
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: adminActor(ctx?.actor ?? 'mcp'),
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 always return 1.0 (no decay).
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
- export declare const DEFAULT_HALF_LIFE_DAYS = 7;
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 always return 1.0 (no decay).
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 1.0;
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
- export const DEFAULT_HALF_LIFE_DAYS = 7;
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) {
@@ -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
- enabled: 'auto',
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,
@@ -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
@@ -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
@@ -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, and
11
- * raw-archive.ts. Nothing imports FROM this file except cli.ts and api.ts,
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 {
@@ -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, and
11
- * raw-archive.ts. Nothing imports FROM this file except cli.ts and api.ts,
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,