diff --git a/agent-memory-mesh/src/config.ts b/agent-memory-mesh/src/config.ts index 9c507dc..e59a726 100644 --- a/agent-memory-mesh/src/config.ts +++ b/agent-memory-mesh/src/config.ts @@ -32,6 +32,26 @@ export interface MemoryConfig { * Leave empty for rule-based synthesis (no LLM required). */ consolidationModel: string; + /** JSON file path for per-note access scores and decay tracking. */ + scoringPath: string; + /** Enable exponential time-decay on memory chunk scores. */ + decayEnabled: boolean; + /** Half-life in days for the decay formula (score halves every N days). */ + decayHalfLifeDays: number; + /** Enable LRU in-memory cache for search results. */ + searchCacheEnabled: boolean; + /** Maximum number of search results to cache. */ + searchCacheSize: number; + /** Cache TTL in milliseconds. */ + searchCacheTtlMs: number; + /** Directory for memory state snapshots. */ + snapshotsDir: string; + /** JSON file path for provenance records. */ + provenancePath: string; + /** JSON file path for hook rules and alerts. */ + hooksStatePath: string; + /** JSON file path for remote node registry. */ + nodeRegistryPath: string; } export function loadConfig(): MemoryConfig { @@ -50,5 +70,15 @@ export function loadConfig(): MemoryConfig { port: Number(env("MEMORY_PORT", "8377")), apiKey: env("MEMORY_API_KEY", ""), consolidationModel: env("CONSOLIDATION_MODEL", ""), + scoringPath: env("SCORING_PATH") || join(base, "scoring.json"), + decayEnabled: env("DECAY_ENABLED") === "true", + decayHalfLifeDays: Number(env("DECAY_HALF_LIFE_DAYS", "30")), + searchCacheEnabled: env("SEARCH_CACHE_ENABLED") !== "false", + searchCacheSize: Number(env("SEARCH_CACHE_SIZE", "100")), + searchCacheTtlMs: Number(env("SEARCH_CACHE_TTL_MS", "60000")), + snapshotsDir: env("SNAPSHOTS_DIR") || join(base, "snapshots"), + provenancePath: env("PROVENANCE_PATH") || join(base, "provenance.json"), + hooksStatePath: env("HOOKS_STATE_PATH") || join(base, "hooks.json"), + nodeRegistryPath: env("NODE_REGISTRY_PATH") || join(base, "nodes.json"), }; } diff --git a/agent-memory-mesh/src/memory/cache.ts b/agent-memory-mesh/src/memory/cache.ts new file mode 100644 index 0000000..e11cbad --- /dev/null +++ b/agent-memory-mesh/src/memory/cache.ts @@ -0,0 +1,57 @@ +export class LruCache { + private map: Map = new Map(); + private _hits = 0; + private _misses = 0; + + constructor(private maxSize: number, private ttlMs: number) {} + + get(key: string): T | undefined { + const entry = this.map.get(key); + if (!entry) { + this._misses++; + return undefined; + } + if (Date.now() > entry.expiresAt) { + this.map.delete(key); + this._misses++; + return undefined; + } + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, entry); + this._hits++; + return entry.value; + } + + set(key: string, value: T): void { + if (this.map.has(key)) this.map.delete(key); + else if (this.map.size >= this.maxSize) { + // Evict least recently used (first entry in insertion order) + const lruKey = this.map.keys().next().value; + if (lruKey !== undefined) this.map.delete(lruKey); + } + this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + delete(key: string): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } + + stats() { + return { + size: this.map.size, + maxSize: this.maxSize, + ttlMs: this.ttlMs, + hits: this._hits, + misses: this._misses, + }; + } +} + +export function cacheKey(query: string, k: number, filter?: string, policy?: string): string { + return `${query}|${k}|${filter ?? ""}|${policy ?? ""}`; +} diff --git a/agent-memory-mesh/src/memory/scoring.ts b/agent-memory-mesh/src/memory/scoring.ts new file mode 100644 index 0000000..8964c94 --- /dev/null +++ b/agent-memory-mesh/src/memory/scoring.ts @@ -0,0 +1,69 @@ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import type { RetrievalHit } from "./store.js"; + +export interface ChunkScore { + notePath: string; + score: number; + accessCount: number; + lastAccessedAt: string; + createdAt: string; +} + +export interface ScoringConfig { + decayEnabled: boolean; + decayHalfLifeDays: number; + minScore: number; +} + +export class ScoringStore { + private scores: Map = new Map(); + + constructor(private filePath: string) { + if (existsSync(filePath)) { + const data = JSON.parse(readFileSync(filePath, "utf8")) as ChunkScore[]; + for (const s of data) this.scores.set(s.notePath, s); + } + } + + private save(): void { + writeFileSync(this.filePath, JSON.stringify([...this.scores.values()], null, 2)); + } + + recordAccess(notePath: string): ChunkScore { + const now = new Date().toISOString(); + const existing = this.scores.get(notePath); + const entry: ChunkScore = { + notePath, + score: 1.0, + accessCount: (existing?.accessCount ?? 0) + 1, + lastAccessedAt: now, + createdAt: existing?.createdAt ?? now, + }; + this.scores.set(notePath, entry); + this.save(); + return entry; + } + + getDecayedScore(notePath: string, cfg: ScoringConfig): number { + if (!cfg.decayEnabled) return 1.0; + const entry = this.scores.get(notePath); + if (!entry) return 1.0; + const daysSince = (Date.now() - new Date(entry.lastAccessedAt).getTime()) / 86400000; + const decayed = Math.pow(0.5, daysSince / cfg.decayHalfLifeDays); + return Math.max(cfg.minScore, Math.min(1.0, decayed)); + } + + listScores(): ChunkScore[] { + return [...this.scores.values()]; + } + + applyDecayScores(hits: RetrievalHit[], cfg: ScoringConfig): RetrievalHit[] { + if (!cfg.decayEnabled) return hits; + const adjusted = hits.map((h) => ({ + ...h, + score: h.score * this.getDecayedScore(h.chunk.notePath, cfg), + })); + adjusted.sort((a, b) => b.score - a.score); + return adjusted; + } +} diff --git a/agent-memory-mesh/src/service/engine.ts b/agent-memory-mesh/src/service/engine.ts index 3e39b20..bbe9694 100644 --- a/agent-memory-mesh/src/service/engine.ts +++ b/agent-memory-mesh/src/service/engine.ts @@ -17,8 +17,10 @@ import { Consolidator, type ConsolidationResult } from "../memory/consolidator.j import { ContextGraph, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult } from "../memory/context-graph.js"; import { RetrievalPolicyStore, applyRecencyBoost, type RetrievalPolicy } from "../memory/retrieval-policy.js"; import { FeedbackStore, applyFeedbackScoring, type FeedbackSignal, type NoteFeedback } from "../memory/feedback.js"; +import { ScoringStore, type ChunkScore } from "../memory/scoring.js"; +import { LruCache, cacheKey } from "../memory/cache.js"; -export { type WorkMemoryEntry, type WorkMemoryQuery, type ConsolidationResult, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult, type RetrievalPolicy, type FeedbackSignal, type NoteFeedback }; +export { type WorkMemoryEntry, type WorkMemoryQuery, type ConsolidationResult, type ContextGraphEntity, type ContextGraphEdge, type EntityType, type NeighborResult, type RetrievalPolicy, type FeedbackSignal, type NoteFeedback, type ChunkScore }; export interface WikiSummary { entityId: string; @@ -41,6 +43,8 @@ export class MemoryEngine { private graph: ContextGraph; private policies: RetrievalPolicyStore; private feedback: FeedbackStore; + private scoring: ScoringStore; + private searchCache: LruCache; private opened = false; constructor(private cfg: MemoryConfig) { @@ -58,6 +62,8 @@ export class MemoryEngine { }); this.policies = new RetrievalPolicyStore(cfg.policiesPath); this.feedback = new FeedbackStore(cfg.feedbackPath); + this.scoring = new ScoringStore(cfg.scoringPath); + this.searchCache = new LruCache(cfg.searchCacheSize, cfg.searchCacheTtlMs); } /** Open the store lazily, sizing the table from a probe embedding the first time. */ @@ -68,11 +74,24 @@ export class MemoryEngine { this.opened = true; } + private get scoringCfg() { + return { decayEnabled: this.cfg.decayEnabled, decayHalfLifeDays: this.cfg.decayHalfLifeDays, minScore: 0.05 }; + } + /** Hybrid (vector + keyword) search. Optional metadata filter, e.g. type/tags. */ async search(query: string, k = 8, filter?: string): Promise { + const key = cacheKey(query, k, filter); + if (this.cfg.searchCacheEnabled) { + const cached = this.searchCache.get(key); + if (cached) return cached; + } await this.ensureOpen(); const qvec = await this.embedder.embed(query); - return this.store.retrieve(query, qvec, k, filter); + let hits = await this.store.retrieve(query, qvec, k, filter); + hits.forEach((h) => this.scoring.recordAccess(h.chunk.notePath)); + hits = this.scoring.applyDecayScores(hits, this.scoringCfg); + if (this.cfg.searchCacheEnabled) this.searchCache.set(key, hits); + return hits; } /** Rebuild the index from the vault. */ @@ -179,7 +198,7 @@ export class MemoryEngine { return this.policies.delete(name); } - /** Search using a named policy (or "default"). Applies recency boost + wiki preload. */ + /** Search using a named policy (or "default"). Applies recency boost + feedback scoring + decay. */ async searchWithPolicy( query: string, policyName = "default", @@ -187,11 +206,21 @@ export class MemoryEngine { ): Promise { const base = this.policies.get(policyName) ?? this.policies.get("default")!; const policy: RetrievalPolicy = { ...base, ...overrides }; + const key = cacheKey(query, policy.k, policy.filter, policyName); + if (this.cfg.searchCacheEnabled) { + const cached = this.searchCache.get(key); + if (cached) { + const wikis = policy.includeWiki ? this.collectWikis(query, policy.wikiEntityTypes) : []; + return { hits: cached, wikis, policy }; + } + } await this.ensureOpen(); const qvec = await this.embedder.embed(query); let hits = await this.store.retrieve(query, qvec, policy.k, policy.filter); if (policy.boostRecent) hits = applyRecencyBoost(hits, policy.boostRecentFactor); hits = applyFeedbackScoring(hits, this.feedback); + hits = this.scoring.applyDecayScores(hits, this.scoringCfg); + if (this.cfg.searchCacheEnabled) this.searchCache.set(key, hits); const wikis = policy.includeWiki ? this.collectWikis(query, policy.wikiEntityTypes) : []; return { hits, wikis, policy }; } @@ -267,6 +296,26 @@ export class MemoryEngine { return { processed: processedIds.length, signals }; } + // Scoring & decay + + getChunkScore(notePath: string): number { + return this.scoring.getDecayedScore(notePath, this.scoringCfg); + } + + listChunkScores(): ChunkScore[] { + return this.scoring.listScores(); + } + + // Search cache + + clearSearchCache(): void { + this.searchCache.clear(); + } + + searchCacheStats() { + return this.searchCache.stats(); + } + private collectWikis(query: string, types?: EntityType[]): WikiSummary[] { const matches = this.graph.findByName(query); const candidates = types ? matches.filter((e) => types.includes(e.type)) : matches; diff --git a/agent-memory-mesh/src/service/http.ts b/agent-memory-mesh/src/service/http.ts index f1750c1..cdfccba 100644 --- a/agent-memory-mesh/src/service/http.ts +++ b/agent-memory-mesh/src/service/http.ts @@ -279,6 +279,28 @@ export function startHttp( return send(res, 200, { entity }); } + // Scoring & decay endpoints + if (req.method === "GET" && url.pathname === "/scoring/scores") { + const notePath = url.searchParams.get("notePath"); + if (notePath) return send(res, 200, { score: engine.getChunkScore(notePath) }); + return send(res, 200, { scores: engine.listChunkScores() }); + } + if (req.method === "GET" && url.pathname === "/scoring/summary") { + const scores = engine.listChunkScores(); + const avg = scores.length ? scores.reduce((s, c) => s + c.score, 0) / scores.length : 0; + const below50 = scores.filter((c) => c.score < 0.5).length; + return send(res, 200, { totalNotes: scores.length, avgScore: avg, decayedBelow50Pct: below50 }); + } + + // Search cache endpoints + if (req.method === "DELETE" && url.pathname === "/cache") { + engine.clearSearchCache(); + return send(res, 200, { ok: true }); + } + if (req.method === "GET" && url.pathname === "/cache/stats") { + return send(res, 200, engine.searchCacheStats()); + } + return send(res, 404, { error: "not found" }); } catch (err) { return send(res, 500, { error: String(err) }); diff --git a/agent-memory-mesh/src/service/mcp.ts b/agent-memory-mesh/src/service/mcp.ts index 6560f08..5d91a1f 100644 --- a/agent-memory-mesh/src/service/mcp.ts +++ b/agent-memory-mesh/src/service/mcp.ts @@ -359,6 +359,21 @@ export async function startMcpStdio(cfg: MemoryConfig, engine: MemoryEngine): Pr } ); + server.registerTool( + "get_chunk_score", + { + title: "Get chunk decay score", + description: "Get the time-decay relevance score for a vault note. Returns 1.0 if decay is disabled or note has never been accessed.", + inputSchema: { + notePath: z.string().describe("Vault-relative note path."), + }, + }, + async ({ notePath }) => { + const score = engine.getChunkScore(notePath); + return { content: [{ type: "text", text: `Decay score for ${notePath}: ${score.toFixed(4)}` }] }; + } + ); + const transport = new StdioServerTransport(); await server.connect(transport); console.error("[mcp] agent-memory-mesh stdio server ready"); diff --git a/agent-memory-mesh/test/cache.test.ts b/agent-memory-mesh/test/cache.test.ts new file mode 100644 index 0000000..f39ebd2 --- /dev/null +++ b/agent-memory-mesh/test/cache.test.ts @@ -0,0 +1,50 @@ +import { LruCache, cacheKey } from "../src/memory/cache.js"; + +export async function runCacheTests() { + // Basic get/set + const cache = new LruCache(3, 60000); + cache.set("a", "alpha"); + console.assert(cache.get("a") === "alpha", "cache hit"); + console.assert(cache.get("z") === undefined, "cache miss for unknown key"); + + // TTL expiry + const shortCache = new LruCache(10, 1); + shortCache.set("x", "xval"); + await new Promise((r) => setTimeout(r, 10)); + console.assert(shortCache.get("x") === undefined, "expired entry returns undefined"); + + // LRU eviction: size-3 cache, touch "1" so "2" becomes LRU, then "4" evicts "2" + const lru = new LruCache(3, 60000); + lru.set("1", 1); + lru.set("2", 2); + lru.set("3", 3); + lru.get("1"); // promote "1" + lru.set("4", 4); // evicts "2" (LRU) + console.assert(lru.get("2") === undefined, "LRU entry evicted"); + console.assert(lru.get("1") !== undefined, "touched entry still present"); + console.assert(lru.get("3") !== undefined, "non-LRU entry still present"); + console.assert(lru.get("4") !== undefined, "newly added entry present"); + + // Stats + const stats = lru.stats(); + console.assert(stats.maxSize === 3, "maxSize correct"); + console.assert(stats.hits > 0, "hits > 0"); + console.assert(stats.misses > 0, "misses > 0"); + + // clear + lru.clear(); + console.assert(lru.stats().size === 0, "cleared"); + + // delete + const d = new LruCache(5, 60000); + d.set("k", "v"); + d.delete("k"); + console.assert(d.get("k") === undefined, "deleted key gone"); + + // cacheKey + const k = cacheKey("hello world", 8, "type=doc", "default"); + console.assert(k.includes("hello world"), "cacheKey includes query"); + console.assert(cacheKey("q", 5) !== cacheKey("q", 8), "different k produces different key"); + + console.log("[cache] All tests passed"); +} diff --git a/agent-memory-mesh/test/run-tests.ts b/agent-memory-mesh/test/run-tests.ts index d1ca5c2..0d402c4 100644 --- a/agent-memory-mesh/test/run-tests.ts +++ b/agent-memory-mesh/test/run-tests.ts @@ -5,6 +5,8 @@ import { runConsolidatorTests } from "./consolidator.test.js"; import { runContextGraphTests } from "./context-graph.test.js"; import { runRetrievalPolicyTests } from "./retrieval-policy.test.js"; import { runFeedbackTests } from "./feedback.test.js"; +import { runScoringTests } from "./scoring.test.js"; +import { runCacheTests } from "./cache.test.js"; async function main() { let exitCode = 0; @@ -14,6 +16,8 @@ async function main() { { name: "ContextGraph", fn: runContextGraphTests }, { name: "RetrievalPolicy", fn: runRetrievalPolicyTests }, { name: "Feedback", fn: runFeedbackTests }, + { name: "Scoring", fn: runScoringTests }, + { name: "Cache", fn: runCacheTests }, ]; for (const suite of suites) { diff --git a/agent-memory-mesh/test/scoring.test.ts b/agent-memory-mesh/test/scoring.test.ts new file mode 100644 index 0000000..1e1718c --- /dev/null +++ b/agent-memory-mesh/test/scoring.test.ts @@ -0,0 +1,66 @@ +import { ScoringStore } from "../src/memory/scoring.js"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +export async function runScoringTests() { + const file = join(tmpdir(), `scoring-test-${randomUUID()}.json`); + const store = new ScoringStore(file); + const cfg = { decayEnabled: true, decayHalfLifeDays: 30, minScore: 0.05 }; + + const entry = store.recordAccess("notes/foo.md"); + console.assert(entry.accessCount === 1, "accessCount should be 1"); + console.assert(entry.score === 1.0, "score should be 1.0 after access"); + + const entry2 = store.recordAccess("notes/foo.md"); + console.assert(entry2.accessCount === 2, "accessCount should be 2"); + + const score = store.getDecayedScore("notes/foo.md", cfg); + console.assert(score >= 0.99, `Score should be ~1.0 immediately, got ${score}`); + + console.assert(store.getDecayedScore("notes/unseen.md", cfg) === 1.0, "unseen note = 1.0"); + console.assert( + store.getDecayedScore("notes/foo.md", { ...cfg, decayEnabled: false }) === 1.0, + "disabled decay = 1.0" + ); + + // Test applyDecayScores: stale note with 90-day-old lastAccessedAt should sink + const staleFile = join(tmpdir(), `scoring-stale-${randomUUID()}.json`); + const staleDate = new Date(Date.now() - 90 * 86400000).toISOString(); + writeFileSync( + staleFile, + JSON.stringify([ + { + notePath: "notes/stale.md", + score: 1.0, + accessCount: 1, + lastAccessedAt: staleDate, + createdAt: staleDate, + }, + ]) + ); + const staleStore = new ScoringStore(staleFile); + const hits = [ + { chunk: { notePath: "notes/stale.md", text: "old", source: "vault" }, score: 0.9 }, + { chunk: { notePath: "notes/fresh.md", text: "new", source: "vault" }, score: 0.5 }, + ]; + const sorted = staleStore.applyDecayScores(hits as any, cfg); + console.assert( + sorted[0].chunk.notePath === "notes/fresh.md", + `Fresh note should win after decay, got ${sorted[0].chunk.notePath}` + ); + + // Decay disabled: order unchanged + const unsorted = staleStore.applyDecayScores(hits as any, { ...cfg, decayEnabled: false }); + console.assert( + unsorted[0].chunk.notePath === "notes/stale.md", + "Decay disabled: original order preserved" + ); + + // Persistence across reload + const store2 = new ScoringStore(file); + console.assert(store2.listScores().length > 0, "Scores persist across reload"); + + console.log("[scoring] All tests passed"); +}