diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index f352860e3c1..9124b27fd22 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -1,4 +1,5 @@ import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { mergeAbortSignals } from "./mergeAbortSignals.ts"; import { mergeClientAnthropicBeta } from "../config/anthropicHeaders.ts"; import { applyContextEditingToBody } from "../config/contextEditing.ts"; import { findOffendingField, stripGroqUnsupportedFields } from "../config/providerFieldStrips.ts"; @@ -172,7 +173,10 @@ export function mergeUpstreamExtraHeaders( } } -// extracted to ./mergeAbortSignals.ts (Wave 6 resilience leaf) +// extracted to ./mergeAbortSignals.ts (Wave 6 resilience leaf); re-exported here so +// existing barrel importers (e.g. ./antigravity) keep resolving it from "./base". +export { mergeAbortSignals } from "./mergeAbortSignals.ts"; + function hasActiveClaudeThinking(body: Record): boolean { const thinking = body.thinking as Record | undefined; return thinking?.type === "enabled" || thinking?.type === "adaptive"; diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index cd5a5c03ba5..70a9a57190d 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -1327,7 +1327,7 @@ export function createMcpServer(): McpServer { toolDef.name, async (args) => { try { - const parsedArgs = toolDef.inputSchema.parse(args ?? {}); + const parsedArgs = toolDef.inputSchema.parse(args ?? {}) as Record; const result = await toolDef.handler(parsedArgs); return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; } catch (err) { diff --git a/open-sse/mcp-server/tools/dispatchTools.ts b/open-sse/mcp-server/tools/dispatchTools.ts index 750c9cf5292..c5a6abc426c 100644 --- a/open-sse/mcp-server/tools/dispatchTools.ts +++ b/open-sse/mcp-server/tools/dispatchTools.ts @@ -11,10 +11,12 @@ * SUBSTRATE_HTTP_URL env var (e.g., http://localhost:8000) */ +import { z } from "zod"; + interface McpToolExtraLike { name: string; description: string; - inputSchema: Record; + inputSchema: z.ZodTypeAny; handler: (input: Record, extra?: Record) => Promise; } @@ -117,31 +119,22 @@ export const dispatchTools: McpToolExtraLike[] = [ name: "substrate_dispatch", description: "Dispatch a prompt to substrate with optional tier-based model routing (heavy=reasoning, main=standard, worker=fast). Returns task status and artifacts.", - inputSchema: { - type: "object", - properties: { - prompt: { - type: "string", - description: "The prompt, task description, or codebase instruction to dispatch.", - }, - tier: { - type: "string", - enum: ["heavy", "main", "worker"], - description: - "Model tier: heavy (gpt-5.5 reasoning), main (gpt-5.4-mini), worker (codex spark). Defaults to auto.", - }, - engine: { - type: "string", - enum: ["forge", "codex", "claude", "agentapi"], - description: "Execution engine. Defaults to forge.", - }, - cwd: { - type: "string", - description: "Working directory for execution context (optional).", - }, - }, - required: ["prompt"], - }, + inputSchema: z.object({ + prompt: z + .string() + .describe("The prompt, task description, or codebase instruction to dispatch."), + tier: z + .enum(["heavy", "main", "worker"]) + .optional() + .describe( + "Model tier: heavy (gpt-5.5 reasoning), main (gpt-5.4-mini), worker (codex spark). Defaults to auto." + ), + engine: z + .enum(["forge", "codex", "claude", "agentapi"]) + .optional() + .describe("Execution engine. Defaults to forge."), + cwd: z.string().optional().describe("Working directory for execution context (optional)."), + }), handler: async ( input: Record, extra?: Record, @@ -159,25 +152,14 @@ export const dispatchTools: McpToolExtraLike[] = [ name: "substrate_plan", description: "Invoke substrate planner to generate task plan without execution. Returns structured plan for review before running dispatch.", - inputSchema: { - type: "object", - properties: { - prompt: { - type: "string", - description: "Task description or request to plan.", - }, - engine: { - type: "string", - enum: ["forge", "codex", "claude", "agentapi"], - description: "Planner engine. Defaults to claude.", - }, - cwd: { - type: "string", - description: "Working directory context (optional).", - }, - }, - required: ["prompt"], - }, + inputSchema: z.object({ + prompt: z.string().describe("Task description or request to plan."), + engine: z + .enum(["forge", "codex", "claude", "agentapi"]) + .optional() + .describe("Planner engine. Defaults to claude."), + cwd: z.string().optional().describe("Working directory context (optional)."), + }), handler: async ( input: Record, extra?: Record, @@ -193,10 +175,7 @@ export const dispatchTools: McpToolExtraLike[] = [ { name: "substrate_health", description: "Health check: verify substrate HTTP server is reachable and operational.", - inputSchema: { - type: "object", - properties: {}, - }, + inputSchema: z.object({}), handler: async ( _input: Record, extra?: Record, diff --git a/open-sse/translator/formats.ts b/open-sse/translator/formats.ts index 4e0bd391f05..5ff2098b9dd 100644 --- a/open-sse/translator/formats.ts +++ b/open-sse/translator/formats.ts @@ -5,6 +5,7 @@ export const FORMATS = { OPENAI_RESPONSE: "openai-response", CLAUDE: "claude", GEMINI: "gemini", + GEMINI_CLI: "gemini-cli", CODEX: "codex", ANTIGRAVITY: "antigravity", KIRO: "kiro", diff --git a/src/lib/combos/steps.ts b/src/lib/combos/steps.ts index 33f08e58a5e..1d344d63249 100644 --- a/src/lib/combos/steps.ts +++ b/src/lib/combos/steps.ts @@ -11,6 +11,7 @@ export interface ComboModelStep { weight: number; label?: string; tags?: string[]; + allowedConnectionIds?: string[]; } export interface ComboRefStep { @@ -277,6 +278,11 @@ export function normalizeComboStep( const tags = Array.isArray(value.tags) ? value.tags.map((tag) => toTrimmedString(tag)).filter((tag): tag is string => !!tag) : undefined; + const allowedConnectionIds = Array.isArray(value.allowedConnectionIds) + ? value.allowedConnectionIds + .map((id) => toTrimmedString(id)) + .filter((id): id is string => !!id) + : undefined; return { id: @@ -289,6 +295,7 @@ export function normalizeComboStep( weight, ...(label ? { label } : {}), ...(tags && tags.length > 0 ? { tags } : {}), + ...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}), }; } diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index adfd68afe27..3430ca1f22a 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -64,10 +64,10 @@ function normalizeStoredCombo( combo: JsonRecord, db: ReturnType, extraNames: string[] = [] -): JsonRecord { +) { return normalizeComboRecord(combo, { allCombos: getComboNameSet(db, extraNames), - }) as JsonRecord; + }); } function parseComboRow(row: unknown): JsonRecord | null { diff --git a/src/lib/db/migrations/111_memory_typed_decay.sql b/src/lib/db/migrations/111_memory_typed_decay.sql new file mode 100644 index 00000000000..dd8fd93badd --- /dev/null +++ b/src/lib/db/migrations/111_memory_typed_decay.sql @@ -0,0 +1,10 @@ +-- TV6 — Typed memory decay: track access frequency so decay can grant access-based immunity. +-- +-- `access_count` increments each time a memory is injected into a prompt; `last_accessed_at` +-- records the most recent injection (and re-bases the decay clock so recently-used memories +-- survive). Both default to a never-accessed baseline, so every pre-existing row behaves as +-- freshly-created: its decay clock falls back to `created_at` and its access count starts at 0. +-- These columns only feed the OPT-IN, default-off decay sweep (`MEMORY_TYPED_DECAY_ENABLED`); +-- with the sweep disabled they are pure, harmless telemetry. +ALTER TABLE memories ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE memories ADD COLUMN last_accessed_at TEXT; diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts index 69be6cce2fc..e7861df888f 100644 --- a/src/lib/memory/store.ts +++ b/src/lib/memory/store.ts @@ -30,6 +30,8 @@ interface MemoryRow { created_at: string; updated_at: string; expires_at: string | null; + access_count?: number | null; + last_accessed_at?: string | null; } // Memory cache configuration @@ -79,6 +81,8 @@ function rowToMemory(row: MemoryRow): Memory { createdAt: new Date(String(row.created_at)), updatedAt: new Date(String(row.updated_at)), expiresAt: row.expires_at ? new Date(String(row.expires_at)) : null, + accessCount: typeof row.access_count === "number" ? row.access_count : 0, + lastAccessedAt: row.last_accessed_at ? new Date(String(row.last_accessed_at)) : null, }; } @@ -155,7 +159,7 @@ function scheduleVectorUpsert(id: string, content: string): void { * Create a new memory entry (UPSERT: updates existing if same apiKeyId + key) */ export async function createMemory( - memory: Omit + memory: Omit ): Promise { const db = getDbInstance(); const now = new Date().toISOString(); @@ -190,6 +194,10 @@ export async function createMemory( createdAt: new Date(String(existing.created_at)), updatedAt: new Date(now), expiresAt: memory.expiresAt ?? null, + accessCount: typeof existing.access_count === "number" ? existing.access_count : 0, + lastAccessedAt: existing.last_accessed_at + ? new Date(String(existing.last_accessed_at)) + : null, }; // Invalidate and update cache @@ -259,6 +267,8 @@ export async function createMemory( createdAt: new Date(now), updatedAt: new Date(now), expiresAt: memory.expiresAt ?? null, + accessCount: 0, + lastAccessedAt: null, }; // Cache the newly created memory @@ -543,3 +553,28 @@ export function getMemoryTokensUsed(apiKeyId?: string): number { const row = stmt.get(...(apiKeyId ? [apiKeyId] : [])) as { tokensUsed: number } | undefined; return row?.tokensUsed ?? 0; } + +/** + * TV6: record that the given memories were injected into a prompt. Increments + * `access_count` and stamps `last_accessed_at` so the decay clock re-bases and access + * immunity can accrue. Best-effort and non-blocking by contract — callers fire-and-forget; + * any error (DB closed in test teardown, missing columns pre-migration) is swallowed so + * retrieval is never impacted. + */ +export function recordMemoryAccess(ids: string[]): void { + if (!Array.isArray(ids) || ids.length === 0) return; + const unique = Array.from(new Set(ids.filter((id) => typeof id === "string" && id))); + if (unique.length === 0) return; + try { + const db = getDbInstance(); + const placeholders = unique.map(() => "?").join(", "); + const stmt = db.prepare( + `UPDATE memories SET access_count = access_count + 1, last_accessed_at = ? ` + + `WHERE id IN (${placeholders})` + ); + stmt.run(new Date().toISOString(), ...unique); + for (const id of unique) invalidateMemoryCache(id); + } catch { + // intentional swallow — access tracking is opportunistic telemetry, never load-bearing + } +} diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index db20351b3e9..b4f471ac845 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -270,7 +270,6 @@ function validateProviderSpecificData( // Re-export validation helpers from dedicated module to avoid webpack barrel-file // optimization bug that truncates exports from large files. export { validateBody, isValidationFailure } from "./helpers"; -export type { ValidationResult } from "./helpers"; // ──── Provider Schemas ──── diff --git a/src/types/index.ts b/src/types/index.ts index ba28c26d70c..2839ebda54b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,7 +5,7 @@ * Import from "@/types" in any file. */ -export type { ProviderConnection, ProviderNode, ModelCooldownErrorPayload } from "./provider"; +export type { ModelCooldownErrorPayload } from "./provider"; export type { ApiKey } from "./apiKey"; export type { Combo, ComboStrategy, ComboNode } from "./combo"; export type { UsageEntry, UsageStats, ProviderUsageStats, ModelUsageStats, CallLog } from "./usage";