UNPKG

agents

Version:

A home for your AI agents

2,063 lines 85.7 kB
import { i as _classPrivateFieldInitSpec, n as _classPrivateFieldSet2, r as _assertClassBrand, t as _classPrivateFieldGet2 } from "../classPrivateFieldGet2-DZBYAB34.js";
import { t as LifecycleCapability } from "../capability-B4WbF81e.js";
import { t as _classPrivateMethodInitSpec } from "../classPrivateMethodInitSpec-qMjJ6sHQ.js";
import { SqlError } from "../sql-error.js";
import { n as sanitizeMessage, t as byteLength } from "../sanitize-D9TujEK8.js";
import { i as estimatedDataUrlBytes, n as estimateMessageTokens, r as estimateStringTokens, t as estimateAttachmentTokens } from "../tokens-nHAKcN6M.js";
import { createHash } from "node:crypto";
//#region src/sessions/chunking.ts
/**
* Row chunking for the Sessions capability.
*
* SQLite on a Durable Object caps how many bytes one row may hold, so a
* message whose serialized JSON exceeds the budget is split across the
* message row and numbered continuation rows. Reads concatenate the slices
* back into the original string, so a round-trip is exact and nothing is
* ever truncated or too large to store.
*
* The split is on BYTES, not characters: SQLite's limit is a byte limit and
* one character can be up to four of them. A boundary is never placed
* between a high surrogate and its low surrogate either, because a lone
* surrogate is not valid UTF-8 and would not survive the round-trip.
* `splitContent(s).join("")` always equals `s`.
*/
/**
* Serialized byte ceiling for one stored row. A message larger than this is
* split across continuation rows; the overwhelmingly common message fits in
* one row and costs exactly one billed row write, as it always has.
*/
const MAX_INLINE_ROW_BYTES = 1536 * 1024;
/**
* Split a serialized message into row-sized slices, root slice first.
*
* Always returns at least one slice, and never an empty slice unless the
* input itself is empty. Slice 0 lives in the message row; the rest become
* continuation rows numbered from 1.
*/
function splitContent(json, budget = MAX_INLINE_ROW_BYTES) {
	if (json.length === 0) return [""];
	const slices = [];
	let start = 0;
	let bytes = 0;
	let index = 0;
	while (index < json.length) {
		const code = json.charCodeAt(index);
		let width;
		let step = 1;
		if (code < 128) width = 1;
		else if (code < 2048) width = 2;
		else if (code >= 55296 && code <= 56319 && index + 1 < json.length) {
			const low = json.charCodeAt(index + 1);
			if (low >= 56320 && low <= 57343) {
				width = 4;
				step = 2;
			} else width = 3;
		} else width = 3;
		if (bytes > 0 && bytes + width > budget) {
			slices.push(json.slice(start, index));
			start = index;
			bytes = 0;
		}
		bytes += width;
		index += step;
	}
	slices.push(json.slice(start));
	return slices;
}
//#endregion
//#region src/sessions/attachment-store.ts
/**
* Content-addressed byte storage for session attachments.
*
* An attachment is a typed media payload that never belongs inside a message
* row: an image, an audio clip, a PDF. The store holds the raw bytes under
* their SHA-256 and hands back a content address; the message keeps only a
* pointer part. Payloads larger than one SQLite row are split across numbered
* chunk rows, the same way an oversized message is.
*
* Everything here is synchronous, so bytes and the message row that points at
* them commit in ONE transaction. There is no window in which a stored pointer
* has no bytes behind it, and no cleanup pass for half-written payloads.
*
* Content addressing buys idempotency — a replayed append re-derives the same
* address and stores nothing new. It is not a space-saving claim: two messages
* that happen to carry the same image share a record, but nothing in the design
* depends on that being common.
*/
/**
* SQLite window for one attachment chunk. Attachments are immutable, so a
* larger row means fewer billed writes; this leaves headroom below SQLite's
* 2 MiB ceiling for the row key and record overhead.
*/
const ATTACHMENT_CHUNK_BYTES = MAX_INLINE_ROW_BYTES;
/** The content address of a payload. Pure: no storage access. */
function hashPayload(bytes) {
	return createHash("sha256").update(bytes).digest("hex");
}
/** A standalone copy of a slice, since `SqlParam` accepts only `ArrayBuffer`. */
function chunkBuffer(bytes, start, end) {
	return bytes.slice(start, end).buffer;
}
var _io = /* @__PURE__ */ new WeakMap();
var _AttachmentStore_brand = /* @__PURE__ */ new WeakSet();
var AttachmentStore = class {
	constructor(io) {
		_classPrivateMethodInitSpec(this, _AttachmentStore_brand);
		_classPrivateFieldInitSpec(this, _io, void 0);
		_classPrivateFieldSet2(_io, this, io);
	}
	ensureTables() {
		_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_meta (
        hash TEXT PRIMARY KEY,
        bytes INTEGER NOT NULL,
        media_type TEXT NOT NULL,
        chunks INTEGER NOT NULL
      ) WITHOUT ROWID`, []);
		_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_chunks (
        hash TEXT NOT NULL,
        idx INTEGER NOT NULL,
        data BLOB NOT NULL,
        PRIMARY KEY (hash, idx)
      ) WITHOUT ROWID`, []);
		_classPrivateFieldGet2(_io, this).sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_attachment_refs (
        session_id TEXT NOT NULL,
        message_id TEXT NOT NULL,
        hash TEXT NOT NULL,
        PRIMARY KEY (session_id, message_id, hash)
      ) WITHOUT ROWID`, []);
	}
	/**
	* Store one payload under its precomputed address. Idempotent: bytes that
	* are already present cost a single read and no writes.
	*
	* Call inside the caller's transaction so the payload and the message that
	* references it commit together. The address is passed in rather than
	* derived so hashing stays outside the transaction.
	*/
	put(payload, hash) {
		const { bytes, mediaType } = payload;
		if (_classPrivateFieldGet2(_io, this).sql("SELECT hash FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]).length > 0) return;
		let chunks = 0;
		for (let start = 0; start < bytes.length; start += ATTACHMENT_CHUNK_BYTES) {
			const end = Math.min(start + ATTACHMENT_CHUNK_BYTES, bytes.length);
			_classPrivateFieldGet2(_io, this).sqlWrite("INSERT INTO cf_agents_session_attachment_chunks (hash, idx, data) VALUES (?, ?, ?)", [
				hash,
				chunks,
				chunkBuffer(bytes, start, end)
			]);
			chunks++;
		}
		_classPrivateFieldGet2(_io, this).sqlWrite(`INSERT INTO cf_agents_session_attachment_meta (hash, bytes, media_type, chunks)
       VALUES (?, ?, ?, ?)`, [
			hash,
			bytes.length,
			mediaType,
			chunks
		]);
	}
	/** Read one payload back, or `undefined` when the address is unknown. */
	get(hash) {
		const [meta] = _classPrivateFieldGet2(_io, this).sql("SELECT bytes, media_type, chunks FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]);
		if (!meta) return void 0;
		const out = new Uint8Array(meta.bytes);
		if (meta.chunks > 0) {
			const rows = _classPrivateFieldGet2(_io, this).sql("SELECT idx, data FROM cf_agents_session_attachment_chunks WHERE hash = ? ORDER BY idx", [hash]);
			let offset = 0;
			for (const row of rows) {
				const slice = new Uint8Array(row.data);
				out.set(slice, offset);
				offset += slice.length;
			}
		}
		return {
			mediaType: meta.media_type,
			bytes: out
		};
	}
	/** Record that one message references these payloads. */
	addRefs(sessionId, messageId, hashes) {
		for (const hash of hashes) _classPrivateFieldGet2(_io, this).sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_attachment_refs
           (session_id, message_id, hash) VALUES (?, ?, ?)`, [
			sessionId,
			messageId,
			hash
		]);
	}
	/**
	* Point one message at exactly `hashes`, adding and dropping references to
	* match, and collect whatever that orphaned. Call after the new payloads are
	* stored, so a hash the message still uses is never briefly unreferenced.
	*/
	replaceRefs(sessionId, messageId, hashes) {
		const current = _classPrivateFieldGet2(_io, this).sql(`SELECT hash FROM cf_agents_session_attachment_refs
        WHERE session_id = ? AND message_id = ?`, [sessionId, messageId]);
		const wanted = new Set(hashes);
		const held = new Set(current.map((row) => row.hash));
		const dropped = [];
		for (const hash of held) {
			if (wanted.has(hash)) continue;
			_classPrivateFieldGet2(_io, this).sqlWrite(`DELETE FROM cf_agents_session_attachment_refs
          WHERE session_id = ? AND message_id = ? AND hash = ?`, [
				sessionId,
				messageId,
				hash
			]);
			dropped.push(hash);
		}
		this.addRefs(sessionId, messageId, hashes.filter((hash) => !held.has(hash)));
		if (dropped.length > 0) _assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, dropped);
	}
	/**
	* Drop the references held by the given messages and collect any payload
	* that no longer has a reader.
	*/
	releaseMessages(sessionId, messageIds) {
		if (messageIds.length === 0) return;
		const ids = JSON.stringify(messageIds);
		const orphanCandidates = _classPrivateFieldGet2(_io, this).sql(`SELECT DISTINCT hash FROM cf_agents_session_attachment_refs
        WHERE session_id = ? AND message_id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
		if (orphanCandidates.length === 0) return;
		_classPrivateFieldGet2(_io, this).sqlWrite(`DELETE FROM cf_agents_session_attachment_refs
        WHERE session_id = ? AND message_id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
		_assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, orphanCandidates.map((row) => row.hash));
	}
	/** Drop every reference held by one session and collect what it orphaned. */
	releaseSession(sessionId) {
		const candidates = _classPrivateFieldGet2(_io, this).sql("SELECT DISTINCT hash FROM cf_agents_session_attachment_refs WHERE session_id = ?", [sessionId]);
		if (candidates.length === 0) return;
		_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_refs WHERE session_id = ?", [sessionId]);
		_assertClassBrand(_AttachmentStore_brand, this, _collect).call(this, candidates.map((row) => row.hash));
	}
};
/** Delete payloads that no reference points at any more. */
function _collect(hashes) {
	for (const hash of hashes) {
		if (_classPrivateFieldGet2(_io, this).sql("SELECT hash FROM cf_agents_session_attachment_refs WHERE hash = ? LIMIT 1", [hash]).length > 0) continue;
		_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_chunks WHERE hash = ?", [hash]);
		_classPrivateFieldGet2(_io, this).sqlWrite("DELETE FROM cf_agents_session_attachment_meta WHERE hash = ?", [hash]);
	}
}
//#endregion
//#region src/sessions/attachment-ingest.ts
/**
* Which parts of a message become attachments, and how they come back.
*
* The rule is typed, not size-based: a part that DECLARES a non-text media
* type and carries its payload inline is an attachment, whatever its size.
* Text, reasoning and plain tool output are never extracted — they stay in the
* row and, if they are too large for one, chunk across continuation rows.
*
* That distinction is the whole point. Sizing the rule off bytes would make a
* message's stored shape depend on how big an image happened to be, which is
* how the previous design ended up as a rescue mechanism that competed with
* row chunking. Extraction here is uniform, so a reader never has to ask why
* one image is a pointer and another is inline.
*
* Extraction is lossless: bytes move to the attachment store and a read puts
* them back verbatim. Nothing that shapes what a model SEES belongs here — a
* cap on tool output discards content and so lives on the read path, in
* `agents/context`, where it can change without having destroyed anything.
*/
/** Pointer scheme written into a stored part in place of its payload. */
const ATTACHMENT_URL_PREFIX = "attachment:sha256:";
/** Hostile or deeply nested tool output stops here rather than recursing forever. */
const MAX_WALK_DEPTH = 8;
/** Build the pointer for a content address. */
function attachmentUrl(hash) {
	return `${ATTACHMENT_URL_PREFIX}${hash}`;
}
/** The content address in a pointer, or `null` when the value is not one. */
function parseAttachmentUrl(url) {
	if (typeof url !== "string" || !url.startsWith(ATTACHMENT_URL_PREFIX)) return null;
	const hash = url.slice(18);
	return /^[0-9a-f]{64}$/.test(hash) ? hash : null;
}
/** Parse a base64 `data:` URL. Non-base64 data URLs are left alone. */
function parseDataUrl(url) {
	if (!url.startsWith("data:")) return null;
	const comma = url.indexOf(",");
	if (comma < 0) return null;
	const header = url.slice(5, comma);
	if (!header.endsWith(";base64")) return null;
	return {
		mediaType: header.slice(0, -7) || "application/octet-stream",
		payload: url.slice(comma + 1)
	};
}
/**
* Text payloads stay in the message. They chunk perfectly well, they are what
* FTS indexes, and moving them out would put prose behind a pointer for no gain.
*/
function isTextMediaType(mediaType) {
	return mediaType.startsWith("text/");
}
function decodeBase64(payload) {
	const compact = /[\t\n\f\r ]/.test(payload) ? payload.replace(/[\t\n\f\r ]/g, "") : payload;
	if (compact.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
	let binary;
	try {
		binary = atob(compact);
	} catch {
		return null;
	}
	const bytes = new Uint8Array(binary.length);
	for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
	return bytes;
}
function encodeBase64(bytes) {
	const step = 32768;
	let binary = "";
	for (let i = 0; i < bytes.length; i += step) binary += String.fromCharCode(...bytes.subarray(i, i + step));
	return btoa(binary);
}
/** Rebuild the exact `data:` URL a payload was extracted from. */
function dataUrl(mediaType, bytes) {
	return `data:${mediaType};base64,${encodeBase64(bytes)}`;
}
/**
* A value carrying inline media: anything that declares a `mediaType` and
* holds its bytes in a `url` data URL or a base64 `data` string. Covers AI SDK
* file parts and the media entries tool results return, without either shape
* being named here.
*/
function inlineMediaOf(value) {
	const declared = typeof value.mediaType === "string" ? value.mediaType : null;
	const url = value.url;
	if (typeof url === "string") {
		const parsed = parseDataUrl(url);
		if (!parsed) return null;
		const mediaType = declared ?? parsed.mediaType;
		if (isTextMediaType(mediaType)) return null;
		const bytes = decodeBase64(parsed.payload);
		return bytes ? {
			field: "url",
			mediaType,
			bytes
		} : null;
	}
	if (declared && typeof value.data === "string" && !isTextMediaType(declared)) {
		const bytes = decodeBase64(value.data);
		return bytes ? {
			field: "data",
			mediaType: declared,
			bytes
		} : null;
	}
	return null;
}
/**
* Replace inline media with pointers, collecting the payloads to store.
*
* Returns the original message by reference when nothing was extracted, so the
* overwhelmingly common text-only write allocates nothing and pays only a walk.
*/
function extractAttachments(message) {
	const attachments = [];
	const seen = /* @__PURE__ */ new Set();
	const walk = (value, depth) => {
		if (depth > MAX_WALK_DEPTH || value === null || typeof value !== "object") return value;
		if (Array.isArray(value)) {
			let changed = false;
			const next = value.map((entry) => {
				const walked = walk(entry, depth + 1);
				if (walked !== entry) changed = true;
				return walked;
			});
			return changed ? next : value;
		}
		const record = value;
		const media = inlineMediaOf(record);
		if (media) {
			const hash = hashPayload(media.bytes);
			if (!seen.has(hash)) {
				seen.add(hash);
				attachments.push({
					hash,
					payload: {
						bytes: media.bytes,
						mediaType: media.mediaType
					}
				});
			}
			return {
				...record,
				mediaType: media.mediaType,
				[media.field]: attachmentUrl(hash)
			};
		}
		let changed = false;
		const next = {};
		for (const [key, entry] of Object.entries(record)) {
			const walked = walk(entry, depth + 1);
			if (walked !== entry) changed = true;
			next[key] = walked;
		}
		return changed ? next : value;
	};
	if (message.parts.length === 0) return {
		message,
		attachments
	};
	const parts = walk(message.parts, 0);
	if (parts === message.parts) return {
		message,
		attachments
	};
	return {
		message: {
			...message,
			parts
		},
		attachments
	};
}
/**
* Put payloads back inline, undoing extraction exactly.
*
* A pointer whose bytes cannot be loaded is left as it is: an unresolvable
* pointer is a truthful record that the reference survived its payload, which
* only a bug could produce, and inventing a placeholder would hide it.
*/
function resolveAttachments(message, load) {
	const walk = (value, depth) => {
		if (depth > MAX_WALK_DEPTH || value === null || typeof value !== "object") return value;
		if (Array.isArray(value)) {
			let changed = false;
			const next = value.map((entry) => {
				const walked = walk(entry, depth + 1);
				if (walked !== entry) changed = true;
				return walked;
			});
			return changed ? next : value;
		}
		const record = value;
		const urlHash = parseAttachmentUrl(record.url);
		if (urlHash) {
			const loaded = load(urlHash);
			if (!loaded) return value;
			return {
				...record,
				url: dataUrl(loaded.mediaType, loaded.bytes)
			};
		}
		const dataHash = parseAttachmentUrl(record.data);
		if (dataHash) {
			const loaded = load(dataHash);
			if (!loaded) return value;
			return {
				...record,
				data: encodeBase64(loaded.bytes)
			};
		}
		let changed = false;
		const next = {};
		for (const [key, entry] of Object.entries(record)) {
			const walked = walk(entry, depth + 1);
			if (walked !== entry) changed = true;
			next[key] = walked;
		}
		return changed ? next : value;
	};
	const parts = walk(message.parts, 0);
	return parts === message.parts ? message : {
		...message,
		parts
	};
}
//#endregion
//#region src/sessions/compaction-helpers.ts
/** Prefix for all compaction messages (overlays and summaries) */
const COMPACTION_PREFIX = "compaction_";
/** Head messages kept verbatim so the conversation's opening survives. */
const PROTECT_HEAD = 3;
/** Tail messages kept verbatim regardless of the token budget. */
const MIN_TAIL_MESSAGES = 2;
/** Check if a message is a compaction message */
function isCompactionMessage(msg) {
	return msg.id.startsWith(COMPACTION_PREFIX);
}
/**
* Check if a message contains tool invocations.
*/
function hasToolCalls(msg) {
	return msg.parts.some((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool");
}
/**
* Get tool call IDs from a message's parts.
*/
function getToolCallIds(msg) {
	const ids = /* @__PURE__ */ new Set();
	for (const part of msg.parts) if ((part.type.startsWith("tool-") || part.type === "dynamic-tool") && "toolCallId" in part) ids.add(part.toolCallId);
	return ids;
}
/**
* Check if a message is a tool result referencing a specific call ID.
*/
function isToolResultFor(msg, callIds) {
	return msg.parts.some((p) => (p.type.startsWith("tool-") || p.type === "dynamic-tool") && "toolCallId" in p && callIds.has(p.toolCallId));
}
/**
* Align a boundary index forward to avoid splitting tool call/result groups.
* If the boundary falls between an assistant message with tool calls and its
* tool results, move it forward past the results.
*/
function alignBoundaryForward(messages, idx) {
	if (idx <= 0 || idx >= messages.length) return idx;
	const prev = messages[idx - 1];
	if (prev.role === "assistant" && hasToolCalls(prev)) {
		const callIds = getToolCallIds(prev);
		while (idx < messages.length && isToolResultFor(messages[idx], callIds)) idx++;
	}
	return idx;
}
/**
* Align a boundary index backward to avoid splitting tool call/result groups.
* If the boundary falls in the middle of tool results, move it backward to
* include the assistant message that made the calls.
*/
function alignBoundaryBackward(messages, idx) {
	if (idx <= 0 || idx >= messages.length) return idx;
	while (idx > 0) {
		const msg = messages[idx];
		if (msg.role === "assistant" && hasToolCalls(msg)) break;
		const prev = messages[idx - 1];
		if (prev.role === "assistant" && hasToolCalls(prev)) {
			if (isToolResultFor(msg, getToolCallIds(prev))) {
				idx--;
				continue;
			}
		}
		break;
	}
	return idx;
}
/**
* Find the compression end boundary using a token budget for the tail.
* Walks backward from the end, accumulating tokens until budget is reached.
* Returns the index where compression should stop (everything from this
* index onward is protected).
*
* @param messages All messages
* @param headEnd Index where the protected head ends (compression starts here)
* @param tailTokenBudget Maximum tokens to keep in the tail
* @param minTailMessages Minimum messages to protect in the tail (fallback)
*/
function findTailCutByTokens(messages, headEnd, tailTokenBudget = 2e4, minTailMessages = 2) {
	const n = messages.length;
	let accumulated = 0;
	let tokenCut = n;
	for (let i = n - 1; i >= headEnd; i--) {
		const msgTokens = estimateMessageTokens([messages[i]]);
		if (accumulated + msgTokens > tailTokenBudget && tokenCut < n) break;
		accumulated += msgTokens;
		tokenCut = i;
	}
	const minCut = n - minTailMessages;
	return alignBoundaryBackward(messages, minCut >= headEnd ? Math.min(tokenCut, minCut) : tokenCut);
}
function computeSummaryBudget(messages) {
	const contentTokens = estimateMessageTokens(messages);
	const budget = Math.floor(contentTokens * .2);
	return Math.max(100, budget);
}
/**
* Build a prompt for LLM summarization of compressed messages.
*
* @param messages Messages to summarize
* @param previousSummary Previous summary for iterative updates (or null for first compaction)
* @param budget Target token count for the summary
*/
function buildSummaryPrompt(messages, previousSummary, budget) {
	const content = messages.map((msg) => {
		const textParts = msg.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n");
		const toolParts = msg.parts.filter((p) => p.type.startsWith("tool-") || p.type === "dynamic-tool").map((p) => {
			const tp = p;
			const parts = [`[Tool: ${tp.toolName ?? "unknown"}]`];
			if (tp.input) parts.push(`Input: ${JSON.stringify(tp.input).slice(0, 500)}`);
			if (tp.output) parts.push(`Output: ${JSON.stringify(tp.output).slice(0, 500)}`);
			return parts.join("\n");
		}).join("\n");
		return `[${msg.role}]\n${textParts}${toolParts ? "\n" + toolParts : ""}`;
	}).join("\n\n---\n\n");
	if (previousSummary) return `You are updating a conversation summary. A previous summary exists below. New conversation turns have occurred since then and need to be incorporated.

PREVIOUS SUMMARY:
${previousSummary}

NEW TURNS TO INCORPORATE:
${content}

Update the summary. PRESERVE existing information that is still relevant. ADD new information. Remove information only if it is clearly obsolete.

## Topic
[What the conversation is about]

## Key Points
[Important information, decisions, and conclusions from the conversation]

## Current State
[Where things stand now — what has been done, what is in progress]

## Open Items
[Unresolved questions, pending tasks, or next steps discussed]

Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
	return `Create a concise summary of this conversation that preserves the important information for future context.

CONVERSATION TO SUMMARIZE:
${content}

Use this structure:

## Topic
[What the conversation is about]

## Key Points
[Important information, decisions, and conclusions from the conversation]

## Current State
[Where things stand now — what has been done, what is in progress]

## Open Items
[Unresolved questions, pending tasks, or next steps discussed]

Target ~${budget} tokens. Be factual — only include information that was explicitly discussed in the conversation. Do NOT invent file paths, commands, or details that were not mentioned. Write only the summary body.`;
}
/**
* Reference compaction implementation.
*
* Implements the full hermes-style compaction algorithm:
* 1. Protect head messages (first N)
* 2. Protect tail by token budget (walk backward)
* 3. Align boundaries to tool call groups
* 4. Summarize middle section with LLM (structured format)
* 5. Iterative summary updates on subsequent compactions
*
* @example
* ```typescript
* import { createCompactFunction } from "agents/sessions";
*
* sessions
*   .session()
*   .onCompaction(
*     createCompactFunction({
*       summarize: (prompt) => generateText({ model, prompt }).then((r) => r.text)
*     })
*   )
*   .compactAfter(100_000);
* ```
*/
function createCompactFunction(opts) {
	const keepRecentTokens = opts.keepRecentTokens ?? 2e4;
	return async (messages) => {
		if (messages.length <= 5) return null;
		const compressStart = alignBoundaryForward(messages, PROTECT_HEAD);
		const compressEnd = findTailCutByTokens(messages, compressStart, keepRecentTokens, MIN_TAIL_MESSAGES);
		if (compressEnd <= compressStart) return null;
		const middleMessages = messages.slice(compressStart, compressEnd).filter((m) => !isCompactionMessage(m));
		if (middleMessages.length === 0) return null;
		const existingCompaction = messages.find(isCompactionMessage);
		const prompt = buildSummaryPrompt(middleMessages, existingCompaction ? existingCompaction.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n") : null, computeSummaryBudget(middleMessages));
		const summary = await opts.summarize(prompt);
		if (!summary.trim()) return null;
		return {
			fromMessageId: middleMessages[0].id,
			toMessageId: middleMessages[middleMessages.length - 1].id,
			summary
		};
	};
}
//#endregion
//#region src/sessions/overlays.ts
/**
* Compaction overlay planning shared by the read path and the stats
* derivation. Reproduces the selection semantics of the legacy
* `applyCompactions` exactly: walk the path root → leaf; at each position the
* newest overlay that starts here and ends at-or-after here on this branch
* wins; its span is skipped and later overlaps inside the span never apply.
*/
/** Plan overlay spans over an ordered list of path message ids. */
function planOverlays(pathIds, compactions) {
	if (compactions.length === 0) return [];
	const indexById = new Map(pathIds.map((id, index) => [id, index]));
	const spans = [];
	let i = 0;
	while (i < pathIds.length) {
		const compaction = compactions.filter((compaction) => compaction.fromMessageId === pathIds[i] && (indexById.get(compaction.toMessageId) ?? -1) >= i).at(-1);
		if (compaction) {
			const endIndex = indexById.get(compaction.toMessageId) ?? -1;
			if (endIndex >= i) {
				spans.push({
					startIndex: i,
					endIndex,
					compaction
				});
				i = endIndex + 1;
				continue;
			}
		}
		i++;
	}
	return spans;
}
/** The synthetic message an overlay span renders as. */
function overlayMessage(compaction) {
	return {
		id: `${COMPACTION_PREFIX}${compaction.id}`,
		role: "assistant",
		parts: [{
			type: "text",
			text: compaction.summary
		}],
		createdAt: /* @__PURE__ */ new Date()
	};
}
//#endregion
//#region src/sessions/core.ts
/**
* @internal Storage engine behind the Sessions capability. One instance per
* capability, owning the `cf_agents_session_*` tables. All methods assume the
* caller has settled startup ordering (`lifecycle.ready()` for public API).
*
* Write economics: rows written cost ~1000× rows read on DO SQLite. Every
* table is WITHOUT ROWID with no secondary index, so one row write bills one
* row. State is derived from existing rows, never kept in counter rows, and
* an unchanged update writes nothing. The only in-memory state is the tail
* of each session (its leaf id and next `seq`), read once per object
* lifetime because finding it means scanning the session's rows.
*/
/**
* Bounds for each content-hydration query on a history path. In workerd the
* SQLite allocator shares the isolate's memory budget with the JS heap, so
* oversized transient result sets surface as SQLITE_NOMEM (#1710). Chunks
* are bounded by BOTH row count and cumulative stored bytes.
*/
const HISTORY_CONTENT_CHUNK_SIZE = 50;
const HISTORY_CONTENT_CHUNK_BYTES = 4 * 1024 * 1024;
/**
* Rows per content window on a newest-first read. Such a read walks the path
* by id alone — no per-row byte subqueries, so no byte-bounded chunking — and
* is typically stopped by its consumer within the first few messages, so a
* small fixed window keeps both the rows read and the memory held low.
*/
const NEWEST_FIRST_WINDOW_ROWS = 8;
/**
* Deepest path a history read follows: the root row is depth 0, so a read
* returns at most this many rows plus one. A longer branch shows its most
* recent rows only, and `getRecentHistory` reports that as truncated.
*/
const MAX_PATH_DEPTH = 1e4;
var _reservedMetadataKeys = /* @__PURE__ */ new WeakMap();
var _listeners = /* @__PURE__ */ new WeakMap();
var _tails = /* @__PURE__ */ new WeakMap();
var _pathTokens = /* @__PURE__ */ new WeakMap();
var _attachments = /* @__PURE__ */ new WeakMap();
var _tablesEnsured = /* @__PURE__ */ new WeakMap();
var _fts = /* @__PURE__ */ new WeakMap();
var _SessionsCore_brand = /* @__PURE__ */ new WeakSet();
var SessionsCore = class {
	constructor(options, io) {
		_classPrivateMethodInitSpec(this, _SessionsCore_brand);
		_classPrivateFieldInitSpec(this, _reservedMetadataKeys, void 0);
		_classPrivateFieldInitSpec(this, _listeners, /* @__PURE__ */ new Set());
		_classPrivateFieldInitSpec(this, _tails, /* @__PURE__ */ new Map());
		_classPrivateFieldInitSpec(this, _pathTokens, /* @__PURE__ */ new Map());
		_classPrivateFieldInitSpec(this, _attachments, void 0);
		_classPrivateFieldInitSpec(this, _tablesEnsured, false);
		_classPrivateFieldInitSpec(this, _fts, false);
		this.io = io;
		_classPrivateFieldSet2(_attachments, this, new AttachmentStore(io));
		_classPrivateFieldSet2(_reservedMetadataKeys, this, options.reservedMetadataKeys ?? []);
	}
	ensureTables() {
		if (_classPrivateFieldGet2(_tablesEnsured, this)) return;
		this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_messages (
        session_id TEXT NOT NULL,
        id TEXT NOT NULL,
        seq INTEGER NOT NULL,
        parent_id TEXT,
        type TEXT NOT NULL DEFAULT 'message',
        role TEXT NOT NULL,
        content TEXT NOT NULL,
        content_chunks INTEGER NOT NULL DEFAULT 0,
        token_estimate INTEGER NOT NULL DEFAULT 0,
        created_at INTEGER NOT NULL,
        PRIMARY KEY (session_id, id)
      ) WITHOUT ROWID`, []);
		this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_message_chunks (
        session_id TEXT NOT NULL,
        id TEXT NOT NULL,
        idx INTEGER NOT NULL,
        content TEXT NOT NULL,
        PRIMARY KEY (session_id, id, idx)
      ) WITHOUT ROWID`, []);
		this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_compactions (
        session_id TEXT NOT NULL,
        id TEXT NOT NULL,
        seq INTEGER NOT NULL,
        summary TEXT NOT NULL,
        from_message_id TEXT NOT NULL,
        to_message_id TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        PRIMARY KEY (session_id, id)
      ) WITHOUT ROWID`, []);
		this.io.sqlWrite(`CREATE TABLE IF NOT EXISTS cf_agents_session_config (
        session_id TEXT NOT NULL,
        key TEXT NOT NULL,
        value TEXT NOT NULL,
        PRIMARY KEY (session_id, key)
      ) WITHOUT ROWID`, []);
		_classPrivateFieldGet2(_attachments, this).ensureTables();
		_classPrivateFieldSet2(_fts, this, _assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "cf_agents_session_fts"));
		_classPrivateFieldSet2(_tablesEnsured, this, true);
	}
	/**
	* Lift the legacy `assistant_*` message and compaction tables.
	*
	* The copy is pure SQL, so SQLite streams it rather than materializing rows
	* in the isolate. Each source is then verified row by row against its
	* destination and DROPPED: keeping tombstones would leave every upgraded
	* object holding its history twice inside the same 10 GB. A table whose
	* verification fails is left in place with a `session:migration:incomplete`
	* event, and the method returns false so the caller leaves the schema
	* version unstamped and retries on a later start. `assistant_config`
	* belongs to Think, which lifts and drops it itself.
	*/
	migrateLegacy() {
		_classPrivateFieldGet2(_pathTokens, this).clear();
		let complete = true;
		const drop = (name) => {
			if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, name)) this.io.sqlWrite(`DROP TABLE ${name}`, []);
		};
		/**
		* Drop a lifted source only once every one of its rows has a copy holding
		* the same payload. Matching on the key alone would accept a destination
		* row that merely occupies the key.
		*/
		const dropWhenCopied = (source, destination, payload) => {
			const [counts] = this.io.sql(`SELECT
           (SELECT COUNT(*) FROM ${source}) AS source,
           (SELECT COUNT(*) FROM ${source} AS legacy
             JOIN ${destination} AS lifted
               ON lifted.session_id = legacy.session_id
              AND lifted.id = legacy.id
              AND lifted.${payload} = legacy.${payload}) AS copied`, []);
			if (counts && counts.source === counts.copied) {
				drop(source);
				return;
			}
			complete = false;
			this.io.emit("session:migration:incomplete", {
				table: source,
				source: counts?.source ?? 0,
				copied: counts?.copied ?? 0
			});
		};
		if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "assistant_messages")) {
			this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_messages
          (session_id, id, seq, parent_id, role, content, token_estimate, created_at)
         SELECT session_id, id,
           ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, rowid ASC),
           parent_id, role, content,
           CAST(LENGTH(CAST(content AS BLOB)) / 4 AS INTEGER),
           COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0) * 1000
         FROM assistant_messages`, []);
			if (_classPrivateFieldGet2(_fts, this)) _assertClassBrand(_SessionsCore_brand, this, _backfillMissingFtsRows).call(this);
			dropWhenCopied("assistant_messages", "cf_agents_session_messages", "content");
		}
		if (_assertClassBrand(_SessionsCore_brand, this, _tableExists).call(this, "assistant_compactions")) {
			this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_compactions
          (session_id, id, seq, summary, from_message_id, to_message_id, created_at)
         SELECT session_id, id,
           ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, rowid ASC),
           summary, from_message_id, to_message_id,
           COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0) * 1000
         FROM assistant_compactions`, []);
			dropWhenCopied("assistant_compactions", "cf_agents_session_compactions", "summary");
		}
		drop("assistant_sessions");
		drop("assistant_fts");
		return complete;
	}
	subscribe(listener) {
		_classPrivateFieldGet2(_listeners, this).add(listener);
		return () => _classPrivateFieldGet2(_listeners, this).delete(listener);
	}
	/**
	* Dispatch after a durable write. A listener that throws must not turn a
	* committed write into a rejected call, so failures are reported through
	* telemetry and dispatch continues.
	*/
	async notify(event) {
		for (const listener of _classPrivateFieldGet2(_listeners, this)) try {
			await listener(event);
		} catch (error) {
			const detail = error instanceof Error ? error.message : String(error);
			console.warn(`[Sessions] change listener failed: ${detail}`);
			this.io.emit("session:error", {
				sessionId: event.sessionId,
				event: event.type,
				error: detail
			});
		}
	}
	exists(sessionId, id) {
		return this.io.sql("SELECT id FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]).length > 0;
	}
	getMessage(sessionId, id) {
		const content = _assertClassBrand(_SessionsCore_brand, this, _content).call(this, sessionId, id);
		const parsed = content === null ? null : _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, content);
		return parsed && _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed);
	}
	/**
	* Drop the in-memory tail and token-total caches for a session. For a
	* caller whose enclosing transaction rolled back after an append or update
	* ran inside it: the rows are gone but the caches already moved. The next
	* read re-derives both from storage.
	*/
	forgetCaches(sessionId) {
		_classPrivateFieldGet2(_tails, this).delete(sessionId);
		_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
	}
	latestLeafId(sessionId) {
		return _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId).leafId;
	}
	getLatestLeaf(sessionId) {
		const leafId = this.latestLeafId(sessionId);
		return leafId ? this.getMessage(sessionId, leafId) : null;
	}
	getBranches(sessionId, messageId) {
		const rows = this.io.sql(`SELECT id, content, content_chunks FROM cf_agents_session_messages
       WHERE session_id = ? AND parent_id = ? ORDER BY seq ASC`, [sessionId, messageId]);
		const continued = _assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, rows.filter((row) => row.content_chunks > 0).map((row) => row.id));
		const result = [];
		for (const row of rows) {
			const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, row.content_chunks === 0 ? row.content : row.content + (continued.get(row.id) ?? ""));
			if (parsed) result.push(_assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed));
		}
		return result;
	}
	/**
	* The active branch path as content-free rows, root → leaf. Recurses over
	* (id, parent_id) only — carrying content through the recursive queue
	* materializes the transcript several times inside SQLite (#1710).
	* `bytes` counts the message row, its continuation rows, AND the payloads
	* it points at, charged at the size they take once inlined, so a byte
	* budget over these rows bounds real hydrated memory.
	*/
	pathRowStats(sessionId, leafId) {
		const leaf = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
		if (!leaf) return [];
		return this.io.sql(`WITH RECURSIVE path(id, parent_id, depth) AS (
        SELECT id, parent_id, 0 FROM cf_agents_session_messages
        WHERE session_id = ? AND id = ?
        UNION ALL
        SELECT m.id, m.parent_id, p.depth + 1 FROM cf_agents_session_messages m
        JOIN path p ON m.id = p.parent_id
        WHERE m.session_id = ? AND p.depth < ${MAX_PATH_DEPTH}
      )
      SELECT path.id AS id, am.role AS role,
        LENGTH(CAST(am.content AS BLOB)) + CASE WHEN am.content_chunks = 0 THEN 0
          ELSE COALESCE((
            SELECT SUM(LENGTH(CAST(c.content AS BLOB)))
            FROM cf_agents_session_message_chunks c
            WHERE c.session_id = am.session_id AND c.id = am.id
          ), 0) END
        + COALESCE((
            SELECT SUM((meta.bytes + 2) / 3 * 4)
            FROM cf_agents_session_attachment_refs r
            JOIN cf_agents_session_attachment_meta meta ON meta.hash = r.hash
            WHERE r.session_id = am.session_id AND r.message_id = am.id
          ), 0) AS bytes,
        am.token_estimate AS tokenEstimate
      FROM path JOIN cf_agents_session_messages am
        ON am.session_id = ? AND am.id = path.id
      ORDER BY path.depth DESC`, [
			sessionId,
			leaf,
			sessionId,
			sessionId
		]);
	}
	/**
	* Stream the path ending at `leafId` (default: active leaf), root → leaf
	* (or leaf → root with `newestFirst`), compaction overlays collapsed. Peak
	* memory is one bounded content window — never the whole transcript.
	*/
	async *streamHistory(sessionId, options) {
		if (options.newestFirst === true) {
			yield* _assertClassBrand(_SessionsCore_brand, this, _walkFromLeaf).call(this, sessionId, options.leafId, options.signal);
			return;
		}
		const stats = this.pathRowStats(sessionId, options.leafId);
		if (stats.length === 0) return;
		yield* _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, stats, options.signal);
	}
	async getHistory(sessionId, options) {
		const messages = [];
		for await (const message of this.streamHistory(sessionId, options)) messages.push(message);
		return messages;
	}
	/**
	* Byte-budgeted read of the most recent messages on the active branch
	* path — the longest suffix whose stored size fits `maxContentBytes`.
	*
	* There is no message-count floor: one used to exist, and it admitted
	* rows regardless of size, so a window of media-heavy messages could
	* hydrate far past the limit meant to bound it. The newest message is
	* always returned even if it alone exceeds the budget, since returning
	* nothing is worse. Overlays whose anchors fall outside the window are
	* skipped, showing the raw recent messages.
	*/
	async getRecentHistory(sessionId, maxContentBytes, leafId) {
		const stats = this.pathRowStats(sessionId, leafId);
		if (stats.length === 0) return {
			messages: [],
			truncated: false,
			totalContentBytes: 0
		};
		const totalContentBytes = stats.reduce((sum, row) => sum + row.bytes, 0);
		let start = stats.length - 1;
		let used = stats[start].bytes;
		while (start > 0) {
			const next = stats[start - 1].bytes;
			if (used + next > maxContentBytes) break;
			start--;
			used += next;
		}
		const messages = [];
		for await (const message of _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, stats.slice(start))) messages.push(message);
		const capped = stats.length > MAX_PATH_DEPTH && _assertClassBrand(_SessionsCore_brand, this, _hasParent).call(this, sessionId, stats[0].id);
		return {
			messages,
			truncated: start > 0 || capped,
			totalContentBytes
		};
	}
	/**
	* Heuristic token estimate for the active path with compaction overlays
	* applied: stamped per-row estimates, minus compacted spans, plus their
	* summaries. Derived from content-free rows on each call; it gates cheap
	* triggers only, and model-reported usage stays authoritative.
	*/
	tokenEstimate(sessionId) {
		const leafId = this.latestLeafId(sessionId);
		const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
		if (memo && memo.leafId === leafId) return Math.max(0, Math.ceil(memo.total));
		const stats = this.pathRowStats(sessionId);
		const counted = new Set(stats.map((row) => row.id));
		let tokens = stats.reduce((sum, row) => sum + row.tokenEstimate, 0);
		for (const span of planOverlays(stats.map((row) => row.id), this.getCompactions(sessionId))) {
			for (let i = span.startIndex; i <= span.endIndex; i++) {
				tokens -= stats[i].tokenEstimate;
				counted.delete(stats[i].id);
			}
			tokens += estimateStringTokens(span.compaction.summary);
		}
		_classPrivateFieldGet2(_pathTokens, this).set(sessionId, {
			leafId,
			counted,
			total: tokens,
			depth: stats.length
		});
		return Math.max(0, Math.ceil(tokens));
	}
	stripReservedMetadata(message) {
		if (_classPrivateFieldGet2(_reservedMetadataKeys, this).length === 0 || typeof message.metadata !== "object" || message.metadata === null || Array.isArray(message.metadata)) return message;
		const metadata = { ...message.metadata };
		let changed = false;
		for (const key of _classPrivateFieldGet2(_reservedMetadataKeys, this)) if (key in metadata) {
			delete metadata[key];
			changed = true;
		}
		if (!changed) return message;
		if (Object.keys(metadata).length > 0) return {
			...message,
			metadata
		};
		const { metadata: _dropped, ...withoutMetadata } = message;
		return withoutMetadata;
	}
	/**
	* Stamped row estimate: the part heuristic over the message as written,
	* plus a weight per inline file payload so media never counts as zero.
	*/
	estimateRowTokens(message) {
		let tokens = estimateMessageTokens([message]);
		for (const part of message.parts) {
			if (part.type !== "file") continue;
			if (typeof part.url === "string" && part.url.startsWith("data:")) tokens += estimateAttachmentTokens(part.mediaType ?? "application/octet-stream", estimatedDataUrlBytes(part.url));
		}
		return tokens;
	}
	/**
	* Durable append. The caller has already sanitized the message. Message,
	* continuation, attachment, and FTS rows commit in one synchronous SQLite
	* transaction. Returns the stored message: the input itself when it was
	* inserted (extraction is lossless, so the two are identical), or the row
	* already holding the id when it was not.
	*/
	append(sessionId, message, parentId, tokenEstimate) {
		if (this.exists(sessionId, message.id)) {
			const existing = this.getMessage(sessionId, message.id);
			if (existing) return {
				inserted: false,
				message: existing
			};
		}
		const tail = _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId);
		let parent;
		if (parentId === void 0) parent = tail.leafId;
		else parent = parentId && this.exists(sessionId, parentId) ? parentId : null;
		const { message: staged, attachments } = extractAttachments(message);
		const slices = splitContent(JSON.stringify(staged));
		const seq = tail.nextSeq;
		this.io.transaction(() => {
			for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
			this.io.sqlWrite(`INSERT INTO cf_agents_session_messages
          (session_id, id, seq, parent_id, role, content, content_chunks, token_estimate, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
				sessionId,
				message.id,
				seq,
				parent,
				message.role,
				slices[0],
				slices.length - 1,
				tokenEstimate,
				Date.now()
			]);
			_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
			_classPrivateFieldGet2(_attachments, this).addRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
			_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, false);
		});
		_classPrivateFieldGet2(_tails, this).set(sessionId, {
			leafId: message.id,
			nextSeq: seq + 1
		});
		const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
		if (memo) if (memo.leafId === parent && memo.depth <= MAX_PATH_DEPTH) {
			memo.leafId = message.id;
			memo.counted.add(message.id);
			memo.total += tokenEstimate;
			memo.depth += 1;
		} else _classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
		this.io.emit("session:message:appended", {
			sessionId,
			messageId: message.id,
			tokenEstimate
		});
		return {
			inserted: true,
			message
		};
	}
	/**
	* Durable update of an existing row. An identical row writes nothing: no
	* row, no continuation, no FTS, no event. The no-op guard compares the
	* FULL reassembled content, not just the slice the message row holds.
	*/
	update(sessionId, message, tokenEstimate) {
		const oldRows = this.io.sql("SELECT content, content_chunks, token_estimate FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, message.id]);
		if (oldRows.length === 0) return "missing";
		const old = oldRows[0];
		const oldContent = old.content_chunks === 0 ? old.content : old.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [message.id]).get(message.id) ?? "");
		const { message: staged, attachments } = extractAttachments(message);
		const json = JSON.stringify(staged);
		if (oldContent === json) return "unchanged";
		const slices = splitContent(json);
		this.io.transaction(() => {
			for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
			this.io.sqlWrite(`UPDATE cf_agents_session_messages
         SET role = ?, content = ?, content_chunks = ?, token_estimate = ?
         WHERE session_id = ? AND id = ?`, [
				message.role,
				slices[0],
				slices.length - 1,
				tokenEstimate,
				sessionId,
				message.id
			]);
			if (old.content_chunks > slices.length - 1) this.io.sqlWrite(`DELETE FROM cf_agents_session_message_chunks
           WHERE session_id = ? AND id = ? AND idx > ?`, [
				sessionId,
				message.id,
				slices.length - 1
			]);
			_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
			_classPrivateFieldGet2(_attachments, this).replaceRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
			_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, true);
		});
		const memo = _classPrivateFieldGet2(_pathTokens, this).get(sessionId);
		if (memo?.counted.has(message.id)) memo.total += tokenEstimate - (old.token_estimate ?? 0);
		this.io.emit("session:message:updated", {
			sessionId,
			messageId: message.id
		});
		return "updated";
	}
	/**
	* Delete rows, SPLICING children to their grandparent so a mid-chain
	* delete never decapitates older history. Only surviving boundary children
	* are rewired: a prefix delete writes one boundary child, not one child
	* per deleted message.
	*/
	deleteMessages(sessionId, messageIds) {
		const uniqueIds = [...new Set(messageIds)];
		if (uniqueIds.length === 0) return;
		const ids = JSON.stringify(uniqueIds);
		this.io.transaction(() => {
			this.io.sqlWrite(`WITH RECURSIVE
         deleted(id) AS (SELECT value FROM json_each(?)),
         rewire(child_id, ancestor_id, depth) AS (
           SELECT child.id, child.parent_id, 0
           FROM cf_agents_session_messages AS child
           JOIN deleted ON deleted.id = child.parent_id
           WHERE child.session_id = ?
             AND child.id NOT IN (SELECT id FROM deleted)
           UNION ALL
           SELECT rewire.child_id, parent.parent_id, rewire.depth + 1
           FROM rewire
           JOIN cf_agents_session_messages AS parent
             ON parent.id = rewire.ancestor_id
           JOIN deleted ON deleted.id = parent.id
           WHERE parent.session_id = ? AND rewire.depth < 10000
         ),
         nearest(child_id, ancestor_id) AS (
           SELECT child_id, ancestor_id FROM rewire
           WHERE ancestor_id IS NULL
              OR ancestor_id NOT IN (SELECT id FROM deleted)
         )
       UPDATE cf_agents_session_messages
       SET parent_id = (
         SELECT nearest.ancestor_id FROM nearest
         WHERE nearest.child_id = cf_agents_session_messages.id
       )
         WHERE session_id = ?
           AND id IN (SELECT child_id FROM nearest)`, [
				ids,
				sessionId,
				sessionId,
				sessionId
			]);
			this.io.sqlWrite(`DELETE FROM cf_agents_session_messages
         WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
			this.io.sqlWrite(`DELETE FROM cf_agents_session_message_chunks
         WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
			_classPrivateFieldGet2(_attachments, this).releaseMessages(sessionId, uniqueIds);
			if (_classPrivateFieldGet2(_fts, this)) this.io.sqlWrite(`DELETE FROM cf_agents_session_fts
           WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, ids]);
		});
		_classPrivateFieldGet2(_tails, this).delete(sessionId);
		_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
		this.io.emit("session:messages:deleted", {
			sessionId,
			count: uniqueIds.length
		});
	}
	clearMessages(sessionId) {
		this.io.transaction(() => {
			this.io.sqlWrite("DELETE FROM cf_agents_session_messages WHERE session_id = ?", [sessionId]);
			this.io.sqlWrite("DELETE FROM cf_agents_session_message_chunks WHERE session_id = ?", [sessionId]);
			this.io.sqlWrite("DELETE FROM cf_agents_session_compactions WHERE session_id = ?", [sessionId]);
			_classPrivateFieldGet2(_attachments, this).releaseSession(sessionId);
			if (_classPrivateFieldGet2(_fts, this)) this.io.sqlWrite("DELETE FROM cf_agents_session_fts WHERE session_id = ?", [sessionId]);
		});
		_classPrivateFieldGet2(_tails, this).set(sessionId, {
			leafId: null,
			nextSeq: 1
		});
		_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
		this.io.emit("session:cleared", { sessionId });
	}
	addCompaction(sessionId, summary, fromMessageId, toMessageId) {
		const id = crypto.randomUUID();
		const now = Date.now();
		const seq = this.io.sql("SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM cf_agents_session_compactions WHERE session_id = ?", [sessionId])[0]?.seq ?? 1;
		this.io.sqlWrite(`INSERT INTO cf_agents_session_compactions
        (session_id, id, seq, summary, from_message_id, to_message_id, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?)`, [
			sessionId,
			id,
			seq,
			summary,
			fromMessageId,
			toMessageId,
			now
		]);
		_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
		this.io.emit("session:compacted", {
			sessionId,
			compactionId: id
		});
		return {
			id,
			summary,
			fromMessageId,
			toMessageId,
			createdAt: new Date(now).toISOString()
		};
	}
	getCompactions(sessionId) {
		return this.io.sql(`SELECT id, summary, from_message_id, to_message_id, created_at
         FROM cf_agents_session_compactions
         WHERE session_id = ? ORDER BY seq ASC`, [sessionId]).map((row) => ({
			id: row.id,
			summary: row.summary,
			fromMessageId: row.from_message_id,
			toMessageId: row.to_message_id,
			createdAt: new Date(row.created_at).toISOString()
		}));
	}
	search(sessionId, query, limit) {
		_assertClassBrand(_SessionsCore_brand, this, _ensureFts).call(this);
		const sanitized = `"${query.replace(/"/g, "\"\"")}"`;
		return this.io.sql(`SELECT f.id, f.role, f.content FROM cf_agents_session_fts f
         INNER JOIN cf_agents_session_messages m
           ON m.session_id = f.session_id AND m.id = f.id
         WHERE cf_agents_session_fts MATCH ? AND f.session_id = ?
         ORDER BY rank LIMIT ?`, [
			sanitized,
			sessionId,
			limit
		]).map((row) => ({
			id: row.id,
			role: row.role,
			content: row.content
		}));
	}
	/**
	* Import one historical message verbatim (migrations, cross-DO moves):
	* explicit parent and timestamp, stamped estimate, no change-feed events.
	*/
	/** Returns `false` when the id already exists and nothing was written. */
	importMessage(sessionId, message, options) {
		const { message: staged, attachments } = extractAttachments(message);
		const slices = splitContent(JSON.stringify(staged));
		const tail = _assertClassBrand(_SessionsCore_brand, this, _tail).call(this, sessionId);
		let inserted = 0;
		this.io.transaction(() => {
			for (const attachment of attachments) _classPrivateFieldGet2(_attachments, this).put(attachment.payload, attachment.hash);
			inserted = this.io.sqlWrite(`INSERT OR IGNORE INTO cf_agents_session_messages
          (session_id, id, seq, parent_id, role, content, content_chunks, token_estimate, created_at)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
				sessionId,
				message.id,
				tail.nextSeq,
				options.parentId,
				message.role,
				slices[0],
				slices.length - 1,
				this.estimateRowTokens(message),
				options.createdAt
			]);
			if (inserted === 0) return;
			_assertClassBrand(_SessionsCore_brand, this, _writeContinuations).call(this, sessionId, message.id, slices);
			_classPrivateFieldGet2(_attachments, this).addRefs(sessionId, message.id, attachments.map((attachment) => attachment.hash));
			_assertClassBrand(_SessionsCore_brand, this, _indexFts).call(this, sessionId, staged, false);
		});
		if (inserted === 0) return false;
		_classPrivateFieldGet2(_tails, this).set(sessionId, {
			leafId: message.id,
			nextSeq: tail.nextSeq + 1
		});
		_classPrivateFieldGet2(_pathTokens, this).delete(sessionId);
		return true;
	}
};
function _tableExists(name) {
	return this.io.sql("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?", [name]).length > 0;
}
/**
* Build the FTS index on demand. Maintaining it costs an extra billed row
* on every append, so it exists only on objects that have actually
* searched; the first search pays a one-time SQL backfill instead.
*/
function _ensureFts() {
	if (_classPrivateFieldGet2(_fts, this)) return;
	this.io.sqlWrite(`CREATE VIRTUAL TABLE cf_agents_session_fts
       USING fts5(id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, tokenize='porter unicode61')`, []);
	_classPrivateFieldSet2(_fts, this, true);
	_assertClassBrand(_SessionsCore_brand, this, _backfillMissingFtsRows).call(this);
}
/** Index rows that predate indexing, in SQL, without loading JSON into JS. */
function _backfillMissingFtsRows() {
	this.io.sqlWrite(`INSERT INTO cf_agents_session_fts (id, session_id, role, content)
       SELECT m.id, m.session_id, m.role,
         group_concat(json_extract(part.value, '$.text'), ' ')
       FROM cf_agents_session_messages AS m
       JOIN json_each(
         CASE WHEN json_valid(m.content) THEN m.content ELSE '{"parts":[]}' END,
         '$.parts'
       ) AS part
       WHERE json_extract(part.value, '$.type') = 'text'
         AND COALESCE(json_extract(part.value, '$.text'), '') <> ''
         AND NOT EXISTS (
           SELECT 1 FROM cf_agents_session_fts AS existing
           WHERE existing.id = m.id AND existing.session_id = m.session_id
         )
       GROUP BY m.id, m.session_id, m.role`, []);
}
/**
* Continuation slices for the given ids, keyed by id and already joined in
* `idx` order. One query, and only for ids the caller knows have them.
*/
function _continuations(sessionId, ids) {
	const joined = /* @__PURE__ */ new Map();
	if (ids.length === 0) return joined;
	const rows = this.io.sql(`SELECT id, content FROM cf_agents_session_message_chunks
       WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))
       ORDER BY id ASC, idx ASC`, [sessionId, JSON.stringify([...ids])]);
	for (const row of rows) joined.set(row.id, (joined.get(row.id) ?? "") + row.content);
	return joined;
}
/** Reassemble one stored row, reading continuations only when it has any. */
function _content(sessionId, id) {
	const rows = this.io.sql("SELECT content, content_chunks FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]);
	if (rows.length === 0) return null;
	const row = rows[0];
	if (row.content_chunks === 0) return row.content;
	return row.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [id]).get(id) ?? "");
}
function _hasParent(sessionId, id) {
	const [row] = this.io.sql("SELECT parent_id FROM cf_agents_session_messages WHERE session_id = ? AND id = ?", [sessionId, id]);
	return row?.parent_id != null;
}
/**
* The session's newest row. Children insert after their parents, so the
* max-seq row is provably childless: it is the active leaf, and the next
* append numbers from it. Read once per object lifetime, since the table
* is keyed by id and finding the max means scanning the session's rows.
*/
function _tail(sessionId) {
	const cached = _classPrivateFieldGet2(_tails, this).get(sessionId);
	if (cached) return cached;
	const [row] = this.io.sql("SELECT id, seq FROM cf_agents_session_messages WHERE session_id = ? ORDER BY seq DESC LIMIT 1", [sessionId]);
	const tail = row ? {
		leafId: row.id,
		nextSeq: row.seq + 1
	} : {
		leafId: null,
		nextSeq: 1
	};
	_classPrivateFieldGet2(_tails, this).set(sessionId, tail);
	return tail;
}
function _resolveLeafId(sessionId, leafId) {
	if (leafId) return this.exists(sessionId, leafId) ? leafId : null;
	return this.latestLeafId(sessionId);
}
/**
* The active branch path as ids alone, root → leaf. The cheapest walk the
* tree allows — one row per step, following `parent_id` by primary key —
* for readers that will hydrate only a few rows and do not need the sizes
* `pathRowStats` charges per row.
*/
function _pathIds(sessionId, leafId) {
	const leaf = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
	if (!leaf) return [];
	return this.io.sql(`WITH RECURSIVE path(id, parent_id, depth) AS (
          SELECT id, parent_id, 0 FROM cf_agents_session_messages
          WHERE session_id = ? AND id = ?
          UNION ALL
          SELECT m.id, m.parent_id, p.depth + 1 FROM cf_agents_session_messages m
          JOIN path p ON m.id = p.parent_id
          WHERE m.session_id = ? AND p.depth < ${MAX_PATH_DEPTH}
        )
        SELECT id FROM path ORDER BY depth DESC`, [
		sessionId,
		leaf,
		sessionId
	]).map((row) => row.id);
}
/**
* Split path rows into bounded hydration queries: at most `maxRows` rows,
* and — when the rows carry sizes — at most `HISTORY_CONTENT_CHUNK_BYTES`
* of stored content, with a single oversized row always standing alone.
*/
function* _boundedStatsChunks(rows, maxRows = HISTORY_CONTENT_CHUNK_SIZE) {
	let start = 0;
	while (start < rows.length) {
		let end = start;
		let bytes = 0;
		while (end < rows.length && end - start < maxRows) {
			const nextBytes = rows[end].bytes;
			if (end > start && bytes + nextBytes > HISTORY_CONTENT_CHUNK_BYTES) break;
			bytes += nextBytes;
			end++;
		}
		yield rows.slice(start, end);
		start = end;
	}
}
/**
* Fetch and parse one already-bounded content window. The common window
* has no continuation rows at all, so the second query is issued only for
* the ids that actually carry them and never runs otherwise.
*/
function _contentByStats(sessionId, rows) {
	const result = /* @__PURE__ */ new Map();
	if (rows.length === 0) return result;
	const fetched = this.io.sql(`SELECT id, content, content_chunks FROM cf_agents_session_messages
       WHERE session_id = ? AND id IN (SELECT value FROM json_each(?))`, [sessionId, JSON.stringify(rows.map((row) => row.id))]);
	const continued = _assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, fetched.filter((row) => row.content_chunks > 0).map((row) => row.id));
	for (const row of fetched) {
		const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, row.content_chunks === 0 ? row.content : row.content + (continued.get(row.id) ?? ""));
		if (parsed) result.set(row.id, _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed));
	}
	return result;
}
/**
* Stream a known path window without retaining earlier content chunks.
*
* The path is first cut into segments — a compaction overlay, or a run of
* raw rows between overlays — in root → leaf order. `newestFirst` walks
* those segments, and the rows inside each, from the leaf instead, in
* small fixed windows, so the first content fetched is the newest and a
* consumer that stops early never touches older rows.
*/
async function* _streamStats(sessionId, stats, signal, newestFirst = false, plannedSpans) {
	const spans = plannedSpans ?? planOverlays(stats.map((row) => row.id), this.getCompactions(sessionId));
	const spanByStart = new Map(spans.map((span) => [span.startIndex, span]));
	const segments = [];
	let index = 0;
	while (index < stats.length) {
		const span = spanByStart.get(index);
		if (span) {
			segments.push({ overlay: span.compaction });
			index = span.endIndex + 1;
			continue;
		}
		let runEnd = index + 1;
		while (runEnd < stats.length && !spanByStart.has(runEnd)) runEnd++;
		segments.push({ rows: stats.slice(index, runEnd) });
		index = runEnd;
	}
	if (newestFirst) segments.reverse();
	for (const segment of segments) {
		if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
		if ("overlay" in segment) {
			yield overlayMessage(segment.overlay);
			continue;
		}
		const rows = newestFirst ? [...segment.rows].reverse() : segment.rows;
		const windowRows = newestFirst ? NEWEST_FIRST_WINDOW_ROWS : HISTORY_CONTENT_CHUNK_SIZE;
		for (const chunk of _assertClassBrand(_SessionsCore_brand, this, _boundedStatsChunks).call(this, rows, windowRows)) {
			const content = _assertClassBrand(_SessionsCore_brand, this, _contentByStats).call(this, sessionId, chunk);
			for (const row of chunk) {
				const parsed = content.get(row.id);
				if (parsed) yield parsed;
			}
			if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
		}
	}
}
/**
* The path leaf → root as a chain of point reads: each row names its
* parent, so the next read is known before the current message is
* yielded, and a consumer that stops early has paid for exactly the rows it
* saw.
*
* Compaction overlays are honored without planning them up front. An
* overlay that applies to this branch ends at a row the walk reaches
* before any row it covers, so the raw walk is exact until it lands on
* some compaction's `toMessageId`. Only then is the remaining prefix read
* as ids and planned root → leaf — the order overlay selection is defined
* in — and streamed leaf-first with the overlays collapsed. A lookup that
* stops in the messages after the last compaction never pays for that.
*/
async function* _walkFromLeaf(sessionId, leafId, signal) {
	const compactions = this.getCompactions(sessionId);
	const spanEnds = new Set(compactions.map((c) => c.toMessageId));
	let next = _assertClassBrand(_SessionsCore_brand, this, _resolveLeafId).call(this, sessionId, leafId);
	let depth = 0;
	while (next !== null && depth <= MAX_PATH_DEPTH) {
		if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("History read aborted");
		if (spanEnds.has(next)) {
			yield* _assertClassBrand(_SessionsCore_brand, this, _streamOverlaidPrefix).call(this, sessionId, leafId, next, compactions, signal);
			return;
		}
		const [row] = this.io.sql(`SELECT parent_id, content, content_chunks FROM cf_agents_session_messages
         WHERE session_id = ? AND id = ?`, [sessionId, next]);
		if (!row) return;
		const json = row.content_chunks === 0 ? row.content : row.content + (_assertClassBrand(_SessionsCore_brand, this, _continuations).call(this, sessionId, [next]).get(next) ?? "");
		const parsed = _assertClassBrand(_SessionsCore_brand, this, _parse).call(this, json);
		if (parsed) yield _assertClassBrand(_SessionsCore_brand, this, _inline).call(this, parsed);
		next = row.parent_id;
		depth++;
	}
}
/**
* The path from `fromId` down to the root, leaf-first with overlays
* collapsed. Spans are planned over the WHOLE path (selection is
* root → leaf and a chosen span suppresses overlaps inside it), then only
* those ending at or before `fromId` apply: `fromId` is some compaction's
* end, and a chosen span reaching past it would have ended at a row the
* raw walk visited first.
*/
async function* _streamOverlaidPrefix(sessionId, leafId, fromId, compactions, signal) {
	const ids = _assertClassBrand(_SessionsCore_brand, this, _pathIds).call(this, sessionId, leafId);
	const end = ids.indexOf(fromId);
	if (end === -1) return;
	const spans = planOverlays(ids, compactions).filter((span) => span.endIndex <= end);
	yield* _assertClassBrand(_SessionsCore_brand, this, _streamStats).call(this, sessionId, ids.slice(0, end + 1).map((id) => ({
		id,
		bytes: 0
	})), signal, true, spans);
}
/**
* Write one message's slices: the row itself plus its continuation rows.
* Callers run this inside their own transaction, so a message and its
* continuations always commit together.
*/
function _writeContinuations(sessionId, id, slices) {
	for (let idx = 1; idx < slices.length; idx++) this.io.sqlWrite(`INSERT OR REPLACE INTO cf_agents_session_message_chunks
          (session_id, id, idx, content) VALUES (?, ?, ?, ?)`, [
		sessionId,
		id,
		idx,
		slices[idx]
	]);
}
/** Maintain the FTS row when the index exists; an unchanged text writes nothing. */
function _indexFts(sessionId, message, replace) {
	if (!_classPrivateFieldGet2(_fts, this)) return;
	const text = message.parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join(" ");
	if (replace) {
		const existing = this.io.sql("SELECT content FROM cf_agents_session_fts WHERE id = ? AND session_id = ?", [message.id, sessionId]);
		if (existing.length > 0) {
			if (existing[0].content === text) return;
			this.io.sqlWrite("DELETE FROM cf_agents_session_fts WHERE id = ? AND session_id = ?", [message.id, sessionId]);
		}
	}
	if (text) this.io.sqlWrite("INSERT INTO cf_agents_session_fts (id, session_id, role, content) VALUES (?, ?, ?, ?)", [
		message.id,
		sessionId,
		message.role,
		text
	]);
}
/** Put attachment payloads back inline, so a read returns what was written. */
function _inline(message) {
	return resolveAttachments(message, (hash) => _classPrivateFieldGet2(_attachments, this).get(hash));
}
function _parse(json) {
	try {
		const message = JSON.parse(json);
		if (typeof message?.id === "string" && typeof message?.role === "string" && Array.isArray(message?.parts)) return message;
	} catch {}
	return null;
}
//#endregion
//#region src/sessions/handle.ts
var _core$1 = /* @__PURE__ */ new WeakMap();
var _ready = /* @__PURE__ */ new WeakMap();
var _compactionFn = /* @__PURE__ */ new WeakMap();
var _tokenThreshold = /* @__PURE__ */ new WeakMap();
var _Session_brand = /* @__PURE__ */ new WeakSet();
var Session = class {
	/** @internal Constructed by the Sessions capability only. */
	constructor(sessionId, core, ready) {
		_classPrivateMethodInitSpec(this, _Session_brand);
		_classPrivateFieldInitSpec(this, _core$1, void 0);
		_classPrivateFieldInitSpec(this, _ready, void 0);
		_classPrivateFieldInitSpec(this, _compactionFn, null);
		_classPrivateFieldInitSpec(this, _tokenThreshold, void 0);
		this.sessionId = sessionId;
		_classPrivateFieldSet2(_core$1, this, core);
		_classPrivateFieldSet2(_ready, this, ready);
	}
	/** Register the function `compact()` calls to summarize a branch. */
	onCompaction(fn) {
		_classPrivateFieldSet2(_compactionFn, this, fn);
		return this;
	}
	/**
	* Auto-compact after an append once the estimated token count crosses the
	* threshold. Requires `onCompaction()`. The estimate is derived from the
	* stamped per-row estimates, never from the transcript.
	*/
	compactAfter(tokenThreshold) {
		_classPrivateFieldSet2(_tokenThreshold, this, tokenThreshold);
		return this;
	}
	/**
	* Stream the active branch path root → leaf (leaf → root with
	* `newestFirst`) with compaction overlays applied. Peak memory is one
	* bounded content window, never the whole transcript, and a consumer that
	* breaks out early leaves the rows it never reached unread.
	*/
	async *history(options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		yield* _classPrivateFieldGet2(_core$1, this).streamHistory(this.sessionId, options);
	}
	/**
	* Stream history in bounded non-empty batches. Both message count and
	* serialized bytes bound each batch; a single large message is yielded by
	* itself.
	*/
	async *historyBatches(options = {}) {
		const batchSize = Math.max(1, Math.floor(options.batchSize ?? 50));
		const maxBatchBytes = Math.max(1, Math.floor(options.maxBatchBytes ?? 4 * 1024 * 1024));
		let batch = [];
		let batchBytes = 0;
		for await (const message of this.history(options)) {
			const bytes = byteLength(JSON.stringify(message));
			if (batch.length > 0 && (batch.length >= batchSize || batchBytes + bytes > maxBatchBytes)) {
				yield batch;
				batch = [];
				batchBytes = 0;
			}
			batch.push(message);
			batchBytes += bytes;
			if (batch.length >= batchSize || batchBytes >= maxBatchBytes) {
				yield batch;
				batch = [];
				batchBytes = 0;
			}
		}
		if (batch.length > 0) yield batch;
	}
	/**
	* Materialize the whole selected path. Prefer `history()` or
	* `getRecentHistory()` inside a Durable Object: this holds every message
	* of the branch in memory at once.
	*/
	async getHistory(options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getHistory(this.sessionId, options);
	}
	/**
	* Byte-budgeted read of the most recent messages on the active branch path
	* (always at least the leaf). The budget counts each row, its continuation
	* rows, and the payloads it points at, so it bounds hydrated memory (#1710).
	*/
	async getRecentHistory(maxContentBytes, options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getRecentHistory(this.sessionId, maxContentBytes, options.leafId);
	}
	/**
	* Per-row stored sizes (row plus continuation rows and attachments) and
	* stamped token estimates for the active branch path (root → leaf)
	* WITHOUT loading message content.
	*/
	async getHistoryRowStats(leafId) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).pathRowStats(this.sessionId, leafId);
	}
	async getMessage(id) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getMessage(this.sessionId, id);
	}
	async getLatestLeaf() {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getLatestLeaf(this.sessionId);
	}
	async getBranches(messageId) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getBranches(this.sessionId, messageId);
	}
	/**
	* Full-text search over this session's text parts. The index is built on
	* the first call and maintained from then on.
	*/
	async search(query, options) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).search(this.sessionId, query, options?.limit ?? 20);
	}
	/**
	* Append one message. Idempotent on id: a repeated append returns the row
	* already stored and dispatches an `append` event with `inserted: false`.
	*/
	async appendMessage(message, options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
		const result = _classPrivateFieldGet2(_core$1, this).append(this.sessionId, prepared.message, options.parentId, prepared.tokenEstimate);
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "append",
			sessionId: this.sessionId,
			message: result.message,
			parentId: options.parentId,
			inserted: result.inserted
		});
		if (result.inserted) await _assertClassBrand(_Session_brand, this, _maybeAutoCompact).call(this);
		return result;
	}
	/**
	* Update one stored row. Returns the stored form, or `null` when the id is
	* not in this session. An unchanged message writes nothing and dispatches
	* no event.
	*/
	async updateMessage(message, options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
		const outcome = _classPrivateFieldGet2(_core$1, this).update(this.sessionId, prepared.message, prepared.tokenEstimate);
		if (outcome === "missing") return null;
		if (outcome === "updated") await _classPrivateFieldGet2(_core$1, this).notify({
			type: "update",
			sessionId: this.sessionId,
			message: prepared.message
		});
		return prepared.message;
	}
	/**
	* @internal Synchronous write aperture for same-isolate first-party
	* machinery that must commit a message inside a caller-owned SQLite
	* transaction — the stream cutover, where the finished message and the
	* discard of its stream rows land together. Bypasses readiness (the
	* caller owns startup ordering) and defers everything that follows a
	* write — the change-feed dispatch and auto-compaction: call the returned
	* `after()` once the transaction commits, or subscribers (the host's
	* message mirror) never hear about the write. If the transaction rolls
	* back after an `upsert` ran inside it, call `abandon()`: the write is
	* gone but the in-memory tail and token-total caches already moved.
	* Will break without notice; never use from application code.
	*/
	__DO_NOT_USE_WILL_BREAK__sync() {
		return {
			abandon: () => _classPrivateFieldGet2(_core$1, this).forgetCaches(this.sessionId),
			upsert: (message, options = {}) => {
				const prepared = _assertClassBrand(_Session_brand, this, _prepare).call(this, message, options.source);
				if (!_classPrivateFieldGet2(_core$1, this).exists(this.sessionId, message.id)) {
					const result = _classPrivateFieldGet2(_core$1, this).append(this.sessionId, prepared.message, options.parentId, prepared.tokenEstimate);
					return {
						result,
						after: async () => {
							await _classPrivateFieldGet2(_core$1, this).notify({
								type: "append",
								sessionId: this.sessionId,
								message: result.message,
								parentId: options.parentId,
								inserted: result.inserted
							});
							if (result.inserted) await _assertClassBrand(_Session_brand, this, _maybeAutoCompact).call(this);
						}
					};
				}
				const outcome = _classPrivateFieldGet2(_core$1, this).update(this.sessionId, prepared.message, prepared.tokenEstimate);
				return {
					result: {
						inserted: false,
						message: prepared.message
					},
					after: () => outcome === "updated" ? _classPrivateFieldGet2(_core$1, this).notify({
						type: "update",
						sessionId: this.sessionId,
						message: prepared.message
					}) : Promise.resolve()
				};
			}
		};
	}
	async upsertMessage(message, options = {}) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		if (!_classPrivateFieldGet2(_core$1, this).exists(this.sessionId, message.id)) return this.appendMessage(message, options);
		return {
			inserted: false,
			message: await this.updateMessage(message, { source: options.source }) ?? message
		};
	}
	/**
	* Import one historical message verbatim (migrations, cross-object moves):
	* explicit parent and timestamp. A row actually written dispatches an
	* `import` change event so a host cache can mark itself stale; it is not
	* an `append`, so a cache does not patch itself per imported row, and an
	* id that already exists writes nothing and dispatches nothing.
	*/
	async importMessage(message, options) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		if (!_classPrivateFieldGet2(_core$1, this).importMessage(this.sessionId, message, options)) return;
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "import",
			sessionId: this.sessionId,
			message,
			parentId: options.parentId
		});
	}
	async deleteMessages(messageIds) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		_classPrivateFieldGet2(_core$1, this).deleteMessages(this.sessionId, messageIds);
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "delete",
			sessionId: this.sessionId,
			messageIds
		});
	}
	async clearMessages() {
		await _classPrivateFieldGet2(_ready, this).call(this);
		_classPrivateFieldGet2(_core$1, this).clearMessages(this.sessionId);
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "clear",
			sessionId: this.sessionId
		});
	}
	/**
	* Store an overlay directly. Dispatches a `compaction` change event: the
	* rows are untouched, but what a path read returns has changed.
	*/
	async addCompaction(summary, fromMessageId, toMessageId) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		const compaction = _classPrivateFieldGet2(_core$1, this).addCompaction(this.sessionId, summary, fromMessageId, toMessageId);
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "compaction",
			sessionId: this.sessionId,
			compaction
		});
		return compaction;
	}
	async getCompactions() {
		await _classPrivateFieldGet2(_ready, this).call(this);
		return _classPrivateFieldGet2(_core$1, this).getCompactions(this.sessionId);
	}
	/**
	* Run the registered compaction function and store the result as an
	* overlay. When `leafId` is given, compact that root-to-leaf branch instead
	* of the active branch. Requires `onCompaction()`. A compaction function
	* that throws is reported through the `session:error` capability event and
	* the call returns `null`.
	*/
	async compact(leafId) {
		await _classPrivateFieldGet2(_ready, this).call(this);
		const fn = _classPrivateFieldGet2(_compactionFn, this);
		if (!fn) throw new Error("No compaction function registered. Call onCompaction() first.");
		const history = await _classPrivateFieldGet2(_core$1, this).getHistory(this.sessionId, { leafId });
		let result;
		try {
			result = await fn(history);
		} catch (error) {
			_classPrivateFieldGet2(_core$1, this).io.emit("session:error", {
				sessionId: this.sessionId,
				error: error instanceof Error ? error.message : String(error)
			});
			return null;
		}
		if (!result) return null;
		const historyIds = new Set(history.map((message) => message.id));
		if (!historyIds.has(result.toMessageId)) return null;
		const existing = _classPrivateFieldGet2(_core$1, this).getCompactions(this.sessionId).filter((compaction) => historyIds.has(`compaction_${compaction.id}`) || historyIds.has(compaction.fromMessageId) && historyIds.has(compaction.toMessageId));
		const fromId = existing.length > 0 ? existing[0].fromMessageId : result.fromMessageId;
		_classPrivateFieldGet2(_core$1, this).addCompaction(this.sessionId, result.summary, fromId, result.toMessageId);
		await _classPrivateFieldGet2(_core$1, this).notify({
			type: "compact",
			sessionId: this.sessionId
		});
		return {
			...result,
			fromMessageId: fromId
		};
	}
};
/**
* The shared write pipeline: sanitize provider metadata and strip reserved
* metadata on client-source input. Content is never truncated and never
* too large: a message that exceeds the row budget is split across
* continuation rows by the durable write.
*/
function _prepare(message, source) {
	let prepared = sanitizeMessage(message);
	if (source === "client") prepared = _classPrivateFieldGet2(_core$1, this).stripReservedMetadata(prepared);
	return {
		message: prepared,
		tokenEstimate: _classPrivateFieldGet2(_core$1, this).estimateRowTokens(prepared)
	};
}
/** Gate on the derived estimate, then compact. Failures are non-fatal. */
async function _maybeAutoCompact() {
	const threshold = _classPrivateFieldGet2(_tokenThreshold, this);
	if (threshold == null || !_classPrivateFieldGet2(_compactionFn, this)) return;
	if (_classPrivateFieldGet2(_core$1, this).tokenEstimate(this.sessionId) <= threshold) return;
	try {
		await this.compact();
	} catch (error) {
		const detail = error instanceof Error ? error.message : String(error);
		console.warn(`[Sessions] auto-compaction failed: ${detail}`);
		_classPrivateFieldGet2(_core$1, this).io.emit("session:error", {
			sessionId: this.sessionId,
			error: detail
		});
	}
}
//#endregion
//#region src/sessions/sessions.ts
/**
* Durable conversation history for Lifecycle Objects. `Sessions` owns the
* `cf_agents_session_*` tables: tree-structured messages (branch
* regeneration, latest-leaf paths), compaction overlays, and full-text
* search whose index is built on first use. A message larger than one
* SQLite row is split across continuation rows and reassembled on read, so
* nothing is ever too large to store.
*
* Sessions consumes only the standard capability services — storage and
* events. It needs no alarm, so it also works on facets (facets have
* isolated SQLite but no independent alarm slot).
*
* @experimental The API surface may change before stabilizing.
*/
const SESSIONS_SCHEMA_VERSION_KEY = "cf_agents:sessions_schema_version";
const CURRENT_SESSIONS_SCHEMA_VERSION = 1;
var _core = /* @__PURE__ */ new WeakMap();
var _handles = /* @__PURE__ */ new WeakMap();
/**
* Durable conversation history for a Lifecycle Object.
*
* `session()` returns a per-session handle for reads (streamed, byte
* budgeted), writes (sanitized, media offloaded), branch navigation, and
* compaction overlays. `subscribe()` is the change feed a cache-owning host
* mirrors.
*
* @experimental The API surface may change before stabilizing.
*/
var Sessions = class extends LifecycleCapability {
	constructor(options = {}) {
		super("sessions");
		_classPrivateFieldInitSpec(this, _core, void 0);
		_classPrivateFieldInitSpec(this, _handles, /* @__PURE__ */ new Map());
		const exec = (query, params) => {
			try {
				return this.lifecycle.storage.sql.exec(query, ...params);
			} catch (cause) {
				throw new SqlError(query, cause);
			}
		};
		_classPrivateFieldSet2(_core, this, new SessionsCore(options, {
			sql: (query, params) => [...exec(query, params)],
			sqlWrite: (query, params) => exec(query, params).rowsWritten,
			transaction: (fn) => this.lifecycle.storage.transactionSync(fn),
			emit: (type, payload) => this.lifecycle.events.emit(type, payload)
		}));
	}
	/** Migrate session storage during Lifecycle startup. */
	async onStart() {
		const storage = this.lifecycle.storage;
		const version = await storage.get(SESSIONS_SCHEMA_VERSION_KEY) ?? 0;
		_classPrivateFieldGet2(_core, this).ensureTables();
		if (version < CURRENT_SESSIONS_SCHEMA_VERSION) {
			if (_classPrivateFieldGet2(_core, this).migrateLegacy()) await storage.put(SESSIONS_SCHEMA_VERSION_KEY, CURRENT_SESSIONS_SCHEMA_VERSION);
		}
	}
	/**
	* The handle for one session. The default (empty) id is the primary path:
	* in the one-object-per-conversation model a Durable Object holds exactly
	* one session. Handles are cached, so per-session configuration (the
	* compaction trigger) survives repeated calls.
	*/
	session(sessionId = "") {
		const existing = _classPrivateFieldGet2(_handles, this).get(sessionId);
		if (existing) return existing;
		const handle = new Session(sessionId, _classPrivateFieldGet2(_core, this), () => this.lifecycle.ready());
		_classPrivateFieldGet2(_handles, this).set(sessionId, handle);
		return handle;
	}
	/**
	* Subscribe to the change feed: ordered dispatch after every durable
	* write, with the stored message. The host cache mirror lives here;
	* telemetry additionally flows through capability events.
	*/
	subscribe(listener) {
		return _classPrivateFieldGet2(_core, this).subscribe(listener);
	}
};
//#endregion
export { COMPACTION_PREFIX, Session, Sessions, createCompactFunction, isCompactionMessage };

//# sourceMappingURL=index.js.map