agents
Version:
A home for your AI agents
2,063 lines • 85.7 kB
JavaScript
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