agents
Version:
A home for your AI agents
454 lines (453 loc) • 15.8 kB
JavaScript
import { r as estimateStringTokens } from "../tokens-nHAKcN6M.js";
import { z } from "zod";
//#region src/context/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
*
* Entries live in one namespaced FTS5 table, separate from the session
* message index.
*
* @example
* ```ts
* const context = new ContextBlocks([
* { label: "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 VIRTUAL TABLE IF NOT EXISTS cf_agents_search_fts
USING fts5(
label UNINDEXED,
key UNINDEXED,
content,
tokenize='porter unicode61'
)
`;
this.agent.sql`DROP TABLE IF EXISTS cf_agents_search_entries`;
this.initialized = true;
}
async get() {
this.ensureTable();
const count = this.agent.sql`
SELECT COUNT(*) as count FROM cf_agents_search_fts
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.agent.sql`
DELETE FROM cf_agents_search_fts
WHERE label = ${this.label} AND key = ${key}
`;
this.agent.sql`
INSERT INTO cf_agents_search_fts (label, key, content)
VALUES (${this.label}, ${key}, ${content})
`;
}
};
//#endregion
//#region src/context/blocks.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 {
/**
* @param configs Blocks to load on first use.
* @param promptStore Persists the frozen system prompt, keeping the
* provider's prefix cache warm across wakes.
* @param defaultProvider Supplies storage for blocks declared without a
* provider, so a host can offer durable writable blocks by label alone.
*/
constructor(configs, promptStore, defaultProvider) {
this.blocks = /* @__PURE__ */ new Map();
this.snapshot = null;
this.loaded = false;
this.configs = configs;
this.promptStore = promptStore ?? null;
this.defaultProvider = defaultProvider ?? null;
}
/** Fill in the host's storage for a block declared without a provider. */
withDefaultProvider(config) {
if (config.provider || !this.defaultProvider) return config;
return {
...config,
provider: this.defaultProvider(config.label)
};
}
/**
* Load all blocks from their providers. Hosts call this once at startup;
* every other entry point loads lazily.
*/
async load() {
this.configs = this.configs.map((config) => this.withDefaultProvider(config));
for (const config of this.configs) this.blocks.set(config.label, await this.loadBlock(config));
this.loaded = true;
}
/** Initialize a block's provider and read its current content. */
async loadBlock(config) {
const provider = config.provider;
provider?.init?.(config.label);
const content = provider ? await provider.get() ?? "" : "";
const searchable = isSearchProvider(provider);
return {
label: config.label,
description: config.description,
content,
tokens: estimateStringTokens(content),
maxTokens: config.maxTokens,
writable: isWritableProvider(provider) || searchable && !!provider?.set,
isSearchable: searchable
};
}
/**
* 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(input) {
if (!this.loaded) await this.load();
if (this.configs.some((c) => c.label === input.label)) throw new Error(`Block "${input.label}" already exists`);
const config = this.withDefaultProvider(input);
this.configs.push(config);
const block = await this.loadBlock(config);
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.
*/
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);
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.isSearchable) throw new Error(`Block "${label}" is a keyed provider. Use 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,
isSearchable: false
};
this.blocks.set(label, block);
if (config?.provider && isWritableProvider(config.provider)) await config.provider.set(content);
return block;
}
/** 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);
}
renderPrompt() {
const parts = [];
const sep = "═".repeat(46);
for (const block of this.blocks.values()) {
if (!block.content && !block.writable && !block.isSearchable) 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.writable) header += " [readonly]";
else header += " [writable]";
parts.push(`${sep}\n${header}\n${sep}\n${block.content}`);
}
return parts.join("\n\n");
}
/**
* The frozen system prompt. The first call renders the blocks and stores
* the result; every later call returns that same string, so the model
* provider's prefix cache stays warm. Block edits do not change it until
* `refreshSystemPrompt()` is called.
*/
async freezeSystemPrompt() {
if (this.promptStore) {
const stored = await this.promptStore.get();
if (stored !== null) return stored;
}
if (this.snapshot !== null) return this.snapshot;
if (!this.loaded) await this.load();
this.snapshot = this.renderPrompt();
await this.promptStore?.set(this.snapshot);
return this.snapshot;
}
/**
* Reload every block from its provider, re-render the system prompt, and
* persist it. Use this after block content has changed.
*/
async refreshSystemPrompt() {
this.loaded = false;
await this.load();
this.snapshot = this.renderPrompt();
await this.promptStore?.set(this.snapshot);
return this.snapshot;
}
/**
* AI tools for context blocks.
*
* Auto-wired based on provider capabilities:
* - `set_context` — when any block is writable
* - `search_context` — when any block is a search provider
*/
async tools() {
if (!this.loaded) await this.load();
const blocks = Array.from(this.blocks.values());
const writable = blocks.filter((b) => b.writable);
const searchLabels = blocks.filter((b) => b.isSearchable).map((b) => b.label);
const toolSet = {};
if (writable.length > 0) {
const blockDescriptions = writable.map((b) => {
const kind = b.isSearchable ? "searchable, keyed entries" : "writable";
return `- "${b.label}" (${kind}): ${b.description ?? "no description"}`;
});
const keyedBlocks = writable.filter((b) => 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 (searchable blocks: " + keyedBlocks.map((b) => `"${b.label}"`).join(", ") + "). A title keeps updates stable; a description helps the model pick the right entry.",
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 searchable blocks, pass `metadata: { title, description }` — title stabilises updates, description helps the model pick entries. Metadata is optional." : "";
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.isSearchable) {
const key = contextEntryKey(metadata?.title, content);
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 (searchLabels.length > 0) 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/context/sqlite-provider.ts
/** SQLite-backed writable context block provider. */
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
export { AgentContextProvider, AgentSearchProvider, ContextBlocks };
//# sourceMappingURL=index.js.map