hippo-memory 1.49.0 → 1.51.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/dist/search.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Zero external dependencies when embeddings are not available.
4
4
  */
5
5
  import { estimateTokens } from './token-ledger.js';
6
- import { calculateStrength, netWrong } from './memory.js';
6
+ import { calculateStrength, netWrong, CHURN_STALE_TAG } from './memory.js';
7
7
  import { isOutcomeFastAblated, isRecallBoostAblated, isRecencyAblated, evalRecencyScaleDays, evalNow } from './ablation.js';
8
8
  import { extractPathTags, pathBoostMultiplier } from './path-context.js';
9
9
  import { detectScope, scopeMatch } from './scope.js';
@@ -16,6 +16,10 @@ import { openHippoDb, closeHippoDb } from './db.js';
16
16
  import { rrfFuse } from './rrf.js';
17
17
  import { graphRankStream, selectGraphSeeds, DEFAULT_GRAPH_SEED_COUNT } from './graph-stream.js';
18
18
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
19
+ export const CHURN_STALE_RANK_MULTIPLIER = 0.5; // SHORTCUT: untuned; FE3 measures before any default.
20
+ export function churnStaleFactor(entry) {
21
+ return entry.tags.includes(CHURN_STALE_TAG) ? CHURN_STALE_RANK_MULTIPLIER : 1.0;
22
+ }
19
23
  // ---------------------------------------------------------------------------
20
24
  // Tokenizer
21
25
  // ---------------------------------------------------------------------------
