UNPKG

agents

Version:

A home for your AI agents

454 lines (453 loc) 15.8 kB
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