diff --git a/README.md b/README.md index f4dd14d3..70e2c9ff 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The full specification is in [`spec/`](./spec/): - [Web](./spec/integrations/web.md) — browser integration, postMessage, security tiers - [Desktop](./spec/integrations/desktop.md) — Unix sockets, native messaging - [MCP Interoperability](./spec/integrations/mcp.md) — MCP Apps bridge, proxy, discovery alignment +- [LLM Context Injection](./spec/integrations/llm-context.md) — ephemeral `` tail, prompt-cache-friendly framing ### Status and limits diff --git a/packages/typescript/integrations/claude/slop-mcp-proxy/README.md b/packages/typescript/integrations/claude/slop-mcp-proxy/README.md index 88a3ceb5..922c3cce 100644 --- a/packages/typescript/integrations/claude/slop-mcp-proxy/README.md +++ b/packages/typescript/integrations/claude/slop-mcp-proxy/README.md @@ -62,7 +62,7 @@ This plugin provides **5 static MCP tools**: | `app_action` | Perform a single action on an app node | | `app_action_batch` | Perform multiple actions in one call | -App state is injected into context on every user message via a `UserPromptSubmit` hook. Claude reads affordances, paths, and parameter schemas from the injected state tree and uses the generic action tools to invoke them. +App state is injected into context on every user message via a `UserPromptSubmit` hook. The bridge renders the live `` / `` blocks and writes them to `/tmp/claude-slop-plugin/context.txt` on every state change and on a 10s heartbeat; the hook emits the file verbatim and drops it once the file is more than 30s old, so a dead bridge stops injecting. Claude reads affordances, paths, and parameter schemas from the injected state tree and uses the generic action tools to invoke them. ## Comparison with `slop-native` diff --git a/packages/typescript/integrations/claude/slop-mcp-proxy/hooks/scripts/inject-state.mjs b/packages/typescript/integrations/claude/slop-mcp-proxy/hooks/scripts/inject-state.mjs index e6b4c6d0..386bcc60 100644 --- a/packages/typescript/integrations/claude/slop-mcp-proxy/hooks/scripts/inject-state.mjs +++ b/packages/typescript/integrations/claude/slop-mcp-proxy/hooks/scripts/inject-state.mjs @@ -1,61 +1,28 @@ #!/usr/bin/env node /** - * Hook script: reads the shared state file written by slop-bridge - * and outputs connected providers' state trees for context injection. + * Hook script: emits the framed SLOP context written by slop-bridge. * - * Called by the UserPromptSubmit hook on every user message. - * Outputs nothing if no providers are connected/discovered or if the state is stale. + * The bridge owns rendering — this script is just a stale-aware reader. + * Outputs nothing if the file is missing, empty, or older than the freshness + * threshold. Stale-detection uses file mtime, no JSON parsing. */ import fs from "node:fs"; -const STATE_FILE = "/tmp/claude-slop-plugin/state.json"; -const STALE_THRESHOLD = 30_000; // 30 seconds +const CONTEXT_FILE = "/tmp/claude-slop-plugin/context.txt"; +const STALE_THRESHOLD_MS = 30_000; try { - if (!fs.existsSync(STATE_FILE)) process.exit(0); + if (!fs.existsSync(CONTEXT_FILE)) process.exit(0); - const raw = fs.readFileSync(STATE_FILE, "utf-8"); - const data = JSON.parse(raw); + const stat = fs.statSync(CONTEXT_FILE); + if (Date.now() - stat.mtimeMs > STALE_THRESHOLD_MS) process.exit(0); - // Skip injection if state file is stale (MCP server may have crashed) - if (data.lastUpdated && Date.now() - data.lastUpdated > STALE_THRESHOLD) { - process.exit(0); - } + const content = fs.readFileSync(CONTEXT_FILE, "utf-8"); + if (content.trim().length === 0) process.exit(0); - const hasProviders = data.providers && data.providers.length > 0; - const hasAvailable = data.available && data.available.length > 0; - - if (!hasProviders && !hasAvailable) process.exit(0); - - let output = "## SLOP Apps\n\n"; - - if (hasProviders) { - output += `${data.providers.length} app(s) connected. `; - output += - "Use app_action or app_action_batch to act on apps. Call connect_app to refresh full state and available actions.\n\n"; - - for (const provider of data.providers) { - output += `### ${provider.name} (${provider.id})\n\n`; - if (provider.state && provider.state !== "(no state yet)") { - output += "```\n" + provider.state + "\n```\n\n"; - } else { - output += "(awaiting state snapshot)\n\n"; - } - } - } - - if (hasAvailable) { - output += "### Available (not connected)\n\n"; - for (const app of data.available) { - output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport}, ${app.source})\n`; - } - output += "\nCall connect_app with an app name to connect.\n"; - } - - process.stdout.write(output); + process.stdout.write(content); } catch { - // Silently exit — don't break the session if the state file is corrupt process.exit(0); } diff --git a/packages/typescript/integrations/claude/slop-mcp-proxy/servers/slop-bridge.mjs b/packages/typescript/integrations/claude/slop-mcp-proxy/servers/slop-bridge.mjs index 46779c48..4e726f68 100644 --- a/packages/typescript/integrations/claude/slop-mcp-proxy/servers/slop-bridge.mjs +++ b/packages/typescript/integrations/claude/slop-mcp-proxy/servers/slop-bridge.mjs @@ -20,9 +20,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { buildSlopContext } from "@slop-ai/discovery/context"; import { createDiscoveryService } from "@slop-ai/discovery/service"; import { createToolHandlers } from "@slop-ai/discovery/tools"; -import { formatTree } from "@slop-ai/consumer"; import fs from "node:fs"; import path from "node:path"; @@ -31,7 +31,8 @@ import path from "node:path"; // --------------------------------------------------------------------------- const STATE_DIR = "/tmp/claude-slop-plugin"; -const STATE_FILE = path.join(STATE_DIR, "state.json"); +const CONTEXT_FILE = path.join(STATE_DIR, "context.txt"); +const LEGACY_STATE_FILE = path.join(STATE_DIR, "state.json"); // --------------------------------------------------------------------------- // Logger (MCP servers must not write to stdout — use stderr) @@ -53,49 +54,44 @@ const handlers = createToolHandlers(discovery); // State file management (for hook-based context injection) // --------------------------------------------------------------------------- -function writeStateFile() { +function writeContextFile() { try { fs.mkdirSync(STATE_DIR, { recursive: true }); - const connected = discovery.getProviders(); - const discovered = discovery.getDiscovered(); - const connectedIds = new Set(connected.map((p) => p.id)); - - // Discovered but not connected - const available = discovered - .filter((d) => !connectedIds.has(d.id)) - .map((d) => ({ - id: d.id, - name: d.name, - transport: d.transport.type, - source: d.source ?? "local", - })); - - if (connected.length === 0 && available.length === 0) { - if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); - return; + if (fs.existsSync(LEGACY_STATE_FILE)) { + try { + fs.unlinkSync(LEGACY_STATE_FILE); + } catch {} } - const providers = connected.map((p) => { - const tree = p.consumer.getTree(p.subscriptionId); - return { - id: p.id, - name: p.name, - state: tree ? formatTree(tree) : "(no state yet)", - }; - }); + const { stateTail, availableAppsTail } = buildSlopContext(discovery); + const parts = [stateTail, availableAppsTail].filter((t) => !!t); - fs.writeFileSync(STATE_FILE, JSON.stringify({ lastUpdated: Date.now(), providers, available }, null, 2)); + if (parts.length === 0) { + if (fs.existsSync(CONTEXT_FILE)) fs.unlinkSync(CONTEXT_FILE); + return; + } + + fs.writeFileSync(CONTEXT_FILE, parts.join("\n\n")); } catch (err) { - log.error("Failed to write state file:", err.message); + log.error("Failed to write context file:", err.message); } } -// Update state file whenever state changes +// Update context file whenever state changes discovery.onStateChange(() => { - writeStateFile(); + writeContextFile(); }); +// Heartbeat: re-render the context file periodically while we're alive so +// `generated_at` refreshes and the hook's stale-detection signals "bridge +// died", not "state hasn't changed recently". The tail is uncached by design. +const HEARTBEAT_INTERVAL_MS = 10_000; +const heartbeatTimer = setInterval(() => { + writeContextFile(); +}, HEARTBEAT_INTERVAL_MS); +heartbeatTimer.unref?.(); + // --------------------------------------------------------------------------- // Static MCP Tool definitions // --------------------------------------------------------------------------- @@ -341,14 +337,14 @@ async function main() { process.on("SIGINT", () => { discovery.stop(); try { - fs.unlinkSync(STATE_FILE); + fs.unlinkSync(CONTEXT_FILE); } catch {} process.exit(0); }); process.on("SIGTERM", () => { discovery.stop(); try { - fs.unlinkSync(STATE_FILE); + fs.unlinkSync(CONTEXT_FILE); } catch {} process.exit(0); }); diff --git a/packages/typescript/integrations/claude/slop-native/README.md b/packages/typescript/integrations/claude/slop-native/README.md index 7fa179ac..6722423c 100644 --- a/packages/typescript/integrations/claude/slop-native/README.md +++ b/packages/typescript/integrations/claude/slop-native/README.md @@ -113,7 +113,7 @@ Claude calls these directly — no proxy through meta-tools needed. Tools are re 2. When Claude calls `connect_app` with an app name, the service lazy-connects via the appropriate transport (WebSocket, Unix socket, or extension relay) and subscribes to the state tree. 3. `createDynamicTools` from `@slop-ai/discovery/tools` converts each connected app's affordances into namespaced MCP tools. The server notifies Claude via `tools/list_changed`. 4. Claude calls affordance tools directly (e.g. `excalidraw__elements__add_rectangle`). The server resolves each tool name to a provider + path + action and invokes it. -5. The `UserPromptSubmit` hook reads a shared state file (`/tmp/claude-slop-plugin/state.json`) that the MCP server updates whenever state changes, injecting live state into Claude's context. +5. The MCP server renders the live `` / `` blocks and writes them to `/tmp/claude-slop-plugin/context.txt` on every state change and on a 10s heartbeat. The bundled `UserPromptSubmit` hook emits that file verbatim into Claude's context, and drops it once the file is more than 30s old so a dead bridge stops injecting. 6. When Claude calls `disconnect_app`, the provider is disconnected, its tools are removed, and state drops from the hook. ## Architecture diff --git a/packages/typescript/integrations/claude/slop-native/hooks/scripts/inject-state.mjs b/packages/typescript/integrations/claude/slop-native/hooks/scripts/inject-state.mjs index d1c613c2..386bcc60 100644 --- a/packages/typescript/integrations/claude/slop-native/hooks/scripts/inject-state.mjs +++ b/packages/typescript/integrations/claude/slop-native/hooks/scripts/inject-state.mjs @@ -1,61 +1,28 @@ #!/usr/bin/env node /** - * Hook script: reads the shared state file written by slop-bridge - * and outputs connected providers' state trees for context injection. + * Hook script: emits the framed SLOP context written by slop-bridge. * - * Called by the UserPromptSubmit hook on every user message. - * Outputs nothing if no providers are connected/discovered or if the state is stale. + * The bridge owns rendering — this script is just a stale-aware reader. + * Outputs nothing if the file is missing, empty, or older than the freshness + * threshold. Stale-detection uses file mtime, no JSON parsing. */ import fs from "node:fs"; -const STATE_FILE = "/tmp/claude-slop-plugin/state.json"; -const STALE_THRESHOLD = 30_000; // 30 seconds +const CONTEXT_FILE = "/tmp/claude-slop-plugin/context.txt"; +const STALE_THRESHOLD_MS = 30_000; try { - if (!fs.existsSync(STATE_FILE)) process.exit(0); + if (!fs.existsSync(CONTEXT_FILE)) process.exit(0); - const raw = fs.readFileSync(STATE_FILE, "utf-8"); - const data = JSON.parse(raw); + const stat = fs.statSync(CONTEXT_FILE); + if (Date.now() - stat.mtimeMs > STALE_THRESHOLD_MS) process.exit(0); - // Skip injection if state file is stale (MCP server may have crashed) - if (data.lastUpdated && Date.now() - data.lastUpdated > STALE_THRESHOLD) { - process.exit(0); - } + const content = fs.readFileSync(CONTEXT_FILE, "utf-8"); + if (content.trim().length === 0) process.exit(0); - const hasProviders = data.providers && data.providers.length > 0; - const hasAvailable = data.available && data.available.length > 0; - - if (!hasProviders && !hasAvailable) process.exit(0); - - let output = "## SLOP Apps\n\n"; - - if (hasProviders) { - output += `${data.providers.length} app(s) connected. `; - output += - "Use the app-specific tools registered for each connected app to act on them directly. Call connect_app to refresh state and tool registration if needed.\n\n"; - - for (const provider of data.providers) { - output += `### ${provider.name} (${provider.id})\n\n`; - if (provider.state && provider.state !== "(no state yet)") { - output += "```\n" + provider.state + "\n```\n\n"; - } else { - output += "(awaiting state snapshot)\n\n"; - } - } - } - - if (hasAvailable) { - output += "### Available (not connected)\n\n"; - for (const app of data.available) { - output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport}, ${app.source})\n`; - } - output += "\nCall connect_app with an app name to connect.\n"; - } - - process.stdout.write(output); + process.stdout.write(content); } catch { - // Silently exit — don't break the session if the state file is corrupt process.exit(0); } diff --git a/packages/typescript/integrations/claude/slop-native/servers/slop-bridge.mjs b/packages/typescript/integrations/claude/slop-native/servers/slop-bridge.mjs index 061bbfa2..b2db38b7 100644 --- a/packages/typescript/integrations/claude/slop-native/servers/slop-bridge.mjs +++ b/packages/typescript/integrations/claude/slop-native/servers/slop-bridge.mjs @@ -18,9 +18,9 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { buildSlopContext } from "@slop-ai/discovery/context"; import { createDiscoveryService } from "@slop-ai/discovery/service"; import { createToolHandlers, createDynamicTools } from "@slop-ai/discovery/tools"; -import { formatTree } from "@slop-ai/consumer"; import fs from "node:fs"; import path from "node:path"; @@ -29,7 +29,8 @@ import path from "node:path"; // --------------------------------------------------------------------------- const STATE_DIR = "/tmp/claude-slop-plugin"; -const STATE_FILE = path.join(STATE_DIR, "state.json"); +const CONTEXT_FILE = path.join(STATE_DIR, "context.txt"); +const LEGACY_STATE_FILE = path.join(STATE_DIR, "state.json"); // --------------------------------------------------------------------------- // Logger (MCP servers must not write to stdout — use stderr) @@ -57,49 +58,44 @@ let dynamicToolSet = createDynamicTools(discovery); // State file management (for hook-based context injection) // --------------------------------------------------------------------------- -function writeStateFile() { +function writeContextFile() { try { fs.mkdirSync(STATE_DIR, { recursive: true }); - const connected = discovery.getProviders(); - const discovered = discovery.getDiscovered(); - const connectedIds = new Set(connected.map((p) => p.id)); - - // Discovered but not connected - const available = discovered - .filter((d) => !connectedIds.has(d.id)) - .map((d) => ({ - id: d.id, - name: d.name, - transport: d.transport.type, - source: d.source ?? "local", - })); - - if (connected.length === 0 && available.length === 0) { - if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); - return; + if (fs.existsSync(LEGACY_STATE_FILE)) { + try { + fs.unlinkSync(LEGACY_STATE_FILE); + } catch {} } - const providers = connected.map((p) => { - const tree = p.consumer.getTree(p.subscriptionId); - return { - id: p.id, - name: p.name, - state: tree ? formatTree(tree) : "(no state yet)", - }; - }); + const { stateTail, availableAppsTail } = buildSlopContext(discovery); + const parts = [stateTail, availableAppsTail].filter((t) => !!t); + + if (parts.length === 0) { + if (fs.existsSync(CONTEXT_FILE)) fs.unlinkSync(CONTEXT_FILE); + return; + } - fs.writeFileSync(STATE_FILE, JSON.stringify({ lastUpdated: Date.now(), providers, available }, null, 2)); + fs.writeFileSync(CONTEXT_FILE, parts.join("\n\n")); } catch (err) { - log.error("Failed to write state file:", err.message); + log.error("Failed to write context file:", err.message); } } +// Heartbeat: re-render the context file periodically while we're alive so +// `generated_at` refreshes and the hook's stale-detection signals "bridge +// died", not "state hasn't changed recently". The tail is uncached by design. +const HEARTBEAT_INTERVAL_MS = 10_000; +const heartbeatTimer = setInterval(() => { + writeContextFile(); +}, HEARTBEAT_INTERVAL_MS); +heartbeatTimer.unref?.(); + // Update state file + dynamic tools whenever state changes discovery.onStateChange(() => { const prevCount = dynamicToolSet.tools.length; dynamicToolSet = createDynamicTools(discovery); - writeStateFile(); + writeContextFile(); // Notify Claude that the tool list changed (new/removed affordances) if (dynamicToolSet.tools.length !== prevCount) { @@ -312,14 +308,14 @@ async function main() { process.on("SIGINT", () => { discovery.stop(); try { - fs.unlinkSync(STATE_FILE); + fs.unlinkSync(CONTEXT_FILE); } catch {} process.exit(0); }); process.on("SIGTERM", () => { discovery.stop(); try { - fs.unlinkSync(STATE_FILE); + fs.unlinkSync(CONTEXT_FILE); } catch {} process.exit(0); }); diff --git a/packages/typescript/integrations/codex/slop/README.md b/packages/typescript/integrations/codex/slop/README.md index cafaf7df..94aa91c0 100644 --- a/packages/typescript/integrations/codex/slop/README.md +++ b/packages/typescript/integrations/codex/slop/README.md @@ -39,8 +39,8 @@ Codex loads the plugin-local MCP server from `.mcp.json`. That server uses `@slo 1. discover local and browser-announced SLOP providers 2. connect to a provider on demand with `connect_app` -3. write connected-provider state to `/tmp/codex-slop-plugin/state.json` -4. let the bundled `UserPromptSubmit` hook inject that state into future Codex turns +3. render the connected-provider state and available-app catalog as a framed `` / `` block and write it to `/tmp/codex-slop-plugin/context.txt` +4. let the bundled `UserPromptSubmit` hook emit that file verbatim into future Codex turns (the bridge re-renders on every state change and on a 10s heartbeat; the hook drops the file after 30s without an update so a dead bridge stops injecting) 5. execute actions through `app_action` or `app_action_batch` `connect_app` still returns the current formatted state tree immediately, so Codex can inspect and act in the same turn it establishes a connection. After that, future user turns get live injected state automatically. diff --git a/packages/typescript/integrations/codex/slop/hooks/scripts/inject-state.mjs b/packages/typescript/integrations/codex/slop/hooks/scripts/inject-state.mjs index 223bbc33..ffda45c7 100644 --- a/packages/typescript/integrations/codex/slop/hooks/scripts/inject-state.mjs +++ b/packages/typescript/integrations/codex/slop/hooks/scripts/inject-state.mjs @@ -1,59 +1,28 @@ #!/usr/bin/env node /** - * Hook script: reads the shared state file written by slop-bridge - * and outputs connected providers' state trees for context injection. + * Hook script: emits the framed SLOP context written by slop-bridge. * - * Called by the UserPromptSubmit hook on every user message. - * Outputs nothing if no providers are connected/discovered or if the state is stale. + * The bridge owns rendering — this script is just a stale-aware reader. + * Outputs nothing if the file is missing, empty, or older than the freshness + * threshold. Stale-detection uses file mtime, no JSON parsing. */ import fs from "node:fs"; -const STATE_FILE = "/tmp/codex-slop-plugin/state.json"; -const STALE_THRESHOLD = 30_000; // 30 seconds +const CONTEXT_FILE = "/tmp/codex-slop-plugin/context.txt"; +const STALE_THRESHOLD_MS = 30_000; try { - if (!fs.existsSync(STATE_FILE)) process.exit(0); + if (!fs.existsSync(CONTEXT_FILE)) process.exit(0); - const raw = fs.readFileSync(STATE_FILE, "utf-8"); - const data = JSON.parse(raw); + const stat = fs.statSync(CONTEXT_FILE); + if (Date.now() - stat.mtimeMs > STALE_THRESHOLD_MS) process.exit(0); - if (data.lastUpdated && Date.now() - data.lastUpdated > STALE_THRESHOLD) { - process.exit(0); - } + const content = fs.readFileSync(CONTEXT_FILE, "utf-8"); + if (content.trim().length === 0) process.exit(0); - const hasProviders = data.providers && data.providers.length > 0; - const hasAvailable = data.available && data.available.length > 0; - - if (!hasProviders && !hasAvailable) process.exit(0); - - let output = "## SLOP Apps\n\n"; - - if (hasProviders) { - output += `${data.providers.length} app(s) connected. `; - output += - "Read the state trees below before acting. Use app_action or app_action_batch to invoke affordances, and call connect_app only when you need to connect a new app or force a refresh.\n\n"; - - for (const provider of data.providers) { - output += `### ${provider.name} (${provider.id})\n\n`; - if (provider.state && provider.state !== "(no state yet)") { - output += "```\n" + provider.state + "\n```\n\n"; - } else { - output += "(awaiting state snapshot)\n\n"; - } - } - } - - if (hasAvailable) { - output += "### Available (not connected)\n\n"; - for (const app of data.available) { - output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport}, ${app.source})\n`; - } - output += "\nCall connect_app with an app name to connect it.\n"; - } - - process.stdout.write(output); + process.stdout.write(content); } catch { process.exit(0); } diff --git a/packages/typescript/integrations/codex/slop/servers/slop-bridge.mjs b/packages/typescript/integrations/codex/slop/servers/slop-bridge.mjs index 4c0669de..f6633ca5 100644 --- a/packages/typescript/integrations/codex/slop/servers/slop-bridge.mjs +++ b/packages/typescript/integrations/codex/slop/servers/slop-bridge.mjs @@ -12,14 +12,15 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { buildSlopContext } from "@slop-ai/discovery/context"; import { createDiscoveryService } from "@slop-ai/discovery/service"; import { createToolHandlers } from "@slop-ai/discovery/tools"; -import { formatTree } from "@slop-ai/consumer"; import fs from "node:fs"; import path from "node:path"; const STATE_DIR = "/tmp/codex-slop-plugin"; -const STATE_FILE = path.join(STATE_DIR, "state.json"); +const CONTEXT_FILE = path.join(STATE_DIR, "context.txt"); +const LEGACY_STATE_FILE = path.join(STATE_DIR, "state.json"); const log = { info: (...args) => console.error("[codex-slop]", ...args), @@ -29,47 +30,46 @@ const log = { const discovery = createDiscoveryService({ logger: log, autoConnect: false }); const handlers = createToolHandlers(discovery); -function writeStateFile() { +function writeContextFile() { try { fs.mkdirSync(STATE_DIR, { recursive: true }); - const connected = discovery.getProviders(); - const discovered = discovery.getDiscovered(); - const connectedIds = new Set(connected.map((provider) => provider.id)); - - const available = discovered - .filter((descriptor) => !connectedIds.has(descriptor.id)) - .map((descriptor) => ({ - id: descriptor.id, - name: descriptor.name, - transport: descriptor.transport.type, - source: descriptor.source ?? "local", - })); - - if (connected.length === 0 && available.length === 0) { - if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE); - return; + // Drop the legacy JSON state file the first time we run with the new layout. + if (fs.existsSync(LEGACY_STATE_FILE)) { + try { + fs.unlinkSync(LEGACY_STATE_FILE); + } catch {} } - const providers = connected.map((provider) => { - const tree = provider.consumer.getTree(provider.subscriptionId); - return { - id: provider.id, - name: provider.name, - state: tree ? formatTree(tree) : "(no state yet)", - }; - }); + const { stateTail, availableAppsTail } = buildSlopContext(discovery); + const parts = [stateTail, availableAppsTail].filter((t) => !!t); - fs.writeFileSync(STATE_FILE, JSON.stringify({ lastUpdated: Date.now(), providers, available }, null, 2)); + if (parts.length === 0) { + if (fs.existsSync(CONTEXT_FILE)) fs.unlinkSync(CONTEXT_FILE); + return; + } + + fs.writeFileSync(CONTEXT_FILE, parts.join("\n\n")); } catch (err) { - log.error("Failed to write state file:", err.message); + log.error("Failed to write context file:", err.message); } } discovery.onStateChange(() => { - writeStateFile(); + writeContextFile(); }); +// Heartbeat: re-render the context file periodically while we're alive. This +// keeps `generated_at` honest and lets the hook's stale-detection signal +// "bridge died", not "state hasn't changed recently". Re-rendering (not just +// touching mtime) avoids the model seeing an old timestamp on unchanged state. +// The tail is uncached by design, so refreshing its bytes is harmless. +const HEARTBEAT_INTERVAL_MS = 10_000; +const heartbeatTimer = setInterval(() => { + writeContextFile(); +}, HEARTBEAT_INTERVAL_MS); +heartbeatTimer.unref?.(); + const TOOLS = [ { name: "list_apps", @@ -295,7 +295,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { async function main() { discovery.start(); log.info("Discovery started (local + bridge)"); - writeStateFile(); + writeContextFile(); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/packages/typescript/integrations/discovery/__tests__/context.test.ts b/packages/typescript/integrations/discovery/__tests__/context.test.ts new file mode 100644 index 00000000..29e27fc3 --- /dev/null +++ b/packages/typescript/integrations/discovery/__tests__/context.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test } from "bun:test"; +import type { SlopNode } from "@slop-ai/consumer"; +import { buildSlopAvailableAppsTail, buildSlopContext, buildSlopStateTail } from "../src/context"; +import type { ConnectedProvider, DiscoveryService, ProviderDescriptor } from "../src/discovery"; + +const FIXED_TS = "2026-04-28T10:30:00.000Z"; + +function descriptor(id: string, name: string, capabilities: string[] = []): ProviderDescriptor { + return { + id, + name, + slop_version: "1.0", + transport: { type: "ws", url: `ws://example/${id}` }, + capabilities, + source: "local", + }; +} + +function makeProvider(id: string, name: string, tree: SlopNode | null): ConnectedProvider { + const desc = descriptor(id, name); + const fakeConsumer = { + getTree: (_subId: string) => tree, + } as unknown as ConnectedProvider["consumer"]; + return { + id, + name, + descriptor: desc, + consumer: fakeConsumer, + subscriptionId: "sub-1", + status: "connected", + }; +} + +function fakeDiscovery(opts: { + connected?: ConnectedProvider[]; + discovered?: ProviderDescriptor[]; +}): DiscoveryService { + const connected = opts.connected ?? []; + const discovered = opts.discovered ?? connected.map((p) => p.descriptor); + return { + getDiscovered: () => discovered, + getProviders: () => connected, + getProvider: (id) => connected.find((p) => p.id === id) ?? null, + ensureConnected: async () => null, + disconnect: () => false, + onStateChange: () => {}, + start: () => {}, + stop: () => {}, + }; +} + +const sampleTree: SlopNode = { + id: "mail-app", + type: "root", + properties: { label: "Mail" }, + children: [], +}; + +describe("buildSlopStateTail", () => { + test("connected provider with tree appears only in the state tail", () => { + const provider = makeProvider("mail", "Mail", sampleTree); + const discovery = fakeDiscovery({ connected: [provider] }); + const out = buildSlopStateTail(discovery, { generatedAt: FIXED_TS }); + expect(out).not.toBeNull(); + expect(out).toContain("Mail (mail)"); + expect(out).toContain("[root] mail-app"); + }); + + test("connected provider awaiting snapshot is rendered as awaiting", () => { + const provider = makeProvider("mail", "Mail", null); + const discovery = fakeDiscovery({ connected: [provider] }); + const out = buildSlopStateTail(discovery, { generatedAt: FIXED_TS }); + expect(out).toContain("(awaiting snapshot)"); + }); + + test("returns null when no providers are connected", () => { + const discovery = fakeDiscovery({}); + expect(buildSlopStateTail(discovery)).toBeNull(); + }); +}); + +describe("buildSlopAvailableAppsTail", () => { + test("only unconnected discovered apps appear in the catalog", () => { + const connected = makeProvider("mail", "Mail", sampleTree); + const discovery = fakeDiscovery({ + connected: [connected], + discovered: [connected.descriptor, descriptor("calendar", "Calendar", ["events"])], + }); + const out = buildSlopAvailableAppsTail(discovery, { generatedAt: FIXED_TS }); + expect(out).not.toBeNull(); + expect(out).toContain("Calendar (id: `calendar`, ws, local)"); + expect(out).toContain("capabilities: events"); + expect(out).not.toContain("Mail (id: `mail`"); + }); + + test("returns null when every discovered app is already connected", () => { + const provider = makeProvider("mail", "Mail", sampleTree); + const discovery = fakeDiscovery({ connected: [provider] }); + expect(buildSlopAvailableAppsTail(discovery)).toBeNull(); + }); +}); + +describe("buildSlopStateTail projection", () => { + test("projectTree is applied before rendering", () => { + const provider = makeProvider("mail", "Mail", sampleTree); + const discovery = fakeDiscovery({ connected: [provider] }); + const projected: SlopNode = { + id: "filtered", + type: "root", + properties: { label: "Filtered" }, + }; + const out = buildSlopStateTail(discovery, { + generatedAt: FIXED_TS, + projectTree: () => projected, + }); + expect(out).toContain("[root] filtered"); + expect(out).not.toContain("[root] mail-app"); + }); +}); + +describe("buildSlopContext", () => { + test("returns both tails in one call", () => { + const provider = makeProvider("mail", "Mail", sampleTree); + const discovery = fakeDiscovery({ + connected: [provider], + discovered: [provider.descriptor, descriptor("calendar", "Calendar")], + }); + const ctx = buildSlopContext(discovery, { generatedAt: FIXED_TS }); + expect(ctx.stateTail).toContain(" { + const ctx = buildSlopContext(fakeDiscovery({})); + expect(ctx.stateTail).toBeNull(); + expect(ctx.availableAppsTail).toBeNull(); + }); +}); diff --git a/packages/typescript/integrations/discovery/package.json b/packages/typescript/integrations/discovery/package.json index 50ba094a..7c4747f0 100644 --- a/packages/typescript/integrations/discovery/package.json +++ b/packages/typescript/integrations/discovery/package.json @@ -1,6 +1,6 @@ { "name": "@slop-ai/discovery", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "description": "SLOP bridge and discovery primitives. Use `@slop-ai/discovery/service` for connection orchestration and `@slop-ai/discovery/tools` for host tool helpers.", "main": "dist/index.js", @@ -24,6 +24,10 @@ "./anthropic-agent-sdk": { "import": "./dist/anthropic-agent-sdk.js", "types": "./dist/anthropic-agent-sdk.d.ts" + }, + "./context": { + "import": "./dist/context.js", + "types": "./dist/context.d.ts" } }, "files": ["src", "dist"], @@ -37,7 +41,7 @@ "directory": "packages/typescript/integrations/discovery" }, "scripts": { - "build": "bun build src/index.ts src/service.ts src/tools.ts src/anthropic-agent-sdk.ts src/cli.ts --outdir dist --format esm --target node --external '@slop-ai/consumer' --external '@anthropic-ai/claude-agent-sdk' --external '@modelcontextprotocol/sdk' --external 'zod' --external 'ws' && bunx tsc --emitDeclarationOnly --outDir dist", + "build": "bun build src/index.ts src/service.ts src/tools.ts src/anthropic-agent-sdk.ts src/context.ts src/cli.ts --outdir dist --format esm --target node --external '@slop-ai/consumer' --external '@anthropic-ai/claude-agent-sdk' --external '@modelcontextprotocol/sdk' --external 'zod' --external 'ws' && bunx tsc --emitDeclarationOnly --outDir dist", "build:bundle": "bun build src/cli.ts --outfile dist/cli.bundle.js --format esm --target node", "clean": "rm -rf dist" }, diff --git a/packages/typescript/integrations/discovery/src/cli.ts b/packages/typescript/integrations/discovery/src/cli.ts index aa5484e0..5bf5a256 100644 --- a/packages/typescript/integrations/discovery/src/cli.ts +++ b/packages/typescript/integrations/discovery/src/cli.ts @@ -32,26 +32,33 @@ server.tool( async () => handlers.listApps(), ); -server.tool( +const connectAppDescription: string = isPluginMode + ? "Connect to a discovered application and refresh its full current state. " + + "Use this when you need to inspect a newly discovered app or refresh the active app state." + : "Connect to an application running on this computer and see its full current state and every action you can perform."; + +// MCP SDK's `server.tool` overloads inflate inference depth when zod +// `.describe()` chains meet conditional descriptions. Hold the registration +// behind a less-specific signature to keep declaration generation healthy. +const registerTool = server.tool.bind(server) as (...args: unknown[]) => void; + +registerTool( "connect_app", - isPluginMode - ? "Connect to a discovered application and refresh its full current state. " + - "Use this when you need to inspect a newly discovered app or refresh the active app state." - : "Connect to an application running on this computer and see its full current state and every action you can perform.", + connectAppDescription, { app: z.string().describe("App name or ID to connect and inspect."), }, - async (args) => handlers.connectApp(args), + async (args: { app: string }) => handlers.connectApp(args), ); -server.tool( +registerTool( "disconnect_app", "Disconnect from an application. Removes its action tools and stops state updates. " + "Use when you're done interacting with an app.", { app: z.string().describe("App name or ID to disconnect from."), }, - async (args) => handlers.disconnectApp(args), + async (args: { app: string }) => handlers.disconnectApp(args), ); discovery.start(); diff --git a/packages/typescript/integrations/discovery/src/context.ts b/packages/typescript/integrations/discovery/src/context.ts new file mode 100644 index 00000000..dc84abb3 --- /dev/null +++ b/packages/typescript/integrations/discovery/src/context.ts @@ -0,0 +1,93 @@ +import { + type AvailableSlopApp, + renderSlopAvailableApps, + renderSlopStateTail, + type SlopNode, + type SlopStateApp, +} from "@slop-ai/consumer"; +import type { ConnectedProvider, DiscoveryService, ProviderDescriptor } from "./discovery"; + +export interface BuildContextOptions { + /** ISO timestamp; defaults to `new Date().toISOString()`. Pass an explicit value for byte-stable output. */ + generatedAt?: string; + /** + * Optional per-provider tree projection applied before rendering. The spec + * (`spec/integrations/llm-context.md`) recommends salience and view-scope + * filtering before the tail is built. Use `prepareTree` / `filterTree` / + * `autoCompact` from `@slop-ai/core` here, or any custom transform that + * returns a `SlopNode`. If omitted, the raw subscribed tree is rendered + * unchanged — appropriate only for small apps that fit comfortably. + */ + projectTree?: (tree: SlopNode, provider: ConnectedProvider) => SlopNode; +} + +/** + * Render the live-state tail for currently connected providers. Includes + * "awaiting snapshot" markers for providers that are connected but have not + * yet produced a tree. + */ +export function buildSlopStateTail( + discovery: DiscoveryService, + options: BuildContextOptions = {}, +): string | null { + const apps = discovery.getProviders().map((p) => toStateApp(p, options.projectTree)); + return renderSlopStateTail({ apps, generatedAt: options.generatedAt }); +} + +/** + * Render the catalog tail for discovered providers that are NOT currently + * connected. This is host capability context, not live observation, and goes + * into a sibling `` block per spec/integrations/llm-context.md. + */ +export function buildSlopAvailableAppsTail( + discovery: DiscoveryService, + options: BuildContextOptions = {}, +): string | null { + const connectedIds = new Set(discovery.getProviders().map((p) => p.id)); + const unconnected = discovery.getDiscovered().filter((d) => !connectedIds.has(d.id)); + const apps = unconnected.map(toAvailableApp); + return renderSlopAvailableApps({ apps, generatedAt: options.generatedAt }); +} + +export interface SlopContext { + stateTail: string | null; + availableAppsTail: string | null; +} + +/** + * Convenience helper returning both context blocks in one call. Either field + * may be null if the corresponding list is empty; callers can pass them + * directly into `composeMessagesWithSlopState`. + */ +export function buildSlopContext( + discovery: DiscoveryService, + options: BuildContextOptions = {}, +): SlopContext { + return { + stateTail: buildSlopStateTail(discovery, options), + availableAppsTail: buildSlopAvailableAppsTail(discovery, options), + }; +} + +function toStateApp( + provider: ConnectedProvider, + projectTree?: BuildContextOptions["projectTree"], +): SlopStateApp { + const raw = provider.consumer.getTree(provider.subscriptionId); + const tree = raw && projectTree ? projectTree(raw, provider) : raw; + return { + id: provider.id, + name: provider.name, + tree: tree ?? null, + }; +} + +function toAvailableApp(desc: ProviderDescriptor): AvailableSlopApp { + return { + id: desc.id, + name: desc.name, + transport: desc.transport.type, + source: desc.source ?? "local", + capabilities: desc.capabilities, + }; +} diff --git a/packages/typescript/integrations/openclaw-plugin/package.json b/packages/typescript/integrations/openclaw-plugin/package.json index 1c1f8ecf..cd2294d6 100644 --- a/packages/typescript/integrations/openclaw-plugin/package.json +++ b/packages/typescript/integrations/openclaw-plugin/package.json @@ -25,7 +25,7 @@ "extensions": ["./dist/index.js"] }, "scripts": { - "build": "bun build src/index.ts --outdir dist --format esm --target node --external '@slop-ai/consumer' --external '@slop-ai/discovery' --external '@slop-ai/discovery/service' --external '@slop-ai/discovery/tools' --external '@sinclair/typebox' --external openclaw && bunx tsc --emitDeclarationOnly --outDir dist", + "build": "bun build src/index.ts --outdir dist --format esm --target node --external '@slop-ai/consumer' --external '@slop-ai/discovery' --external '@slop-ai/discovery/service' --external '@slop-ai/discovery/tools' --external '@slop-ai/discovery/context' --external '@sinclair/typebox' --external openclaw && bunx tsc --emitDeclarationOnly --outDir dist", "clean": "rm -rf dist" }, "dependencies": { diff --git a/packages/typescript/integrations/openclaw-plugin/src/index.ts b/packages/typescript/integrations/openclaw-plugin/src/index.ts index b96e424a..6ef91512 100644 --- a/packages/typescript/integrations/openclaw-plugin/src/index.ts +++ b/packages/typescript/integrations/openclaw-plugin/src/index.ts @@ -1,47 +1,9 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createDiscoveryService, type DiscoveryService } from "@slop-ai/discovery/service"; +import { buildSlopContext } from "@slop-ai/discovery/context"; +import { createDiscoveryService } from "@slop-ai/discovery/service"; import { createToolHandlers } from "@slop-ai/discovery/tools"; -import { formatTree } from "@slop-ai/consumer"; import { registerSlopTools } from "./tools"; -function buildStateContext(discovery: DiscoveryService): string | null { - const connected = discovery.getProviders(); - const discovered = discovery.getDiscovered(); - const connectedIds = new Set(connected.map((p) => p.id)); - - const available = discovered.filter((d) => !connectedIds.has(d.id)); - - if (connected.length === 0 && available.length === 0) return null; - - let output = "## SLOP Apps\n\n"; - - if (connected.length > 0) { - output += `${connected.length} app(s) connected. `; - output += - "Use app_action or app_action_batch to act on them. Call connect_app to refresh detailed state or disconnect_app when you're done.\n\n"; - - for (const p of connected) { - const tree = p.consumer.getTree(p.subscriptionId); - output += `### ${p.name} (${p.id})\n\n`; - if (tree) { - output += "```\n" + formatTree(tree) + "\n```\n\n"; - } else { - output += "(awaiting state snapshot)\n\n"; - } - } - } - - if (available.length > 0) { - output += "### Available (not connected)\n\n"; - for (const app of available) { - output += `- **${app.name}** (id: \`${app.id}\`, ${app.transport.type}, ${app.source ?? "local"})\n`; - } - output += "\nCall connect_app with an app name to connect.\n"; - } - - return output; -} - export default definePluginEntry({ id: "slop", name: "App Control", @@ -57,10 +19,18 @@ export default definePluginEntry({ // State injection: inject connected providers' state into the prompt // before each inference, so the model sees live app state without tool calls. + // + // Note: spec/integrations/llm-context.md recommends placing the state tail + // *after* the stable history so prefix caches still hit. OpenClaw's plugin + // API only exposes `prependContext`, so we accept the suboptimal placement + // until OpenClaw grows an append/suffix hook. Functionally this still + // gives the model fresh state on every turn; it just doesn't preserve + // upstream prompt caches as cleanly as the recommended placement. api.on("before_prompt_build", () => { - const context = buildStateContext(discovery); - if (!context) return {}; - return { prependContext: context }; + const { stateTail, availableAppsTail } = buildSlopContext(discovery); + const parts = [stateTail, availableAppsTail].filter((t): t is string => !!t); + if (parts.length === 0) return {}; + return { prependContext: parts.join("\n\n") }; }); discovery.start(); diff --git a/packages/typescript/sdk/consumer/__tests__/llm-context.test.ts b/packages/typescript/sdk/consumer/__tests__/llm-context.test.ts new file mode 100644 index 00000000..f6d3a1d7 --- /dev/null +++ b/packages/typescript/sdk/consumer/__tests__/llm-context.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "bun:test"; +import { + composeMessagesWithSlopState, + escapeSlopContextTags, + renderSlopAvailableApps, + renderSlopStateTail, + stripSlopContextBlocks, +} from "../src/llm-context"; +import type { SlopNode } from "../src/types"; + +const FIXED_TS = "2026-04-28T10:30:00.000Z"; + +const sampleTree: SlopNode = { + id: "mail-app", + type: "root", + properties: { label: "Mail" }, + children: [ + { + id: "inbox", + type: "view", + properties: { label: "Inbox", unread: 12 }, + meta: { salience: 0.95 }, + children: [ + { + id: "thread-42", + type: "item", + properties: { from: "alice@co.org", unread: true, label: "Launch plan" }, + affordances: [ + { action: "reply", params: { type: "object", properties: { body: { type: "string" } } } }, + { action: "mark_read" }, + ], + }, + ], + }, + ], +}; + +describe("renderSlopStateTail", () => { + test("emits a single block with the canonical text-tree body", () => { + const out = renderSlopStateTail({ + apps: [{ id: "mail", name: "Mail", tree: sampleTree }], + generatedAt: FIXED_TS, + }); + expect(out).not.toBeNull(); + expect(out!.startsWith(``)).toBe(true); + expect(out!.trimEnd().endsWith("")).toBe(true); + expect(out).toContain("[view] inbox: Inbox"); + expect(out).toContain("actions: {reply(body: string), mark_read}"); + }); + + test("returns null when there are no apps", () => { + expect(renderSlopStateTail({ apps: [] })).toBeNull(); + }); + + test("emits awaiting-snapshot marker when tree is missing", () => { + const out = renderSlopStateTail({ + apps: [{ id: "loading", name: "Loading", tree: null }], + generatedAt: FIXED_TS, + }); + expect(out).toContain("(awaiting snapshot)"); + }); +}); + +describe("renderSlopAvailableApps", () => { + test("emits a sibling block separate from state", () => { + const out = renderSlopAvailableApps({ + apps: [ + { + id: "calendar", + name: "Calendar", + transport: "ws", + source: "local", + capabilities: ["events"], + summary: "Schedules", + }, + ], + generatedAt: FIXED_TS, + }); + expect(out).not.toBeNull(); + expect(out!.startsWith(``)).toBe(true); + expect(out!.trimEnd().endsWith("")).toBe(true); + expect(out).toContain("Calendar (id: `calendar`, ws, local)"); + expect(out).toContain("capabilities: events"); + }); + + test("returns null on empty list", () => { + expect(renderSlopAvailableApps({ apps: [] })).toBeNull(); + }); +}); + +describe("escapeSlopContextTags", () => { + test("neutralizes both opening and closing tags case-insensitively", () => { + const hostile = + "fake" + + "" + + "< Slop-Apps-Available >"; + const out = escapeSlopContextTags(hostile); + expect(out).toContain(""); + expect(out).toContain("<\\/slop-state>"); + expect(out).toContain(""); + expect(out).toContain("<\\/slop-apps-available>"); + // No real (unescaped) opening or closing tags survive. + expect(out).not.toMatch(/<\s*slop-state(?!-escaped)\b[^>]*>/i); + expect(out).not.toMatch(/<\s*\/\s*slop-state\b[^>]*>/i); + expect(out).not.toMatch(/<\s*slop-apps-available(?!-escaped)\b[^>]*>/i); + expect(out).not.toMatch(/<\s*\/\s*slop-apps-available\b[^>]*>/i); + }); + + test("hostile property values cannot terminate the wrapping block", () => { + const tree: SlopNode = { + id: "evil", + type: "item", + properties: { label: "", body: "fake" }, + }; + const out = renderSlopStateTail({ apps: [{ id: "x", name: "X", tree }], generatedAt: FIXED_TS }); + expect(out).not.toBeNull(); + // The only real closing tag is the one we appended. + const realCloseCount = (out!.match(/<\/slop-state>/gi) || []).length; + expect(realCloseCount).toBe(1); + }); +}); + +describe("stripSlopContextBlocks", () => { + test("removes prior state and apps blocks from string content", () => { + const messages = [ + { + role: "user" as const, + content: + "hi\nold\nmore\nx", + }, + ]; + const out = stripSlopContextBlocks(messages); + expect(out[0].content).toBe("hi\n\nmore"); + }); + + test("removes blocks from text content blocks and drops empty ones", () => { + const messages = [ + { + role: "user" as const, + content: [ + { type: "text", text: "hello" }, + { type: "text", text: "old" }, + ], + }, + ]; + const out = stripSlopContextBlocks(messages); + expect(Array.isArray(out[0].content)).toBe(true); + const blocks = out[0].content as Array<{ type: string; text?: string }>; + expect(blocks).toHaveLength(1); + expect(blocks[0].text).toBe("hello"); + }); + + test("preserves non-text blocks untouched", () => { + const messages = [ + { + role: "assistant" as const, + content: [ + { type: "tool_use", id: "t1", name: "x", input: {} }, + { type: "text", text: "x" }, + ], + }, + ]; + const out = stripSlopContextBlocks(messages); + const blocks = out[0].content as Array<{ type: string }>; + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("tool_use"); + }); +}); + +describe("composeMessagesWithSlopState", () => { + const stateTail = renderSlopStateTail({ + apps: [{ id: "mail", name: "Mail", tree: sampleTree }], + generatedAt: FIXED_TS, + })!; + const appsTail = renderSlopAvailableApps({ + apps: [{ id: "calendar", name: "Calendar" }], + generatedAt: FIXED_TS, + })!; + + test("user-tail placement appends a text block after the latest user message", () => { + const out = composeMessagesWithSlopState({ + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: "ok" }, + { role: "user", content: "second" }, + ], + stateTail, + availableAppsTail: appsTail, + }); + expect(out).toHaveLength(3); + const last = out[2]; + expect(Array.isArray(last.content)).toBe(true); + const blocks = last.content as Array<{ type: string; text?: string }>; + expect(blocks[0].text).toBe("second"); + expect(blocks[1].text).toContain(" { + const out = composeMessagesWithSlopState({ + messages: [ + { role: "user", content: "what's in the inbox?" }, + { role: "assistant", content: "let me check", tool_calls: [{ id: "t1", type: "function", function: { name: "f", arguments: "{}" } }] }, + { role: "tool", content: "result", tool_call_id: "t1" }, + ], + stateTail, + }); + // Tail must end up last, not attached to the earlier user message. + expect(out).toHaveLength(4); + expect(out[0].role).toBe("user"); + expect(out[0].content).toBe("what's in the inbox?"); + expect(out[1].role).toBe("assistant"); + expect(out[2].role).toBe("tool"); + expect(out[3].role).toBe("user"); + expect(Array.isArray(out[3].content)).toBe(true); + expect((out[3].content as Array<{ text?: string }>)[0].text).toContain(" { + const out = composeMessagesWithSlopState({ + messages: [{ role: "user", content: "hi" }], + stateTail, + placement: "synthetic-context", + }); + expect(out).toHaveLength(2); + expect(out[1].role).toBe("user"); + expect(Array.isArray(out[1].content)).toBe(true); + }); + + test("string fallback keeps content as a string when preferStringContent is set", () => { + const out = composeMessagesWithSlopState({ + messages: [{ role: "user", content: "hi" }], + stateTail, + preferStringContent: true, + }); + expect(typeof out[0].content).toBe("string"); + expect(out[0].content as string).toContain("hi"); + expect(out[0].content as string).toContain(" { + const messages = [{ role: "user" as const, content: "hi" }]; + const once = composeMessagesWithSlopState({ + messages, + stateTail, + placement: "synthetic-context", + }); + const twice = composeMessagesWithSlopState({ + messages: once, + stateTail, + placement: "synthetic-context", + }); + expect(twice).toHaveLength(2); + expect(JSON.stringify(twice)).toBe(JSON.stringify(once)); + }); + + test("strip drops messages whose content was only a SLOP context block", () => { + const out = stripSlopContextBlocks([ + { role: "user", content: "hi" }, + { role: "user", content: [{ type: "text", text: "x" }] }, + ]); + expect(out).toHaveLength(1); + expect(out[0].content).toBe("hi"); + }); + + test("strip drops string-content messages that were only a SLOP context block", () => { + const out = stripSlopContextBlocks([ + { role: "user", content: "hi" }, + { role: "user", content: "x" }, + ]); + expect(out).toHaveLength(1); + expect(out[0].content).toBe("hi"); + }); + + test("strip preserves messages that have tool_calls even when content empties", () => { + const out = stripSlopContextBlocks([ + { + role: "assistant", + content: [{ type: "text", text: "x" }], + tool_calls: [{ id: "t1", type: "function", function: { name: "f", arguments: "{}" } }], + }, + ]); + expect(out).toHaveLength(1); + expect(out[0].tool_calls).toBeDefined(); + expect(Array.isArray(out[0].content)).toBe(true); + expect((out[0].content as unknown[]).length).toBe(0); + }); + + test("idempotent: composing twice yields identical output", () => { + const messages = [ + { role: "user" as const, content: "first" }, + { role: "assistant" as const, content: "ok" }, + { role: "user" as const, content: "second" }, + ]; + const once = composeMessagesWithSlopState({ messages, stateTail, availableAppsTail: appsTail }); + const twice = composeMessagesWithSlopState({ + messages: once, + stateTail, + availableAppsTail: appsTail, + }); + expect(JSON.stringify(twice)).toBe(JSON.stringify(once)); + }); + + test("byte-stable prefix: same stored history serializes identically across turns", () => { + const baseHistory = [ + { role: "user" as const, content: "first" }, + { role: "assistant" as const, content: "ok" }, + ]; + const turnN = composeMessagesWithSlopState({ + messages: [...baseHistory, { role: "user", content: "second" }], + stateTail, + }); + const turnNPlus1 = composeMessagesWithSlopState({ + messages: [ + ...baseHistory, + { role: "user", content: "second" }, + { role: "assistant", content: "did it" }, + { role: "user", content: "third" }, + ], + stateTail, + }); + // Messages 0..1 must be byte-identical between turns (they come from the + // stable history and were never touched by the composer). + expect(JSON.stringify(turnN.slice(0, 2))).toBe(JSON.stringify(turnNPlus1.slice(0, 2))); + }); + + test("no tail provided: returns stripped history without modification", () => { + const messages = [{ role: "user" as const, content: "hi" }]; + const out = composeMessagesWithSlopState({ messages }); + expect(out).toHaveLength(1); + expect(out[0].content).toBe("hi"); + }); +}); diff --git a/packages/typescript/sdk/consumer/package.json b/packages/typescript/sdk/consumer/package.json index 542e75f9..a68b2765 100644 --- a/packages/typescript/sdk/consumer/package.json +++ b/packages/typescript/sdk/consumer/package.json @@ -1,6 +1,6 @@ { "name": "@slop-ai/consumer", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/typescript/sdk/consumer/src/browser.ts b/packages/typescript/sdk/consumer/src/browser.ts index 922001cf..ea004acf 100644 --- a/packages/typescript/sdk/consumer/src/browser.ts +++ b/packages/typescript/sdk/consumer/src/browser.ts @@ -3,6 +3,13 @@ import { SlopConsumer as SlopConsumerImpl } from "./consumer"; import { Emitter as EmitterImpl } from "./emitter"; import { StateMirror as StateMirrorImpl } from "./state-mirror"; +import { + composeMessagesWithSlopState as composeMessagesWithSlopStateImpl, + escapeSlopContextTags as escapeSlopContextTagsImpl, + renderSlopAvailableApps as renderSlopAvailableAppsImpl, + renderSlopStateTail as renderSlopStateTailImpl, + stripSlopContextBlocks as stripSlopContextBlocksImpl, +} from "./llm-context"; import { affordancesToTools as affordancesToToolsImpl, formatTree as formatTreeImpl } from "./tools"; import { PostMessageClientTransport as PostMessageClientTransportImpl } from "./transport-pm"; import { WebSocketClientTransport as WebSocketClientTransportImpl } from "./transport-ws"; @@ -24,6 +31,25 @@ export const affordancesToTools = affordancesToToolsImpl; export const formatTree = formatTreeImpl; export type { ChatMessage, LlmTool, ToolSet } from "./tools"; +// LLM context tail +export const renderSlopStateTail = renderSlopStateTailImpl; +export const renderSlopAvailableApps = renderSlopAvailableAppsImpl; +export const stripSlopContextBlocks = stripSlopContextBlocksImpl; +export const composeMessagesWithSlopState = composeMessagesWithSlopStateImpl; +export const escapeSlopContextTags = escapeSlopContextTagsImpl; +export type { + AvailableSlopApp, + ComposableMessage, + ComposeMessagesOptions, + ContentBlock, + RenderAvailableAppsInput, + RenderSlopStateInput, + RenderSlopStateOptions, + SlopStateApp, + SlopStatePlacement, + TextContentBlock, +} from "./llm-context"; + // Emitter export const Emitter = EmitterImpl; export type Emitter = InstanceType; diff --git a/packages/typescript/sdk/consumer/src/index.ts b/packages/typescript/sdk/consumer/src/index.ts index a5bf7ef3..e9ba113b 100644 --- a/packages/typescript/sdk/consumer/src/index.ts +++ b/packages/typescript/sdk/consumer/src/index.ts @@ -1,6 +1,13 @@ import { SlopConsumer as SlopConsumerImpl } from "./consumer"; import { Emitter as EmitterImpl } from "./emitter"; import { StateMirror as StateMirrorImpl } from "./state-mirror"; +import { + composeMessagesWithSlopState as composeMessagesWithSlopStateImpl, + escapeSlopContextTags as escapeSlopContextTagsImpl, + renderSlopAvailableApps as renderSlopAvailableAppsImpl, + renderSlopStateTail as renderSlopStateTailImpl, + stripSlopContextBlocks as stripSlopContextBlocksImpl, +} from "./llm-context"; import { affordancesToTools as affordancesToToolsImpl, formatTree as formatTreeImpl } from "./tools"; import { NodeSocketClientTransport as NodeSocketClientTransportImpl } from "./transport-node-socket"; import { PostMessageClientTransport as PostMessageClientTransportImpl } from "./transport-pm"; @@ -25,6 +32,25 @@ export const affordancesToTools = affordancesToToolsImpl; export const formatTree = formatTreeImpl; export type { ChatMessage, LlmTool, ToolSet } from "./tools"; +// LLM context tail +export const renderSlopStateTail = renderSlopStateTailImpl; +export const renderSlopAvailableApps = renderSlopAvailableAppsImpl; +export const stripSlopContextBlocks = stripSlopContextBlocksImpl; +export const composeMessagesWithSlopState = composeMessagesWithSlopStateImpl; +export const escapeSlopContextTags = escapeSlopContextTagsImpl; +export type { + AvailableSlopApp, + ComposableMessage, + ComposeMessagesOptions, + ContentBlock, + RenderAvailableAppsInput, + RenderSlopStateInput, + RenderSlopStateOptions, + SlopStateApp, + SlopStatePlacement, + TextContentBlock, +} from "./llm-context"; + // Emitter (for custom transports) export const Emitter = EmitterImpl; export type Emitter = InstanceType; diff --git a/packages/typescript/sdk/consumer/src/llm-context.ts b/packages/typescript/sdk/consumer/src/llm-context.ts new file mode 100644 index 00000000..084993d8 --- /dev/null +++ b/packages/typescript/sdk/consumer/src/llm-context.ts @@ -0,0 +1,272 @@ +import { formatTree } from "./tools"; +import type { SlopNode } from "./types"; + +// Tag-detection regexes. `[^>]*` is sufficient because we never emit `>` inside +// attribute values; opening tags also tolerate hostile attribute-shaped text. +const STATE_OPEN_RE = /<\s*slop-state\b[^>]*>/gi; +const STATE_CLOSE_RE = /<\s*\/\s*slop-state\b[^>]*>/gi; +const APPS_OPEN_RE = /<\s*slop-apps-available\b[^>]*>/gi; +const APPS_CLOSE_RE = /<\s*\/\s*slop-apps-available\b[^>]*>/gi; +const STATE_BLOCK_RE = /<\s*slop-state\b[^>]*>[\s\S]*?<\s*\/\s*slop-state\b[^>]*>/gi; +const APPS_BLOCK_RE = /<\s*slop-apps-available\b[^>]*>[\s\S]*?<\s*\/\s*slop-apps-available\b[^>]*>/gi; + +/** + * Neutralize SLOP context tags inside untrusted application text so a hostile + * property value cannot terminate the wrapping block or fake a new one. + * + * Rules implement the contract in spec/integrations/llm-context.md: + * - -> + * - -> <\/slop-state> + * - -> + * - -> <\/slop-apps-available> + */ +export function escapeSlopContextTags(text: string): string { + return text + .replace(STATE_OPEN_RE, "") + .replace(STATE_CLOSE_RE, "<\\/slop-state>") + .replace(APPS_OPEN_RE, "") + .replace(APPS_CLOSE_RE, "<\\/slop-apps-available>"); +} + +export interface SlopStateApp { + id: string; + name: string; + /** Materialized tree. Pass `null`/`undefined` to emit an "awaiting snapshot" marker. */ + tree?: SlopNode | null; +} + +export interface RenderSlopStateInput { + apps: SlopStateApp[]; + /** ISO timestamp emitted as the `generated_at` attribute. Defaults to `new Date().toISOString()`. */ + generatedAt?: string; +} + +export interface RenderSlopStateOptions { + format?: string; +} + +/** + * Render the live-state tail. Returns `null` when there are no apps so callers + * can decide whether to emit any tail at all. + */ +export function renderSlopStateTail( + input: RenderSlopStateInput, + options: RenderSlopStateOptions = {}, +): string | null { + if (!input.apps || input.apps.length === 0) return null; + const format = options.format ?? "text/tree"; + const generatedAt = input.generatedAt ?? new Date().toISOString(); + + const sections: string[] = ["## SLOP Apps", ""]; + for (const app of input.apps) { + const safeName = escapeSlopContextTags(app.name); + const safeId = escapeSlopContextTags(app.id); + sections.push(`### ${safeName} (${safeId})`); + sections.push(""); + if (app.tree) { + sections.push(escapeSlopContextTags(formatTree(app.tree))); + } else { + sections.push("(awaiting snapshot)"); + } + sections.push(""); + } + const body = sections.join("\n").replace(/\n+$/, ""); + return `\n${body}\n`; +} + +export interface AvailableSlopApp { + id: string; + name: string; + /** Transport hint shown alongside the app, e.g. "ws", "unix", "stdio". */ + transport?: string; + /** Discovery source, e.g. "local" or "bridge". */ + source?: string; + capabilities?: string[]; + summary?: string; +} + +export interface RenderAvailableAppsInput { + apps: AvailableSlopApp[]; + generatedAt?: string; +} + +/** + * Render the available-but-unconnected apps catalog. Returns `null` when the + * list is empty. + */ +export function renderSlopAvailableApps(input: RenderAvailableAppsInput): string | null { + if (!input.apps || input.apps.length === 0) return null; + const generatedAt = input.generatedAt ?? new Date().toISOString(); + const lines: string[] = ["## Available SLOP Apps", ""]; + for (const app of input.apps) { + const safeName = escapeSlopContextTags(app.name); + const safeId = escapeSlopContextTags(app.id); + const meta: string[] = []; + if (app.transport) meta.push(escapeSlopContextTags(app.transport)); + if (app.source) meta.push(escapeSlopContextTags(app.source)); + let line = `- ${safeName} (id: \`${safeId}\``; + if (meta.length > 0) line += `, ${meta.join(", ")}`; + line += ")"; + if (app.capabilities && app.capabilities.length > 0) { + line += ` — capabilities: ${app.capabilities.map(escapeSlopContextTags).join(", ")}`; + } + if (app.summary) { + line += ` — ${escapeSlopContextTags(app.summary)}`; + } + lines.push(line); + } + return `\n${lines.join("\n")}\n`; +} + +// --------------------------------------------------------------------------- +// Message composition +// --------------------------------------------------------------------------- + +export interface TextContentBlock { + type: "text"; + text: string; + [key: string]: unknown; +} + +export type ContentBlock = TextContentBlock | { type: string; [key: string]: unknown }; + +export interface ComposableMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string | ContentBlock[]; + [key: string]: unknown; +} + +function stripFromString(text: string): string { + return text.replace(STATE_BLOCK_RE, "").replace(APPS_BLOCK_RE, ""); +} + +function hasOnlyRoleAndContent(m: ComposableMessage): boolean { + for (const key of Object.keys(m)) { + if (key !== "role" && key !== "content") return false; + } + return true; +} + +/** + * Remove any prior `` and `` blocks from + * stored messages. Operates on string content and on text blocks inside + * block-shaped content. Drops empty text blocks so the output never contains + * `{ type: "text", text: "" }`. + */ +export function stripSlopContextBlocks(messages: M[]): M[] { + const out: M[] = []; + for (const m of messages) { + if (typeof m.content === "string") { + const stripped = stripFromString(m.content).replace(/\n{3,}/g, "\n\n").replace(/\s+$/, ""); + // Same rule as the block-content path: if the message stripped down to + // an empty string and carries no other top-level payload (tool_calls, + // tool_call_id, etc.), it was a SLOP-context-only message — drop it + // rather than emit an empty-content message that some providers reject. + if (stripped.length === 0 && hasOnlyRoleAndContent(m)) continue; + out.push({ ...m, content: stripped }); + continue; + } + if (Array.isArray(m.content)) { + const cleaned: ContentBlock[] = []; + for (const block of m.content) { + if (block.type === "text" && typeof (block as TextContentBlock).text === "string") { + const next = stripFromString((block as TextContentBlock).text) + .replace(/\n{3,}/g, "\n\n") + .replace(/\s+$/, ""); + if (next.length > 0) { + cleaned.push({ ...(block as TextContentBlock), text: next }); + } + continue; + } + cleaned.push(block); + } + // Drop messages that were a SLOP-context-only tail. A message qualifies + // only when its content blocks all stripped away AND it carries no other + // top-level payload (e.g. OpenAI-style `tool_calls`, `tool_call_id`, + // function metadata). Otherwise we'd lose meaningful assistant turns + // that happened to contain a context block alongside tool calls. + if (cleaned.length === 0 && hasOnlyRoleAndContent(m)) continue; + out.push({ ...m, content: cleaned }); + continue; + } + out.push(m); + } + return out; +} + +export type SlopStatePlacement = "user-tail" | "synthetic-context"; + +export interface ComposeMessagesOptions { + messages: M[]; + stateTail?: string | null; + availableAppsTail?: string | null; + /** Where to attach the fresh context. Defaults to `"user-tail"`. */ + placement?: SlopStatePlacement; + /** Role used for `synthetic-context` placement. Defaults to `"user"`. */ + syntheticRole?: "user" | "system"; + /** + * Keep the latest user message as a plain string when it already is one. + * Default behaviour upgrades it to block-shaped content because most modern + * APIs accept blocks and the spec recommends a separate text block. + */ + preferStringContent?: boolean; +} + +/** + * Compose stored conversation messages with a fresh SLOP context tail. + * + * Always strips prior `` / `` blocks first so + * `composeMessagesWithSlopState` is idempotent: running it twice on the same + * input produces the same output. The stored history MUST never carry SLOP + * context across turns; this helper enforces that on every call. + */ +export function composeMessagesWithSlopState( + opts: ComposeMessagesOptions, +): M[] { + const placement: SlopStatePlacement = opts.placement ?? "user-tail"; + const tails = [opts.stateTail, opts.availableAppsTail].filter( + (t): t is string => typeof t === "string" && t.length > 0, + ); + const stripped = stripSlopContextBlocks(opts.messages); + if (tails.length === 0) return stripped; + + const tailText = tails.join("\n"); + + const appendSynthetic = (msgs: M[], role: "user" | "system"): M[] => { + const synthetic = { + role, + content: [{ type: "text", text: tailText } satisfies TextContentBlock], + } as unknown as M; + return [...msgs, synthetic]; + }; + + if (placement === "synthetic-context") { + return appendSynthetic(stripped, opts.syntheticRole ?? "user"); + } + + // user-tail: only attach when the LAST stripped message is a user message. + // If the history ends on an assistant or tool message, attaching to an + // earlier user message would place volatile state before later stored + // messages and break the "tail after stored prefix" rule. Fall back to a + // synthetic context message in that case so the tail stays last. + const last = stripped[stripped.length - 1]; + if (!last || last.role !== "user") { + return appendSynthetic(stripped, "user"); + } + + let updated: M; + if (typeof last.content === "string") { + if (opts.preferStringContent) { + const next = last.content.length > 0 ? `${last.content}\n\n${tailText}` : tailText; + updated = { ...last, content: next }; + } else { + const blocks: ContentBlock[] = []; + if (last.content.length > 0) blocks.push({ type: "text", text: last.content }); + blocks.push({ type: "text", text: tailText }); + updated = { ...last, content: blocks }; + } + } else { + updated = { ...last, content: [...last.content, { type: "text", text: tailText }] }; + } + return stripped.map((m, i) => (i === stripped.length - 1 ? updated : m)); +} diff --git a/spec/integrations/llm-context.md b/spec/integrations/llm-context.md new file mode 100644 index 00000000..14869527 --- /dev/null +++ b/spec/integrations/llm-context.md @@ -0,0 +1,181 @@ +# LLM Context Injection + +SLOP exposes live application state. To make that state visible to a language model, a consumer must serialize the current tree into the model's context window on every turn. Done naively, this either bloats the conversation history (every past state lingers forever) or breaks prompt caching (every turn invalidates the prefix). This document defines the conventions a SLOP-aware LLM host SHOULD follow so that state stays fresh, history stays clean, and the cache stays warm. + +This is an integration concern, not part of the core protocol. Providers do not need to know how their state is rendered into a prompt. Consumers that do not interface with an LLM (analytics tools, replay, headless tests) MAY ignore this document entirely. + +## The problem + +A SLOP consumer driving a model has two pressures that pull in opposite directions: + +- **Freshness** — the model must reason about state *now*, not whatever the tree looked like ten turns ago. +- **Efficiency** — the conversation prefix must be stable enough to hit the provider's prompt cache, otherwise every turn re-bills the entire history. + +Embedding the full tree into each user message satisfies freshness but defeats caching: the prefix changes every turn. Snapshotting once and never refreshing satisfies caching but leaves the model reasoning over stale state. Neither is acceptable for an interactive agent. + +## The pattern: ephemeral state tail + +The recommended pattern is to treat current state as an **ephemeral tail** appended to the request, never persisted into conversation history. + +``` +┌─ Stable conversation history ───────────────────────────┐ +│ system prompt │ +│ user: "find the unread thread from alice" │ +│ assistant: "opening it now" + tool call │ +│ tool result │ +│ user: "reply with 'on it'" │ ← cache boundary, when supported +└─────────────────────────────────────────────────────────┘ +┌─ Ephemeral tail (rebuilt every turn) ───────────────────┐ +│ │ +│ ...current SLOP tree projection... │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +Rules: + +1. The conversation history MUST contain only messages — user input, assistant output, tool calls, tool results. It MUST NOT contain state-tail blocks from prior turns. +2. The current state projection is rendered fresh on every request and appended after the last stored message. +3. When the next turn arrives, the previous state tail is discarded by construction (it was never stored). The new tail reflects the current tree. + +Because old state is never written into history, no removal step is required. The consumer holds one mutable reference to the live tree, and the prompt builder reads it at request time. + +## The `` tag + +State SHOULD be delimited by an explicit `` ... `` tag, analogous to the framing used for tool definitions and tool results. This gives the model a stable signal for "this region is the live SLOP observation, not part of the conversation." + +``` + +## SLOP Apps + +### Mail (mail-app) + +[root] mail-app: Mail + [context] session (user="alice", account="work") + [view] inbox: Inbox (unread=12) salience=0.95 + [item] thread-42: Launch plan (from="alice@co.org", unread=true) actions: {reply(body: string), mark_read} + +``` + +Conventions: + +- `` is the default delimiter. Hosts MAY use a different host-specific delimiter only when their model API requires it, but they MUST define that delimiter once and keep it stable across turns. Generic `` is discouraged because it can collide with non-SLOP context. +- `generated_at`, when present, MUST be an ISO 8601 / RFC 3339 timestamp. Hosts SHOULD emit UTC timestamps with a `Z` suffix. +- The body MAY be the canonical text tree format, JSON, YAML, or another documented projection. The canonical text tree format from [state-tree.md](../core/state-tree.md#consumer-display-format) is the default because it is compact, human-readable, and already includes paths, properties, summaries, and affordances. JSON is appropriate when the host wants schema-first parsing. +- The body SHOULD be a salience-filtered projection (see [attention.md](../core/attention.md)), not the raw tree. Hosts SHOULD respect `meta.focus`, salience scores, and view-scoping (see [scaling.md](../extensions/scaling.md)) to keep the tail small. +- When the tail includes multiple providers or apps, the body MUST preserve clear per-provider or per-app boundaries with a stable human-readable name and provider/app ID. Text projections SHOULD use one section per provider or app, as in `### Mail (mail-app)`. +- Disconnected providers MUST NOT leave stale tree content in `` as if it were current. Hosts SHOULD either omit disconnected providers from live state or include only a clearly labeled disconnected/status-only entry, optionally with a last-observed timestamp. +- The tail MAY include affordances available on focused nodes so the model can act without a separate query. + +## Related SLOP context blocks + +Hosts sometimes need to surface SLOP-related context that is not live state. For example, a local host may know that several SLOP-enabled applications are discoverable but not connected yet. That catalog is useful to the model, but it is not a current tree observation and MUST NOT be mixed into ``. + +Hosts that expose available-but-unconnected applications SHOULD use a sibling block: + +``` + +- Mail (id: `mail-app`, websocket, local) +- Calendar (id: `calendar-app`, unix, local) + +``` + +`` is host catalog context, not a replacement for the live state tail. It SHOULD follow the same lifecycle as the state tail: render it fresh at request time, place it after the stored conversation prefix, and never persist it into conversation history. Hosts MAY omit this block entirely when tool calls such as `list_apps` provide the same catalog. + +## Security model + +The state tail is an observation channel, not an instruction channel. Delimiters make the prompt easier to parse, but they are not a security boundary. + +Hosts MUST treat all state-tail content as untrusted application data. A node property, document body, chat message, or page title may contain hostile instructions or text that resembles opening tags, closing tags, tool calls, or higher-priority messages. Hosts MUST serialize state with a structured encoder, use a format-independent encoding layer, or escape raw text so user-controlled content cannot terminate the `` block, fake a new SLOP context block, or masquerade as host-authored instructions. + +For text tag blocks, hosts MUST neutralize any app-controlled text that resembles a SLOP context tag — both opening and closing — before final block assembly. Both directions matter: a fake closing tag can terminate the wrapping block, and a fake opening tag can let hostile content masquerade as a fresh, host-authored SLOP block in models that are lenient about nesting. At minimum, apply case-insensitive substitutions that tolerate whitespace and attribute-like text: + +| Match (case-insensitive) | Replacement | +|---|---| +| `<\s*slop-state\b[^>]*>` | `` | +| `<\s*/\s*slop-state\b[^>]*>` | `<\/slop-state>` | +| `<\s*slop-apps-available\b[^>]*>` | `` | +| `<\s*/\s*slop-apps-available\b[^>]*>` | `<\/slop-apps-available>` | + +Apply the substitutions to every piece of app-controlled text rendered inside a SLOP context block: provider names, app IDs, tree text, properties, summaries, labels, affordance descriptions, and available-app metadata. + +The system or developer prompt SHOULD explicitly tell the model that `` contains untrusted live state and must not override system, developer, user, or tool instructions. Providers MUST still re-authorize every `invoke` against live state, caller identity, and resource policy; see [transport.md](../core/transport.md#security-considerations) and [affordances.md](../core/affordances.md#applicability-is-not-authorization). + +## Placement + +Two provider-compatible placements are acceptable. Hosts SHOULD choose based on the target API's message model and cache controls: + +1. **Trailing user message tail** — append the `` block after the latest user message text, preferably as a separate content block when the model API supports block-structured messages. This is the recommended default for chat-completions APIs without explicit cache controls or APIs that cannot represent a separate host-authored context message. +2. **Synthetic context message** — insert a host-authored message containing only the `` block after the latest stored message and before model generation. This is the recommended default for APIs with explicit cache controls or checkpoints, because the cache marker can sit on the last stable block before the state and the volatile state can live in its own post-boundary message. This placement is only valid when the provider accepts that role and the SDK/provider does not reorder or merge it ahead of the cacheable history. + +A `` block MUST NOT be placed inside an assistant message or a tool result. + +Some host APIs only expose a "prepend context" hook that runs before the stored prefix rather than after it. Such hosts MAY still emit the state tail through the prepend hook, but they MUST treat this as a known prompt-cache regression: the volatile state will appear ahead of stable history and invalidate prefix caches on every turn. Hosts in this category SHOULD document the limitation in their integration README and SHOULD push for an append-style hook upstream so the placement can move to a cache-friendly position. + +## Prompt caching + +This pattern is designed to be cache-friendly for prefix-based prompt caches, but cache APIs differ by provider. Some providers apply caching automatically to exact matching prompt prefixes. Others expose explicit cache controls or checkpoints on structured content blocks. The portable rule is the same in both cases: keep stable instructions, tools, and stored messages before the volatile state tail. + +The host SHOULD: + +1. Preserve exact serialization and ordering for the stable prefix: system/developer instructions, tool definitions, and stored conversation messages. +2. Render the state tail after that stable prefix. +3. Where explicit cache controls are supported, place the cache control on the last stable block before the state tail. +4. Where caching is automatic, do not invent a synthetic marker; just keep the state tail last and monitor cached-token metrics. + +``` +turn N: [system + tools + msgs 1..k] | cache boundary if supported | +turn N+1: [system + tools + msgs 1..k+2] | cache boundary if supported | + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + identical prefix through msg k can hit cache +``` + +On turn N+1, the state tail is expected to be uncached. The newly added exchange after message `k` may also be uncached until that request has been processed and becomes part of a future reusable prefix. The benefit is that the long prefix already seen by the model remains reusable instead of being invalidated by fresh app state. + +For a typical SLOP projection (a few hundred to a few thousand tokens), paying the uncached cost of the current state tail is acceptable. For very large tails, the host SHOULD apply more aggressive salience filtering, view scoping, windowing, or lazy subtree loading rather than trying to cache live state. + +The host SHOULD NOT place explicit cache controls after ordinary live state tails. State changes between most turns, so caching the tail usually produces a low hit rate while consuming cache capacity that could be used elsewhere. If a host intentionally pins a stable snapshot for later temporal reasoning, that snapshot is no longer an ephemeral live-state tail; see [Trade-offs](#trade-offs). + +### Provider compatibility + +The pattern itself — clean history plus an ephemeral tail — requires no provider-specific features. Only the cache-integration step varies, and it degrades gracefully when caching is unavailable. Hosts SHOULD classify each target model and endpoint by its actual cache API and act accordingly. Vendor behavior changes over time and can vary by model, deployment type, region, and transport facade. + +- **Automatic prefix caching** (for example, OpenAI automatic prompt caching, DeepSeek context caching, Fireworks prompt caching, Gemini implicit caching, DashScope/Qwen implicit caching, and caching on supported Groq or Together endpoints). Caching is implicit on exact matching prompt prefixes. The host SHOULD keep the prefix stable: stable system-prompt serialization, stable tool ordering, no timestamps or randomized IDs in stored messages, and consistent cache-routing hints where the provider offers them (for example session affinity, cache keys, isolation keys, or retention settings). The state tail naturally remains outside the reusable prefix. +- **Explicit block markers** (for example, Anthropic `cache_control`, Amazon Bedrock `cachePoint`, and DashScope/Qwen explicit `cache_control`). The host places the marker on the last stable content block before the state tail, as described above. For Anthropic-style block-level `cache_control`, a separate final state message or content block is usually cleaner than appending state to the same block as the latest user text, because the cache marker must remain on stable content before the volatile state. Providers in this category often impose minimum-token thresholds, maximum marker counts, block-lookback limits, or TTLs; below the threshold the host SHOULD fall back to whatever implicit caching the provider offers, not invent a synthetic marker. +- **Named cached resources** (for example, Google Gemini `CachedContent`). The host creates a separate cached resource for stable, reusable material such as system instructions, large documents, or tool schemas, then references that resource when sending the live request with the `` tail as ordinary uncached request content. This is not the same as placing a marker immediately before the tail. +- **No prefix caching** (e.g. some smaller hosted endpoints, certain fine-tuning runtimes). The freshness and history-hygiene properties of the pattern still hold; the host simply pays full input tokens every turn. No host action is required beyond keeping the state tail last so a future caching upgrade is automatically beneficial. +- **Local or self-hosted runtimes** (for example, vLLM automatic prefix caching, SGLang radix caching, llama.cpp prompt caches, Ollama, TGI, or custom serving stacks). Cache behavior depends on runtime flags, server mode, batching, session reuse, and eviction policy. The host controls serialization end-to-end, so exact-prefix stability is achievable, but it SHOULD verify cache reuse in the chosen runtime rather than assuming every deployment reuses prefixes automatically. + +The `` framing is plain text and works on any model, but tag-following quality varies. Frontier models respect explicit delimiters reliably; smaller open-weight models occasionally leak the tag into output or treat tail content as instructions. Hosts targeting weaker models SHOULD strengthen the system prompt's description of the tag contract and SHOULD NOT relax the security guidance in [Security model](#security-model) — provider capability does not change the trust level of state-tail content. + +## Trade-offs + +The ephemeral-tail pattern is optimized for **present-tense** reasoning: the model acts on what is true *now*. It is intentionally weaker for **temporal** reasoning — the model cannot answer "what was selected when I asked you that earlier?" because past states are not retained. + +Hosts that need temporal reasoning have two options: + +- **Inline diffs** — when state changes meaningfully between turns, persist a compact host-authored delta note in an assistant or tool-result message, such as `State change: Mail unread 12 -> 13; focused thread thread-42 -> thread-91`. The tail still carries the full current state; history carries a compact change log. This costs some tokens but preserves cacheability of the message prefix. +- **Snapshot pinning** — on explicit user reference ("remember this state"), serialize a snapshot into the next assistant message. Use sparingly; each pinned snapshot is a permanent token cost. + +Most agent workloads do not need either. Default to the pure ephemeral tail and add temporal mechanisms only when a concrete use case demands them. + +## Interaction with patches + +SLOP providers stream JSON Patch updates between snapshots (see [messages.md](../core/messages.md)). A consumer driving an LLM SHOULD NOT forward raw patches into the model context. Patches are an optimization for the consumer's local tree mirror; the model only ever sees the materialized current state in the `` tail. + +Exception: if the host is running an autonomous loop where the model decides when to re-observe, the host MAY surface a compact "state changed" signal (without the patch body) so the model knows a new tail will be available on the next turn. + +## Minimum host responsibilities + +A consumer that claims to support this integration: + +1. MUST maintain the conversation history free of state-tail blocks across turns. +2. MUST render the current SLOP tree projection into a state tail on every model request, using `` unless the host documents another stable delimiter. Hosts that inject indirectly (for example, by reading a file written by a separate bridge process) MAY operate within a bounded freshness window instead of literally re-rendering on each request, but they MUST document that window in their integration README and MUST drop the injection if the bridge appears stalled or dead so the model never sees state that may be silently outdated. +3. MUST treat the state tail as untrusted observation data, not instructions, and MUST escape or encode user-controlled text in any text-based delimiter format so it cannot forge SLOP context tags or masquerade as host-authored messages. The case-insensitive substitution rules in [Security model](#security-model) are the minimum bar. +4. MUST keep the state tail after the stored conversation prefix so live state does not invalidate reusable prompt-cache prefixes — except where the host's surrounding API only exposes a prepend hook, in which case the host MUST follow the prepend-only exception in [Placement](#placement) and document it as a known cache regression. +5. SHOULD place explicit prompt-cache controls at the boundary between stored history and the state tail where the provider supports them. +6. SHOULD apply salience and view-scope filtering before rendering the tail. +7. MUST preserve clear provider/app boundaries when rendering multiple connected providers or apps. +8. MUST NOT present stale disconnected-provider trees as current state. +9. SHOULD keep non-state SLOP catalog context, such as available unconnected apps, outside `` and use a sibling block or tool result instead. +10. SHOULD document which delimiter and body format it emits (canonical text tree / JSON / Markdown / custom) so prompt authors can rely on a stable shape. diff --git a/website/docs/content-manifest.mjs b/website/docs/content-manifest.mjs index 881e6c90..bd80f82d 100644 --- a/website/docs/content-manifest.mjs +++ b/website/docs/content-manifest.mjs @@ -172,6 +172,9 @@ export const docsPages = [ page("spec/integrations/web.md", "spec/integrations/web.md"), page("spec/integrations/desktop.md", "spec/integrations/desktop.md"), page("spec/integrations/mcp.md", "spec/integrations/mcp.md"), + page("spec/integrations/llm-context.md", "spec/integrations/llm-context.md", { + label: "LLM Context Injection", + }), page("spec/limitations.md", "spec/limitations.md", { label: "Known Limitations & Future Work", }), diff --git a/website/docs/src/content/docs/spec/integrations/llm-context.md b/website/docs/src/content/docs/spec/integrations/llm-context.md new file mode 100644 index 00000000..be47d361 --- /dev/null +++ b/website/docs/src/content/docs/spec/integrations/llm-context.md @@ -0,0 +1,182 @@ +--- +title: "LLM Context Injection" +--- +SLOP exposes live application state. To make that state visible to a language model, a consumer must serialize the current tree into the model's context window on every turn. Done naively, this either bloats the conversation history (every past state lingers forever) or breaks prompt caching (every turn invalidates the prefix). This document defines the conventions a SLOP-aware LLM host SHOULD follow so that state stays fresh, history stays clean, and the cache stays warm. + +This is an integration concern, not part of the core protocol. Providers do not need to know how their state is rendered into a prompt. Consumers that do not interface with an LLM (analytics tools, replay, headless tests) MAY ignore this document entirely. + +## The problem + +A SLOP consumer driving a model has two pressures that pull in opposite directions: + +- **Freshness** — the model must reason about state *now*, not whatever the tree looked like ten turns ago. +- **Efficiency** — the conversation prefix must be stable enough to hit the provider's prompt cache, otherwise every turn re-bills the entire history. + +Embedding the full tree into each user message satisfies freshness but defeats caching: the prefix changes every turn. Snapshotting once and never refreshing satisfies caching but leaves the model reasoning over stale state. Neither is acceptable for an interactive agent. + +## The pattern: ephemeral state tail + +The recommended pattern is to treat current state as an **ephemeral tail** appended to the request, never persisted into conversation history. + +``` +┌─ Stable conversation history ───────────────────────────┐ +│ system prompt │ +│ user: "find the unread thread from alice" │ +│ assistant: "opening it now" + tool call │ +│ tool result │ +│ user: "reply with 'on it'" │ ← cache boundary, when supported +└─────────────────────────────────────────────────────────┘ +┌─ Ephemeral tail (rebuilt every turn) ───────────────────┐ +│ │ +│ ...current SLOP tree projection... │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +Rules: + +1. The conversation history MUST contain only messages — user input, assistant output, tool calls, tool results. It MUST NOT contain state-tail blocks from prior turns. +2. The current state projection is rendered fresh on every request and appended after the last stored message. +3. When the next turn arrives, the previous state tail is discarded by construction (it was never stored). The new tail reflects the current tree. + +Because old state is never written into history, no removal step is required. The consumer holds one mutable reference to the live tree, and the prompt builder reads it at request time. + +## The `` tag + +State SHOULD be delimited by an explicit `` ... `` tag, analogous to the framing used for tool definitions and tool results. This gives the model a stable signal for "this region is the live SLOP observation, not part of the conversation." + +``` + +## SLOP Apps + +### Mail (mail-app) + +[root] mail-app: Mail + [context] session (user="alice", account="work") + [view] inbox: Inbox (unread=12) salience=0.95 + [item] thread-42: Launch plan (from="alice@co.org", unread=true) actions: {reply(body: string), mark_read} + +``` + +Conventions: + +- `` is the default delimiter. Hosts MAY use a different host-specific delimiter only when their model API requires it, but they MUST define that delimiter once and keep it stable across turns. Generic `` is discouraged because it can collide with non-SLOP context. +- `generated_at`, when present, MUST be an ISO 8601 / RFC 3339 timestamp. Hosts SHOULD emit UTC timestamps with a `Z` suffix. +- The body MAY be the canonical text tree format, JSON, YAML, or another documented projection. The canonical text tree format from [state-tree.md](/spec/core/state-tree#consumer-display-format) is the default because it is compact, human-readable, and already includes paths, properties, summaries, and affordances. JSON is appropriate when the host wants schema-first parsing. +- The body SHOULD be a salience-filtered projection (see [attention.md](/spec/core/attention)), not the raw tree. Hosts SHOULD respect `meta.focus`, salience scores, and view-scoping (see [scaling.md](/spec/extensions/scaling)) to keep the tail small. +- When the tail includes multiple providers or apps, the body MUST preserve clear per-provider or per-app boundaries with a stable human-readable name and provider/app ID. Text projections SHOULD use one section per provider or app, as in `### Mail (mail-app)`. +- Disconnected providers MUST NOT leave stale tree content in `` as if it were current. Hosts SHOULD either omit disconnected providers from live state or include only a clearly labeled disconnected/status-only entry, optionally with a last-observed timestamp. +- The tail MAY include affordances available on focused nodes so the model can act without a separate query. + +## Related SLOP context blocks + +Hosts sometimes need to surface SLOP-related context that is not live state. For example, a local host may know that several SLOP-enabled applications are discoverable but not connected yet. That catalog is useful to the model, but it is not a current tree observation and MUST NOT be mixed into ``. + +Hosts that expose available-but-unconnected applications SHOULD use a sibling block: + +``` + +- Mail (id: `mail-app`, websocket, local) +- Calendar (id: `calendar-app`, unix, local) + +``` + +`` is host catalog context, not a replacement for the live state tail. It SHOULD follow the same lifecycle as the state tail: render it fresh at request time, place it after the stored conversation prefix, and never persist it into conversation history. Hosts MAY omit this block entirely when tool calls such as `list_apps` provide the same catalog. + +## Security model + +The state tail is an observation channel, not an instruction channel. Delimiters make the prompt easier to parse, but they are not a security boundary. + +Hosts MUST treat all state-tail content as untrusted application data. A node property, document body, chat message, or page title may contain hostile instructions or text that resembles opening tags, closing tags, tool calls, or higher-priority messages. Hosts MUST serialize state with a structured encoder, use a format-independent encoding layer, or escape raw text so user-controlled content cannot terminate the `` block, fake a new SLOP context block, or masquerade as host-authored instructions. + +For text tag blocks, hosts MUST neutralize any app-controlled text that resembles a SLOP context tag — both opening and closing — before final block assembly. Both directions matter: a fake closing tag can terminate the wrapping block, and a fake opening tag can let hostile content masquerade as a fresh, host-authored SLOP block in models that are lenient about nesting. At minimum, apply case-insensitive substitutions that tolerate whitespace and attribute-like text: + +| Match (case-insensitive) | Replacement | +|---|---| +| `<\s*slop-state\b[^>]*>` | `` | +| `<\s*/\s*slop-state\b[^>]*>` | `<\/slop-state>` | +| `<\s*slop-apps-available\b[^>]*>` | `` | +| `<\s*/\s*slop-apps-available\b[^>]*>` | `<\/slop-apps-available>` | + +Apply the substitutions to every piece of app-controlled text rendered inside a SLOP context block: provider names, app IDs, tree text, properties, summaries, labels, affordance descriptions, and available-app metadata. + +The system or developer prompt SHOULD explicitly tell the model that `` contains untrusted live state and must not override system, developer, user, or tool instructions. Providers MUST still re-authorize every `invoke` against live state, caller identity, and resource policy; see [transport.md](/spec/core/transport#security-considerations) and [affordances.md](/spec/core/affordances#applicability-is-not-authorization). + +## Placement + +Two provider-compatible placements are acceptable. Hosts SHOULD choose based on the target API's message model and cache controls: + +1. **Trailing user message tail** — append the `` block after the latest user message text, preferably as a separate content block when the model API supports block-structured messages. This is the recommended default for chat-completions APIs without explicit cache controls or APIs that cannot represent a separate host-authored context message. +2. **Synthetic context message** — insert a host-authored message containing only the `` block after the latest stored message and before model generation. This is the recommended default for APIs with explicit cache controls or checkpoints, because the cache marker can sit on the last stable block before the state and the volatile state can live in its own post-boundary message. This placement is only valid when the provider accepts that role and the SDK/provider does not reorder or merge it ahead of the cacheable history. + +A `` block MUST NOT be placed inside an assistant message or a tool result. + +Some host APIs only expose a "prepend context" hook that runs before the stored prefix rather than after it. Such hosts MAY still emit the state tail through the prepend hook, but they MUST treat this as a known prompt-cache regression: the volatile state will appear ahead of stable history and invalidate prefix caches on every turn. Hosts in this category SHOULD document the limitation in their integration README and SHOULD push for an append-style hook upstream so the placement can move to a cache-friendly position. + +## Prompt caching + +This pattern is designed to be cache-friendly for prefix-based prompt caches, but cache APIs differ by provider. Some providers apply caching automatically to exact matching prompt prefixes. Others expose explicit cache controls or checkpoints on structured content blocks. The portable rule is the same in both cases: keep stable instructions, tools, and stored messages before the volatile state tail. + +The host SHOULD: + +1. Preserve exact serialization and ordering for the stable prefix: system/developer instructions, tool definitions, and stored conversation messages. +2. Render the state tail after that stable prefix. +3. Where explicit cache controls are supported, place the cache control on the last stable block before the state tail. +4. Where caching is automatic, do not invent a synthetic marker; just keep the state tail last and monitor cached-token metrics. + +``` +turn N: [system + tools + msgs 1..k] | cache boundary if supported | +turn N+1: [system + tools + msgs 1..k+2] | cache boundary if supported | + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + identical prefix through msg k can hit cache +``` + +On turn N+1, the state tail is expected to be uncached. The newly added exchange after message `k` may also be uncached until that request has been processed and becomes part of a future reusable prefix. The benefit is that the long prefix already seen by the model remains reusable instead of being invalidated by fresh app state. + +For a typical SLOP projection (a few hundred to a few thousand tokens), paying the uncached cost of the current state tail is acceptable. For very large tails, the host SHOULD apply more aggressive salience filtering, view scoping, windowing, or lazy subtree loading rather than trying to cache live state. + +The host SHOULD NOT place explicit cache controls after ordinary live state tails. State changes between most turns, so caching the tail usually produces a low hit rate while consuming cache capacity that could be used elsewhere. If a host intentionally pins a stable snapshot for later temporal reasoning, that snapshot is no longer an ephemeral live-state tail; see [Trade-offs](#trade-offs). + +### Provider compatibility + +The pattern itself — clean history plus an ephemeral tail — requires no provider-specific features. Only the cache-integration step varies, and it degrades gracefully when caching is unavailable. Hosts SHOULD classify each target model and endpoint by its actual cache API and act accordingly. Vendor behavior changes over time and can vary by model, deployment type, region, and transport facade. + +- **Automatic prefix caching** (for example, OpenAI automatic prompt caching, DeepSeek context caching, Fireworks prompt caching, Gemini implicit caching, DashScope/Qwen implicit caching, and caching on supported Groq or Together endpoints). Caching is implicit on exact matching prompt prefixes. The host SHOULD keep the prefix stable: stable system-prompt serialization, stable tool ordering, no timestamps or randomized IDs in stored messages, and consistent cache-routing hints where the provider offers them (for example session affinity, cache keys, isolation keys, or retention settings). The state tail naturally remains outside the reusable prefix. +- **Explicit block markers** (for example, Anthropic `cache_control`, Amazon Bedrock `cachePoint`, and DashScope/Qwen explicit `cache_control`). The host places the marker on the last stable content block before the state tail, as described above. For Anthropic-style block-level `cache_control`, a separate final state message or content block is usually cleaner than appending state to the same block as the latest user text, because the cache marker must remain on stable content before the volatile state. Providers in this category often impose minimum-token thresholds, maximum marker counts, block-lookback limits, or TTLs; below the threshold the host SHOULD fall back to whatever implicit caching the provider offers, not invent a synthetic marker. +- **Named cached resources** (for example, Google Gemini `CachedContent`). The host creates a separate cached resource for stable, reusable material such as system instructions, large documents, or tool schemas, then references that resource when sending the live request with the `` tail as ordinary uncached request content. This is not the same as placing a marker immediately before the tail. +- **No prefix caching** (e.g. some smaller hosted endpoints, certain fine-tuning runtimes). The freshness and history-hygiene properties of the pattern still hold; the host simply pays full input tokens every turn. No host action is required beyond keeping the state tail last so a future caching upgrade is automatically beneficial. +- **Local or self-hosted runtimes** (for example, vLLM automatic prefix caching, SGLang radix caching, llama.cpp prompt caches, Ollama, TGI, or custom serving stacks). Cache behavior depends on runtime flags, server mode, batching, session reuse, and eviction policy. The host controls serialization end-to-end, so exact-prefix stability is achievable, but it SHOULD verify cache reuse in the chosen runtime rather than assuming every deployment reuses prefixes automatically. + +The `` framing is plain text and works on any model, but tag-following quality varies. Frontier models respect explicit delimiters reliably; smaller open-weight models occasionally leak the tag into output or treat tail content as instructions. Hosts targeting weaker models SHOULD strengthen the system prompt's description of the tag contract and SHOULD NOT relax the security guidance in [Security model](#security-model) — provider capability does not change the trust level of state-tail content. + +## Trade-offs + +The ephemeral-tail pattern is optimized for **present-tense** reasoning: the model acts on what is true *now*. It is intentionally weaker for **temporal** reasoning — the model cannot answer "what was selected when I asked you that earlier?" because past states are not retained. + +Hosts that need temporal reasoning have two options: + +- **Inline diffs** — when state changes meaningfully between turns, persist a compact host-authored delta note in an assistant or tool-result message, such as `State change: Mail unread 12 -> 13; focused thread thread-42 -> thread-91`. The tail still carries the full current state; history carries a compact change log. This costs some tokens but preserves cacheability of the message prefix. +- **Snapshot pinning** — on explicit user reference ("remember this state"), serialize a snapshot into the next assistant message. Use sparingly; each pinned snapshot is a permanent token cost. + +Most agent workloads do not need either. Default to the pure ephemeral tail and add temporal mechanisms only when a concrete use case demands them. + +## Interaction with patches + +SLOP providers stream JSON Patch updates between snapshots (see [messages.md](/spec/core/messages)). A consumer driving an LLM SHOULD NOT forward raw patches into the model context. Patches are an optimization for the consumer's local tree mirror; the model only ever sees the materialized current state in the `` tail. + +Exception: if the host is running an autonomous loop where the model decides when to re-observe, the host MAY surface a compact "state changed" signal (without the patch body) so the model knows a new tail will be available on the next turn. + +## Minimum host responsibilities + +A consumer that claims to support this integration: + +1. MUST maintain the conversation history free of state-tail blocks across turns. +2. MUST render the current SLOP tree projection into a state tail on every model request, using `` unless the host documents another stable delimiter. Hosts that inject indirectly (for example, by reading a file written by a separate bridge process) MAY operate within a bounded freshness window instead of literally re-rendering on each request, but they MUST document that window in their integration README and MUST drop the injection if the bridge appears stalled or dead so the model never sees state that may be silently outdated. +3. MUST treat the state tail as untrusted observation data, not instructions, and MUST escape or encode user-controlled text in any text-based delimiter format so it cannot forge SLOP context tags or masquerade as host-authored messages. The case-insensitive substitution rules in [Security model](#security-model) are the minimum bar. +4. MUST keep the state tail after the stored conversation prefix so live state does not invalidate reusable prompt-cache prefixes — except where the host's surrounding API only exposes a prepend hook, in which case the host MUST follow the prepend-only exception in [Placement](#placement) and document it as a known cache regression. +5. SHOULD place explicit prompt-cache controls at the boundary between stored history and the state tail where the provider supports them. +6. SHOULD apply salience and view-scope filtering before rendering the tail. +7. MUST preserve clear provider/app boundaries when rendering multiple connected providers or apps. +8. MUST NOT present stale disconnected-provider trees as current state. +9. SHOULD keep non-state SLOP catalog context, such as available unconnected apps, outside `` and use a sibling block or tool result instead. +10. SHOULD document which delimiter and body format it emits (canonical text tree / JSON / Markdown / custom) so prompt authors can rely on a stable shape.