Z ZPI 文档 v0.3.8
介绍页

源码

packages/builtin-plugins/src/memory/long-memory.ts

typescript791 行查看原文
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;
    }
  }
}