mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-14 04:28:34 +00:00
feat(memory): Add opt-in temporal decay for hybrid search scoring
Exponential decay (half-life configurable, default 30 days) applied
before MMR re-ranking. Dated daily files (memory/YYYY-MM-DD.md) use
filename date; evergreen files (MEMORY.md, topic files) are not
decayed; other sources fall back to file mtime.
Config: memorySearch.query.hybrid.temporalDecay.{enabled, halfLifeDays}
Default: disabled (backwards compatible, opt-in).
This commit is contained in:
committed by
Peter Steinberger
parent
fa9420069a
commit
6b3e0710f4
166
src/memory/temporal-decay.ts
Normal file
166
src/memory/temporal-decay.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type TemporalDecayConfig = {
|
||||
enabled: boolean;
|
||||
halfLifeDays: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_TEMPORAL_DECAY_CONFIG: TemporalDecayConfig = {
|
||||
enabled: false,
|
||||
halfLifeDays: 30,
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DATED_MEMORY_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
|
||||
export function toDecayLambda(halfLifeDays: number): number {
|
||||
if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.LN2 / halfLifeDays;
|
||||
}
|
||||
|
||||
export function calculateTemporalDecayMultiplier(params: {
|
||||
ageInDays: number;
|
||||
halfLifeDays: number;
|
||||
}): number {
|
||||
const lambda = toDecayLambda(params.halfLifeDays);
|
||||
const clampedAge = Math.max(0, params.ageInDays);
|
||||
if (lambda <= 0 || !Number.isFinite(clampedAge)) {
|
||||
return 1;
|
||||
}
|
||||
return Math.exp(-lambda * clampedAge);
|
||||
}
|
||||
|
||||
export function applyTemporalDecayToScore(params: {
|
||||
score: number;
|
||||
ageInDays: number;
|
||||
halfLifeDays: number;
|
||||
}): number {
|
||||
return params.score * calculateTemporalDecayMultiplier(params);
|
||||
}
|
||||
|
||||
function parseMemoryDateFromPath(filePath: string): Date | null {
|
||||
const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
const match = DATED_MEMORY_PATH_RE.exec(normalized);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Date.UTC(year, month - 1, day);
|
||||
const parsed = new Date(timestamp);
|
||||
if (
|
||||
parsed.getUTCFullYear() !== year ||
|
||||
parsed.getUTCMonth() !== month - 1 ||
|
||||
parsed.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isEvergreenMemoryPath(filePath: string): boolean {
|
||||
const normalized = filePath.replaceAll("\\", "/").replace(/^\.\//, "");
|
||||
if (normalized === "MEMORY.md" || normalized === "memory.md") {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.startsWith("memory/")) {
|
||||
return false;
|
||||
}
|
||||
return !DATED_MEMORY_PATH_RE.test(normalized);
|
||||
}
|
||||
|
||||
async function extractTimestamp(params: {
|
||||
filePath: string;
|
||||
source?: string;
|
||||
workspaceDir?: string;
|
||||
}): Promise<Date | null> {
|
||||
const fromPath = parseMemoryDateFromPath(params.filePath);
|
||||
if (fromPath) {
|
||||
return fromPath;
|
||||
}
|
||||
|
||||
// Memory root/topic files are evergreen knowledge and should not decay.
|
||||
if (params.source === "memory" && isEvergreenMemoryPath(params.filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!params.workspaceDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absolutePath = path.isAbsolute(params.filePath)
|
||||
? params.filePath
|
||||
: path.resolve(params.workspaceDir, params.filePath);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(absolutePath);
|
||||
if (!Number.isFinite(stat.mtimeMs)) {
|
||||
return null;
|
||||
}
|
||||
return new Date(stat.mtimeMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ageInDaysFromTimestamp(timestamp: Date, nowMs: number): number {
|
||||
const ageMs = Math.max(0, nowMs - timestamp.getTime());
|
||||
return ageMs / DAY_MS;
|
||||
}
|
||||
|
||||
export async function applyTemporalDecayToHybridResults<
|
||||
T extends { path: string; score: number; source: string },
|
||||
>(params: {
|
||||
results: T[];
|
||||
temporalDecay?: Partial<TemporalDecayConfig>;
|
||||
workspaceDir?: string;
|
||||
nowMs?: number;
|
||||
}): Promise<T[]> {
|
||||
const config = { ...DEFAULT_TEMPORAL_DECAY_CONFIG, ...params.temporalDecay };
|
||||
if (!config.enabled) {
|
||||
return [...params.results];
|
||||
}
|
||||
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const timestampCache = new Map<string, Date | null>();
|
||||
|
||||
return Promise.all(
|
||||
params.results.map(async (entry) => {
|
||||
const cacheKey = `${entry.source}:${entry.path}`;
|
||||
if (!timestampCache.has(cacheKey)) {
|
||||
const timestamp = await extractTimestamp({
|
||||
filePath: entry.path,
|
||||
source: entry.source,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
timestampCache.set(cacheKey, timestamp);
|
||||
}
|
||||
|
||||
const timestamp = timestampCache.get(cacheKey) ?? null;
|
||||
if (!timestamp) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const decayedScore = applyTemporalDecayToScore({
|
||||
score: entry.score,
|
||||
ageInDays: ageInDaysFromTimestamp(timestamp, nowMs),
|
||||
halfLifeDays: config.halfLifeDays,
|
||||
});
|
||||
|
||||
return {
|
||||
...entry,
|
||||
score: decayedScore,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user