AkurAI Build
Menu

popagent

public

Latest change c5cc989445aeac1b3fd80068df03e23002a15a47 - Let evolution retain bounded durable facts by AkurAI Build

import type {
  AutonomySettings,
  EvolutionRevision,
  EvolutionSignal,
} from "./api-types";
import { initializeEvolutionStorage } from "./autonomy-settings";
import { storage } from "./storage";
import { longTermMemory, MAX_FACT_CHARACTERS } from "./long-term-memory";
import { RESOURCE_ID } from "./sessions";

export const MAX_EVOLUTION_SUMMARY_CHARACTERS = 4_000;
export const MAX_LEARNED_CONTENT_CHARACTERS = 12_000;
const MAX_EVOLUTION_ERROR_CHARACTERS = 2_000;
const MAX_EVOLUTION_RATIONALE_CHARACTERS = 2_000;
const TRUNCATION_MARKER = "\n… [truncated]";
const textEncoder = new TextEncoder();

const SIGNAL_COLUMNS = `
  id,
  session_id AS "sessionId",
  turn_id AS "turnId",
  trace_id AS "traceId",
  agent_id AS "agentId",
  workspace_id AS "workspaceId",
  kind,
  summary,
  status,
  attempts,
  next_attempt_at::text AS "nextAttemptAt",
  processed_at::text AS "processedAt",
  error,
  created_at::text AS "createdAt",
  updated_at::text AS "updatedAt"
`;
const REVISION_COLUMNS = `
  id,
  agent_id AS "agentId",
  target_type AS "targetType",
  target_key AS "targetKey",
  before_content AS "beforeContent",
  after_content AS "afterContent",
  rationale,
  evidence_ids AS "evidenceIds",
  status,
  reverts_revision_id AS "revertsRevisionId",
  created_at::text AS "createdAt",
  applied_at::text AS "appliedAt"
`;

const SENSITIVE_PATTERNS = [
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*/i,
  /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/i,
  /\b(?:password|passwd|secret|api[_ -]?key|access[_ -]?token|refresh[_ -]?token)\s*[:=]\s*["']?[^\s"',;}]{4,}/i,
  /\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{16,}|AKIA[A-Z0-9]{16})\b/,
  /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
  /\bhttps?:\/\/[^\s/:]+:[^\s/@]+@[^\s]+/i,
];
const FORBIDDEN_SCOPE_PATTERNS = [
  /\b(?:bypass|disable|override|evade|ignore)\b[\s\S]{0,80}\b(?:containment|security|hook|authentication|permission|access|secret|credential|execution limit|timeout|boundary)\b/i,
  /\b(?:grant|expand|change|modify)\b[\s\S]{0,80}\b(?:tool membership|workspace access|browser access|authentication|hooks?|execution bounds?|permissions?)\b/i,
  /\b(?:edit|replace|rewrite|mutate)\b[\s\S]{0,80}\b(?:base|user-managed) instructions?\b/i,
];

export type EvolutionSignalInput = {
  sessionId?: string | null;
  turnId?: string | null;
  traceId?: string | null;
  agentId: string;
  workspaceId?: string | null;
  kind: EvolutionSignal["kind"];
  summary: string;
};

export type EvolutionProposal = {
  agentId: string;
  targetType: EvolutionRevision["targetType"];
  targetKey: string;
  content: string;
  rationale: string;
  evidenceIds: string[];
};

type ApplyPolicy = Pick<AutonomySettings, "enabled" | "autoApplyStrategies" | "autoCreateSkills" | "autoRetainFacts">;

type StoredSkill = { id: string; instructions: string; evolutionManaged: boolean };

function truncate(value: string, maximum: number): string {
  if (value.length <= maximum) return value;
  if (maximum <= TRUNCATION_MARKER.length) return value.slice(0, maximum);
  let end = maximum - TRUNCATION_MARKER.length;
  const codeUnit = value.charCodeAt(end - 1);
  if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) end--;
  return `${value.slice(0, end).trimEnd()}${TRUNCATION_MARKER}`;
}

export function containsSensitiveEvolutionText(value: string): boolean {
  return SENSITIVE_PATTERNS.some((pattern) => pattern.test(value));
}

