hippo-memory 1.50.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/api.js CHANGED
@@ -17,7 +17,7 @@ import { detectInstruction } from './instruction-detect.js';
17
17
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
18
18
  import { summarizeFailures } from './failure-log.js';
19
19
  import { formatHandoffEvidenceLine } from './handoff.js';
20
- import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
20
+ import { createMemory, applyOutcome, calculateStrength, Layer, CHURN_STALE_TAG, } from './memory.js';
21
21
  import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
22
22
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
23
23
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
@@ -25,7 +25,7 @@ import { evalNow } from './ablation.js';
25
25
  import { archiveRawMemory } from './raw-archive.js';
26
26
  import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
27
27
  import { applyGoalStackBoost } from './goals.js';
28
- import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
28
+ import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
29
29
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
30
30
  import { scopeMatch } from './scope.js';
31
31
  import { consolidate } from './consolidate.js';
@@ -313,6 +313,10 @@ function recallFrom(ctx, opts, windowSize, all) {
313
313
  // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
314
314
  // into the same field per the plan's Task 3 mapping table).
315
315
  droppedPreRankCount = all.length - entries.length;
316
+ entries = entries
317
+ .map((e, i) => ({ e, s: (1 - i / entries.length) * churnStaleFactor(e) }))
318
+ .sort((a, b) => b.s - a.s)
319
+ .map((r) => r.e);
316
320
  // BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
317
321
  // Score is a placeholder — the physics/hybrid scorers in src/search.ts
318
322
  // produce richer breakdowns and will replace this when wired up.
@@ -992,7 +996,10 @@ export function outcome(ctx, ids, good, opts) {
992
996
  const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
993
997
  if (!entry)
994
998
  continue;
995
- const updated = applyOutcome(entry, good);
999
+ let updated = applyOutcome(entry, good);
1000
+ if (good && updated.tags.includes(CHURN_STALE_TAG)) { // FE2: a good outcome reconfirms the entry
1001
+ updated = { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
1002
+ }
996
1003
  writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
997
1004
  appendAuditEvent(db, {
998
1005
  tenantId: ctx.tenantId,
@@ -0,0 +1,22 @@
1
+ /** Git subprocess helpers for FE2 churn-staleness (src/invalidation.ts). */
2
+ /** A real git failure (not git grep's expected exit-1-no-match). Callers abort the whole run on this. */
3
+ export declare class GitReadError extends Error {
4
+ }
5
+ export declare function gitLsFilesAtHead(repoRoot: string): Set<string>;
6
+ export interface ChurnCommitFile {
7
+ status: string;
8
+ path: string;
9
+ }
10
+ export interface ChurnCommit {
11
+ hash: string;
12
+ date: string;
13
+ files: ChurnCommitFile[];
14
+ }
15
+ export declare function fetchChurnWindowLog(repoRoot: string, sinceIso: string): ChurnCommit[];
16
+ /** `git grep -o -h -w -F -f <patternFile> <rev>`; returns the subset of `patterns` found. */
17
+ export declare function gitGrepPresence(repoRoot: string, patterns: string[], rev: string): Set<string>;
18
+ /** Last commit at or before `beforeIso`, or null when the repo has none. */
19
+ export declare function resolveCommitBefore(repoRoot: string, beforeIso: string): string | null;
20
+ /** package.json `scripts` at a revision; {} when the file is absent, null when unparsable. Git failures throw GitReadError. */
21
+ export declare function packageScriptsAt(repoRoot: string, rev: string): Record<string, string> | null;
22
+ //# sourceMappingURL=churn-git.d.ts.map
@@ -0,0 +1,102 @@
1
+ /** Git subprocess helpers for FE2 churn-staleness (src/invalidation.ts). */
2
+ import { execFileSync } from 'child_process';
3
+ import * as fs from 'fs';
4
+ import * as os from 'os';
5
+ import * as path from 'path';
6
+ // A `git log --name-status` dump can run to tens of MB; default 1MB pipe would truncate it.
7
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
8
+ /** A real git failure (not git grep's expected exit-1-no-match). Callers abort the whole run on this. */
9
+ export class GitReadError extends Error {
10
+ }
11
+ function runGit(args, repoRoot) {
12
+ try {
13
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
14
+ }
15
+ catch (err) {
16
+ throw new GitReadError(`git ${args.join(' ')} failed: ${err instanceof Error ? err.message : String(err)}`);
17
+ }
18
+ }
19
+ // git grep exits 1 for "no match" -- a normal empty result, not a failure.
20
+ function runGitGrepOrEmpty(args, repoRoot) {
21
+ try {
22
+ return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER });
23
+ }
24
+ catch (err) {
25
+ // SAFETY: execFileSync attaches `status` to the thrown Error on a non-zero child exit.
26
+ const status = err.status;
27
+ if (status === 1)
28
+ return '';
29
+ throw new GitReadError(`git ${args.join(' ')} failed: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ }
32
+ // ls-tree, not ls-files: the index can hold staged adds/deletes that HEAD does not.
33
+ export function gitLsFilesAtHead(repoRoot) {
34
+ const raw = runGit(['ls-tree', '-r', '--name-only', 'HEAD'], repoRoot);
35
+ return new Set(raw.split('\n').map((l) => l.trim()).filter(Boolean));
36
+ }
37
+ // 1-day buffer guards against git's --since boundary excluding a commit dated exactly at the anchor.
38
+ export function fetchChurnWindowLog(repoRoot, sinceIso) {
39
+ const buffered = new Date(new Date(sinceIso).getTime() - 24 * 60 * 60 * 1000).toISOString();
40
+ const raw = runGit(
41
+ // --first-parent -m: a merge's changes land as one diff against its mainline parent.
42
+ ['log', '--no-renames', '--first-parent', '-m', `--since=${buffered}`, '--pretty=format:%x01%H%x02%cI', '--name-status'], repoRoot);
43
+ const commits = [];
44
+ let current = null;
45
+ for (const line of raw.split('\n')) {
46
+ if (line.startsWith('\x01')) {
47
+ const [hash, date] = line.slice(1).split('\x02');
48
+ current = { hash, date, files: [] };
49
+ commits.push(current);
50
+ }
51
+ else if (current && line.trim()) {
52
+ const m = line.match(/^([AMD])\s+(.+)$/);
53
+ if (m)
54
+ current.files.push({ status: m[1], path: m[2] });
55
+ }
56
+ }
57
+ return commits;
58
+ }
59
+ /** `git grep -o -h -w -F -f <patternFile> <rev>`; returns the subset of `patterns` found. */
60
+ export function gitGrepPresence(repoRoot, patterns, rev) {
61
+ if (patterns.length === 0)
62
+ return new Set();
63
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hippo-churn-grep-'));
64
+ try {
65
+ const patternFile = path.join(tmpDir, 'patterns.txt');
66
+ fs.writeFileSync(patternFile, `${patterns.join('\n')}\n`, 'utf8');
67
+ const raw = runGitGrepOrEmpty(['grep', '-o', '-h', '-w', '-F', '-f', patternFile, rev], repoRoot);
68
+ const patternSet = new Set(patterns);
69
+ const found = new Set();
70
+ for (const line of raw.split('\n')) {
71
+ const t = line.trim();
72
+ if (patternSet.has(t))
73
+ found.add(t);
74
+ }
75
+ return found;
76
+ }
77
+ finally {
78
+ fs.rmSync(tmpDir, { recursive: true, force: true });
79
+ }
80
+ }
81
+ /** Last commit at or before `beforeIso`, or null when the repo has none. */
82
+ export function resolveCommitBefore(repoRoot, beforeIso) {
83
+ const raw = runGit(['rev-list', '-1', '--first-parent', `--before=${beforeIso}`, 'HEAD'], repoRoot).trim();
84
+ return raw === '' ? null : raw;
85
+ }
86
+ /** package.json `scripts` at a revision; {} when the file is absent, null when unparsable. Git failures throw GitReadError. */
87
+ export function packageScriptsAt(repoRoot, rev) {
88
+ if (runGit(['ls-tree', '--name-only', rev, '--', 'package.json'], repoRoot).trim() === '')
89
+ return {};
90
+ const raw = runGit(['show', `${rev}:package.json`], repoRoot);
91
+ try {
92
+ // SAFETY: optional chaining makes a `null` or scalar package.json read as "no scripts".
93
+ const parsed = JSON.parse(raw);
94
+ return parsed?.scripts ?? {};
95
+ }
96
+ catch (err) {
97
+ if (err instanceof SyntaxError)
98
+ return null;
99
+ throw err;
100
+ }
101
+ }
102
+ //# sourceMappingURL=churn-git.js.map
package/dist/cli.js CHANGED
@@ -68,7 +68,8 @@ import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
68
68
  import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
69
69
  import { rowToGoal } from './goals.js';
70
70
  import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
71
- import { extractInvalidationTarget, invalidateMatching } from './invalidation.js';
71
+ import { extractInvalidationTarget, invalidateMatching, detectChurnStale } from './invalidation.js';
72
+ import { resolveProjectIdentity } from './project-identity.js';
72
73
  import { extractPathTags } from './path-context.js';
73
74
  import { detectScope } from './scope.js';
74
75
  import { getGlobalRoot, initGlobal, shareMemory, listPeers, autoShare, transferScore, searchBothHybrid, syncGlobalToLocal, } from './shared.js';
@@ -200,6 +201,24 @@ function requireInit(hippoRoot) {
200
201
  process.exit(1);
201
202
  }
202
203
  }
204
+ /** FE2: run detectChurnStale against every store this repo's memories can live in. */
205
+ function runChurnStaleForRepo(hippoRoot, dryRun) {
206
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim();
207
+ const projectName = resolveProjectIdentity(process.cwd()).name;
208
+ const globalRoot = getGlobalRoot();
209
+ const roots = globalRoot !== hippoRoot && isInitialized(globalRoot) ? [hippoRoot, globalRoot] : [hippoRoot];
210
+ const tenantId = resolveTenantId({});
211
+ return roots.map((root) => {
212
+ // One store failing must not abort sleep's later phases or skip the other store.
213
+ try {
214
+ return { root, result: detectChurnStale(root, repoRoot, { tenantId, projectName, dryRun }) };
215
+ }
216
+ catch (err) {
217
+ const message = err instanceof Error ? err.message : String(err);
218
+ return { root, result: { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned: [], dryRun, preview: [], error: message } };
219
+ }
220
+ });
221
+ }
203
222
  /**
204
223
  * H2: when HIPPO_REQUIRE_SERVER is set, the CLI must not silently fall back to
205
224
  * direct DB mode — a missing server then masks a real misconfiguration (the
@@ -263,7 +282,7 @@ async function runViaServerIfAvailable(hippoRoot, httpFn) {
263
282
  // and as off under === true (`--pin=true` would not pin), so parseArgs and main() refuse one.
264
283
  // tests/cli-parse-flag-equals.test.ts fails when a switch read is missing from this set.
265
284
  export const BOOLEAN_FLAGS = new Set([
266
- 'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
285
+ 'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'churn', 'continuity',
267
286
  'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
268
287
  'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
269
288
  'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
@@ -2197,6 +2216,8 @@ async function cmdExplain(hippoRoot, query, flags) {
2197
2216
  console.log(` source: x${fmt(b.sourceBump, 2)} (local priority bump over global)`);
2198
2217
  if (b.outcomeBoost !== 1)
2199
2218
  console.log(` outcome: x${fmt(b.outcomeBoost, 3)} (user feedback: pos-neg = ${(r.entry.outcome_positive ?? 0) - (r.entry.outcome_negative ?? 0)})`);
2219
+ if (b.churnStaleMultiplier !== 1)
2220
+ console.log(` churn: x${fmt(b.churnStaleMultiplier, 2)} (tagged 'churn-stale')`);
2200
2221
  if (b.preMmrRank !== undefined && b.postMmrRank !== undefined && b.preMmrRank !== b.postMmrRank) {
2201
2222
  const arrow = b.postMmrRank < b.preMmrRank ? 'up' : 'down';
2202
2223
  console.log(` mmr: rank ${b.preMmrRank} -> ${b.postMmrRank} (diversity ${arrow})`);
@@ -2840,6 +2861,15 @@ async function cmdSleepCore(hippoRoot, flags) {
2840
2861
  if (added > 0)
2841
2862
  console.log(`Auto-learned ${added} lessons from today's git commits.`);
2842
2863
  }
2864
+ // FE2: opt-in code-churn staleness, off by default (config.churnStaleness.enabled).
2865
+ if (config.churnStaleness.enabled && isGitRepo(process.cwd())) {
2866
+ for (const { root, result } of runChurnStaleForRepo(hippoRoot, false)) {
2867
+ if (result.marked > 0)
2868
+ console.log(`Tagged ${result.marked} memories churn-stale in ${root}.`);
2869
+ if (result.error)
2870
+ console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
2871
+ }
2872
+ }
2843
2873
  // Also learn from Claude Code MEMORY.md files
2844
2874
  const memImported = learnFromMemoryMd(hippoRoot);
2845
2875
  if (memImported > 0)
@@ -9022,6 +9052,11 @@ Commands:
9022
9052
  'invalidated' re-weakens previously invalidated
9023
9053
  memories - preview with --dry-run first
9024
9054
  --reason "<why>" Optional: what replaced it
9055
+ invalidate --churn FE2: tag memories 'churn-stale' whose named file
9056
+ changed or was deleted, or whose named symbol or
9057
+ npm script was removed, in this repo's git history
9058
+ since the memory was stored or confirmed
9059
+ --dry-run Preview what would be tagged; writes nothing
9025
9060
  wm <sub> Working memory — bounded buffer for current state
9026
9061
  wm push Push a working memory entry
9027
9062
  --scope <scope> Scope name (default: default)
@@ -9134,6 +9169,7 @@ Examples:
9134
9169
  hippo invalidate "REST API" --dry-run
9135
9170
  hippo invalidate "REST API" --reason "migrated to GraphQL"
9136
9171
  hippo invalidate --id mem_a1b2c3d4e5f6 --reason "superseded by new policy"
9172
+ hippo invalidate --churn --dry-run
9137
9173
  hippo export memories.json
9138
9174
  hippo export --format markdown memories.md
9139
9175
  hippo sleep --dry-run
@@ -9898,6 +9934,42 @@ async function main(command, args, flags, hippoRoot) {
9898
9934
  }
9899
9935
  case 'invalidate': {
9900
9936
  requireInit(hippoRoot);
9937
+ if (flags['churn'] === true) {
9938
+ if (args[0] || flags['id'] !== undefined) {
9939
+ console.error('Usage: hippo invalidate --churn [--dry-run]');
9940
+ console.error('--churn takes no pattern or --id.');
9941
+ process.exit(1);
9942
+ }
9943
+ if (!isGitRepo(process.cwd())) {
9944
+ console.error('hippo invalidate --churn must run inside a git repository.');
9945
+ process.exit(1);
9946
+ }
9947
+ const churnDryRun = flags['dry-run'] === true;
9948
+ let churnFailed = false;
9949
+ for (const { root, result } of runChurnStaleForRepo(hippoRoot, churnDryRun)) {
9950
+ if (result.error) {
9951
+ console.error(`Churn-staleness check failed for ${root}: ${result.error}`);
9952
+ churnFailed = true;
9953
+ continue;
9954
+ }
9955
+ if (result.preview.length === 0) {
9956
+ console.log(`No churn-stale candidates in ${root}.`);
9957
+ }
9958
+ else if (churnDryRun) {
9959
+ console.log(`DRY RUN - ${result.marked} memories in ${root} WOULD be tagged churn-stale (${result.alreadyMarked} already tagged):`);
9960
+ }
9961
+ else {
9962
+ console.log(`Tagged ${result.marked} memories churn-stale in ${root} (${result.alreadyMarked} already tagged):`);
9963
+ }
9964
+ result.preview.forEach(p => console.log(` ${p.id} ${p.evidence} ${p.already ? '(already) ' : ''}${p.headline}`));
9965
+ if (result.skippedPinned.length > 0) {
9966
+ console.log(`Skipped ${result.skippedPinned.length} pinned: ${result.skippedPinned.join(', ')}`);
9967
+ }
9968
+ }
9969
+ if (churnFailed)
9970
+ process.exit(1);
9971
+ break;
9972
+ }
9901
9973
  const target = args[0];
9902
9974
  if (flags['id'] === true) {
9903
9975
  // Value-less --id must never silently fall through to pattern mode
package/dist/config.d.ts CHANGED
@@ -116,6 +116,11 @@ export interface HippoConfig {
116
116
  * Default 180. 0 keeps dormant memories forever. */
117
117
  retentionDays: number;
118
118
  };
119
+ /** FE2: tags a memory `churn-stale` when its named file/symbol/script
120
+ * changed since storage. Default OFF - FE3 measures before it flips. */
121
+ churnStaleness: {
122
+ enabled: boolean;
123
+ };
119
124
  }
120
125
  export declare function loadConfig(hippoRoot: string): HippoConfig;
121
126
  export declare function saveConfig(hippoRoot: string, config: HippoConfig): void;
package/dist/config.js CHANGED
@@ -73,6 +73,9 @@ const DEFAULT_CONFIG = {
73
73
  enabled: true,
74
74
  retentionDays: 180,
75
75
  },
76
+ churnStaleness: {
77
+ enabled: false,
78
+ },
76
79
  };
77
80
  function isMemoryValueConfig(value) {
78
81
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -80,6 +83,9 @@ function isMemoryValueConfig(value) {
80
83
  function isDormantConfig(value) {
81
84
  return typeof value === 'object' && value !== null && !Array.isArray(value);
82
85
  }
86
+ function isChurnStalenessConfig(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
83
89
  export function loadConfig(hippoRoot) {
84
90
  const configPath = path.join(hippoRoot, 'config.json');
85
91
  if (!fs.existsSync(configPath))
@@ -124,6 +130,21 @@ export function loadConfig(hippoRoot) {
124
130
  `(got ${JSON.stringify(dormantRetentionDays)}) - using ${DEFAULT_CONFIG.dormant.retentionDays}.`);
125
131
  dormantRetentionDays = DEFAULT_CONFIG.dormant.retentionDays;
126
132
  }
133
+ // Same "never silently wrong" rule as memoryValue/dormant above.
134
+ const churnStalenessRaw = raw.churnStaleness;
135
+ const validChurnStalenessConfig = churnStalenessRaw === undefined || isChurnStalenessConfig(churnStalenessRaw);
136
+ if (!validChurnStalenessConfig) {
137
+ console.error(`Warning: config.json's "churnStaleness" must be an object like {"enabled": true} ` +
138
+ `(got ${JSON.stringify(churnStalenessRaw)}) - using defaults.`);
139
+ }
140
+ let churnStalenessEnabled = churnStalenessRaw !== undefined && isChurnStalenessConfig(churnStalenessRaw)
141
+ ? churnStalenessRaw.enabled
142
+ : DEFAULT_CONFIG.churnStaleness.enabled;
143
+ if (churnStalenessEnabled !== true && churnStalenessEnabled !== false) {
144
+ console.error(`Warning: config.json's "churnStaleness.enabled" must be true or false ` +
145
+ `(got ${JSON.stringify(churnStalenessEnabled)}) - using false.`);
146
+ churnStalenessEnabled = false;
147
+ }
127
148
  return {
128
149
  defaultHalfLifeDays: raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays,
129
150
  defaultBudget: raw.defaultBudget ?? DEFAULT_CONFIG.defaultBudget,
@@ -155,6 +176,9 @@ export function loadConfig(hippoRoot) {
155
176
  enabled: dormantEnabled,
156
177
  retentionDays: dormantRetentionDays,
157
178
  },
179
+ churnStaleness: {
180
+ enabled: churnStalenessEnabled,
181
+ },
158
182
  };
159
183
  }
160
184
  catch (err) {
@@ -38,4 +38,34 @@ export declare function extractInvalidationTarget(message: string): Invalidation
38
38
  * auto-learn-from-git path) inherit this contract.
39
39
  */
40
40
  export declare function invalidateMatching(hippoRoot: string, target: InvalidationTarget, tenantId?: string, options?: InvalidationOptions): InvalidationResult;
41
+ export interface ChurnRefs {
42
+ paths: string[];
43
+ symbols: string[];
44
+ scripts: string[];
45
+ }
46
+ /** Pure syntactic extraction; repo-aware resolution (trackedness, path relativization) happens only in detectChurnStale. */
47
+ export declare function extractChurnRefs(content: string): ChurnRefs;
48
+ export interface DetectChurnStaleOptions {
49
+ tenantId: string;
50
+ projectName: string;
51
+ /** Evaluate matches but write nothing. */
52
+ dryRun?: boolean;
53
+ }
54
+ export interface ChurnStalePreviewRow {
55
+ id: string;
56
+ headline: string;
57
+ evidence: string;
58
+ already: boolean;
59
+ }
60
+ export interface ChurnStaleResult {
61
+ checked: number;
62
+ marked: number;
63
+ alreadyMarked: number;
64
+ skippedPinned: string[];
65
+ dryRun: boolean;
66
+ preview: ChurnStalePreviewRow[];
67
+ error?: string;
68
+ }
69
+ /** Flags memories whose named file/symbol/script changed or disappeared since storage (or last confirmation); only adds/removes CHURN_STALE_TAG, never confidence/half-life/strength. */
70
+ export declare function detectChurnStale(hippoRoot: string, repoRoot: string, opts: DetectChurnStaleOptions): ChurnStaleResult;
41
71
  //# sourceMappingURL=invalidation.d.ts.map
@@ -1,4 +1,7 @@
1
- import { loadAllEntries, writeEntry } from './store.js';
1
+ import { loadAllEntries, readEntry, writeEntry } from './store.js';
2
+ import { openHippoDb, closeHippoDb } from './db.js';
3
+ import { CHURN_STALE_TAG } from './memory.js';
4
+ import { GitReadError, gitLsFilesAtHead, fetchChurnWindowLog, gitGrepPresence, resolveCommitBefore, packageScriptsAt, } from './churn-git.js';
2
5
  /**
3
6
  * Extract what was replaced/removed from a commit message.
4
7
  * Returns null if the commit isn't a breaking/migration change.
@@ -126,4 +129,266 @@ function matchScore(fromTokens, contentTokens) {
126
129
  const matches = fromTokens.filter(t => contentSet.has(t)).length;
127
130
  return matches / fromTokens.length;
128
131
  }
132
+ // FE2: staleness from code churn (opt-in, ROADMAP Part XIII).
133
+ const CHURN_PATH_EXTENSIONS = [
134
+ 'ts', 'tsx', 'js', 'mjs', 'cjs', 'py', 'md', 'json', 'sh', 'ps1',
135
+ 'toml', 'yml', 'yaml', 'sql', 'rs', 'go', 'css', 'html',
136
+ ];
137
+ const CHURN_PATH_RE = new RegExp(
138
+ // The lookahead stops `ts` winning over `tsx` and `js` over `json`.
139
+ `[A-Za-z0-9_./\\\\:-]+\\.(?:${CHURN_PATH_EXTENSIONS.join('|')})(?![A-Za-z0-9_])(?::\\d+(?:-\\d+)?)?`, 'g');
140
+ const CHURN_SYMBOL_RE = /`([A-Za-z_$][A-Za-z0-9_$]*)(?:\(\))?`/g;
141
+ const CHURN_SCRIPT_RE = /npm run(?:-script)?\s+([A-Za-z0-9:_-]+)/g;
142
+ /** Pure syntactic extraction; repo-aware resolution (trackedness, path relativization) happens only in detectChurnStale. */
143
+ export function extractChurnRefs(content) {
144
+ const paths = new Set();
145
+ for (const m of content.matchAll(CHURN_PATH_RE)) {
146
+ let token = m[0].replace(/:\d+(?:-\d+)?$/, '');
147
+ token = token.replace(/\\/g, '/').replace(/^\.\//, '');
148
+ if (token)
149
+ paths.add(token);
150
+ }
151
+ const symbols = new Set();
152
+ for (const m of content.matchAll(CHURN_SYMBOL_RE)) {
153
+ const name = m[1];
154
+ // Plain words never count: require a camelCase/PascalCase transition or an inner underscore.
155
+ if (name.length >= 6 && (/[a-z][A-Z]/.test(name) || /[A-Za-z0-9]_[A-Za-z0-9]/.test(name))) {
156
+ symbols.add(name);
157
+ }
158
+ }
159
+ const scripts = new Set();
160
+ for (const m of content.matchAll(CHURN_SCRIPT_RE))
161
+ scripts.add(m[1]);
162
+ return { paths: [...paths], symbols: [...symbols], scripts: [...scripts] };
163
+ }
164
+ const IS_WIN32 = process.platform === 'win32';
165
+ function buildTrackedIndex(paths) {
166
+ const lower = new Map();
167
+ for (const p of paths)
168
+ lower.set(p.toLowerCase(), p);
169
+ return { exact: paths, lower };
170
+ }
171
+ // Returns git's spelling so later exact matches against the log agree on case-insensitive Windows.
172
+ function trackedSpelling(index, candidate) {
173
+ if (index.exact.has(candidate))
174
+ return candidate;
175
+ return IS_WIN32 ? index.lower.get(candidate.toLowerCase()) ?? null : null;
176
+ }
177
+ function isTrackedPath(index, candidate) {
178
+ return trackedSpelling(index, candidate) !== null;
179
+ }
180
+ // Absolute-path relativization and the <repoName>/ strip both need the repo, so they
181
+ // happen here rather than in the pure extractor.
182
+ function resolveTrackedPath(token, repoRoot, projectName, index) {
183
+ let candidate = token;
184
+ const isAbsUnix = candidate.startsWith('/');
185
+ const isAbsWin = /^[A-Za-z]:\//.test(candidate);
186
+ if (isAbsUnix || isAbsWin) {
187
+ const rootPosix = repoRoot.replace(/\\/g, '/').replace(/\/$/, '');
188
+ const prefix = `${rootPosix}/`;
189
+ const under = IS_WIN32
190
+ ? candidate.toLowerCase().startsWith(prefix.toLowerCase())
191
+ : candidate.startsWith(prefix);
192
+ if (!under)
193
+ return null;
194
+ candidate = candidate.slice(prefix.length);
195
+ }
196
+ const direct = trackedSpelling(index, candidate);
197
+ if (direct)
198
+ return direct;
199
+ const repoPrefix = `${projectName}/`;
200
+ if (candidate.toLowerCase().startsWith(repoPrefix.toLowerCase())) {
201
+ return trackedSpelling(index, candidate.slice(repoPrefix.length));
202
+ }
203
+ return null;
204
+ }
205
+ // Anchor SQL runs on its own handle (closed in finally); queryAuditEvents is NOT
206
+ // reused here because its 10k-row cap would silently miss old confirmations.
207
+ function queryConfirmedAt(hippoRoot, tenantId) {
208
+ const db = openHippoDb(hippoRoot);
209
+ try {
210
+ // SAFETY: the SELECT list above is exactly target_id and ts; no other shape reaches this cast.
211
+ const rows = db.prepare(`SELECT target_id, MAX(ts) AS ts FROM audit_log
212
+ WHERE tenant_id = ? AND op = 'outcome' AND target_id IS NOT NULL
213
+ AND json_extract(metadata_json, '$.good') = 1
214
+ GROUP BY target_id`).all(tenantId);
215
+ return new Map(rows.map((r) => [r.target_id, r.ts]));
216
+ }
217
+ finally {
218
+ closeHippoDb(db);
219
+ }
220
+ }
221
+ function emptyChurnResult(dryRun, skippedPinned = [], error) {
222
+ const result = { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
223
+ if (error !== undefined)
224
+ result.error = error;
225
+ return result;
226
+ }
227
+ /** Flags memories whose named file/symbol/script changed or disappeared since storage (or last confirmation); only adds/removes CHURN_STALE_TAG, never confidence/half-life/strength. */
228
+ export function detectChurnStale(hippoRoot, repoRoot, opts) {
229
+ const dryRun = opts.dryRun === true;
230
+ if (!opts.projectName)
231
+ return emptyChurnResult(dryRun);
232
+ const entries = loadAllEntries(hippoRoot, opts.tenantId);
233
+ const skippedPinned = [];
234
+ const candidates = [];
235
+ for (const entry of entries) {
236
+ if (!entry.origin_project || entry.origin_project !== opts.projectName)
237
+ continue;
238
+ if (entry.superseded_by)
239
+ continue;
240
+ if (entry.kind === 'raw' || entry.kind === 'archived')
241
+ continue;
242
+ if (entry.pinned) {
243
+ skippedPinned.push(entry.id);
244
+ continue;
245
+ }
246
+ // An unparsable created has no anchor to compare commits against.
247
+ if (Number.isNaN(Date.parse(entry.created)))
248
+ continue;
249
+ candidates.push({ entry, refs: extractChurnRefs(entry.content) });
250
+ }
251
+ if (candidates.length === 0)
252
+ return emptyChurnResult(dryRun, skippedPinned);
253
+ const confirmedAt = queryConfirmedAt(hippoRoot, opts.tenantId);
254
+ // Compared as epoch ms, not strings: imported rows may carry offsets or other ISO forms.
255
+ const anchorOf = (entry) => {
256
+ const confirmed = confirmedAt.get(entry.id);
257
+ const confirmedMs = confirmed ? Date.parse(confirmed) : NaN;
258
+ return confirmedMs > Date.parse(entry.created) ? new Date(confirmedMs).toISOString() : new Date(entry.created).toISOString();
259
+ };
260
+ const needsPaths = candidates.some((c) => c.refs.paths.length > 0);
261
+ const needsSymbols = candidates.some((c) => c.refs.symbols.length > 0);
262
+ const needsScripts = candidates.some((c) => c.refs.scripts.length > 0);
263
+ if (!needsPaths && !needsSymbols && !needsScripts) {
264
+ return { checked: candidates.length, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
265
+ }
266
+ const result = { checked: 0, marked: 0, alreadyMarked: 0, skippedPinned, dryRun, preview: [] };
267
+ try {
268
+ const headFiles = gitLsFilesAtHead(repoRoot);
269
+ let windowLog = [];
270
+ if (needsPaths) {
271
+ const minAnchor = candidates.reduce((min, c) => {
272
+ const a = anchorOf(c.entry);
273
+ return min === '' || a < min ? a : min;
274
+ }, '');
275
+ windowLog = fetchChurnWindowLog(repoRoot, minAnchor);
276
+ }
277
+ const touchedInWindow = new Set();
278
+ for (const c of windowLog)
279
+ for (const f of c.files)
280
+ touchedInWindow.add(f.path);
281
+ const trackedIndex = buildTrackedIndex(new Set([...headFiles, ...touchedInWindow]));
282
+ const headIndex = buildTrackedIndex(headFiles);
283
+ const presentAtHeadSymbols = needsSymbols
284
+ ? gitGrepPresence(repoRoot, [...new Set(candidates.flatMap((c) => c.refs.symbols))], 'HEAD')
285
+ : new Set();
286
+ const anchorCommitCache = new Map();
287
+ const resolveAnchorCommit = (anchorIso) => {
288
+ if (!anchorCommitCache.has(anchorIso)) {
289
+ anchorCommitCache.set(anchorIso, resolveCommitBefore(repoRoot, anchorIso));
290
+ }
291
+ return anchorCommitCache.get(anchorIso) ?? null;
292
+ };
293
+ const symbolsAtCommit = new Map();
294
+ const symbolsPresentAt = (hash, symbols) => {
295
+ const known = symbolsAtCommit.get(hash) ?? new Set();
296
+ const missing = symbols.filter((s) => !known.has(s));
297
+ if (missing.length > 0) {
298
+ for (const s of gitGrepPresence(repoRoot, missing, hash))
299
+ known.add(s);
300
+ symbolsAtCommit.set(hash, known);
301
+ }
302
+ return known;
303
+ };
304
+ const headScripts = needsScripts ? packageScriptsAt(repoRoot, 'HEAD') : null;
305
+ const scriptsAtCommit = new Map();
306
+ // Collected here, written only after every candidate's evidence is
307
+ // computed: a GitReadError thrown mid-loop must never leave an earlier
308
+ // candidate tagged while a later one aborts the run untagged.
309
+ const toTag = [];
310
+ for (const { entry, refs } of candidates) {
311
+ result.checked++;
312
+ const anchor = anchorOf(entry);
313
+ const anchorTime = new Date(anchor).getTime();
314
+ let evidence = null;
315
+ for (const rawPath of refs.paths) {
316
+ const resolved = resolveTrackedPath(rawPath, repoRoot, opts.projectName, trackedIndex);
317
+ if (!resolved)
318
+ continue;
319
+ if (isTrackedPath(headIndex, resolved)) {
320
+ const changed = windowLog.some((c) => new Date(c.date).getTime() > anchorTime && c.files.some((f) => f.path === resolved));
321
+ if (changed) {
322
+ evidence = `file-changed: ${resolved}`;
323
+ break;
324
+ }
325
+ }
326
+ else {
327
+ // --no-renames means a rename shows as a D + A pair, so this also fires for renames.
328
+ const deleted = windowLog.some((c) => new Date(c.date).getTime() > anchorTime &&
329
+ c.files.some((f) => f.status === 'D' && f.path === resolved));
330
+ if (deleted) {
331
+ evidence = `file-deleted: ${resolved}`;
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ if (!evidence && refs.symbols.length > 0) {
337
+ const absent = refs.symbols.filter((s) => !presentAtHeadSymbols.has(s));
338
+ if (absent.length > 0) {
339
+ const anchorCommit = resolveAnchorCommit(anchor);
340
+ if (anchorCommit) {
341
+ const presentAtAnchor = symbolsPresentAt(anchorCommit, absent);
342
+ const hit = absent.find((s) => presentAtAnchor.has(s));
343
+ if (hit)
344
+ evidence = `symbol-gone: \`${hit}\``;
345
+ }
346
+ }
347
+ }
348
+ if (!evidence && refs.scripts.length > 0 && headScripts !== null) {
349
+ const anchorCommit = resolveAnchorCommit(anchor);
350
+ if (anchorCommit) {
351
+ if (!scriptsAtCommit.has(anchorCommit)) {
352
+ scriptsAtCommit.set(anchorCommit, packageScriptsAt(repoRoot, anchorCommit));
353
+ }
354
+ const anchorScripts = scriptsAtCommit.get(anchorCommit);
355
+ const hit = anchorScripts
356
+ ? refs.scripts.find((s) => anchorScripts[s] !== undefined && headScripts[s] === undefined)
357
+ : undefined;
358
+ if (hit)
359
+ evidence = `script-gone: ${hit}`;
360
+ }
361
+ }
362
+ if (!evidence)
363
+ continue;
364
+ const headline = entry.content.replace(/\s+/g, ' ').slice(0, 60);
365
+ if (entry.tags.includes(CHURN_STALE_TAG)) {
366
+ result.alreadyMarked++;
367
+ result.preview.push({ id: entry.id, headline, evidence, already: true });
368
+ continue;
369
+ }
370
+ result.marked++;
371
+ result.preview.push({ id: entry.id, headline, evidence, already: false });
372
+ toTag.push(entry);
373
+ }
374
+ if (!dryRun && toTag.length > 0) {
375
+ // The git calls above can take seconds; a good outcome landing meanwhile moves the anchor past the evidence.
376
+ const confirmedNow = queryConfirmedAt(hippoRoot, opts.tenantId);
377
+ for (const stale of toTag) {
378
+ if (confirmedNow.get(stale.id) !== confirmedAt.get(stale.id))
379
+ continue;
380
+ const entry = readEntry(hippoRoot, stale.id, opts.tenantId);
381
+ if (!entry || entry.tags.includes(CHURN_STALE_TAG))
382
+ continue;
383
+ writeEntry(hippoRoot, { ...entry, tags: [...entry.tags, CHURN_STALE_TAG] });
384
+ }
385
+ }
386
+ }
387
+ catch (err) {
388
+ if (err instanceof GitReadError)
389
+ return emptyChurnResult(dryRun, skippedPinned, err.message);
390
+ throw err;
391
+ }
392
+ return result;
393
+ }
129
394
  //# sourceMappingURL=invalidation.js.map
package/dist/memory.d.ts CHANGED
@@ -120,6 +120,8 @@ export declare const POLICY_HALF_LIFE_DAYS = 90;
120
120
  export declare const SKILL_HALF_LIFE_DAYS = 90;
121
121
  export declare const PROJECT_BRIEF_HALF_LIFE_DAYS = 90;
122
122
  export declare const CUSTOMER_NOTE_HALF_LIFE_DAYS = 90;
123
+ /** FE2: tag on a memory whose named file/symbol/script changed after it was stored. */
124
+ export declare const CHURN_STALE_TAG = "churn-stale";
123
125
  /**
124
126
  * Test-only helper. Tests that mutate `process.env.HIPPO_LOSS_AVERSION_RATIO`
125
127
  * MUST call this in BOTH `beforeEach` AND `afterEach`:
package/dist/memory.js CHANGED
@@ -18,6 +18,8 @@ export const POLICY_HALF_LIFE_DAYS = 90;
18
18
  export const SKILL_HALF_LIFE_DAYS = 90;
19
19
  export const PROJECT_BRIEF_HALF_LIFE_DAYS = 90;
20
20
  export const CUSTOMER_NOTE_HALF_LIFE_DAYS = 90;
21
+ /** FE2: tag on a memory whose named file/symbol/script changed after it was stored. */
22
+ export const CHURN_STALE_TAG = 'churn-stale';
21
23
  // Emotional multipliers from PLAN.md.
22
24
  //
23
25
  // v1.13.5 / J5 loss-aversion calibration (Lovallo-Kahneman TFAS empirics:
package/dist/search.d.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  import { estimateTokens } from './token-ledger.js';
6
6
  import { MemoryEntry } from './memory.js';
7
7
  import type { PhysicsConfig } from './physics-config.js';
8
+ export declare const CHURN_STALE_RANK_MULTIPLIER = 0.5;
9
+ export declare function churnStaleFactor(entry: MemoryEntry): number;
8
10
  export declare function tokenize(text: string): string[];
9
11
  /**
10
12
  * Tokenized BM25 corpus. Callers can pre-build this with `buildCorpus` once
@@ -119,6 +121,8 @@ export interface ScoreBreakdown {
119
121
  * to [0.85, 1.15]. Immediate nudge from `hippo outcome --good/--bad`.
120
122
  * Separate from the slow strength-via-reward-factor path. */
121
123
  outcomeBoost: number;
124
+ /** FE2: CHURN_STALE_RANK_MULTIPLIER if tagged 'churn-stale', else 1.0. */
125
+ churnStaleMultiplier: number;
122
126
  /** Pre-MMR rank (1-indexed). Only set when MMR re-ranking ran. */
123
127
  preMmrRank?: number;
124
128
  /** Post-MMR rank (1-indexed). Only set when MMR re-ranking ran. */
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/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.50.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.50.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.50.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.50.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.50.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.50.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",