Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion open-sse/executors/base.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown>): boolean {
const thinking = body.thinking as Record<string, unknown> | undefined;
return thinking?.type === "enabled" || thinking?.type === "adaptive";
Expand Down
2 changes: 1 addition & 1 deletion open-sse/mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const result = await toolDef.handler(parsedArgs);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
Expand Down
77 changes: 28 additions & 49 deletions open-sse/mcp-server/tools/dispatchTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
inputSchema: z.ZodTypeAny;
handler: (input: Record<string, unknown>, extra?: Record<string, unknown>) => Promise<object>;
}

Expand Down Expand Up @@ -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<string, unknown>,
extra?: Record<string, unknown>,
Expand All @@ -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<string, unknown>,
extra?: Record<string, unknown>,
Expand All @@ -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<string, unknown>,
extra?: Record<string, unknown>,
Expand Down
1 change: 1 addition & 0 deletions open-sse/translator/formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/lib/combos/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ComboModelStep {
weight: number;
label?: string;
tags?: string[];
allowedConnectionIds?: string[];
}

export interface ComboRefStep {
Expand Down Expand Up @@ -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:
Expand All @@ -289,6 +295,7 @@ export function normalizeComboStep(
weight,
...(label ? { label } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}),
};
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/db/combos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ function normalizeStoredCombo(
combo: JsonRecord,
db: ReturnType<typeof getDbInstance>,
extraNames: string[] = []
): JsonRecord {
) {
return normalizeComboRecord(combo, {
allCombos: getComboNameSet(db, extraNames),
}) as JsonRecord;
});
}

function parseComboRow(row: unknown): JsonRecord | null {
Expand Down
10 changes: 10 additions & 0 deletions src/lib/db/migrations/111_memory_typed_decay.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- TV6 — Typed memory decay: track access frequency so decay can grant access-based immunity.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Renumber the migration to avoid new gaps

Adding this migration as 111 while the highest existing migration before it is 107 introduces new unexplained gaps 108, 109, and 110. I checked scripts/check/check-migration-numbering.mjs: its frozen gap allowlist only permits 026 and 055, so this new file adds fresh migration-numbering anomalies that the repo's migration-numbering test/gate will report. Please either use the next contiguous slot or add documented compatibility/allowlist handling for the skipped versions.

Useful? React with 👍 / 👎.

--
-- `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Comment says "Both default" but last_accessed_at has no DEFAULT clause

Line 5-8 states both columns "default to a never-accessed baseline", but migration line 10 adds last_accessed_at as plain TEXT with no DEFAULT. Existing rows receive NULL, not a concrete default value. The code tolerates NULL (mapped to lastAccessedAt: null in rowToMemory), so runtime behavior is correct, but the comment overstates the column definition.

-- 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;
37 changes: 36 additions & 1 deletion src/lib/memory/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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, "id" | "createdAt" | "updatedAt">
memory: Omit<Memory, "id" | "createdAt" | "updatedAt" | "accessCount" | "lastAccessedAt">
): Promise<Memory> {
const db = getDbInstance();
const now = new Date().toISOString();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Unbounded IN clause may hit SQLite parameter limit

recordMemoryAccess builds WHERE id IN (...) with one placeholder per id. SQLite defaults to SQLITE_MAX_VARIABLE_NUMBER = 999, so arrays larger than ~990 IDs cause prepare() to throw "too many SQL variables". The catch swallows it, but the entire batch fails silently. The current call site (retrieval.ts:237) stays well under the limit, but this is an exported helper so future callers could exceed it. Consider batching or capping the array size.

`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
}
}
1 change: 0 additions & 1 deletion src/shared/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────

Expand Down
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down