export function redactEvolutionText(value: string, maximum = MAX_EVOLUTION_SUMMARY_CHARACTERS): string {
  let redacted = truncate(value.trim(), maximum * 2);
  for (const pattern of SENSITIVE_PATTERNS) {
    redacted = redacted.replace(new RegExp(pattern.source, `${pattern.flags.replace("g", "")}g`), "[redacted]");
  }
  return truncate(redacted, maximum);
}

export function buildEvolutionEvidence(input: {
  goal: string;
  outcome?: string | null;
  failure?: string | null;
  classification: string;
}): string {
  const parts = [
    `Goal: ${redactEvolutionText(input.goal, 1_200) || "Unspecified"}`,
    `Classification: ${redactEvolutionText(input.classification, 200) || "unknown"}`,
  ];
  if (input.outcome?.trim()) parts.push(`Outcome: ${redactEvolutionText(input.outcome, 1_800)}`);
  if (input.failure?.trim()) parts.push(`Failure: ${redactEvolutionText(input.failure, 800)}`);
  return redactEvolutionText(parts.join("\n"));
}

async function sha256(value: string): Promise<string> {
  const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", textEncoder.encode(value)));
  let result = "";
  for (const byte of bytes) result += byte.toString(16).padStart(2, "0");
  return result;
}

function identifier(value: string | null | undefined, maximum = 200): string | null {
  const normalized = value?.trim() ?? "";
  if (!normalized) return null;
  if (normalized.length > maximum) throw new RangeError(`Evolution identifier exceeds ${maximum} characters`);
  return normalized;
}

function errorText(error: unknown): string {
  const raw = error instanceof Error ? error.message : String(error);
  return redactEvolutionText(raw || "Evolution reflection failed", MAX_EVOLUTION_ERROR_CHARACTERS);
}
function evolutionFactKey(agentId: string, targetKey: string): string {
  const key = `evolution.${agentId}.${targetKey}`;
  if (key.length > 128) throw new RangeError("Evolution fact key exceeds 128 characters");
  return key;
}


function validateProposal(
  proposal: EvolutionProposal,
  evidenceIds: ReadonlySet<string>,
  policy: ApplyPolicy,
): EvolutionProposal {
  const agentId = identifier(proposal.agentId, 100);
  const targetKey = identifier(proposal.targetKey, 100);
  const content = proposal.content.trim();
  const rationale = proposal.rationale.trim();
  if (proposal.targetType !== "overlay" && proposal.targetType !== "skill" && proposal.targetType !== "fact") {
    throw new Error("Evolution proposal target type is not allowed");
  }
  if (!agentId || !targetKey) throw new RangeError("Evolution proposal target is required");
  const maximumContent = proposal.targetType === "fact"
    ? MAX_FACT_CHARACTERS
    : MAX_LEARNED_CONTENT_CHARACTERS;
  if (!content || content.length > maximumContent) {
    throw new RangeError(`Evolution proposal content must be between 1 and ${maximumContent} characters`);
  }
  if (!rationale || rationale.length > MAX_EVOLUTION_RATIONALE_CHARACTERS) {
    throw new RangeError(`Evolution rationale must be between 1 and ${MAX_EVOLUTION_RATIONALE_CHARACTERS} characters`);
  }
  if (containsSensitiveEvolutionText(content) || containsSensitiveEvolutionText(rationale)) {
    throw new Error("Evolution proposal contains sensitive material");
  }
  if (FORBIDDEN_SCOPE_PATTERNS.some((pattern) => pattern.test(content))) {
    throw new Error("Evolution proposal attempts to change a protected boundary");
  }
  if (!proposal.evidenceIds.length || proposal.evidenceIds.some((id) => !evidenceIds.has(id))) {
    throw new Error("Evolution proposal cites evidence outside the claimed batch");
  }
  const uniqueEvidenceIds = [...new Set(proposal.evidenceIds)];
  if (proposal.targetType === "overlay") {
    if (!policy.autoApplyStrategies) throw new Error("Automatic learned overlays are disabled");
    if (targetKey !== "instructions") throw new Error("Learned overlays may target only instructions");
  } else if (proposal.targetType === "skill") {
    if (!policy.autoCreateSkills) throw new Error("Automatic skill creation is disabled");
    if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(targetKey)) {
      throw new Error("Learned skill names must use lowercase hyphenated form");
    }
  } else {
    if (!policy.autoRetainFacts) throw new Error("Automatic durable facts are disabled");
    if (!/^[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(targetKey)) {
      throw new Error("Evolution fact keys must use lowercase semantic-key form");
    }
    evolutionFactKey(agentId, targetKey);
  }
  return {
    ...proposal,
    agentId,
    targetKey,
    content,
    rationale,
    evidenceIds: uniqueEvidenceIds,
  };
}

export class EvolutionStore {
  readonly storage = storage;

  init(): Promise<void> {
    return initializeEvolutionStorage();
  }

  async enqueueSignal(input: EvolutionSignalInput): Promise<EvolutionSignal> {
    const agentId = identifier(input.agentId, 100);
    if (!agentId) throw new RangeError("Evolution signal agent id is required");
    const sessionId = identifier(input.sessionId);
    const turnId = identifier(input.turnId);
    const traceId = identifier(input.traceId);
    const workspaceId = identifier(input.workspaceId);
    const summary = redactEvolutionText(input.summary)
      || (input.kind === "turn-success" ? "Turn completed successfully" : "Turn failed");
    const dedupeKey = await sha256(JSON.stringify([
      sessionId,
      turnId,
      traceId,
      agentId,
      workspaceId,
      input.kind,
      sessionId || turnId || traceId ? null : summary,
    ]));
    await this.init();
    return this.storage.db.one<EvolutionSignal>(`
      INSERT INTO popagent_evolution_signals (
        id, dedupe_key, session_id, turn_id, trace_id, agent_id, workspace_id,
        kind, summary, status, next_attempt_at
      ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', NOW())
      ON CONFLICT (dedupe_key) DO UPDATE
      SET dedupe_key = EXCLUDED.dedupe_key
      RETURNING ${SIGNAL_COLUMNS}
    `, [
      crypto.randomUUID(),
      dedupeKey,
      sessionId,
      turnId,
      traceId,
      agentId,
      workspaceId,
      input.kind,
      summary,
    ]);
  }

  enqueueCompletedTurn(input: Omit<EvolutionSignalInput, "kind">): Promise<EvolutionSignal> {
    return this.enqueueSignal({ ...input, kind: "turn-success" });
  }

  enqueueFailedTurn(input: Omit<EvolutionSignalInput, "kind">): Promise<EvolutionSignal> {
    return this.enqueueSignal({ ...input, kind: "turn-failure" });
  }

  async listSignals(limit = 100): Promise<EvolutionSignal[]> {
    await this.init();
    const boundedLimit = Number.isFinite(limit)
      ? Math.min(Math.max(Math.trunc(limit), 1), 200)
      : 100;
    return this.storage.db.any<EvolutionSignal>(`
      SELECT ${SIGNAL_COLUMNS}
      FROM popagent_evolution_signals
      ORDER BY created_at DESC, id DESC
      LIMIT $1
    `, [boundedLimit]);
  }

  async manageSignal(id: string, action: "ignore" | "retry"): Promise<EvolutionSignal | "conflict" | undefined> {
    await this.init();
    const signal = await this.storage.db.oneOrNone<EvolutionSignal>(`
      UPDATE popagent_evolution_signals
      SET status = $2,
          attempts = CASE WHEN $2 = 'pending' THEN 0 ELSE attempts END,
          next_attempt_at = CASE WHEN $2 = 'pending' THEN NOW() ELSE NULL END,
          processed_at = CASE WHEN $2 = 'ignored' THEN NOW() ELSE NULL END,
          error = NULL,
          updated_at = NOW()
      WHERE id = $1
        AND (($2 = 'ignored' AND status IN ('pending', 'dead-letter'))
          OR ($2 = 'pending' AND status = 'dead-letter'))
      RETURNING ${SIGNAL_COLUMNS}
    `, [id, action === "ignore" ? "ignored" : "pending"]);
    if (signal) return signal;
    return await this.storage.db.oneOrNone<{ id: string }>(
      "SELECT id FROM popagent_evolution_signals WHERE id=$1",
      [id],
    ) ? "conflict" : undefined;
  }

  async listRevisions(limit = 100): Promise<EvolutionRevision[]> {
    await this.init();
    const boundedLimit = Number.isFinite(limit)
      ? Math.min(Math.max(Math.trunc(limit), 1), 200)
      : 100;
    return this.storage.db.any<EvolutionRevision>(`
      SELECT ${REVISION_COLUMNS}
      FROM popagent_evolution_revisions
      ORDER BY created_at DESC, id DESC
      LIMIT $1
    `, [boundedLimit]);
  }

  async learnedOverlay(agentId: string): Promise<string | undefined> {
    await this.init();
    const row = await this.storage.db.oneOrNone<{ content: string }>(`
      SELECT content FROM popagent_learned_overlays WHERE agent_id = $1
    `, [agentId]);
    return row?.content;
  }

  async claimBatch(limit: number, maxAttempts = 10): Promise<EvolutionSignal[]> {
    await this.init();
    const boundedLimit = Number.isFinite(limit)
      ? Math.min(Math.max(Math.trunc(limit), 1), 100)
      : 1;
    const boundedMaxAttempts = Number.isFinite(maxAttempts)
      ? Math.min(Math.max(Math.trunc(maxAttempts), 1), 10)
      : 10;
    const claimed = await this.storage.db.tx(async (db) => {
      const policy = await db.one<ApplyPolicy>(`
        SELECT enabled,
               auto_apply_strategies AS "autoApplyStrategies",
               auto_create_skills AS "autoCreateSkills",
               auto_retain_facts AS "autoRetainFacts"
        FROM popagent_autonomy_settings
        WHERE singleton = TRUE
        FOR UPDATE
      `);
      if (!policy.enabled || (!policy.autoApplyStrategies && !policy.autoCreateSkills && !policy.autoRetainFacts)) {
        await db.none(`
          WITH candidates AS (
            SELECT id
            FROM popagent_evolution_signals
            WHERE status = 'pending' AND next_attempt_at <= NOW()
            ORDER BY next_attempt_at, created_at, id
            FOR UPDATE SKIP LOCKED
            LIMIT $1
          )
          UPDATE popagent_evolution_signals AS signal
          SET status = 'ignored',
              next_attempt_at = NULL,
              processed_at = NOW(),
              error = NULL,
              updated_at = NOW()
          FROM candidates
          WHERE signal.id = candidates.id
        `, [boundedLimit]);
        return [];
      }
      await db.none(`
        UPDATE popagent_evolution_signals
        SET status = 'dead-letter',
            next_attempt_at = NULL,
            processed_at = NOW(),
            error = 'Evolution retry attempts exhausted before claim',
            updated_at = NOW()
        WHERE status = 'pending' AND attempts >= $1
      `, [boundedMaxAttempts]);
      return db.any<EvolutionSignal>(`
        WITH candidates AS (
          SELECT id
          FROM popagent_evolution_signals
          WHERE status = 'pending'
            AND next_attempt_at <= NOW()
          ORDER BY next_attempt_at, created_at, id
          FOR UPDATE SKIP LOCKED
          LIMIT $1
        ), updated AS (
          UPDATE popagent_evolution_signals AS signal
          SET status = 'processing',
              attempts = signal.attempts + 1,
              error = NULL,
              updated_at = NOW()
          FROM candidates
          WHERE signal.id = candidates.id
          RETURNING signal.*
        )
        SELECT ${SIGNAL_COLUMNS} FROM updated
      `, [boundedLimit]);
    });
    return claimed.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
  }

  async recoverStaleClaims(staleAfterMs: number, maxAttempts: number): Promise<number> {
    await this.init();
    const boundedStaleAfterMs = Math.min(Math.max(Math.trunc(staleAfterMs), 60_000), 604_800_000);
    return this.storage.db.tx(async (db) => {
      const stale = await db.any<{ id: string }>(`
        SELECT id
        FROM popagent_evolution_signals
        WHERE status = 'processing'
          AND updated_at < NOW() - ($1 * INTERVAL '1 millisecond')
        FOR UPDATE
      `, [boundedStaleAfterMs]);
      if (!stale.length) return 0;
      const policy = await db.one<ApplyPolicy>(`
        SELECT enabled,
               auto_apply_strategies AS "autoApplyStrategies",
               auto_create_skills AS "autoCreateSkills",
               auto_retain_facts AS "autoRetainFacts"
        FROM popagent_autonomy_settings
        WHERE singleton = TRUE
        FOR UPDATE
      `);
      const disabled = !policy.enabled
        || (!policy.autoApplyStrategies && !policy.autoCreateSkills && !policy.autoRetainFacts);
      const result = await db.query(`
        UPDATE popagent_evolution_signals
        SET status = CASE
              WHEN $3 THEN 'ignored'
              WHEN attempts >= $2 THEN 'dead-letter'
              ELSE 'pending'
            END,
            attempts = CASE WHEN $3 THEN GREATEST(attempts - 1, 0) ELSE attempts END,
            next_attempt_at = CASE WHEN $3 OR attempts >= $2 THEN NULL ELSE NOW() END,
            processed_at = CASE WHEN $3 OR attempts >= $2 THEN NOW() ELSE NULL END,
            error = CASE
              WHEN $3 THEN NULL
              ELSE 'Reflection worker stopped before completing the claimed batch'
            END,
            updated_at = NOW()
        WHERE id = ANY($1::text[]) AND status = 'processing'
      `, [stale.map(({ id }) => id), maxAttempts, disabled]);
      return result.rowCount ?? 0;
    });
  }

  async failClaimed(signalIds: string[], error: unknown, maxAttempts: number): Promise<void> {
    if (!signalIds.length) return;
    await this.init();
    await this.storage.db.none(`
      UPDATE popagent_evolution_signals
      SET status = CASE WHEN attempts >= $3 THEN 'dead-letter' ELSE 'pending' END,
          next_attempt_at = CASE
            WHEN attempts >= $3 THEN NULL
            ELSE NOW() + LEAST(3600000, 60000 * power(2, GREATEST(attempts - 1, 0))) * INTERVAL '1 millisecond'
          END,
          processed_at = CASE WHEN attempts >= $3 THEN NOW() ELSE NULL END,
          error = $2,
          updated_at = NOW()
      WHERE id = ANY($1::text[]) AND status = 'processing'
    `, [signalIds.slice(0, 100), errorText(error), maxAttempts]);
  }

