agents
Version:
A home for your AI agents
2,374 lines • 82.9 kB
JavaScript
import "../../../types.js";
import { m as estimateStringTokens, p as estimateMessageTokens, t as COMPACTION_PREFIX } from "../../../compaction-helpers-iiKMr2TQ.js";
import { z } from "zod";
//#region src/experimental/memory/session/search.ts
/**
* Check if a provider is a SearchProvider (has a `search` method).
*/
function isSearchProvider(provider) {
return typeof provider === "object" && provider !== null && "search" in provider && typeof provider.search === "function";
}
/**
* SearchProvider backed by Durable Object SQLite with FTS5.
*
* - `get()` returns a count of indexed entries
* - `search(query)` full-text search using FTS5
* - `set(key, content)` indexes or replaces content under a key
*
* Each instance uses a namespaced FTS5 table to avoid collisions
* with the session message search.
*
* @example
* ```ts
* Session.create(this)
* .withContext("knowledge", {
* provider: new AgentSearchProvider(this)
* })
* ```
*/
var AgentSearchProvider = class {
constructor(agent) {
this.label = "";
this.initialized = false;
this.agent = agent;
}
init(label) {
this.label = label;
}
ensureTable() {
if (this.initialized) return;
this.agent.sql`
CREATE TABLE IF NOT EXISTS cf_agents_search_entries (
label TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (label, key)
)
`;
this.agent.sql`
CREATE VIRTUAL TABLE IF NOT EXISTS cf_agents_search_fts
USING fts5(
label UNINDEXED,
key UNINDEXED,
content,
tokenize='porter unicode61'
)
`;
this.initialized = true;
}
async get() {
this.ensureTable();
const count = this.agent.sql`
SELECT COUNT(*) as count FROM cf_agents_search_entries
WHERE label = ${this.label}
`[0]?.count ?? 0;
if (count === 0) return null;
return `${count} entries indexed.`;
}
async search(query) {
this.ensureTable();
const sanitized = query.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, "\"\"")}"`).join(" ");
if (!sanitized) return null;
try {
const rows = this.agent.sql`
SELECT f.key, f.content
FROM cf_agents_search_fts f
WHERE cf_agents_search_fts MATCH ${sanitized}
AND f.label = ${this.label}
ORDER BY rank
LIMIT 10
`;
if (rows.length === 0) return null;
return rows.map((r) => `[${r.key}]\n${r.content}`).join("\n\n");
} catch {
return null;
}
}
async set(key, content) {
this.ensureTable();
this.deleteFTS(key);
this.agent.sql`
INSERT INTO cf_agents_search_entries (label, key, content)
VALUES (${this.label}, ${key}, ${content})
ON CONFLICT(label, key) DO UPDATE SET
content = ${content},
updated_at = CURRENT_TIMESTAMP
`;
this.agent.sql`
INSERT INTO cf_agents_search_fts (label, key, content)
VALUES (${this.label}, ${key}, ${content})
`;
}
deleteFTS(key) {
const rows = this.agent.sql`
SELECT rowid FROM cf_agents_search_fts
WHERE key = ${key} AND label = ${this.label}
`;
for (const row of rows) this.agent.sql`DELETE FROM cf_agents_search_fts WHERE rowid = ${row.rowid}`;
}
};
//#endregion
//#region src/experimental/memory/session/skills.ts
/**
* Check if a provider is a SkillProvider (has a `load` method).
*/
function isSkillProvider(provider) {
return typeof provider === "object" && provider !== null && "load" in provider && typeof provider.load === "function";
}
/**
* SkillProvider backed by an R2 bucket.
*
* - `get()` returns a metadata listing of all skills (key + description)
* - `load(key)` fetches a skill's full content
* - `set(key, content, description?)` writes a skill
*
* Descriptions are pulled from R2 custom metadata (`description` key).
* If a prefix is provided, it is prepended on storage operations and
* stripped from keys in metadata. `keys`, when provided, is matched against
* these prefix-relative keys.
*
* @example
* ```ts
* const skills = new R2SkillProvider(env.SKILLS_BUCKET, {
* prefix: "skills/",
* keys: ["code-review", "debugging"]
* });
* ```
*/
var R2SkillProvider = class {
constructor(bucket, options) {
this.bucket = bucket;
this.prefix = options?.prefix ?? "";
this.keys = options?.keys?.length ? new Set(options.keys) : null;
}
async get() {
const entries = [];
let cursor;
let truncated = true;
while (truncated) {
const listed = await this.bucket.list({
prefix: this.prefix,
cursor,
include: ["customMetadata"]
});
for (const obj of listed.objects) {
const key = obj.key.slice(this.prefix.length);
if (!this.allowsKey(key)) continue;
const desc = obj.customMetadata?.description;
entries.push(`- ${key}${desc ? `: ${desc}` : ""}`);
}
truncated = listed.truncated;
cursor = listed.truncated ? listed.cursor : void 0;
}
return entries.length > 0 ? entries.join("\n") : null;
}
async load(key) {
if (!this.allowsKey(key)) return null;
const obj = await this.bucket.get(this.prefix + key);
if (!obj) return null;
return obj.text();
}
async set(key, content, description) {
await this.bucket.put(this.prefix + key, content, { customMetadata: description ? { description } : void 0 });
}
allowsKey(key) {
return this.keys === null || this.keys.has(key);
}
};
//#endregion
//#region src/experimental/memory/session/context.ts
function slugify(text) {
return text.slice(0, 60).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
function stableHash(text) {
let hash = 2166136261;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
function contextEntryKey(metadataTitle, content) {
if (metadataTitle?.trim()) return slugify(metadataTitle) || `entry-${stableHash(metadataTitle)}`;
return `${slugify(content) || "entry"}-${stableHash(content)}`;
}
/**
* Check if a provider is writable (has a `set` method).
*/
function isWritableProvider(provider) {
return typeof provider === "object" && provider !== null && "set" in provider && typeof provider.set === "function";
}
/**
* Manages context blocks with frozen snapshot support.
*/
var ContextBlocks = class {
constructor(configs, promptStore) {
this.blocks = /* @__PURE__ */ new Map();
this.snapshot = null;
this.loaded = false;
this._loadedSkills = /* @__PURE__ */ new Set();
this._onUnloadSkill = null;
this.configs = configs;
this.promptStore = promptStore ?? null;
}
/**
* Register a callback invoked when a skill is unloaded.
* Session uses this to update the stored tool result message.
*/
setUnloadCallback(cb) {
this._onUnloadSkill = cb;
}
isLoaded() {
return this.loaded;
}
/**
* Load all blocks from their providers.
* Called once at session init.
*/
async load() {
for (const config of this.configs) {
if (config.provider?.init) config.provider.init(config.label);
const content = config.provider ? await config.provider.get() ?? "" : "";
const skill = config.provider ? isSkillProvider(config.provider) : false;
const searchable = config.provider ? isSearchProvider(config.provider) : false;
const writable = config.provider ? isWritableProvider(config.provider) || skill && !!config.provider.set || searchable && !!config.provider.set : false;
this.blocks.set(config.label, {
label: config.label,
description: config.description,
content,
tokens: estimateStringTokens(content),
maxTokens: config.maxTokens,
writable,
isSkill: skill,
isSearchable: searchable
});
}
this.loaded = true;
}
/**
* Dynamically register a new context block after initialization.
* Used by extensions to contribute context at runtime.
*
* If blocks have already been loaded, the new block's provider is
* initialized and loaded immediately. The snapshot is NOT updated
* automatically — call `refreshSystemPrompt()` to rebuild.
*/
async addBlock(config) {
if (!this.loaded) await this.load();
if (this.configs.some((c) => c.label === config.label)) throw new Error(`Block "${config.label}" already exists`);
this.configs.push(config);
if (config.provider?.init) config.provider.init(config.label);
const content = config.provider ? await config.provider.get() ?? "" : "";
const skill = config.provider ? isSkillProvider(config.provider) : false;
const searchable = config.provider ? isSearchProvider(config.provider) : false;
const writable = config.provider ? isWritableProvider(config.provider) || skill && !!config.provider.set || searchable && !!config.provider.set : false;
const block = {
label: config.label,
description: config.description,
content,
tokens: estimateStringTokens(content),
maxTokens: config.maxTokens,
writable,
isSkill: skill,
isSearchable: searchable
};
this.blocks.set(config.label, block);
return block;
}
/**
* Remove a dynamically registered context block.
* Used during extension unload cleanup.
*
* Returns true if the block existed and was removed.
* The snapshot is NOT updated automatically — call
* `refreshSystemPrompt()` to rebuild.
*
* Note: loaded skills for this block are cleaned up from the
* tracking set but the skill unload callback is NOT fired
* (history reclamation is skipped — appropriate for full
* extension removal).
*/
removeBlock(label) {
const idx = this.configs.findIndex((c) => c.label === label);
if (idx === -1) return false;
this.configs.splice(idx, 1);
this.blocks.delete(label);
for (const id of this._loadedSkills) if (id.startsWith(`${label}:`)) this._loadedSkills.delete(id);
return true;
}
/**
* Get a block by label.
*/
getBlock(label) {
return this.blocks.get(label) ?? null;
}
/**
* Get all blocks.
*/
getBlocks() {
return Array.from(this.blocks.values());
}
/**
* Set block content. Writes to provider immediately.
* Does NOT update the frozen snapshot.
*/
async setBlock(label, content) {
if (!this.loaded) await this.load();
const config = this.configs.find((c) => c.label === label);
const existing = this.blocks.get(label);
if (!existing?.writable) throw new Error(`Block "${label}" is readonly`);
if (existing.isSkill || existing.isSearchable) throw new Error(`Block "${label}" is a keyed provider. Use setSkill() or setSearchEntry() instead.`);
const tokens = estimateStringTokens(content);
const maxTokens = config?.maxTokens ?? existing?.maxTokens;
if (maxTokens !== void 0 && tokens > maxTokens) throw new Error(`Block "${label}" exceeds maxTokens: ${tokens} > ${maxTokens}`);
const block = {
label,
description: config?.description ?? existing?.description,
content,
tokens,
maxTokens,
writable: true,
isSkill: false,
isSearchable: false
};
this.blocks.set(label, block);
if (config?.provider && isWritableProvider(config.provider)) await config.provider.set(content);
return block;
}
/**
* Set a skill entry within a skill block.
*/
async setSkill(label, key, content, description) {
if (!this.loaded) await this.load();
const config = this.configs.find((c) => c.label === label);
const existing = this.blocks.get(label);
if (!existing?.isSkill) throw new Error(`Block "${label}" is not a skill provider`);
const provider = config?.provider;
if (!provider || !isSkillProvider(provider) || !provider.set) throw new Error(`Block "${label}" does not support writes`);
await provider.set(key, content, description);
const metadata = await provider.get();
if (metadata) {
existing.content = metadata;
existing.tokens = estimateStringTokens(metadata);
}
}
/**
* Load a skill's full content from a skill block.
*/
async loadSkill(label, key) {
if (!this.loaded) await this.load();
const config = this.configs.find((c) => c.label === label);
if (!config?.provider || !isSkillProvider(config.provider)) throw new Error(`Block "${label}" is not a skill provider`);
const content = await config.provider.load(key);
if (content !== null) this._loadedSkills.add(`${label}:${key}`);
return content;
}
/**
* Unload a previously loaded skill. Updates the stored tool result
* message via the unload callback (set by Session).
*/
unloadSkill(label, key) {
const id = `${label}:${key}`;
if (!this._loadedSkills.has(id)) return false;
this._loadedSkills.delete(id);
this._onUnloadSkill?.(label, key);
return true;
}
/**
* Get the set of currently loaded skill keys (as "label:key" strings).
*/
getLoadedSkillKeys() {
return this._loadedSkills;
}
/**
* Restore loaded skill tracking from a set of "label:key" strings.
* Used by Session to reconstruct state after hibernation.
*/
restoreLoadedSkills(skillIds) {
this._loadedSkills = new Set(skillIds);
}
/**
* Clear all loaded skill tracking. Called when messages are cleared.
*/
clearSkillState() {
this._loadedSkills.clear();
}
/**
* Index a search entry within a searchable block.
*/
async setSearchEntry(label, key, content) {
if (!this.loaded) await this.load();
const config = this.configs.find((c) => c.label === label);
const existing = this.blocks.get(label);
if (!existing?.isSearchable) throw new Error(`Block "${label}" is not a search provider`);
const provider = config?.provider;
if (!provider || !isSearchProvider(provider) || !provider.set) throw new Error(`Block "${label}" does not support writes`);
await provider.set(key, content);
existing.content = await provider.get() ?? "";
existing.tokens = estimateStringTokens(existing.content);
}
/**
* Search a searchable block.
*/
async searchContext(label, query) {
if (!this.loaded) await this.load();
const config = this.configs.find((c) => c.label === label);
if (!config?.provider || !isSearchProvider(config.provider)) throw new Error(`Block "${label}" is not a search provider`);
return config.provider.search(query);
}
/**
* Append content to a block.
*/
async appendToBlock(label, content) {
if (!this.loaded) await this.load();
const existing = this.blocks.get(label);
if (!existing) throw new Error(`Block "${label}" not found`);
const needsSep = existing.content.length > 0 && !content.startsWith("\n");
return this.setBlock(label, existing.content + (needsSep ? "\n" : "") + content);
}
/**
* Get the system prompt string with context blocks.
*
* Returns a frozen snapshot: first call renders and caches,
* subsequent calls return the same string (preserves LLM prefix cache).
* Call refreshSnapshot() to re-render after block changes take effect.
*/
toSystemPrompt() {
if (!this.loaded) throw new Error("Context blocks not loaded. Call load() first.");
if (this.snapshot !== null) return this.snapshot;
return this.captureSnapshot();
}
/**
* Force re-render the snapshot from current block state.
*/
refreshSnapshot() {
return this.captureSnapshot();
}
renderPrompt() {
const parts = [];
const sep = "═".repeat(46);
for (const block of this.blocks.values()) {
if (!block.content && !block.writable && !block.isSearchable && !block.isSkill) continue;
let header = block.label.toUpperCase();
if (block.description) header += ` (${block.description})`;
if (block.maxTokens) {
const pct = Math.round(block.tokens / block.maxTokens * 100);
header += ` [${pct}% — ${block.tokens}/${block.maxTokens} tokens]`;
}
if (block.isSearchable) header += " [searchable]";
else if (block.isSkill) header += " [loadable]";
else if (!block.writable) header += " [readonly]";
else header += " [writable]";
parts.push(`${sep}\n${header}\n${sep}\n${block.content}`);
}
return parts.join("\n\n");
}
captureSnapshot() {
this.snapshot = this.renderPrompt();
return this.snapshot;
}
/**
* Get writable blocks (for tool description).
*/
getWritableBlocks() {
return Array.from(this.blocks.values()).filter((b) => b.writable);
}
/**
* Check if any skill providers are registered.
*/
hasSkillBlocks() {
return Array.from(this.blocks.values()).some((b) => b.isSkill);
}
/**
* Check whether any CONFIGURED provider is skill-capable, without
* requiring `load()` to have run. Used by Session to decide whether the
* init-time loaded-skill restore scan is needed at all.
*/
hasSkillCapableConfigs() {
return this.configs.some((c) => c.provider !== void 0 && isSkillProvider(c.provider));
}
/**
* Get skill block labels.
*/
getSkillLabels() {
return Array.from(this.blocks.values()).filter((b) => b.isSkill).map((b) => b.label);
}
/**
* Check if any search providers are registered.
*/
hasSearchBlocks() {
return Array.from(this.blocks.values()).some((b) => b.isSearchable);
}
/**
* Get searchable block labels.
*/
getSearchLabels() {
return Array.from(this.blocks.values()).filter((b) => b.isSearchable).map((b) => b.label);
}
/**
* Return the cached system prompt. If no cached prompt exists,
* loads blocks from providers, renders, and persists to the store.
* Subsequent calls return the stored value without re-rendering.
*/
async freezeSystemPrompt() {
if (this.promptStore) {
const stored = await this.promptStore.get();
if (stored !== null) return stored;
}
if (!this.loaded) await this.load();
const prompt = this.toSystemPrompt();
if (this.promptStore) await this.promptStore.set(prompt);
return prompt;
}
/**
* Return the prompt text used for token estimation without persisting a new
* frozen prompt to the prompt store.
*
* This still reads an existing cached prompt when present, so estimates match
* the prompt that inference would reuse. If no cached prompt exists, it loads
* providers and renders the current blocks without freezing the snapshot.
*/
async getSystemPromptForEstimate() {
if (this.snapshot !== null) return this.snapshot;
if (this.promptStore) {
const stored = await this.promptStore.get();
if (stored !== null) return stored;
}
if (!this.loaded) await this.load();
return this.renderPrompt();
}
/**
* Force reload blocks from providers, re-render the system prompt,
* and persist to the store. Use this after block content has changed
* or to invalidate the cached prompt.
*/
async refreshSystemPrompt() {
this.loaded = false;
await this.load();
const prompt = this.refreshSnapshot();
if (this.promptStore) await this.promptStore.set(prompt);
return prompt;
}
/**
* AI tools for context blocks.
*
* Auto-wired based on provider capabilities:
* - `set_context` — when any block is writable
* - `load_context` — when any block is a skill provider
* - `search_context` — when any block is a search provider
*/
async tools() {
if (!this.loaded) await this.load();
const writable = this.getWritableBlocks();
const hasSkills = this.hasSkillBlocks();
const hasSearch = this.hasSearchBlocks();
const toolSet = {};
if (writable.length > 0) {
const blockDescriptions = writable.map((b) => {
const kind = b.isSkill ? "skill collection, keyed entries" : b.isSearchable ? "searchable, keyed entries" : "writable";
return `- "${b.label}" (${kind}): ${b.description ?? "no description"}`;
});
const keyedBlocks = writable.filter((b) => b.isSkill || b.isSearchable);
const properties = {
label: {
type: "string",
enum: writable.map((b) => b.label),
description: "Block label to write to"
},
content: {
type: "string",
description: "The main content to write to the block."
},
action: {
type: "string",
enum: ["replace", "append"],
description: "replace (default) or append"
}
};
if (keyedBlocks.length > 0) properties.metadata = {
type: "object",
description: "Optional metadata for keyed entries (skill collections, searchable blocks: " + keyedBlocks.map((b) => `"${b.label}"`).join(", ") + "). Short content doesn't need metadata; longer loadable entries (skills) benefit from a title and description so the model can pick the right one without loading it.",
properties: {
title: {
type: "string",
description: "Short title. Used as a stable identifier — entries with the same title are updated in place, different titles create new entries."
},
description: {
type: "string",
description: "One-line summary shown alongside the title in the system prompt so the model can decide when to load the entry."
}
}
};
const metadataHint = keyedBlocks.length > 0 ? "\n\nFor keyed blocks (skill collections / searchable), pass `metadata: { title, description }` — title stabilises updates, description helps the model pick entries. Metadata is optional; short content rarely needs it, long loadable entries benefit most." : "";
toolSet.set_context = {
description: `Write to a context block. Available blocks:\n${blockDescriptions.join("\n")}\n\nWrites are durable and persist across sessions.${metadataHint}`,
inputSchema: z.fromJSONSchema({
type: "object",
properties,
required: ["label", "content"]
}),
execute: async ({ label, content, metadata, action }) => {
try {
const block = this.blocks.get(label);
if (!block) return `Error: block "${label}" not found`;
if (block.isSkill || block.isSearchable) {
const title = metadata?.title;
const description = metadata?.description;
const key = contextEntryKey(title, content);
if (block.isSkill) await this.setSkill(label, key, content, description ?? title);
else await this.setSearchEntry(label, key, content);
return `Indexed "${key}" in ${label}.`;
}
const updated = action === "append" ? await this.appendToBlock(label, content) : await this.setBlock(label, content);
return `Written to ${label}. Usage: ${updated.maxTokens ? `${Math.round(updated.tokens / updated.maxTokens * 100)}% (${updated.tokens}/${updated.maxTokens} tokens)` : `${updated.tokens} tokens`}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
}
};
}
if (hasSkills) {
const skillLabels = this.getSkillLabels();
toolSet.load_context = {
description: "Load a document from a skill block by key. Available skill blocks: " + skillLabels.map((l) => `"${l}"`).join(", ") + ". Check the system prompt for available keys.",
inputSchema: z.fromJSONSchema({
type: "object",
properties: {
label: {
type: "string",
enum: skillLabels,
description: "Skill block label"
},
key: {
type: "string",
description: "Skill key to load"
}
},
required: ["label", "key"]
}),
execute: async ({ label, key }) => {
try {
if (!skillLabels.includes(label)) return `Error: "${label}" is not a skill block. Skill blocks: ${skillLabels.join(", ")}`;
return await this.loadSkill(label, key) ?? `Not found: ${key}`;
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
}
};
const loadedList = [...this._loadedSkills];
toolSet.unload_context = {
description: "Unload a previously loaded skill to free context space. The skill remains available for re-loading." + (loadedList.length > 0 ? " Currently loaded: " + loadedList.join(", ") + "." : " No skills currently loaded."),
inputSchema: z.fromJSONSchema({
type: "object",
properties: {
label: {
type: "string",
enum: skillLabels,
description: "Skill block label"
},
key: {
type: "string",
description: "Skill key to unload"
}
},
required: ["label", "key"]
}),
execute: async ({ label, key }) => {
if (!skillLabels.includes(label)) return `Error: "${label}" is not a skill block. Skill blocks: ${skillLabels.join(", ")}`;
if (!this.unloadSkill(label, key)) return `Skill "${key}" is not currently loaded in "${label}".`;
return `Unloaded "${key}" from ${label}. Context reclaimed.`;
}
};
}
if (hasSearch) {
const searchLabels = this.getSearchLabels();
toolSet.search_context = {
description: "Search for information in a searchable context block. ONLY these blocks are searchable: " + searchLabels.map((l) => `"${l}"`).join(", ") + ". Other blocks cannot be searched.",
inputSchema: z.fromJSONSchema({
type: "object",
properties: {
label: {
type: "string",
enum: searchLabels,
description: "Searchable block label"
},
query: {
type: "string",
description: "Search query"
}
},
required: ["label", "query"]
}),
execute: async ({ label, query }) => {
try {
if (!searchLabels.includes(label)) return `Error: "${label}" is not searchable. Searchable blocks: ${searchLabels.join(", ")}`;
return await this.searchContext(label, query) ?? "No results found.";
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
}
};
}
return toolSet;
}
};
//#endregion
//#region src/experimental/memory/session/providers/agent.ts
/**
* Bounds for each content-hydration query on a history path.
*
* Message rows can be up to ~1.8MB each (see ROW_MAX_BYTES in agents/chat),
* so content is fetched in bounded batches rather than one statement to keep
* any per-statement buffering in the SQLite layer small. In workerd the
* SQLite allocator shares the isolate's memory budget with the JS heap —
* oversized transient result sets surface as SQLITE_NOMEM (#1710).
*
* Chunks are bounded by BOTH row count and cumulative stored bytes (sizes
* come from the path row stats, so no content is read to compute them).
* Without the byte bound, 50 near-cap rows could still materialize ~90MB
* in a single statement.
*/
const HISTORY_CONTENT_CHUNK_SIZE = 50;
const HISTORY_CONTENT_CHUNK_BYTES = 4 * 1024 * 1024;
var AgentSessionProvider = class {
/**
* @param agent - Agent or any object with a `sql` tagged template method
* @param sessionId - Optional session ID to isolate multiple sessions in the same DO.
* Messages are filtered by session_id within shared tables.
*/
constructor(agent, sessionId) {
this.initialized = false;
this.activeLeafId = void 0;
this.agent = agent;
this.sessionId = sessionId ?? "";
}
ensureTable() {
if (this.initialized) return;
this.agent.sql`
CREATE TABLE IF NOT EXISTS assistant_messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL DEFAULT '',
parent_id TEXT,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
this.agent.sql`
CREATE INDEX IF NOT EXISTS idx_assistant_msg_parent
ON assistant_messages(parent_id)
`;
this.agent.sql`
CREATE INDEX IF NOT EXISTS idx_assistant_msg_session
ON assistant_messages(session_id)
`;
this.agent.sql`
CREATE TABLE IF NOT EXISTS assistant_compactions (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL,
from_message_id TEXT NOT NULL,
to_message_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
this.agent.sql`
CREATE VIRTUAL TABLE IF NOT EXISTS assistant_fts
USING fts5(id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, tokenize='porter unicode61')
`;
this.agent.sql`
CREATE TABLE IF NOT EXISTS assistant_config (
session_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (session_id, key)
)
`;
this.initialized = true;
}
getMessage(id) {
this.ensureTable();
const rows = this.agent.sql`
SELECT content FROM assistant_messages WHERE id = ${id} AND session_id = ${this.sessionId}
`;
return rows.length > 0 ? this.parse(rows[0].content) : null;
}
getHistory(leafId) {
this.ensureTable();
const leaf = leafId ? this.leafRowById(leafId) : this.latestLeafRow();
if (!leaf) return [];
const messages = this.messagesByPathStats(this.pathRowStats(leaf.id));
const compactions = this.getCompactions();
if (compactions.length === 0) return messages;
return this.applyCompactions(messages, compactions);
}
getRecentHistory(leafId, maxContentBytes, minRecentMessages = 1) {
this.ensureTable();
const leaf = leafId ? this.leafRowById(leafId) : this.latestLeafRow();
if (!leaf) return {
messages: [],
truncated: false,
totalContentBytes: 0
};
const stats = this.pathRowStats(leaf.id);
const totalContentBytes = stats.reduce((sum, row) => sum + row.bytes, 0);
const minRecent = Math.max(1, Math.floor(minRecentMessages));
let start = stats.length - 1;
let used = stats[start]?.bytes ?? 0;
while (start > 0 && (stats.length - start < minRecent || used + stats[start - 1].bytes <= maxContentBytes)) {
start--;
used += stats[start].bytes;
}
const messages = this.messagesByPathStats(stats.slice(start));
const compactions = this.getCompactions();
return {
messages: compactions.length === 0 ? messages : this.applyCompactions(messages, compactions),
truncated: start > 0,
totalContentBytes
};
}
getHistoryRowStats(leafId) {
this.ensureTable();
const leaf = leafId ? this.leafRowById(leafId) : this.latestLeafRow();
return leaf ? this.pathRowStats(leaf.id) : [];
}
getLatestLeaf() {
this.ensureTable();
const row = this.latestLeafRow();
return row ? this.getMessage(row.id) : null;
}
getBranches(messageId) {
this.ensureTable();
const rows = this.agent.sql`
SELECT content FROM assistant_messages
WHERE parent_id = ${messageId} AND session_id = ${this.sessionId} ORDER BY created_at ASC
`;
return this.parseRows(rows);
}
getPathLength(leafId) {
this.ensureTable();
const leaf = leafId ? this.agent.sql`
SELECT id FROM assistant_messages WHERE id = ${leafId} AND session_id = ${this.sessionId}
`[0] : this.latestLeafRow();
if (!leaf) return 0;
return this.agent.sql`
WITH RECURSIVE path AS (
SELECT id, parent_id, 0 as depth FROM assistant_messages WHERE id = ${leaf.id}
UNION ALL
SELECT m.id, m.parent_id, p.depth + 1 FROM assistant_messages m
JOIN path p ON m.id = p.parent_id
WHERE m.session_id = ${this.sessionId} AND p.depth < 10000
)
SELECT COUNT(*) as count FROM path
`[0]?.count ?? 0;
}
appendMessage(message, parentId) {
this.ensureTable();
if (this.agent.sql`
SELECT id FROM assistant_messages WHERE id = ${message.id} AND session_id = ${this.sessionId}
`.length > 0) return;
let parent = parentId !== void 0 ? parentId : this.latestLeafRow()?.id ?? null;
if (parent) {
if (this.agent.sql`
SELECT id FROM assistant_messages WHERE id = ${parent} AND session_id = ${this.sessionId}
`.length === 0) parent = null;
}
const json = JSON.stringify(message);
this.agent.sql`
INSERT INTO assistant_messages (id, session_id, parent_id, role, content)
VALUES (${message.id}, ${this.sessionId}, ${parent}, ${message.role}, ${json})
`;
this.indexFTS(message);
this.activeLeafId = message.id;
}
updateMessage(message) {
this.ensureTable();
this.agent.sql`
UPDATE assistant_messages SET content = ${JSON.stringify(message)}
WHERE id = ${message.id} AND session_id = ${this.sessionId}
`;
this.indexFTS(message);
}
deleteMessages(messageIds) {
this.ensureTable();
for (const id of messageIds) {
this.agent.sql`DELETE FROM assistant_messages WHERE id = ${id} AND session_id = ${this.sessionId}`;
this.deleteFTS(id);
}
if (typeof this.activeLeafId === "string" && messageIds.includes(this.activeLeafId)) this.activeLeafId = void 0;
}
clearMessages() {
this.ensureTable();
this.agent.sql`DELETE FROM assistant_messages WHERE session_id = ${this.sessionId}`;
this.agent.sql`DELETE FROM assistant_compactions WHERE session_id = ${this.sessionId}`;
const ftsRows = this.agent.sql`
SELECT rowid FROM assistant_fts WHERE session_id = ${this.sessionId}
`;
for (const row of ftsRows) this.agent.sql`DELETE FROM assistant_fts WHERE rowid = ${row.rowid}`;
this.activeLeafId = void 0;
}
addCompaction(summary, fromMessageId, toMessageId) {
this.ensureTable();
const id = crypto.randomUUID();
this.agent.sql`
INSERT INTO assistant_compactions (id, session_id, summary, from_message_id, to_message_id)
VALUES (${id}, ${this.sessionId}, ${summary}, ${fromMessageId}, ${toMessageId})
`;
return {
id,
summary,
fromMessageId,
toMessageId,
createdAt: (/* @__PURE__ */ new Date()).toISOString()
};
}
getCompactions() {
this.ensureTable();
return this.agent.sql`
SELECT * FROM assistant_compactions WHERE session_id = ${this.sessionId} ORDER BY created_at ASC
`.map((r) => ({
id: r.id,
summary: r.summary,
fromMessageId: r.from_message_id,
toMessageId: r.to_message_id,
createdAt: r.created_at
}));
}
searchMessages(query, limit = 20) {
this.ensureTable();
const sanitized = `"${query.replace(/"/g, "\"\"")}"`;
try {
return this.agent.sql`
SELECT f.id, f.role, f.content FROM assistant_fts f
INNER JOIN assistant_messages m ON m.id = f.id AND m.session_id = f.session_id
WHERE assistant_fts MATCH ${sanitized} AND f.session_id = ${this.sessionId}
ORDER BY rank LIMIT ${limit}
`.map((r) => ({
id: r.id,
role: r.role,
content: r.content
}));
} catch {
return [];
}
}
latestLeafRow() {
if (this.activeLeafId !== void 0) {
if (this.agent.sql`
SELECT m.id FROM assistant_messages m
WHERE m.id = ${this.activeLeafId} AND m.session_id = ${this.sessionId}
AND NOT EXISTS (
SELECT 1 FROM assistant_messages c
WHERE c.parent_id = ${this.activeLeafId}
AND c.session_id = ${this.sessionId}
)
`.length > 0) return { id: this.activeLeafId };
this.activeLeafId = void 0;
}
const rows = this.agent.sql`
SELECT m.id FROM assistant_messages m
LEFT JOIN assistant_messages c ON c.parent_id = m.id AND c.session_id = ${this.sessionId}
WHERE c.id IS NULL AND m.session_id = ${this.sessionId}
ORDER BY m.created_at DESC, m.rowid DESC LIMIT 1
`;
this.activeLeafId = rows[0]?.id;
return rows[0] ?? null;
}
leafRowById(leafId) {
return this.agent.sql`
SELECT id FROM assistant_messages WHERE id = ${leafId} AND session_id = ${this.sessionId}
`[0] ?? null;
}
/**
* The active branch path as (id, role, content size) rows, root → leaf.
*
* Recurses over (id, parent_id) only. Carrying `content` through the
* recursive queue AND the ORDER BY sorter materializes the entire
* transcript several times over inside SQLite's allocator, which in
* workerd shares the isolate's memory budget with the JS heap — large
* media-heavy sessions then fail with SQLITE_NOMEM on wake (#1710).
* Content is fetched separately in bounded chunks (`messagesByPathStats`).
*/
pathRowStats(leafId) {
return this.agent.sql`
WITH RECURSIVE path(id, parent_id, depth) AS (
SELECT id, parent_id, 0 FROM assistant_messages WHERE id = ${leafId}
UNION ALL
SELECT m.id, m.parent_id, p.depth + 1 FROM assistant_messages m
JOIN path p ON m.id = p.parent_id
WHERE m.session_id = ${this.sessionId} AND p.depth < 10000
)
SELECT path.id AS id, am.role AS role, LENGTH(CAST(am.content AS BLOB)) AS bytes
FROM path JOIN assistant_messages am ON am.id = path.id
ORDER BY path.depth DESC
`;
}
/**
* Fetch and parse message content for an ordered list of path rows.
*
* Content is read in chunks bounded by both row count and cumulative
* stored bytes (no ORDER BY — SQLite streams rows without materializing
* the result set) and reassembled in path order. Rows that fail to parse
* are skipped, matching previous behavior.
*/
messagesByPathStats(rows) {
const contentById = /* @__PURE__ */ new Map();
const fetchChunk = (ids) => {
const fetched = this.agent.sql`
SELECT id, content FROM assistant_messages
WHERE session_id = ${this.sessionId}
AND id IN (SELECT value FROM json_each(${JSON.stringify(ids)}))
`;
for (const row of fetched) contentById.set(row.id, row.content);
};
let chunk = [];
let chunkBytes = 0;
for (const row of rows) {
if (chunk.length > 0 && (chunk.length >= HISTORY_CONTENT_CHUNK_SIZE || chunkBytes + row.bytes > HISTORY_CONTENT_CHUNK_BYTES)) {
fetchChunk(chunk);
chunk = [];
chunkBytes = 0;
}
chunk.push(row.id);
chunkBytes += row.bytes;
}
if (chunk.length > 0) fetchChunk(chunk);
const result = [];
for (const row of rows) {
const content = contentById.get(row.id);
if (content === void 0) continue;
const msg = this.parse(content);
if (msg) result.push(msg);
}
return result;
}
indexFTS(message) {
const text = message.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ");
this.deleteFTS(message.id);
if (text) this.agent.sql`
INSERT INTO assistant_fts (id, session_id, role, content)
VALUES (${message.id}, ${this.sessionId}, ${message.role}, ${text})
`;
}
deleteFTS(id) {
const rows = this.agent.sql`
SELECT rowid FROM assistant_fts WHERE id = ${id} AND session_id = ${this.sessionId}
`;
for (const row of rows) this.agent.sql`DELETE FROM assistant_fts WHERE rowid = ${row.rowid}`;
}
applyCompactions(messages, compactions) {
const ids = messages.map((m) => m.id);
const result = [];
let i = 0;
while (i < messages.length) {
const matching = compactions.filter((c) => c.fromMessageId === ids[i]);
const comp = matching.length > 1 ? matching[matching.length - 1] : matching[0];
if (comp) {
const endIdx = ids.indexOf(comp.toMessageId);
if (endIdx >= i) {
result.push({
id: `${COMPACTION_PREFIX}${comp.id}`,
role: "assistant",
parts: [{
type: "text",
text: comp.summary
}],
createdAt: /* @__PURE__ */ new Date()
});
i = endIdx + 1;
continue;
}
}
result.push(messages[i]);
i++;
}
return result;
}
parse(json) {
try {
const msg = JSON.parse(json);
if (typeof msg?.id === "string" && typeof msg?.role === "string" && Array.isArray(msg?.parts)) return msg;
} catch {}
return null;
}
parseRows(rows) {
const result = [];
for (const row of rows) {
const msg = this.parse(row.content);
if (msg) result.push(msg);
}
return result;
}
};
//#endregion
//#region src/experimental/memory/session/providers/agent-context.ts
var AgentContextProvider = class {
constructor(agent, label) {
this.initialized = false;
this.agent = agent;
this.label = label ?? "";
}
init(label) {
if (!this.label) this.label = label;
}
ensureTable() {
if (this.initialized) return;
this.agent.sql`
CREATE TABLE IF NOT EXISTS cf_agents_context_blocks (
label TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
this.initialized = true;
}
async get() {
this.ensureTable();
return this.agent.sql`
SELECT content FROM cf_agents_context_blocks WHERE label = ${this.label}
`[0]?.content ?? null;
}
async set(content) {
this.ensureTable();
this.agent.sql`
INSERT INTO cf_agents_context_blocks (label, content)
VALUES (${this.label}, ${content})
ON CONFLICT(label) DO UPDATE SET content = ${content}, updated_at = CURRENT_TIMESTAMP
`;
}
};
//#endregion
//#region src/experimental/memory/session/session.ts
function isBroadcaster(obj) {
return typeof obj === "object" && obj !== null && "broadcast" in obj && typeof obj.broadcast === "function";
}
function isSqlProvider(arg) {
return "sql" in arg && typeof arg.sql === "function";
}
var Session = class Session {
constructor(storage, options) {
this._warnedCompactionNoOp = false;
this._ready = false;
this._skillScanRan = false;
this._warnedNoRecentHistorySupport = false;
this.storage = storage;
this.context = new ContextBlocks(options?.context ?? [], options?.promptStore);
this._tokenCounter = options?.tokenCounter;
this._compactionErrorHandler = options?.onCompactionError;
this._ready = true;
}
/**
* Chainable session creation with auto-wired providers.
*
* Pass a `SqlProvider` (Agent with `sql` method) for auto-wired SQLite,
* or a `SessionProvider` directly for custom storage (Postgres, etc.).
*
* @example
* ```ts
* // Auto-wired SQLite (DO Agent)
* const session = Session.create(this)
* .withContext("soul", { provider: { get: async () => "You are helpful." } })
* .withContext("memory", { description: "Learned facts", maxTokens: 1100 })
* .withCachedPrompt();
*
* // Skills from R2 (on-demand loading via load_context tool)
* const session = Session.create(this)
* .withContext("skills", {
* provider: new R2SkillProvider(env.SKILLS_BUCKET, { prefix: "skills/" })
* })
* .withCachedPrompt();
*
* // Custom storage provider (Postgres, etc.)
* const session = Session.create(postgresProvider)
* .withContext("memory", {
* maxTokens: 1100,
* provider: new PostgresContextProvider(conn, "memory")
* })
* .withCachedPrompt(new PostgresContextProvider(conn, "_prompt"));
* ```
*/
static create(provider) {
const session = Object.create(Session.prototype);
if (isSqlProvider(provider)) {
session._agent = provider;
if (isBroadcaster(provider)) session._broadcaster = provider;
} else session._storageProvider = provider;
session._pending = [];
session._ready = false;
return session;
}
forSession(sessionId) {
this._sessionId = sessionId;
return this;
}
withContext(label, options) {
this._pending.push({
label,
options: options ?? {}
});
return this;
}
withCachedPrompt(provider) {
this._cachedPrompt = provider ?? true;
return this;
}
/**
* Register a compaction function. Called by `compact()` to compress
* message history into a summary overlay.
*/
onCompaction(fn) {
this._compactionFn = fn;
return this;
}
/**
* Auto-compact when estimated token count exceeds the threshold.
* Checked after each `appendMessage`. Requires `onCompaction()`.
*
* By default this uses a Workers-safe heuristic over stored messages plus
* the Session-managed frozen system prompt. Provide `tokenCounter` when you
* have model-reported usage or a tokenizer and need a stricter budget.
*/
compactAfter(tokenThreshold, options) {
this._tokenThreshold = tokenThreshold;
if (options?.tokenCounter) this._tokenCounter = options.tokenCounter;
return this;
}
/**
* Handle failures from the automatic `compactAfter()` trigger.
*
* Manual `compact()` still reports errors through the existing session error
* broadcast path.
*/
onCompactionError(handler) {
this._compactionErrorHandler = handler;
return this;
}
/**
* @internal
* Framework hook for cache-owning callers that need to mirror message
* storage changes. Application code should use the normal Session methods.
*/
internal_onMessagesChanged(listener) {
this._messageChangeListener = listener ?? void 0;
return this;
}
_ensureReady() {
if (this._ready) return;
const configs = (this._pending ?? []).map(({ label, options: opts }) => {
let provider = opts.provider;
if (!provider && this._agent) {
const key = this._sessionId ? `${label}_${this._sessionId}` : label;
provider = new AgentContextProvider(this._agent, key);
}
return {
label,
description: opts.description,
maxTokens: opts.maxTokens,
provider
};
});
let promptStore;
if (this._cachedPrompt === true && this._agent) {
const key = this._sessionId ? `_system_prompt_${this._sessionId}` : "_system_prompt";
promptStore = new AgentContextProvider(this._agent, key);
} else if (this._cachedPrompt && this._cachedPrompt !== true) promptStore = this._cachedPrompt;
if (this._storageProvider) this.storage = this._storageProvider;
else if (this._agent) this.storage = new AgentSessionProvider(this._agent, this._sessionId);
else throw new Error("Session.create() requires a SqlProvider or SessionProvider");
this.context = new ContextBlocks(configs, promptStore);
this.context.setUnloadCallback((label, key) => {
this._reclaimLoadedSkill(label, key).catch(() => {});
});
this._ready = true;
this._restorePromise = this._restoreLoadedSkills().catch(() => {});
}
/**
* Await the background skill-restore kicked off by `_ensureReady()`.
* Idempotent and cheap — every async public method calls this so that
* `_loadedSkills` reflects conversation history before any read or write.
*/
async _ensureRestored() {
this._ensureReady();
if (this._restorePromise) await this._restorePromise;
}
async _notifyMessagesChanged(event) {
await this._messageChangeListener?.(event);
}
/**
* Reconstruct which skills are loaded by scanning conversation history
* for load_context tool results that haven't been unloaded.
* Runs once per init to survive hibernation / eviction, including for
* async SessionProviders (e.g. Postgres) where we must `await` history.
*
* Skipped entirely when no skill-capable provider is configured —
* `load_context` results can only exist when a skill block was registered,
* and the scan would otherwise read the whole transcript on every wake,
* bypassing byte-budgeted hydration (#1710). A skill block added later via
* `addContext()` triggers the scan at that point instead.
*/
async _restoreLoadedSkills() {
if (!this.context.hasSkillCapableConfigs()) return;
await this._scanHistoryForLoadedSkills();
}
/**
* Scan stored history for load/unload_context tool results and restore
* the loaded-skill tracking set.
*
* Memory-bounded when the provider supports `getHistoryRowStats`: rows
* are enumerated without content, then only assistant rows are fetched
* and scanned ONE AT A TIME — peak memory is a single message instead of
* the whole transcript. Falls back to a full `getHistory()` read for
* providers without row stats (e.g. Postgres).
*
* Note: the bounded path scans raw path rows, so `load_context` results
* inside compacted ranges are still seen (the full-read path hides them
* behind compaction overlays). That superset is intentional — the stored
* tool result still exists and can be reclaimed by `unloadSkill`.
*/
async _scanHistoryForLoadedSkills() {
this._skillScanRan = true;
const loaded = /* @__PURE__ */ new Set();
const scanMessage = (msg) => {
if (msg.role !== "assistant") return;
for (const part of msg.parts) if (part.toolName === "load_context" && part.state === "output-available") {
const input = part.input;
if (input?.label && input?.key) {
const id = `${input.label}:${input.key}`;
if (typeof part.output === "string" && part.output.startsWith("[skill unloaded:")) loaded.delete(id);
else loaded.add(id);
}
} else if (part.toolName === "unload_context" && part.state === "output-available") {
const input = part.input;
if (input?.label && input?.key) loaded.delete(`${input.label}:${input.key}`);
}
};
if (this.storage.getHistoryRowStats) {
const stats = await this.storage.getHistoryRowStats();
for (const row of stats) {
if (row.role !== "assistant") continue;
const msg = await this.storage.getMessage(row.id);
if (msg) scanMessage(msg);
}
} else for (const msg of await this.storage.getHistory()) scanMessage(msg);
if (loaded.size > 0) this.context.restoreLoadedSkills(loaded);
}
/**
* Reclaim context-window tokens consumed by a previously loaded skill.
*
* When a skill is loaded via the `load_context` tool, its full body is
* embedded as that tool call's `output-available` result inside the
* assistant message — which means every subsequent turn replays the
* entire skill as part of the conversation history and pays for it in
* input tokens.
*
* This method walks back through history, finds the matching
* `load_context` tool result for `(label, key)`, and replaces its bulky
* `output` with a short marker `[skill unloaded: <key>]`. The skill
* content is dropped from future turns and the tokens are reclaimed.
* The skill itself stays available to reload via `load_context`.
*/
async _reclaimLoadedSkill(label, key) {
const history = await this.storage.getHistory();
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
if (msg.role !== "assistant") continue;
let changed = false;
const newParts = msg.parts.map((part) => {
if (part.toolName === "load_context" && part.state === "output-available") {
const input = part.input;
if (input?.label === label && input?.key === key) {
changed = true;
return {
...part,
output: `[skill unloaded: ${key}]`
};
}
}
return part;
});
if (changed) {
await this.updateMessage({
...msg,
parts: newParts
});
return;
}
}
}
async getHistory(leafId) {
await this._ensureRestored();
return this.storage.getHistory(leafId);
}
/**
* Byte-budgeted read of the most recent messages on the active branch
* path (always at least the leaf message, and at least
* `minRecentMessages` when the path is long enough). Lets hosts hydrate
* a bounded window instead of the full transcript so wake-time memory
* scales with the budget rather than total session history (#1710).
*
* Falls back to a full (untruncated) read when the provider doesn't
* implement `getRecentHistory`. The fallback reports honest metadata
* (`truncated: false` and the real serialized size) and warns once so a
* host relying on the budget knows it is not being enforced.
*/
async getRecentHistory(maxContentBytes, minRecentMessages = 1) {
await this._ensureRestored();
if (this.storage.getRecentHistory) return this.storage.getRecentHistory(null, maxContentBytes, minRecentMessages);
if (!this._warnedNoRecentHistorySupport) {
this._warnedNoRecentHistorySupport = true;
console.warn("[Session] The configured SessionProvider does not implement getRecentHistory; the requested byte budget cannot be enforced and the FULL history was loaded. Implement getRecentHistory (and getHistoryRowStats) on the provider to bound hydration.");
}
const messages = await this.storage.getHistory();
let totalContentBytes = 0;
for (const message of messages) totalContentBytes += JSON.stringify(message).length;
return {
messages,
truncated: false,
totalContentBytes
};
}
/**
* Per-row stored sizes for the active branch path (root → leaf) WITHOUT
* loading message content, or `null` when the provider doesn't support it.
* Lets hosts find oversized rows (e.g. inline base64 media) and process
* them one at a time with bounded memory.
*/
async getHistoryRowStats() {
await this._ensureRestored();
if (!this.storage.getHistoryRowStats) return null;
return this.storage.getHistoryRowStats();
}
async getMessage(id) {
await this._ensureRestored();
return this.storage.getMessage(id);
}
async getLatestLeaf() {
await this._ensureRestored();
return this.storage.getLatestLeaf();
}
async getBranches(messageId) {
await this._ensureRestored();
return this.storage.getBranches(messageId);
}
async getPathLength(leafId) {
await this._ensureRestored();
return this.storage.getPathLength(leafId);
}
_broadcast(type, data) {
if (!this._broadcaster) return;
this._broadcaster.broadcast(JSON.stringify({
type,
...data
}));
}
_shouldEstimateTokens() {
return Boolean(this._broadcaster || this._tokenThreshold != null && this._compactionFn);
}
async _estimateTokenCount() {
const messages = await this.getHistory();
const systemPrompt = await this.context.getSystemPromptForEstimate();
if (this._tokenCounter) {
if (!this.context.isLoaded()) await this.context.load();
const contextBlocks = this.context.getBlocks();
const estimate = await this._tokenCounter({
messages,
systemPrompt,
contextBlocks
});
return Number.isFinite(estimate) ? Math.max(0, Math.ceil(estimate)) : 0;
}
return estimateMessageTokens(messages) + estimateStringTokens(systemPrompt);
}
async _handleAutoCompactionError(error) {
const message = error instanceof Error ? error.message : String(error);
if (this._compactionErrorHandler) try {
await this._compactionErrorHandler(error);
} catch (handlerError) {
const handlerMessage = handlerError instanceof Error ? handlerError.message : String(handlerError);
console.warn(`Session auto-compaction error handler failed: ${handlerMessage}`);
}
else console.warn(`Session auto-compaction failed: ${message}`);
this._emitError(message);
}
async _emitStatus(phase, extra) {
let tokenEstimate = 0;
if (this._shouldEstimateTokens()) try {
tokenEstimate = await this._estimateTokenCount();
} catch (err) {
await this._handleAutoCompactionError(err);
}
this._broadcast("cf_agent_session", {
phase,
tokenEstimate,
tokenThreshold: this._tokenThreshold ?? null,
...extra
});
return tokenEstimate;
}
_emitError(error) {
this._broadcast("cf_agent_session_error", { error });
}
async appendMessage(message, parentId) {
await this._appendMessage(message, parentId);
}
async _appendMessage(message, parentId) {
await this._ensureRestored();
if (await this.storage.getMessage(message.id)) {
await this._emitStatus("idle");
await this._notifyMessagesChanged({
type: "append",
message,
parentId,
inserted: false
});
return;
}
await this.storage.appendMessage(message, parentId);
const tokenEstimate = await this._emitStatus("idle");
let compacted = false;
if (this._tokenThreshold != null && this._compactionFn && tokenEstimate > this._tokenThreshold) try {
compacted = Boolean(await this.compact());
if (!compacted && !this._warnedCompactionNoOp) {
this._warnedCompactionNoOp = true;
console.warn(`[Session] Auto-compaction fired (~${tokenEstimate} tokens > ${this._tokenThreshold}) but the compaction function returned null, so history was not shortened. ` + (this._tokenCounter ? `A tokenCounter is configured and now flows to the boundary logic, but it is invoked per-message there — a whole-prompt/usage counter (e.g. returning a fixed usage.inputTokens regardless of which messages are passed) degrades the tail budget to minTailMessages and can still no-op. Pass a per-message CompactOptions.tokenCounter for precise tail budgeting.` : `If your history is tool-heavy, configure a tokenCounter on compactAfter() — it flows to createCompactFunction's boundary logic automatically.`));
} else if (compacted) this._warnedCompactionNoOp = false;
} catch (err) {
await this._handleAutoCompactionError(err);
}
if (!compacted) await this._notifyMessagesChanged({
type: "append",
message,
parentId,
inserted: true
});
}
async updateMessage(message) {
await this._ensureRestored();
await this.storage.updateMessage(message);
await this._emitStatus("idle");
await this._notifyMessagesChanged({
type: "update",
message
});
}
/**
* @internal
* Rewrite a stored message WITHOUT the public-write side effects: no
* token-estimate status broadcast (which reads the FULL history) and no
* auto-compaction check. For framework maintenance passes that rewrite
* many rows with bounded memory — e.g. media eviction (#1710) — where the
* per-row full-history estimate would reintroduce the memory pressure the
* pass exists to remove. The message-change listener still fires so a
* cache-owning host stays coherent. Application code should use
* `updateMessage`.
*/
async internal_rewriteMessage(message) {
await this._ensureRestored();
await this.storage.updateMessage(message);
await this._notifyMessagesChanged({
type: "update",
message
});
}
async deleteMessages(messageIds) {
await this._ensureRestored();
await this.storage.deleteMessages(messageIds);
await this._emitStatus("idle");
await this._notifyMessagesChanged({
type: "delete",
messageIds
});
}
async clearMessages() {
await this._ensureRestored();
await this.storage.clearMessages();
this.context.clearSkillState();
await this.context.refreshSystemPrompt();
await this._emitStatus("idle");
await this._notifyMessagesChanged({ type: "clear" });
}
async addCompaction(summary, fromMessageId, toMessageId) {
await this._ensureRestored();
return this.storage.addCompaction(summary, fromMessageId, toMessageId);
}
async getCompactions() {
await this._ensureRestored();
return this.storage.getCompactions();
}
/**
* Run the registered compaction function and store the result as an overlay.
* Requires `onCompaction()` to be called first.
*/
async compact() {
await this._ensureRestored();
if (!this._compactionFn) throw new Error("No compaction function registered. Call onCompaction() first.");
const tokensBefore = await this._emitStatus("compacting");
let result;
try {
result = await this._compactionFn(await this.getHistory(), { tokenCounter: this._tokenCounter });
} catch (err) {
this._emitError(err instanceof Error ? err.message : String(err));
return null;
}
if (!result) {
await this._emitStatus("idle");
return null;
}
if (!new Set((await this.getHistory()).map((m) => m.id)).has(result.toMessageId)) {
await this._emitStatus("idle");
return null;
}
const existing = await this.getCompactions();
const fromId = existing.length > 0 ? existing[0].fromMessageId : result.fromMessageId;
await this.addCompaction(result.summary, fromId, result.toMessageId);
await this.refreshSystemPrompt();
await this._emitStatus("idle", { compacted: { tokensBefore } });
await this._notifyMessagesChanged({ type: "compact" });
return {
...result,
fromMessageId: fromId
};
}
getContextBlock(label) {
this._ensureReady();
return this.context.getBlock(label);
}
getContextBlocks() {
this._ensureReady();
return this.context.getBlocks();
}
async replaceContextBlock(label, content) {
await this._ensureRestored();
return this.context.setBlock(label, content);
}
async appendContextBlock(label, content) {
await this._ensureRestored();
return this.context.appendToBlock(label, content);
}
/**
* Dynamically register a new context block after session initialization.
*
* This is a **builder / runtime API**, not an LLM tool. The LLM writes
* into existing context blocks via the `set_context` tool (see
* `ContextBlocks.tools()`); it cannot declare new blocks itself. This
* method is how extension / host code contributes blocks at runtime
* (e.g. an extension's `onLoad` handler registering its own memory block).
*
* The block's provider is initialized and loaded immediately.
* Call `refreshSystemPrompt()` afterward to include the new block
* in the system prompt.
*
* Note: When called without a provider, auto-wires to SQLite via
* AgentContextProvider. Requires the session to have been created
* via `Session.create(agent)` (not the direct constructor).
*/
async addContext(label, options) {
await this._ensureRestored();
const opts = options ?? {};
let provider = opts.provider;
if (!provider) {
if (!this._agent) throw new Error(`addContext("${label}") requires an explicit provider when Session uses a SessionProvider`);
const key = this._sessionId ? `${label}_${this._sessionId}` : label;
provider = new AgentContextProvider(this._agent, key);
}
const block = await this.context.addBlock({
label,
description: opts.description,
maxTokens: opts.maxTokens,
provider
});
if (block.isSkill && !this._skillScanRan) await this._scanHistoryForLoadedSkills();
return block;
}
/**
* Remove a dynamically registered context block.
* Used during extension unload cleanup.
*
* Returns true if the block existed and was removed.
* Call `refreshSystemPrompt()` afterward to rebuild the prompt
* without the removed block.
*/
removeContext(label) {
this._ensureReady();
return this.context.removeBlock(label);
}
/**
* Unload a previously loaded skill, reclaiming context space.
* The tool result in conversation history is replaced with a short marker.
*
* Async so that the session's background skill-state restore (which
* reads conversation history) is awaited first — otherwise a freshly
* rehydrated DO could report "not loaded" for a skill that's actually
* present in history.
*/
async unloadSkill(label, key) {
await this._ensureRestored();
return this.context.unloadSkill(label, key);
}
/**
* Get currently loaded skill keys (as "label:key" strings).
* Async for the same reason as `unloadSkill` — must wait for restore.
*/
async getLoadedSkillKeys() {
await this._ensureRestored();
return this.context.getLoadedSkillKeys();
}
async freezeSystemPrompt() {
await this._ensureRestored();
return this.context.freezeSystemPrompt();
}
async refreshSystemPrompt() {
await this._ensureRestored();
return this.context.refreshSystemPrompt();
}
async search(query, options) {
await this._ensureRestored();
if (!this.storage.searchMessages) throw new Error("Session provider does not support search");
return this.storage.searchMessages(query, options?.limit ?? 20);
}
/** Returns set_context and load_context tools. */
async tools() {
await this._ensureRestored();
return this.context.tools();
}
};
//#endregion
//#region src/experimental/memory/session/manager.ts
var SessionManager = class SessionManager {
constructor(agent, _options = {}) {
this._pending = [];
this._sessions = /* @__PURE__ */ new Map();
this._tableReady = false;
this._ready = false;
this.agent = agent;
this._ready = true;
this._ensureTable();
}
/**
* Chainable SessionManager creation with auto-wired context for all sessions.
*
* @example
* ```ts
* const manager = SessionManager.create(this)
* .withContext("soul", { provider: { get: async () => "You are helpful." } })
* .withContext("memory", { description: "Learned facts", maxTokens: 1100 })
* .withCachedPrompt()
* .compactAfter(100_000);
*
* // Each getSession(id) auto-creates namespaced providers:
* // memory key: "memory_<sessionId>"
* // prompt key: "_system_prompt_<sessionId>"
* const session = manager.getSession("chat-123");
* ```
*/
static create(agent) {
const mgr = Object.create(SessionManager.prototype);
mgr.agent = agent;
mgr._pending = [];
mgr._compactionFn = null;
mgr._tokenThreshold = void 0;
mgr._tokenCounter = void 0;
mgr._compactionErrorHandler = void 0;
mgr._sessions = /* @__PURE__ */ new Map();
mgr._tableReady = false;
mgr._ready = false;
return mgr;
}
withContext(label, options) {
this._pending.push({
label,
options: options ?? {}
});
return this;
}
withCachedPrompt(provider) {
this._cachedPrompt = provider ?? true;
return this;
}
/**
* Register a compaction function propagated to all sessions.
* Called by `Session.compact()` to compress message history.
*/
onCompaction(fn) {
this._compactionFn = fn;
return this;
}
/**
* Auto-compact when estimated token count exceeds the threshold.
* Propagated to all sessions. Requires `onCompaction()`.
*/
compactAfter(tokenThreshold, options) {
this._tokenThreshold = tokenThreshold;
if (options?.tokenCounter) this._tokenCounter = options.tokenCounter;
return this;
}
/**
* Handle failures from automatic compaction in managed sessions.
*/
onCompactionError(handler) {
this._compactionErrorHandler = handler;
return this;
}
/**
* Add a searchable context block that searches conversation history
* across all sessions managed by this manager.
*
* The model can use `search_context` to find relevant messages from
* any session. The block is readonly (no `set`).
*
* @example
* ```ts
* SessionManager.create(this)
* .withContext("memory", { maxTokens: 1100 })
* .withSearchableHistory("history")
* .withCachedPrompt();
* ```
*/
withSearchableHistory(label) {
this._historyLabel = label;
return this;
}
_ensureReady() {
if (this._ready) return;
this._ready = true;
this._ensureTable();
}
_ensureTable() {
if (this._tableReady) return;
this.agent.sql`
CREATE TABLE IF NOT EXISTS assistant_sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_session_id TEXT,
model TEXT,
source TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
estimated_cost REAL DEFAULT 0,
end_reason TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`;
this.agent.sql`
CREATE VIRTUAL TABLE IF NOT EXISTS assistant_fts
USING fts5(id UNINDEXED, session_id UNINDEXED, role UNINDEXED, content, tokenize='porter unicode61')
`;
this._tableReady = true;
}
_createHistoryProvider() {
const mgr = this;
return {
async get() {
const sessions = mgr.list();
if (sessions.length === 0) return null;
return `${sessions.length} session${sessions.length === 1 ? "" : "s"} available for search.`;
},
async search(query) {
const results = mgr.search(query, { limit: 10 });
if (results.length === 0) return null;
return results.map((r) => `[${r.role}] ${r.content}`).join("\n---\n");
}
};
}
/** Get or create the Session instance for a session ID. */
getSession(sessionId) {
this._ensureReady();
let session = this._sessions.get(sessionId);
if (!session) {
const s = Session.create(this.agent).forSession(sessionId);
for (const { label, options } of this._pending) s.withContext(label, options);
if (this._cachedPrompt === true) s.withCachedPrompt();
else if (this._cachedPrompt) s.withCachedPrompt(this._cachedPrompt);
if (this._historyLabel) s.withContext(this._historyLabel, {
description: "Cross-session conversation history",
provider: this._createHistoryProvider()
});
if (this._compactionFn) s.onCompaction(this._compactionFn);
if (this._tokenThreshold != null) s.compactAfter(this._tokenThreshold, { tokenCounter: this._tokenCounter });
if (this._compactionErrorHandler) s.onCompactionError(this._compactionErrorHandler);
session = s;
this._sessions.set(sessionId, session);
}
return session;
}
create(name, opts) {
this._ensureReady();
const id = crypto.randomUUID();
this.agent.sql`
INSERT INTO assistant_sessions (id, name, parent_session_id, model, source)
VALUES (${id}, ${name}, ${opts?.parentSessionId ?? null}, ${opts?.model ?? null}, ${opts?.source ?? null})
`;
return this.get(id);
}
get(sessionId) {
this._ensureReady();
return this.agent.sql`
SELECT * FROM assistant_sessions WHERE id = ${sessionId}
`[0] ?? null;
}
list() {
this._ensureReady();
return this.agent.sql`
SELECT * FROM assistant_sessions ORDER BY updated_at DESC
`;
}
async delete(sessionId) {
await this.getSession(sessionId).clearMessages();
this.agent.sql`DELETE FROM assistant_sessions WHERE id = ${sessionId}`;
this._sessions.delete(sessionId);
}
rename(sessionId, name) {
this._ensureReady();
this.agent.sql`
UPDATE assistant_sessions SET name = ${name}, updated_at = CURRENT_TIMESTAMP
WHERE id = ${sessionId}
`;
}
async append(sessionId, message, parentId) {
await this.getSession(sessionId).appendMessage(message, parentId);
this._touch(sessionId);
return message.id;
}
async upsert(sessionId, message, parentId) {
const session = this.getSession(sessionId);
if (await session.getMessage(message.id)) await session.updateMessage(message);
else await session.appendMessage(message, parentId);
this._touch(sessionId);
return message.id;
}
async appendAll(sessionId, messages, parentId) {
const session = this.getSession(sessionId);
let lastParent = parentId ?? null;
for (const msg of messages) {
await session.appendMessage(msg, lastParent);
lastParent = msg.id;
}
this._touch(sessionId);
return lastParent;
}
async getHistory(sessionId, leafId) {
return this.getSession(sessionId).getHistory(leafId);
}
async getMessageCount(sessionId) {
return this.getSession(sessionId).getPathLength();
}
async clearMessages(sessionId) {
await this.getSession(sessionId).clearMessages();
this._touch(sessionId);
}
async deleteMessages(sessionId, messageIds) {
await this.getSession(sessionId).deleteMessages(messageIds);
this._touch(sessionId);
}
async getBranches(sessionId, messageId) {
return this.getSession(sessionId).getBranches(messageId);
}
/**
* Fork a session at a specific message, creating a new session
* with the history up to that point copied over.
*/
async fork(sessionId, atMessageId, newName) {
const info = this.create(newName, { parentSessionId: sessionId });
const history = await this.getSession(sessionId).getHistory(atMessageId);
const newSession = this.getSession(info.id);
let parentId = null;
for (const msg of history) {
const newId = crypto.randomUUID();
const copy = {
...msg,
id: newId
};
await newSession.appendMessage(copy, parentId);
parentId = newId;
}
this._touch(info.id);
return info;
}
async addCompaction(sessionId, summary, fromId, toId) {
return this.getSession(sessionId).addCompaction(summary, fromId, toId);
}
async getCompactions(sessionId) {
return this.getSession(sessionId).getCompactions();
}
async compactAndSplit(sessionId, summary, newName) {
const old = this.get(sessionId);
this.agent.sql`
UPDATE assistant_sessions SET end_reason = 'compaction', updated_at = CURRENT_TIMESTAMP
WHERE id = ${sessionId}
`;
const info = this.create(newName ?? old?.name ?? "Compacted", {
parentSessionId: sessionId,
model: old?.model ?? void 0,
source: old?.source ?? void 0
});
await this.append(info.id, {
id: crypto.randomUUID(),
role: "assistant",
parts: [{
type: "text",
text: `[Context from previous session]\n\n${summary}`
}]
});
return info;
}
addUsage(sessionId, inputTokens, outputTokens, cost) {
this._ensureReady();
this.agent.sql`
UPDATE assistant_sessions SET
input_tokens = input_tokens + ${inputTokens},
output_tokens = output_tokens + ${outputTokens},
estimated_cost = estimated_cost + ${cost},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${sessionId}
`;
}
search(query, options) {
this._ensureReady();
const limit = options?.limit ?? 20;
const sanitized = query.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, "\"\"")}"`).join(" ");
if (!sanitized) return [];
try {
return this.agent.sql`
SELECT id, role, content FROM assistant_fts
WHERE assistant_fts MATCH ${sanitized}
ORDER BY rank LIMIT ${limit}
`.map((r) => ({
id: r.id,
role: r.role,
content: r.content,
createdAt: ""
}));
} catch {
return [];
}
}
tools() {
return { session_search: {
description: "Search past conversations for relevant context. Searches across all sessions.",
inputSchema: z.fromJSONSchema({
type: "object",
properties: { query: {
type: "string",
description: "Search query"
} },
required: ["query"]
}),
execute: async ({ query }) => {
try {
const results = this.search(query, { limit: 10 });
if (results.length === 0) return "No results found.";
return results.map((r) => `[${r.role}] ${r.content}`).join("\n---\n");
} catch (err) {
return `Error: ${err instanceof Error ? err.message : String(err)}`;
}
}
} };
}
_touch(sessionId) {
this.agent.sql`
UPDATE assistant_sessions SET updated_at = CURRENT_TIMESTAMP
WHERE id = ${sessionId}
`;
}
};
//#endregion
//#region src/experimental/memory/session/providers/postgres-adapter.ts
function isPostgresConnection(client) {
return typeof client.execute === "function";
}
/**
* Normalise an incoming client into a `PostgresConnection`. When given a
* `pg`-style client we translate `?` placeholders to `$1, $2, ...` so the
* providers can keep using the portable `?` syntax internally.
*/
function toPostgresConnection(client) {
if (isPostgresConnection(client)) return client;
const pg = client;
return { async execute(query, args) {
let idx = 0;
const pgQuery = query.replace(/\?/g, () => `$${++idx}`);
return { rows: (await pg.query(pgQuery, args ?? [])).rows };
} };
}
//#endregion
//#region src/experimental/memory/session/providers/postgres.ts
var PostgresSessionProvider = class {
/**
* @param client A raw `pg.Client` (recommended) or any `PostgresConnection`.
* Must already be connected — this provider never opens or closes the
* underlying client.
* @param sessionId Session identifier. Different ids are fully isolated
* rows within the shared tables. Defaults to `""`.
*/
constructor(client, sessionId) {
this.conn = toPostgresConnection(client);
this.sessionId = sessionId ?? "";
}
async getMessage(id) {
const { rows } = await this.conn.execute("SELECT content FROM assistant_messages WHERE id = ? AND session_id = ?", [id, this.sessionId]);
return rows.length > 0 ? this.parse(rows[0].content) : null;
}
async getHistory(leafId) {
const leaf = leafId ? (await this.conn.execute("SELECT id FROM assistant_messages WHERE id = ? AND session_id = ?", [leafId, this.sessionId])).rows[0] : await this.latestLeafRow();
if (!leaf) return [];
const { rows } = await this.conn.execute(`WITH RECURSIVE path AS (
SELECT id, parent_id, content, 0 as depth FROM assistant_messages WHERE id = ? AND session_id = ?
UNION ALL
SELECT m.id, m.parent_id, m.content, p.depth + 1 FROM assistant_messages m
JOIN path p ON m.id = p.parent_id
WHERE m.session_id = ? AND p.depth < 10000
)
SELECT content FROM path ORDER BY depth DESC`, [
leaf.id,
this.sessionId,
this.sessionId
]);
const messages = this.parseRows(rows);
const compactions = await this.getCompactions();
if (compactions.length === 0) return messages;
return this.applyCompactions(messages, compactions);
}
async getLatestLeaf() {
const row = await this.latestLeafRow();
return row ? this.parse(row.content) : null;
}
async getBranches(messageId) {
const { rows } = await this.conn.execute("SELECT content FROM assistant_messages WHERE parent_id = ? AND session_id = ? ORDER BY created_at ASC", [messageId, this.sessionId]);
return this.parseRows(rows);
}
async getPathLength(leafId) {
const leaf = leafId ? (await this.conn.execute("SELECT id FROM assistant_messages WHERE id = ? AND session_id = ?", [leafId, this.sessionId])).rows[0] : await this.latestLeafRow();
if (!leaf) return 0;
const { rows } = await this.conn.execute(`WITH RECURSIVE path AS (
SELECT id, parent_id, 0 as depth FROM assistant_messages WHERE id = ? AND session_id = ?
UNION ALL
SELECT m.id, m.parent_id, p.depth + 1 FROM assistant_messages m
JOIN path p ON m.id = p.parent_id
WHERE m.session_id = ? AND p.depth < 10000
)
SELECT COUNT(*) as count FROM path`, [
leaf.id,
this.sessionId,
this.sessionId
]);
return Number(rows[0]?.count ?? 0);
}
async appendMessage(message, parentId) {
let parent = parentId !== void 0 ? parentId : (await this.latestLeafRow())?.id ?? null;
if (parent) {
const { rows } = await this.conn.execute("SELECT id FROM assistant_messages WHERE id = ? AND session_id = ?", [parent, this.sessionId]);
if (rows.length === 0) parent = null;
}
const json = JSON.stringify(message);
const text = this.extractSearchableText(json);
await this.conn.execute(`INSERT INTO assistant_messages (id, session_id, parent_id, role, content, text_content)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (session_id, id) DO NOTHING`, [
message.id,
this.sessionId,
parent,
message.role,
json,
text
]);
}
async updateMessage(message) {
const json = JSON.stringify(message);
await this.conn.execute("UPDATE assistant_messages SET content = ?, text_content = ? WHERE id = ? AND session_id = ?", [
json,
this.extractSearchableText(json),
message.id,
this.sessionId
]);
}
async deleteMessages(messageIds) {
for (const id of messageIds) await this.conn.execute("DELETE FROM assistant_messages WHERE id = ? AND session_id = ?", [id, this.sessionId]);
}
async clearMessages() {
await this.conn.execute("DELETE FROM assistant_messages WHERE session_id = ?", [this.sessionId]);
await this.conn.execute("DELETE FROM assistant_compactions WHERE session_id = ?", [this.sessionId]);
}
async addCompaction(summary, fromMessageId, toMessageId) {
const id = crypto.randomUUID();
await this.conn.execute("INSERT INTO assistant_compactions (id, session_id, summary, from_message_id, to_message_id) VALUES (?, ?, ?, ?, ?)", [
id,
this.sessionId,
summary,
fromMessageId,
toMessageId
]);
return {
id,
summary,
fromMessageId,
toMessageId,
createdAt: (/* @__PURE__ */ new Date()).toISOString()
};
}
async getCompactions() {
const { rows } = await this.conn.execute("SELECT * FROM assistant_compactions WHERE session_id = ? ORDER BY created_at ASC", [this.sessionId]);
return rows.map((r) => ({
id: r.id,
summary: r.summary,
fromMessageId: r.from_message_id,
toMessageId: r.to_message_id,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at)
}));
}
async searchMessages(query, limit = 20) {
const { rows } = await this.conn.execute(`SELECT id, role, text_content FROM assistant_messages
WHERE session_id = ? AND content_tsv @@ plainto_tsquery('english', ?)
ORDER BY ts_rank(content_tsv, plainto_tsquery('english', ?)) DESC
LIMIT ?`, [
this.sessionId,
query,
query,
limit
]);
return rows.map((r) => ({
id: r.id,
role: r.role,
content: r.text_content ?? "",
createdAt: ""
}));
}
async latestLeafRow() {
const { rows } = await this.conn.execute(`SELECT m.id, m.content FROM assistant_messages m
LEFT JOIN assistant_messages c ON c.parent_id = m.id AND c.session_id = ?
WHERE c.id IS NULL AND m.session_id = ?
ORDER BY m.created_at DESC LIMIT 1`, [this.sessionId, this.sessionId]);
return rows[0] ?? null;
}
applyCompactions(messages, compactions) {
const ids = messages.map((m) => m.id);
const result = [];
let i = 0;
while (i < messages.length) {
const matching = compactions.filter((c) => c.fromMessageId === ids[i]);
const comp = matching.length > 1 ? matching[matching.length - 1] : matching[0];
if (comp) {
const endIdx = ids.indexOf(comp.toMessageId);
if (endIdx >= i) {
result.push({
id: `compaction_${comp.id}`,
role: "assistant",
parts: [{
type: "text",
text: comp.summary
}],
createdAt: /* @__PURE__ */ new Date()
});
i = endIdx + 1;
continue;
}
}
result.push(messages[i]);
i++;
}
return result;
}
parse(json) {
try {
const msg = JSON.parse(json);
if (typeof msg?.id === "string" && typeof msg?.role === "string" && Array.isArray(msg?.parts)) return msg;
} catch {}
return null;
}
parseRows(rows) {
const result = [];
for (const row of rows) {
const msg = this.parse(row.content);
if (msg) result.push(msg);
}
return result;
}
/**
* Extract just the human-readable text from a message's JSON blob
* and store it in `text_content`, which feeds the generated `content_tsv`
* column used for FTS. The full structured message (parts, tool calls,
* metadata) is still stored verbatim in `content` — this is the source
* of truth. Indexing the raw JSON would return FTS hits on keys like
* `"role"`, `"parts"`, `"dynamic-tool"`, etc.
*/
extractSearchableText(json) {
const msg = this.parse(json);
if (!msg) return json;
return msg.parts.filter((p) => p.type === "text" && p.text).map((p) => p.text).join("\n");
}
};
//#endregion
//#region src/experimental/memory/session/providers/postgres-context.ts
var PostgresContextProvider = class {
/**
* @param client A raw `pg.Client` (recommended) or any `PostgresConnection`.
* Must already be connected.
* @param label Block label used as the primary key row in
* `cf_agents_context_blocks`. Pass a session-scoped label (e.g.
* `` `memory_${sessionId}` ``) for per-session state.
*/
constructor(client, label) {
this.conn = toPostgresConnection(client);
this.label = label;
}
async get() {
const { rows } = await this.conn.execute("SELECT content FROM cf_agents_context_blocks WHERE label = ?", [this.label]);
return rows[0]?.content ?? null;
}
async set(content) {
await this.conn.execute(`INSERT INTO cf_agents_context_blocks (label, content)
VALUES (?, ?)
ON CONFLICT (label) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()`, [this.label, content]);
}
};
//#endregion
//#region src/experimental/memory/session/providers/postgres-search.ts
var PostgresSearchProvider = class {
/**
* @param client A raw `pg.Client` (recommended) or any `PostgresConnection`.
* Must already be connected.
*/
constructor(client) {
this.label = "";
this.conn = toPostgresConnection(client);
}
init(label) {
this.label = label;
}
async get() {
const { rows } = await this.conn.execute("SELECT COUNT(*) as count FROM cf_agents_search_entries WHERE label = ?", [this.label]);
const count = Number(rows[0]?.count ?? 0);
if (count === 0) return null;
return `${count} entries indexed.`;
}
async search(query) {
if (!query.trim()) return null;
const { rows } = await this.conn.execute(`SELECT key, content FROM cf_agents_search_entries
WHERE label = ? AND content_tsv @@ plainto_tsquery('english', ?)
ORDER BY ts_rank(content_tsv, plainto_tsquery('english', ?)) DESC
LIMIT 10`, [
this.label,
query,
query
]);
if (rows.length === 0) return "No results found.";
return rows.map((r) => `[${r.key}]\n${r.content}`).join("\n\n");
}
async set(key, content) {
await this.conn.execute(`INSERT INTO cf_agents_search_entries (label, key, content)
VALUES (?, ?, ?)
ON CONFLICT (label, key) DO UPDATE SET
content = EXCLUDED.content,
updated_at = NOW()`, [
this.label,
key,
content
]);
}
};
//#endregion
export { AgentContextProvider, AgentSearchProvider, AgentSessionProvider, PostgresContextProvider, PostgresSearchProvider, PostgresSessionProvider, R2SkillProvider, Session, SessionManager, isSearchProvider, isSkillProvider, isWritableProvider };
//# sourceMappingURL=index.js.map