, test
if (first === 'providers') {
const providersSubcommand = rest[0] ?? 'list';
const providersArgs = rest.slice(1);
- return { command: 'providers', providersSubcommand, providersArgs, noOnboarding };
+ return { command: 'providers', providersSubcommand, providersArgs, noOnboarding, noResume };
}
// skill — v0.8.0 #240: agentskills.io install/publish
@@ -572,7 +627,15 @@ export function parseCliArgs(argv: string[]): ParsedCliCommand {
const skillSubcommand = rest[0] ?? 'help';
const dryRun = rest.includes('--dry-run');
const skillArgs = rest.slice(1).filter((a) => a !== '--dry-run');
- return { command: 'skill', skillSubcommand, skillArgs, dryRun, noOnboarding };
+ return { command: 'skill', skillSubcommand, skillArgs, dryRun, noOnboarding, noResume };
+ }
+
+ if (first === 'migrate') {
+ const migrateSubcommand = rest[0] === 'import' ? 'import' : 'import';
+ const rawArgs = rest[0] === 'import' ? rest.slice(1) : rest;
+ const dryRun = rawArgs.includes('--dry-run');
+ const migrateArgs = rawArgs.filter((arg) => arg !== '--dry-run');
+ return { command: 'migrate', migrateSubcommand, migrateArgs, dryRun, noOnboarding, noResume };
}
// serve — supports --port
@@ -584,7 +647,7 @@ export function parseCliArgs(argv: string[]): ParsedCliCommand {
i += 1;
}
}
- return { command: 'serve', port, noOnboarding };
+ return { command: 'serve', port, noOnboarding, noResume };
}
// chat subcommand or -q flag at top level
@@ -624,12 +687,12 @@ export function parseCliArgs(argv: string[]): ParsedCliCommand {
// If -q was used at the top level (no 'chat' subcommand), treat as chat
if (!isChat && query) {
- return { command: 'chat', query, sessionId, continueSession, port, noOnboarding };
+ return { command: 'chat', query, sessionId, continueSession, port, noOnboarding, noResume };
}
// 'chat' subcommand with no query → start REPL
if (isChat && !query && !continueSession) {
- return { command: 'repl', noOnboarding };
+ return { command: 'repl', noOnboarding, noResume };
}
return {
@@ -639,6 +702,7 @@ export function parseCliArgs(argv: string[]): ParsedCliCommand {
continueSession,
port,
noOnboarding,
+ noResume,
};
}
@@ -656,6 +720,7 @@ export function renderCliHelp(): string {
' serve Start HTTP server + dashboard',
' gateway status Show gateway platform connection status',
' gateway connect Connect a platform (e.g., telegram)',
+ ' migrate import Import Hermes/OpenClaw config, memories, personas, skills',
' mcp list List connected MCP servers',
' mcp auth Authenticate with an MCP provider (github, slack, google)',
' mcp add Add a custom MCP server',
@@ -672,6 +737,7 @@ export function renderCliHelp(): string {
'Options:',
' -q "msg" One-shot chat (alias for chat)',
' --no-onboarding Skip first-run wizard',
+ ' --no-resume Disable startup auto-resume from in-progress checkpoints',
' --port N Server port (default: 3117)',
'',
'Session actions (REST):',
@@ -1252,6 +1318,257 @@ async function runProviders(runtime: CliRuntimeLike, parsed: ParsedCliCommand):
return `Unknown providers subcommand: ${sub}. Available: list (default), set , test`;
}
+type MigrateSection = 'skills' | 'memories' | 'personas' | 'config';
+
+export interface MigrateImportAction {
+ section: MigrateSection;
+ source: string;
+ target: string;
+ action: 'copy' | 'merge' | 'skip' | 'missing';
+ reason?: string;
+}
+
+export interface MigrateImportOptions {
+ sourceDir?: string;
+ from?: 'hermes' | 'openclaw' | string;
+ targetDir?: string;
+ homeDir?: string;
+ only?: MigrateSection[];
+ dryRun?: boolean;
+ force?: boolean;
+}
+
+export interface MigrateImportResult {
+ sourceDir: string;
+ targetDir: string;
+ dryRun: boolean;
+ actions: MigrateImportAction[];
+}
+
+const MIGRATE_SECTIONS: MigrateSection[] = ['skills', 'memories', 'personas', 'config'];
+
+function expandHomePath(value: string, home = homedir()): string {
+ return value === '~' ? home : value.startsWith('~/') ? join(home, value.slice(2)) : value;
+}
+
+async function pathExists(path: string): Promise {
+ try {
+ await access(path, constants.F_OK);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function readJsonObject(path: string): Promise | null> {
+ try {
+ const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
+ ? parsed as Record
+ : null;
+ } catch {
+ return null;
+ }
+}
+
+async function collectFiles(dirPath: string, predicate: (path: string) => boolean): Promise {
+ if (!(await pathExists(dirPath))) return [];
+ const out: string[] = [];
+ const entries = await readdir(dirPath, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = join(dirPath, entry.name);
+ if (entry.isDirectory()) {
+ out.push(...await collectFiles(fullPath, predicate));
+ } else if (entry.isFile() && predicate(fullPath)) {
+ out.push(fullPath);
+ }
+ }
+ return out;
+}
+
+function relativeTo(parent: string, child: string): string {
+ return child.slice(parent.length).replace(/^[/\\]/, '');
+}
+
+async function copyOrPlan(
+ actions: MigrateImportAction[],
+ section: MigrateSection,
+ source: string,
+ target: string,
+ options: Required>
+): Promise {
+ if (!(await pathExists(source))) {
+ actions.push({ section, source, target, action: 'missing', reason: 'source not found' });
+ return;
+ }
+ const exists = await pathExists(target);
+ if (exists && !options.force) {
+ actions.push({ section, source, target, action: 'skip', reason: 'target exists' });
+ return;
+ }
+ actions.push({ section, source, target, action: 'copy' });
+ if (options.dryRun) return;
+ await mkdir(dirname(target), { recursive: true });
+ await copyFile(source, target);
+}
+
+async function mergeJsonOrPlan(
+ actions: MigrateImportAction[],
+ section: MigrateSection,
+ source: string,
+ target: string,
+ options: Required>
+): Promise {
+ const sourceJson = await readJsonObject(source);
+ if (!sourceJson) {
+ actions.push({ section, source, target, action: 'missing', reason: 'source config not found or invalid' });
+ return;
+ }
+ const targetJson = await readJsonObject(target);
+ const merged = options.force || !targetJson
+ ? { ...(targetJson ?? {}), ...sourceJson }
+ : { ...sourceJson, ...targetJson };
+ const changed = JSON.stringify(targetJson ?? {}) !== JSON.stringify(merged);
+ actions.push({ section, source, target, action: changed ? 'merge' : 'skip', reason: changed ? undefined : 'already up to date' });
+ if (options.dryRun || !changed) return;
+ await mkdir(dirname(target), { recursive: true });
+ await writeFile(target, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
+}
+
+async function detectMigrationSource(options: MigrateImportOptions): Promise {
+ const home = options.homeDir ?? homedir();
+ if (options.sourceDir) return expandHomePath(options.sourceDir, home);
+ if (options.from && options.from !== 'hermes' && options.from !== 'openclaw') {
+ return expandHomePath(options.from, home);
+ }
+ const candidates = options.from === 'openclaw'
+ ? [join(home, '.openclaw')]
+ : options.from === 'hermes'
+ ? [join(home, '.hermes')]
+ : [join(home, '.hermes'), join(home, '.openclaw')];
+ for (const candidate of candidates) {
+ if (await pathExists(candidate)) return candidate;
+ }
+ return candidates[0]!;
+}
+
+export async function migrateImport(options: MigrateImportOptions = {}): Promise {
+ const home = options.homeDir ?? homedir();
+ const sourceDir = await detectMigrationSource(options);
+ const targetDir = expandHomePath(options.targetDir ?? join(home, '.crowclaw'), home);
+ const only = options.only?.length ? options.only : MIGRATE_SECTIONS;
+ const dryRun = options.dryRun ?? false;
+ const force = options.force ?? false;
+ const actions: MigrateImportAction[] = [];
+
+ if (only.includes('skills')) {
+ const sourceSkills = join(sourceDir, 'skills');
+ const files = await collectFiles(sourceSkills, (path) => path.endsWith('.md'));
+ if (files.length === 0) {
+ actions.push({ section: 'skills', source: sourceSkills, target: join(targetDir, 'skills'), action: 'missing', reason: 'no skill markdown files found' });
+ }
+ for (const file of files) {
+ await copyOrPlan(actions, 'skills', file, join(targetDir, 'skills', relativeTo(sourceSkills, file)), { dryRun, force });
+ }
+ }
+
+ if (only.includes('personas')) {
+ const sourcePersonas = join(sourceDir, 'personas');
+ const files = await collectFiles(sourcePersonas, () => true);
+ if (files.length === 0) {
+ actions.push({ section: 'personas', source: sourcePersonas, target: join(targetDir, 'personas'), action: 'missing', reason: 'no persona files found' });
+ }
+ for (const file of files) {
+ await copyOrPlan(actions, 'personas', file, join(targetDir, 'personas', relativeTo(sourcePersonas, file)), { dryRun, force });
+ }
+ }
+
+ if (only.includes('memories')) {
+ for (const name of ['memories.db', 'memory.db', 'memories.json', 'memory.json']) {
+ await copyOrPlan(actions, 'memories', join(sourceDir, name), join(targetDir, name), { dryRun, force });
+ }
+ }
+
+ if (only.includes('config')) {
+ for (const name of ['config.json', 'runtime-config.json']) {
+ await mergeJsonOrPlan(actions, 'config', join(sourceDir, name), join(targetDir, name), { dryRun, force });
+ }
+ }
+
+ return { sourceDir, targetDir, dryRun, actions };
+}
+
+function parseMigrateImportArgs(args: string[] = [], dryRun = false): MigrateImportOptions | { error: string } {
+ const options: MigrateImportOptions = { dryRun };
+ const positional: string[] = [];
+ for (let i = 0; i < args.length; i += 1) {
+ const arg = args[i]!;
+ if (arg === '--from') {
+ options.from = args[i + 1];
+ i += 1;
+ continue;
+ }
+ if (arg.startsWith('--from=')) {
+ options.from = arg.slice('--from='.length);
+ continue;
+ }
+ if (arg === '--only') {
+ const value = args[i + 1];
+ if (!value) return { error: 'Missing value for --only' };
+ options.only = value.split(',').map((item) => item.trim()).filter(Boolean) as MigrateSection[];
+ i += 1;
+ continue;
+ }
+ if (arg.startsWith('--only=')) {
+ options.only = arg.slice('--only='.length).split(',').map((item) => item.trim()).filter(Boolean) as MigrateSection[];
+ continue;
+ }
+ if (arg === '--target') {
+ options.targetDir = args[i + 1];
+ i += 1;
+ continue;
+ }
+ if (arg.startsWith('--target=')) {
+ options.targetDir = arg.slice('--target='.length);
+ continue;
+ }
+ if (arg === '--force') {
+ options.force = true;
+ continue;
+ }
+ if (!arg.startsWith('-')) {
+ positional.push(arg);
+ continue;
+ }
+ return { error: `Unknown migrate option: ${arg}` };
+ }
+ if (options.only?.some((section) => !MIGRATE_SECTIONS.includes(section))) {
+ return { error: `Invalid --only value. Use one or more of: ${MIGRATE_SECTIONS.join(', ')}` };
+ }
+ if (positional[0]) options.sourceDir = positional[0];
+ return options;
+}
+
+export async function runMigrateCommand(parsed: ParsedCliCommand): Promise {
+ const sub = parsed.migrateSubcommand ?? 'import';
+ if (sub !== 'import') {
+ return 'Usage: crowclaw migrate import [source-dir] [--from hermes|openclaw|path] [--only skills|memories|personas|config] [--dry-run] [--force]';
+ }
+ const options = parseMigrateImportArgs(parsed.migrateArgs ?? [], parsed.dryRun ?? false);
+ if ('error' in options) {
+ return options.error;
+ }
+ const result = await migrateImport(options);
+ const lines = [
+ `${result.dryRun ? 'Dry run' : 'Migration'}: ${result.sourceDir} -> ${result.targetDir}`,
+ ];
+ for (const action of result.actions) {
+ const suffix = action.reason ? ` (${action.reason})` : '';
+ lines.push(` ${action.action.padEnd(7)} ${action.section.padEnd(8)} ${action.source} -> ${action.target}${suffix}`);
+ }
+ return lines.join('\n');
+}
+
async function runMcpCommand(runtime: CliRuntimeLike, parsed: ParsedCliCommand): Promise {
const sub = parsed.mcpSubcommand ?? 'list';
const mcpArgs = parsed.mcpArgs ?? [];
@@ -2270,11 +2587,16 @@ export async function runCliInputLine(
export async function runCli(argv: string[], options: CliRunOptions = {}): Promise {
const parsed = parseCliArgs(argv);
- const runtime = options.runtime ?? await lazyCreateRuntime(options.runtimeOptions);
+ if (parsed.command === 'help') {
+ return renderCliHelp();
+ }
+ if (parsed.command === 'migrate') {
+ return runMigrateCommand(parsed);
+ }
+
+ const runtime = options.runtime ?? await lazyCreateRuntime(runtimeOptionsForParsed(parsed, options.runtimeOptions));
switch (parsed.command) {
- case 'help':
- return renderCliHelp();
case 'status':
return runStatus(runtime);
case 'tools':
@@ -2847,7 +3169,13 @@ export async function startRepl(options: ReplOptions = {}): Promise {
export async function runServe(options: CliRunOptions & { port?: number } = {}): Promise {
const port = options.port ?? 3117;
- const runtime = options.runtime ?? await lazyCreateRuntime(options.runtimeOptions);
+ const bindPlan = resolveTailnetBindHost({
+ fallbackHost: options.runtimeOptions?.hostname,
+ });
+ const runtimeOptions = bindPlan.hostname
+ ? { ...(options.runtimeOptions ?? {}), hostname: bindPlan.hostname }
+ : options.runtimeOptions;
+ const runtime = options.runtime ?? await lazyCreateRuntime(runtimeOptions);
// Start an HTTP server that delegates to the runtime fetch handler
const { createServer } = await import('node:http');
@@ -2905,9 +3233,14 @@ export async function runServe(options: CliRunOptions & { port?: number } = {}):
}
});
- server.listen(port, () => {
- stdout.write(`CrowClaw server running at http://localhost:${port}\n`);
- stdout.write(`Dashboard at http://localhost:${port}/dashboard\n`);
+ const onListening = () => {
+ const displayHost = bindPlan.hostname ?? 'localhost';
+ if (bindPlan.warning) stdout.write(`[network] ${bindPlan.warning}\n`);
+ if (bindPlan.source === 'tailscale' && bindPlan.hostname) {
+ stdout.write(`[network] Bound to Tailscale address ${bindPlan.hostname}\n`);
+ }
+ stdout.write(`CrowClaw server running at http://${displayHost}:${port}\n`);
+ stdout.write(`Dashboard at http://${displayHost}:${port}/dashboard\n`);
for (const gs of gatewayStatuses) {
if (gs.connected) {
const name = gs.botName ? `${gs.platform} (${gs.botName})` : gs.platform;
@@ -2917,7 +3250,12 @@ export async function runServe(options: CliRunOptions & { port?: number } = {}):
}
}
stdout.write('Press Ctrl+C to stop.\n');
- });
+ };
+ if (bindPlan.hostname) {
+ server.listen(port, bindPlan.hostname, onListening);
+ } else {
+ server.listen(port, onListening);
+ }
// Track in-flight requests for graceful drain
let inFlight = 0;
@@ -2998,6 +3336,7 @@ async function applyConfigToEnv(argv: string[]): Promise {
export async function main(argv: string[] = process.argv.slice(2)): Promise {
const parsed = parseCliArgs(argv);
+ const runtimeOptions = runtimeOptionsForParsed(parsed);
switch (parsed.command) {
case 'help':
@@ -3006,7 +3345,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise): Record {
const out: Record = {};
for (const key of Object.keys(obj).sort()) {
- out[key] = obj[key];
+ const value = obj[key];
+ if (value !== undefined) {
+ out[key] = value;
+ }
}
return out;
}
@@ -88,8 +91,8 @@ async function sha256Hex(data: string): Promise {
const digest = await crypto.subtle.digest('SHA-256', bytes);
const view = new Uint8Array(digest);
let hex = '';
- for (let i = 0; i < view.length; i++) {
- hex += view[i].toString(16).padStart(2, '0');
+ for (const byte of view) {
+ hex += byte.toString(16).padStart(2, '0');
}
return hex;
}
diff --git a/packages/core/src/branching.ts b/packages/core/src/branching.ts
index 805de90..a763c1e 100644
--- a/packages/core/src/branching.ts
+++ b/packages/core/src/branching.ts
@@ -95,9 +95,14 @@ export class ConversationTree {
let shared = 0;
const minLen = Math.min(a.messages.length, b.messages.length);
for (let i = 0; i < minLen; i++) {
+ const messageA = a.messages[i];
+ const messageB = b.messages[i];
+ if (!messageA || !messageB) {
+ break;
+ }
if (
- a.messages[i].content === b.messages[i].content &&
- a.messages[i].role === b.messages[i].role
+ messageA.content === messageB.content &&
+ messageA.role === messageB.role
) {
shared++;
} else {
@@ -178,8 +183,12 @@ export class ConversationTree {
function findLastAssistantMessage(messages: ConversationMessage[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
- if (messages[i].role === 'assistant') {
- return messages[i].content;
+ const message = messages[i];
+ if (!message) {
+ continue;
+ }
+ if (message.role === 'assistant') {
+ return message.content;
}
}
return undefined;
diff --git a/packages/core/src/compression-utils.ts b/packages/core/src/compression-utils.ts
index 657d1e0..e546652 100644
--- a/packages/core/src/compression-utils.ts
+++ b/packages/core/src/compression-utils.ts
@@ -16,6 +16,9 @@ export function identifyToolPairs(messages: ConversationMessage[]): ToolCallPair
for (let i = 0; i < messages.length - 1; i++) {
const msg = messages[i];
const next = messages[i + 1];
+ if (!msg || !next) {
+ continue;
+ }
if (msg.role === 'assistant' && next.role === 'tool') {
pairs.push({
callIndex: i,
@@ -43,7 +46,7 @@ export function splitWithPairPreservation(
const pairs = identifyToolPairs(messages);
if (pairs.length > 0) {
const lastPair = pairs[pairs.length - 1];
- if (lastPair.resultIndex === messages.length - 1) {
+ if (lastPair && lastPair.resultIndex === messages.length - 1) {
// The final message is part of a pair — keep the pair
return {
toCompress: messages.slice(0, lastPair.callIndex),
@@ -56,7 +59,11 @@ export function splitWithPairPreservation(
// Separate system prefix
let systemEnd = 0;
- while (systemEnd < messages.length && messages[systemEnd].role === 'system') {
+ while (systemEnd < messages.length) {
+ const message = messages[systemEnd];
+ if (!message || message.role !== 'system') {
+ break;
+ }
systemEnd++;
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 8ee2b2a..0a718e9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -13,8 +13,8 @@ export type {
PreToolCallVeto,
ToolResultTransform,
} from './plugins.js';
-import { buildSystemPrompt, buildMemoryPrefix, type PromptBuilderInput } from './prompt-builder.js';
-import { matchSkillManifests, filterAndBudgetSkills, checkSkillGates, type ParsedSkillFile, type SkillManifest } from './skill-manifest.js';
+import { buildSystemPrompt, buildMemoryPrefix, normalizeLocale, type PromptBuilderInput, type SupportedLocale } from './prompt-builder.js';
+import { matchSkillManifests, filterAndBudgetSkills, checkSkillGates, localizeSkillFile, type ParsedSkillFile, type SkillManifest } from './skill-manifest.js';
import type { MatchedSkill } from './prompt-builder.js';
import type { StreamChunk, StreamingProviderAdapter } from './streaming.js';
import { createCheckpoint, type CheckpointStore, type SessionCheckpoint } from './checkpoint.js';
@@ -60,11 +60,25 @@ export interface ToolExecutionContext {
agentId: string;
sessionId: string;
workspaceId?: string;
+ /** Delegation depth propagated through child agents and sandboxed tool RPC. */
+ delegateDepth?: number;
/** Env passed to tools. Use sanitizeEnv() to strip sensitive vars before passing. */
env?: unknown;
signal?: AbortSignal;
}
+export function normalizeDelegateDepth(delegateDepth: unknown): number {
+ if (delegateDepth === undefined) return 0;
+ if (
+ typeof delegateDepth !== 'number'
+ || !Number.isSafeInteger(delegateDepth)
+ || delegateDepth < 0
+ ) {
+ throw new TypeError('delegateDepth must be a non-negative safe integer.');
+ }
+ return delegateDepth;
+}
+
/** Env var patterns that should never be exposed to tools. */
const SENSITIVE_ENV_PATTERNS = [
/api[_-]?key/i, /secret/i, /token/i, /password/i, /credential/i,
@@ -115,6 +129,10 @@ export interface ProviderRequest {
messages: ConversationMessage[];
availableTools: ToolManifest[];
signal?: AbortSignal;
+ /** Optional provider-level generation cap. Providers map this to their API-specific token field. */
+ maxTokens?: number;
+ /** Optional sampling temperature. Providers may drop it for models that reject temperature. */
+ temperature?: number;
}
export interface ProviderResponseUsage {
@@ -183,10 +201,14 @@ export interface AgentRunInput {
systemPrompt?: string;
workspaceId?: string;
userId?: string;
+ /** Delegation depth propagated to all tool calls made during this run. */
+ delegateDepth?: number;
env?: unknown;
signal?: AbortSignal;
/** Pre-recalled memories to inject into the system prompt. */
memories?: string[];
+ /** Preferred UI/user locale for dynamic system prompt language. */
+ locale?: SupportedLocale;
}
/**
@@ -685,6 +707,25 @@ export class AgentLoop {
this.toolFailureStreakLimit = options.toolFailureStreakLimit ?? 3;
}
+ private auditProvenance(input?: { agentId?: string; sessionId?: string }): {
+ agentId?: string;
+ sessionId?: string;
+ model?: string;
+ provider?: string;
+ presetId?: string;
+ } {
+ const providerWithModel = this.provider as ProviderAdapter & { getModel?: () => string };
+ const model = typeof providerWithModel.getModel === 'function' ? providerWithModel.getModel() : undefined;
+ const presetId = this.agentPreset?.role;
+ return {
+ ...(input?.agentId ? { agentId: input.agentId } : {}),
+ ...(input?.sessionId ? { sessionId: input.sessionId } : {}),
+ ...(model ? { model } : {}),
+ ...(this.providerName ? { provider: this.providerName } : {}),
+ ...(presetId ? { presetId } : {}),
+ };
+ }
+
/**
* #54: Mid-run course correction. Operator submits guidance via the control
* channel (WS / REST); the next loop iteration drains pending steers and
@@ -862,6 +903,7 @@ export class AgentLoop {
agentPreset?: { role: string; goal: string; backstory?: string };
personaPrompt?: string;
memories?: string[];
+ locale?: SupportedLocale;
}): string | undefined {
// #79: When contextInjection is 'never', the caller owns the whole prompt
// lifecycle. We strip the runtime/workspace/tools bootstrap that
@@ -874,6 +916,7 @@ export class AgentLoop {
personaPrompt: promptParams.personaPrompt,
agentPreset: promptParams.agentPreset,
matchedSkills: promptParams.matchedSkills,
+ locale: promptParams.locale,
// No runtimeName/sessionId/workspaceId/userId/availableTools/memories.
// No reasoningGuidance (suppressed by absence of availableTools).
}
@@ -1016,7 +1059,7 @@ export class AgentLoop {
}
/** Scan a command string in tool input for dangerous patterns */
- private scanToolCommandInput(toolCall: ToolCall): { blocked: boolean; warnings: string[] } {
+ private scanToolCommandInput(toolCall: ToolCall, input?: { agentId?: string; sessionId?: string }): { blocked: boolean; warnings: string[] } {
if (!this.securityPolicy.scanCommands) return { blocked: false, warnings: [] };
const commandFields = ['command', 'cmd', 'script', 'code', 'shell', 'exec'];
@@ -1042,13 +1085,14 @@ export class AgentLoop {
type: blocked ? 'command_blocked' : 'command_warned',
severity: blocked ? 'critical' : 'warning',
detail: warnings.join('; '),
+ ...this.auditProvenance(input),
});
}
return { blocked, warnings };
}
/** Apply redaction to tool output if security policy requires it */
- private redactToolResult(result: ToolExecutionResult): ToolExecutionResult {
+ private redactToolResult(result: ToolExecutionResult, input?: { agentId?: string; sessionId?: string }): ToolExecutionResult {
if (!this.securityPolicy.redactToolOutput) return result;
let output = result.output;
let mutated = false;
@@ -1061,6 +1105,7 @@ export class AgentLoop {
type: 'credential_redacted',
severity: 'info',
detail: `Credentials/PII redacted in output from tool "${result.toolName}"`,
+ ...this.auditProvenance(input),
});
}
// Second-order prompt-injection scan. Indirect injection (malicious HTML
@@ -1081,6 +1126,7 @@ export class AgentLoop {
type: 'injection_detected',
severity: threatCount >= 3 ? 'critical' : 'warning',
detail: `Prompt injection in output from tool "${result.toolName}" (threats=${threatCount}: ${topThreats})`,
+ ...this.auditProvenance(input),
});
}
if (!mutated) return result;
@@ -1118,10 +1164,12 @@ export class AgentLoop {
}
private async executeToolCall(toolCall: ToolCall, input: AgentRunInput): Promise {
+ const delegateDepth = normalizeDelegateDepth(input.delegateDepth);
const context: ToolExecutionContext = {
agentId: input.agentId,
sessionId: input.sessionId,
workspaceId: input.workspaceId,
+ delegateDepth,
env: sanitizeEnv(input.env),
signal: input.signal
};
@@ -1158,7 +1206,7 @@ export class AgentLoop {
type: 'command_blocked',
severity: 'warning',
detail: `plugin-veto: ${verdict.reason ?? 'no reason given'}`,
- sessionId: input.sessionId,
+ ...this.auditProvenance(input),
});
const def = this.tools.get(toolCall.name);
return {
@@ -1187,7 +1235,7 @@ export class AgentLoop {
type: 'command_blocked',
severity: 'critical',
detail: `hardline-blocked: ${hardline.description} (pattern: ${hardline.pattern})`,
- sessionId: input.sessionId,
+ ...this.auditProvenance(input),
});
return {
toolName: definition.manifest.name,
@@ -1203,7 +1251,7 @@ export class AgentLoop {
}
// Command scanning: check tool input for dangerous commands
- const commandScan = this.scanToolCommandInput(toolCall);
+ const commandScan = this.scanToolCommandInput(toolCall, input);
if (commandScan.blocked) {
return {
toolName: definition.manifest.name,
@@ -1222,7 +1270,7 @@ export class AgentLoop {
type: 'approval_required',
severity: 'warning',
detail: `Approval required for tool "${definition.manifest.name}" (danger: ${definition.manifest.dangerLevel})`,
- sessionId: input.sessionId,
+ ...this.auditProvenance(input),
});
const approved = this.approvalDecider
? await this.approvalDecider(definition, toolCall.input, context)
@@ -1233,7 +1281,7 @@ export class AgentLoop {
type: 'approval_denied',
severity: 'critical',
detail: `Approval denied for tool "${definition.manifest.name}"`,
- sessionId: input.sessionId,
+ ...this.auditProvenance(input),
});
return {
toolName: definition.manifest.name,
@@ -1270,7 +1318,7 @@ export class AgentLoop {
rawResult: ToolExecutionResult,
input: AgentRunInput,
): Promise {
- const redacted = this.redactToolResult(rawResult);
+ const redacted = this.redactToolResult(rawResult, input);
if (!this.plugins) return redacted;
const transformed = await this.plugins.transformToolResult({
@@ -1416,6 +1464,7 @@ export class AgentLoop {
async run(input: AgentRunInput): Promise {
// #239: capture run-start so `agent:terminated` carries an honest durationMs.
const runStartMs = Date.now();
+ normalizeDelegateDepth(input.delegateDepth);
// #239: AbortSignal handling for the 'aborted' termination reason.
// ensureNotAborted throws synchronously; wrap so we can emit before rethrow.
@@ -1485,7 +1534,7 @@ export class AgentLoop {
type: 'injection_detected',
severity: injectionScan.threats.some(t => t.severity === 'high') ? 'critical' : 'warning',
detail: threatSummary,
- sessionId: input.sessionId,
+ ...this.auditProvenance(input),
});
}
}
@@ -1506,12 +1555,15 @@ export class AgentLoop {
if (this.skills.length > 0) {
const skillMatches = matchSkillManifests(input.userMessage, this.skills, 3);
if (skillMatches.length > 0) {
- matchedSkills = skillMatches.map(({ skill }) => ({
- name: skill.manifest.name,
- description: skill.manifest.description,
- instructions: skill.instructions,
- tools: skill.manifest.tools,
- }));
+ matchedSkills = skillMatches.map(({ skill }) => {
+ const localized = localizeSkillFile(skill, normalizeLocale(input.locale));
+ return {
+ name: localized.name,
+ description: localized.description,
+ instructions: localized.instructions,
+ tools: skill.manifest.tools,
+ };
+ });
// Warn about required tools that aren't registered
const registeredToolNames = new Set(toolList.map(t => t.name));
@@ -1579,6 +1631,7 @@ export class AgentLoop {
matchedSkills,
agentPreset: this.agentPreset,
memories: input.memories,
+ locale: input.locale,
});
// Track 2.3: Use prompt caching-aware system prompt builder
@@ -1634,6 +1687,7 @@ export class AgentLoop {
let toolErrorTerminal = false;
for (let iteration = 0; iteration < this.maxToolIterations; iteration += 1) {
+ this.eventBus?.emit('iteration:start', { sessionId: input.sessionId, agentId: input.agentId, iteration });
try {
ensureNotAborted(input.signal);
} catch (err) {
@@ -1660,11 +1714,13 @@ export class AgentLoop {
if (budgetCheck.exceeded) {
tokenBudgetExceeded = true;
finalResponse = currentResponse.assistantMessage ?? 'Token budget exceeded.';
+ this.eventBus?.emit('iteration:end', { sessionId: input.sessionId, agentId: input.agentId, iteration, toolCount: 0 });
break;
}
if (!currentResponse.toolCalls || currentResponse.toolCalls.length === 0) {
finalResponse = currentResponse.assistantMessage ?? finalResponse;
+ this.eventBus?.emit('iteration:end', { sessionId: input.sessionId, agentId: input.agentId, iteration, toolCount: 0 });
break;
}
@@ -1771,6 +1827,12 @@ export class AgentLoop {
if (iterationResults.length > 0) {
session.lastToolActivityAt = Date.now();
}
+ this.eventBus?.emit('iteration:end', {
+ sessionId: input.sessionId,
+ agentId: input.agentId,
+ iteration,
+ toolCount: iterationResults.length,
+ });
const iterationWarning = budgetStatus(iteration + 1, this.maxToolIterations, this.budgetWarningThreshold, this.budgetCriticalThreshold);
@@ -1838,8 +1900,8 @@ export class AgentLoop {
// Reset streak for any tool that succeeded in this iteration. This is
// intentional per-tool: a different tool failing keeps its own streak.
for (const k of Array.from(toolFailureStreak.keys())) {
- const [toolName] = k.split('|');
- if (successfulToolNamesThisIter.has(toolName)) {
+ const toolName = k.split('|')[0];
+ if (toolName && successfulToolNamesThisIter.has(toolName)) {
toolFailureStreak.delete(k);
}
}
@@ -2041,9 +2103,12 @@ export class AgentLoop {
async *runStreaming(input: {
userMessage: string;
sessionState: SessionState;
+ delegateDepth?: number;
signal?: AbortSignal;
+ locale?: SupportedLocale;
}): AsyncGenerator {
const { userMessage, sessionState: session, signal } = input;
+ normalizeDelegateDepth(input.delegateDepth);
// #239: capture run-start so `agent:terminated` carries an honest durationMs.
const streamStartMs = Date.now();
@@ -2054,7 +2119,9 @@ export class AgentLoop {
agentId: session.agentId,
sessionId: session.sessionId,
userMessage,
+ delegateDepth: input.delegateDepth,
signal,
+ locale: input.locale,
};
try {
const result = await this.run(runInput);
@@ -2091,6 +2158,7 @@ export class AgentLoop {
type: 'injection_detected',
severity: injectionScan.threats.some(t => t.severity === 'high') ? 'critical' : 'warning',
detail: threatSummary,
+ ...this.auditProvenance(session),
});
}
}
@@ -2107,12 +2175,15 @@ export class AgentLoop {
if (this.skills.length > 0) {
const skillMatches = matchSkillManifests(userMessage, this.skills, 3);
if (skillMatches.length > 0) {
- matchedSkills = skillMatches.map(({ skill }) => ({
- name: skill.manifest.name,
- description: skill.manifest.description,
- instructions: skill.instructions,
- tools: skill.manifest.tools,
- }));
+ matchedSkills = skillMatches.map(({ skill }) => {
+ const localized = localizeSkillFile(skill, normalizeLocale(input.locale));
+ return {
+ name: localized.name,
+ description: localized.description,
+ instructions: localized.instructions,
+ tools: skill.manifest.tools,
+ };
+ });
// Warn about required tools that aren't registered
const registeredToolNames = new Set(streamToolList.map(t => t.name));
@@ -2158,6 +2229,7 @@ export class AgentLoop {
availableTools: streamToolList,
matchedSkills,
agentPreset: this.agentPreset,
+ locale: input.locale,
});
let streamErrorReflectionCount = 0;
@@ -2173,6 +2245,7 @@ export class AgentLoop {
for (let iteration = 0; iteration < this.maxToolIterations; iteration += 1) {
ensureNotAborted(signal);
yield { type: 'iteration-start', iteration };
+ this.eventBus?.emit('iteration:start', { sessionId: session.sessionId, agentId: session.agentId, iteration });
// #54: drain pending /steer guidance for this turn (streaming path).
const streamSteers = this.drainPendingSteers(session.sessionId);
@@ -2206,7 +2279,7 @@ export class AgentLoop {
let streamConsumed = false;
for (let providerIdx = 0; providerIdx < streamProviderCandidates.length; providerIdx++) {
const candidateProvider = streamProviderCandidates[providerIdx];
- if (!candidateProvider.generateStream) continue;
+ if (!candidateProvider || !candidateProvider.generateStream) continue;
try {
const rawStream = candidateProvider.generateStream(request);
@@ -2280,6 +2353,7 @@ export class AgentLoop {
// #239: surface token-budget exhaustion to the soft-landing path.
streamTokenBudgetExceeded = true;
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: 0 });
break;
}
@@ -2287,6 +2361,7 @@ export class AgentLoop {
if (streamToolCalls.length === 0) {
lastStreamHadToolCalls = false;
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: 0 });
break;
}
@@ -2328,10 +2403,10 @@ export class AgentLoop {
// #235: validation gate before each parallel tool call.
safetyPartition.parallel.map((tc) => this.runToolCallWithValidation(tc, runInput))
);
- for (let i = 0; i < settled.length; i++) {
- const tc = safetyPartition.parallel[i];
- const toolCallId = resolvedIds.get(tc) ?? `tc-${tc.name}`;
+ for (const [i, tc] of safetyPartition.parallel.entries()) {
const s = settled[i];
+ if (!s) continue;
+ const toolCallId = resolvedIds.get(tc) ?? `tc-${tc.name}`;
const rawResult: ToolExecutionResult = s.status === 'fulfilled'
? s.value
: {
@@ -2375,10 +2450,10 @@ export class AgentLoop {
// #235: validation gate before each tool call.
streamToolCalls.map((tc) => this.runToolCallWithValidation(tc, runInput))
);
- for (let i = 0; i < settled.length; i++) {
- const tc = streamToolCalls[i];
- const toolCallId = resolvedIds.get(tc) ?? `tc-${tc.name}`;
+ for (const [i, tc] of streamToolCalls.entries()) {
const s = settled[i];
+ if (!s) continue;
+ const toolCallId = resolvedIds.get(tc) ?? `tc-${tc.name}`;
const rawResult: ToolExecutionResult = s.status === 'fulfilled'
? s.value
: {
@@ -2409,7 +2484,7 @@ export class AgentLoop {
type: 'command_blocked',
severity: 'critical',
detail: `hardline-blocked: ${streamHardline.description} (pattern: ${streamHardline.pattern})`,
- sessionId: session.sessionId,
+ ...this.auditProvenance(session),
});
const blockedResult: ToolExecutionResult = {
toolName: tc.name,
@@ -2429,7 +2504,7 @@ export class AgentLoop {
}
// Security: command scanning in streaming path
- const streamCmdScan = this.scanToolCommandInput({ name: tc.name, input: tc.input });
+ const streamCmdScan = this.scanToolCommandInput({ name: tc.name, input: tc.input }, session);
if (streamCmdScan.blocked) {
const blockedResult: ToolExecutionResult = {
toolName: tc.name,
@@ -2459,6 +2534,9 @@ export class AgentLoop {
const context: ToolExecutionContext = {
agentId: session.agentId,
sessionId: session.sessionId,
+ workspaceId: session.workspaceId,
+ delegateDepth: normalizeDelegateDepth(input.delegateDepth),
+ signal,
};
const approved = this.approvalDecider
? await this.approvalDecider(def!, tc.input, context)
@@ -2510,6 +2588,8 @@ export class AgentLoop {
const context: ToolExecutionContext = {
agentId: session.agentId,
sessionId: session.sessionId,
+ workspaceId: session.workspaceId,
+ delegateDepth: normalizeDelegateDepth(input.delegateDepth),
signal,
};
let toolResult = await this.tools.execute(tc.name, tc.input, context);
@@ -2520,7 +2600,7 @@ export class AgentLoop {
}
// Security: redact tool output in streaming path
- toolResult = this.redactToolResult(toolResult);
+ toolResult = this.redactToolResult(toolResult, session);
// #235: structured envelope on failure.
toolResult = this.wrapFailureAsEnvelope(toolResult);
@@ -2537,6 +2617,7 @@ export class AgentLoop {
} else if (this.stopOnToolError) {
finalResponse = 'Stopped after tool failure.';
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: iterationToolResults.length });
streamTerminationReason = 'tool_error_terminal';
this.emitTerminated(session.sessionId, streamTerminationReason, streamIterationsCompleted, streamStartMs);
yield { type: 'done', response: finalResponse, usage: accumulatedUsage, terminationReason: streamTerminationReason };
@@ -2577,8 +2658,8 @@ export class AgentLoop {
}
}
for (const k of Array.from(streamToolFailureStreak.keys())) {
- const [toolName] = k.split('|');
- if (successfulStreamToolNames.has(toolName)) {
+ const toolName = k.split('|')[0];
+ if (toolName && successfulStreamToolNames.has(toolName)) {
streamToolFailureStreak.delete(k);
}
}
@@ -2587,6 +2668,7 @@ export class AgentLoop {
streamTerminationReason = 'tool_error_terminal';
finalResponse = 'Stopped after tool failure (3 consecutive identical errors).';
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: iterationToolResults.length });
this.emitTerminated(session.sessionId, streamTerminationReason, streamIterationsCompleted, streamStartMs);
yield { type: 'done', response: finalResponse, usage: accumulatedUsage, terminationReason: streamTerminationReason };
return;
@@ -2608,6 +2690,7 @@ export class AgentLoop {
} else if (this.stopOnToolError) {
finalResponse = 'Stopped after tool failure.';
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: iterationToolResults.length });
streamTerminationReason = 'tool_error_terminal';
this.emitTerminated(session.sessionId, streamTerminationReason, streamIterationsCompleted, streamStartMs);
yield { type: 'done', response: finalResponse, usage: accumulatedUsage, terminationReason: streamTerminationReason };
@@ -2622,6 +2705,7 @@ export class AgentLoop {
streamIterationsCompleted = iteration + 1;
yield { type: 'iteration-end', iteration };
+ this.eventBus?.emit('iteration:end', { sessionId: session.sessionId, agentId: session.agentId, iteration, toolCount: iterationToolResults.length });
}
// #239 (streaming, Hermes parity): graceful soft-landing on budget
@@ -2820,7 +2904,7 @@ export function isToolAllowedForFork(session: SessionState, toolName: string): b
return whitelist.some((entry) => entry === toolName || toolName.startsWith(`${entry}.`));
}
-export { buildSystemPrompt, buildMemoryPrefix, type MatchedSkill, type PromptBuilderInput } from './prompt-builder.js';
+export { buildSystemPrompt, buildMemoryPrefix, normalizeLocale, type MatchedSkill, type PromptBuilderInput, type SupportedLocale } from './prompt-builder.js';
export {
isPrivateUrl,
@@ -2843,9 +2927,11 @@ export {
type CommandRisk,
type CommandScanResult,
SecurityAuditLog,
+ FileSecurityAuditLog,
type SecurityEvent,
type SecurityEventType,
type SecurityEventSeverity,
+ type FileSecurityAuditLogOptions,
// v0.8.0 (#234) — code.execute audit hook. The helper appends a
// `tool.code-execute` entry tagged with the truncated source + allowed-tool
// list. Called from packages/tools/src/code-execute.ts at the call site so
@@ -2856,9 +2942,10 @@ export {
export { UsageTracker, type TokenUsage, type UsageRecord, type SessionUsageSummary } from './usage.js';
export { DetailedUsageTracker, type UsageEntry, type UsageSummary } from './usage-tracker.js';
+export { setTelemetryHooks, getTelemetryHooks, type TelemetryHooks, type TelemetrySpan } from './telemetry.js';
export { ConversationTree, type ConversationBranch, type BranchComparison } from './branching.js';
-export { parseSkillFile, renderSkillFile, loadSkillsFromDirectory, matchSkillManifests, filterAndBudgetSkills, checkSkillGates, validateSkillManifest, type SkillManifest, type ParsedSkillFile, type SkillFileSystem, type SkillDirectoryEntry, type SkillConfigRequirements, type SkillValidationResult } from './skill-manifest.js';
+export { parseSkillFile, renderSkillFile, loadSkillsFromDirectory, matchSkillManifests, filterAndBudgetSkills, checkSkillGates, validateSkillManifest, localizeSkillFile, type SkillManifest, type ParsedSkillFile, type SkillFileSystem, type SkillDirectoryEntry, type SkillConfigRequirements, type SkillValidationResult } from './skill-manifest.js';
export { agentPresets, getAgentPreset, listAgentPresets, listAgentPresetNames, type AgentPreset } from './agent-presets.js';
diff --git a/packages/core/src/persona.ts b/packages/core/src/persona.ts
index acf4109..e9e4816 100644
--- a/packages/core/src/persona.ts
+++ b/packages/core/src/persona.ts
@@ -3,6 +3,7 @@ export interface PersonaFiles {
identity?: string;
agents?: string;
user?: string;
+ locales?: Partial>>;
}
export interface PersonaConfig {
@@ -20,8 +21,9 @@ export function parseIdentity(content: string): PersonaConfig {
for (const line of lines) {
const match = line.match(/\*\*(\w+):\*\*\s*(.+)/);
if (match) {
- const key = match[1].toLowerCase();
- const value = match[2].trim();
+ const key = match[1]?.toLowerCase();
+ const value = match[2]?.trim();
+ if (!key || !value) continue;
if (key === 'name') config.name = value;
if (key === 'type') config.type = value;
if (key === 'vibe') config.vibe = value;
@@ -33,23 +35,30 @@ export function parseIdentity(content: string): PersonaConfig {
}
/** Build system prompt section from persona files */
-export function buildPersonaPrompt(files: PersonaFiles): string {
+export function buildPersonaPrompt(files: PersonaFiles, locale: 'en' | 'ko' = 'en'): string {
+ const localized = files.locales?.[locale];
+ const resolved: Omit = {
+ identity: localized?.identity ?? files.identity,
+ soul: localized?.soul ?? files.soul,
+ agents: localized?.agents ?? files.agents,
+ user: localized?.user ?? files.user,
+ };
const sections: string[] = [];
- if (files.identity) {
- sections.push(`\n${files.identity.trim()}\n`);
+ if (resolved.identity) {
+ sections.push(`\n${resolved.identity.trim()}\n`);
}
- if (files.soul) {
- sections.push(`\n${files.soul.trim()}\n`);
+ if (resolved.soul) {
+ sections.push(`\n${resolved.soul.trim()}\n`);
}
- if (files.agents) {
- sections.push(`\n${files.agents.trim()}\n`);
+ if (resolved.agents) {
+ sections.push(`\n${resolved.agents.trim()}\n`);
}
- if (files.user) {
- sections.push(`\n${files.user.trim()}\n`);
+ if (resolved.user) {
+ sections.push(`\n${resolved.user.trim()}\n`);
}
return sections.join('\n\n');
@@ -61,7 +70,7 @@ export async function loadPersonaFiles(
fs: { readFile(path: string): Promise; joinPath(...parts: string[]): string },
): Promise {
const files: PersonaFiles = {};
- const names: Array<[keyof PersonaFiles, string]> = [
+ const names: Array<[keyof Omit, string]> = [
['soul', 'SOUL.md'],
['identity', 'IDENTITY.md'],
['agents', 'AGENTS.md'],
@@ -76,6 +85,20 @@ export async function loadPersonaFiles(
}
}
+ for (const locale of ['en', 'ko'] as const) {
+ const localized: Omit = {};
+ for (const [key, filename] of names) {
+ try {
+ localized[key] = await fs.readFile(fs.joinPath(dirPath, filename.replace('.md', `.${locale}.md`)));
+ } catch {
+ // Locale-specific file doesn't exist — skip
+ }
+ }
+ if (localized.soul || localized.identity || localized.agents || localized.user) {
+ files.locales = { ...(files.locales ?? {}), [locale]: localized };
+ }
+ }
+
return files;
}
@@ -95,6 +118,7 @@ export interface PersonaProfile {
name: string;
files: PersonaFiles;
prompt: string; // pre-built persona prompt
+ prompts?: Partial>;
}
export class PersonaRegistry {
@@ -111,6 +135,10 @@ export class PersonaRegistry {
name: 'default',
files: defaultFiles,
prompt: getDefaultPersonaPrompt(),
+ prompts: {
+ en: buildPersonaPrompt(defaultFiles, 'en'),
+ ko: buildPersonaPrompt(defaultFiles, 'ko'),
+ },
});
}
@@ -120,6 +148,10 @@ export class PersonaRegistry {
name,
files,
prompt: buildPersonaPrompt(files),
+ prompts: {
+ en: buildPersonaPrompt(files, 'en'),
+ ko: buildPersonaPrompt(files, 'ko'),
+ },
});
}
@@ -144,6 +176,12 @@ export class PersonaRegistry {
return this.personas.get(this.active)!;
}
+ /** Get the currently active persona prompt for a locale. */
+ getActivePrompt(locale: 'en' | 'ko' = 'en'): string {
+ const profile = this.getActive();
+ return profile.prompts?.[locale] ?? profile.prompt;
+ }
+
/** List all registered personas with active indicator */
list(): Array<{ name: string; active: boolean }> {
return [...this.personas.keys()].map((name) => ({
@@ -192,7 +230,7 @@ export async function scanPersonaDirectories(
for (const dirName of dirs) {
const dirPath = baseDir.endsWith(separator) ? baseDir + dirName : baseDir + separator + dirName;
const files: PersonaFiles = {};
- const fileNames: Array<[keyof PersonaFiles, string]> = [
+ const fileNames: Array<[keyof Omit, string]> = [
['soul', 'SOUL.md'],
['identity', 'IDENTITY.md'],
['agents', 'AGENTS.md'],
@@ -207,6 +245,20 @@ export async function scanPersonaDirectories(
}
}
+ for (const locale of ['en', 'ko'] as const) {
+ const localized: Omit = {};
+ for (const [key, filename] of fileNames) {
+ try {
+ localized[key] = await readFile(dirPath + separator + filename.replace('.md', `.${locale}.md`));
+ } catch {
+ // Locale-specific file doesn't exist — skip
+ }
+ }
+ if (localized.soul || localized.identity || localized.agents || localized.user) {
+ files.locales = { ...(files.locales ?? {}), [locale]: localized };
+ }
+ }
+
// Only add if at least one file was loaded
if (files.soul || files.identity || files.agents || files.user) {
result.set(dirName, files);
diff --git a/packages/core/src/plugins.ts b/packages/core/src/plugins.ts
index 3670fff..a49be49 100644
--- a/packages/core/src/plugins.ts
+++ b/packages/core/src/plugins.ts
@@ -114,6 +114,14 @@ export class PluginManager {
return [...this.plugins.values()];
}
+ get(name: string): Plugin | undefined {
+ return this.plugins.get(name);
+ }
+
+ unregister(name: string): boolean {
+ return this.plugins.delete(name);
+ }
+
async emit(hook: K, payload: PluginHookPayloads[K], context: PluginContext): Promise {
for (const plugin of this.plugins.values()) {
await plugin.on?.(hook, payload, context);
diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts
index d690f72..cd31bb7 100644
--- a/packages/core/src/prompt-builder.ts
+++ b/packages/core/src/prompt-builder.ts
@@ -1,6 +1,8 @@
import type { ToolManifest } from './index.js';
import type { SkillManifest } from './skill-manifest.js';
+export type SupportedLocale = 'en' | 'ko';
+
export interface MatchedSkill {
name: string;
description: string;
@@ -18,6 +20,8 @@ export interface PromptBuilderInput {
matchedSkills?: MatchedSkill[];
agentPreset?: { role: string; goal: string; backstory?: string };
personaPrompt?: string;
+ /** Preferred language for model-facing dynamic instructions and responses. */
+ locale?: SupportedLocale;
/** Include reasoning guidance for tool usage. true = built-in, string = custom. Default: true when tools present. */
reasoningGuidance?: boolean | string;
/** Recalled memories to inject as context. Max ~5 entries recommended. */
@@ -26,6 +30,7 @@ export interface PromptBuilderInput {
export function buildSystemPrompt(input: PromptBuilderInput): string | undefined {
const sections: string[] = [];
+ const locale = normalizeLocale(input.locale);
if (input.personaPrompt) {
sections.push(input.personaPrompt);
@@ -62,6 +67,10 @@ export function buildSystemPrompt(input: PromptBuilderInput): string | undefined
sections.push(['Runtime context:', ...runtimeLines].join('\n'));
}
+ if (input.locale) {
+ sections.push(buildLocaleDirective(locale));
+ }
+
// Memory context is now injected as an untrusted user-context prefix
// (not in the system prompt) to prevent memory injection attacks.
// See buildMemoryPrefix() for the injection format.
@@ -85,6 +94,20 @@ export function buildSystemPrompt(input: PromptBuilderInput): string | undefined
return sections.length > 0 ? sections.join('\n\n') : undefined;
}
+export function normalizeLocale(locale: unknown): SupportedLocale {
+ return locale === 'ko' ? 'ko' : 'en';
+}
+
+function buildLocaleDirective(locale: SupportedLocale): string {
+ const defaultLanguage = locale === 'ko' ? 'Korean' : 'English';
+ return [
+ 'Response language:',
+ `- Respond in ${defaultLanguage} by default.`,
+ '- Keep code, commands, file paths, identifiers, API names, and quoted source text in their original language.',
+ '- If the user explicitly asks for another language, follow the user request for that turn.',
+ ].join('\n');
+}
+
function buildReasoningGuidance(tools: ToolManifest[]): string {
const hasWebTools = tools.some((t) => t.name.startsWith('web.'));
const hasWorkspaceTools = tools.some((t) => t.name.startsWith('workspace.'));
diff --git a/packages/core/src/reasoning-blocks.ts b/packages/core/src/reasoning-blocks.ts
index 933e632..8849e61 100644
--- a/packages/core/src/reasoning-blocks.ts
+++ b/packages/core/src/reasoning-blocks.ts
@@ -382,6 +382,9 @@ export class StreamingReasoningParser {
/** Consume a single character at `i`, route it to text/reasoning/tool-call. */
private emitChar(events: StreamingReasoningEvent[], i: number): number {
const ch = this.buffer[i];
+ if (ch === undefined) {
+ return i + 1;
+ }
if (this.toolCallBuf !== null) {
this.toolCallBuf += ch;
} else if (this.currentTag) {
diff --git a/packages/core/src/security.ts b/packages/core/src/security.ts
index a692399..25bf37b 100644
--- a/packages/core/src/security.ts
+++ b/packages/core/src/security.ts
@@ -7,6 +7,7 @@ const PRIVATE_IP_PATTERNS = [
/^10\./, // 10.0.0.0/8 RFC1918
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 RFC1918
/^192\.168\./, // 192.168.0.0/16 RFC1918
+ /^192\.0\.0\./, // 192.0.0.0/24 IETF protocol assignments
/^0\./, // 0.0.0.0/8 "this network"
/^169\.254\./, // 169.254.0.0/16 link-local (AWS/GCP IMDS)
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT
@@ -16,6 +17,8 @@ const PRIVATE_IP_PATTERNS = [
/^fc00:/i, /^fd[0-9a-f]{2}:/i, // fc00::/7 ULA (covers both fc00 and fd00)
/^fe80:/i, // fe80::/10 link-local
/^ff[0-9a-f]{2}:/i, // ff00::/8 multicast
+ /^2001:(?:0{1,4}:|:)/i, // 2001::/32 Teredo
+ /^2002:/i, // 2002::/16 6to4
/^::ffff:/i, // IPv4-mapped IPv6 (::ffff:10.0.0.1 etc.)
/^0:0:0:0:0:ffff:/i, // IPv4-mapped long form
/^0:0:0:0:0:0:/i, // other abbreviated-zero forms
@@ -24,26 +27,136 @@ const PRIVATE_IP_PATTERNS = [
/^.*\.internal$/i
];
+export interface UrlSafetyOptions {
+ /**
+ * Comma-separated CIDRs or literal host/IP entries that may bypass the
+ * default private-network SSRF block. Intended for explicit tailnet opt-in
+ * through CROWCLAW_TAILNET_ALLOWLIST.
+ */
+ tailnetAllowlist?: string | string[];
+ env?: Record;
+}
+
+function getRuntimeEnv(): Record {
+ return (globalThis as unknown as { process?: { env?: Record } }).process?.env ?? {};
+}
+
+function normalizeAddress(value: string): string {
+ const unwrapped = value.trim().replace(/^\[|\]$/g, '').split('%')[0]!;
+ const mapped = unwrapped.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
+ return (mapped ? mapped[1]! : unwrapped).toLowerCase();
+}
+
+function ipv4ToInt(ip: string): number | null {
+ const parts = ip.split('.');
+ if (parts.length !== 4) return null;
+ let result = 0;
+ for (const part of parts) {
+ if (!/^\d+$/.test(part)) return null;
+ const n = Number(part);
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
+ result = (result << 8) | n;
+ }
+ return result >>> 0;
+}
+
+function expandIpv6(address: string): number[] | null {
+ const normalized = normalizeAddress(address);
+ if (!normalized.includes(':')) return null;
+ const [headRaw, tailRaw] = normalized.split('::');
+ if (normalized.indexOf('::') !== normalized.lastIndexOf('::')) return null;
+ const head = headRaw ? headRaw.split(':').filter(Boolean) : [];
+ const tail = tailRaw ? tailRaw.split(':').filter(Boolean) : [];
+ const parseGroup = (group: string): number | null => {
+ if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
+ return parseInt(group, 16);
+ };
+ if (tailRaw === undefined) {
+ if (head.length !== 8) return null;
+ return head.map(parseGroup).every((v): v is number => v !== null)
+ ? head.map((group) => parseInt(group, 16))
+ : null;
+ }
+ const missing = 8 - head.length - tail.length;
+ if (missing < 1) return null;
+ const groups = [...head, ...Array.from({ length: missing }, () => '0'), ...tail];
+ const parsed = groups.map(parseGroup);
+ return parsed.every((v): v is number => v !== null) ? parsed : null;
+}
+
+function ipv6MatchesCidr(ip: string, base: string, prefixLength: number): boolean {
+ const target = expandIpv6(ip);
+ const cidrBase = expandIpv6(base);
+ if (!target || !cidrBase || prefixLength < 0 || prefixLength > 128) return false;
+ const fullGroups = Math.floor(prefixLength / 16);
+ const partialBits = prefixLength % 16;
+ for (let i = 0; i < fullGroups; i++) {
+ if (target[i] !== cidrBase[i]) return false;
+ }
+ if (partialBits === 0) return true;
+ const mask = (0xffff << (16 - partialBits)) & 0xffff;
+ return (target[fullGroups]! & mask) === (cidrBase[fullGroups]! & mask);
+}
+
+function matchesAllowlistEntry(value: string, entry: string): boolean {
+ const target = normalizeAddress(value);
+ const candidate = entry.trim().replace(/^\[|\]$/g, '').toLowerCase();
+ if (!candidate) return false;
+ if (!candidate.includes('/')) {
+ return target === normalizeAddress(candidate);
+ }
+ const [base, prefixRaw] = candidate.split('/');
+ const prefixLength = Number(prefixRaw);
+ if (!base || !Number.isInteger(prefixLength)) return false;
+ const target4 = ipv4ToInt(target);
+ const base4 = ipv4ToInt(base);
+ if (target4 !== null && base4 !== null) {
+ if (prefixLength < 0 || prefixLength > 32) return false;
+ const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0;
+ return (target4 & mask) === (base4 & mask);
+ }
+ return ipv6MatchesCidr(target, base, prefixLength);
+}
+
+function getTailnetAllowlist(options?: UrlSafetyOptions): string[] {
+ const configured = options?.tailnetAllowlist
+ ?? options?.env?.CROWCLAW_TAILNET_ALLOWLIST
+ ?? getRuntimeEnv().CROWCLAW_TAILNET_ALLOWLIST;
+ if (Array.isArray(configured)) {
+ return configured.map((entry) => entry.trim()).filter(Boolean);
+ }
+ return (configured ?? '').split(',').map((entry) => entry.trim()).filter(Boolean);
+}
+
+export function isTailnetAllowlistedAddress(address: string, options?: UrlSafetyOptions): boolean {
+ const allowlist = getTailnetAllowlist(options);
+ if (allowlist.length === 0) return false;
+ return allowlist.some((entry) => matchesAllowlistEntry(address, entry));
+}
+
/**
* Check if a bare IP address (already resolved) matches a private/internal range.
* Separate from isPrivateUrl so DNS-rebinding-aware callers can validate the
* resolved IP, not just the hostname string.
*/
-export function isPrivateIpAddress(ip: string): boolean {
- return PRIVATE_IP_PATTERNS.some(p => p.test(ip));
+export function isPrivateIpAddress(ip: string, options?: UrlSafetyOptions): boolean {
+ const normalized = normalizeAddress(ip);
+ if (isTailnetAllowlistedAddress(normalized, options)) return false;
+ return PRIVATE_IP_PATTERNS.some(p => p.test(normalized));
}
-export function isPrivateUrl(url: string): boolean {
+export function isPrivateUrl(url: string, options?: UrlSafetyOptions): boolean {
try {
const parsed = new URL(url);
- const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
+ const hostname = normalizeAddress(parsed.hostname); // strip IPv6 brackets/zone ids
+ if (isTailnetAllowlistedAddress(hostname, options)) return false;
return PRIVATE_IP_PATTERNS.some(p => p.test(hostname));
} catch {
return true; // invalid URLs are treated as private
}
}
-export function validateFetchUrl(url: string): { safe: boolean; reason?: string } {
+export function validateFetchUrl(url: string, options?: UrlSafetyOptions): { safe: boolean; reason?: string } {
if (!url) return { safe: false, reason: 'Empty URL' };
try {
@@ -51,7 +164,7 @@ export function validateFetchUrl(url: string): { safe: boolean; reason?: string
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { safe: false, reason: `Disallowed protocol: ${parsed.protocol}` };
}
- if (isPrivateUrl(url)) {
+ if (isPrivateUrl(url, options)) {
return { safe: false, reason: 'URL resolves to private/internal network' };
}
return { safe: true };
@@ -71,20 +184,21 @@ export function validateFetchUrl(url: string): { safe: boolean; reason?: string
*/
export async function resolveAndValidateUrl(
url: string,
- resolver: (hostname: string) => Promise
+ resolver: (hostname: string) => Promise,
+ options?: UrlSafetyOptions
): Promise<{ safe: boolean; reason?: string; resolvedIps?: string[] }> {
- const base = validateFetchUrl(url);
+ const base = validateFetchUrl(url, options);
if (!base.safe) return base;
try {
const parsed = new URL(url);
- const host = parsed.hostname.replace(/^\[|\]$/g, '');
+ const host = normalizeAddress(parsed.hostname);
// Literal IPs skip DNS (no rebinding risk).
if (/^[0-9.]+$/.test(host) || host.includes(':')) {
return { safe: true, resolvedIps: [host] };
}
const ips = await resolver(host);
if (ips.length === 0) return { safe: false, reason: 'Hostname did not resolve to any IP' };
- const badIp = ips.find(ip => isPrivateIpAddress(ip));
+ const badIp = ips.find(ip => isPrivateIpAddress(ip, options));
if (badIp) {
return { safe: false, reason: `Hostname resolves to private IP: ${badIp}`, resolvedIps: ips };
}
@@ -545,6 +659,7 @@ export type SecurityEventType =
| 'command_warned'
| 'pii_redacted'
| 'ssrf_blocked'
+ | 'rate_limit_exceeded'
| 'approval_required'
| 'approval_denied'
// v0.8.0 (#234) — `code.execute` pipeline tool. Recorded at the call site
@@ -562,6 +677,10 @@ export interface SecurityEvent {
severity: SecurityEventSeverity;
detail: string;
sessionId?: string;
+ agentId?: string;
+ model?: string;
+ provider?: string;
+ presetId?: string;
}
export class SecurityAuditLog {
@@ -572,11 +691,16 @@ export class SecurityAuditLog {
this.maxEvents = maxEvents;
}
- record(event: Omit): void {
+ record(event: Omit): SecurityEvent {
const entry: SecurityEvent = {
...event,
timestamp: new Date().toISOString(),
};
+ this.recordEntry(entry);
+ return entry;
+ }
+
+ protected recordEntry(entry: SecurityEvent): void {
this.events.push(entry);
if (this.events.length > this.maxEvents) {
this.events = this.events.slice(-this.maxEvents);
@@ -611,6 +735,162 @@ export class SecurityAuditLog {
clear(): void {
this.events = [];
}
+
+ flush(): SecurityEvent[] {
+ const flushed = [...this.events];
+ this.events = [];
+ return flushed;
+ }
+}
+
+function getProcessEnv(name: string): string | undefined {
+ const processRef = (globalThis as { process?: { env?: Record } }).process;
+ return processRef?.env?.[name];
+}
+
+function defaultCrowclawDataDir(): string {
+ return getProcessEnv('CROWCLAW_DATA_DIR')
+ ?? `${getProcessEnv('HOME') ?? '/tmp'}/.crowclaw`;
+}
+
+function dateStamp(timestamp: string): string {
+ return timestamp.slice(0, 10);
+}
+
+export interface FileSecurityAuditLogOptions {
+ baseDir?: string;
+ maxEvents?: number;
+ retentionDays?: number;
+}
+
+interface FsPromisesApi {
+ mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise;
+ readdir(path: string): Promise;
+ readFile(path: string, encoding: 'utf-8'): Promise;
+ appendFile(path: string, data: string, options?: { encoding?: 'utf-8'; mode?: number }): Promise;
+ chmod(path: string, mode: number): Promise;
+ unlink(path: string): Promise;
+}
+
+function loadFsPromises(): Promise {
+ const processRef = (() => {
+ try {
+ return new Function('return typeof process === "object" ? process : undefined')() as
+ | { getBuiltinModule?: (specifier: string) => unknown }
+ | undefined;
+ } catch {
+ return (globalThis as { process?: { getBuiltinModule?: (specifier: string) => unknown } }).process;
+ }
+ })();
+ const builtin = processRef?.getBuiltinModule?.('node:fs/promises')
+ ?? processRef?.getBuiltinModule?.('fs/promises');
+ if (builtin) return Promise.resolve(builtin as FsPromisesApi);
+
+ const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise;
+ return dynamicImport('node:fs/promises');
+}
+
+export class FileSecurityAuditLog extends SecurityAuditLog {
+ private readonly baseDir: string;
+ private readonly retentionDays: number;
+ private writeQueue: Promise = Promise.resolve();
+ private clearedAt: string | null = null;
+
+ constructor(options: FileSecurityAuditLogOptions = {}) {
+ super(options.maxEvents ?? 500);
+ this.baseDir = options.baseDir ?? `${defaultCrowclawDataDir()}/audit`;
+ const envRetention = Number.parseInt(getProcessEnv('CROWCLAW_AUDIT_RETENTION_DAYS') ?? '', 10);
+ this.retentionDays = options.retentionDays ?? (Number.isFinite(envRetention) && envRetention > 0 ? envRetention : 30);
+ }
+
+ override record(event: Omit): SecurityEvent {
+ const entry = super.record(event);
+ this.enqueueWrite(entry);
+ return entry;
+ }
+
+ async readEvents(options: { since?: string; type?: string; severity?: string; limit?: number } = {}): Promise {
+ const fs = await loadFsPromises();
+ await fs.mkdir(this.baseDir, { recursive: true, mode: 0o700 });
+ const entries = await fs.readdir(this.baseDir).catch(() => []);
+ const files = entries
+ .filter((name) => /^audit-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name))
+ .sort()
+ .reverse();
+ const sinceTime = options.since ? Date.parse(options.since) : Number.NEGATIVE_INFINITY;
+ const events: SecurityEvent[] = [];
+
+ for (const file of files) {
+ const text = await fs.readFile(`${this.baseDir}/${file}`, 'utf-8').catch(() => '');
+ for (const line of text.split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const event = JSON.parse(line) as SecurityEvent;
+ const eventTime = Date.parse(event.timestamp);
+ if (this.clearedAt && eventTime <= Date.parse(this.clearedAt)) continue;
+ if (Number.isFinite(sinceTime) && eventTime < sinceTime) continue;
+ if (options.type && event.type !== options.type) continue;
+ if (options.severity && event.severity !== options.severity) continue;
+ events.push(event);
+ } catch {
+ // Skip malformed historical rows instead of failing the audit API.
+ }
+ }
+ }
+
+ events.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
+ return options.limit ? events.slice(0, options.limit) : events;
+ }
+
+ async drainWrites(): Promise {
+ await this.writeQueue;
+ }
+
+ override clear(): void {
+ super.clear();
+ this.clearedAt = new Date().toISOString();
+ this.writeQueue = this.writeQueue
+ .then(() => this.deleteAuditFiles())
+ .catch(() => {});
+ }
+
+ private enqueueWrite(entry: SecurityEvent): void {
+ this.writeQueue = this.writeQueue
+ .then(() => this.append(entry))
+ .catch(() => {});
+ }
+
+ private async append(entry: SecurityEvent): Promise {
+ const fs = await loadFsPromises();
+ await fs.mkdir(this.baseDir, { recursive: true, mode: 0o700 });
+ const path = `${this.baseDir}/audit-${dateStamp(entry.timestamp)}.jsonl`;
+ await fs.appendFile(path, JSON.stringify(entry) + '\n', { encoding: 'utf-8', mode: 0o600 });
+ await fs.chmod(path, 0o600).catch(() => {});
+ await this.pruneOldFiles(fs);
+ }
+
+ private async pruneOldFiles(fs: FsPromisesApi): Promise {
+ if (this.retentionDays <= 0) return;
+ const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000;
+ const entries = await fs.readdir(this.baseDir).catch(() => []);
+ await Promise.all(entries.map(async (name) => {
+ const match = name.match(/^audit-(\d{4}-\d{2}-\d{2})\.jsonl$/);
+ if (!match) return;
+ const date = match[1];
+ if (!date) return;
+ if (Date.parse(date) >= cutoff) return;
+ await fs.unlink(`${this.baseDir}/${name}`).catch(() => {});
+ }));
+ }
+
+ private async deleteAuditFiles(): Promise {
+ const fs = await loadFsPromises();
+ const entries = await fs.readdir(this.baseDir).catch(() => []);
+ await Promise.all(entries.map(async (name) => {
+ if (!/^audit-\d{4}-\d{2}-\d{2}\.jsonl$/.test(name)) return;
+ await fs.unlink(`${this.baseDir}/${name}`).catch(() => {});
+ }));
+ }
}
// ---------------------------------------------------------------------------
@@ -630,6 +910,10 @@ export class SecurityAuditLog {
export interface CodeExecuteAuditPayload {
sessionId: string;
+ agentId?: string;
+ model?: string;
+ provider?: string;
+ presetId?: string;
language: 'js' | 'ts' | 'python';
code: string;
/** Bytes of `code` to keep in the audit row before truncation. Defaults to 4 KB. */
@@ -664,6 +948,10 @@ export function recordCodeExecuteAudit(
type: 'tool.code-execute',
severity,
sessionId: payload.sessionId,
+ ...(payload.agentId ? { agentId: payload.agentId } : {}),
+ ...(payload.model ? { model: payload.model } : {}),
+ ...(payload.provider ? { provider: payload.provider } : {}),
+ ...(payload.presetId ? { presetId: payload.presetId } : {}),
detail: `code.execute language=${payload.language} allowedTools=[${allowedList}]\n----- source -----\n${truncated}\n----- end source -----`,
});
}
diff --git a/packages/core/src/skill-manifest.ts b/packages/core/src/skill-manifest.ts
index b3563bd..64a49ce 100644
--- a/packages/core/src/skill-manifest.ts
+++ b/packages/core/src/skill-manifest.ts
@@ -74,6 +74,14 @@ export interface SkillConfigRequirements {
tools?: string[];
}
+export type SkillLocale = 'en' | 'ko';
+
+export interface LocalizedSkillMetadata {
+ name?: string;
+ description?: string;
+ triggers?: string[];
+}
+
export interface SkillManifest {
// ---- Existing CrowClaw fields (KEEP) ----
name: string;
@@ -108,13 +116,24 @@ export interface SkillManifest {
config_requirements?: SkillConfigRequirements;
/** ISO 8601 timestamp of last modification. */
updated_at?: string;
+ /** Locale-specific display metadata. Instructions can use body markers. */
+ i18n?: Partial>;
+ /**
+ * Optional SHA-256 integrity pin for the instruction body.
+ * Format: `sha256:<64 lowercase/uppercase hex chars>`.
+ */
+ content_hash?: string;
}
export interface ParsedSkillFile {
manifest: SkillManifest;
instructions: string; // The markdown body (after frontmatter)
+ /** Locale-specific instruction body extracted from `` blocks. */
+ localizedInstructions?: Partial>;
raw: string; // Original file content
filePath?: string;
+ /** True when `manifest.content_hash` was present but did not match `instructions`. */
+ hashMismatch?: boolean;
}
/**
@@ -172,6 +191,13 @@ export function validateSkillManifest(
if (manifest.updated_at !== undefined && typeof manifest.updated_at !== 'string') {
errors.push('updated_at must be an ISO-8601 string');
}
+ if (manifest.content_hash !== undefined) {
+ if (typeof manifest.content_hash !== 'string') {
+ errors.push('content_hash must be a string');
+ } else if (!/^sha256:[a-f0-9]{64}$/i.test(manifest.content_hash)) {
+ warnings.push('content_hash should use sha256:<64 hex chars>');
+ }
+ }
if (manifest.config_requirements !== undefined) {
const cr = manifest.config_requirements;
if (typeof cr !== 'object' || cr === null) {
@@ -204,7 +230,8 @@ export function parseSkillFile(
if (endIndex === -1) return null;
const yamlBlock = trimmed.slice(3, endIndex).trim();
- const instructions = trimmed.slice(endIndex + 3).trim();
+ const rawInstructions = trimmed.slice(endIndex + 3).trim();
+ const { defaultInstructions, localizedInstructions } = extractLocalizedInstructions(rawInstructions);
// Simple YAML parser (no external dep)
const yaml = parseSimpleYaml(yamlBlock);
@@ -239,16 +266,71 @@ export function parseSkillFile(
platforms: Array.isArray(yaml.platforms) ? (yaml.platforms as string[]) : undefined,
config_requirements,
updated_at: yaml.updated_at as string | undefined,
+ i18n: parseLocalizedSkillMetadata((yaml as Record).i18n),
+ content_hash: yaml.content_hash as string | undefined,
};
return {
manifest,
- instructions,
+ instructions: defaultInstructions,
+ localizedInstructions,
raw: content,
filePath,
};
}
+export function localizeSkillFile(
+ skill: ParsedSkillFile,
+ locale: SkillLocale = 'en',
+): { name: string; description: string; instructions: string; triggers: string[] } {
+ const localized = skill.manifest.i18n?.[locale];
+ return {
+ name: localized?.name ?? skill.manifest.name,
+ description: localized?.description ?? skill.manifest.description,
+ instructions: skill.localizedInstructions?.[locale] ?? skill.instructions,
+ triggers: localized?.triggers ?? skill.manifest.triggers,
+ };
+}
+
+function parseLocalizedSkillMetadata(raw: unknown): SkillManifest['i18n'] {
+ if (!raw || typeof raw !== 'object') return undefined;
+ const out: Partial> = {};
+ for (const locale of ['en', 'ko'] as const) {
+ const value = (raw as Record)[locale];
+ if (!value || typeof value !== 'object') continue;
+ const obj = value as Record;
+ const meta: LocalizedSkillMetadata = {};
+ if (typeof obj.name === 'string') meta.name = obj.name;
+ if (typeof obj.description === 'string') meta.description = obj.description;
+ if (Array.isArray(obj.triggers)) meta.triggers = obj.triggers.filter((v): v is string => typeof v === 'string');
+ if (Object.keys(meta).length > 0) out[locale] = meta;
+ }
+ return Object.keys(out).length > 0 ? out : undefined;
+}
+
+function extractLocalizedInstructions(instructions: string): {
+ defaultInstructions: string;
+ localizedInstructions?: Partial>;
+} {
+ const localized: Partial> = {};
+ let defaultInstructions = instructions;
+
+ for (const locale of ['en', 'ko'] as const) {
+ const pattern = new RegExp(`([\\s\\S]*?)`, 'g');
+ const parts: string[] = [];
+ defaultInstructions = defaultInstructions.replace(pattern, (_match, body: string) => {
+ parts.push(body.trim());
+ return '';
+ }).trim();
+ if (parts.length > 0) localized[locale] = parts.join('\n\n');
+ }
+
+ return {
+ defaultInstructions,
+ localizedInstructions: Object.keys(localized).length > 0 ? localized : undefined,
+ };
+}
+
function parseConfigRequirements(raw: unknown): SkillConfigRequirements | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const obj = raw as Record;
@@ -316,6 +398,7 @@ export function renderSkillFile(
}
}
if (manifest.updated_at) lines.push(`updated_at: ${manifest.updated_at}`);
+ if (manifest.content_hash) lines.push(`content_hash: ${manifest.content_hash}`);
lines.push('---');
lines.push('');
lines.push(instructions);
@@ -333,6 +416,67 @@ export interface SkillFileSystem {
joinPath(...segments: string[]): string;
}
+export interface LoadSkillsOptions {
+ /** Reject hash-mismatched skills instead of loading with `hashMismatch: true`. */
+ strict?: boolean;
+ /** Alias for `strict`, kept explicit for call sites that name the concern. */
+ strictHashes?: boolean;
+ /** Receives soft integrity warnings. Defaults to `console`. */
+ logger?: { warn(message: string): void };
+}
+
+export async function computeSkillInstructionsHash(instructions: string): Promise {
+ const subtle = globalThis.crypto?.subtle;
+ if (!subtle) {
+ throw new Error('Web Crypto API is not available; cannot verify skill content_hash');
+ }
+ const bytes = new TextEncoder().encode(instructions);
+ const digest = await subtle.digest('SHA-256', bytes);
+ const hex = [...new Uint8Array(digest)]
+ .map((byte) => byte.toString(16).padStart(2, '0'))
+ .join('');
+ return `sha256:${hex}`;
+}
+
+export async function verifySkillContentHash(parsed: ParsedSkillFile): Promise {
+ const expected = parsed.manifest.content_hash;
+ if (!expected) {
+ parsed.hashMismatch = false;
+ return parsed;
+ }
+ const actual = await computeSkillInstructionsHash(parsed.instructions);
+ parsed.hashMismatch = actual.toLowerCase() !== expected.toLowerCase();
+ return parsed;
+}
+
+async function loadParsedSkill(
+ content: string,
+ filePath: string,
+ options: LoadSkillsOptions
+): Promise {
+ const parsed = parseSkillFile(content, filePath);
+ if (!parsed) return null;
+ if (!parsed.manifest.content_hash) return parsed;
+
+ const logger = options.logger ?? console;
+ try {
+ await verifySkillContentHash(parsed);
+ } catch (error: unknown) {
+ parsed.hashMismatch = true;
+ logger.warn(
+ `Skill ${filePath} content_hash could not be verified: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+
+ if (parsed.hashMismatch) {
+ logger.warn(`Skill ${filePath} content_hash mismatch; expected ${parsed.manifest.content_hash}`);
+ if (options.strict ?? options.strictHashes) {
+ return null;
+ }
+ }
+ return parsed;
+}
+
/**
* Load all SKILL.md files from a directory using an injected filesystem.
* This keeps the core package runtime-agnostic (works in Node, Workers, etc.).
@@ -342,7 +486,8 @@ export interface SkillFileSystem {
*/
export async function loadSkillsFromDirectory(
dirPath: string,
- fs: SkillFileSystem
+ fs: SkillFileSystem,
+ options: LoadSkillsOptions = {}
): Promise {
const skills: ParsedSkillFile[] = [];
@@ -355,7 +500,7 @@ export async function loadSkillsFromDirectory(
const skillPath = fs.joinPath(dirPath, entry.name, 'SKILL.md');
try {
const content = await fs.readFile(skillPath);
- const parsed = parseSkillFile(content, skillPath);
+ const parsed = await loadParsedSkill(content, skillPath, options);
if (parsed) skills.push(parsed);
} catch {
/* no SKILL.md in this dir */
@@ -364,7 +509,7 @@ export async function loadSkillsFromDirectory(
// Also support flat .md files
const skillPath = fs.joinPath(dirPath, entry.name);
const content = await fs.readFile(skillPath);
- const parsed = parseSkillFile(content, skillPath);
+ const parsed = await loadParsedSkill(content, skillPath, options);
if (parsed) skills.push(parsed);
}
}
@@ -393,7 +538,9 @@ export function matchSkillManifests(
let score = 0;
// Trigger phrase match (highest weight)
- for (const trigger of skill.manifest.triggers) {
+ const localizedTriggers = Object.values(skill.manifest.i18n ?? {})
+ .flatMap((entry) => entry?.triggers ?? []);
+ for (const trigger of [...skill.manifest.triggers, ...localizedTriggers]) {
if (queryLower.includes(trigger.toLowerCase())) score += 10;
else if (trigger.toLowerCase().includes(queryLower)) score += 5;
}
@@ -402,7 +549,10 @@ export function matchSkillManifests(
if (queryLower.includes(skill.manifest.name.toLowerCase())) score += 8;
// Description word overlap
- const descWords = skill.manifest.description.toLowerCase().split(/\s+/);
+ const localizedDescriptions = Object.values(skill.manifest.i18n ?? {})
+ .map((entry) => entry?.description)
+ .filter((value): value is string => typeof value === 'string');
+ const descWords = [skill.manifest.description, ...localizedDescriptions].join(' ').toLowerCase().split(/\s+/);
for (const word of queryWords) {
if (descWords.includes(word)) score += 2;
}
diff --git a/packages/core/src/structured-compression.ts b/packages/core/src/structured-compression.ts
index d7420ed..5888c90 100644
--- a/packages/core/src/structured-compression.ts
+++ b/packages/core/src/structured-compression.ts
@@ -53,6 +53,7 @@ function extractPending(
// Unanswered user questions: user messages with '?' that have no subsequent assistant reply
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
+ if (!msg) continue;
if (msg.role !== 'user' || !msg.content.includes('?')) continue;
const globalIdx = allMessages.indexOf(msg);
@@ -68,6 +69,7 @@ function extractPending(
// Failed tool calls without a subsequent retry of the same tool
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
+ if (!msg) continue;
if (msg.role !== 'tool') continue;
if (msg.metadata?.ok !== false) continue;
diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts
new file mode 100644
index 0000000..05ed723
--- /dev/null
+++ b/packages/core/src/telemetry.ts
@@ -0,0 +1,20 @@
+export interface TelemetrySpan {
+ setAttribute(name: string, value: string | number | boolean): void;
+ spanContext?(): { traceId?: string; spanId?: string };
+ end(): void;
+}
+
+export interface TelemetryHooks {
+ startSpan(name: string, attributes?: Record): TelemetrySpan | null;
+ getActiveSpan?(): TelemetrySpan | null;
+}
+
+let hooks: TelemetryHooks | null = null;
+
+export function setTelemetryHooks(next: TelemetryHooks | null): void {
+ hooks = next;
+}
+
+export function getTelemetryHooks(): TelemetryHooks | null {
+ return hooks;
+}
diff --git a/packages/core/src/usage-tracker.ts b/packages/core/src/usage-tracker.ts
index baf7626..5911f5e 100644
--- a/packages/core/src/usage-tracker.ts
+++ b/packages/core/src/usage-tracker.ts
@@ -2,6 +2,8 @@
// DetailedUsageTracker — per-call usage tracking with cost estimation
// ---------------------------------------------------------------------------
+import { getTelemetryHooks } from './telemetry.js';
+
export interface UsageEntry {
timestamp: string;
model: string;
@@ -78,6 +80,13 @@ export class DetailedUsageTracker {
private entries: UsageEntry[] = [];
record(entry: Omit): void {
+ const activeSpan = getTelemetryHooks()?.getActiveSpan?.();
+ activeSpan?.setAttribute('llm.token_count.prompt', entry.inputTokens);
+ activeSpan?.setAttribute('llm.token_count.completion', entry.outputTokens);
+ activeSpan?.setAttribute('gen_ai.usage.cost', entry.costUsd);
+ activeSpan?.setAttribute('gen_ai.response.model', entry.model);
+ activeSpan?.setAttribute('gen_ai.system', entry.provider);
+
this.entries.push({
...entry,
timestamp: new Date().toISOString(),
diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts
index 1cac86a..6d55a68 100644
--- a/packages/core/src/usage.ts
+++ b/packages/core/src/usage.ts
@@ -108,6 +108,8 @@ export class UsageTracker {
}
const timestamps = sessionRecords.map(r => r.timestamp).sort();
+ const firstRequestAt = timestamps[0] ?? '';
+ const lastRequestAt = timestamps[timestamps.length - 1] ?? firstRequestAt;
return {
sessionId,
@@ -119,8 +121,8 @@ export class UsageTracker {
averageLatencyMs: totalLatency / sessionRecords.length,
modelBreakdown,
toolCallCount,
- firstRequestAt: timestamps[0],
- lastRequestAt: timestamps[timestamps.length - 1],
+ firstRequestAt,
+ lastRequestAt,
};
}
diff --git a/packages/gateway/package.json b/packages/gateway/package.json
index cacbe61..5e42ecc 100644
--- a/packages/gateway/package.json
+++ b/packages/gateway/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/gateway",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
diff --git a/packages/gateway/src/channel-registry.ts b/packages/gateway/src/channel-registry.ts
index f0a695d..e13bab0 100644
--- a/packages/gateway/src/channel-registry.ts
+++ b/packages/gateway/src/channel-registry.ts
@@ -174,6 +174,64 @@ export const slackChannel: ChannelAdapter = {
},
};
+export const whatsappChannel: ChannelAdapter = {
+ name: 'whatsapp',
+ displayName: 'WhatsApp',
+ normalizeInbound(payload: unknown): NormalizedChannelMessage | null {
+ const p = payload as Record;
+ const entry = Array.isArray(p.entry) ? p.entry[0] as Record | undefined : undefined;
+ const changes = Array.isArray(entry?.changes) ? entry?.changes[0] as Record | undefined : undefined;
+ const value = changes?.value as Record | undefined;
+ const metadata = value?.metadata as Record | undefined;
+ const message = Array.isArray(value?.messages) ? value?.messages[0] as Record | undefined : undefined;
+ const text = (message?.text as Record | undefined)?.body;
+ const channelId = metadata?.phone_number_id;
+ if (!message?.from || typeof text !== 'string' || !channelId) return null;
+ return {
+ platform: 'whatsapp',
+ channelId: String(channelId),
+ senderId: String(message.from),
+ text,
+ messageId: String(message.id ?? ''),
+ timestamp: message.timestamp ? new Date(Number(message.timestamp) * 1000).toISOString() : undefined,
+ raw: payload,
+ };
+ },
+ buildOutbound(channelId, text) {
+ return {
+ messaging_product: 'whatsapp',
+ to: channelId,
+ type: 'text',
+ text: { body: text },
+ };
+ },
+};
+
+export const signalChannel: ChannelAdapter = {
+ name: 'signal',
+ displayName: 'Signal',
+ normalizeInbound(payload: unknown): NormalizedChannelMessage | null {
+ const p = payload as Record;
+ const envelope = p.envelope as Record | undefined;
+ const dataMessage = envelope?.dataMessage as Record | undefined;
+ const text = dataMessage?.message;
+ const senderId = envelope?.sourceNumber ?? envelope?.sourceUuid;
+ if (typeof text !== 'string' || !senderId) return null;
+ return {
+ platform: 'signal',
+ channelId: String(senderId),
+ senderId: String(senderId),
+ text,
+ messageId: envelope?.timestamp ? String(envelope.timestamp) : undefined,
+ timestamp: envelope?.timestamp ? new Date(Number(envelope.timestamp)).toISOString() : undefined,
+ raw: payload,
+ };
+ },
+ buildOutbound(channelId, text) {
+ return { recipient: channelId, message: text };
+ },
+};
+
export const genericChannel: ChannelAdapter = {
name: 'generic',
displayName: 'Generic Webhook',
@@ -202,4 +260,6 @@ export const genericChannel: ChannelAdapter = {
channels.register(telegramChannel);
channels.register(discordChannel);
channels.register(slackChannel);
+channels.register(whatsappChannel);
+channels.register(signalChannel);
channels.register(genericChannel);
diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts
index d9a1b05..1fc57ff 100644
--- a/packages/gateway/src/index.ts
+++ b/packages/gateway/src/index.ts
@@ -1,5 +1,161 @@
export type GatewayPlatform = 'webhook' | 'telegram' | 'discord' | 'slack' | 'whatsapp' | 'signal' | 'email' | 'matrix' | 'sms';
+export type GatewayPolicyTier = 'restricted' | 'balanced' | 'open';
+
+export interface GatewayEndpointPolicy {
+ policyTier: GatewayPolicyTier;
+ /** Optional endpoint/path allowlist. Supports exact matches and trailing `*` prefixes. */
+ allowedEndpoints?: string[];
+ /** Optional protocol allowlist. Values may be `https` or `https:`. */
+ protocols?: string[];
+ /** Optional HTTP method allowlist. Values are normalized to uppercase. */
+ methods?: string[];
+ /** Optional pathname allowlist. Supports exact paths and trailing `*` prefixes. */
+ paths?: string[];
+}
+
+export interface GatewayEndpointPolicyDecision {
+ allowed: boolean;
+ reason:
+ | 'allowed'
+ | 'invalid-url'
+ | 'disallowed-protocol'
+ | 'disallowed-method'
+ | 'disallowed-path'
+ | 'unsafe-url';
+ policyTier: GatewayPolicyTier;
+ observability: {
+ event: 'gateway:endpoint_policy';
+ reason: GatewayEndpointPolicyDecision['reason'];
+ method: string;
+ protocol?: string;
+ path?: string;
+ policyTier: GatewayPolicyTier;
+ };
+}
+
+const TIER_PROTOCOLS: Record = {
+ restricted: ['https:'],
+ balanced: ['http:', 'https:'],
+ open: ['http:', 'https:'],
+};
+
+const TIER_METHODS: Record = {
+ restricted: ['GET', 'POST'],
+ balanced: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
+ open: null,
+};
+
+function normalizeProtocol(protocol: string): string {
+ return protocol.endsWith(':') ? protocol.toLowerCase() : `${protocol.toLowerCase()}:`;
+}
+
+function normalizeMethod(method: string): string {
+ return method.trim().toUpperCase();
+}
+
+function endpointMatchesPolicy(candidates: string[], patterns: string[]): boolean {
+ return patterns.some((pattern) => candidates.some((candidate) => {
+ if (pattern.endsWith('*')) return candidate.startsWith(pattern.slice(0, -1));
+ return candidate === pattern;
+ }));
+}
+
+export function createDefaultEndpointPolicy(policyTier: GatewayPolicyTier = 'balanced'): GatewayEndpointPolicy {
+ return { policyTier };
+}
+
+export function evaluateGatewayEndpointPolicy(
+ endpoint: { url: string; method?: string },
+ policy: GatewayEndpointPolicy = createDefaultEndpointPolicy(),
+): GatewayEndpointPolicyDecision {
+ const method = normalizeMethod(endpoint.method ?? 'GET');
+ let parsed: URL;
+ try {
+ parsed = new URL(endpoint.url);
+ } catch {
+ return {
+ allowed: false,
+ reason: 'invalid-url',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'invalid-url', method, policyTier: policy.policyTier },
+ };
+ }
+
+ const protocol = parsed.protocol;
+ const tierProtocols = TIER_PROTOCOLS[policy.policyTier];
+ const configuredProtocols = policy.protocols?.map(normalizeProtocol);
+ const allowedProtocols = configuredProtocols
+ ? configuredProtocols.filter((candidate) => tierProtocols.includes(candidate))
+ : tierProtocols;
+ if (!allowedProtocols.includes(protocol)) {
+ return {
+ allowed: false,
+ reason: 'disallowed-protocol',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'disallowed-protocol', method, protocol, path: parsed.pathname, policyTier: policy.policyTier },
+ };
+ }
+
+ const tierMethods = TIER_METHODS[policy.policyTier];
+ const configuredMethods = policy.methods?.map(normalizeMethod);
+ const allowedMethods = configuredMethods && tierMethods
+ ? configuredMethods.filter((candidate) => tierMethods.includes(candidate))
+ : configuredMethods ?? tierMethods;
+ if (allowedMethods && !allowedMethods.includes(method)) {
+ return {
+ allowed: false,
+ reason: 'disallowed-method',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'disallowed-method', method, protocol, path: parsed.pathname, policyTier: policy.policyTier },
+ };
+ }
+
+ const endpointPatterns = policy.allowedEndpoints ?? policy.paths;
+ const endpointCandidates = [
+ parsed.pathname,
+ `${parsed.origin}${parsed.pathname}`,
+ endpoint.url,
+ ];
+ if (endpointPatterns && !endpointMatchesPolicy(endpointCandidates, endpointPatterns)) {
+ return {
+ allowed: false,
+ reason: 'disallowed-path',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'disallowed-path', method, protocol, path: parsed.pathname, policyTier: policy.policyTier },
+ };
+ }
+
+ const urlSafety = validateFetchUrl(endpoint.url);
+ if (!urlSafety.safe) {
+ return {
+ allowed: false,
+ reason: 'unsafe-url',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'unsafe-url', method, protocol, path: parsed.pathname, policyTier: policy.policyTier },
+ };
+ }
+
+ return {
+ allowed: true,
+ reason: 'allowed',
+ policyTier: policy.policyTier,
+ observability: { event: 'gateway:endpoint_policy', reason: 'allowed', method, protocol, path: parsed.pathname, policyTier: policy.policyTier },
+ };
+}
+
+export type GatewayCallerScope = 'pairing' | 'operator' | 'owner';
+
+const TOKEN_SCOPE_RANK: Record = {
+ pairing: 0,
+ operator: 1,
+ owner: 2,
+};
+
+export function canMutateToken(callerScope: GatewayCallerScope, targetScope: GatewayCallerScope): boolean {
+ return TOKEN_SCOPE_RANK[callerScope] >= TOKEN_SCOPE_RANK[targetScope];
+}
+
// Inline URL safety check (gateway is zero-dep, cannot import from @crowclaw/core).
// Patterns kept in sync with `packages/core/src/security.ts` PRIVATE_IP_PATTERNS —
// update both when changing. IPv4-mapped IPv6, CGNAT, and multicast ranges included
@@ -599,6 +755,24 @@ export interface GatewayConfig {
* when neither model nor provider declared a timeout.
*/
globalRequestTimeoutMs?: number;
+ /**
+ * Issue #73: Policy tier used for outbound gateway HTTP endpoints.
+ * `restricted` limits protocols/methods most tightly; `balanced` is the
+ * default; `open` keeps SSRF checks but removes method restrictions.
+ */
+ policyTier?: GatewayPolicyTier;
+ /**
+ * Issue #73: Optional allowlist for outbound endpoint paths or full URLs.
+ * Exact matches and trailing `*` prefixes are supported.
+ */
+ allowedEndpoints?: string[];
+}
+
+export function resolveGatewayEndpointPolicy(config?: GatewayConfig): GatewayEndpointPolicy {
+ return {
+ policyTier: config?.policyTier ?? 'balanced',
+ ...(config?.allowedEndpoints ? { allowedEndpoints: config.allowedEndpoints } : {}),
+ };
}
/**
@@ -1691,12 +1865,20 @@ export async function sendTelegramMessage(
*/
export async function sendDiscordMessage(
webhookUrl: string,
- content: string
+ content: string,
+ options?: { endpointPolicy?: GatewayEndpointPolicy }
): Promise {
- // Validate webhook URL to prevent SSRF
- const urlCheck = validateFetchUrl(webhookUrl);
- if (!urlCheck.safe) {
- return { ok: false, platform: 'discord', error: `URL blocked: ${urlCheck.reason}` };
+ const endpointDecision = evaluateGatewayEndpointPolicy(
+ { url: webhookUrl, method: 'POST' },
+ options?.endpointPolicy ?? createDefaultEndpointPolicy('balanced'),
+ );
+ if (!endpointDecision.allowed) {
+ return {
+ ok: false,
+ platform: 'discord',
+ error: `Endpoint policy blocked: ${endpointDecision.reason}`,
+ raw: endpointDecision.observability,
+ };
}
const payload = buildDiscordSendPayload({ content });
@@ -2113,7 +2295,7 @@ export async function getTelegramWebhookInfo(
export const normalizeTelegramUpdate = normalizeTelegramWebhook;
-export { channels, type ChannelAdapter, type NormalizedChannelMessage, telegramChannel, discordChannel, slackChannel, genericChannel } from './channel-registry.js';
+export { channels, type ChannelAdapter, type NormalizedChannelMessage, telegramChannel, discordChannel, slackChannel, whatsappChannel, signalChannel, genericChannel } from './channel-registry.js';
export async function normalizeGatewayRequest(platform: GatewayPlatform, request: Request): Promise {
const payload = await request.json();
diff --git a/packages/gateway/src/platform-rate-limiter.ts b/packages/gateway/src/platform-rate-limiter.ts
index 8d6952f..f636152 100644
--- a/packages/gateway/src/platform-rate-limiter.ts
+++ b/packages/gateway/src/platform-rate-limiter.ts
@@ -33,7 +33,11 @@ export class PlatformRateLimiter {
*/
private pruneExpired(arr: number[], cutoff: number): void {
let i = 0;
- while (i < arr.length && arr[i] <= cutoff) i++;
+ while (i < arr.length) {
+ const value = arr[i];
+ if (value === undefined || value > cutoff) break;
+ i++;
+ }
if (i > 0) arr.splice(0, i);
}
diff --git a/packages/learning/package.json b/packages/learning/package.json
index b563bae..942698b 100644
--- a/packages/learning/package.json
+++ b/packages/learning/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/learning",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -17,7 +17,7 @@
"access": "public"
},
"dependencies": {
- "@crowclaw/core": "0.8.1"
+ "@crowclaw/core": "0.8.2"
},
"repository": {
"type": "git",
diff --git a/packages/learning/src/atropos-env.ts b/packages/learning/src/atropos-env.ts
new file mode 100644
index 0000000..5d6376b
--- /dev/null
+++ b/packages/learning/src/atropos-env.ts
@@ -0,0 +1,132 @@
+import type { TrajectoryEntry } from './trajectory.js';
+import { scoreTrajectory } from './trajectory-scorer.js';
+
+export interface AtroposEnvConfig {
+ baseUrl: string;
+ environment: string;
+ apiKey?: string;
+ fetch?: typeof fetch;
+ headers?: Record;
+}
+
+export interface AtroposPrompt {
+ id: string;
+ prompt: string;
+ metadata?: Record;
+}
+
+export interface AtroposRegistration {
+ environment: string;
+ ok: boolean;
+ raw?: unknown;
+}
+
+export interface AtroposRollout {
+ promptId: string;
+ prompt: string;
+ response: string;
+ reward?: number;
+ trajectory?: TrajectoryEntry;
+ metadata?: Record;
+}
+
+export interface AtroposSubmitResult {
+ ok: boolean;
+ raw?: unknown;
+}
+
+export type AtroposRewardFn = (trajectory: TrajectoryEntry) => number;
+
+export function defaultAtroposReward(trajectory: TrajectoryEntry): number {
+ return scoreTrajectory(trajectory).overall;
+}
+
+export class AtroposEnv {
+ private readonly baseUrl: string;
+ private readonly fetchImpl: typeof fetch;
+
+ constructor(private readonly config: AtroposEnvConfig) {
+ this.baseUrl = config.baseUrl.replace(/\/+$/, '');
+ this.fetchImpl = config.fetch ?? fetch;
+ }
+
+ async register(metadata: Record = {}): Promise {
+ const raw = await this.request('/register_environment', {
+ environment: this.config.environment,
+ metadata,
+ });
+ return { environment: this.config.environment, ok: true, raw };
+ }
+
+ async getBatch(count = 1): Promise {
+ const raw = await this.request('/get_batch', {
+ environment: this.config.environment,
+ count,
+ });
+ const prompts = Array.isArray((raw as { prompts?: unknown }).prompts)
+ ? (raw as { prompts: unknown[] }).prompts
+ : Array.isArray(raw)
+ ? raw as unknown[]
+ : [];
+ return prompts
+ .map((item, index) => normalizeAtroposPrompt(item, index))
+ .filter((prompt): prompt is AtroposPrompt => prompt !== null);
+ }
+
+ async fetchPrompt(): Promise {
+ return (await this.getBatch(1))[0] ?? null;
+ }
+
+ async submitRollout(rollout: AtroposRollout): Promise {
+ const raw = await this.request('/batch_completions', {
+ environment: this.config.environment,
+ completions: [{
+ prompt_id: rollout.promptId,
+ prompt: rollout.prompt,
+ response: rollout.response,
+ reward: rollout.reward ?? (rollout.trajectory ? defaultAtroposReward(rollout.trajectory) : undefined),
+ trajectory: rollout.trajectory,
+ metadata: rollout.metadata,
+ }],
+ });
+ return { ok: true, raw };
+ }
+
+ private async request(path: string, body: Record): Promise {
+ const headers: Record = {
+ 'content-type': 'application/json',
+ ...this.config.headers,
+ };
+ if (this.config.apiKey) {
+ headers.authorization = `Bearer ${this.config.apiKey}`;
+ }
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) {
+ throw new Error(`Atropos ${path} failed with ${response.status}`);
+ }
+ const text = await response.text();
+ return text ? JSON.parse(text) as unknown : {};
+ }
+}
+
+function normalizeAtroposPrompt(item: unknown, index: number): AtroposPrompt | null {
+ if (!item || typeof item !== 'object') return null;
+ const obj = item as Record;
+ const prompt = typeof obj.prompt === 'string'
+ ? obj.prompt
+ : typeof obj.text === 'string'
+ ? obj.text
+ : typeof obj.message === 'string'
+ ? obj.message
+ : '';
+ if (!prompt) return null;
+ return {
+ id: typeof obj.id === 'string' ? obj.id : typeof obj.prompt_id === 'string' ? obj.prompt_id : `atropos-${index}`,
+ prompt,
+ metadata: obj.metadata && typeof obj.metadata === 'object' ? obj.metadata as Record : undefined,
+ };
+}
diff --git a/packages/learning/src/batch-runner.ts b/packages/learning/src/batch-runner.ts
index ea8dba0..386db94 100644
--- a/packages/learning/src/batch-runner.ts
+++ b/packages/learning/src/batch-runner.ts
@@ -3,6 +3,7 @@ import type { ConversationMessage } from '@crowclaw/core';
export interface BatchPrompt {
id: string;
prompt: string;
+ expected?: BatchExpectedOutput;
metadata?: Record;
systemPrompt?: string;
agentPreset?: string;
@@ -11,6 +12,15 @@ export interface BatchPrompt {
model?: string;
}
+export type BatchExpectedOutput =
+ | string
+ | string[]
+ | {
+ equals?: string;
+ contains?: string | string[];
+ regex?: string;
+ };
+
export interface BatchRunConfig {
runName: string;
maxTurns?: number; // Max tool iterations per prompt (default: 8)
@@ -36,10 +46,17 @@ export interface BatchRunResult {
toolCalls: Array<{ toolName: string; ok: boolean; output: string }>;
messages: ConversationMessage[];
durationMs: number;
+ assertions?: BatchAssertionResult;
error?: string;
metadata?: Record;
}
+export interface BatchAssertionResult {
+ evaluated: boolean;
+ passed: boolean;
+ failures: string[];
+}
+
export interface BatchRunSummary {
runName: string;
startedAt: string;
@@ -50,6 +67,7 @@ export interface BatchRunSummary {
skipped: number;
totalDurationMs: number;
avgDurationMs: number;
+ accuracy?: number;
results: BatchRunResult[];
}
@@ -81,6 +99,7 @@ export function parseJsonlPrompts(jsonl: string): BatchPrompt[] {
id: (parsed.id as string) ?? `prompt-${idx}`,
prompt: (parsed.prompt as string) ?? (parsed.text as string) ?? (parsed.message as string) ?? '',
metadata: parsed.metadata as Record | undefined,
+ expected: (parsed.expected ?? parsed.expectedOutput) as BatchExpectedOutput | undefined,
systemPrompt: parsed.systemPrompt as string | undefined,
agentPreset: parsed.agentPreset as string | undefined,
toolset: parsed.toolset as string | undefined,
@@ -150,6 +169,8 @@ export async function runBatch(
clearTimeout(timer);
const durationMs = Date.now() - start;
+ const assertions = evaluateExpectedOutput(agentResult.finalResponse, prompt.expected);
+ const ok = assertions ? assertions.passed : true;
completed++;
config.onProgress?.({
@@ -162,11 +183,12 @@ export async function runBatch(
return {
promptId: prompt.id,
sessionId,
- ok: true,
+ ok,
response: agentResult.finalResponse,
toolCalls: agentResult.toolResults,
messages: agentResult.session.messages,
durationMs,
+ assertions,
metadata: prompt.metadata,
} satisfies BatchRunResult;
} catch (err: unknown) {
@@ -189,6 +211,7 @@ export async function runBatch(
toolCalls: [],
messages: [],
durationMs: Date.now() - start,
+ assertions: evaluateExpectedOutput('', prompt.expected),
error: msg,
metadata: prompt.metadata,
} satisfies BatchRunResult;
@@ -200,6 +223,10 @@ export async function runBatch(
const completedAt = new Date().toISOString();
const totalDurationMs = results.reduce((sum, r) => sum + r.durationMs, 0);
+ const evaluated = results.filter((result) => result.assertions?.evaluated);
+ const accuracy = evaluated.length > 0
+ ? Math.round((evaluated.filter((result) => result.assertions?.passed).length / evaluated.length) * 1000) / 1000
+ : undefined;
return {
runName: config.runName,
@@ -211,6 +238,58 @@ export async function runBatch(
skipped,
totalDurationMs,
avgDurationMs: results.length > 0 ? Math.round(totalDurationMs / results.length) : 0,
+ accuracy,
results,
};
}
+
+function normalizeForComparison(value: string): string {
+ return value.trim().replace(/\s+/g, ' ').toLowerCase();
+}
+
+export function evaluateExpectedOutput(
+ response: string,
+ expected?: BatchExpectedOutput
+): BatchAssertionResult | undefined {
+ if (expected === undefined) return undefined;
+ const failures: string[] = [];
+ const normalizedResponse = normalizeForComparison(response);
+
+ const requireContains = (needle: string): void => {
+ if (!normalizedResponse.includes(normalizeForComparison(needle))) {
+ failures.push(`missing expected text: ${needle}`);
+ }
+ };
+
+ if (typeof expected === 'string') {
+ requireContains(expected);
+ } else if (Array.isArray(expected)) {
+ for (const item of expected) requireContains(item);
+ } else {
+ if (expected.equals !== undefined && normalizedResponse !== normalizeForComparison(expected.equals)) {
+ failures.push('response did not equal expected output');
+ }
+ const contains = expected.contains;
+ if (typeof contains === 'string') {
+ requireContains(contains);
+ } else if (Array.isArray(contains)) {
+ for (const item of contains) requireContains(item);
+ }
+ if (expected.regex !== undefined) {
+ try {
+ const regex = new RegExp(expected.regex, 'i');
+ if (!regex.test(response)) {
+ failures.push(`response did not match regex: ${expected.regex}`);
+ }
+ } catch {
+ failures.push(`invalid expected regex: ${expected.regex}`);
+ }
+ }
+ }
+
+ return {
+ evaluated: true,
+ passed: failures.length === 0,
+ failures,
+ };
+}
diff --git a/packages/learning/src/index.ts b/packages/learning/src/index.ts
index 9b598d5..7c87955 100644
--- a/packages/learning/src/index.ts
+++ b/packages/learning/src/index.ts
@@ -530,8 +530,11 @@ export class LearningPipeline {
}
export {
+ evaluateExpectedOutput,
parseJsonlPrompts,
runBatch,
+ type BatchAssertionResult,
+ type BatchExpectedOutput,
type BatchPrompt,
type BatchRunConfig,
type BatchProgress,
@@ -578,6 +581,17 @@ export {
rankByScore,
} from './rl-export.js';
+export {
+ AtroposEnv,
+ defaultAtroposReward,
+ type AtroposEnvConfig,
+ type AtroposPrompt,
+ type AtroposRegistration,
+ type AtroposRewardFn,
+ type AtroposRollout,
+ type AtroposSubmitResult,
+} from './atropos-env.js';
+
export {
SkillMetricsTracker,
type SkillUsageRecord,
diff --git a/packages/learning/src/trajectory-compressor.ts b/packages/learning/src/trajectory-compressor.ts
index 1e6b9e8..a5d6656 100644
--- a/packages/learning/src/trajectory-compressor.ts
+++ b/packages/learning/src/trajectory-compressor.ts
@@ -65,6 +65,9 @@ function compressKeyTurns(turns: TrajectoryTurn[]): TrajectoryTurn[] {
for (let i = 0; i < turns.length; i++) {
const turn = turns[i];
+ if (!turn) {
+ continue;
+ }
// Always keep user messages
if (turn.role === 'user') {
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index a078038..f3718a2 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/mcp-server",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -17,7 +17,7 @@
"access": "public"
},
"dependencies": {
- "@crowclaw/core": "0.8.1"
+ "@crowclaw/core": "0.8.2"
},
"devDependencies": {
"@types/node": "^22.0.0"
diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts
index 5ad6169..7b68da8 100644
--- a/packages/mcp-server/src/index.ts
+++ b/packages/mcp-server/src/index.ts
@@ -1,5 +1,6 @@
import { createInterface } from 'node:readline';
import { timingSafeEqual } from 'node:crypto';
+import type { SessionState, ToolCatalog } from '@crowclaw/core';
// ---------------------------------------------------------------------------
// MCP protocol types
@@ -95,6 +96,16 @@ export interface CrowClawMcpServerOptions {
* exposing the bridge to remote clients MUST set this.
*/
ownerToken?: string;
+ sessionStore?: {
+ listRecent?(limit?: number): Promise;
+ list?(): Promise;
+ get?(sessionId: string): Promise;
+ };
+ memoryStore?: {
+ search?(sessionId: string, query: string, limit?: number): Promise;
+ searchByScope?(scope: 'session' | 'user' | 'workspace', query: string, limit?: number, scopeKey?: string): Promise;
+ };
+ toolCatalog?: ToolCatalog;
}
export class CrowClawMcpServer {
@@ -305,6 +316,26 @@ export class CrowClawMcpServer {
}
case 'crowclaw.sessions.list':
+ if (this.options?.sessionStore?.listRecent || this.options?.sessionStore?.list) {
+ const sessions = this.options.sessionStore.listRecent
+ ? await this.options.sessionStore.listRecent(50)
+ : await this.options.sessionStore.list!();
+ return this.respondOk(id, {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify({
+ sessions: sessions.map((session) => ({
+ sessionId: session.sessionId,
+ agentId: session.agentId,
+ messageCount: session.messages.length,
+ updatedAt: session.updatedAt,
+ })),
+ }, null, 2),
+ },
+ ],
+ });
+ }
return this.respondOk(id, {
content: [
{
@@ -323,6 +354,27 @@ export class CrowClawMcpServer {
'crowclaw.sessions.get requires sessionId (string)',
);
}
+ if (this.options?.sessionStore?.get) {
+ const session = await this.options.sessionStore.get(sessionId);
+ return this.respondOk(id, {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify(session ? {
+ sessionId: session.sessionId,
+ agentId: session.agentId,
+ updatedAt: session.updatedAt,
+ messages: session.messages.map((message) => ({
+ role: message.role,
+ content: message.content,
+ createdAt: message.createdAt,
+ name: message.name,
+ })),
+ } : { sessionId, found: false, messages: [] }, null, 2),
+ },
+ ],
+ });
+ }
return this.respondOk(id, {
content: [
{
@@ -345,7 +397,11 @@ export class CrowClawMcpServer {
{
type: 'text',
text: JSON.stringify(
- { tools: this.getVisibleTools(callerToken).map((t) => t.name) },
+ {
+ tools: this.options?.toolCatalog
+ ? this.options.toolCatalog.list().map((t) => t.name)
+ : this.getVisibleTools(callerToken).map((t) => t.name),
+ },
null,
2,
),
@@ -362,6 +418,21 @@ export class CrowClawMcpServer {
'crowclaw.memories.search requires query (string)',
);
}
+ if (this.options?.memoryStore?.searchByScope || this.options?.memoryStore?.search) {
+ const limit = typeof args['limit'] === 'number' ? args['limit'] : 10;
+ const sessionId = typeof args['sessionId'] === 'string' ? args['sessionId'] : '';
+ const results = this.options.memoryStore.searchByScope
+ ? await this.options.memoryStore.searchByScope('session', query, limit)
+ : await this.options.memoryStore.search!(sessionId, query, limit);
+ return this.respondOk(id, {
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify({ query, results }, null, 2),
+ },
+ ],
+ });
+ }
return this.respondOk(id, {
content: [
{
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index d1e1177..a3dae2e 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/mcp",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -17,7 +17,7 @@
"access": "public"
},
"dependencies": {
- "@crowclaw/sandbox-executor": "0.8.1"
+ "@crowclaw/sandbox-executor": "0.8.2"
},
"devDependencies": {
"@types/node": "^22.0.0"
diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts
index 6a01b13..bdb2d17 100644
--- a/packages/mcp/src/index.ts
+++ b/packages/mcp/src/index.ts
@@ -487,6 +487,9 @@ export class MultiServerMcpManager {
async callTool(name: string, arguments_: Record): Promise {
const [serverName, ...rest] = name.split('.');
+ if (!serverName) {
+ throw new Error('Invalid MCP tool name.');
+ }
const client = this.servers[serverName];
if (!client) {
throw new Error(`Unknown MCP server: ${serverName}`);
diff --git a/packages/memory/examples/honcho-compatible.ts b/packages/memory/examples/honcho-compatible.ts
new file mode 100644
index 0000000..70a98a9
--- /dev/null
+++ b/packages/memory/examples/honcho-compatible.ts
@@ -0,0 +1,44 @@
+import { createMemoryBackendPlugin } from '@crowclaw/plugins';
+
+export interface HonchoCompatibleClient {
+ search(input: {
+ sessionId: string;
+ query: string;
+ limit: number;
+ scope?: string;
+ scopeKey?: string;
+ }): Promise;
+ remember(record: Record): Promise;
+ forget(id: string): Promise;
+ list(input: { sessionId: string; scope?: string; limit?: number }): Promise;
+ syncTurn?(sessionId: string, summary: string, metadata?: Record): Promise;
+ close?(): Promise;
+}
+
+export function createHonchoCompatibleMemoryPlugin(client: HonchoCompatibleClient) {
+ return createMemoryBackendPlugin({
+ name: 'honcho-compatible-memory',
+ version: '0.1.0',
+ description: 'Reference adapter for a Honcho-compatible external memory backend.',
+ provider: {
+ recall(sessionId, query, limit, scope, scopeKey) {
+ return client.search({ sessionId, query, limit, scope, scopeKey });
+ },
+ store(record) {
+ return client.remember(record);
+ },
+ delete(id) {
+ return client.forget(id);
+ },
+ list(sessionId, scope, limit) {
+ return client.list({ sessionId, scope, limit });
+ },
+ sync_turn(sessionId, summary, metadata) {
+ return client.syncTurn?.(sessionId, summary, metadata) ?? Promise.resolve();
+ },
+ shutdown() {
+ return client.close?.() ?? Promise.resolve();
+ },
+ },
+ });
+}
diff --git a/packages/memory/package.json b/packages/memory/package.json
index 6d17a10..ea7ae80 100644
--- a/packages/memory/package.json
+++ b/packages/memory/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/memory",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -11,14 +11,15 @@
}
},
"files": [
- "dist"
+ "dist",
+ "examples"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
- "@crowclaw/core": "0.8.1",
- "@crowclaw/storage": "0.8.1"
+ "@crowclaw/core": "0.8.2",
+ "@crowclaw/storage": "0.8.2"
},
"repository": {
"type": "git",
diff --git a/packages/memory/src/dream-memory.ts b/packages/memory/src/dream-memory.ts
index 98d3e33..7b49b2e 100644
--- a/packages/memory/src/dream-memory.ts
+++ b/packages/memory/src/dream-memory.ts
@@ -64,6 +64,14 @@ export class InMemoryDreamStore implements DreamMemoryStore {
const chunkSize = Math.max(1, Math.ceil(liveEntries.length / maxEntries));
for (let i = 0; i < liveEntries.length; i += chunkSize) {
const chunk = liveEntries.slice(i, i + chunkSize);
+ const firstEntry = chunk[0];
+ if (!firstEntry) {
+ continue;
+ }
+ const firstLiveEntry = firstEntry[1];
+ if (!firstLiveEntry) {
+ continue;
+ }
const sessionIds = chunk.map(([id]) => id);
const mergedContent = chunk
.map(([, entry]) => entry.summary)
@@ -74,7 +82,7 @@ export class InMemoryDreamStore implements DreamMemoryStore {
content: mergedContent,
source: 'consolidation',
sourceSessionIds: sessionIds,
- createdAt: chunk[0][1].createdAt,
+ createdAt: firstLiveEntry.createdAt,
consolidatedAt: new Date().toISOString(),
};
diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts
index 374f972..c6a0de6 100644
--- a/packages/memory/src/index.ts
+++ b/packages/memory/src/index.ts
@@ -87,7 +87,22 @@ export class MemoryService {
return null;
}
- const note = this.summarize(messages, scope);
+ const fallbackNote = this.summarize(messages, scope);
+ let semanticSummary = '';
+ if (this.provider?.llmSummarize) {
+ try {
+ semanticSummary = (await this.provider.llmSummarize(messages)).trim();
+ } catch {
+ semanticSummary = '';
+ }
+ }
+ const note: MemoryNote = semanticSummary
+ ? {
+ ...fallbackNote,
+ summary: semanticSummary,
+ tags: uniqueTags([...fallbackNote.tags, 'semantic-summary']),
+ }
+ : fallbackNote;
const record: MemoryRecord = {
id: crypto.randomUUID(),
sessionId,
@@ -380,5 +395,5 @@ export {
// v0.8.0 Hermes parity (#233) — pluggable MemoryProvider ABC.
export type { MemoryProvider } from './provider.js';
export type { MemoryScope } from './types.js';
-export { InMemoryMemoryProvider } from './provider.js';
+export { InMemoryMemoryProvider, PluginMemoryProvider, memoryProviderFromPluginRegistry } from './provider.js';
export type { MemoryRecord as ProviderMemoryRecord, ConversationMessage as ProviderConversationMessage } from './types.js';
diff --git a/packages/memory/src/memory-manager.ts b/packages/memory/src/memory-manager.ts
index 11c07b5..5354a02 100644
--- a/packages/memory/src/memory-manager.ts
+++ b/packages/memory/src/memory-manager.ts
@@ -4,6 +4,8 @@ import type {
MemoryRecord,
SessionTranscriptMessage,
} from './memory-provider.js';
+import type { DreamMemoryStore } from './dream-memory.js';
+import type { MemoryScope } from './types.js';
/**
* Per-provider outcome reported by `MemoryManager.shutdown`. Hosts can log
@@ -30,6 +32,15 @@ export interface SessionEndResult {
*/
export const SKIP_REDACTION_FLAG = '__skipRedaction';
+export interface MemoryManagerEventSink {
+ emit(type: 'memory:scoped_write', data: Record): void;
+}
+
+export interface MemoryManagerOptions {
+ eventBus?: MemoryManagerEventSink;
+ dreamMemory?: DreamMemoryStore;
+}
+
/**
* Orchestrates multiple MemoryProviders, fanning out writes to all backends
* and merging results on recall. Deduplication is key-based: when multiple
@@ -46,6 +57,13 @@ export const SKIP_REDACTION_FLAG = '__skipRedaction';
*/
export class MemoryManager {
private providers: MemoryProvider[] = [];
+ private readonly eventBus?: MemoryManagerEventSink;
+ private readonly dreamMemory?: DreamMemoryStore;
+
+ constructor(options: MemoryManagerOptions = {}) {
+ this.eventBus = options.eventBus;
+ this.dreamMemory = options.dreamMemory;
+ }
addProvider(provider: MemoryProvider): void {
this.providers.push(provider);
@@ -57,14 +75,23 @@ export class MemoryManager {
* `metadata[SKIP_REDACTION_FLAG] = true`. The opt-out flag is stripped
* before being persisted.
*/
- async store(key: string, content: string, metadata?: Record): Promise {
+ async store(
+ key: string,
+ content: string,
+ metadata?: Record,
+ scopeArg?: MemoryScope
+ ): Promise {
+ const metadataScope = typeof metadata?.scope === 'string' && isMemoryScope(metadata.scope)
+ ? metadata.scope
+ : undefined;
+ const scope = scopeArg ?? metadataScope;
const skipRedaction = metadata?.[SKIP_REDACTION_FLAG] === true;
// Always strip the opt-out flag — it's a routing hint, not data we
// want sitting in a memory backend (and would leak across providers).
let cleanedMetadata: Record | undefined = metadata;
- if (metadata && SKIP_REDACTION_FLAG in metadata) {
- const { [SKIP_REDACTION_FLAG]: _drop, ...rest } = metadata;
+ if (metadata && (SKIP_REDACTION_FLAG in metadata || metadataScope)) {
+ const { [SKIP_REDACTION_FLAG]: _drop, scope: _scope, ...rest } = metadata;
cleanedMetadata = Object.keys(rest).length > 0 ? rest : undefined;
}
@@ -80,8 +107,18 @@ export class MemoryManager {
: undefined;
}
+ const providers = this.providers.filter((provider) => acceptsScope(provider, scope));
await Promise.all(
- this.providers.map((provider) => provider.store(key, safeContent, safeMetadata))
+ providers.map(async (provider) => {
+ await provider.store(key, safeContent, safeMetadata, scope);
+ if (scope) {
+ this.eventBus?.emit('memory:scoped_write', {
+ provider: provider.name,
+ key,
+ scope,
+ });
+ }
+ })
);
}
@@ -90,9 +127,11 @@ export class MemoryManager {
* When duplicates exist, the record with the highest score wins;
* ties are broken by most recent createdAt.
*/
- async recall(query: string, limit = 10): Promise {
+ async recall(query: string, limit = 10, scope?: MemoryScope): Promise {
const allResults = await Promise.all(
- this.providers.map((provider) => provider.recall(query, limit))
+ this.providers
+ .filter((provider) => acceptsScope(provider, scope))
+ .map((provider) => provider.recall(query, limit, scope))
);
const merged = allResults.flat();
@@ -121,7 +160,7 @@ export class MemoryManager {
// the issue is observable in logs.
messages = [];
}
- return Promise.all(
+ const providerResults = await Promise.all(
this.providers.map(async (provider): Promise => {
if (typeof provider.onSessionEnd !== 'function') {
return { provider: provider.name, invoked: false, ok: true };
@@ -135,6 +174,13 @@ export class MemoryManager {
}
}),
);
+ const dreamStores = new Set();
+ if (this.dreamMemory) dreamStores.add(this.dreamMemory);
+ for (const provider of this.providers) {
+ if (provider.dreamMemory) dreamStores.add(provider.dreamMemory);
+ }
+ await Promise.all([...dreamStores].map((dream) => dream.consolidate()));
+ return providerResults;
}
/** Remove a key from ALL providers. Returns true if at least one provider removed it. */
@@ -176,3 +222,14 @@ export class MemoryManager {
return candidate.createdAt > existing.createdAt;
}
}
+
+function isMemoryScope(value: string): value is MemoryScope {
+ return value === 'session' || value === 'user' || value === 'workspace';
+}
+
+function acceptsScope(provider: MemoryProvider, scope?: MemoryScope): boolean {
+ if (!scope || !provider.acceptedScopes || provider.acceptedScopes.length === 0) {
+ return true;
+ }
+ return provider.acceptedScopes.includes(scope);
+}
diff --git a/packages/memory/src/memory-provider.ts b/packages/memory/src/memory-provider.ts
index cc327b1..24e94ae 100644
--- a/packages/memory/src/memory-provider.ts
+++ b/packages/memory/src/memory-provider.ts
@@ -1,6 +1,8 @@
import type { MemoryRecord as StorageMemoryRecord, MemoryStore } from '@crowclaw/storage';
import type { EmbeddingMemoryStoreOptions } from './embedding-store.js';
import { EmbeddingMemoryStore } from './embedding-store.js';
+import type { DreamMemoryStore } from './dream-memory.js';
+import type { MemoryScope } from './types.js';
/**
* A simplified memory record returned by the MemoryProvider abstraction.
@@ -41,8 +43,12 @@ export interface SessionTranscriptMessage {
*/
export interface MemoryProvider {
name: string;
- store(key: string, content: string, metadata?: Record): Promise;
- recall(query: string, limit?: number): Promise;
+ /** Scopes this backend accepts. Omitted means all scopes for backward compatibility. */
+ acceptedScopes?: MemoryScope[];
+ dreamMemory?: DreamMemoryStore;
+ llmSummarize?: (messages: SessionTranscriptMessage[]) => Promise;
+ store(key: string, content: string, metadata?: Record, scope?: MemoryScope): Promise;
+ recall(query: string, limit?: number, scope?: MemoryScope): Promise;
forget(key: string): Promise;
/**
* Optional end-of-session hook. The host calls this with the full
@@ -69,6 +75,28 @@ function toMemoryRecord(record: StorageMemoryRecord): MemoryRecord {
};
}
+function uniqueTags(values: string[]): string[] {
+ return [...new Set(values.filter(Boolean).map((value) => value.toLowerCase()))];
+}
+
+function summarizeTranscript(messages: SessionTranscriptMessage[]): { summary: string; tags: string[] } {
+ const recentText = messages
+ .slice(-8)
+ .map((message) => message.content)
+ .join(' ')
+ .trim();
+ const tags = uniqueTags(
+ recentText
+ .split(/\W+/)
+ .filter((token) => token.length >= 4)
+ .slice(0, 8)
+ );
+ return {
+ summary: `Recent activity: ${recentText.slice(0, 400)}`,
+ tags,
+ };
+}
+
// ---------------------------------------------------------------------------
// BuiltInMemoryProvider
// ---------------------------------------------------------------------------
@@ -79,24 +107,34 @@ function toMemoryRecord(record: StorageMemoryRecord): MemoryRecord {
*/
export class BuiltInMemoryProvider implements MemoryProvider {
readonly name: string;
+ readonly acceptedScopes?: MemoryScope[];
private readonly memoryStore: MemoryStore;
private readonly sessionId: string;
/** Track stored ids so forget() can locate records. */
private readonly storedIds = new Map();
+ llmSummarize?: (messages: SessionTranscriptMessage[]) => Promise;
- constructor(memoryStore: MemoryStore, name = 'built-in', sessionId = DEFAULT_SESSION_ID) {
+ constructor(
+ memoryStore: MemoryStore,
+ name = 'built-in',
+ sessionId = DEFAULT_SESSION_ID,
+ options: { acceptedScopes?: MemoryScope[]; llmSummarize?: (messages: SessionTranscriptMessage[]) => Promise } = {}
+ ) {
this.memoryStore = memoryStore;
this.name = name;
this.sessionId = sessionId;
+ this.acceptedScopes = options.acceptedScopes;
+ this.llmSummarize = options.llmSummarize;
}
- async store(key: string, content: string, metadata?: Record): Promise {
+ async store(key: string, content: string, metadata?: Record, scope: MemoryScope = 'session'): Promise {
const id = crypto.randomUUID();
this.storedIds.set(key, id);
const record: StorageMemoryRecord = {
id,
sessionId: this.sessionId,
- scope: 'session',
+ scope,
+ scopeKey: typeof metadata?.scopeKey === 'string' ? metadata.scopeKey : undefined,
summary: content,
tags: [key],
createdAt: new Date().toISOString(),
@@ -105,11 +143,38 @@ export class BuiltInMemoryProvider implements MemoryProvider {
await this.memoryStore.write(record);
}
- async recall(query: string, limit = 10): Promise {
- const results = await this.memoryStore.search(this.sessionId, query, limit);
+ async recall(query: string, limit = 10, scope?: MemoryScope): Promise {
+ const results = scope
+ ? await this.memoryStore.searchByScope(scope, query, limit)
+ : await this.memoryStore.search(this.sessionId, query, limit);
return results.map(toMemoryRecord);
}
+ async onSessionEnd(sessionId: string, messages: SessionTranscriptMessage[]): Promise {
+ if (messages.length === 0) return;
+ const fallback = summarizeTranscript(messages);
+ let semanticSummary = '';
+ if (this.llmSummarize) {
+ try {
+ semanticSummary = (await this.llmSummarize(messages)).trim();
+ } catch {
+ semanticSummary = '';
+ }
+ }
+ const summary = semanticSummary || fallback.summary;
+ const tags = semanticSummary ? uniqueTags([...fallback.tags, 'semantic-summary']) : fallback.tags;
+ const record: StorageMemoryRecord = {
+ id: crypto.randomUUID(),
+ sessionId,
+ scope: 'session',
+ summary,
+ tags,
+ createdAt: new Date().toISOString(),
+ metadata: { messages: messages.length, source: semanticSummary ? 'llm' : 'local' },
+ };
+ await this.memoryStore.write(record);
+ }
+
async forget(key: string): Promise {
// The underlying MemoryStore interface does not expose a delete method,
// so we write a tombstone record that marks the key as forgotten.
@@ -142,23 +207,31 @@ export class BuiltInMemoryProvider implements MemoryProvider {
*/
export class EmbeddingMemoryProvider implements MemoryProvider {
readonly name: string;
+ readonly acceptedScopes?: MemoryScope[];
private readonly embeddingStore: EmbeddingMemoryStore;
private readonly sessionId: string;
private readonly storedIds = new Map();
- constructor(options: EmbeddingMemoryStoreOptions, name = 'embedding', sessionId = DEFAULT_SESSION_ID) {
+ constructor(
+ options: EmbeddingMemoryStoreOptions,
+ name = 'embedding',
+ sessionId = DEFAULT_SESSION_ID,
+ acceptedScopes?: MemoryScope[]
+ ) {
this.embeddingStore = new EmbeddingMemoryStore(options);
this.name = name;
this.sessionId = sessionId;
+ this.acceptedScopes = acceptedScopes;
}
- async store(key: string, content: string, metadata?: Record): Promise {
+ async store(key: string, content: string, metadata?: Record, scope: MemoryScope = 'session'): Promise {
const id = crypto.randomUUID();
this.storedIds.set(key, id);
const record: StorageMemoryRecord = {
id,
sessionId: this.sessionId,
- scope: 'session',
+ scope,
+ scopeKey: typeof metadata?.scopeKey === 'string' ? metadata.scopeKey : undefined,
summary: content,
tags: [key],
createdAt: new Date().toISOString(),
@@ -167,8 +240,10 @@ export class EmbeddingMemoryProvider implements MemoryProvider {
await this.embeddingStore.write(record);
}
- async recall(query: string, limit = 10): Promise {
- const results = await this.embeddingStore.search(this.sessionId, query, limit);
+ async recall(query: string, limit = 10, scope?: MemoryScope): Promise {
+ const results = scope
+ ? await this.embeddingStore.searchByScope(scope, query, limit)
+ : await this.embeddingStore.search(this.sessionId, query, limit);
return results.map(toMemoryRecord);
}
diff --git a/packages/memory/src/provider.ts b/packages/memory/src/provider.ts
index 3388918..bc05720 100644
--- a/packages/memory/src/provider.ts
+++ b/packages/memory/src/provider.ts
@@ -83,10 +83,121 @@ export interface MemoryProvider {
messages: ConversationMessage[]
): Promise;
+ /**
+ * Optional semantic session summarizer. Hosts wire this to the active LLM
+ * provider when explicitly enabled; providers fall back to the deterministic
+ * local summarizer when this is absent or returns an empty string.
+ */
+ llmSummarize?(messages: ConversationMessage[]): Promise;
+
/** Graceful shutdown. Wait up to 10s for in-flight sync_turn calls. */
shutdown?(): Promise;
}
+interface MemoryBackendProviderLike {
+ recall(sessionId: string, query: string, limit: number, scope?: string, scopeKey?: string): Promise;
+ store(record: Record): Promise;
+ delete(id: string): Promise;
+ list(sessionId: string, scope?: string, limit?: number): Promise;
+ init?(config?: Record): Promise;
+ prefetch?(sessionId: string, query: string, limit: number): Promise;
+ sync_turn?(sessionId: string, summary: string, metadata?: Record): Promise;
+ shutdown?(): Promise;
+}
+
+interface MemoryBackendPluginLike {
+ name?: string;
+ kind?: string;
+ manifest?: { memoryBackend?: boolean; name?: string };
+ provider?: MemoryBackendProviderLike;
+}
+
+export interface MemoryPluginRegistryLike {
+ list(): unknown[];
+}
+
+function isMemoryBackendProvider(value: unknown): value is MemoryBackendProviderLike {
+ if (!value || typeof value !== 'object') return false;
+ const provider = value as Partial>;
+ return typeof provider.recall === 'function'
+ && typeof provider.store === 'function'
+ && typeof provider.delete === 'function'
+ && typeof provider.list === 'function';
+}
+
+function isMemoryBackendPlugin(value: unknown): value is MemoryBackendPluginLike & { provider: MemoryBackendProviderLike } {
+ if (!value || typeof value !== 'object') return false;
+ const plugin = value as MemoryBackendPluginLike;
+ return plugin.kind === 'memory-backend'
+ && plugin.manifest?.memoryBackend === true
+ && isMemoryBackendProvider(plugin.provider);
+}
+
+/**
+ * Adapts a registered `MemoryBackendPlugin` from the plugin registry to the
+ * canonical `MemoryProvider` consumed by `MemoryService` and runtime hosts.
+ *
+ * The memory package intentionally keeps this structural so it can consume the
+ * registry without depending on `@crowclaw/plugins` and reintroducing a package
+ * layering cycle.
+ */
+export class PluginMemoryProvider implements MemoryProvider {
+ readonly name: string;
+ private readonly backend: MemoryBackendProviderLike;
+
+ constructor(plugin: MemoryBackendPluginLike & { provider: MemoryBackendProviderLike }) {
+ this.name = plugin.manifest?.name ?? plugin.name ?? 'memory-backend-plugin';
+ this.backend = plugin.provider;
+ }
+
+ async init(config?: Record): Promise {
+ await this.backend.init?.(config);
+ }
+
+ async prefetch(sessionId: string, query: string, limit: number): Promise {
+ if (!this.backend.prefetch) {
+ return this.recall(sessionId, query, limit);
+ }
+ return this.backend.prefetch(sessionId, query, limit) as Promise;
+ }
+
+ async recall(
+ sessionId: string,
+ query: string,
+ limit: number,
+ scope?: MemoryScope,
+ scopeKey?: string
+ ): Promise {
+ return this.backend.recall(sessionId, query, limit, scope, scopeKey) as Promise;
+ }
+
+ async sync_turn(sessionId: string, summary: string, metadata?: Record): Promise {
+ await this.backend.sync_turn?.(sessionId, summary, metadata);
+ }
+
+ async store(record: Omit): Promise {
+ const stored = await this.backend.store(record as Record);
+ return stored as MemoryRecord;
+ }
+
+ async delete(id: string): Promise {
+ return this.backend.delete(id);
+ }
+
+ async list(sessionId: string, scope?: MemoryScope, limit?: number): Promise {
+ return this.backend.list(sessionId, scope, limit) as Promise;
+ }
+
+ async shutdown(): Promise {
+ await this.backend.shutdown?.();
+ }
+}
+
+export function memoryProviderFromPluginRegistry(registry?: MemoryPluginRegistryLike): MemoryProvider | undefined {
+ const plugin = registry?.list().find(isMemoryBackendPlugin);
+ return plugin ? new PluginMemoryProvider(plugin) : undefined;
+}
+
// ---------------------------------------------------------------------------
// InMemoryMemoryProvider — default backend
// ---------------------------------------------------------------------------
@@ -155,9 +266,11 @@ function isExpired(record: MemoryRecord): boolean {
export class InMemoryMemoryProvider implements MemoryProvider {
private readonly memoryStore: MemoryStore;
private readonly inFlight = new Set>();
+ llmSummarize?: (messages: ConversationMessage[]) => Promise;
- constructor(memoryStore: MemoryStore) {
+ constructor(memoryStore: MemoryStore, options: { llmSummarize?: (messages: ConversationMessage[]) => Promise } = {}) {
this.memoryStore = memoryStore;
+ this.llmSummarize = options.llmSummarize;
}
async recall(
@@ -262,7 +375,18 @@ export class InMemoryMemoryProvider implements MemoryProvider {
messages: ConversationMessage[]
): Promise {
if (messages.length === 0) return null;
- const record = summarizeTranscript(messages, 'session', sessionId);
+ const fallback = summarizeTranscript(messages, 'session', sessionId);
+ let llmSummary = '';
+ if (this.llmSummarize) {
+ try {
+ llmSummary = (await this.llmSummarize(messages)).trim();
+ } catch {
+ llmSummary = '';
+ }
+ }
+ const record = llmSummary
+ ? { ...fallback, summary: llmSummary, tags: uniqueTags([...fallback.tags, 'semantic-summary']) }
+ : fallback;
await this.memoryStore.write(record);
return record;
}
diff --git a/packages/plugins/examples/auto-redact-pii.ts b/packages/plugins/examples/auto-redact-pii.ts
new file mode 100644
index 0000000..df85164
--- /dev/null
+++ b/packages/plugins/examples/auto-redact-pii.ts
@@ -0,0 +1,26 @@
+import { redactPII, type Plugin, type PluginContext, type ToolResultTransform } from '@crowclaw/core';
+
+export class AutoRedactPiiPlugin implements Plugin {
+ readonly name = 'auto-redact-pii';
+
+ transformToolResult(
+ payload: { result: { output: string; metadata?: Record } },
+ _context: PluginContext,
+ ): ToolResultTransform | void {
+ const redacted = redactPII(payload.result.output);
+ if (redacted.redactedCount === 0) return undefined;
+
+ return {
+ output: redacted.text,
+ metadata: {
+ ...(payload.result.metadata ?? {}),
+ piiRedactedCount: redacted.redactedCount,
+ piiRedactedTypes: redacted.redactedTypes,
+ },
+ };
+ }
+}
+
+export function createAutoRedactPiiPlugin(): Plugin {
+ return new AutoRedactPiiPlugin();
+}
diff --git a/packages/plugins/examples/block-rm-rf-everything.ts b/packages/plugins/examples/block-rm-rf-everything.ts
new file mode 100644
index 0000000..467c369
--- /dev/null
+++ b/packages/plugins/examples/block-rm-rf-everything.ts
@@ -0,0 +1,37 @@
+import type { Plugin, PluginContext, PreToolCallVeto } from '@crowclaw/core';
+
+const SHELL_TOOLS = new Set(['terminal.exec', 'terminal.background']);
+const DESTRUCTIVE_PATTERNS = [
+ /\brm\s+-[A-Za-z]*r[A-Za-z]*f[A-Za-z]*\s+(?:\/|~|\$HOME|\.{1,2}(?:\s|$))/,
+ /\bfind\s+\/\s+.*\s+-delete\b/,
+ /\bchmod\s+-R\s+777\s+(?:\/|~|\$HOME)\b/,
+ /\bdd\s+.*\bof=\/dev\/(?:disk|rdisk|sda|nvme)/,
+];
+
+function commandFromInput(input: Record): string {
+ const command = input.command ?? input.cmd;
+ return typeof command === 'string' ? command : '';
+}
+
+export class BlockRmRfEverythingPlugin implements Plugin {
+ readonly name = 'block-rm-rf-everything';
+
+ preToolCall(
+ payload: { toolName: string; input: Record },
+ _context: PluginContext,
+ ): PreToolCallVeto {
+ if (!SHELL_TOOLS.has(payload.toolName)) return { veto: false };
+
+ const command = commandFromInput(payload.input);
+ if (!command) return { veto: false };
+
+ const blocked = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
+ return blocked
+ ? { veto: true, reason: 'organization policy blocks broad destructive shell commands' }
+ : { veto: false };
+ }
+}
+
+export function createBlockRmRfEverythingPlugin(): Plugin {
+ return new BlockRmRfEverythingPlugin();
+}
diff --git a/packages/plugins/examples/metric-tap.ts b/packages/plugins/examples/metric-tap.ts
new file mode 100644
index 0000000..90beda0
--- /dev/null
+++ b/packages/plugins/examples/metric-tap.ts
@@ -0,0 +1,75 @@
+import type { Plugin, PluginContext, PluginHookPayloads, PluginHookName, PreToolCallVeto } from '@crowclaw/core';
+
+export interface MetricTapRecord {
+ toolName: string;
+ ok: boolean;
+ durationMs: number;
+ sessionId: string;
+}
+
+function metricKey(payload: { toolName: string; sessionId: string; agentId: string }): string {
+ return `${payload.sessionId}:${payload.agentId}:${payload.toolName}`;
+}
+
+export class MetricTapPlugin implements Plugin {
+ readonly name = 'metric-tap';
+ private readonly startedAt = new Map();
+ private readonly records: MetricTapRecord[] = [];
+
+ preToolCall(
+ payload: { toolName: string; sessionId: string; agentId: string },
+ _context: PluginContext,
+ ): PreToolCallVeto {
+ this.startedAt.set(metricKey(payload), Date.now());
+ return { veto: false };
+ }
+
+ on(hook: K, payload: PluginHookPayloads[K], context: PluginContext): void {
+ if (hook !== 'tool:result' && hook !== 'tool:error') return;
+
+ const result = (payload as PluginHookPayloads['tool:result']).result;
+ const key = metricKey({
+ toolName: result.toolName,
+ sessionId: context.sessionId,
+ agentId: context.agentId,
+ });
+ const started = this.startedAt.get(key) ?? Date.now();
+ this.startedAt.delete(key);
+ this.records.push({
+ toolName: result.toolName,
+ ok: result.ok,
+ durationMs: Math.max(0, Date.now() - started),
+ sessionId: context.sessionId,
+ });
+ }
+
+ snapshot(): MetricTapRecord[] {
+ return [...this.records];
+ }
+
+ renderPrometheus(): string {
+ const totals = new Map();
+ for (const record of this.records) {
+ const current = totals.get(record.toolName) ?? { count: 0, errors: 0, totalMs: 0 };
+ current.count += 1;
+ current.errors += record.ok ? 0 : 1;
+ current.totalMs += record.durationMs;
+ totals.set(record.toolName, current);
+ }
+
+ const lines: string[] = [
+ '# HELP crowclaw_plugin_tool_calls_total Tool calls observed by the metric-tap plugin.',
+ '# TYPE crowclaw_plugin_tool_calls_total counter',
+ ];
+ for (const [toolName, total] of totals) {
+ lines.push(`crowclaw_plugin_tool_calls_total{tool="${toolName}"} ${total.count}`);
+ lines.push(`crowclaw_plugin_tool_errors_total{tool="${toolName}"} ${total.errors}`);
+ lines.push(`crowclaw_plugin_tool_duration_ms_total{tool="${toolName}"} ${total.totalMs}`);
+ }
+ return `${lines.join('\n')}\n`;
+ }
+}
+
+export function createMetricTapPlugin(): MetricTapPlugin {
+ return new MetricTapPlugin();
+}
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
index 79ef5d8..95b5b9b 100644
--- a/packages/plugins/package.json
+++ b/packages/plugins/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/plugins",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -8,16 +8,21 @@
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
+ },
+ "./contracts": {
+ "types": "./src/contracts.ts",
+ "import": "./dist/contracts.js"
}
},
"files": [
- "dist"
+ "dist",
+ "examples"
],
"publishConfig": {
"access": "public"
},
"dependencies": {
- "@crowclaw/core": "0.8.1"
+ "@crowclaw/core": "0.8.2"
},
"repository": {
"type": "git",
diff --git a/packages/plugins/src/contracts.ts b/packages/plugins/src/contracts.ts
new file mode 100644
index 0000000..69d9c08
--- /dev/null
+++ b/packages/plugins/src/contracts.ts
@@ -0,0 +1,35 @@
+import type { Plugin } from '@crowclaw/core';
+
+export interface MemoryBackendManifest {
+ name: string;
+ version?: string;
+ description?: string;
+ author?: string;
+ repo?: string;
+ defaultConfigSchema?: Record;
+ hooks?: string[];
+ tools?: string[];
+ memoryBackend: true;
+ permissions?: {
+ tools?: string[];
+ memory?: 'none' | 'read' | 'write' | 'readwrite';
+ network?: boolean;
+ };
+}
+
+export interface MemoryBackendProvider {
+ recall(sessionId: string, query: string, limit: number, scope?: string, scopeKey?: string): Promise;
+ store(record: Record): Promise;
+ delete(id: string): Promise;
+ list(sessionId: string, scope?: string, limit?: number): Promise;
+ init?(config?: Record): Promise;
+ prefetch?(sessionId: string, query: string, limit: number): Promise;
+ sync_turn?(sessionId: string, summary: string, metadata?: Record): Promise;
+ shutdown?(): Promise;
+}
+
+export interface MemoryBackendPlugin extends Plugin {
+ kind: 'memory-backend';
+ manifest: MemoryBackendManifest;
+ provider: MemoryBackendProvider;
+}
diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts
index aaeee04..f280dff 100644
--- a/packages/plugins/src/index.ts
+++ b/packages/plugins/src/index.ts
@@ -9,10 +9,19 @@
* New code should prefer `import { PluginManager, ... } from '@crowclaw/core'`.
*/
+import type { Plugin, PluginContext, ToolResultTransform, PreToolCallVeto } from '@crowclaw/core';
+import type { MemoryBackendPlugin, MemoryBackendProvider } from './contracts.js';
+
export {
PluginManager,
MemoryCapturePlugin,
} from '@crowclaw/core';
+export type {
+ MemoryBackendManifest,
+ MemoryBackendPlugin,
+ MemoryBackendProvider,
+} from './contracts.js';
+
export type {
Plugin,
PluginContext,
@@ -23,3 +32,143 @@ export type {
PreToolCallVeto,
ToolResultTransform,
} from '@crowclaw/core';
+
+export interface PluginManifest {
+ name: string;
+ version?: string;
+ description?: string;
+ author?: string;
+ repo?: string;
+ defaultConfigSchema?: Record;
+ hooks?: string[];
+ tools?: string[];
+ memoryBackend?: boolean;
+ permissions?: {
+ tools?: string[];
+ memory?: 'none' | 'read' | 'write' | 'readwrite';
+ network?: boolean;
+ };
+}
+
+export interface PluginCatalogEntry {
+ manifest: PluginManifest;
+ plugin: Plugin;
+}
+
+export interface PluginValidationResult {
+ valid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+const PLUGIN_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/i;
+const UNSAFE_PLUGIN_TOOLS = new Set(['terminal.exec', 'terminal.background', 'git.commit', 'git.branch']);
+
+export function validatePluginManifest(manifest: Partial | null | undefined): PluginValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+ if (!manifest || typeof manifest !== 'object') {
+ return { valid: false, errors: ['manifest is missing or not an object'], warnings };
+ }
+ if (!manifest.name || typeof manifest.name !== 'string') {
+ errors.push('name is required');
+ } else if (!PLUGIN_NAME_RE.test(manifest.name)) {
+ errors.push('name must be a safe plugin slug');
+ }
+ if (manifest.version !== undefined && typeof manifest.version !== 'string') {
+ errors.push('version must be a string');
+ }
+ const declaredTools = [...(manifest.tools ?? []), ...(manifest.permissions?.tools ?? [])];
+ for (const tool of declaredTools) {
+ if (typeof tool !== 'string' || tool.trim() === '') {
+ errors.push('tools must contain only non-empty strings');
+ } else if (UNSAFE_PLUGIN_TOOLS.has(tool)) {
+ errors.push(`plugin manifest may not request raw command tool: ${tool}`);
+ }
+ }
+ if (manifest.hooks && !Array.isArray(manifest.hooks)) {
+ errors.push('hooks must be an array');
+ }
+ if (manifest.memoryBackend && manifest.permissions?.memory === 'none') {
+ warnings.push('memoryBackend plugins normally require memory read/write permission');
+ }
+ return { valid: errors.length === 0, errors, warnings };
+}
+
+export class PluginCatalog {
+ private readonly entries = new Map();
+
+ register(manifest: PluginManifest, plugin: Plugin): PluginValidationResult {
+ const validation = validatePluginManifest(manifest);
+ if (!validation.valid) return validation;
+ this.entries.set(manifest.name, { manifest, plugin });
+ return validation;
+ }
+
+ list(): PluginManifest[] {
+ return [...this.entries.values()].map((entry) => entry.manifest);
+ }
+
+ get(name: string): PluginCatalogEntry | undefined {
+ return this.entries.get(name);
+ }
+}
+
+export function createMemoryBackendPlugin(options: {
+ name: string;
+ provider: MemoryBackendProvider;
+ version?: string;
+ description?: string;
+}): MemoryBackendPlugin {
+ return {
+ name: options.name,
+ kind: 'memory-backend',
+ provider: options.provider,
+ manifest: {
+ name: options.name,
+ version: options.version,
+ description: options.description ?? 'Memory backend provider plugin',
+ memoryBackend: true,
+ hooks: ['agent:beforeRun', 'agent:afterRun'],
+ permissions: { memory: 'readwrite' },
+ },
+ };
+}
+
+export class ReferencePreToolCallPlugin implements Plugin {
+ readonly name: string;
+
+ constructor(
+ name = 'reference-pre-tool-call',
+ private readonly denyTools: string[] = [],
+ ) {
+ this.name = name;
+ }
+
+ preToolCall(payload: { toolName: string }, _context: PluginContext): PreToolCallVeto {
+ if (this.denyTools.includes(payload.toolName)) {
+ return { veto: true, reason: `tool denied by ${this.name}` };
+ }
+ return { veto: false };
+ }
+}
+
+export class ReferenceToolResultPlugin implements Plugin {
+ readonly name: string;
+
+ constructor(name = 'reference-tool-result') {
+ this.name = name;
+ }
+
+ transformToolResult(
+ payload: { result: { metadata?: Record } },
+ _context: PluginContext,
+ ): ToolResultTransform {
+ return {
+ metadata: {
+ ...(payload.result.metadata ?? {}),
+ transformedBy: this.name,
+ },
+ };
+ }
+}
diff --git a/packages/providers/package.json b/packages/providers/package.json
index f8be316..1b6b656 100644
--- a/packages/providers/package.json
+++ b/packages/providers/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/providers",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -17,7 +17,7 @@
"access": "public"
},
"dependencies": {
- "@crowclaw/core": "0.8.1"
+ "@crowclaw/core": "0.8.2"
},
"repository": {
"type": "git",
diff --git a/packages/providers/src/api-mode.ts b/packages/providers/src/api-mode.ts
index 04c2f1e..b53dbda 100644
--- a/packages/providers/src/api-mode.ts
+++ b/packages/providers/src/api-mode.ts
@@ -60,7 +60,7 @@ const OPENAI_RESPONSES_CAPABILITIES: ApiModeCapabilities = {
toolUse: true,
vision: true,
reasoning: true,
- caching: false,
+ caching: true,
batchApi: true,
};
@@ -69,7 +69,7 @@ const OPENAI_CHAT_CAPABILITIES: ApiModeCapabilities = {
toolUse: true,
vision: true,
reasoning: false,
- caching: false,
+ caching: true,
batchApi: false,
};
diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts
index 843a985..f4e65da 100644
--- a/packages/providers/src/index.ts
+++ b/packages/providers/src/index.ts
@@ -51,6 +51,22 @@ export interface OpenAICompatibleConfig {
* ChatGPT (Codex) backend, which requires `store: false` on every call.
*/
extraBodyFields?: Record;
+ /** Default token cap for provider requests when a request does not supply one. */
+ maxTokens?: number;
+ /** Default temperature for non-reasoning models. Reasoning models reject this field. */
+ temperature?: number;
+ /** OpenAI Responses API reasoning effort for models that accept reasoning controls. */
+ reasoningEffort?: 'low' | 'medium' | 'high';
+ /** Retry budget for transient 429/5xx provider responses. Default: 2 retries. */
+ maxRetries?: number;
+ /** Base delay for exponential backoff retries. Default: 250ms. Tests can set 0. */
+ retryBaseDelayMs?: number;
+ /** Dependency-injected sleep for retry tests. */
+ sleep?: (ms: number) => Promise;
+ /** Optional OpenAI prompt cache routing key. When omitted CrowClaw derives a stable prefix key. */
+ promptCacheKey?: string;
+ /** OpenAI prompt cache retention policy when supported by the endpoint. */
+ promptCacheRetention?: 'in-memory' | '24h';
/**
* v0.7.2: When the Responses API is in use, route the system prompt to the
* top-level `instructions` field instead of injecting a `developer` message
@@ -58,9 +74,10 @@ export interface OpenAICompatibleConfig {
*/
systemPromptAsInstructions?: boolean;
/**
- * v0.7.2: When set, `generate()` collects from `generateStream()` instead of
- * issuing a non-streaming POST. Required by the ChatGPT (Codex) backend,
- * which rejects `stream: false` calls.
+ * v0.7.2: When set, `generate()` and native structured-output requests
+ * collect from `generateStream()` instead of issuing a non-streaming POST.
+ * Required by the ChatGPT (Codex) backend, which rejects `stream: false`
+ * calls.
*/
requireStream?: boolean;
/**
@@ -141,6 +158,9 @@ interface ChatCompletionsResponse {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
+ prompt_tokens_details?: {
+ cached_tokens?: number;
+ };
};
}
@@ -685,15 +705,50 @@ function countMessageChars(messages: ConversationMessage[]): number {
return chars;
}
+function getOpenAIEncodingFamily(model: string): 'o200k' | 'cl100k' {
+ const id = model.toLowerCase();
+ return /^(?:gpt-4o|gpt-5|o1|o3|o4|codex)/.test(id) ? 'o200k' : 'cl100k';
+}
+
+function countEncodedTextTokens(text: string, family: 'o200k' | 'cl100k'): number {
+ if (!text) return 0;
+ const chunks = text.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]|[A-Za-z]+|\d+|[^\s]/gu) ?? [];
+ let total = 0;
+ for (const chunk of chunks) {
+ if (/^[A-Za-z]+$/.test(chunk)) {
+ total += Math.max(1, Math.ceil(chunk.length / (family === 'o200k' ? 4 : 3.5)));
+ } else if (/^\d+$/.test(chunk)) {
+ total += Math.max(1, Math.ceil(chunk.length / 3));
+ } else {
+ total += 1;
+ }
+ }
+ return total;
+}
+
+function countOpenAIMessageTokens(messages: ConversationMessage[], model: string): number {
+ const family = getOpenAIEncodingFamily(model);
+ let total = 0;
+ for (const msg of messages) {
+ total += 3; // role/message framing overhead used by OpenAI chat encodings.
+ total += countEncodedTextTokens(msg.role, family);
+ total += countEncodedTextTokens(msg.content, family);
+ if (msg.name) total += countEncodedTextTokens(msg.name, family);
+ }
+ return total;
+}
+
function extractOpenAIUsage(payload: ChatCompletionsResponse): ProviderResponseUsage | undefined {
const u = payload.usage;
if (!u) return undefined;
const inputTokens = u.prompt_tokens ?? 0;
const outputTokens = u.completion_tokens ?? 0;
+ const cachedTokens = u.prompt_tokens_details?.cached_tokens ?? 0;
return {
inputTokens,
outputTokens,
totalTokens: u.total_tokens ?? (inputTokens + outputTokens),
+ ...(cachedTokens > 0 ? { cachedTokens } : {}),
};
}
@@ -756,13 +811,148 @@ function checkRateLimitHeaders(headers: Headers, pool: CredentialPool, key: stri
/**
* Detect whether the configured (baseUrl, model) combination supports
* OpenAI's native `response_format: json_schema` mode. We restrict the native
- * path to api.openai.com gpt-4o / gpt-4.1 family to avoid 400s from
+ * path to api.openai.com gpt-4o / gpt-4.1 / gpt-5 / reasoning families to avoid 400s from
* OpenAI-compatible backends (OpenRouter, NVIDIA, vLLM) that don't honour the
* field. Everything else falls back to the schema-block envelope.
*/
function supportsNativeJsonSchema(baseUrl: string, model: string): boolean {
if (!/api\.openai\.com/i.test(baseUrl)) return false;
- return /^gpt-4o|^gpt-4\.1/i.test(model);
+ return /^(?:gpt-4o|gpt-4\.1|gpt-5|o1|o3|o4)/i.test(model);
+}
+
+function isReasoningModel(model: string): boolean {
+ return /^(?:o1|o3|o4)/i.test(model);
+}
+
+function isOpenAIHosted(baseUrl: string): boolean {
+ return /api\.openai\.com/i.test(baseUrl);
+}
+
+function stablePrefixHash(input: string): string {
+ let hash = 0x811c9dc5;
+ for (let i = 0; i < input.length; i += 1) {
+ hash ^= input.charCodeAt(i);
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash.toString(36);
+}
+
+function stableToolsForPromptCache(availableTools: ToolManifest[]): ToolManifest[] {
+ return [...availableTools].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function applyPromptCacheFields(
+ body: Record,
+ config: OpenAICompatibleConfig,
+ request: ProviderRequest,
+): void {
+ if (!isOpenAIHosted(config.baseUrl)) return;
+ const staticPrefix = JSON.stringify({
+ model: config.model,
+ systemPrompt: request.systemPrompt ?? '',
+ tools: stableToolsForPromptCache(request.availableTools).map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ schema: tool.inputSchema ?? null,
+ })),
+ });
+ body.prompt_cache_key = (config.promptCacheKey ?? `crowclaw-${stablePrefixHash(staticPrefix)}`).slice(0, 512);
+ if (config.promptCacheRetention) {
+ body.prompt_cache_retention = config.promptCacheRetention;
+ }
+}
+
+function applyOpenAITokenAndSamplingFields(
+ body: Record,
+ options: {
+ model: string;
+ isResponsesApi: boolean;
+ maxTokens?: number;
+ temperature?: number;
+ reasoningEffort?: 'low' | 'medium' | 'high';
+ },
+): void {
+ const reasoning = isReasoningModel(options.model);
+ const maxTokens = options.maxTokens ?? 16384;
+
+ if (options.isResponsesApi) {
+ body.max_output_tokens = maxTokens;
+ if (reasoning && options.reasoningEffort) {
+ body.reasoning_effort = options.reasoningEffort;
+ }
+ } else if (reasoning) {
+ body.max_completion_tokens = maxTokens;
+ } else {
+ body.max_tokens = maxTokens;
+ }
+
+ if (reasoning) {
+ delete body.temperature;
+ return;
+ }
+
+ if (options.temperature !== undefined) {
+ body.temperature = options.temperature;
+ }
+}
+
+function shouldRetryProviderStatus(status: number): boolean {
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
+}
+
+function parseRetryAfterMs(headers: Headers): number | null {
+ const retryAfter = headers.get('retry-after');
+ if (!retryAfter) return null;
+ const seconds = Number.parseFloat(retryAfter);
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
+ const retryDate = new Date(retryAfter).getTime();
+ if (!Number.isNaN(retryDate)) return Math.max(0, retryDate - Date.now());
+ return null;
+}
+
+function disableSameKeyRetryForCredentialPool(
+ config: OpenAICompatibleConfig,
+ pool?: CredentialPool,
+): OpenAICompatibleConfig {
+ return pool ? { ...config, maxRetries: 0 } : config;
+}
+
+async function fetchOpenAIWithRetry(
+ fetcher: () => Promise,
+ config: OpenAICompatibleConfig,
+ signal?: AbortSignal,
+): Promise {
+ const maxRetries = config.maxRetries ?? 2;
+ const baseDelayMs = config.retryBaseDelayMs ?? 250;
+ const sleep = config.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
+ let attempt = 0;
+
+ while (true) {
+ const response = await fetcher();
+ if (!shouldRetryProviderStatus(response.status) || attempt >= maxRetries || signal?.aborted) {
+ return response;
+ }
+ const retryAfterMs = parseRetryAfterMs(response.headers);
+ const exponentialMs = baseDelayMs * 2 ** attempt;
+ const jitterMs = baseDelayMs === 0 ? 0 : Math.floor(Math.random() * Math.max(1, baseDelayMs));
+ await sleep(retryAfterMs ?? (exponentialMs + jitterMs));
+ attempt += 1;
+ }
+}
+
+function extractResponsesOutputText(payload: Record): string {
+ const output = payload.output;
+ if (!Array.isArray(output)) return '';
+ let text = '';
+ for (const item of output as Array<{ type?: string; content?: Array<{ type?: string; text?: string }> }>) {
+ if (item.type !== 'message' || !Array.isArray(item.content)) continue;
+ for (const part of item.content) {
+ if (part.type === 'output_text' && part.text) {
+ text += part.text;
+ }
+ }
+ }
+ return text;
}
/**
@@ -1058,10 +1248,9 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
return `${base}/chat/completions`;
}
- /** Estimate token count for messages (~4 chars per token for OpenAI models) */
+ /** Estimate token count using the model's OpenAI encoding family. */
countTokens(messages: ConversationMessage[]): number {
- const chars = countMessageChars(messages);
- return Math.ceil(chars / 4);
+ return countOpenAIMessageTokens(messages, this.config.model);
}
/**
@@ -1109,6 +1298,7 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
if (!apiKey) {
return new EchoProvider().generate(request);
}
+ let activeApiKey = apiKey;
const isResponsesApi = this.getEndpointUrl().endsWith('/responses');
// Issue #56: Strip stale budget warnings before sending to model.
@@ -1131,6 +1321,14 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
model: this.config.model,
...(this.config.extraBodyFields ?? {}),
};
+ applyOpenAITokenAndSamplingFields(body, {
+ model: this.config.model,
+ isResponsesApi,
+ maxTokens: request.maxTokens ?? this.config.maxTokens,
+ temperature: request.temperature ?? this.config.temperature,
+ reasoningEffort: this.config.reasoningEffort,
+ });
+ applyPromptCacheFields(body, this.config, request);
if (isResponsesApi) {
const useInstructions = !!this.config.systemPromptAsInstructions;
@@ -1151,9 +1349,10 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
}
if (request.availableTools.length > 0) {
+ const stableTools = stableToolsForPromptCache(request.availableTools);
body.tools = isResponsesApi
- ? buildResponsesApiTools(request.availableTools)
- : buildOpenAITools(request.availableTools);
+ ? buildResponsesApiTools(stableTools)
+ : buildOpenAITools(stableTools);
body.tool_choice = 'auto';
}
@@ -1169,27 +1368,28 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
signal: request.signal,
});
- let response = await performFetch(apiKey);
+ const retryConfig = disableSameKeyRetryForCredentialPool(this.config, pool);
+ let response = await fetchOpenAIWithRetry(() => performFetch(activeApiKey), retryConfig, request.signal);
if (response.status === 401 && this.config.onAuthFailure) {
const refreshed = await this.config.onAuthFailure();
if (refreshed && tokenProvider) {
- apiKey = await tokenProvider();
- response = await performFetch(apiKey);
+ activeApiKey = await tokenProvider();
+ response = await fetchOpenAIWithRetry(() => performFetch(activeApiKey), retryConfig, request.signal);
}
}
if (!response.ok) {
if (pool) {
- pool.reportFailure(apiKey, response.status);
+ pool.reportFailure(activeApiKey, response.status);
}
const errBody = await response.text().catch(() => '');
throw new Error(`Provider request failed: ${response.status} ${response.statusText}${errBody ? ` — ${errBody.slice(0, 200)}` : ''}`);
}
if (pool) {
- pool.reportSuccess(apiKey);
- checkRateLimitHeaders(response.headers, pool, apiKey);
+ pool.reportSuccess(activeApiKey);
+ checkRateLimitHeaders(response.headers, pool, activeApiKey);
}
const rawPayload = (await response.json()) as Record;
@@ -1218,6 +1418,9 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
inputTokens: rawUsage.input_tokens ?? 0,
outputTokens: rawUsage.output_tokens ?? 0,
totalTokens: (rawUsage.input_tokens ?? 0) + (rawUsage.output_tokens ?? 0),
+ ...(((rawPayload.usage as { input_tokens_details?: { cached_tokens?: number } }).input_tokens_details?.cached_tokens ?? 0) > 0
+ ? { cachedTokens: (rawPayload.usage as { input_tokens_details?: { cached_tokens?: number } }).input_tokens_details!.cached_tokens }
+ : {}),
} : undefined;
// v0.8.0 (#231 / #236): scan the full assistant turn for reasoning
// blocks and Hermes-style `` spans. Native function_call
@@ -1297,6 +1500,7 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
yield* echo.generateStream(request);
return;
}
+ let activeApiKey = apiKey;
const isResponsesApi = this.getEndpointUrl().endsWith('/responses');
// Issue #56: Strip stale budget warnings before sending to model.
@@ -1319,6 +1523,14 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
stream: true,
...(this.config.extraBodyFields ?? {}),
};
+ applyOpenAITokenAndSamplingFields(body, {
+ model: this.config.model,
+ isResponsesApi,
+ maxTokens: request.maxTokens ?? this.config.maxTokens,
+ temperature: request.temperature ?? this.config.temperature,
+ reasoningEffort: this.config.reasoningEffort,
+ });
+ applyPromptCacheFields(body, this.config, request);
if (isResponsesApi) {
const useInstructions = !!this.config.systemPromptAsInstructions;
@@ -1339,9 +1551,10 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
}
if (request.availableTools.length > 0) {
+ const stableTools = stableToolsForPromptCache(request.availableTools);
body.tools = isResponsesApi
- ? buildResponsesApiTools(request.availableTools)
- : buildOpenAITools(request.availableTools);
+ ? buildResponsesApiTools(stableTools)
+ : buildOpenAITools(stableTools);
body.tool_choice = 'auto';
}
@@ -1357,19 +1570,20 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
signal: request.signal,
});
- let response = await performFetch(apiKey);
+ const retryConfig = disableSameKeyRetryForCredentialPool(this.config, pool);
+ let response = await fetchOpenAIWithRetry(() => performFetch(activeApiKey), retryConfig, request.signal);
if (response.status === 401 && this.config.onAuthFailure) {
const refreshed = await this.config.onAuthFailure();
if (refreshed && tokenProvider) {
- apiKey = await tokenProvider();
- response = await performFetch(apiKey);
+ activeApiKey = await tokenProvider();
+ response = await fetchOpenAIWithRetry(() => performFetch(activeApiKey), retryConfig, request.signal);
}
}
if (!response.ok) {
if (pool) {
- pool.reportFailure(apiKey, response.status);
+ pool.reportFailure(activeApiKey, response.status);
}
const errBody = await response.text().catch(() => '');
yield { type: 'error', error: `Provider request failed: ${response.status} ${response.statusText}${errBody ? ` — ${errBody.slice(0, 200)}` : ''}` };
@@ -1377,8 +1591,8 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
}
if (pool) {
- pool.reportSuccess(apiKey);
- checkRateLimitHeaders(response.headers, pool, apiKey);
+ pool.reportSuccess(activeApiKey);
+ checkRateLimitHeaders(response.headers, pool, activeApiKey);
}
if (!response.body) {
@@ -1546,9 +1760,11 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
/**
* v0.8.0 Hermes parity (#237): JSON-schema-typed generation. On
- * api.openai.com gpt-4o / gpt-4.1 family models, uses the native
+ * api.openai.com gpt-4o / gpt-4.1 / gpt-5 / reasoning family models, uses the native
* `response_format: json_schema` mode (strict). Everything else falls back
- * to a system-prompt envelope that embeds the schema.
+ * to a system-prompt envelope that embeds the schema. Providers that set
+ * `requireStream` (notably ChatGPT/Codex) also use the envelope path because
+ * native structured-output calls are non-streaming.
*
* Failures are surfaced as a typed envelope (`ok: false`) rather than
* thrown, so route handlers can render a structured error in the dashboard
@@ -1557,7 +1773,7 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
async generateStructured(req: StructuredOutputRequest): Promise> {
const useNativeJsonSchema = supportsNativeJsonSchema(this.config.baseUrl, this.config.model);
- if (useNativeJsonSchema) {
+ if (useNativeJsonSchema && !this.config.requireStream) {
try {
return await this.callNativeStructured(req);
} catch (err) {
@@ -1585,7 +1801,7 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
return finalizeStructuredResponse(response.assistantMessage ?? '', req);
}
- /** Native /chat/completions json_schema path for OpenAI gpt-4o / gpt-4.1 family. */
+ /** Native json_schema path for OpenAI gpt-4o / gpt-4.1 / gpt-5 / reasoning families. */
private async callNativeStructured(req: StructuredOutputRequest): Promise> {
const pool = this.config.credentialPool;
const tokenProvider = this.config.tokenProvider;
@@ -1615,21 +1831,48 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
: message.content,
}));
- const body = {
+ const isResponsesApi = this.getEndpointUrl().endsWith('/responses');
+ const body: Record = {
+ model: this.config.model,
+ ...(this.config.extraBodyFields ?? {}),
+ };
+
+ applyOpenAITokenAndSamplingFields(body, {
model: this.config.model,
- messages: mappedMessages,
- response_format: {
+ isResponsesApi,
+ maxTokens: this.config.maxTokens,
+ temperature: this.config.temperature,
+ reasoningEffort: this.config.reasoningEffort,
+ });
+ applyPromptCacheFields(body, this.config, {
+ messages: req.messages,
+ systemPrompt: req.messages.find((message) => message.role === 'system')?.content,
+ availableTools: [],
+ });
+
+ if (isResponsesApi) {
+ body.input = mappedMessages;
+ body.text = {
+ format: {
+ type: 'json_schema',
+ name: 'output',
+ schema: req.schema,
+ strict: true,
+ },
+ };
+ } else {
+ body.messages = mappedMessages;
+ body.response_format = {
type: 'json_schema',
json_schema: {
name: 'output',
schema: req.schema,
strict: true,
},
- },
- ...(this.config.extraBodyFields ?? {}),
- };
+ };
+ }
- const response = await fetch(`${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
+ const response = await fetch(this.getEndpointUrl(), {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
@@ -1644,8 +1887,10 @@ export class OpenAICompatibleProvider implements ProviderAdapter, StreamingProvi
return { ok: false, error: 'provider', details: `${response.status} ${response.statusText}${errBody ? ` — ${errBody.slice(0, 200)}` : ''}` };
}
- const payload = (await response.json()) as ChatCompletionsResponse;
- const text = normalizeOpenAIMessageContent(payload.choices?.[0]?.message?.content) ?? '';
+ const payload = (await response.json()) as Record;
+ const text = isResponsesApi
+ ? extractResponsesOutputText(payload)
+ : normalizeOpenAIMessageContent((payload as ChatCompletionsResponse).choices?.[0]?.message?.content) ?? '';
return finalizeStructuredResponse(text, req);
}
}
diff --git a/packages/providers/src/model-catalog.ts b/packages/providers/src/model-catalog.ts
index fb61802..1fe0536 100644
--- a/packages/providers/src/model-catalog.ts
+++ b/packages/providers/src/model-catalog.ts
@@ -75,6 +75,20 @@ export const FALLBACK_MANIFEST: ModelManifest = {
supportsImages: true,
supportsStreaming: true,
},
+ {
+ id: 'gpt-5',
+ contextLength: 400_000,
+ supportsTools: true,
+ supportsImages: true,
+ supportsStreaming: true,
+ },
+ {
+ id: 'gpt-5.5',
+ contextLength: 400_000,
+ supportsTools: true,
+ supportsImages: true,
+ supportsStreaming: true,
+ },
{
id: 'o3',
contextLength: 200_000,
diff --git a/packages/runtime-cloudflare/package.json b/packages/runtime-cloudflare/package.json
index 33a1d57..a2d261d 100644
--- a/packages/runtime-cloudflare/package.json
+++ b/packages/runtime-cloudflare/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/runtime-cloudflare",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -18,19 +18,19 @@
},
"dependencies": {
"@cloudflare/sandbox": "^0.8.9",
- "@crowclaw/core": "0.8.1",
- "@crowclaw/memory": "0.8.1",
- "@crowclaw/providers": "0.8.1",
- "@crowclaw/sandbox-executor": "0.8.1",
- "@crowclaw/shared": "0.8.1",
- "@crowclaw/storage": "0.8.1",
- "@crowclaw/tools": "0.8.1",
- "@crowclaw/gateway": "0.8.1",
- "@crowclaw/workspace": "0.8.1",
- "@crowclaw/learning": "0.8.1",
- "@crowclaw/mcp": "0.8.1",
- "@crowclaw/scheduler": "0.8.1",
- "@crowclaw/plugins": "0.8.1"
+ "@crowclaw/core": "0.8.2",
+ "@crowclaw/memory": "0.8.2",
+ "@crowclaw/providers": "0.8.2",
+ "@crowclaw/sandbox-executor": "0.8.2",
+ "@crowclaw/shared": "0.8.2",
+ "@crowclaw/storage": "0.8.2",
+ "@crowclaw/tools": "0.8.2",
+ "@crowclaw/gateway": "0.8.2",
+ "@crowclaw/workspace": "0.8.2",
+ "@crowclaw/learning": "0.8.2",
+ "@crowclaw/mcp": "0.8.2",
+ "@crowclaw/scheduler": "0.8.2",
+ "@crowclaw/plugins": "0.8.2"
},
"repository": {
"type": "git",
diff --git a/packages/runtime-cloudflare/src/agent-do.ts b/packages/runtime-cloudflare/src/agent-do.ts
index d26aa26..412784b 100644
--- a/packages/runtime-cloudflare/src/agent-do.ts
+++ b/packages/runtime-cloudflare/src/agent-do.ts
@@ -1,9 +1,9 @@
import { getSandbox } from '@cloudflare/sandbox';
-import { AgentLoop, getAgentPreset, listAgentPresets, InMemoryCheckpointStore, createCheckpoint, restoreFromCheckpoint, createReplaySession, validateFetchUrl, type ParsedSkillFile, type ProviderAdapter, type CheckpointTrigger, type SessionState } from '@crowclaw/core';
+import { AgentLoop, DetailedUsageTracker, SecurityAuditLog, getAgentPreset, listAgentPresets, InMemoryCheckpointStore, createCheckpoint, restoreFromCheckpoint, createReplaySession, validateFetchUrl, type ParsedSkillFile, type ProviderAdapter, type CheckpointTrigger, type SessionState } from '@crowclaw/core';
import { buildGatewayDeliveryPlan, normalizeGatewayRequest } from '@crowclaw/gateway';
import { InMemorySkillStore, LearningPipeline, SkillRegistry, getBuiltInSkills } from '@crowclaw/learning';
import { McpClient, McpHttpTransport, getMcpPresetDescription, listMcpPresetNames } from '@crowclaw/mcp';
-import { MemoryService } from '@crowclaw/memory';
+import { FrozenMemory, InMemoryFrozenStore, MemoryService } from '@crowclaw/memory';
import { MemoryCapturePlugin, PluginManager } from '@crowclaw/plugins';
import { OpenAICompatibleProvider, isModelOverridable } from '@crowclaw/providers';
import { buildToolBridgeArtifacts, CloudflareSandboxExecutor, registerSandboxTools } from '@crowclaw/sandbox-executor';
@@ -306,6 +306,10 @@ export class AgentSessionDurableObject {
private readonly sessionStore: D1SessionStore;
private readonly memoryStore: D1MemoryStore;
private readonly memoryService: MemoryService;
+ private readonly frozenMemory = new FrozenMemory(new InMemoryFrozenStore(), 'MEMORY');
+ private readonly frozenUserProfile = new FrozenMemory(new InMemoryFrozenStore(), 'USER');
+ private readonly securityAuditLog = new SecurityAuditLog(500);
+ private readonly usageTracker = new DetailedUsageTracker();
private readonly workspaceStore = new InMemoryWorkspaceStore();
private readonly schedulerStore = new InMemorySchedulerStore();
private readonly checkpointStore = new InMemoryCheckpointStore({ maxCheckpoints: 1000 });
@@ -541,7 +545,7 @@ export class AgentSessionDurableObject {
resolvedProvider,
registry,
this.sessionStore,
- { plugins: this.plugins, runtimeName: 'cloudflare', skills, agentPreset }
+ { plugins: this.plugins, runtimeName: 'cloudflare', skills, agentPreset, providerName: 'openai-compatible' }
);
}
@@ -568,6 +572,26 @@ export class AgentSessionDurableObject {
return Response.json(this.plugins.list().map((plugin) => ({ name: plugin.name })));
}
+ if (request.method === 'GET' && url.pathname.endsWith('/agent-skills')) {
+ await this.ensureSkillsLoaded();
+ const resolved = this.skillRegistry.resolve();
+ return Response.json({
+ skills: resolved.map((s) => ({
+ name: s.manifest.name,
+ description: s.manifest.description,
+ triggers: s.manifest.triggers ?? [],
+ tools: s.manifest.tools ?? [],
+ })),
+ version: __CROWCLAW_VERSION__,
+ });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/tools') && !url.pathname.endsWith('/mcp/tools')) {
+ const registry = createRegistry(this.sessionStore, this.memoryStore, this.workspaceStore, this.mcpClient);
+ const tools = registry.list();
+ return Response.json({ tools, count: tools.length });
+ }
+
if (request.method === 'GET' && url.pathname.endsWith('/system/status')) {
await this.ensureSchedulerHydrated();
this.pruneStaleSessions();
@@ -613,6 +637,206 @@ export class AgentSessionDurableObject {
});
}
+ if (request.method === 'GET' && url.pathname.endsWith('/diagnostics')) {
+ await this.ensureSchedulerHydrated();
+ this.pruneStaleSessions();
+ const jobs = await this.schedulerStore.listJobs();
+ const dynamicMcpClient = this.mcpClient as unknown as { getStatus?: () => { degraded?: boolean; lastError?: unknown } | null | undefined };
+ const mcpStatus = dynamicMcpClient.getStatus?.();
+ return Response.json({
+ ok: true,
+ runtime: 'cloudflare',
+ version: __CROWCLAW_VERSION__,
+ activeSessions: 0,
+ wsConnections: 0,
+ bridgeSessions: this.codeBridgeSessions.size,
+ browserSessions: this.browserSessions.size,
+ schedulerJobs: jobs.length,
+ transport: { ws: false, sse: false },
+ provider: {
+ configured: Boolean(this.env.OPENAI_API_KEY),
+ reachable: Boolean(this.env.OPENAI_API_KEY),
+ lastCallOk: null,
+ },
+ scheduler: {
+ running: this.autonomousRunning,
+ errored: false,
+ lastTick: this.autonomousLastTick,
+ jobCount: jobs.length,
+ },
+ mcp: {
+ total: mcpStatus ? 1 : 0,
+ connected: mcpStatus && !mcpStatus.degraded ? 1 : 0,
+ degraded: mcpStatus?.degraded ? 1 : 0,
+ },
+ });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/config/snapshot')) {
+ await this.ensureActivePresetHydrated();
+ return Response.json({
+ ok: true,
+ activePreset: this.activePreset,
+ activeToolset: this.activePreset.toolset,
+ disabledSkills: [],
+ gatewayConfigs: {},
+ providerConfig: {
+ primary: {
+ name: 'Cloudflare OpenAI',
+ provider: 'openai',
+ model: this.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
+ baseUrl: this.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
+ apiKey: this.env.OPENAI_API_KEY ? '***' : '',
+ },
+ },
+ });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/config/schema')) {
+ return Response.json({
+ ok: true,
+ schema: {
+ runtime: 'cloudflare',
+ sections: ['provider', 'gateway', 'presets', 'remoteAccess'],
+ },
+ });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/config/validate')) {
+ return Response.json({ ok: true, errors: [], warnings: [] });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/config/diff')) {
+ return Response.json({ ok: true, diff: [] });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/config/remote-access')) {
+ return Response.json({
+ ok: true,
+ publicUrl: null,
+ bindTailnetOnly: false,
+ trustedProxies: [],
+ runtime: 'cloudflare',
+ });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/config/remote-access')) {
+ return Response.json(
+ { ok: false, error: 'remote_access_config_is_managed_by_worker_environment' },
+ { status: 501 },
+ );
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/memory/snapshot')) {
+ return Response.json({
+ ok: true,
+ memory: { entries: this.frozenMemory.getAll(), version: this.frozenMemory.snapshotVersion, size: this.frozenMemory.size },
+ user: { entries: this.frozenUserProfile.getAll(), version: this.frozenUserProfile.snapshotVersion, size: this.frozenUserProfile.size },
+ });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/memory/snapshot')) {
+ const body = (await request.json().catch(() => ({}))) as { namespace?: 'memory' | 'user'; action?: 'set' | 'remove'; key?: string; value?: string; category?: string };
+ if (!body.key || (body.action !== 'set' && body.action !== 'remove')) {
+ return Response.json({ ok: false, error: 'Expected { namespace, action, key }' }, { status: 400 });
+ }
+ const target = body.namespace === 'user' ? this.frozenUserProfile : this.frozenMemory;
+ if (body.action === 'set') {
+ target.set(body.key, body.value ?? '', body.category);
+ } else {
+ target.remove(body.key);
+ }
+ await target.save(this.state.id.toString()).catch(() => {});
+ return Response.json({ ok: true, size: target.size, version: target.snapshotVersion });
+ }
+
+ if (request.method === 'GET' && (url.pathname.endsWith('/security/events') || url.pathname.endsWith('/security/audit'))) {
+ const type = url.searchParams.get('type');
+ const severity = url.searchParams.get('severity');
+ const limit = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
+ let events = type ? this.securityAuditLog.getEventsByType(type) : this.securityAuditLog.getEvents();
+ if (severity) events = events.filter((event) => event.severity === severity);
+ return Response.json({ events: Number.isFinite(limit) ? events.slice(0, limit) : events });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/security/stats')) {
+ return Response.json(this.securityAuditLog.getStats());
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/security/status')) {
+ const stats = this.securityAuditLog.getStats();
+ return Response.json({
+ policy: {
+ redactToolOutput: true,
+ scanUserInput: true,
+ scanCommands: true,
+ blockDangerousCommands: true,
+ piiRedaction: true,
+ },
+ protections: ['ssrf', 'dashboard-auth', 'webhook-signatures', 'command-scan'],
+ activeCount: 4,
+ totalCount: 4,
+ grade: 'A',
+ stats,
+ });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/security/policy')) {
+ return Response.json(
+ { ok: false, error: 'security_policy_is_read_only_on_workers' },
+ { status: 501 },
+ );
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/security/events/clear')) {
+ this.securityAuditLog.clear();
+ return Response.json({ ok: true });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/usage')) {
+ return Response.json(this.usageTracker.getSummary());
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/usage/reset')) {
+ this.usageTracker.reset();
+ return Response.json({ ok: true });
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/providers/config')) {
+ return Response.json({
+ configured: Boolean(this.env.OPENAI_API_KEY),
+ slots: {
+ primary: {
+ name: 'Cloudflare OpenAI',
+ provider: 'openai',
+ model: this.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
+ baseUrl: this.env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1',
+ apiKey: this.env.OPENAI_API_KEY ? '***' : '',
+ },
+ fallback: null,
+ vision: null,
+ compression: null,
+ embedding: null,
+ },
+ });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/providers/config')) {
+ return Response.json(
+ { ok: false, error: 'provider_config_is_managed_by_worker_environment' },
+ { status: 501 },
+ );
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/providers/test')) {
+ return Response.json({
+ ok: Boolean(this.env.OPENAI_API_KEY),
+ provider: 'openai',
+ model: this.env.OPENAI_MODEL ?? 'gpt-4.1-mini',
+ error: this.env.OPENAI_API_KEY ? undefined : 'OPENAI_API_KEY is not configured',
+ }, { status: this.env.OPENAI_API_KEY ? 200 : 400 });
+ }
+
if (request.method === 'GET' && url.pathname.endsWith('/skills')) {
await this.ensureSkillsLoaded();
const allSkills = this.skillRegistry.resolveAll();
@@ -1522,6 +1746,24 @@ export class AgentSessionDurableObject {
return Response.json(await this.learning.listDrafts());
}
+ if (request.method === 'GET' && url.pathname.endsWith('/learning/drafts/pending')) {
+ const drafts = await this.learning.listDrafts();
+ return Response.json(drafts.filter((draft) => draft.status === 'draft'));
+ }
+
+ if (request.method === 'GET' && url.pathname.endsWith('/learning/dashboard')) {
+ const drafts = await this.learning.listDrafts();
+ const published = drafts.filter((draft) => draft.status === 'published').length;
+ const pending = drafts.length - published;
+ return Response.json({
+ ok: true,
+ total: drafts.length,
+ pending,
+ published,
+ drafts,
+ });
+ }
+
if (request.method === 'POST' && url.pathname.endsWith('/learning/drafts')) {
const body = (await request.json()) as { title: string; messages: Array<{ role: 'user' | 'assistant' | 'tool' | 'system'; content: string; createdAt?: string }> };
const stored = await this.learning.captureDraft(
@@ -1531,6 +1773,19 @@ export class AgentSessionDurableObject {
return Response.json(stored);
}
+ if (request.method === 'POST' && url.pathname.endsWith('/learning/auto-capture')) {
+ const body = (await request.json()) as { title?: string; trigger?: string; messages?: Array<{ role: 'user' | 'assistant' | 'tool' | 'system'; content: string; createdAt?: string }> };
+ const messages = (body.messages ?? []).map((message) => ({ ...message, createdAt: message.createdAt ?? new Date().toISOString() }));
+ const draft = await this.learning.autoCapture(messages, body.title, { trigger: body.trigger });
+ return Response.json({ ok: true, captured: Boolean(draft), draft });
+ }
+
+ if (request.method === 'POST' && url.pathname.endsWith('/learning/match')) {
+ const body = (await request.json()) as { query?: string; limit?: number };
+ const matches = await this.learning.findRelevantSkills(body.query ?? '', body.limit);
+ return Response.json({ ok: true, matches });
+ }
+
if (request.method === 'POST' && /\/learning\/drafts\/.+\/publish$/.test(url.pathname)) {
const parts = url.pathname.split('/').filter(Boolean);
const id = parts[parts.length - 2] ?? '';
diff --git a/packages/runtime-cloudflare/src/index.ts b/packages/runtime-cloudflare/src/index.ts
index ce226d8..438389b 100644
--- a/packages/runtime-cloudflare/src/index.ts
+++ b/packages/runtime-cloudflare/src/index.ts
@@ -92,6 +92,89 @@ function getSpecialSessionStub(env: RuntimeEnv, name: string) {
return env.AGENT_SESSIONS.get(durableId);
}
+function unsupportedOnWorkers(path: string): Response {
+ return Response.json(
+ { ok: false, error: 'unsupported_on_workers', path },
+ { status: 501 }
+ );
+}
+
+// Public Node routes that require a host process, mutable local config, or
+// provider credentials managed outside the Worker environment. Keep this table
+// in sync with scripts/audit-routes.mjs so parity drift is explicit instead of
+// silently falling through to 404.
+const WORKER_UNSUPPORTED_ROUTES = new Set([
+ '/api/acp/info',
+ '/api/acp/prompt',
+ '/api/acp/request',
+ '/api/acp/sessions',
+ '/api/agent/preset',
+ '/api/clarify',
+ '/api/config',
+ '/api/config-presets',
+ '/api/config/agent',
+ '/api/config/provider',
+ '/api/config/provider/test',
+ '/api/context',
+ '/api/events',
+ '/api/feedback',
+ '/api/gateway/activity',
+ '/api/gateway/pairing/approve',
+ '/api/gateway/pairing/reject',
+ '/api/gateway/pairings',
+ '/api/gateway/telegram/webhook',
+ '/api/mcp/catalog',
+ '/api/mcp/connect',
+ '/api/mcp/disconnect',
+ '/api/mcp/presets/status',
+ '/api/mcp/server/request',
+ '/api/mcp/server/tools',
+ '/api/mcp/servers',
+ '/api/mcp/servers/install',
+ '/api/mcp/verify',
+ '/api/metrics',
+ '/api/persona/active',
+ '/api/persona/switch',
+ '/api/personas',
+ '/api/plugins/catalog',
+ '/api/plugins/configure',
+ '/api/plugins/install',
+ '/api/plugins/uninstall',
+ '/api/providers/failover-preview',
+ '/api/providers/failover-simulate',
+ '/api/providers/models',
+ '/api/providers/plan',
+ '/api/providers/pool',
+ '/api/providers/route',
+ '/api/send-message',
+ '/api/skills/import',
+ '/api/skills/install',
+ '/api/skills/preview',
+ '/api/structured-output',
+ '/api/system/preflight',
+ '/api/system/release-check',
+ '/api/system/version',
+ '/api/todo',
+ '/api/toolset/select',
+ '/api/user/profile',
+]);
+
+function maybeUnsupportedOnWorkers(path: string): Response | null {
+ return WORKER_UNSUPPORTED_ROUTES.has(path) ? unsupportedOnWorkers(path) : null;
+}
+
+async function forwardToSystemSession(request: Request, env: RuntimeEnv, url: URL, internalPath: string): Promise {
+ const stub = getSpecialSessionStub(env, '__system__');
+ const init: RequestInit = {
+ method: request.method,
+ headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' },
+ };
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
+ init.body = await request.text();
+ }
+ return stub.fetch(new Request(`https://internal/session${internalPath}${url.search}`, init));
+}
+
/**
* Derive the cookie-safe token from CROWCLAW_DASHBOARD_TOKEN using HMAC-SHA256.
* Mirrors the Node runtime so `/api/auth/verify` semantics are consistent
@@ -212,6 +295,57 @@ export default {
return Response.json({ ok: true, service: 'crowclaw', runtime: 'cloudflare' });
}
+ if (request.method === 'GET' && url.pathname === '/healthz') {
+ return Response.json({ ok: true, service: 'crowclaw', runtime: 'cloudflare' });
+ }
+
+ if (request.method === 'GET' && url.pathname === '/readyz') {
+ return Response.json({ ok: true, service: 'crowclaw', runtime: 'cloudflare' });
+ }
+
+ if (request.method === 'GET' && url.pathname === '/.well-known/agent-skills') {
+ const stub = getSpecialSessionStub(env, '__system__');
+ return stub.fetch(new Request('https://internal/session/agent-skills', {
+ method: 'GET',
+ headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' }
+ }));
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/capabilities') {
+ return Response.json({
+ provider: {
+ status: env.OPENAI_API_KEY ? 'live' : 'disconnected',
+ detail: env.OPENAI_API_KEY ? (env.OPENAI_MODEL ?? 'gpt-4.1-mini') : 'OPENAI_API_KEY is not configured',
+ },
+ chat: { status: env.OPENAI_API_KEY ? 'live' : 'disconnected' },
+ streaming: { status: 'live' },
+ tools: { status: 'live', detail: 'Worker-safe tools' },
+ memory: { status: 'live', detail: 'D1-backed' },
+ skills: { status: 'live' },
+ scheduler: { status: 'live' },
+ gateway: { status: 'live' },
+ mcp: { status: 'simulated', detail: 'Worker-safe MCP subset' },
+ browser: { status: 'live' },
+ workspace: { status: 'live', detail: 'Durable Object workspace' },
+ });
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/tools') {
+ const stub = getSpecialSessionStub(env, '__system__');
+ return stub.fetch(new Request('https://internal/session/tools', {
+ method: 'GET',
+ headers: { 'content-type': request.headers.get('content-type') ?? 'application/json' }
+ }));
+ }
+
+ if (url.pathname.startsWith('/api/terminal/')) {
+ return unsupportedOnWorkers(url.pathname);
+ }
+
+ if (/^\/api\/code\/bridge\/(spawn|terminate|capabilities|process|ping|heartbeat)$/.test(url.pathname)) {
+ return unsupportedOnWorkers(url.pathname);
+ }
+
if (request.method === 'GET' && url.pathname === '/api/system/status') {
const stub = getSpecialSessionStub(env, '__system__');
return stub.fetch(new Request('https://internal/session/system/status', {
@@ -220,6 +354,54 @@ export default {
}));
}
+ if (request.method === 'GET' && url.pathname === '/api/diagnostics') {
+ return forwardToSystemSession(request, env, url, '/diagnostics');
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/config/snapshot') {
+ return forwardToSystemSession(request, env, url, '/config/snapshot');
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/config/schema') {
+ return forwardToSystemSession(request, env, url, '/config/schema');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/config/validate') {
+ return forwardToSystemSession(request, env, url, '/config/validate');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/config/diff') {
+ return forwardToSystemSession(request, env, url, '/config/diff');
+ }
+
+ if ((request.method === 'GET' || request.method === 'POST') && url.pathname === '/api/config/remote-access') {
+ return forwardToSystemSession(request, env, url, '/config/remote-access');
+ }
+
+ if ((request.method === 'GET' || request.method === 'POST') && url.pathname === '/api/memory/snapshot') {
+ return forwardToSystemSession(request, env, url, '/memory/snapshot');
+ }
+
+ if ((request.method === 'GET' || request.method === 'POST') && url.pathname === '/api/usage') {
+ return forwardToSystemSession(request, env, url, '/usage');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/usage/reset') {
+ return forwardToSystemSession(request, env, url, '/usage/reset');
+ }
+
+ if (url.pathname.startsWith('/api/security/')) {
+ return forwardToSystemSession(request, env, url, url.pathname.replace('/api', ''));
+ }
+
+ if ((request.method === 'GET' || request.method === 'POST') && url.pathname === '/api/providers/config') {
+ return forwardToSystemSession(request, env, url, '/providers/config');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/providers/test') {
+ return forwardToSystemSession(request, env, url, '/providers/test');
+ }
+
if (request.method === 'GET' && url.pathname === '/api/skills') {
const stub = getSpecialSessionStub(env, '__system__');
return stub.fetch(new Request('https://internal/session/skills', {
@@ -742,6 +924,22 @@ export default {
}));
}
+ if (request.method === 'GET' && url.pathname === '/api/learning/drafts/pending') {
+ return forwardToSystemSession(request, env, url, '/learning/drafts/pending');
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/learning/dashboard') {
+ return forwardToSystemSession(request, env, url, '/learning/dashboard');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/learning/auto-capture') {
+ return forwardToSystemSession(request, env, url, '/learning/auto-capture');
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/learning/match') {
+ return forwardToSystemSession(request, env, url, '/learning/match');
+ }
+
if (request.method === 'POST' && url.pathname === '/api/learning/drafts') {
const stub = getSpecialSessionStub(env, '__system__');
return stub.fetch(new Request('https://internal/session/learning/drafts', {
@@ -1223,6 +1421,9 @@ export default {
return stub.fetch(new Request(`https://internal/session/${actionPath}${search}`, init));
}
+ const unsupported = maybeUnsupportedOnWorkers(url.pathname);
+ if (unsupported) return unsupported;
+
return new Response('Not found', { status: 404 });
},
};
diff --git a/packages/runtime-node/package.json b/packages/runtime-node/package.json
index 2a0acce..89ba341 100644
--- a/packages/runtime-node/package.json
+++ b/packages/runtime-node/package.json
@@ -1,6 +1,6 @@
{
"name": "@crowclaw/runtime-node",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "module",
"main": "dist/index.js",
"types": "src/index.ts",
@@ -33,19 +33,27 @@
"access": "public"
},
"dependencies": {
- "@crowclaw/acp": "0.8.1",
- "@crowclaw/core": "0.8.1",
- "@crowclaw/gateway": "0.8.1",
- "@crowclaw/learning": "0.8.1",
- "@crowclaw/mcp": "0.8.1",
- "@crowclaw/mcp-server": "0.8.1",
- "@crowclaw/memory": "0.8.1",
- "@crowclaw/plugins": "0.8.1",
- "@crowclaw/providers": "0.8.1",
- "@crowclaw/scheduler": "0.8.1",
- "@crowclaw/storage": "0.8.1",
- "@crowclaw/tools": "0.8.1",
- "@crowclaw/workspace": "0.8.1"
+ "@crowclaw/acp": "0.8.2",
+ "@crowclaw/core": "0.8.2",
+ "@crowclaw/gateway": "0.8.2",
+ "@crowclaw/learning": "0.8.2",
+ "@crowclaw/mcp": "0.8.2",
+ "@crowclaw/mcp-server": "0.8.2",
+ "@crowclaw/memory": "0.8.2",
+ "@crowclaw/plugins": "0.8.2",
+ "@crowclaw/providers": "0.8.2",
+ "@crowclaw/scheduler": "0.8.2",
+ "@crowclaw/storage": "0.8.2",
+ "@crowclaw/tools": "0.8.2",
+ "@crowclaw/workspace": "0.8.2"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.9.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ }
},
"repository": {
"type": "git",
diff --git a/packages/runtime-node/src/agent-bootstrap.ts b/packages/runtime-node/src/agent-bootstrap.ts
new file mode 100644
index 0000000..ce52cc0
--- /dev/null
+++ b/packages/runtime-node/src/agent-bootstrap.ts
@@ -0,0 +1,475 @@
+import {
+ AgentLoop,
+ getAgentPreset,
+ restoreFromCheckpoint,
+ formatContextForPrompt,
+ scoreComplexity,
+ selectModelForComplexity,
+ type CheckpointStore,
+ type ContextEngineResult,
+ type ParsedSkillFile,
+ type ProviderAdapter,
+ type SessionCheckpoint,
+ type ToolCatalog,
+ type ToolDefinition,
+ type ToolExecutionContext,
+ type ToolExecutionResult,
+ type ToolExecutor,
+ type ToolManifest,
+ type SupportedLocale,
+} from '@crowclaw/core';
+import { isModelOverridable } from '@crowclaw/providers';
+import { InMemorySessionStore, type MessageStore as MessageStoreInterface } from '@crowclaw/storage';
+import { ToolRegistry } from '@crowclaw/tools';
+import { createProviderFromSlot } from './provider-factory.js';
+import type { RuntimeConfigStore } from './config-store.js';
+import type { EventBus } from './event-bus.js';
+import type { FeedbackLedger } from './runtime-support.js';
+import type { Logger } from './logger.js';
+import type { NodeRuntimeOptions } from './runtime-support.js';
+
+export interface ExecutionOverrides {
+ agentPreset?: string;
+ toolsetPreset?: string;
+ skillSlugs?: string[];
+ model?: string;
+}
+
+export interface AgentBootstrapContext {
+ options: NodeRuntimeOptions;
+ provider: () => ProviderAdapter;
+ store: InMemorySessionStore;
+ configStore: RuntimeConfigStore;
+ tools: ToolRegistry;
+ toolsetPresets: Map[number]>;
+ skillRegistry: {
+ resolve(): ParsedSkillFile[];
+ };
+ personaRegistry: {
+ getActive(): { prompt?: string };
+ getActivePrompt?(locale: SupportedLocale): string;
+ };
+ getPersonaPrompt: () => string | undefined;
+ plugins: unknown;
+ usageTracker: unknown;
+ checkpointStore: CheckpointStore;
+ autoResumedCheckpointIds: Set