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
177 changes: 166 additions & 11 deletions packages/agent-memory/src/MemoryStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const EVICTION_SCAN_LIMIT = 10000;
const CONSOLIDATE_SCAN_LIMIT = 10000;
const DEFAULT_SUMMARY_IMPORTANCE = 0.7;
const SUMMARY_SOURCE = 'summary';
const DEFAULT_CONFIG_REFRESH_MS = 30000;
const MIN_CONFIG_REFRESH_MS = 1000;
const MAX_DISTANCE = 2;

// Read lazily so only discovery users pay the disk read on import (and avoid a
// bundler hazard, since package.json is not always emitted).
Expand All @@ -45,6 +48,18 @@ export interface MemoryDiscoveryConfig {
heartbeatIntervalMs?: number;
}

export interface MemoryConfigRefreshConfig {
enabled?: boolean;
intervalMs?: number;
}

export interface MemoryConfigSnapshot {
threshold: number;
weights: RecallWeights;
halfLifeSeconds: number;
maxItemsPerScope?: number;
}

export interface MemoryStoreOptions {
client: MemoryStoreClient;
name: string;
Expand All @@ -54,16 +69,23 @@ export interface MemoryStoreOptions {
halfLifeSeconds?: number;
maxItemsPerScope?: number;
discovery?: boolean | MemoryDiscoveryConfig;
configRefresh?: boolean | MemoryConfigRefreshConfig;
}

export class MemoryStore {
private readonly client: MemoryStoreClient;
private readonly name: string;
private readonly embedFn: EmbedFn;
private readonly defaultThreshold: number;
private readonly weights: RecallWeights;
private readonly halfLifeSeconds: number;
private readonly maxItemsPerScope?: number;
private defaultThreshold: number;
private weights: RecallWeights;
private halfLifeSeconds: number;
private maxItemsPerScope?: number;
private readonly initialThreshold: number;
private readonly initialWeights: RecallWeights;
private readonly initialHalfLifeSeconds: number;
private readonly initialMaxItemsPerScope?: number;
private readonly configKey: string;
private configRefreshHandle: ReturnType<typeof setInterval> | null = null;
private readonly discovery: MemoryDiscovery | null;
private discoveryReady: Promise<void> | null = null;
private dims?: number;
Expand All @@ -72,11 +94,119 @@ export class MemoryStore {
this.client = options.client;
this.name = options.name;
this.embedFn = options.embedFn;
this.defaultThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD;
this.weights = options.weights ?? DEFAULT_WEIGHTS;
this.halfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS;
this.maxItemsPerScope = options.maxItemsPerScope;
this.initialThreshold = options.defaultThreshold ?? DEFAULT_THRESHOLD;
this.initialWeights = { ...(options.weights ?? DEFAULT_WEIGHTS) };
this.initialHalfLifeSeconds = options.halfLifeSeconds ?? DEFAULT_HALF_LIFE_SECONDS;
this.initialMaxItemsPerScope = options.maxItemsPerScope;
this.defaultThreshold = this.initialThreshold;
this.weights = { ...this.initialWeights };
this.halfLifeSeconds = this.initialHalfLifeSeconds;
this.maxItemsPerScope = this.initialMaxItemsPerScope;
this.configKey = `${this.name}:__mem_config`;
this.discovery = this.createDiscovery(options.discovery);
this.startConfigRefresh(options.configRefresh);
}

currentConfig(): MemoryConfigSnapshot {
return {
threshold: this.defaultThreshold,
weights: { ...this.weights },
halfLifeSeconds: this.halfLifeSeconds,
maxItemsPerScope: this.maxItemsPerScope,
};
}

async refreshConfig(): Promise<void> {
try {
const raw = await this.client.call('HGETALL', this.configKey);
this.applyConfig(parseHashReply(raw));
} catch {
// Best-effort: a failed refresh keeps the last-known config in place.
}
}

private startConfigRefresh(config?: boolean | MemoryConfigRefreshConfig): void {
if (!config) {
return;
}
const settings = config === true ? {} : config;
if (settings.enabled === false) {
return;
}
const intervalMs = Math.max(
MIN_CONFIG_REFRESH_MS,
settings.intervalMs ?? DEFAULT_CONFIG_REFRESH_MS,
);
void this.refreshConfig();
const handle = setInterval(() => {
void this.refreshConfig();
}, intervalMs);
handle.unref?.();
this.configRefreshHandle = handle;
}

private applyConfig(raw: Record<string, string>): void {
let threshold = this.initialThreshold;
// Weights are a partial update: if any component is in the config, start
// from the LIVE weights and overlay only what's present, so tuning one knob
// (the proposal engine's common case) doesn't reset the others. With no
// weight field at all, fall back to the constructor values like the rest.
const weightFieldPresent =
raw['recall.weights.similarity'] !== undefined ||
raw['recall.weights.recency'] !== undefined ||
raw['recall.weights.importance'] !== undefined;
const weights: RecallWeights = { ...(weightFieldPresent ? this.weights : this.initialWeights) };
let halfLifeSeconds = this.initialHalfLifeSeconds;
let maxItemsPerScope = this.initialMaxItemsPerScope;

for (const [field, value] of Object.entries(raw)) {
const num = Number(value);
if (!Number.isFinite(num)) {
continue;
}
switch (field) {
case 'recall.threshold':
if (num >= 0 && num <= MAX_DISTANCE) {
threshold = num;
}
break;
case 'recall.weights.similarity':
if (num >= 0) {
weights.similarity = num;
}
break;
case 'recall.weights.recency':
if (num >= 0) {
weights.recency = num;
}
break;
case 'recall.weights.importance':
if (num >= 0) {
weights.importance = num;
}
break;
case 'recall.halfLifeSeconds':
if (num > 0) {
halfLifeSeconds = num;
}
break;
case 'maxItemsPerScope':
if (num >= 1) {
maxItemsPerScope = Math.floor(num);
}
break;
default:
break;
}
}

this.defaultThreshold = threshold;
// An all-zero weight vector would make every composite score 0 and leave
// recall ordering undefined, so reject it and keep the configured weights.
const weightSum = weights.similarity + weights.recency + weights.importance;
this.weights = weightSum > 0 ? weights : { ...this.initialWeights };
this.halfLifeSeconds = halfLifeSeconds;
this.maxItemsPerScope = maxItemsPerScope;
}

private createDiscovery(config?: boolean | MemoryDiscoveryConfig): MemoryDiscovery | null {
Expand All @@ -102,6 +232,10 @@ export class MemoryStore {
}

async close(): Promise<void> {
if (this.configRefreshHandle) {
clearInterval(this.configRefreshHandle);
this.configRefreshHandle = null;
}
Comment thread
cursor[bot] marked this conversation as resolved.
if (this.discoveryReady) {
await this.discoveryReady.catch(() => undefined);
}
Expand All @@ -114,6 +248,9 @@ export class MemoryStore {
const k = options.k ?? DEFAULT_RECALL_K;
const threshold = options.threshold ?? this.defaultThreshold;
const weights = options.weights ?? this.weights;
// Snapshot the half-life alongside threshold/weights so a concurrent
// configRefresh can't score one recall with a mix of config versions.
const halfLifeSeconds = this.halfLifeSeconds;
const fetchK = k * RECALL_OVERFETCH;
const tags = options.tags ?? [];
const scope = {
Expand Down Expand Up @@ -161,7 +298,7 @@ export class MemoryStore {
ageSeconds,
importance: item.importance,
weights,
halfLifeSeconds: this.halfLifeSeconds,
halfLifeSeconds,
});
if (!Number.isFinite(score)) {
continue;
Expand Down Expand Up @@ -386,6 +523,11 @@ export class MemoryStore {
if (max === undefined) {
return;
}
// Snapshot the eviction tunables alongside max so an opt-in configRefresh
// landing mid-pass can't score victims with a different weight/half-life
// set than the capacity check ran with.
const weights = this.weights;
const halfLifeSeconds = this.halfLifeSeconds;
// Tags are part of the partition (as in recall/forgetByScope), so a
// tag-scoped write caps its own tag bucket.
const filter = buildScopeFilter(scope, scope.tags ?? []);
Expand Down Expand Up @@ -444,8 +586,8 @@ export class MemoryStore {
const dropCount = Math.min(total - max, candidates.length);
const evictKeys = selectEvictions(candidates, candidates.length - dropCount, {
now,
halfLifeSeconds: this.halfLifeSeconds,
weights: this.weights,
halfLifeSeconds,
weights,
});
if (evictKeys.length === 0) {
return;
Expand Down Expand Up @@ -480,3 +622,16 @@ function ftSearchTotal(raw: unknown): number {
return Number.isFinite(total) && total > 0 ? total : 0;
}

function parseHashReply(raw: unknown): Record<string, string> {
Comment thread
jamby77 marked this conversation as resolved.
const out: Record<string, string> = {};
if (Array.isArray(raw)) {
for (let i = 0; i + 1 < raw.length; i += 2) {
out[String(raw[i])] = String(raw[i + 1]);
}
} else if (raw !== null && typeof raw === 'object') {
for (const [field, value] of Object.entries(raw as Record<string, unknown>)) {
out[field] = String(value);
}
}
return out;
}
Loading
Loading