源码
packages/builtin-plugins/src/memory/long-memory.ts
import { createHash } from "node:crypto";
import { readdir, rm } from "node:fs/promises";
import { join } from "node:path";
import type { CombinedSelection } from "../types.ts";
import { consolidationInstructions } from "./consolidation-instructions.ts";
import { extractionInstructions } from "./extraction-instructions.ts";
import { consolidationPrompt, extractionPrompt, longMemoryInstructions } from "./prompts.ts";
import type { LongMemoryStatus, MemorySettings, MemorySource } from "./types.ts";
import {
backupMemorySummary,
diffFile,
finishMemorySummary,
memoryNotesDirectory,
memoryWorkspaceDiff,
prepareMemoryWorkspace,
readMemoryNotes,
readMemoryFile as readOptional,
recoverMemorySummary,
replaceMemoryFile,
resetMemoryBaseline,
summaryLimit,
validateMemorySummary,
validateMemoryWorkspace,
} from "./workspace.ts";
const HOUR = 3_600_000;
const DAY = 24 * HOUR;
const MAX_SELECTED = 256;
const MAX_UNUSED_DAYS = 30;
const MAX_ATTEMPTS = 3;
const PIPELINE_VERSION = "zpi-memory-v2";
const MAX_SELECTED_CHARACTERS = 300_000;
export interface LongMemorySource {
id: string;
cwd: string;
title: string;
createdAt: number;
updatedAt: number;
boundary: string;
active?: boolean;
eligible?: boolean;
evidencePath?: string;
/** After clear, return only evidence newer than this source boundary. */
read(afterBoundary?: string): Promise<string>;
}
export interface MemoryRunRequest {
stage: "extract" | "consolidate";
prompt: string;
instructions: string;
directory?: string;
readFiles?: string[];
model: CombinedSelection;
signal: AbortSignal;
}
export interface MemoryRunResult {
text: string;
usage?: { tokens: number; cost: number | null };
}
interface MemoryRecord {
id: string;
title: string;
cwd: string;
boundary: string;
updatedAt: number;
generatedAt: number;
evidencePath?: string;
rolloutSlug?: string;
rolloutSummary: string;
usageCount: number;
lastUsage?: number;
}
interface MemoryJob {
boundary: string;
version: string;
attempts: number;
retryAt: number;
completed: boolean;
lastError?: string;
}
/** JSON state is persisted through the injected storage port. */
export interface LongMemoryState {
version: 2;
records: Record<string, MemoryRecord>;
jobs: Record<string, MemoryJob>;
tombstones: Record<string, string>;
publishedSummary?: string;
activeFingerprint?: string;
activeSources: { id: string; boundary: string }[];
invalidated: boolean;
lastSuccess?: number;
consolidationRetryAt?: number;
lastRun?: number;
outcome?: LongMemoryStatus["status"];
message?: string;
tokens: number;
cost: number | null;
}
export interface LongMemoryOptions {
directory: string;
getState(): LongMemoryState | undefined;
setState(state: LongMemoryState): void;
sources(): Promise<LongMemorySource[]>;
run(request: MemoryRunRequest): Promise<MemoryRunResult>;
now?(): number;
}
function initialState(): LongMemoryState {
return {
version: 2,
records: {},
jobs: {},
tombstones: {},
activeSources: [],
invalidated: false,
tokens: 0,
cost: 0,
};
}
function digest(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function differentSourceGeneration(previous: string, current: string): boolean {
const previousSeparator = previous.lastIndexOf(":");
const currentSeparator = current.lastIndexOf(":");
return (
previousSeparator >= 0 &&
currentSeparator >= 0 &&
previous.slice(0, previousSeparator) !== current.slice(0, currentSeparator)
);
}
function safeError(error: unknown): string {
return redact(error instanceof Error ? error.message : String(error)).slice(0, 800);
}
/** Best-effort defense in depth; provenance filtering and model instructions remain necessary. */
function redact(text: string): string {
return text
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "[REDACTED_SECRET]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED_SECRET]")
.replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+\b/g, "[REDACTED_SECRET]")
.replace(
/(["']?(?:api[_ -]?key|access[_-]?token|refresh[_-]?token|password|client_secret)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&]+)/gi,
"$1[REDACTED_SECRET]",
)
.replace(/([?&](?:token|key|code|secret|signature|access_token)=)[^&#\s]+/gi, "$1[REDACTED_SECRET]");
}
function parseOutput(text: string, keys: string[], maxCharacters: number): Record<string, string> {
const value: unknown = JSON.parse(text);
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("记忆模型未返回 JSON 对象。");
const record = value as Record<string, unknown>;
if (Object.keys(record).length !== keys.length || keys.some((key) => typeof record[key] !== "string"))
throw new Error(`记忆模型产物字段无效;需要 ${keys.join("、")}。`);
const output: Record<string, string> = {};
for (const key of keys) {
const content = redact((record[key] as string).trim());
if ([...content].length > maxCharacters) throw new Error(`记忆产物 ${key} 超过容量上限。`);
output[key] = content;
}
return output;
}
function boundSummary(text: string): string {
if (Buffer.byteLength(text, "utf8") <= 9_000) return text;
const suffix = "\n[Summary truncated to 9,000 UTF-8 bytes.]";
let result = "";
let size = Buffer.byteLength(suffix);
for (const character of text) {
const bytes = Buffer.byteLength(character);
if (size + bytes > 9_000) break;
result += character;
size += bytes;
}
return result + suffix;
}
function summaryName(record: { id: string }): string {
return `rollout_summaries/${digest(record.id).slice(0, 24)}.md`;
}
function summaryFile(record: MemoryRecord): string {
return [
`session_id: ${JSON.stringify(record.id)}`,
`source_boundary: ${JSON.stringify(record.boundary)}`,
`updated_at: ${new Date(record.updatedAt).toISOString()}`,
`workspace: ${JSON.stringify(record.cwd)}`,
`title: ${JSON.stringify(redact(record.title))}`,
...(record.evidencePath ? [`evidence_path: ${JSON.stringify(record.evidencePath)}`] : []),
"",
record.rolloutSummary,
"",
].join("\n");
}
/** One owner per active user store. All calls share its cancellation and publication barrier. */
export class LongMemoryService {
private readonly options: LongMemoryOptions;
private readonly now: () => number;
private settings?: MemorySettings;
private pending?: Promise<void>;
private maintenance?: Promise<void>;
private controller?: AbortController;
private epoch = 0;
private usageEpoch = 0;
private closed = false;
private initialization?: Promise<void>;
private readonly modelCalls = new Set<Promise<MemoryRunResult>>();
constructor(options: LongMemoryOptions) {
this.options = options;
this.now = options.now ?? Date.now;
}
private state(): LongMemoryState {
const state = this.options.getState() ?? initialState();
if (state.version !== 2) throw new Error("storage: Unsupported memory state format");
return structuredClone(state);
}
private save(state: LongMemoryState): void {
this.options.setState(structuredClone(state));
}
private outcome(status: LongMemoryStatus["status"], message: string): void {
const state = this.state();
state.outcome = status;
state.message = message;
state.lastRun = this.now();
this.save(state);
}
init(): Promise<void> {
if (this.closed) return Promise.reject(new Error("记忆服务已关闭。"));
if (this.initialization) return this.initialization;
this.initialization = this.initialize().catch((error) => {
this.initialization = undefined;
throw error;
});
return this.initialization;
}
private async initialize(): Promise<void> {
const state = this.state();
await prepareMemoryWorkspace(this.options.directory);
await recoverMemorySummary(this.options.directory, state.publishedSummary);
// The rollback journal protects a last usable summary if the app died mid-worker.
const current = await readOptional(join(this.options.directory, "memory_summary.md"), summaryLimit);
if (current) {
try {
validateMemorySummary(current);
state.publishedSummary = current;
this.save(state);
} catch {
/* A failed first build has no usable summary. */
}
}
}
configure(settings: MemorySettings): void {
this.settings = structuredClone(settings);
if (!settings.long || !settings.generate) {
this.cancel();
}
}
check(triggerSelection: CombinedSelection, settings: MemorySettings): Promise<void> {
this.configure(settings);
if (this.closed || this.maintenance || !settings.long || !settings.generate) return Promise.resolve();
if (this.pending) return this.pending;
const frozenSettings = structuredClone(settings);
const frozenTrigger = structuredClone(triggerSelection);
const controller = new AbortController();
this.controller = controller;
const epoch = this.epoch;
const work = this.execute(frozenTrigger, frozenSettings, controller.signal, epoch).catch(
(error: unknown) => {
if (!this.closed && epoch === this.epoch) {
this.outcome("failed", safeError(error));
}
},
);
this.pending = work.finally(() => {
if (this.controller === controller) this.controller = undefined;
this.pending = undefined;
});
return this.pending;
}
private cancel(): void {
this.epoch++;
this.controller?.abort();
}
async stop(): Promise<void> {
const running = this.pending;
this.cancel();
await running;
if (running && !this.closed) this.outcome("cancelled", "记忆任务已取消,保留上一份有效记忆。");
}
async close(): Promise<void> {
this.closed = true;
await this.stop();
await this.initialization?.catch(() => {});
await this.maintenance;
}
/** After closing and releasing foreground leases, wait for transport cancellation and filesystem cleanup. */
async drain(): Promise<void> {
await Promise.allSettled([...this.modelCalls]);
}
clear(): Promise<void> {
if (this.maintenance) return this.maintenance;
const task = this.performClear();
this.maintenance = task.finally(() => {
this.maintenance = undefined;
});
return this.maintenance;
}
private async performClear(): Promise<void> {
this.usageEpoch++;
await this.stop();
await this.initialization?.catch(() => {});
const sources = await this.options.sources();
const state = initialState();
state.tombstones = Object.fromEntries(sources.map((source) => [source.id, source.boundary]));
state.outcome = "idle";
state.message = "长期记忆已清空;现有会话版本不会重新提取。";
// Withdraw usable summary state before deleting files, including on filesystem failure.
this.save(state);
await rm(this.options.directory, { recursive: true, force: true });
this.initialization = undefined;
}
async invalidateSource(id: string): Promise<void> {
// Hide stale summary immediately, before awaiting an abort-aware model runner.
const previous = this.state();
const affectsPublished = previous.activeSources.some((source) => source.id === id);
if (!previous.records[id] && !previous.jobs[id] && !affectsPublished) return;
previous.invalidated ||= affectsPublished;
this.save(previous);
await this.stop();
const state = this.state();
// Deletion/rewrite invalidates evidence, but only explicit clear establishes a cutoff.
// A rewritten surviving conversation must be eligible for full re-extraction.
delete state.records[id];
delete state.jobs[id];
state.invalidated ||= affectsPublished;
this.save(state);
}
status(): LongMemoryStatus {
const state = this.state();
const status =
this.settings?.long === false
? "disabled"
: this.settings?.generate === false
? "paused"
: this.pending
? "running"
: (state.outcome ?? "idle");
return {
path: this.options.directory,
running: Boolean(this.pending),
status,
message:
status === "disabled"
? "长期记忆已关闭,已有数据保留。"
: status === "paused"
? "自动生成已暂停,仍可使用已有记忆。"
: (state.message ?? "尚未运行长期记忆。"),
lastRun: state.lastRun,
tokens: state.tokens,
cost: state.cost,
sources: this.publicSources(state),
};
}
async context(): Promise<{ instructions: string[]; sources: MemorySource[] }> {
await this.init();
const state = this.state();
// Freeze injected text per turn. Paths intentionally name the current, mutable v2 library.
let summary = state.invalidated ? "" : (state.publishedSummary ?? "");
if (!state.invalidated && !this.pending) {
const current = await readOptional(join(this.options.directory, "memory_summary.md"), summaryLimit);
try {
validateMemorySummary(current);
summary = current;
} catch {
summary = "";
}
}
return {
instructions: longMemoryInstructions(this.options.directory, summary),
sources: state.invalidated ? [] : this.publicSources(state),
};
}
/** Called only for sources actually read by the main agent, not merely injected navigation. */
recordUsage(ids: string[]): void {
const state = this.state();
for (const id of new Set(ids)) {
const record = state.records[id];
if (!record) continue;
record.usageCount++;
record.lastUsage = this.now();
}
this.save(state);
}
private publicSources(state: LongMemoryState): MemorySource[] {
return state.activeSources.flatMap(({ id }) => {
const record = state.records[id];
return record
? [
{
sessionId: id,
title: record.title,
workspace: record.cwd,
summaryPath: join(this.options.directory, summaryName(record)),
},
]
: [];
});
}
private assertCurrent(signal: AbortSignal, epoch: number): void {
if (signal.aborted || this.closed || epoch !== this.epoch) throw new Error("记忆任务已取消。");
}
private async model(request: MemoryRunRequest, epoch: number): Promise<string> {
this.assertCurrent(request.signal, epoch);
let abort: (() => void) | undefined;
const cancelled = new Promise<never>((_, reject) => {
abort = () => reject(new Error("记忆任务已取消。"));
request.signal.addEventListener("abort", abort, { once: true });
});
const usageEpoch = this.usageEpoch;
// An aborted request can report billed usage after cancellation; count it without publishing its result.
// A clear or close invalidates this accounting callback as well.
const operation = Promise.resolve()
.then(() => {
request.signal.throwIfAborted();
return this.options.run(request);
})
.then(
(result) => {
if (!this.closed && usageEpoch === this.usageEpoch) this.addUsage(result.usage);
return result;
},
(error: unknown) => {
if (
!this.closed &&
usageEpoch === this.usageEpoch &&
error &&
typeof error === "object" &&
"usage" in error
)
this.addUsage(error.usage as MemoryRunResult["usage"]);
throw error;
},
);
this.modelCalls.add(operation);
void operation.finally(() => this.modelCalls.delete(operation)).catch(() => {});
try {
// File workers must fully stop before rollback, clear or the next writer can proceed.
void cancelled.catch(() => {});
const result =
request.stage === "consolidate" ? await operation : await Promise.race([operation, cancelled]);
this.assertCurrent(request.signal, epoch);
return result.text;
} finally {
if (abort) request.signal.removeEventListener("abort", abort);
}
}
private addUsage(usage: MemoryRunResult["usage"]): void {
if (!usage || !Number.isFinite(usage.tokens)) return;
const state = this.state();
state.tokens += Math.max(0, usage.tokens);
state.cost =
state.cost === null || usage.cost === null || !Number.isFinite(usage.cost)
? null
: state.cost + Math.max(0, usage.cost);
this.save(state);
}
private async execute(
trigger: CombinedSelection,
settings: MemorySettings,
signal: AbortSignal,
epoch: number,
): Promise<void> {
await this.init();
this.assertCurrent(signal, epoch);
const base = settings.modelMode === "manual" ? settings.selection : trigger;
if (!base) throw new Error("长期记忆尚未指定模型。请选择模型或切换为跟随会话。");
const extractModel = structuredClone(settings.extractSelection ?? base);
const consolidateModel = structuredClone(settings.consolidateSelection ?? base);
this.outcome("running", "正在检查历史会话。");
const sources = await this.options.sources();
this.assertCurrent(signal, epoch);
const now = this.now();
const byId = new Map(sources.map((source) => [source.id, source]));
let state = this.state();
for (const id of Object.keys(state.records)) {
if (!byId.has(id) || byId.get(id)?.eligible === false) {
delete state.records[id];
delete state.jobs[id];
state.invalidated ||= state.activeSources.some((source) => source.id === id);
}
}
this.save(state);
const window = sources
.filter((source) => source.eligible !== false && source.updatedAt >= now - settings.maxAgeDays * DAY)
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id))
.slice(0, 5_000);
const stale = window.filter((source) => {
const job = state.jobs[source.id];
return (
state.tombstones[source.id] !== source.boundary &&
(!job || job.boundary !== source.boundary || job.version !== PIPELINE_VERSION || !job.completed)
);
});
const eligible = stale.filter((source) => {
const job = state.jobs[source.id];
return (
!source.active &&
source.updatedAt <= now - settings.minIdleHours * HOUR &&
(!job ||
job.boundary !== source.boundary ||
job.version !== PIPELINE_VERSION ||
(job.attempts < MAX_ATTEMPTS && job.retryAt <= now))
);
});
const waitingFailure = stale.find((source) => {
const job = state.jobs[source.id];
return job?.boundary === source.boundary && job.lastError && !eligible.includes(source);
});
let failures = waitingFailure ? 1 : 0;
if (waitingFailure)
this.outcome("failed", state.jobs[waitingFailure.id].lastError ?? "提取失败,等待重试。");
let completed = 0;
for (const source of eligible.slice(0, settings.maxRolloutsPerBatch)) {
this.assertCurrent(signal, epoch);
state = this.state();
const oldJob = state.jobs[source.id];
const attempts =
oldJob?.boundary === source.boundary && oldJob.version === PIPELINE_VERSION ? oldJob.attempts : 0;
state.jobs[source.id] = {
boundary: source.boundary,
version: PIPELINE_VERSION,
attempts,
retryAt: 0,
completed: false,
};
this.save(state);
try {
const clearedBoundary = state.tombstones[source.id];
const evidence = redact(await source.read(clearedBoundary));
this.assertCurrent(signal, epoch);
const result = evidence.trim()
? parseOutput(
await this.model(
{
stage: "extract",
instructions: extractionInstructions,
prompt: extractionPrompt(
{
...source,
title: redact(source.title),
read: undefined,
clearedBoundary,
processingVersion: PIPELINE_VERSION,
},
evidence,
),
model: extractModel,
signal,
},
epoch,
),
["rollout_summary", "rollout_slug"],
100_000,
)
: { rollout_summary: "", rollout_slug: "" };
if (result.rollout_slug && !/^[a-zA-Z0-9_-]{1,80}$/.test(result.rollout_slug))
throw new Error("提取产物 rollout_slug 必须为 1–80 个字母、数字、连字符或下划线。");
this.assertCurrent(signal, epoch);
state = this.state();
state.jobs[source.id].completed = true;
if (
!evidence.trim() &&
clearedBoundary &&
differentSourceGeneration(clearedBoundary, source.boundary)
)
state.tombstones[source.id] = source.boundary;
if (!result.rollout_summary) {
delete state.records[source.id];
if (state.activeSources.some(({ id }) => id === source.id)) state.invalidated = true;
} else {
const old = state.records[source.id];
state.records[source.id] = {
id: source.id,
title: source.title,
cwd: source.cwd,
boundary: source.boundary,
updatedAt: source.updatedAt,
evidencePath: source.evidencePath,
generatedAt: this.now(),
rolloutSlug: result.rollout_slug,
rolloutSummary: boundSummary(result.rollout_summary),
usageCount: old?.usageCount ?? 0,
lastUsage: old?.lastUsage,
};
}
this.save(state);
completed++;
} catch (error) {
this.assertCurrent(signal, epoch);
failures++;
state = this.state();
const message = `会话 ${source.title} 提取失败:${safeError(error)}`;
state.jobs[source.id].attempts++;
state.jobs[source.id].retryAt = this.now() + HOUR;
state.jobs[source.id].lastError = message;
this.save(state);
this.outcome("failed", message);
}
}
this.assertCurrent(signal, epoch);
state = this.state();
const ranked = Object.values(state.records)
.filter((record) => (record.lastUsage ?? record.updatedAt) >= now - MAX_UNUSED_DAYS * DAY)
.sort(
(a, b) =>
b.usageCount - a.usageCount ||
(b.lastUsage ?? b.updatedAt) - (a.lastUsage ?? a.updatedAt) ||
b.updatedAt - a.updatedAt ||
b.id.localeCompare(a.id),
);
const selected: MemoryRecord[] = [];
let characterBudget = MAX_SELECTED_CHARACTERS;
for (const record of ranked.slice(0, MAX_SELECTED)) {
const size = [...record.rolloutSummary].length;
if (size > characterBudget) continue;
characterBudget -= size;
selected.push(record);
}
selected.sort((a, b) => a.id.localeCompare(b.id));
const selectedIds = new Set(selected.map(({ id }) => id));
if (state.activeSources.some(({ id }) => !selectedIds.has(id))) {
state.invalidated = true;
this.save(state);
}
const notes = await readMemoryNotes(this.options.directory);
if (notes.some(({ content }) => redact(content) !== content))
throw new Error("长期记忆请求文件包含可识别凭据,请移除后再检查更新;原文件未修改。");
const hasNotes = notes.some(
({ path, content }) => path.startsWith(`${memoryNotesDirectory}/`) && content.trim(),
);
this.assertCurrent(signal, epoch);
const summaries = selected.map((record) => ({ path: summaryName(record), content: summaryFile(record) }));
const fingerprint = digest(JSON.stringify({ version: PIPELINE_VERSION, summaries, notes }));
const directory = this.options.directory;
const previousSummary = state.publishedSummary;
if (!selected.length && !hasNotes && !previousSummary) {
if (!failures)
this.outcome(
completed ? "unchanged" : stale.length ? "waiting-idle" : "no-candidates",
completed
? "已检查会话,没有值得保留的新内容。"
: stale.length
? "历史会话尚未达到空闲门槛,或正在等待失败重试。"
: "没有符合条件的历史会话。",
);
return;
}
if (state.consolidationRetryAt && state.consolidationRetryAt > now) {
this.outcome("failed", "上次整合失败,等待一小时退避后重试;上一份有效产物保留。");
return;
}
const paths = new Set(summaries.map(({ path }) => path));
// Synchronize selected source files in place, retaining names for surviving sessions.
for (const file of await readdir(join(directory, "rollout_summaries")))
if (!paths.has(`rollout_summaries/${file}`))
await rm(join(directory, "rollout_summaries", file), { recursive: true, force: true });
for (const { path, content } of summaries) {
const target = join(directory, path);
if ((await readOptional(target)) !== content) await replaceMemoryFile(target, content);
}
const diff = await memoryWorkspaceDiff(directory);
this.assertCurrent(signal, epoch);
let validSummary = false;
try {
validateMemorySummary(await readOptional(join(directory, "memory_summary.md"), summaryLimit));
validSummary = true;
} catch {
/* Rebuild missing or invalid artifacts. */
}
if (!diff && fingerprint === state.activeFingerprint && validSummary) {
if (state.invalidated) {
state.invalidated = false;
this.save(state);
}
if (!failures) {
const waiting =
!eligible.length &&
stale.some((source) => source.active || source.updatedAt > now - settings.minIdleHours * HOUR);
this.outcome(
waiting ? "waiting-idle" : "unchanged",
waiting
? "新的历史会话尚未达到空闲门槛,已有记忆继续可用。"
: "输入与工作目录未变化,无需调用整合模型。",
);
}
return;
}
const inventory = [...summaries, ...notes]
.map(
({ path, content }) =>
`${path}: ${content.split("\n").length} lines, ${Buffer.byteLength(content, "utf8")} bytes`,
)
.join("\n");
const manifest = `# Memory Workspace Diff\n\nGenerated by ZPI from the last successful Git baseline. Read first; do not edit.\n\n## Source inventory\n${inventory}\n\n## Changes\n${redact(diff) || "No file changes; rebuild an invalid or missing summary."}\n\nRead extensions/ad_hoc/instructions.md and apply new or edited notes under extensions/ad_hoc/notes/. Preserve note files and mark derived information [ad-hoc note]. Remove conclusions supported only by deleted sources. Do not open original transcripts.`;
await replaceMemoryFile(join(directory, diffFile), manifest);
const inputs = [...summaries, ...notes, { path: diffFile, content: manifest }];
await backupMemorySummary(directory);
try {
await this.model(
{
stage: "consolidate",
instructions: consolidationInstructions,
prompt: consolidationPrompt(),
directory,
readFiles: inputs.map(({ path }) => path),
model: consolidateModel,
signal,
},
epoch,
);
this.assertCurrent(signal, epoch);
const summary = redact(await validateMemoryWorkspace(directory, inputs));
validateMemorySummary(summary);
await replaceMemoryFile(join(directory, "memory_summary.md"), summary);
this.assertCurrent(signal, epoch);
await resetMemoryBaseline(directory);
this.assertCurrent(signal, epoch);
state = this.state();
state.publishedSummary = summary;
state.activeFingerprint = fingerprint;
state.activeSources = selected.map(({ id, boundary }) => ({ id, boundary }));
state.invalidated = false;
state.lastSuccess = this.now();
state.consolidationRetryAt = undefined;
for (const [id, record] of Object.entries(state.records))
if (
!selectedIds.has(id) &&
(record.lastUsage ?? record.updatedAt) < this.now() - MAX_UNUSED_DAYS * DAY
)
delete state.records[id];
this.save(state);
await finishMemorySummary(directory);
this.outcome(
"success",
`记忆已更新;本批提取 ${completed} 个会话${failures ? `,${failures} 个失败待重试` : ""}。`,
);
} catch (error) {
await recoverMemorySummary(directory, this.state().publishedSummary);
this.assertCurrent(signal, epoch);
state = this.state();
state.consolidationRetryAt = this.now() + HOUR;
this.save(state);
throw error;
}
}
}