diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 6ade6025bcbc..fb67a297df16 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -83,6 +83,7 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; +import { DittoHarnessServiceDisabled } from "../src/dittoHarness/DittoHarnessService.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -337,6 +338,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge(DittoHarnessServiceDisabled), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..ad4c1763712f 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -47,6 +47,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, + [WS_METHODS.dittoHarnessStatus]: AuthOrchestrationReadScope, + [WS_METHODS.dittoHarnessSaveMemory]: AuthOrchestrationOperateScope, + [WS_METHODS.dittoHarnessSearchMemories]: AuthOrchestrationReadScope, + [WS_METHODS.dittoHarnessSearchSubjects]: AuthOrchestrationReadScope, + [WS_METHODS.dittoHarnessDream]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, diff --git a/apps/server/src/dittoHarness/DittoHarnessRuntime.test.ts b/apps/server/src/dittoHarness/DittoHarnessRuntime.test.ts new file mode 100644 index 000000000000..bf585cbfc384 --- /dev/null +++ b/apps/server/src/dittoHarness/DittoHarnessRuntime.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { DittoHarnessService, DittoHarnessServiceLive } from "./DittoHarnessService.ts"; +import { dittoActionModelOptions } from "./DittoHarnessRuntime.ts"; + +describe("DittoHarnessRuntime", () => { + it("uses action model settings when configured", () => { + assert.deepEqual( + dittoActionModelOptions({ + chatProvider: "ollama", + chatModel: "qwen3:4b", + chatBaseUrl: "http://127.0.0.1:11434", + actionProvider: "openrouter", + actionModel: "google/gemini-3.5-flash", + actionBaseUrl: "", + }), + { + provider: "openrouter", + model: "google/gemini-3.5-flash", + }, + ); + }); + + it("keeps the chat base URL only when the action provider is not overridden", () => { + assert.deepEqual( + dittoActionModelOptions({ + chatProvider: "ollama", + chatModel: "qwen3:4b", + chatBaseUrl: "http://127.0.0.1:11434", + actionProvider: "ollama", + actionModel: "qwen3-coder:latest", + actionBaseUrl: "", + }), + { + provider: "ollama", + model: "qwen3-coder:latest", + baseUrl: "http://127.0.0.1:11434", + }, + ); + }); + + it("falls back to chat model settings when action settings are absent", () => { + assert.deepEqual( + dittoActionModelOptions({ + chatProvider: "vllm", + chatModel: "local-tool-model", + chatBaseUrl: "http://localhost:8000/v1", + }), + { + provider: "vllm", + model: "local-tool-model", + baseUrl: "http://localhost:8000/v1", + }, + ); + }); +}); + +describe("DittoHarnessService", () => { + const supportLayer = Layer.mergeAll( + ServerSettingsService.layerTest({ + dittoHarness: { + ...DEFAULT_SERVER_SETTINGS.dittoHarness, + enabled: true, + enablePromptContext: true, + embedder: "hash", + }, + }), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-ditto-harness-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + NodeServices.layer, + ); + const layer = Layer.mergeAll( + DittoHarnessServiceLive.pipe(Layer.provide(supportLayer)), + supportLayer, + ); + + it.effect("fails open when prompt context cannot open the native harness", () => + Effect.gen(function* () { + const previousNodePath = process.env.DITTO_HARNESS_NODE_PATH; + process.env.DITTO_HARNESS_NODE_PATH = "/tmp/t3-missing-ditto-harness-node"; + + const result = yield* Effect.gen(function* () { + const service = yield* DittoHarnessService; + return yield* service.buildPromptContext({ + userInput: "what should I remember about this project?", + sessionId: "thread-1", + }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousNodePath === undefined) { + delete process.env.DITTO_HARNESS_NODE_PATH; + } else { + process.env.DITTO_HARNESS_NODE_PATH = previousNodePath; + } + }), + ), + ); + + assert.equal(result, null); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/dittoHarness/DittoHarnessRuntime.ts b/apps/server/src/dittoHarness/DittoHarnessRuntime.ts new file mode 100644 index 000000000000..5500f30f7532 --- /dev/null +++ b/apps/server/src/dittoHarness/DittoHarnessRuntime.ts @@ -0,0 +1,302 @@ +import { DittoHarnessError, type DittoHarnessSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as Path from "effect/Path"; +import * as NodeModule from "node:module"; +import * as NodeURL from "node:url"; + +import type { ServerConfig } from "../config.ts"; + +type ServerConfigShape = ServerConfig["Service"]; + +export interface NativeHarness { + readonly seedUser: (uid: string) => Promise; + readonly saveMemory: (json: unknown) => Promise; + readonly searchMemories: ( + uid: string, + query: string, + opts?: { + readonly sessionId?: string; + readonly limit?: number; + readonly minSimilarity?: number; + }, + ) => Promise>; + readonly searchSubjects: (uid: string, query: string) => Promise>; + readonly dream: (uid: string, opts?: DittoHarnessModelOptions) => Promise; + readonly chat: ( + uid: string, + message: string, + opts?: DittoHarnessModelOptions, + ) => Promise; +} + +export interface NativeHarnessModule { + readonly Harness: { + readonly open: ( + dbPath: string, + opts?: { + readonly ollamaBaseUrl?: string; + readonly embedder?: string; + }, + ) => Promise; + }; + readonly harnessVersion?: () => string; +} + +export interface DittoHarnessModelOptions { + readonly provider?: string; + readonly model?: string; + readonly baseUrl?: string; + readonly sessionId?: string; + readonly maxTurns?: number; + readonly saveMemory?: boolean; + readonly maxMemories?: number; + readonly refine?: boolean; +} + +export interface DittoHarnessChatTurnResult { + readonly response: string; + readonly cost: number; + readonly toolCalls: ReadonlyArray; +} + +export interface OpenHarness { + readonly key: string; + readonly databasePath: string; + readonly settings: Settings; + readonly harness: NativeHarness; + readonly version: string | undefined; +} + +export type DittoRuntimeSettings = Pick< + DittoHarnessSettings, + | "enabled" + | "userId" + | "databasePath" + | "embedder" + | "ollamaBaseUrl" + | "chatProvider" + | "chatModel" + | "chatBaseUrl" + | "actionProvider" + | "actionModel" + | "actionBaseUrl" +> & { + readonly saveMemory?: boolean; +}; + +const requireFromHere = NodeModule.createRequire(import.meta.url); + +export function toDittoHarnessMessage(cause: unknown): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + return String(cause); +} + +export function explainDittoHarnessFailure(message: string): string { + if (message.includes("does not support tools")) { + return [ + message, + "The Ditto provider uses the harness memory tools, so the configured Ollama model must support tool calling.", + "Use a tools-capable local model such as qwen3:4b, then run `ollama pull qwen3:4b`.", + ].join(" "); + } + return message; +} + +export function trimToUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +export function dittoHarnessError( + kind: DittoHarnessError["kind"], + message: string, + cause?: unknown, +) { + return new DittoHarnessError({ + kind, + message, + ...(cause !== undefined ? { cause } : {}), + }); +} + +export function resolveDittoDatabasePath(input: { + readonly path: Path.Path; + readonly config: ServerConfigShape; + readonly settings: Pick; +}): string { + const configured = trimToUndefined(input.settings.databasePath); + if (!configured) { + return input.path.join(input.config.stateDir, "ditto-harness.sqlite"); + } + return input.path.isAbsolute(configured) + ? configured + : input.path.resolve(input.config.stateDir, configured); +} + +function nativeLoadCandidates(path: Path.Path): ReadonlyArray { + const explicit = trimToUndefined(process.env.DITTO_HARNESS_NODE_PATH); + if (explicit) { + return [explicit]; + } + + const currentDir = path.dirname(NodeURL.fileURLToPath(import.meta.url)); + return [ + "@ditto/harness-node", + path.resolve(process.cwd(), "../ditto-harness/rust/crates/node"), + path.resolve(currentDir, "../../../../../ditto-harness/rust/crates/node"), + ]; +} + +export function loadDittoNativeModule(path: Path.Path): NativeHarnessModule { + const failures: string[] = []; + for (const candidate of nativeLoadCandidates(path)) { + try { + const loaded = requireFromHere(candidate) as unknown; + if ( + loaded && + typeof loaded === "object" && + "Harness" in loaded && + typeof (loaded as NativeHarnessModule).Harness?.open === "function" + ) { + return loaded as NativeHarnessModule; + } + failures.push(`${candidate}: module did not export Harness.open`); + } catch (cause) { + failures.push(`${candidate}: ${toDittoHarnessMessage(cause)}`); + } + } + throw new Error( + [ + "Ditto Harness native binding is unavailable.", + "Build ../ditto-harness/rust/crates/node or set DITTO_HARNESS_NODE_PATH.", + ...failures.map((failure) => `- ${failure}`), + ].join("\n"), + ); +} + +function cacheKey(input: { + readonly databasePath: string; + readonly settings: DittoRuntimeSettings; +}): string { + return JSON.stringify({ + databasePath: input.databasePath, + userId: input.settings.userId, + embedder: input.settings.embedder, + ollamaBaseUrl: input.settings.ollamaBaseUrl, + chatProvider: input.settings.chatProvider, + chatModel: input.settings.chatModel, + chatBaseUrl: input.settings.chatBaseUrl, + actionProvider: input.settings.actionProvider, + actionModel: input.settings.actionModel, + actionBaseUrl: input.settings.actionBaseUrl, + saveMemory: input.settings.saveMemory, + }); +} + +export function dittoModelOptions( + settings: Pick, + extra?: Omit, +): DittoHarnessModelOptions { + const model = trimToUndefined(settings.chatModel); + const baseUrl = trimToUndefined(settings.chatBaseUrl); + return { + provider: settings.chatProvider, + ...(typeof settings.saveMemory === "boolean" ? { saveMemory: settings.saveMemory } : {}), + ...extra, + ...(model ? { model } : {}), + ...(baseUrl ? { baseUrl } : {}), + }; +} + +export function dittoActionModelOptions( + settings: Pick< + DittoRuntimeSettings, + | "chatProvider" + | "chatModel" + | "chatBaseUrl" + | "actionProvider" + | "actionModel" + | "actionBaseUrl" + | "saveMemory" + >, + extra?: Omit, +): DittoHarnessModelOptions { + const provider = settings.actionProvider ?? settings.chatProvider; + const model = trimToUndefined(settings.actionModel) ?? trimToUndefined(settings.chatModel); + const canUseChatBaseUrl = + settings.actionProvider === undefined || settings.actionProvider === settings.chatProvider; + const baseUrl = + trimToUndefined(settings.actionBaseUrl) ?? + (canUseChatBaseUrl ? trimToUndefined(settings.chatBaseUrl) : undefined); + return { + provider, + ...(typeof settings.saveMemory === "boolean" ? { saveMemory: settings.saveMemory } : {}), + ...extra, + ...(model ? { model } : {}), + ...(baseUrl ? { baseUrl } : {}), + }; +} + +export function makeDittoHarnessOpener(input: { + readonly config: ServerConfigShape; + readonly path: Path.Path; +}) { + const cached = new Map(); + + return ( + settings: Settings, + ): Effect.Effect, DittoHarnessError> => + Effect.gen(function* () { + const databasePath = resolveDittoDatabasePath({ + path: input.path, + config: input.config, + settings, + }); + if (!settings.enabled) { + return yield* dittoHarnessError("disabled", "Ditto Harness is disabled."); + } + + const key = cacheKey({ databasePath, settings }); + const cachedHarness = cached.get(key); + if (cachedHarness !== undefined) { + return cachedHarness as OpenHarness; + } + + const opened = yield* Effect.tryPromise({ + try: async () => { + const native = loadDittoNativeModule(input.path); + const openOptions: { + readonly embedder?: string; + readonly ollamaBaseUrl?: string; + } = { + embedder: settings.embedder, + }; + const ollamaBaseUrl = trimToUndefined(settings.ollamaBaseUrl); + const harness = await native.Harness.open( + databasePath, + ollamaBaseUrl ? { ...openOptions, ollamaBaseUrl } : openOptions, + ); + await harness.seedUser(settings.userId); + return { + key, + settings, + databasePath, + harness, + version: native.harnessVersion?.(), + } satisfies OpenHarness; + }, + catch: (cause) => + dittoHarnessError( + "unavailable", + `Failed to open Ditto Harness: ${toDittoHarnessMessage(cause)}`, + cause, + ), + }); + cached.set(key, opened); + return opened; + }); +} + +export type DittoHarnessOpener = ReturnType; diff --git a/apps/server/src/dittoHarness/DittoHarnessService.ts b/apps/server/src/dittoHarness/DittoHarnessService.ts new file mode 100644 index 000000000000..acd6da189b10 --- /dev/null +++ b/apps/server/src/dittoHarness/DittoHarnessService.ts @@ -0,0 +1,352 @@ +import { + DEFAULT_SERVER_SETTINGS, + DittoHarnessError, + type DittoHarnessDreamInput, + type DittoHarnessDreamResult, + type DittoHarnessSaveMemoryInput, + type DittoHarnessSaveMemoryResult, + type DittoHarnessSearchMemoriesInput, + type DittoHarnessSearchMemoriesResult, + type DittoHarnessSearchSubjectsInput, + type DittoHarnessSearchSubjectsResult, + type DittoHarnessSettings, + type DittoHarnessStatus, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Context from "effect/Context"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; + +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { + dittoActionModelOptions, + dittoHarnessError, + makeDittoHarnessOpener, + resolveDittoDatabasePath, + toDittoHarnessMessage, +} from "./DittoHarnessRuntime.ts"; + +type ServerConfigShape = ServerConfig["Service"]; + +export interface DittoHarnessPromptContextInput { + readonly userInput: string; + readonly sessionId?: string | undefined; +} + +export interface DittoHarnessServiceShape { + readonly status: Effect.Effect; + readonly saveMemory: ( + input: DittoHarnessSaveMemoryInput, + ) => Effect.Effect; + readonly searchMemories: ( + input: DittoHarnessSearchMemoriesInput, + ) => Effect.Effect; + readonly searchSubjects: ( + input: DittoHarnessSearchSubjectsInput, + ) => Effect.Effect; + readonly dream: ( + input: DittoHarnessDreamInput, + ) => Effect.Effect; + readonly buildPromptContext: ( + input: DittoHarnessPromptContextInput, + ) => Effect.Effect; +} + +export class DittoHarnessService extends Context.Service< + DittoHarnessService, + DittoHarnessServiceShape +>()("t3/dittoHarness/DittoHarnessService") {} + +export const DittoHarnessServiceDisabled = Layer.succeed( + DittoHarnessService, + DittoHarnessService.of({ + status: Effect.map(DateTime.now, (now) => ({ + enabled: false, + state: "disabled" as const, + checkedAt: DateTime.formatIso(now), + message: "Ditto Harness is disabled.", + })), + saveMemory: () => + Effect.fail( + new DittoHarnessError({ + kind: "disabled", + message: "Ditto Harness is disabled.", + }), + ), + searchMemories: () => Effect.succeed({ memories: [] }), + searchSubjects: () => Effect.succeed({ subjects: [] }), + dream: () => + Effect.fail( + new DittoHarnessError({ + kind: "disabled", + message: "Ditto Harness dream is disabled.", + }), + ), + buildPromptContext: () => Effect.succeed(null), + }), +); + +function toMessage(cause: unknown): string { + return toDittoHarnessMessage(cause); +} + +function truncate(value: string, maxChars: number): string { + return value.length <= maxChars ? value : `${value.slice(0, maxChars - 3)}...`; +} + +function readStringField(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const raw = (value as Record)[key]; + return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined; +} + +function formatMemoryPreview(memory: unknown, index: number): string { + const id = readStringField(memory, "id"); + const title = readStringField(memory, "title"); + const summary = readStringField(memory, "summary"); + const prompt = readStringField(memory, "prompt"); + const response = readStringField(memory, "response"); + const parts = [ + `${index + 1}. ${title ?? id ?? "Memory"}`, + summary ? `summary: ${truncate(summary, 360)}` : undefined, + prompt ? `user: ${truncate(prompt, 420)}` : undefined, + response ? `assistant: ${truncate(response, 420)}` : undefined, + ].filter((part): part is string => part !== undefined); + return parts.join("\n"); +} + +function resolvedDatabasePath( + path: Path.Path, + config: ServerConfigShape, + settings: DittoHarnessSettings, +): string { + return resolveDittoDatabasePath({ + path, + config, + settings, + }); +} + +function modelOptions(settings: DittoHarnessSettings) { + return dittoActionModelOptions(settings); +} + +function harnessError(kind: DittoHarnessError["kind"], message: string, cause?: unknown) { + return dittoHarnessError(kind, message, cause); +} + +const makeDittoHarnessService = Effect.gen(function* () { + const config = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const path = yield* Path.Path; + const openHarnessForSettings = makeDittoHarnessOpener({ config, path }); + + const checkedAt = Effect.map(DateTime.now, DateTime.formatIso); + const readSettings = settingsService.getSettings.pipe( + Effect.map((settings) => settings.dittoHarness), + Effect.mapError((cause) => + harnessError( + "operation_failed", + `Failed to read Ditto Harness settings: ${toMessage(cause)}`, + cause, + ), + ), + ); + + const openHarness = Effect.fn("DittoHarnessService.openHarness")(function* () { + const settings = yield* readSettings; + return yield* openHarnessForSettings(settings); + }); + + return { + status: Effect.gen(function* () { + const now = yield* checkedAt; + const settingsResult = yield* Effect.result(readSettings); + if (Result.isFailure(settingsResult)) { + return { + enabled: false, + state: "error", + checkedAt: now, + databasePath: resolvedDatabasePath(path, config, DEFAULT_SERVER_SETTINGS.dittoHarness), + message: settingsResult.failure.message, + } satisfies DittoHarnessStatus; + } + + const settings = settingsResult.success; + const databasePath = resolvedDatabasePath(path, config, settings); + if (!settings.enabled) { + return { + enabled: false, + state: "disabled", + checkedAt: now, + databasePath, + message: "Ditto Harness is disabled.", + } satisfies DittoHarnessStatus; + } + + const opened = yield* Effect.result(openHarness()); + if (Result.isFailure(opened)) { + return { + enabled: true, + state: opened.failure.kind === "unavailable" ? "unavailable" : "error", + checkedAt: now, + databasePath, + message: opened.failure.message, + } satisfies DittoHarnessStatus; + } + + return { + enabled: true, + state: "ready", + checkedAt: now, + databasePath, + ...(opened.success.version ? { version: opened.success.version } : {}), + } satisfies DittoHarnessStatus; + }), + + saveMemory: (input) => + Effect.gen(function* () { + const opened = yield* openHarness(); + const memory = yield* Effect.tryPromise({ + try: () => + opened.harness.saveMemory({ + userId: opened.settings.userId, + prompt: input.prompt, + response: input.response, + summary: input.summary ?? "", + sessionId: input.sessionId ?? "", + source: input.source ?? "t3-code", + sourceContext: input.sourceContext ?? "", + timestamp: input.timestamp, + timezoneOffset: input.timezoneOffset ?? 0, + subjects: input.subjects ?? [], + }), + catch: (cause) => + harnessError( + "operation_failed", + `Failed to save Ditto memory: ${toMessage(cause)}`, + cause, + ), + }); + return { memory }; + }), + + searchMemories: (input) => + Effect.gen(function* () { + const opened = yield* openHarness(); + const memories = yield* Effect.tryPromise({ + try: () => + opened.harness.searchMemories(opened.settings.userId, input.query, { + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.minSimilarity !== undefined ? { minSimilarity: input.minSimilarity } : {}), + }), + catch: (cause) => + harnessError( + "operation_failed", + `Failed to search Ditto memories: ${toMessage(cause)}`, + cause, + ), + }); + return { memories }; + }), + + searchSubjects: (input) => + Effect.gen(function* () { + const opened = yield* openHarness(); + const subjects = yield* Effect.tryPromise({ + try: () => opened.harness.searchSubjects(opened.settings.userId, input.query), + catch: (cause) => + harnessError( + "operation_failed", + `Failed to search Ditto subjects: ${toMessage(cause)}`, + cause, + ), + }); + return { + subjects: input.limit !== undefined ? subjects.slice(0, input.limit) : subjects, + }; + }), + + dream: (input) => + Effect.gen(function* () { + const opened = yield* openHarness(); + if (!opened.settings.dreamEnabled) { + return yield* harnessError("disabled", "Ditto Harness dream is disabled."); + } + const report = yield* Effect.tryPromise({ + try: () => + opened.harness.dream(opened.settings.userId, { + ...modelOptions(opened.settings), + ...(input.maxMemories !== undefined ? { maxMemories: input.maxMemories } : {}), + ...(input.refine !== undefined ? { refine: input.refine } : {}), + }), + catch: (cause) => + harnessError( + "operation_failed", + `Failed to run Ditto Harness dream: ${toMessage(cause)}`, + cause, + ), + }); + return { report }; + }), + + buildPromptContext: (input) => + Effect.gen(function* () { + const settingsResult = yield* Effect.result(readSettings); + if (Result.isFailure(settingsResult)) { + yield* Effect.logWarning("failed to read Ditto Harness settings for prompt context", { + detail: settingsResult.failure.message, + }); + return null; + } + + const settings = settingsResult.success; + if (!settings.enabled || !settings.enablePromptContext || input.userInput.trim() === "") { + return null; + } + + const result = yield* Effect.result( + openHarness().pipe( + Effect.flatMap((opened) => + Effect.tryPromise({ + try: () => + opened.harness.searchMemories(opened.settings.userId, input.userInput, { + ...(input.sessionId ? { sessionId: input.sessionId } : {}), + limit: opened.settings.promptContextLimit, + }), + catch: (cause) => + harnessError( + "operation_failed", + `Failed to build Ditto memory context: ${toMessage(cause)}`, + cause, + ), + }), + ), + ), + ); + + if (Result.isFailure(result)) { + yield* Effect.logWarning("failed to build Ditto Harness prompt context", { + detail: result.failure.message, + }); + return null; + } + if (result.success.length === 0) { + return null; + } + + return [ + "Relevant memory context for this turn:", + result.success.map(formatMemoryPreview).join("\n\n"), + ].join("\n"); + }), + } satisfies DittoHarnessServiceShape; +}); + +export const DittoHarnessServiceLive = Layer.effect(DittoHarnessService, makeDittoHarnessService); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3588244d827..6f5951828dfe 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -62,6 +62,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { DittoHarnessServiceDisabled } from "../../dittoHarness/DittoHarnessService.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); @@ -427,6 +428,7 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(DittoHarnessServiceDisabled), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 812893d8c843..5d57483ffa55 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -47,6 +47,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { DittoHarnessService } from "../../dittoHarness/DittoHarnessService.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -312,6 +313,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const dittoHarness = yield* Effect.serviceOption(DittoHarnessService); const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -794,6 +796,17 @@ const make = Effect.gen(function* () { } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + const promptContext = + normalizedInput !== undefined && Option.isSome(dittoHarness) + ? yield* dittoHarness.value.buildPromptContext({ + userInput: normalizedInput, + sessionId: input.threadId, + }) + : null; + const providerInput = + normalizedInput !== undefined && promptContext !== null + ? `${promptContext}\n\nUser request:\n${normalizedInput}` + : normalizedInput; const activeSession = yield* providerService .listSessions() .pipe( @@ -824,7 +837,7 @@ const make = Effect.gen(function* () { return { threadId: input.threadId, - ...(normalizedInput ? { input: normalizedInput } : {}), + ...(providerInput ? { input: providerInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), diff --git a/apps/server/src/provider/Drivers/DittoDriver.ts b/apps/server/src/provider/Drivers/DittoDriver.ts new file mode 100644 index 000000000000..5433a6fbf2f9 --- /dev/null +++ b/apps/server/src/provider/Drivers/DittoDriver.ts @@ -0,0 +1,126 @@ +import { DittoSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeDittoHarnessOpener } from "../../dittoHarness/DittoHarnessRuntime.ts"; +import { makeDittoTextGeneration } from "../../textGeneration/DittoTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeDittoAdapter } from "../Layers/DittoAdapter.ts"; +import { checkDittoProviderStatus, makePendingDittoProvider } from "../Layers/DittoProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; + +const decodeDittoSettings = Schema.decodeSync(DittoSettings); +const DRIVER_KIND = ProviderDriverKind.make("ditto"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(2); + +export type DittoDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | Path.Path + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const DittoDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Ditto", + supportsMultipleInstances: true, + }, + configSchema: DittoSettings, + defaultConfig: (): DittoSettings => decodeDittoSettings({}), + create: ({ instanceId, displayName, accentColor, enabled, config }) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const path = yield* Path.Path; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies DittoSettings; + const opener = makeDittoHarnessOpener({ + config: serverConfig, + path, + }); + + const adapter = yield* makeDittoAdapter(effectiveConfig, { + instanceId, + opener, + }); + const textGeneration = yield* makeDittoTextGeneration(effectiveConfig, opener); + const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingDittoProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider: checkDittoProviderStatus(effectiveConfig, opener).pipe( + Effect.map(stampIdentity), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Ditto snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/DittoAdapter.test.ts b/apps/server/src/provider/Layers/DittoAdapter.test.ts new file mode 100644 index 000000000000..3ee753442a50 --- /dev/null +++ b/apps/server/src/provider/Layers/DittoAdapter.test.ts @@ -0,0 +1,114 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + DittoHarnessError, + DittoSettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { + DittoHarnessOpener, + DittoRuntimeSettings, + NativeHarness, + OpenHarness, +} from "../../dittoHarness/DittoHarnessRuntime.ts"; +import { makeDittoAdapter } from "./DittoAdapter.ts"; + +const settings = Schema.decodeSync(DittoSettings)({}); +const instanceId = ProviderInstanceId.make("ditto"); + +const harness = (chat: NativeHarness["chat"]): NativeHarness => ({ + seedUser: async () => undefined, + saveMemory: async () => ({}), + searchMemories: async () => [], + searchSubjects: async () => [], + dream: async () => ({}), + chat, +}); + +const opener = + (nativeHarness: NativeHarness): DittoHarnessOpener => + ( + runtimeSettings: Settings, + ): Effect.Effect, DittoHarnessError> => + Effect.succeed({ + key: "test", + databasePath: ":memory:", + settings: runtimeSettings, + harness: nativeHarness, + version: "test", + }); + +const failingOpener: DittoHarnessOpener = ( + _runtimeSettings: Settings, +): Effect.Effect, DittoHarnessError> => + Effect.fail( + new DittoHarnessError({ + kind: "unavailable", + message: "open failed", + }), + ); + +const makeAdapter = (open: DittoHarnessOpener) => + makeDittoAdapter(settings, { instanceId, opener: open }).pipe(Effect.provide(NodeServices.layer)); + +it.effect("keeps turn IDs monotonic after rollback", () => + Effect.gen(function* () { + const adapter = yield* makeAdapter( + opener( + harness(async () => ({ + response: "ok", + cost: 0, + toolCalls: [], + })), + ), + ); + const threadId = ThreadId.make("ditto-rollback"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("ditto"), + threadId, + runtimeMode: "full-access", + }); + + const first = yield* adapter.sendTurn({ threadId, input: "first" }); + yield* adapter.rollbackThread(threadId, 1); + const second = yield* adapter.sendTurn({ threadId, input: "second" }); + + assert.notEqual(first.turnId, second.turnId); + }), +); + +it.effect("finalizes the active turn when opening the harness fails", () => + Effect.gen(function* () { + const adapter = yield* makeAdapter(failingOpener); + const threadId = ThreadId.make("ditto-open-failure"); + const eventsFiber = yield* Stream.take(adapter.streamEvents, 8).pipe( + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("ditto"), + threadId, + runtimeMode: "full-access", + }); + + const error = yield* adapter.sendTurn({ threadId, input: "hello" }).pipe(Effect.flip); + const sessions = yield* adapter.listSessions(); + const events = yield* Fiber.join(eventsFiber); + + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(sessions[0]?.status, "error"); + assert.equal(sessions[0]?.activeTurnId, undefined); + assert.isTrue( + Array.from(events).some( + (event) => event.type === "turn.completed" && event.payload.state === "failed", + ), + ); + }), +); diff --git a/apps/server/src/provider/Layers/DittoAdapter.ts b/apps/server/src/provider/Layers/DittoAdapter.ts new file mode 100644 index 000000000000..b3b33e760b63 --- /dev/null +++ b/apps/server/src/provider/Layers/DittoAdapter.ts @@ -0,0 +1,579 @@ +import { + EventId, + type DittoSettings, + ProviderDriverKind, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderTurnStartResult, + type ProviderUserInputAnswers, + RuntimeItemId, + ThreadId, + TurnId, + type ApprovalRequestId, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import { + dittoModelOptions, + explainDittoHarnessFailure, + toDittoHarnessMessage, + type DittoHarnessOpener, +} from "../../dittoHarness/DittoHarnessRuntime.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { + ProviderAdapterShape, + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../Services/ProviderAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("ditto"); +const DEFAULT_MAX_TURNS = 8; + +interface DittoAdapterOptions { + readonly instanceId: ProviderInstanceId; + readonly opener: DittoHarnessOpener; +} + +interface SessionState { + session: ProviderSession; + snapshot: ProviderThreadSnapshot; + nextTurnSequence: number; + activeTurnId: TurnId | undefined; + interruptedTurnIds: Set; + stopped: boolean; +} + +function providerMismatch(input: ProviderSessionStartInput): ProviderAdapterValidationError | null { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + return null; +} + +function sessionNotFound(threadId: ThreadId): ProviderAdapterSessionNotFoundError { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: String(threadId), + }); +} + +function sessionClosed(threadId: ThreadId): ProviderAdapterSessionClosedError { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId: String(threadId), + }); +} + +function appendAttachmentSummary(input: ProviderSendTurnInput): string { + const prompt = input.input?.trim() ?? ""; + const attachments = input.attachments ?? []; + if (attachments.length === 0) { + return prompt; + } + + const lines = attachments.map( + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + return [prompt, "", "Attachment metadata available to T3 Code for this turn:", ...lines].join( + "\n", + ); +} + +export const makeDittoAdapter = Effect.fn("makeDittoAdapter")(function* ( + settings: DittoSettings, + options: DittoAdapterOptions, +) { + const crypto = yield* Crypto.Crypto; + const runtimeEvents = yield* Queue.unbounded(); + const sessions = new Map(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const emit = (event: ProviderRuntimeEvent): Effect.Effect => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + const randomUUIDv4 = (operation: string, threadId?: ThreadId) => + crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue: threadId + ? `Failed to generate Ditto runtime identifier for thread '${threadId}'.` + : "Failed to generate Ditto runtime identifier.", + cause, + }), + ), + ); + const eventId = (threadId: ThreadId) => + randomUUIDv4("runtimeEvent", threadId).pipe(Effect.map(EventId.make)); + const itemId = (threadId: ThreadId) => + randomUUIDv4("runtimeItem", threadId).pipe(Effect.map((id) => RuntimeItemId.make(id))); + + const baseEvent = Effect.fn("DittoAdapter.baseEvent")(function* ( + state: SessionState, + extra?: { + readonly turnId?: TurnId | undefined; + readonly itemId?: RuntimeItemId | undefined; + }, + ) { + return { + eventId: yield* eventId(state.session.threadId), + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId: state.session.threadId, + createdAt: yield* nowIso, + ...(extra?.turnId !== undefined ? { turnId: extra.turnId } : {}), + ...(extra?.itemId !== undefined ? { itemId: extra.itemId } : {}), + } as const; + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const state = sessions.get(threadId); + if (!state) { + return Effect.fail(sessionNotFound(threadId)); + } + if (state.stopped || state.session.status === "closed") { + return Effect.fail(sessionClosed(threadId)); + } + return Effect.succeed(state); + }; + + function withoutActiveTurn(session: ProviderSession): Omit { + const { activeTurnId: _activeTurnId, ...rest } = session; + return rest; + } + + const completeTurn = Effect.fn("DittoAdapter.completeTurn")(function* (input: { + readonly state: SessionState; + readonly turnId: TurnId; + readonly itemId: RuntimeItemId; + readonly response: string; + readonly cost: number; + readonly toolCalls: ReadonlyArray; + }) { + const interrupted = input.state.interruptedTurnIds.has(input.turnId); + const response = input.response.trim(); + + if (response.length > 0) { + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId, itemId: input.itemId })), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: response, + }, + }); + } + + if (input.toolCalls.length > 0) { + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId })), + type: "tool.summary", + payload: { + summary: `Ditto memory tools: ${input.toolCalls.join(", ")}`, + }, + }); + } + + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId, itemId: input.itemId })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: interrupted ? "failed" : "completed", + ...(response.length > 0 ? { detail: response } : {}), + }, + }); + + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId })), + type: "turn.completed", + payload: { + state: interrupted ? "interrupted" : "completed", + modelUsage: { + provider: settings.chatProvider, + model: settings.chatModel, + cost: input.cost, + toolCalls: input.toolCalls, + }, + }, + }); + + input.state.session = { + ...withoutActiveTurn(input.state.session), + status: "ready", + updatedAt: yield* nowIso, + }; + input.state.activeTurnId = undefined; + }); + + const failTurn = Effect.fn("DittoAdapter.failTurn")(function* (input: { + readonly state: SessionState; + readonly turnId: TurnId; + readonly itemId: RuntimeItemId; + readonly message: string; + }) { + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId, itemId: input.itemId })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "failed", + detail: input.message, + }, + }); + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId })), + type: "runtime.error", + payload: { + message: input.message, + class: "provider_error", + }, + }); + yield* emit({ + ...(yield* baseEvent(input.state, { turnId: input.turnId })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: input.message, + }, + }); + input.state.session = { + ...withoutActiveTurn(input.state.session), + status: "error", + lastError: input.message, + updatedAt: yield* nowIso, + }; + input.state.activeTurnId = undefined; + }); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const mismatch = providerMismatch(input); + if (mismatch) { + return yield* mismatch; + } + + const createdAt = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + threadId: input.threadId, + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + model: input.modelSelection?.model ?? settings.chatModel, + resumeCursor: input.resumeCursor ?? { provider: PROVIDER, threadId: input.threadId }, + createdAt, + updatedAt: createdAt, + }; + + const state: SessionState = { + session, + snapshot: { + threadId: input.threadId, + turns: [], + }, + nextTurnSequence: 0, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + stopped: false, + }; + sessions.set(input.threadId, state); + + yield* emit({ + ...(yield* baseEvent(state)), + type: "session.started", + payload: { + message: "Ditto local session started.", + resume: session.resumeCursor, + }, + }); + yield* emit({ + ...(yield* baseEvent(state)), + type: "thread.started", + payload: { + providerThreadId: String(input.threadId), + }, + }); + yield* emit({ + ...(yield* baseEvent(state)), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + + return session; + }); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const state = yield* requireSession(input.threadId); + const prompt = appendAttachmentSummary(input).trim(); + if (prompt.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Ditto provider requires non-empty input text.", + }); + } + + state.nextTurnSequence += 1; + const turnId = TurnId.make(`ditto-turn-${state.nextTurnSequence}`); + const assistantItemId = yield* itemId(input.threadId); + state.activeTurnId = turnId; + state.session = { + ...state.session, + status: "running", + activeTurnId: turnId, + model: input.modelSelection?.model ?? settings.chatModel, + updatedAt: yield* nowIso, + }; + + yield* emit({ + ...(yield* baseEvent(state, { turnId })), + type: "turn.started", + payload: { + model: input.modelSelection?.model ?? settings.chatModel, + }, + }); + yield* emit({ + ...(yield* baseEvent(state, { turnId, itemId: assistantItemId })), + type: "item.started", + payload: { + itemType: "assistant_message", + status: "inProgress", + title: "Ditto", + }, + }); + + const opened = yield* options.opener(settings).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "open", + detail: cause.message, + cause, + }), + ), + Effect.catch((error: ProviderAdapterRequestError) => + failTurn({ + state, + turnId, + itemId: assistantItemId, + message: error.detail, + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + const result = yield* Effect.tryPromise({ + try: () => + opened.harness.chat(opened.settings.userId, prompt, { + ...dittoModelOptions(opened.settings, { + sessionId: String(input.threadId), + maxTurns: DEFAULT_MAX_TURNS, + saveMemory: settings.saveMemory, + }), + ...(input.modelSelection?.model ? { model: input.modelSelection.model } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "chat", + detail: explainDittoHarnessFailure( + `Ditto chat failed: ${toDittoHarnessMessage(cause)}`, + ), + cause, + }), + }).pipe( + Effect.catch((error: ProviderAdapterRequestError) => + failTurn({ + state, + turnId, + itemId: assistantItemId, + message: error.detail, + }).pipe(Effect.andThen(Effect.fail(error))), + ), + ); + + const assistantText = result.response.trim(); + const userItem = { + type: "userMessage", + content: [{ type: "text", text: input.input ?? "" }], + } as const; + const assistantItem = { + type: "agentMessage", + text: assistantText, + provider: "ditto", + toolCalls: result.toolCalls, + } as const; + const nextTurn: ProviderThreadTurnSnapshot = { + id: turnId, + items: [userItem, assistantItem], + }; + state.snapshot = { + threadId: state.snapshot.threadId, + turns: [...state.snapshot.turns, nextTurn], + }; + + yield* completeTurn({ + state, + turnId, + itemId: assistantItemId, + response: assistantText, + cost: result.cost, + toolCalls: result.toolCalls, + }); + + return { + threadId: input.threadId, + turnId, + ...(state.session.resumeCursor !== undefined + ? { resumeCursor: state.session.resumeCursor } + : {}), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( + threadId, + turnId, + ) => + Effect.gen(function* () { + const state = yield* requireSession(threadId); + const activeTurnId = turnId ?? state.activeTurnId; + if (activeTurnId === undefined) { + return; + } + state.interruptedTurnIds.add(activeTurnId); + yield* emit({ + ...(yield* baseEvent(state, { turnId: activeTurnId })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + }); + + const unsupportedRequest = ( + operation: string, + threadId: ThreadId, + ): Effect.Effect => + requireSession(threadId).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation, + issue: "Ditto provider does not support interactive approval requests yet.", + }), + ), + ), + ); + + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( + threadId: ThreadId, + _requestId: ApprovalRequestId, + _decision: ProviderApprovalDecision, + ) => unsupportedRequest("respondToRequest", threadId); + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = ( + threadId: ThreadId, + _requestId: ApprovalRequestId, + _answers: ProviderUserInputAnswers, + ) => unsupportedRequest("respondToUserInput", threadId); + + const stopSession: ProviderAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const state = sessions.get(threadId); + if (!state) return; + state.stopped = true; + state.session = { + ...withoutActiveTurn(state.session), + status: "closed", + updatedAt: yield* nowIso, + }; + yield* emit({ + ...(yield* baseEvent(state)), + type: "session.exited", + payload: { + reason: "Stopped by T3 Code.", + exitKind: "graceful", + }, + }); + sessions.delete(threadId); + }); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.succeed(sessions.has(threadId)); + + const readThread: ProviderAdapterShape["readThread"] = (threadId) => + requireSession(threadId).pipe(Effect.map((state) => state.snapshot)); + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => + Effect.gen(function* () { + const state = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer between 0 and current turn count.", + }); + } + state.snapshot = { + threadId: state.snapshot.threadId, + turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), + }; + return state.snapshot; + }); + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.sync(() => { + sessions.clear(); + }); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEvents), + } satisfies ProviderAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/DittoProvider.ts b/apps/server/src/provider/Layers/DittoProvider.ts new file mode 100644 index 000000000000..fd8b8b223e96 --- /dev/null +++ b/apps/server/src/provider/Layers/DittoProvider.ts @@ -0,0 +1,229 @@ +import { + DEFAULT_DITTO_CHAT_MODEL, + type DittoSettings, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; + +import { + type DittoHarnessModelOptions, + type DittoHarnessOpener, + dittoHarnessError, + explainDittoHarnessFailure, + trimToUndefined, + toDittoHarnessMessage, +} from "../../dittoHarness/DittoHarnessRuntime.ts"; +import { + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const PROVIDER = ProviderDriverKind.make("ditto"); + +const DITTO_MODEL_CAPABILITIES = createModelCapabilities({ + optionDescriptors: [], +}); + +const DITTO_PRESENTATION = { + displayName: "Ditto", + badgeLabel: "Local", + showInteractionModeToggle: false, +} as const; + +function providerLabel(settings: Pick): string { + switch (settings.chatProvider) { + case "ollama": + return "Ollama"; + case "openrouter": + return "OpenRouter"; + case "vllm": + return "vLLM"; + } +} + +function baseModels(settings: DittoSettings): ReadonlyArray { + const model = trimToUndefined(settings.chatModel) ?? DEFAULT_DITTO_CHAT_MODEL; + const defaults: ServerProviderModel[] = [ + { + slug: model, + name: model, + shortName: model, + subProvider: providerLabel(settings), + isCustom: false, + capabilities: DITTO_MODEL_CAPABILITIES, + }, + ]; + + if (model !== DEFAULT_DITTO_CHAT_MODEL) { + defaults.push({ + slug: DEFAULT_DITTO_CHAT_MODEL, + name: DEFAULT_DITTO_CHAT_MODEL, + shortName: DEFAULT_DITTO_CHAT_MODEL, + subProvider: "Ollama", + isCustom: false, + capabilities: DITTO_MODEL_CAPABILITIES, + }); + } + + return defaults; +} + +function modelsForSettings(settings: DittoSettings): ReadonlyArray { + return providerModelsFromSettings( + baseModels(settings), + settings.customModels, + DITTO_MODEL_CAPABILITIES, + ); +} + +function localAuthLabel(settings: DittoSettings): string { + return settings.chatProvider === "openrouter" ? "OpenRouter" : `Local ${providerLabel(settings)}`; +} + +function modelOptions(settings: DittoSettings): DittoHarnessModelOptions { + const model = trimToUndefined(settings.chatModel); + const baseUrl = trimToUndefined(settings.chatBaseUrl); + return { + provider: settings.chatProvider, + ...(model ? { model } : {}), + ...(baseUrl ? { baseUrl } : {}), + saveMemory: false, + maxTurns: 1, + }; +} + +export function makePendingDittoProvider( + settings: DittoSettings, +): Effect.Effect { + return Effect.map(DateTime.now, (now) => + buildServerProvider({ + driver: PROVIDER, + presentation: DITTO_PRESENTATION, + enabled: settings.enabled, + checkedAt: DateTime.formatIso(now), + models: modelsForSettings(settings), + probe: { + installed: true, + version: null, + status: "warning", + auth: { + status: "authenticated", + type: "local", + label: localAuthLabel(settings), + }, + message: "Checking local Ditto Harness.", + }, + }), + ); +} + +export function checkDittoProviderStatus( + settings: DittoSettings, + opener: DittoHarnessOpener, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + if (!settings.enabled) { + return buildServerProvider({ + driver: PROVIDER, + presentation: DITTO_PRESENTATION, + enabled: false, + checkedAt, + models: modelsForSettings(settings), + probe: { + installed: true, + version: null, + status: "ready", + auth: { + status: "unknown", + type: "local", + }, + message: "Ditto provider is disabled.", + }, + }); + } + + const opened = yield* Effect.result(opener(settings)); + if (opened._tag === "Failure") { + const message = opened.failure.message; + const nativeMissing = message.includes("native binding is unavailable"); + return buildServerProvider({ + driver: PROVIDER, + presentation: DITTO_PRESENTATION, + enabled: true, + checkedAt, + models: modelsForSettings(settings), + probe: { + installed: !nativeMissing, + version: null, + status: "error", + auth: { + status: "unknown", + type: "local", + }, + message, + }, + }); + } + + const chatProbe = yield* Effect.result( + Effect.tryPromise({ + try: () => + opened.success.harness.chat(opened.success.settings.userId, "Reply with: ok", { + ...modelOptions(settings), + sessionId: "__t3_ditto_provider_probe__", + }), + catch: (cause) => + dittoHarnessError( + "operation_failed", + explainDittoHarnessFailure( + `Ditto provider probe failed: ${toDittoHarnessMessage(cause)}`, + ), + cause, + ), + }), + ); + + if (chatProbe._tag === "Failure") { + return buildServerProvider({ + driver: PROVIDER, + presentation: DITTO_PRESENTATION, + enabled: true, + checkedAt, + models: modelsForSettings(settings), + probe: { + installed: true, + version: opened.success.version ?? null, + status: "error", + auth: { + status: "unknown", + type: "local", + }, + message: chatProbe.failure.message, + }, + }); + } + + return buildServerProvider({ + driver: PROVIDER, + presentation: DITTO_PRESENTATION, + enabled: true, + checkedAt, + models: modelsForSettings(settings), + probe: { + installed: true, + version: opened.success.version ?? null, + status: "ready", + auth: { + status: "authenticated", + type: "local", + label: localAuthLabel(settings), + }, + }, + }); + }); +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 663ee90368b8..13f274df6de2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1746,6 +1746,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "claudeAgent", "codex", "cursor", + "ditto", "grok", "opencode", ]); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..b631965db8f6 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { DittoDriver, type DittoDriverEnv } from "./Drivers/DittoDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | DittoDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -45,6 +47,7 @@ export type BuiltInDriversEnv = * iteration order has no functional effect on instance lookup. */ export const BUILT_IN_DRIVERS: ReadonlyArray> = [ + DittoDriver, CodexDriver, ClaudeDriver, CursorDriver, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c1f46a2f8914..818af48f38c8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -74,6 +74,7 @@ import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vite-plus/test"; +import { DittoHarnessServiceDisabled } from "./dittoHarness/DittoHarnessService.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( @@ -659,14 +660,17 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(ServerSettings.ServerSettingsService)({ - start: Effect.void, - ready: Effect.void, - getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), - updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), - streamChanges: Stream.empty, - ...options?.layers?.serverSettings, - }), + Layer.mergeAll( + Layer.mock(ServerSettings.ServerSettingsService)({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS), + streamChanges: Stream.empty, + ...options?.layers?.serverSettings, + }), + DittoHarnessServiceDisabled, + ), ), Layer.provide( Layer.mergeAll( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..876db176a32f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -110,6 +110,7 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import { DittoHarnessServiceLive } from "./dittoHarness/DittoHarnessService.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -373,7 +374,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreDependenciesBaseLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), Layer.provideMerge(CheckpointingLayerLive), @@ -423,6 +424,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); +const RuntimeCoreDependenciesLive = DittoHarnessServiceLive.pipe( + Layer.provideMerge(RuntimeCoreDependenciesBaseLive), +); + const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), diff --git a/apps/server/src/textGeneration/DittoTextGeneration.ts b/apps/server/src/textGeneration/DittoTextGeneration.ts new file mode 100644 index 000000000000..bdde240268a9 --- /dev/null +++ b/apps/server/src/textGeneration/DittoTextGeneration.ts @@ -0,0 +1,197 @@ +import { type DittoSettings, TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { + dittoActionModelOptions, + explainDittoHarnessFailure, + toDittoHarnessMessage, + type DittoHarnessOpener, +} from "../dittoHarness/DittoHarnessRuntime.ts"; +import type { TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +function extractJsonObject(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed; + } + + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/iu); + if (fenced?.[1]) { + const candidate = fenced[1].trim(); + if (candidate.startsWith("{") && candidate.endsWith("}")) { + return candidate; + } + } + + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return trimmed.slice(start, end + 1); + } + + return trimmed; +} + +export const makeDittoTextGeneration = Effect.fn("makeDittoTextGeneration")(function* ( + settings: DittoSettings, + opener: DittoHarnessOpener, +) { + yield* Effect.void; + + const runDittoJson = Effect.fn("DittoTextGeneration.runDittoJson")(function* < + S extends Schema.Top, + >(input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly prompt: string; + readonly outputSchema: S; + }): Effect.fn.Return { + const opened = yield* opener(settings).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: cause.message, + cause, + }), + ), + ); + const result = yield* Effect.tryPromise({ + try: () => + opened.harness.chat( + opened.settings.userId, + [input.prompt, "", "Return only valid JSON. Do not wrap it in markdown fences."].join( + "\n", + ), + { + ...dittoActionModelOptions(opened.settings, { + sessionId: `__t3_${input.operation}__`, + maxTurns: 1, + saveMemory: false, + }), + }, + ), + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: explainDittoHarnessFailure( + `Ditto text generation failed: ${toDittoHarnessMessage(cause)}`, + ), + cause, + }), + }); + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema)); + return yield* decodeOutput(extractJsonObject(result.response)).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: "Ditto returned invalid structured JSON.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "DittoTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runDittoJson({ + operation: "generateCommitMessage", + prompt, + outputSchema, + }); + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "DittoTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runDittoJson({ + operation: "generatePrContent", + prompt, + outputSchema, + }); + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "DittoTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runDittoJson({ + operation: "generateBranchName", + prompt, + outputSchema, + }); + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "DittoTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runDittoJson({ + operation: "generateThreadTitle", + prompt, + outputSchema, + }); + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..05f9e3dbca6d 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "ditto" + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e6a912d5dae0..b5f5bdc18e2e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -73,6 +73,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; +import { DittoHarnessService } from "./dittoHarness/DittoHarnessService.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { @@ -551,6 +552,7 @@ const makeWsRpcLayer = ( const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; + const dittoHarness = yield* DittoHarnessService; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1774,6 +1776,30 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverSignalProcess, processDiagnostics.signal(input), { "rpc.aggregate": "server", }), + [WS_METHODS.dittoHarnessStatus]: (_input) => + observeRpcEffect(WS_METHODS.dittoHarnessStatus, dittoHarness.status, { + "rpc.aggregate": "ditto-harness", + }), + [WS_METHODS.dittoHarnessSaveMemory]: (input) => + observeRpcEffect(WS_METHODS.dittoHarnessSaveMemory, dittoHarness.saveMemory(input), { + "rpc.aggregate": "ditto-harness", + }), + [WS_METHODS.dittoHarnessSearchMemories]: (input) => + observeRpcEffect( + WS_METHODS.dittoHarnessSearchMemories, + dittoHarness.searchMemories(input), + { "rpc.aggregate": "ditto-harness" }, + ), + [WS_METHODS.dittoHarnessSearchSubjects]: (input) => + observeRpcEffect( + WS_METHODS.dittoHarnessSearchSubjects, + dittoHarness.searchSubjects(input), + { "rpc.aggregate": "ditto-harness" }, + ), + [WS_METHODS.dittoHarnessDream]: (input) => + observeRpcEffect(WS_METHODS.dittoHarnessDream, dittoHarness.dream(input), { + "rpc.aggregate": "ditto-harness", + }), [WS_METHODS.serverReportClientActivity]: (input, metadata) => Ref.update(rpcClientIds, (clientIds) => { const next = new Set(clientIds); diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..7d98f0850e7d 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -214,6 +214,22 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const DittoIcon: Icon = ({ className, ...props }) => ( + + + + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..3f3c0b48e464 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,8 +1,9 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, DittoIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { + [ProviderDriverKind.make("ditto")]: DittoIcon, [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index a4e8b7e7a9a7..e98f17f99101 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -31,6 +31,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; * UX is unchanged — only the owning component has moved. */ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial> = { + [ProviderDriverKind.make("ditto")]: "llama3.2:3b", [ProviderDriverKind.make("codex")]: "gpt-6.7-codex-ultra-preview", [ProviderDriverKind.make("claudeAgent")]: "claude-sonnet-5", [ProviderDriverKind.make("cursor")]: "claude-sonnet-4-6", diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index cd34bb35c6b2..59596fbe6343 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -12,6 +12,7 @@ import type { import { cn } from "../../lib/utils"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; @@ -22,8 +23,10 @@ export interface ProviderSettingsFieldModel { readonly label: string; readonly description?: string | undefined; readonly placeholder?: string | undefined; + readonly options?: ReadonlyArray<{ readonly value: string; readonly label: string }> | undefined; readonly clearWhenEmpty: "omit" | "persist"; readonly defaultBooleanValue?: boolean | undefined; + readonly defaultStringValue?: string | undefined; } function titleizeFieldKey(key: string): string { @@ -69,6 +72,14 @@ function readFieldBooleanDefault( return Option.isSome(decoded) && typeof decoded.value === "boolean" ? decoded.value : undefined; } +function readFieldStringDefault( + fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], +): string | undefined { + const decodeDefault = Schema.decodeUnknownOption(fieldSchema as Schema.Decoder); + const decoded = decodeDefault(undefined); + return Option.isSome(decoded) && typeof decoded.value === "string" ? decoded.value : undefined; +} + export function deriveProviderSettingsFields( definition: ProviderClientDefinition, ): ReadonlyArray { @@ -102,10 +113,14 @@ export function deriveProviderSettingsFields( ...(formAnnotation.placeholder !== undefined ? { placeholder: formAnnotation.placeholder } : {}), + ...(formAnnotation.options !== undefined ? { options: formAnnotation.options } : {}), clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", ...(formAnnotation.control === "switch" ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) } : {}), + ...(formAnnotation.control === "select" + ? { defaultStringValue: readFieldStringDefault(fieldSchema) } + : {}), } satisfies ProviderSettingsFieldModel, ]; }); @@ -146,7 +161,11 @@ export function nextProviderConfigWithFieldValue( } const trimmed = value.trim(); - if (field.clearWhenEmpty === "omit" && trimmed.length === 0) { + const emptyStringValue = field.defaultStringValue ?? ""; + if ( + field.clearWhenEmpty === "omit" && + (trimmed.length === 0 || (field.control === "select" && value === emptyStringValue)) + ) { delete base[field.key]; } else { base[field.key] = value; @@ -217,6 +236,45 @@ function ProviderSettingsFieldRow({ ); } + if (field.control === "select" && field.options && field.options.length > 0) { + const selectedValue = + readProviderConfigString(value, field.key) || + field.defaultStringValue || + field.options[0]!.value; + const selectedOption = field.options.find((option) => option.value === selectedValue); + return ( + + + + ); + } + if (field.control === "textarea") { return ( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx index bf79929f1972..2f0c83f304cc 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx @@ -152,7 +152,7 @@ describe("EnvironmentProviderSettings routing", () => { it("routes refresh and provider update commands to the selected environment", async () => { atoms.providers = [provider()]; - const panel = renderPanel(); + let panel = renderPanel(); const refreshButton = visitElements( panel, (element) => element.props["aria-label"] === "Refresh provider status", @@ -163,6 +163,14 @@ describe("EnvironmentProviderSettings routing", () => { expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); + const codexRow = visitElements( + panel, + (element) => element.props.instanceId === codexId && element.props.mode === "list", + ); + expect(codexRow).not.toBeNull(); + (codexRow?.props.onSelect as (() => void) | undefined)?.(); + panel = renderPanel(); + const providerCard = visitElements( panel, (element) => diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..2d0ffe9acc1f 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,12 +2,21 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + DittoSettings, GrokSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + DittoIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -35,6 +44,13 @@ export interface ProviderClientDefinition { } export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ + { + value: ProviderDriverKind.make("ditto"), + label: "Ditto", + icon: DittoIcon, + badgeLabel: "Local", + settingsSchema: DittoSettings, + }, { value: ProviderDriverKind.make("codex"), label: "Codex", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4b35a6e24cc0..999c9ca5a2ea 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,6 +35,12 @@ export const PROVIDER_OPTIONS: Array<{ /** Shown on the model picker sidebar when relevant */ pickerSidebarBadge?: "new" | "soon"; }> = [ + { + value: ProviderDriverKind.make("ditto"), + label: "Ditto", + available: true, + pickerSidebarBadge: "new", + }, { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, { diff --git a/packages/contracts/src/dittoHarness.ts b/packages/contracts/src/dittoHarness.ts new file mode 100644 index 000000000000..04a903dea789 --- /dev/null +++ b/packages/contracts/src/dittoHarness.ts @@ -0,0 +1,111 @@ +import * as Schema from "effect/Schema"; +import { + IsoDateTime, + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; + +export const DittoHarnessEmbedder = Schema.Literals(["ollama", "hash"]); +export type DittoHarnessEmbedder = typeof DittoHarnessEmbedder.Type; + +export const DittoHarnessChatProvider = Schema.Literals(["ollama", "openrouter", "vllm"]); +export type DittoHarnessChatProvider = typeof DittoHarnessChatProvider.Type; + +export const DittoLocalChatProvider = Schema.Literals(["ollama", "vllm"]); +export type DittoLocalChatProvider = typeof DittoLocalChatProvider.Type; + +export const DittoHarnessStatusState = Schema.Literals([ + "disabled", + "ready", + "unavailable", + "error", +]); +export type DittoHarnessStatusState = typeof DittoHarnessStatusState.Type; + +export const DittoHarnessStatus = Schema.Struct({ + enabled: Schema.Boolean, + state: DittoHarnessStatusState, + checkedAt: IsoDateTime, + databasePath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); +export type DittoHarnessStatus = typeof DittoHarnessStatus.Type; + +export const DittoHarnessSubjectInput = Schema.Struct({ + text: TrimmedNonEmptyString, + description: Schema.optional(TrimmedString), + key: Schema.optional(Schema.Boolean), +}); +export type DittoHarnessSubjectInput = typeof DittoHarnessSubjectInput.Type; + +export const DittoHarnessSaveMemoryInput = Schema.Struct({ + prompt: TrimmedNonEmptyString, + response: TrimmedNonEmptyString, + summary: Schema.optional(TrimmedString), + sessionId: Schema.optional(TrimmedString), + source: Schema.optional(TrimmedString), + sourceContext: Schema.optional(TrimmedString), + timestamp: Schema.optional(IsoDateTime), + timezoneOffset: Schema.optional(Schema.Int), + subjects: Schema.optional(Schema.Array(DittoHarnessSubjectInput)), +}); +export type DittoHarnessSaveMemoryInput = typeof DittoHarnessSaveMemoryInput.Type; + +export const DittoHarnessSaveMemoryResult = Schema.Struct({ + memory: Schema.Unknown, +}); +export type DittoHarnessSaveMemoryResult = typeof DittoHarnessSaveMemoryResult.Type; + +export const DittoHarnessSearchMemoriesInput = Schema.Struct({ + query: TrimmedNonEmptyString, + sessionId: Schema.optional(TrimmedString), + limit: Schema.optional(PositiveInt), + minSimilarity: Schema.optional(Schema.Number), +}); +export type DittoHarnessSearchMemoriesInput = typeof DittoHarnessSearchMemoriesInput.Type; + +export const DittoHarnessSearchMemoriesResult = Schema.Struct({ + memories: Schema.Array(Schema.Unknown), +}); +export type DittoHarnessSearchMemoriesResult = typeof DittoHarnessSearchMemoriesResult.Type; + +export const DittoHarnessSearchSubjectsInput = Schema.Struct({ + query: TrimmedNonEmptyString, + limit: Schema.optional(PositiveInt), +}); +export type DittoHarnessSearchSubjectsInput = typeof DittoHarnessSearchSubjectsInput.Type; + +export const DittoHarnessSearchSubjectsResult = Schema.Struct({ + subjects: Schema.Array(Schema.Unknown), +}); +export type DittoHarnessSearchSubjectsResult = typeof DittoHarnessSearchSubjectsResult.Type; + +export const DittoHarnessDreamInput = Schema.Struct({ + maxMemories: Schema.optional(NonNegativeInt), + refine: Schema.optional(Schema.Boolean), +}); +export type DittoHarnessDreamInput = typeof DittoHarnessDreamInput.Type; + +export const DittoHarnessDreamResult = Schema.Struct({ + report: Schema.Unknown, +}); +export type DittoHarnessDreamResult = typeof DittoHarnessDreamResult.Type; + +export const DittoHarnessErrorKind = Schema.Literals([ + "disabled", + "unavailable", + "operation_failed", +]); +export type DittoHarnessErrorKind = typeof DittoHarnessErrorKind.Type; + +export class DittoHarnessError extends Schema.TaggedErrorClass()( + "DittoHarnessError", + { + kind: DittoHarnessErrorKind, + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..1c37b1185bc1 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; +export * from "./dittoHarness.ts"; export * from "./remoteAccess.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..74335312d590 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const DITTO_DRIVER_KIND = ProviderDriverKind.make("ditto"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -148,6 +149,7 @@ export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { + [DITTO_DRIVER_KIND]: "qwen3:4b", [CODEX_DRIVER_KIND]: DEFAULT_MODEL, [CLAUDE_DRIVER_KIND]: "claude-sonnet-5", [CURSOR_DRIVER_KIND]: "auto", @@ -159,6 +161,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial > = { + [DITTO_DRIVER_KIND]: "qwen3:4b", [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL, [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5", [CURSOR_DRIVER_KIND]: "composer-2", @@ -217,6 +220,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< // ── Provider display names ──────────────────────────────────────────── export const PROVIDER_DISPLAY_NAMES: Partial> = { + [DITTO_DRIVER_KIND]: "Ditto", [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..3b5ccc82d930 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,6 +13,18 @@ import { ClientActivityReportInput, HostPowerSnapshot, } from "./background.ts"; +import { + DittoHarnessDreamInput, + DittoHarnessDreamResult, + DittoHarnessError, + DittoHarnessSaveMemoryInput, + DittoHarnessSaveMemoryResult, + DittoHarnessSearchMemoriesInput, + DittoHarnessSearchMemoriesResult, + DittoHarnessSearchSubjectsInput, + DittoHarnessSearchSubjectsResult, + DittoHarnessStatus, +} from "./dittoHarness.ts"; import { FilesystemBrowseInput, FilesystemBrowseResult, @@ -292,6 +304,13 @@ export const WS_METHODS = { serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + // Local Ditto memory and chat harness methods + dittoHarnessStatus: "dittoHarness.status", + dittoHarnessSaveMemory: "dittoHarness.saveMemory", + dittoHarnessSearchMemories: "dittoHarness.searchMemories", + dittoHarnessSearchSubjects: "dittoHarness.searchSubjects", + dittoHarnessDream: "dittoHarness.dream", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -459,6 +478,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, error: EnvironmentAuthorizationError, }); +export const WsDittoHarnessStatusRpc = Rpc.make(WS_METHODS.dittoHarnessStatus, { + payload: Schema.Struct({}), + success: DittoHarnessStatus, + error: EnvironmentAuthorizationError, +}); + +export const WsDittoHarnessSaveMemoryRpc = Rpc.make(WS_METHODS.dittoHarnessSaveMemory, { + payload: DittoHarnessSaveMemoryInput, + success: DittoHarnessSaveMemoryResult, + error: Schema.Union([DittoHarnessError, EnvironmentAuthorizationError]), +}); + +export const WsDittoHarnessSearchMemoriesRpc = Rpc.make(WS_METHODS.dittoHarnessSearchMemories, { + payload: DittoHarnessSearchMemoriesInput, + success: DittoHarnessSearchMemoriesResult, + error: Schema.Union([DittoHarnessError, EnvironmentAuthorizationError]), +}); + +export const WsDittoHarnessSearchSubjectsRpc = Rpc.make(WS_METHODS.dittoHarnessSearchSubjects, { + payload: DittoHarnessSearchSubjectsInput, + success: DittoHarnessSearchSubjectsResult, + error: Schema.Union([DittoHarnessError, EnvironmentAuthorizationError]), +}); + +export const WsDittoHarnessDreamRpc = Rpc.make(WS_METHODS.dittoHarnessDream, { + payload: DittoHarnessDreamInput, + success: DittoHarnessDreamResult, + error: Schema.Union([DittoHarnessError, EnvironmentAuthorizationError]), +}); + export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, { payload: Schema.Struct({}), success: RelayClientStatusSchema, @@ -1036,6 +1085,11 @@ export const WsRpcGroup = RpcGroup.make( WsServerRetryResourceTelemetryRpc, WsServerGetUsageSummaryRpc, WsServerSignalProcessRpc, + WsDittoHarnessStatusRpc, + WsDittoHarnessSaveMemoryRpc, + WsDittoHarnessSearchMemoriesRpc, + WsDittoHarnessSearchSubjectsRpc, + WsDittoHarnessDreamRpc, WsServerReportClientActivityRpc, WsServerReportHostPowerStateRpc, WsServerGetBackgroundPolicyRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d75b0b4ae798..176a24b876b3 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -350,6 +350,49 @@ describe("ServerSettingsPatch.providerInstances", () => { }); }); +describe("Ditto settings", () => { + it("enables the memory harness and prompt context by default", () => { + expect(DEFAULT_SERVER_SETTINGS.dittoHarness.enabled).toBe(true); + expect(DEFAULT_SERVER_SETTINGS.dittoHarness.enablePromptContext).toBe(true); + expect(DEFAULT_SERVER_SETTINGS.dittoHarness.dreamEnabled).toBe(false); + expect(DEFAULT_SERVER_SETTINGS.providers.ditto.chatBaseUrl).toBe(""); + }); + + it("decodes Ditto provider chat and action routing settings", () => { + const decoded = decodeServerSettings({ + providers: { + ditto: { + chatProvider: "openrouter", + chatModel: "anthropic/claude-sonnet-4", + chatBaseUrl: "", + actionProvider: "vllm", + actionModel: "local-tool-model", + actionBaseUrl: " http://localhost:8000/v1 ", + }, + }, + }); + + expect(decoded.providers.ditto.chatProvider).toBe("openrouter"); + expect(decoded.providers.ditto.actionProvider).toBe("vllm"); + expect(decoded.providers.ditto.actionModel).toBe("local-tool-model"); + expect(decoded.providers.ditto.actionBaseUrl).toBe("http://localhost:8000/v1"); + }); + + it("decodes harness action routing patches", () => { + const patch = decodeServerSettingsPatch({ + dittoHarness: { + actionProvider: "openrouter", + actionModel: "google/gemini-3.5-flash", + actionBaseUrl: " https://openrouter.ai/api/v1 ", + }, + }); + + expect(patch.dittoHarness?.actionProvider).toBe("openrouter"); + expect(patch.dittoHarness?.actionModel).toBe("google/gemini-3.5-flash"); + expect(patch.dittoHarness?.actionBaseUrl).toBe("https://openrouter.ai/api/v1"); + }); +}); + describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866e..7d421837ac8a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ThreadEnvMode } from "./environment.ts"; +import { DittoHarnessChatProvider, DittoHarnessEmbedder } from "./dittoHarness.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, DEFAULT_TEXT_GENERATION_REASONING_EFFORT, @@ -278,10 +279,16 @@ const makeBinaryPathSetting = (fallback: string) => Schema.withDecodingDefault(Effect.succeed(fallback)), ); -export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; +export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; + +export interface ProviderSettingsFormOption { + readonly value: string; + readonly label: string; +} export interface ProviderSettingsFormAnnotation { readonly control?: ProviderSettingsFormControl | undefined; + readonly options?: ReadonlyArray | undefined; readonly placeholder?: string | undefined; readonly hidden?: boolean | undefined; readonly clearWhenEmpty?: "omit" | "persist" | undefined; @@ -550,12 +557,183 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const DEFAULT_DITTO_CHAT_MODEL = "qwen3:4b"; +export const DEFAULT_DITTO_LOCAL_BASE_URL = "http://127.0.0.1:11434"; + +const DITTO_CHAT_PROVIDER_FORM_OPTIONS = [ + { value: "ollama", label: "Ollama" }, + { value: "openrouter", label: "OpenRouter" }, + { value: "vllm", label: "vLLM / OpenAI-compatible" }, +] as const satisfies ReadonlyArray; + +export const DittoSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + userId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("local")), + Schema.annotateKey({ + title: "User ID", + description: "Local Ditto user id for memory and subject retrieval.", + providerSettingsForm: { placeholder: "local", clearWhenEmpty: "omit" }, + }), + ), + databasePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Database path", + description: "SQLite database path. Blank stores Ditto memory in the local app state.", + providerSettingsForm: { placeholder: "ditto-harness.sqlite", clearWhenEmpty: "omit" }, + }), + ), + embedder: DittoHarnessEmbedder.pipe( + Schema.withDecodingDefault(Effect.succeed("ollama")), + Schema.annotateKey({ + title: "Embedder", + description: "Use Ollama locally, or hash for deterministic offline smoke tests.", + providerSettingsForm: { + control: "select", + options: [ + { value: "ollama", label: "Ollama" }, + { value: "hash", label: "Hash" }, + ], + clearWhenEmpty: "omit", + }, + }), + ), + ollamaBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DITTO_LOCAL_BASE_URL)), + Schema.annotateKey({ + title: "Ollama URL", + description: "Local Ollama endpoint used for embeddings.", + providerSettingsForm: { + placeholder: DEFAULT_DITTO_LOCAL_BASE_URL, + clearWhenEmpty: "omit", + }, + }), + ), + chatProvider: DittoHarnessChatProvider.pipe( + Schema.withDecodingDefault(Effect.succeed("ollama")), + Schema.annotateKey({ + title: "Chat provider", + description: "Backend used by the local Ditto chat loop.", + providerSettingsForm: { + control: "select", + options: DITTO_CHAT_PROVIDER_FORM_OPTIONS, + clearWhenEmpty: "omit", + }, + }), + ), + chatModel: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DITTO_CHAT_MODEL)), + Schema.annotateKey({ + title: "Chat model", + description: "Model passed to the local Ditto chat loop.", + providerSettingsForm: { + placeholder: DEFAULT_DITTO_CHAT_MODEL, + clearWhenEmpty: "omit", + }, + }), + ), + chatBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Chat URL", + description: "Optional endpoint override. Blank uses the provider default.", + providerSettingsForm: { + placeholder: DEFAULT_DITTO_LOCAL_BASE_URL, + clearWhenEmpty: "omit", + }, + }), + ), + saveMemory: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ + title: "Save turns", + description: "Persist provider exchanges into the local Ditto memory store.", + providerSettingsForm: { control: "switch", clearWhenEmpty: "omit" }, + }), + ), + actionProvider: Schema.optionalKey(DittoHarnessChatProvider).pipe( + Schema.annotateKey({ + title: "Action provider", + description: "Optional stronger backend for Ditto actions.", + providerSettingsForm: { + control: "select", + options: DITTO_CHAT_PROVIDER_FORM_OPTIONS, + clearWhenEmpty: "omit", + }, + }), + ), + actionModel: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ + title: "Action model", + description: "Optional stronger model for Ditto actions.", + providerSettingsForm: { placeholder: "model slug", clearWhenEmpty: "omit" }, + }), + ), + actionBaseUrl: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ + title: "Action URL", + description: "Optional action endpoint override.", + providerSettingsForm: { placeholder: "Provider default", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: [ + "userId", + "databasePath", + "embedder", + "ollamaBaseUrl", + "chatProvider", + "chatModel", + "chatBaseUrl", + "saveMemory", + "actionProvider", + "actionModel", + "actionBaseUrl", + ], + }, +); +export type DittoSettings = typeof DittoSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), }); export type ObservabilitySettings = typeof ObservabilitySettings.Type; +export const DittoHarnessSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + userId: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed("local"))), + databasePath: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + embedder: DittoHarnessEmbedder.pipe(Schema.withDecodingDefault(Effect.succeed("ollama"))), + ollamaBaseUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DITTO_LOCAL_BASE_URL)), + ), + chatProvider: DittoHarnessChatProvider.pipe(Schema.withDecodingDefault(Effect.succeed("ollama"))), + chatModel: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_DITTO_CHAT_MODEL)), + ), + chatBaseUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + actionProvider: Schema.optionalKey(DittoHarnessChatProvider), + actionModel: Schema.optionalKey(TrimmedString), + actionBaseUrl: Schema.optionalKey(TrimmedString), + dreamEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + enablePromptContext: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + promptContextLimit: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })).pipe( + Schema.withDecodingDefault(Effect.succeed(5)), + ), +}); +export type DittoHarnessSettings = typeof DittoHarnessSettings.Type; + export const SourceControlWritingStyleMode = Schema.Literals([ "repo_conventions", "conventional_commits", @@ -687,6 +865,7 @@ export const ServerSettings = Schema.Struct({ // owns its config in its own package, this struct shrinks to nothing and // is removed entirely. providers: Schema.Struct({ + ditto: DittoSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -702,6 +881,7 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + dittoHarness: DittoHarnessSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -845,6 +1025,41 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const DittoSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + userId: Schema.optionalKey(TrimmedString), + databasePath: Schema.optionalKey(TrimmedString), + embedder: Schema.optionalKey(DittoHarnessEmbedder), + ollamaBaseUrl: Schema.optionalKey(TrimmedString), + chatProvider: Schema.optionalKey(DittoHarnessChatProvider), + chatModel: Schema.optionalKey(TrimmedString), + chatBaseUrl: Schema.optionalKey(TrimmedString), + saveMemory: Schema.optionalKey(Schema.Boolean), + actionProvider: Schema.optionalKey(DittoHarnessChatProvider), + actionModel: Schema.optionalKey(TrimmedString), + actionBaseUrl: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +const DittoHarnessSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + userId: Schema.optionalKey(TrimmedString), + databasePath: Schema.optionalKey(TrimmedString), + embedder: Schema.optionalKey(DittoHarnessEmbedder), + ollamaBaseUrl: Schema.optionalKey(TrimmedString), + chatProvider: Schema.optionalKey(DittoHarnessChatProvider), + chatModel: Schema.optionalKey(TrimmedString), + chatBaseUrl: Schema.optionalKey(TrimmedString), + actionProvider: Schema.optionalKey(DittoHarnessChatProvider), + actionModel: Schema.optionalKey(TrimmedString), + actionBaseUrl: Schema.optionalKey(TrimmedString), + dreamEnabled: Schema.optionalKey(Schema.Boolean), + enablePromptContext: Schema.optionalKey(Schema.Boolean), + promptContextLimit: Schema.optionalKey( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 20 })), + ), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -879,8 +1094,10 @@ export const ServerSettingsPatch = Schema.Struct({ otlpMetricsUrl: Schema.optionalKey(TrimmedString), }), ), + dittoHarness: Schema.optionalKey(DittoHarnessSettingsPatch), providers: Schema.optionalKey( Schema.Struct({ + ditto: Schema.optionalKey(DittoSettingsPatch), codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch),