// SF Memory Store — CRUD, ranked queries, maintenance, and prompt formatting
//
// Storage layer for auto-learned project memories. Follows context-store.ts patterns.
// All functions degrade gracefully: return empty results when DB unavailable, never throw.
import { basename } from "node:path";
import { createMemoryRelation } from "./memory-relations.js";
import { repoIdentity } from "./repo-identity.js";
import {
	_getAdapter,
	backfillMemoryTenantRows,
	computeStaticMemoryScore,
	decayMemoriesBefore,
	deleteMemoryEmbedding,
	incrementMemoryHitCount,
	incrementRuntimeCounter,
	incrementRuntimeCounterBy,
	insertMemoryExtractionAttempt,
	insertMemoryRow,
	isDbAvailable,
	listMemoryExtractionAttempts,
	markMemoryUnitProcessed,
	rewriteMemoryId,
	supersedeLowestRankedMemories,
	supersedeMemoryRow,
	transaction,
	updateMemoryContentRow,
} from "./sf-db.js";
import { querySmMemories } from "./sm-client.js";
import { queueMemorySync } from "./sync-scheduler.js";

export { isDbAvailable };

// ─── Tenant Identity ─────────────────────────────────────────────────────────
/**
 * Derive the current project's tenant identifier.
 * Uses repoIdentity (hash of git remote or local git root) as the stable key.
 * Returns a non-empty string, never null/undefined.
 */
function currentTenant(basePath) {
	try {
		return repoIdentity(basePath ?? process.cwd()) || "unknown";
	} catch {
		// Fallback: use cwd basename so memories are at least scoped to something
		return basename(basePath ?? process.cwd()) || "unknown";
	}
}

/**
 * Backfill tenant field for existing memory records that predate schema v68.
 *
 * Called once on session start when SM_ENABLED is set. Sets all rows where
 * tenant IS NULL to the current project's tenant. Idempotent: rows already
 * tagged are not touched. Non-fatal: backfill failure must never block session.
 *
 * @param {string} [basePath] - Project root (defaults to cwd)
 * @returns {number} - Count of rows updated (0 if DB unavailable or no NULLs)
 */
export function backfillMemoryTenants(basePath) {
	if (!isDbAvailable()) return 0;
	try {
		const tenant = currentTenant(basePath);
		return backfillMemoryTenantRows(tenant);
	} catch {
		return 0;
	}
}

// ─── Category Display Order ─────────────────────────────────────────────────
const CATEGORY_PRIORITY = {
	gotcha: 0,
	convention: 1,
	architecture: 2,
	pattern: 3,
	environment: 4,
	preference: 5,
};
const QUERY_TOKEN_STOPWORDS = new Set([
	"the",
	"and",
	"for",
	"with",
	"that",
	"this",
	"from",
	"into",
	"when",
	"then",
	"have",
	"should",
	"would",
	"could",
]);
function safeJsonArray(raw) {
	try {
		const parsed = JSON.parse(raw);
		return Array.isArray(parsed)
			? parsed.filter((t) => typeof t === "string")
			: [];
	} catch {
		return [];
	}
}
function tokenizeMemoryQuery(text) {
	return String(text ?? "")
		.toLowerCase()
		.split(/[^a-z0-9_.-]+/g)
		.map((token) => token.trim())
		.filter((token) => token.length >= 3 && !QUERY_TOKEN_STOPWORDS.has(token));
}
function rankMemoriesByLexicalQuery(memories, query, limit) {
	const queryTokens = tokenizeMemoryQuery(query);
	if (queryTokens.length === 0) return memories.slice(0, limit);
	return memories
		.map((memory, index) => {
			const haystack =
				`${memory.category ?? ""} ${memory.content ?? ""} ${(memory.tags ?? []).join(" ")}`.toLowerCase();
			const lexicalHits = queryTokens.reduce(
				(count, token) => count + (haystack.includes(token) ? 1 : 0),
				0,
			);
			const lexicalScore = lexicalHits / queryTokens.length;
			const staticScore = computeStaticMemoryScore(
				memory.confidence,
				memory.hit_count,
			);
			return {
				memory,
				index,
				score: staticScore + lexicalScore * 2,
			};
		})
		.sort((a, b) => b.score - a.score || a.index - b.index)
		.slice(0, limit)
		.map((entry) => entry.memory);
}
// ─── Row Mapping ────────────────────────────────────────────────────────────
function rowToMemory(row) {
	return {
		seq: row["seq"],
		id: row["id"],
		category: row["category"],
		content: row["content"],
		confidence: row["confidence"],
		source_unit_type: row["source_unit_type"] ?? null,
		source_unit_id: row["source_unit_id"] ?? null,
		created_at: row["created_at"],
		updated_at: row["updated_at"],
		superseded_by: row["superseded_by"] ?? null,
		hit_count: row["hit_count"],
		tags: safeJsonArray(row["tags"]),
		tenant: row["tenant"] ?? null,
	};
}
// ─── Query Functions ────────────────────────────────────────────────────────
/**
 * Get all memories where superseded_by IS NULL.
 * Returns [] if DB is not available. Never throws.
 */
export function getActiveMemories() {
	if (!isDbAvailable()) return [];
	const adapter = _getAdapter();
	if (!adapter) return [];
	try {
		const rows = adapter
			.prepare("SELECT * FROM memories WHERE superseded_by IS NULL")
			.all();
		return rows.map(rowToMemory);
	} catch {
		return [];
	}
}
/**
 * Get active memories ordered by ranking score (computeStaticMemoryScore).
 * Higher-scored memories are more relevant and frequently confirmed.
 *
 * Sorting happens in JS because the canonical scoring formula uses
 * log(), which the SQLite adapter doesn't reliably ship as a function
 * across builds. The pool is bounded by `WHERE superseded_by IS NULL`
 * which for typical projects (10s–1000s of rows) is cheap to sort.
 */
export function getActiveMemoriesRanked(limit = 30) {
	if (!isDbAvailable()) return [];
	const adapter = _getAdapter();
	if (!adapter) return [];
	try {
		const rows = adapter
			.prepare(`SELECT * FROM memories WHERE superseded_by IS NULL`)
			.all();
		return rows
			.map(rowToMemory)
			.map((m) => ({
				m,
				score: computeStaticMemoryScore(m.confidence, m.hit_count),
			}))
			.sort((a, b) => b.score - a.score)
			.slice(0, limit)
			.map((entry) => entry.m);
	} catch {
		return [];
	}
}
/**
 * Query-aware ranking: when an embedding gateway is configured and at least
 * some memories have vectors, rerank the top static-pool by combining the
 * static score with cosine similarity to the embedded query. Falls back
 * cleanly to pure static ranking when:
 *   - query is empty
 *   - no SF_LLM_GATEWAY_KEY (or gateway unreachable)
 *   - no memories have vectors yet (fresh DB or worker offline)
 *
 * The pool oversample (`limit * 5` capped at 50) ensures the embedding
 * rerank can promote a relevant-but-static-cold memory into the top-K.
 */
export async function getRelevantMemoriesRanked(query, limit = 10) {
	if (!isDbAvailable()) return [];
	// Pool sizing: 5× oversample for the embedding rerank to be meaningful,
	// but never less than `limit` itself — otherwise asking for limit=100
	// would silently cap at pool=50 and return only 50 results. Hard ceiling
	// at 200 to bound rerank latency on huge requests.
	const poolSize = Math.min(200, Math.max(limit, limit * 5));
	const pool = getActiveMemoriesRanked(poolSize);
	if (pool.length === 0 || !query.trim()) {
		return pool.slice(0, limit);
	}

	// Phase 3: Query cross-project memories from Singularity Memory (fail-open)
	//
	// Tenant gate (sf-mp723nju-2cpeoc): when SM_ENABLED is on, restrict retrieval
	// to the current project's tenant unless SM_CROSS_TENANT_ENABLED=1 is set.
	// Cross-tenant access logs a warning so operators have an audit trail.
	let crossProjectMemories = [];
	try {
		const smEnabled = process.env.SM_ENABLED === "true";
		const crossTenantEnabled = process.env.SM_CROSS_TENANT_ENABLED === "1";
		const tenant = currentTenant();

		if (smEnabled && crossTenantEnabled) {
			console.warn(
				`[sm-client] WARNING: SM_CROSS_TENANT_ENABLED=1 is set — cross-tenant memory retrieval is active. tenant=${tenant}`,
			);
		}

		const smResults = await querySmMemories(query, {
			limit: Math.max(3, Math.ceil(limit * 0.3)),
			smConnected: smEnabled,
			tenantId: tenant,
			crossTenantEnabled,
		});
		// Convert SM results to local format (all cross-project memories tagged as such)
		crossProjectMemories = (smResults || []).map((m) => ({
			id: `sm-${m.id || "unknown"}`,
			category: m.category || "pattern",
			content: m.content || m.text || "",
			confidence: Math.min(0.8, (m.confidence || 0.5) * 0.9), // Cap and reduce cross-project confidence
			source_unit_type: "cross-project",
			source_unit_id: m.source_tool || "sm",
			created_at: m.created_at || Date.now(),
			updated_at: m.updated_at || Date.now(),
			superseded_by: null,
			hit_count: m.hit_count || 0,
			tags: ["cross-project"],
		}));
	} catch {
		// SM unavailable or query failed — gracefully degrade to local-only
	}

	// Merge local and cross-project memories
	const mergedPool = [...pool, ...crossProjectMemories];
	if (mergedPool.length === 0 || !query.trim()) {
		return mergedPool.slice(0, limit);
	}
	try {
		const { embedQueryViaGateway, loadEmbeddingMap, rankMemoriesByEmbedding } =
			await import("./memory-embeddings.js");
		const [queryVec, embeddingMap] = await Promise.all([
			embedQueryViaGateway(query),
			Promise.resolve(loadEmbeddingMap()),
		]);
		if (!queryVec || embeddingMap.size === 0) {
			return rankMemoriesByLexicalQuery(mergedPool, query, limit);
		}
		let ranked = rankMemoriesByEmbedding(
			mergedPool.map((m) => ({
				id: m.id,
				staticScore: computeStaticMemoryScore(m.confidence, m.hit_count),
			})),
			queryVec,
			embeddingMap,
		);
		// One-pass relation boost: when memory_relations holds edges between
		// pooled memories, propagate score across edges. No-op when the
		// table is empty (the case until a writer adds edges) — listRelations
		// returns nothing per memory, applyRelationBoost short-circuits.
		try {
			const { applyRelationBoost } = await import("./memory-embeddings.js");
			const { listRelationsFor } = await import("./memory-relations.js");
			const edges = [];
			const seen = new Set();
			// Walk relations only for the cosine top-N (N = 2× limit) — we
			// don't need edges for tail-of-pool memories that won't reach
			// the result anyway.
			const probeIds = ranked.slice(0, Math.min(ranked.length, limit * 2));
			for (const r of probeIds) {
				for (const rel of listRelationsFor(r.id)) {
					const key = `${rel.from}->${rel.to}:${rel.rel}`;
					if (seen.has(key)) continue;
					seen.add(key);
					edges.push({
						from: rel.from,
						to: rel.to,
						confidence: rel.confidence,
					});
				}
			}
			ranked = applyRelationBoost(ranked, edges);
		} catch {
			// Relation boost is additive; failure preserves cosine ranking.
		}
		const byId = new Map(mergedPool.map((m) => [m.id, m]));
		// Top-K from cosine+relation rank — feed this into the optional rerank pass.
		const topK = [];
		for (const r of ranked) {
			const mem = byId.get(r.id);
			if (mem) topK.push(mem);
			if (topK.length >= limit) break;
		}
		// Optional rerank refinement: when SF_LLM_GATEWAY_RERANK_MODEL is set
		// AND the gateway has a rerank worker, re-score the cosine top-K with
		// the cross-encoder rerank model. Returns null in every other case
		// (no model configured, no worker online, network error) and we keep
		// the cosine-ranked order as-is — strictly additive precision boost.
		try {
			const { loadGatewayConfigFromEnv, rerankCandidates } = await import(
				"./memory-embeddings.js"
			);
			const cfg = loadGatewayConfigFromEnv();
			if (cfg?.rerankModel && topK.length > 1) {
				const scores = await rerankCandidates(
					cfg,
					query,
					topK.map((m) => ({ id: m.id, text: m.content })),
				);
				if (scores && scores.length > 0) {
					const scoreById = new Map(scores.map((s) => [s.id, s.score]));
					return [...topK].sort(
						(a, b) => (scoreById.get(b.id) ?? 0) - (scoreById.get(a.id) ?? 0),
					);
				}
			}
		} catch {
			// Rerank is best-effort; cosine order is already a fine answer.
		}
		return topK;
	} catch {
		return rankMemoriesByLexicalQuery(mergedPool, query, limit);
	}
}
/**
 * Generate the next memory ID: MEM + zero-padded 3-digit from MAX(seq).
 * Returns MEM001 if no memories exist.
 *
 * NOTE: For race-safe creation, prefer createMemory() which inserts with a
 * placeholder ID then updates to the seq-derived ID atomically.
 */
export function nextMemoryId() {
	if (!isDbAvailable()) return "MEM001";
	const adapter = _getAdapter();
	if (!adapter) return "MEM001";
	try {
		const row = adapter
			.prepare("SELECT MAX(seq) as max_seq FROM memories")
			.get();
		const maxSeq = row ? row["max_seq"] : null;
		if (maxSeq == null || Number.isNaN(maxSeq)) return "MEM001";
		const next = maxSeq + 1;
		return `MEM${String(next).padStart(3, "0")}`;
	} catch {
		return "MEM001";
	}
}
// ─── Mutation Functions ─────────────────────────────────────────────────────
/**
 * Insert a new memory with a race-safe auto-assigned ID.
 * Uses AUTOINCREMENT seq to derive the ID after insert, avoiding
 * the read-then-write race in concurrent scenarios (e.g. worktrees).
 * Returns the assigned ID, or null on failure.
 */
export function createMemory(fields) {
	if (!isDbAvailable()) return null;
	const adapter = _getAdapter();
	if (!adapter) return null;
	try {
		const now = new Date().toISOString();
		// Insert with a temporary placeholder ID — seq is auto-assigned
		const placeholder = `_TMP_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
		insertMemoryRow({
			id: placeholder,
			category: fields.category,
			content: fields.content,
			confidence: fields.confidence ?? 0.8,
			sourceUnitType: fields.source_unit_type ?? null,
			sourceUnitId: fields.source_unit_id ?? null,
			createdAt: now,
			updatedAt: now,
			tags: fields.tags,
			tenant: fields.tenant ?? currentTenant(),
		});
		// Derive the real ID from the assigned seq (SELECT is still fine via adapter)
		const row = adapter
			.prepare("SELECT seq FROM memories WHERE id = :id")
			.get({ ":id": placeholder });
		if (!row) return null; // Should not happen; fail gracefully
		const seq = row["seq"];
		const realId = `MEM${String(seq).padStart(3, "0")}`;
		rewriteMemoryId(placeholder, realId);
		// Verify the rename actually landed — return null on miss so callers
		// are not handed a stale _TMP_... placeholder as if it were a real ID.
		const verify = adapter
			.prepare("SELECT 1 FROM memories WHERE id = :id")
			.get({ ":id": realId });
		if (!verify) return null;

		// Queue SM sync (Tier 1.2 Phase 3): fire-and-forget, non-blocking
		// Uses project root (current working directory) as project identifier
		try {
			const projectId = process.cwd();
			queueMemorySync(projectId, realId, {
				type: fields.category,
				content: fields.content,
				projectId,
				confidence: fields.confidence ?? 0.8,
				createdAt: now,
			});
		} catch {
			// SM sync is best-effort; don't fail memory creation on scheduler errors
		}

		return realId;
	} catch {
		return null;
	}
}
/**
 * Update a memory's content and optionally its confidence.
 *
 * Invalidates the existing embedding row (if any): the stored vector was
 * computed against the old content and would otherwise rank incorrectly
 * on the next cosine query. The next runEmbeddingBackfill sweep will
 * re-embed the new content. This is best-effort — a missing embedding
 * row is the silent-fallback case the ranker already handles.
 */
export function updateMemoryContent(id, content, confidence) {
	if (!isDbAvailable()) return false;
	try {
		updateMemoryContentRow(id, content, confidence, new Date().toISOString());
		try {
			deleteMemoryEmbedding(id);
		} catch {
			// Stale-vector window is brief (until next backfill); never fail
			// the content update because the embedding cleanup raised.
		}
		return true;
	} catch {
		return false;
	}
}
/**
 * Reinforce a memory: increment hit_count, update timestamp.
 */
export function reinforceMemory(id) {
	if (!isDbAvailable()) return false;
	try {
		incrementMemoryHitCount(id, new Date().toISOString());
		return true;
	} catch {
		return false;
	}
}
/**
 * Mark a memory as superseded by another.
 *
 * Drops the superseded memory's embedding row too: loadAllEmbeddings()
 * already filters by superseded_by IS NULL, so the row was dead weight
 * — keeping it would just waste storage and confuse manual SQL audits.
 * Best-effort: a missing/failed delete doesn't block the supersede.
 */
export function supersedeMemory(oldId, newId) {
	if (!isDbAvailable()) return false;
	try {
		supersedeMemoryRow(oldId, newId, new Date().toISOString());
		try {
			deleteMemoryEmbedding(oldId);
		} catch {
			// Orphaned embedding rows are harmless; supersede still succeeded.
		}
		return true;
	} catch {
		return false;
	}
}
// ─── Processed Unit Tracking ────────────────────────────────────────────────
/**
 * Check if a unit has already been processed for memory extraction.
 */
export function isUnitProcessed(unitKey) {
	if (!isDbAvailable()) return false;
	const adapter = _getAdapter();
	if (!adapter) return false;
	try {
		const row = adapter
			.prepare("SELECT 1 FROM memory_processed_units WHERE unit_key = :key")
			.get({ ":key": unitKey });
		return row != null;
	} catch {
		return false;
	}
}
/**
 * Record that a unit has been processed for memory extraction.
 */
export function markUnitProcessed(unitKey, activityFile) {
	if (!isDbAvailable()) return false;
	try {
		markMemoryUnitProcessed(unitKey, activityFile, new Date().toISOString());
		return true;
	} catch {
		return false;
	}
}
/**
 * Record a memory extraction attempt without marking the unit processed.
 *
 * Purpose: make provider skips, rate limits, parse errors, and successful
 * closeouts queryable from the SF DB.
 *
 * Consumer: memory-extractor, auto-unit-closeout, and /memory status.
 */
export function recordMemoryExtractionAttempt({
	unitKey,
	unitType,
	unitId,
	activityFile,
	status,
	failureClass,
	reason,
	error,
}) {
	if (!isDbAvailable()) return false;
	try {
		insertMemoryExtractionAttempt({
			unitKey: unitKey ?? `${unitType}/${unitId}`,
			unitType,
			unitId,
			activityFile,
			status,
			failureClass,
			reason,
			error,
			createdAt: new Date().toISOString(),
		});
		return true;
	} catch {
		return false;
	}
}
/**
 * Return recent memory extraction attempts.
 *
 * Purpose: give diagnostics and tests a structured view of memory closeout
 * lifecycle without relying on sqlite3 shell availability.
 *
 * Consumer: /memory status and memory lifecycle tests.
 */
export function getRecentMemoryExtractionAttempts(limit = 10) {
	if (!isDbAvailable()) return [];
	try {
		return listMemoryExtractionAttempts(limit);
	} catch {
		return [];
	}
}
// ─── Maintenance ────────────────────────────────────────────────────────────
/**
 * Reduce confidence for memories not updated within the last N processed units.
 * "Stale" = updated_at is older than the Nth most recent processed_at.
 */
export function decayStaleMemories(thresholdUnits = 20) {
	if (!isDbAvailable()) return;
	const adapter = _getAdapter();
	if (!adapter) return;
	try {
		// Find the timestamp of the Nth most recent processed unit (read-only SELECT)
		const row = adapter
			.prepare(`SELECT processed_at FROM memory_processed_units
       ORDER BY processed_at DESC
       LIMIT 1 OFFSET :offset`)
			.get({ ":offset": thresholdUnits - 1 });
		if (!row) return; // not enough processed units yet
		const cutoff = row["processed_at"];
		decayMemoriesBefore(cutoff, new Date().toISOString());
	} catch {
		// non-fatal
	}
}
/**
 * Supersede lowest-ranked memories when count exceeds cap.
 *
 * After superseding, sweeps memory_embeddings for rows whose memory is now
 * superseded — keeps the embeddings table aligned with active memories so
 * loadAllEmbeddings doesn't carry dead vectors and storage doesn't grow
 * unbounded. Best-effort cleanup; the cap enforcement is the load-bearing
 * step.
 */
export function enforceMemoryCap(max = 50) {
	if (!isDbAvailable()) return;
	const adapter = _getAdapter();
	if (!adapter) return;
	try {
		const countRow = adapter
			.prepare(
				"SELECT count(*) as cnt FROM memories WHERE superseded_by IS NULL",
			)
			.get();
		const count = countRow?.["cnt"] ?? 0;
		if (count <= max) return;
		const excess = count - max;
		supersedeLowestRankedMemories(excess, new Date().toISOString());
		// Sweep orphaned embeddings for newly-superseded memories.
		try {
			adapter
				.prepare(`DELETE FROM memory_embeddings WHERE memory_id IN (
            SELECT id FROM memories WHERE superseded_by IS NOT NULL
          )`)
				.run();
		} catch {
			// Orphaned rows are harmless to queries (loadAllEmbeddings filters
			// by superseded_by IS NULL); skip-on-error keeps cap enforcement
			// load-bearing without coupling to embedding cleanup.
		}
	} catch {
		// non-fatal
	}
}
// ─── Action Application ─────────────────────────────────────────────────────
/**
 * Restricted action applicator for the memory consolidation (extractor) path.
 *
 * Only accepts `"add"` (alias for CREATE) and `"prune"` (alias for SUPERSEDE —
 * marks the memory superseded so it is excluded from active queries; no new
 * memory is created, which is the correct semantics for "this entry is now
 * stale/wrong").
 *
 * Rejects `"update"`, `"supersede"`, `"reinforce"`, and any other action with
 * an error. The model must prune-then-add instead of updating in place, so
 * every change is visible as an explicit add or remove.
 *
 * Other callers that need the full action surface should use applyMemoryActions.
 *
 * @param {Array<{action: string, [key: string]: unknown}>} actions
 * @param {string} unitType
 * @param {string} unitId
 */
export function applyConsolidationActions(actions, unitType, unitId) {
	if (!isDbAvailable() || actions.length === 0) return;
	// Validate all actions before touching the DB — fail fast on any violation.
	for (const action of actions) {
		const kind = action.action;
		if (kind !== "add" && kind !== "prune") {
			throw new Error(
				`applyConsolidationActions: action "${kind}" is not allowed on the consolidation path. ` +
					`Only "add" and "prune" are permitted. ` +
					`To change an existing memory, prune it and add a fresh one so the change is explicit.`,
			);
		}
	}
	try {
		transaction(() => {
			const createdInBatch = [];
			for (const action of actions) {
				if (action.action === "add") {
					const id = createMemory({
						category: action.category,
						content: action.content,
						confidence: action.confidence,
						source_unit_type: unitType,
						source_unit_id: unitId,
					});
					if (id) createdInBatch.push(id);
				} else if (action.action === "prune") {
					// "prune" supersedes the target memory with a sentinel value indicating
					// it was pruned during consolidation. Using the unitId as superseded_by
					// keeps the audit trail readable without requiring a new memory ID.
					const sentinelId = `pruned:${unitType}:${unitId}`;
					supersedeMemory(action.id, sentinelId);
				}
			}
			// Link co-created memories from the same consolidation pass (same rationale
			// as applyMemoryActions — they share narrative context).
			if (createdInBatch.length > 1) {
				try {
					for (let i = 0; i < createdInBatch.length; i++) {
						for (let j = i + 1; j < createdInBatch.length; j++) {
							createMemoryRelation(
								createdInBatch[i],
								createdInBatch[j],
								"related_to",
								0.5,
							);
						}
					}
				} catch {
					// Relation linkage is additive; skip on failure.
				}
			}
			enforceMemoryCap();
		});
	} catch (err) {
		// Re-throw validation errors (action-kind rejections) so the caller sees them.
		// Swallow DB-level errors as non-fatal (consistent with applyMemoryActions).
		if (
			err instanceof Error &&
			err.message.startsWith("applyConsolidationActions:")
		) {
			throw err;
		}
		// non-fatal — transaction will have rolled back
	}
}
/**
 * Process an array of memory actions in a transaction.
 * Calls enforceMemoryCap at the end.
 */
export function applyMemoryActions(actions, unitType, unitId) {
	if (!isDbAvailable() || actions.length === 0) return;
	try {
		transaction(() => {
			// Track IDs of memories created in THIS batch so we can link them
			// pairwise with `related_to` after the loop. Memories extracted
			// from the same slice transcript share narrative context — a
			// downstream query that hits any one of them benefits from
			// surfacing the cohort via the relation-boost pass in
			// getRelevantMemoriesRanked.
			const createdInBatch = [];
			for (const action of actions) {
				switch (action.action) {
					case "CREATE": {
						const id = createMemory({
							category: action.category,
							content: action.content,
							confidence: action.confidence,
							source_unit_type: unitType,
							source_unit_id: unitId,
						});
						if (id) createdInBatch.push(id);
						break;
					}
					case "UPDATE":
						updateMemoryContent(action.id, action.content, action.confidence);
						break;
					case "REINFORCE":
						reinforceMemory(action.id);
						break;
					case "SUPERSEDE":
						supersedeMemory(action.id, action.superseded_by);
						break;
				}
			}
			// Link co-extracted memories. Confidence 0.5 reflects that
			// "from the same extraction batch" is weaker evidence than
			// an explicit human-authored relation. Pairwise O(n²) is fine
			// for typical extractor batches of 1-5 memories. Best-effort —
			// individual relation failures shouldn't roll back the batch.
			if (createdInBatch.length > 1) {
				try {
					for (let i = 0; i < createdInBatch.length; i++) {
						for (let j = i + 1; j < createdInBatch.length; j++) {
							createMemoryRelation(
								createdInBatch[i],
								createdInBatch[j],
								"related_to",
								0.5,
							);
						}
					}
				} catch {
					// Relation linkage is additive; skip on any failure so the
					// memory batch still commits.
				}
			}
			enforceMemoryCap();
		});
	} catch {
		// non-fatal — transaction will have rolled back
	}
}
// ─── Prompt Formatting ──────────────────────────────────────────────────────
/**
 * Format memories as categorized markdown for system prompt injection.
 * Truncates to token budget (~4 chars per token).
 *
 * `preserveRankOrder` — when true, render in input order (no category
 * grouping). Pass true when the input came from a query-aware ranker
 * (`getRelevantMemoriesRanked`) so semantic relevance dominates over
 * `CATEGORY_PRIORITY`. Default false keeps the historical category-
 * grouped layout for callers that pass static-ranked input.
 *
 * Why the toggle exists: category grouping puts a high-relevance
 * "convention" memory after a low-relevance "gotcha" entry purely
 * because gotcha has higher CATEGORY_PRIORITY. With embedding-aware
 * ranking, that drops the most relevant items below dim ones.
 */
export function formatMemoriesForPrompt(
	memories,
	tokenBudget = 2000,
	preserveRankOrder = false,
) {
	if (memories.length === 0) return "";
	const charBudget = tokenBudget * 4;
	const header = "## Project Memory (auto-learned)\n";
	let output = header;
	let remaining = charBudget - header.length;
	const renderedMemories = [];
	if (preserveRankOrder) {
		// Render in input order — caller already ranked semantically. Each
		// bullet shows the category inline so the agent can still tell
		// gotchas from conventions without losing rank order.
		for (const item of memories) {
			const bullet = `- [${item.category}] ${item.content}\n`;
			if (remaining < bullet.length) break;
			output += bullet;
			renderedMemories.push(item);
			remaining -= bullet.length;
		}
		recordMemoryPromptUsage(renderedMemories);
		const rendered = output.trimEnd();
		recordMemoryPromptTelemetry(rendered);
		return rendered;
	}
	// Group by category
	const grouped = new Map();
	for (const m of memories) {
		const list = grouped.get(m.category) ?? [];
		list.push(m);
		grouped.set(m.category, list);
	}
	// Sort categories by priority
	const sortedCategories = [...grouped.keys()].sort(
		(a, b) => (CATEGORY_PRIORITY[a] ?? 99) - (CATEGORY_PRIORITY[b] ?? 99),
	);
	for (const category of sortedCategories) {
		const items = grouped.get(category);
		const catHeader = `\n### ${category.charAt(0).toUpperCase() + category.slice(1)}\n`;
		if (remaining < catHeader.length + 10) break;
		output += catHeader;
		remaining -= catHeader.length;
		for (const item of items) {
			const bullet = `- ${item.content}\n`;
			if (remaining < bullet.length) break;
			output += bullet;
			renderedMemories.push(item);
			remaining -= bullet.length;
		}
	}
	recordMemoryPromptUsage(renderedMemories);
	const rendered = output.trimEnd();
	recordMemoryPromptTelemetry(rendered);
	return rendered;
}

/**
 * Record approximate prompt cost for rendered memory sections.
 *
 * Purpose: make always-on memory injection visible in headless query/status
 * output instead of hiding token growth in prompt assembly.
 *
 * Consumer: formatMemoriesForPrompt whenever a non-empty memory section is
 * rendered for an autonomous prompt.
 */
export function recordMemoryPromptTelemetry(renderedSection) {
	if (!isDbAvailable() || !renderedSection) return;
	try {
		incrementRuntimeCounter("memory_inject_count");
		incrementRuntimeCounterBy(
			"memory_inject_chars_total",
			renderedSection.length,
		);
	} catch {
		// Prompt rendering must never fail because telemetry failed.
	}
}

/**
 * Increment hit counts for memories that were actually injected into a prompt.
 *
 * Purpose: make memory ranking reflect real reuse instead of only explicit
 * reinforcement events.
 *
 * Consumer: formatMemoriesForPrompt callers across autonomous prompts.
 */
export function recordMemoryPromptUsage(memories) {
	if (!isDbAvailable()) return;
	const now = new Date().toISOString();
	const seen = new Set();
	for (const memory of memories) {
		const id = memory?.id;
		if (typeof id !== "string" || id.startsWith("sm-") || seen.has(id)) {
			continue;
		}
		seen.add(id);
		try {
			incrementMemoryHitCount(id, now);
		} catch {
			// Prompt rendering must never fail because memory telemetry failed.
		}
	}
}