  async applyClaimedBatch(
    signalIds: string[],
    rawProposals: EvolutionProposal[],
    signal?: AbortSignal,
  ): Promise<EvolutionRevision[]> {
    if (!signalIds.length) return [];
    if (rawProposals.length > 100) throw new RangeError("Evolution batch exceeds 100 proposals");
    if (rawProposals.some(({ targetType }) => targetType === "fact")) await longTermMemory.init();
    await this.init();
    const claimedIds = [...new Set(signalIds.slice(0, 100))];
    const evidenceIds = new Set(claimedIds);

    return this.storage.db.tx(async (db) => {
      const claimed = await db.any<{ id: string; sessionId: string | null }>(`
        SELECT id, session_id AS "sessionId"
        FROM popagent_evolution_signals
        WHERE id = ANY($1::text[]) AND status = 'processing'
        FOR UPDATE
      `, [claimedIds]);
      if (claimed.length !== claimedIds.length) throw new Error("Evolution batch is no longer fully claimed");

      const policy = await db.one<ApplyPolicy>(`
        SELECT enabled,
               auto_apply_strategies AS "autoApplyStrategies",
               auto_create_skills AS "autoCreateSkills",
               auto_retain_facts AS "autoRetainFacts"
        FROM popagent_autonomy_settings
        WHERE singleton = TRUE
        FOR UPDATE
      `);
      signal?.throwIfAborted();
      const policyDisabled = !policy.enabled
        || (!policy.autoApplyStrategies && !policy.autoCreateSkills && !policy.autoRetainFacts)
        || rawProposals.some((proposal) =>
          (proposal.targetType === "overlay" && !policy.autoApplyStrategies)
          || (proposal.targetType === "skill" && !policy.autoCreateSkills)
          || (proposal.targetType === "fact" && !policy.autoRetainFacts)
        );
      if (policyDisabled) {
        await db.none(`
          UPDATE popagent_evolution_signals
          SET status = 'ignored',
              attempts = GREATEST(attempts - 1, 0),
              next_attempt_at = NULL,
              processed_at = NOW(),
              error = NULL,
              updated_at = NOW()
          WHERE id = ANY($1::text[]) AND status = 'processing'
        `, [claimedIds]);
        return [];
      }

      const proposals = rawProposals.map((proposal) => validateProposal(proposal, evidenceIds, policy));
      const targets = new Set<string>();
      for (const proposal of proposals) {
        const identity = `${proposal.agentId}\u0000${proposal.targetType}\u0000${proposal.targetKey}`;
        if (targets.has(identity)) throw new Error("Evolution batch contains duplicate targets");
        targets.add(identity);
      }
      const agentIds = [...new Set(proposals.map(({ agentId }) => agentId))];
      if (agentIds.length) {
        const agents = await db.any<{ id: string }>(`
          SELECT id FROM popagent_agents WHERE id = ANY($1::text[]) FOR SHARE
        `, [agentIds]);
        if (agents.length !== agentIds.length) throw new Error("Evolution proposal targets an unknown agent");
      }

      signal?.throwIfAborted();
      const revisions: EvolutionRevision[] = [];
      const appliedEvidenceIds = new Set<string>();
      for (const proposal of proposals) {
        signal?.throwIfAborted();
        let beforeContent: string | null;
        if (proposal.targetType === "overlay") {
          const overlay = await db.oneOrNone<{ content: string }>(`
            SELECT content FROM popagent_learned_overlays
            WHERE agent_id = $1 FOR UPDATE
          `, [proposal.agentId]);
          beforeContent = overlay?.content ?? null;
          if (beforeContent === proposal.content) continue;
          await db.none(`
            INSERT INTO popagent_learned_overlays (agent_id, content)
            VALUES ($1, $2)
            ON CONFLICT (agent_id) DO UPDATE
            SET content = EXCLUDED.content, updated_at = NOW()
          `, [proposal.agentId, proposal.content]);
        } else if (proposal.targetType === "skill") {
          const skill = await db.oneOrNone<StoredSkill>(`
            SELECT id, instructions, evolution_managed AS "evolutionManaged"
            FROM popagent_agent_skills
            WHERE agent_id = $1 AND name = $2
            FOR UPDATE
          `, [proposal.agentId, proposal.targetKey]);
          if (skill && !skill.evolutionManaged) {
            throw new Error("Evolution cannot modify a user-managed skill");
          }
          if (skill && containsSensitiveEvolutionText(skill.instructions)) {
            throw new Error("Evolution cannot retain sensitive material from an existing skill");
          }
          beforeContent = skill?.instructions ?? null;
          if (beforeContent === proposal.content) continue;
          if (skill) {
            await db.none(`
              UPDATE popagent_agent_skills
              SET instructions = $3, updated_at = NOW()
              WHERE agent_id = $1 AND id = $2 AND evolution_managed = TRUE
            `, [proposal.agentId, skill.id, proposal.content]);
          } else {
            await db.none(`
              INSERT INTO popagent_agent_skills (
                id, agent_id, name, description, instructions, "references",
                source_urls, enabled, user_invocable, evolution_managed
              ) VALUES ($1, $2, $3, 'Learned from execution evidence.', $4,
                        '{}'::jsonb, '[]'::jsonb, TRUE, FALSE, TRUE)
            `, [crypto.randomUUID(), proposal.agentId, proposal.targetKey, proposal.content]);
          }
        } else {
          const memoryKey = evolutionFactKey(proposal.agentId, proposal.targetKey);
          const fact = await db.oneOrNone<{ content: string }>(`
            SELECT content
            FROM popagent_memories
            WHERE resource_id = $1 AND kind = 'fact' AND memory_key = $2
            FOR UPDATE
          `, [RESOURCE_ID, memoryKey]);
          beforeContent = fact?.content ?? null;
          if (beforeContent === proposal.content) continue;
          await db.none(`
            DELETE FROM popagent_memories
            WHERE resource_id = $1 AND kind = 'fact' AND memory_key = $2
          `, [RESOURCE_ID, memoryKey]);
          const sourceSessionId = proposal.evidenceIds
            .map((id) => claimed.find((item) => item.id === id)?.sessionId)
            .find((id): id is string => Boolean(id)) ?? "evolution";
          await db.none(`
            INSERT INTO popagent_memories
              (id, resource_id, session_id, kind, memory_key, content, content_hash, importance)
            VALUES ($1, $2, $3, 'fact', $4, $5, encode(digest(lower($5), 'sha256'), 'hex'), 0.8)
            ON CONFLICT (resource_id, kind, content_hash)
            DO UPDATE SET session_id = EXCLUDED.session_id,
                          memory_key = COALESCE(EXCLUDED.memory_key, popagent_memories.memory_key),
                          importance = GREATEST(popagent_memories.importance, EXCLUDED.importance),
                          updated_at = NOW()
          `, [crypto.randomUUID(), RESOURCE_ID, sourceSessionId, memoryKey, proposal.content]);
        }

        const revision = await db.one<EvolutionRevision>(`
          INSERT INTO popagent_evolution_revisions (
            id, agent_id, target_type, target_key, before_content, after_content,
            rationale, evidence_ids, status
          ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'applied')
          RETURNING ${REVISION_COLUMNS}
        `, [
          crypto.randomUUID(),
          proposal.agentId,
          proposal.targetType,
          proposal.targetKey,
          beforeContent,
          proposal.content,
          proposal.rationale,
          JSON.stringify(proposal.evidenceIds),
        ]);
        revisions.push(revision);
        for (const evidenceId of proposal.evidenceIds) appliedEvidenceIds.add(evidenceId);
      }

      signal?.throwIfAborted();
      await db.none(`
        UPDATE popagent_evolution_signals
        SET status = CASE WHEN id = ANY($2::text[]) THEN 'applied' ELSE 'ignored' END,
            next_attempt_at = NULL,
            processed_at = NOW(),
            error = NULL,
            updated_at = NOW()
        WHERE id = ANY($1::text[]) AND status = 'processing'
      `, [claimedIds, [...appliedEvidenceIds]]);
      return revisions;
    });
  }