@@ -368,6 +372,8 @@ export async function hybridSearch(query, entries, options = {}) {
368
372
  // Decision-tagged memories get a 1.2x recall boost
369
373
  const decisionBoost = entries[i].tags.includes('decision') ? 1.2 : 1.0;
370
374
  compositeScore *= decisionBoost;
375
+ const churnStaleMultiplier = churnStaleFactor(entries[i]);
376
+ compositeScore *= churnStaleMultiplier;
371
377
  // Path-based boost: memories tagged with matching path segments get up to 1.3x
372
378
  const pathBoost = pathBoostMultiplier(entries[i].tags, currentPathTags);
373
379
  compositeScore *= pathBoost;
@@ -424,6 +430,7 @@ export async function hybridSearch(query, entries, options = {}) {
424
430
  scopeBoost,
425
431
  sourceBump: 1,
426
432
  outcomeBoost,
433
+ churnStaleMultiplier,
427
434
  matchedTerms,
428
435
  final: compositeScore,
429
436
  ageDays,
@@ -476,7 +483,7 @@ export async function hybridSearch(query, entries, options = {}) {
476
483
  for (const child of childEntriesAsync) {
477
484
  if (!scoredDeduped.some((r) => r.entry.id === child.id)) {
478
485
  const parentResult = scoredDeduped.find((r) => r.entry.id === child.dag_parent_id);
479
- const childScore = parentResult ? parentResult.score * 0.9 : 0;
486
+ const childScore = parentResult ? parentResult.score * 0.9 * churnStaleFactor(child) : 0;
480
487
  scoredDeduped.push({
481
488
  entry: child,
482
489
  score: childScore,
@@ -700,7 +707,8 @@ export async function physicsSearch(query, entries, options = {}) {
700
707
  freshnessMultiplier = summaryFreshnessMultiplier(entry, now);
701
708
  }
702
709
  }
703
- const finalScore = s.finalScore * summaryDeboostMultiplier * freshnessMultiplier;
710
+ const churnStaleMultiplier = churnStaleFactor(entry);
711
+ const finalScore = s.finalScore * summaryDeboostMultiplier * freshnessMultiplier * churnStaleMultiplier;
704
712
  if (finalScore <= 0)
705
713
  continue;
706
714
  const result = {
@@ -726,6 +734,7 @@ export async function physicsSearch(query, entries, options = {}) {
726
734
  scopeBoost: 1,
727
735
  sourceBump: 1,
728
736
  outcomeBoost: 1,
737
+ churnStaleMultiplier,
729
738
  matchedTerms: [],
730
739
  final: finalScore,
731
740
  ageDays,
@@ -768,8 +777,9 @@ export async function physicsSearch(query, entries, options = {}) {
768
777
  }
769
778
  /** Normalize two score pools to [0,1] and combine. */
770
779
  function mergeScorePools(poolA, poolB) {
771
- const maxA = poolA.reduce((m, r) => Math.max(m, r.score), 1e-9);
772
- const maxB = poolB.reduce((m, r) => Math.max(m, r.score), 1e-9);
780
+ const unpenalised = (r) => r.score / churnStaleFactor(r.entry);
781
+ const maxA = poolA.reduce((m, r) => Math.max(m, unpenalised(r)), 1e-9);
782
+ const maxB = poolB.reduce((m, r) => Math.max(m, unpenalised(r)), 1e-9);
773
783
  const merged = [];
774
784
  for (const r of poolA) {
775
785
  merged.push({ ...r, score: r.score / maxA });
@@ -842,6 +852,7 @@ export function search(query, entries, options = {}) {
842
852
  // Decision-tagged memories get a 1.2x recall boost
843
853
  const decisionBoost = entries[i].tags.includes('decision') ? 1.2 : 1.0;
844
854
  composite *= decisionBoost;
855
+ composite *= churnStaleFactor(entries[i]);
845
856
  // Path-based boost: memories tagged with matching path segments get up to 1.3x
846
857
  composite *= pathBoostMultiplier(entries[i].tags, currentPathTagsSync);
847
858
  // Scope boost (sync path)
@@ -883,7 +894,7 @@ export function search(query, entries, options = {}) {
883
894
  for (const child of childEntries) {
884
895
  if (!dedupedSync.some((r) => r.entry.id === child.id)) {
885
896
  const parentResult = dedupedSync.find((r) => r.entry.id === child.dag_parent_id);
886
- const childScore = parentResult ? parentResult.score * 0.9 : 0;
897
+ const childScore = parentResult ? parentResult.score * 0.9 * churnStaleFactor(child) : 0;
887
898
  dedupedSync.push({
888
899
  entry: child,
889
900
  score: childScore,
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, recordTokens, } 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, quarantineList, quarantineApprove, quarantineReject, } 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';
@@ -118,6 +118,9 @@ const VALID_AUDIT_OPS = new Set([
118
118
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
119
  'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
120
120
  'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
121
+ 'quarantine', // CD5: emitted by recordQuarantine; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
122
+ 'quarantine_approve', // CD5: emitted by api.quarantineApprove; lockstep
123
+ 'quarantine_reject', // CD5: emitted by api.quarantineReject; lockstep
121
124
  ]);
122
125
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
123
126
  // enough to dump a small deployment's full audit log without paginating, but
@@ -1163,6 +1166,61 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1163
1166
  sendJson(res, 200, result);
1164
1167
  return;
1165
1168
  }
1169
+ // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1170
+ if (method === 'GET' && path === '/v1/quarantine') {
1171
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1172
+ if (ctx.actor.role !== 'admin') {
1173
+ throw new HttpError(403, '/v1/quarantine requires admin role');
1174
+ }
1175
+ const statusRaw = query.get('status');
1176
+ let status = 'pending';
1177
+ if (statusRaw !== null) {
1178
+ if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
1179
+ throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
1180
+ }
1181
+ status = statusRaw;
1182
+ }
1183
+ sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
1184
+ return;
1185
+ }
1186
+ // POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
1187
+ const quarantineApproveMatch = matchPath('/v1/quarantine/:id/approve', path);
1188
+ if (method === 'POST' && quarantineApproveMatch) {
1189
+ validateIdSegment(quarantineApproveMatch.id, 'memory id');
1190
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1191
+ try {
1192
+ quarantineApprove(ctx, quarantineApproveMatch.id);
1193
+ sendJson(res, 200, { approved: quarantineApproveMatch.id });
1194
+ }
1195
+ catch (e) {
1196
+ const msg = e instanceof Error ? e.message : String(e);
1197
+ if (msg.includes('not quarantined'))
1198
+ throw new HttpError(404, msg);
1199
+ if (msg.includes('is already') || msg.includes('scope changed'))
1200
+ throw new HttpError(409, msg);
1201
+ throw e;
1202
+ }
1203
+ return;
1204
+ }
1205
+ // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1206
+ const quarantineRejectMatch = matchPath('/v1/quarantine/:id/reject', path);
1207
+ if (method === 'POST' && quarantineRejectMatch) {
1208
+ validateIdSegment(quarantineRejectMatch.id, 'memory id');
1209
+ const ctx = buildContextWithAuth(req, opts.hippoRoot);
1210
+ try {
1211
+ quarantineReject(ctx, quarantineRejectMatch.id);
1212
+ sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1213
+ }
1214
+ catch (e) {
1215
+ const msg = e instanceof Error ? e.message : String(e);
1216
+ if (msg.includes('not quarantined'))
1217
+ throw new HttpError(404, msg);
1218
+ if (msg.includes('is already'))
1219
+ throw new HttpError(409, msg);
1220
+ throw e;
1221
+ }
1222
+ return;
1223
+ }
1166
1224
  // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1167
1225
  // validated at the route boundary so an invalid value lands a 400 before
1168
1226
  // we hit the DB.
package/dist/shared.js CHANGED
@@ -13,6 +13,7 @@ import { search, hybridSearch } from './search.js';
13
13
  import { evalNow } from './ablation.js';
14
14
  import { deriveOriginProject, classifyOriginProject, resolveGlobalRootDir } from './project-identity.js';
15
15
  import { detectSecret } from './secret-detect.js';
16
+ import { isQuarantineScope } from './quarantine.js';
16
17
  import { RejectedValueError } from './rejection.js';
17
18
  import { embedMemory, embedAll } from './embeddings.js';
18
19
  /**
@@ -46,6 +47,10 @@ export function promoteToGlobal(localRoot, id, opts) {
46
47
  const entry = readEntry(localRoot, id, opts?.tenantId);
47
48
  if (!entry)
48
49
  throw new Error(`Memory not found: ${id}`);
50
+ // CD5: same veto as shareMemory; a promoted copy would have no quarantine record to review.
51
+ if (isQuarantineScope(entry.scope)) {
52
+ throw new Error(`Refusing to promote ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
53
+ }
49
54
  // v39 S4 producer veto: promote is a producer path to the global store
50
55
  // exactly like shareMemory - same hard rule (codex gating review P2).
51
56
  const promoteSecret = detectSecret(entry);
@@ -272,6 +277,10 @@ export function shareMemory(localRoot, id, options = {}) {
272
277
  throw new Error(`Refusing to share ${id} to the global store: content matches secret material (${secret.reason}). ` +
273
278
  `Secrets stay in their owning project's store.`);
274
279
  }
280
+ // CD5: a quarantined row is unreviewed input, not a lesson; sharing it would spread poison globally.
281
+ if (isQuarantineScope(entry.scope)) {
282
+ throw new Error(`Refusing to share ${id}: it is quarantined pending review. Approve it first via 'hippo quarantine approve ${id}'.`);
283
+ }
275
284
  const score = transferScore(entry);
276
285
  if (score < 0.3 && !options.force)
277
286
  return null;
@@ -385,6 +394,9 @@ export function autoShare(localRoot, options = {}) {
385
394
  // Build set of global content hashes to avoid duplicates
386
395
  const globalContentSet = new Set(globalEntries.map((e) => e.content.toLowerCase().trim().slice(0, 200)));
387
396
  const candidates = localEntries.filter((entry) => {
397
+ // CD5: shareMemory refuses quarantined rows; filtering here keeps sleep from aborting on one.
398
+ if (isQuarantineScope(entry.scope ?? null))
399
+ return false;
388
400
  const score = transferScore(entry);
389
401
  if (score < minScore)
390
402
  return false;
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.49.0";
19
+ export declare const PACKAGE_VERSION = "1.51.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.49.0';
19
+ export const PACKAGE_VERSION = '1.51.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.49.0",
5
+ "version": "1.51.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.49.0",
3
+ "version": "1.51.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.49.0",
5
+ "version": "1.51.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.49.0",
3
+ "version": "1.51.0",
4
4
  "description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",