Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions agent-memory-mesh/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
};
}
57 changes: 57 additions & 0 deletions agent-memory-mesh/src/memory/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export class LruCache<T> {
private map: Map<string, { value: T; expiresAt: number }> = 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 ?? ""}`;
}
69 changes: 69 additions & 0 deletions agent-memory-mesh/src/memory/scoring.ts
Original file line number Diff line number Diff line change
@@ -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<string, ChunkScore> = 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;
}
}
55 changes: 52 additions & 3 deletions agent-memory-mesh/src/service/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +43,8 @@ export class MemoryEngine {
private graph: ContextGraph;
private policies: RetrievalPolicyStore;
private feedback: FeedbackStore;
private scoring: ScoringStore;
private searchCache: LruCache<RetrievalHit[]>;
private opened = false;

constructor(private cfg: MemoryConfig) {
Expand All @@ -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<RetrievalHit[]>(cfg.searchCacheSize, cfg.searchCacheTtlMs);
}

/** Open the store lazily, sizing the table from a probe embedding the first time. */
Expand All @@ -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<RetrievalHit[]> {
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. */
Expand Down Expand Up @@ -179,19 +198,29 @@ 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",
overrides?: Partial<Pick<RetrievalPolicy, "k" | "filter">>
): Promise<PolicySearchResult> {
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 };
}
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions agent-memory-mesh/src/service/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
15 changes: 15 additions & 0 deletions agent-memory-mesh/src/service/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
50 changes: 50 additions & 0 deletions agent-memory-mesh/test/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { LruCache, cacheKey } from "../src/memory/cache.js";

export async function runCacheTests() {
// Basic get/set
const cache = new LruCache<string>(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<string>(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<number>(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<string>(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");
}
Loading