  async ignoreClaimedIfPolicyDisabled(signalIds: string[]): Promise<boolean> {
    if (!signalIds.length) return false;
    await this.init();
    const claimedIds = [...new Set(signalIds.slice(0, 100))];
    return this.storage.db.tx(async (db) => {
      const claimed = await db.any<{ id: string }>(`
        SELECT id FROM popagent_evolution_signals
        WHERE id = ANY($1::text[]) AND status = 'processing'
        FOR UPDATE
      `, [claimedIds]);
      if (!claimed.length) return false;
      const policy = await db.one<ApplyPolicy>(`
        SELECT enabled,
               auto_apply_strategies AS "autoApplyStrategies",
               auto_create_skills AS "autoCreateSkills",
               auto_retain_facts AS "autoRetainFacts"
        FROM popagent_autonomy_settings
        WHERE singleton = TRUE
        FOR UPDATE
      `);
      if (policy.enabled
        && (policy.autoApplyStrategies || policy.autoCreateSkills || policy.autoRetainFacts)) return false;
      await db.none(`
        UPDATE popagent_evolution_signals
        SET status = 'ignored',
            attempts = GREATEST(attempts - 1, 0),
            next_attempt_at = NULL,
            processed_at = NOW(),
            error = NULL,
            updated_at = NOW()
        WHERE id = ANY($1::text[]) AND status = 'processing'
      `, [claimedIds]);
      return true;
    });
  }

  async revertRevision(id: string): Promise<EvolutionRevision | undefined> {
    await this.init();
    await longTermMemory.init();
    return this.storage.db.tx(async (db) => {
      const source = await db.oneOrNone<EvolutionRevision>(`
        SELECT ${REVISION_COLUMNS}
        FROM popagent_evolution_revisions
        WHERE id = $1 AND status = 'applied'
        FOR SHARE
      `, [id]);
      if (!source || source.afterContent === null) return undefined;

      const overlay = source.targetType === "overlay"
        ? await db.oneOrNone<{ content: string }>(`
          SELECT content FROM popagent_learned_overlays
          WHERE agent_id = $1 FOR UPDATE
        `, [source.agentId])
        : undefined;
      const skill = source.targetType === "skill"
        ? await db.oneOrNone<StoredSkill>(`
          SELECT id, instructions, evolution_managed AS "evolutionManaged"
          FROM popagent_agent_skills
          WHERE agent_id = $1 AND name = $2
          FOR UPDATE
        `, [source.agentId, source.targetKey])
        : undefined;
      const fact = source.targetType === "fact"
        ? await db.oneOrNone<{ content: string }>(`
          SELECT content
          FROM popagent_memories
          WHERE resource_id = $1 AND kind = 'fact' AND memory_key = $2
          FOR UPDATE
        `, [RESOURCE_ID, evolutionFactKey(source.agentId, source.targetKey)])
        : undefined;
      const latest = await db.oneOrNone<{ id: string }>(`
        SELECT id
        FROM popagent_evolution_revisions
        WHERE agent_id = $1 AND target_type = $2 AND target_key = $3
        ORDER BY created_at DESC, id DESC
        LIMIT 1
      `, [source.agentId, source.targetType, source.targetKey]);
      if (latest?.id !== source.id) return undefined;

      if (source.targetType === "overlay") {
        if (overlay?.content !== source.afterContent) return undefined;
        if (source.beforeContent === null) {
          await db.none("DELETE FROM popagent_learned_overlays WHERE agent_id = $1", [source.agentId]);
        } else {
          await db.none(`
            UPDATE popagent_learned_overlays
            SET content = $2, updated_at = NOW()
            WHERE agent_id = $1
          `, [source.agentId, source.beforeContent]);
        }
      } else if (source.targetType === "skill") {
        if (!skill?.evolutionManaged || skill.instructions !== source.afterContent) return undefined;
        if (source.beforeContent === null) {
          await db.none("DELETE FROM popagent_agent_skills WHERE id = $1", [skill.id]);
        } else {
          await db.none(`
            UPDATE popagent_agent_skills
            SET instructions = $2, updated_at = NOW()
            WHERE id = $1 AND evolution_managed = TRUE
          `, [skill.id, source.beforeContent]);
        }
      } else {
        const memoryKey = evolutionFactKey(source.agentId, source.targetKey);
        if (fact?.content !== source.afterContent) return undefined;
        if (source.beforeContent === null) {
          await db.none(`
            DELETE FROM popagent_memories
            WHERE resource_id = $1 AND kind = 'fact' AND memory_key = $2
          `, [RESOURCE_ID, memoryKey]);
        } else {
          await db.none(`
            UPDATE popagent_memories
            SET content = $3,
                content_hash = encode(digest(lower($3), 'sha256'), 'hex'),
                updated_at = NOW()
            WHERE resource_id = $1 AND kind = 'fact' AND memory_key = $2
          `, [RESOURCE_ID, memoryKey, source.beforeContent]);
        }
      }

      return db.one<EvolutionRevision>(`
        INSERT INTO popagent_evolution_revisions (
          id, agent_id, target_type, target_key, before_content, after_content,
          rationale, evidence_ids, status, reverts_revision_id
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'reverted', $9)
        RETURNING ${REVISION_COLUMNS}
      `, [
        crypto.randomUUID(),
        source.agentId,
        source.targetType,
        source.targetKey,
        source.afterContent,
        source.beforeContent,
        `Reverted revision ${source.id}: ${source.rationale}`.slice(0, MAX_EVOLUTION_RATIONALE_CHARACTERS),
        JSON.stringify(source.evidenceIds),
        source.id,
      ]);
    });
  }
}

export const evolutionStore = new EvolutionStore();