From 87ea3667e4e61a4c7043171f203b59ae2347ef96 Mon Sep 17 00:00:00 2001 From: Sisyphus-AI Date: Sat, 25 Apr 2026 21:19:58 +0200 Subject: [PATCH 1/2] feat(processor): add model fallback chain when retries are exhausted When the primary model fails after all retries, automatically try the next model from the fallback chain. This adds: - fallbackModels field to MessageV2.User schema - resolveFallbackChain helper to Provider service - outer fallback loop in SessionProcessor.process() - error classification: only retryable errors trigger fallback - No fallback for: AuthError, AbortedError, non-retryable APIError - Fallback for: rate limits, 5xx, other transient errors --- packages/opencode/src/provider/provider.ts | 20 +- packages/opencode/src/session/message-v2.ts | 8 + packages/opencode/src/session/processor.ts | 169 +++++++++---- packages/opencode/test/fake/provider.ts | 15 +- .../opencode/test/session/fallback.test.ts | 222 ++++++++++++++++++ 5 files changed, 387 insertions(+), 47 deletions(-) create mode 100644 packages/opencode/test/session/fallback.test.ts diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d826f6b35050..7f22a7a23a96 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -19,7 +19,7 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer, Context, Schema, Types } from "effect" +import { Effect, Exit, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -933,6 +933,9 @@ export interface Interface { ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> readonly getSmallModel: (providerID: ProviderID) => Effect.Effect readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> + readonly resolveFallbackChain: ( + chain: Array<{ providerID: ProviderID; modelID: ModelID }>, + ) => Effect.Effect<{ model: Model; remaining: Array<{ providerID: ProviderID; modelID: ModelID }> } | undefined> } interface State { @@ -1680,7 +1683,20 @@ const layer: Layer.Layer< } }) - return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel }) + const resolveFallbackChain = Effect.fn("Provider.resolveFallbackChain")(function* ( + chain: Array<{ providerID: ProviderID; modelID: ModelID }>, + ) { + for (let i = 0; i < chain.length; i++) { + const { providerID, modelID } = chain[i] + const exit = yield* getModel(providerID, modelID).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + return { model: exit.value, remaining: chain.slice(i + 1) } + } + } + return undefined + }) + + return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel, resolveFallbackChain }) }), ) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index d04645b7360c..2f734a7905c0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -391,6 +391,14 @@ export const User = Schema.Struct({ }), system: Schema.optional(Schema.String), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + fallbackModels: Schema.optional( + Schema.Array( + Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + }), + ), + ), }) .annotate({ identifier: "UserMessage" }) .pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 21f9329c6fce..5e39e23595d4 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -15,7 +15,8 @@ import type { SessionID } from "./schema" import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { SessionSummary } from "./summary" -import type { Provider } from "@/provider" +import { Provider } from "@/provider" +import { ModelID, ProviderID } from "@/provider/schema" import { Question } from "@/question" import { errorMessage } from "@/util/error" import { Log } from "@/util" @@ -71,6 +72,8 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: MessageV2.TextPart | undefined reasoningMap: Record + shouldFallback: boolean + fallbackChain: Array<{ providerID: ProviderID; modelID: ModelID }> } type StreamEvent = Event @@ -90,6 +93,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionSummary.Service | SessionStatus.Service + | Provider.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -104,6 +108,7 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const provider = yield* Provider.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -121,6 +126,8 @@ export const layer: Layer.Layer< needsCompaction: false, currentText: undefined, reasoningMap: {}, + shouldFallback: false, + fallbackChain: [], } let aborted = false const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) @@ -520,70 +527,146 @@ export const layer: Layer.Layer< yield* session.updateMessage(ctx.assistantMessage) }) - const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) { + const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown, streamInput: LLM.StreamInput) { slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined }) const error = parse(e) + + // Context overflow triggers compaction, not fallback if (MessageV2.ContextOverflowError.isInstance(error)) { ctx.needsCompaction = true yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + + // Auth errors (bad credentials) should NOT fallback - they'll fail again + if (MessageV2.AuthError.isInstance(error)) { + ctx.assistantMessage.error = error + yield* bus.publish(Session.Event.Error, { + sessionID: ctx.assistantMessage.sessionID, + error: ctx.assistantMessage.error, + }) + yield* status.set(ctx.sessionID, { type: "idle" }) + return + } + + // AbortedError (user cancelled) should NOT fallback + if (MessageV2.AbortedError.isInstance(error)) { + ctx.assistantMessage.error = error + yield* bus.publish(Session.Event.Error, { + sessionID: ctx.assistantMessage.sessionID, + error: ctx.assistantMessage.error, + }) + yield* status.set(ctx.sessionID, { type: "idle" }) + return + } + + // APIError with isRetryable=false should NOT fallback (permanent failures) + if (MessageV2.APIError.isInstance(error) && !error.data.isRetryable) { + ctx.assistantMessage.error = error + yield* bus.publish(Session.Event.Error, { + sessionID: ctx.assistantMessage.sessionID, + error: ctx.assistantMessage.error, + }) + yield* status.set(ctx.sessionID, { type: "idle" }) + return + } + ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, }) yield* status.set(ctx.sessionID, { type: "idle" }) + + // Signal that we should try a fallback model if one is available + const fallbackChain = streamInput.user.fallbackModels + if (fallbackChain && fallbackChain.length > 0) { + ctx.shouldFallback = true + ctx.fallbackChain = [...fallbackChain] + } }) const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) { slog.info("process") ctx.needsCompaction = false + ctx.shouldFallback = false + ctx.fallbackChain = [] ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true - return yield* Effect.gen(function* () { - yield* Effect.gen(function* () { - ctx.currentText = undefined - ctx.reasoningMap = {} - const stream = llm.stream(streamInput) + // Fallback loop - iterate through models when retries are exhausted + let currentStreamInput = streamInput - yield* stream.pipe( - Stream.tap((event) => handleEvent(event)), - Stream.takeUntil(() => ctx.needsCompaction), - Stream.runDrain, + return yield* Effect.gen(function* () { + while (true) { + yield* Effect.gen(function* () { + ctx.currentText = undefined + ctx.reasoningMap = {} + const stream = llm.stream(currentStreamInput) + + yield* stream.pipe( + Stream.tap((event) => handleEvent(event)), + Stream.takeUntil(() => ctx.needsCompaction), + Stream.runDrain, + ) + }).pipe( + Effect.onInterrupt(() => + Effect.gen(function* () { + aborted = true + if (!ctx.assistantMessage.error) { + yield* halt(new DOMException("Aborted", "AbortError"), currentStreamInput) + } + }), + ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => Effect.fail(Cause.squash(cause)), + ), + Effect.retry( + SessionRetry.policy({ + parse, + set: (info) => + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + next: info.next, + }), + }), + ), + Effect.catch((e: unknown) => halt(e, currentStreamInput)), + Effect.ensuring(cleanup()), ) - }).pipe( - Effect.onInterrupt(() => - Effect.gen(function* () { - aborted = true - if (!ctx.assistantMessage.error) { - yield* halt(new DOMException("Aborted", "AbortError")) + + // Check if we need to try a fallback model + if (ctx.shouldFallback && ctx.fallbackChain.length > 0) { + const resolved = yield* provider.resolveFallbackChain(ctx.fallbackChain) + + if (resolved) { + // Successfully resolved a fallback model, update stream input and continue + currentStreamInput = { + ...currentStreamInput, + model: resolved.model, + user: { + ...currentStreamInput.user, + fallbackModels: resolved.remaining, + }, } - }), - ), - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => Effect.fail(Cause.squash(cause)), - ), - Effect.retry( - SessionRetry.policy({ - parse, - set: (info) => - status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - next: info.next, - }), - }), - ), - Effect.catch(halt), - Effect.ensuring(cleanup()), - ) - - if (ctx.needsCompaction) return "compact" - if (ctx.blocked || ctx.assistantMessage.error) return "stop" - return "continue" + // Reset fallback state for next attempt + ctx.shouldFallback = false + ctx.fallbackChain = [] + ctx.assistantMessage.error = undefined + continue + } + + // No more fallbacks available or all failed to resolve + return "stop" as const + } + + // Normal flow - check result + if (ctx.needsCompaction) return "compact" as const + if (ctx.blocked || ctx.assistantMessage.error) return "stop" as const + return "continue" as const + } }) }) diff --git a/packages/opencode/test/fake/provider.ts b/packages/opencode/test/fake/provider.ts index bfb185a4b1bf..a9eb2c0de58a 100644 --- a/packages/opencode/test/fake/provider.ts +++ b/packages/opencode/test/fake/provider.ts @@ -70,8 +70,19 @@ export namespace ProviderTest { getSmallModel: Effect.fn("TestProvider.getSmallModel")((providerID) => Effect.succeed(providerID === row.id ? mdl : undefined), ), - defaultModel: Effect.fn("TestProvider.defaultModel")(() => - Effect.succeed({ providerID: row.id, modelID: mdl.id }), + resolveFallbackChain: Effect.fn("TestProvider.resolveFallbackChain")((chain) => + Effect.gen(function* () { + for (let i = 0; i < chain.length; i++) { + const { providerID, modelID } = chain[i] + if (providerID === row.id && modelID === mdl.id) { + return { model: mdl, remaining: chain.slice(i + 1) } + } + } + return undefined + }), + ), + resolveFallbackChain: Effect.fn("TestProvider.resolveFallbackChain")((chain) => + Effect.succeed(chain[0] ? { model: mdl, remaining: chain.slice(1) } : undefined), ), ...override, }), diff --git a/packages/opencode/test/session/fallback.test.ts b/packages/opencode/test/session/fallback.test.ts new file mode 100644 index 000000000000..7e0ab044a693 --- /dev/null +++ b/packages/opencode/test/session/fallback.test.ts @@ -0,0 +1,222 @@ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import type { Agent } from "../../src/agent/agent" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { SessionProcessor } from "../../src/session/processor" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { SessionSummary } from "../../src/session/summary" +import { Snapshot } from "../../src/snapshot" +import { Log } from "../../src/util" +import { NodeFileSystem } from "@effect/platform-node" +import path from "path" +import { provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" + +void Log.init({ print: false }) + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const fallbackRef = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("fallback-model"), +} + +function agent(): Agent.Info { + return { + name: "build", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } +} + +const user = Effect.fn("TestSession.user")(function* (sessionID: SessionID, text: string) { + const session = yield* Session.Service + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +}) + +const assistant = Effect.fn("TestSession.assistant")(function* ( + sessionID: SessionID, + parentID: MessageID, + root: string, +) { + const session = yield* Session.Service + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + } + yield* session.updateMessage(msg) + return msg +}) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + AgentSvc.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + LLM.defaultLayer, + Provider.defaultLayer, + status, +).pipe(Layer.provideMerge(infra)) +const env = Layer.mergeAll( + TestLLMServer.layer, + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), +) + +const it = testEffect(env) + +const boot = Effect.fn("test.boot")(function* () { + const processors = yield* SessionProcessor.Service + const session = yield* Session.Service + const provider = yield* Provider.Service + return { processors, session, provider } +}) + +// Test: Auth error should NOT trigger fallback +it.live("session.processor fallback: auth error does not fallback", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(401, { error: { message: "Unauthorized", type: "authentication_error" } }) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "hi") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const input = { + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + fallbackModels: [{ providerID: fallbackRef.providerID, modelID: fallbackRef.modelID }], + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "hi" }], + tools: {}, + } satisfies LLM.StreamInput + + const value = yield* handle.process(input) + expect(value).toBe("stop") + expect(handle.message.error?.name).toBe("ProviderAuthError") + }), + { git: true, config: () => ({}) }, + ), +) + +it.live("session.processor fallback: non-retryable error does not fallback", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(400, { error: { message: "bad request", code: "invalid_request" } }) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "non-retryable") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const input = { + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + fallbackModels: [{ providerID: fallbackRef.providerID, modelID: fallbackRef.modelID }], + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "non-retryable" }], + tools: {}, + } satisfies LLM.StreamInput + + const value = yield* handle.process(input) + expect(value).toBe("stop") + expect(handle.message.error).toBeDefined() + }), + { git: true, config: () => ({}) }, + ), +) From ec84337eb5913a2b1cba68e73208821ec88e5fbb Mon Sep 17 00:00:00 2001 From: Sisyphus-AI Date: Sat, 25 Apr 2026 23:54:31 +0200 Subject: [PATCH 2/2] feat(memory): add agent_memory table and memory-tools plugin - Add agent_memory table with full-text search and tags support - Add AgentMemory service (save, search, consolidate) in Effect - Add memory-tools plugin for cloud backup/restore via Supabase - Plugin tools: memory.configure, memory.backup, memory.restore, memory.status, memory.schedule --- packages/memory-tools/README.md | 99 + packages/memory-tools/package.json | 33 + packages/memory-tools/src/index.ts | 241 +++ packages/memory-tools/src/sync.ts | 431 +++++ .../supabase/migrations/001_agent_memory.sql | 46 + packages/memory-tools/tsconfig.json | 15 + .../20260425194233_agent_memory/migration.sql | 20 + .../20260425194233_agent_memory/snapshot.json | 1689 +++++++++++++++++ packages/opencode/src/id/id.ts | 1 + packages/opencode/src/session/memory.ts | 101 + packages/opencode/src/session/schema.ts | 12 + packages/opencode/src/session/session.sql.ts | 33 +- packages/opencode/src/storage/schema.ts | 2 +- 13 files changed, 2721 insertions(+), 2 deletions(-) create mode 100644 packages/memory-tools/README.md create mode 100644 packages/memory-tools/package.json create mode 100644 packages/memory-tools/src/index.ts create mode 100644 packages/memory-tools/src/sync.ts create mode 100644 packages/memory-tools/supabase/migrations/001_agent_memory.sql create mode 100644 packages/memory-tools/tsconfig.json create mode 100644 packages/opencode/migration/20260425194233_agent_memory/migration.sql create mode 100644 packages/opencode/migration/20260425194233_agent_memory/snapshot.json create mode 100644 packages/opencode/src/session/memory.ts diff --git a/packages/memory-tools/README.md b/packages/memory-tools/README.md new file mode 100644 index 000000000000..f6cadc1606ee --- /dev/null +++ b/packages/memory-tools/README.md @@ -0,0 +1,99 @@ +# Memory Tools Plugin + +Cloud backup/restore plugin for OpenCode AgentMemory using Supabase. + +## Features + +- **Backup**: Push local memories to Supabase cloud storage +- **Restore**: Pull cloud memories back to local (merge with conflict resolution) +- **Sync Status**: View difference between local and cloud +- **Auto-Sync**: Scheduled backup at configurable intervals (5m, 15m, 1h, 1d) +- **Manual Mode**: Disable auto-sync and trigger manually + +## Setup + +### 1. Run Supabase Migration + +In your Supabase SQL Editor, run the migration from `supabase/migrations/001_agent_memory.sql`: + +```sql +-- Creates the agent_memory table with proper indexes +CREATE TABLE IF NOT EXISTS agent_memory ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + metadata JSONB, + tags JSONB, + strength INTEGER DEFAULT 100 NOT NULL, + status TEXT DEFAULT 'active' NOT NULL, + time_created BIGINT NOT NULL, + time_updated BIGINT NOT NULL +); +``` + +### 2. Configure Plugin + +In your `opencode.json`: + +```json +{ + "plugin": [ + ["@opencode-ai/memory-tools", { + "supabaseUrl": "https://your-project.supabase.co", + "supabaseKey": "your-service-role-key", + "syncInterval": "5m" + }] + ] +} +``` + +Or use the built-in tool: + +``` +memory.configure [interval] +``` + +## Tools + +| Tool | Description | +|------|-------------| +| `memory.configure` | Configure Supabase connection (url, key, interval) | +| `memory.backup` | Push all local memories to cloud | +| `memory.restore` | Pull cloud memories to local (merge) | +| `memory.status` | Show sync status (local count, cloud count, differences) | +| `memory.schedule` | Get or set auto-sync interval | + +## Sync Interval + +Format: `[number][unit]` where unit is `m` (minutes), `h` (hours), `d` (days) + +Examples: +- `5m` - Every 5 minutes +- `15m` - Every 15 minutes +- `1h` - Every hour +- `1d` - Once daily +- `manual` - No auto-sync + +## Conflict Resolution + +When the same memory exists in both local and cloud: +- **Newest wins**: The version with the later `time_updated` timestamp is kept +- Both versions are preserved - no data is deleted + +## Database Path + +Local SQLite database is located at: +- **Windows**: `%APPDATA%\Local\opencode\opencode.db` +- **macOS**: `~/Library/Application Support/opencode/opencode.db` +- **Linux**: `~/.local/share/opencode/opencode.db` + +Override with `OPENCODE_DB` environment variable. + +## Security + +- Uses Supabase service role key (PAT) for server-side operations +- RLS should be disabled for personal use (service role bypasses RLS) +- Config stored at `.opencode/memory-tools.json` in project directory \ No newline at end of file diff --git a/packages/memory-tools/package.json b/packages/memory-tools/package.json new file mode 100644 index 000000000000..eb69ab8f2f65 --- /dev/null +++ b/packages/memory-tools/package.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode-ai/memory-tools", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "tsc" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "dependencies": { + "@supabase/supabase-js": "^2.45.0" + }, + "peerDependencies": { + "@opencode-ai/plugin": "workspace:*" + }, + "devDependencies": { + "@opencode-ai/plugin": "workspace:*", + "@opencode-ai/sdk": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "1.3.12", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "bun-types": "1.3.13", + "typescript": "catalog:" + } +} diff --git a/packages/memory-tools/src/index.ts b/packages/memory-tools/src/index.ts new file mode 100644 index 000000000000..e8ce8d5bb8bd --- /dev/null +++ b/packages/memory-tools/src/index.ts @@ -0,0 +1,241 @@ +/** + * Memory Tools Plugin for OpenCode + * + * Provides cloud backup/restore for AgentMemory via Supabase. + * User configures their own Supabase account via opencode.json. + * + * Usage in opencode.json: + * { + * "plugin": [ + * ["@opencode-ai/memory-tools", { + * "supabaseUrl": "https://xxx.supabase.co", + * "supabaseKey": "eyJ...", + * "syncInterval": "5m" + * }] + * ] + * } + */ + +import type { Plugin, PluginInput } from "@opencode-ai/plugin" +import { tool } from "@opencode-ai/plugin/tool" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import type { ToolDefinition } from "@opencode-ai/plugin/tool" +import { getConfig, saveConfig, parseInterval, getLocalDB, getCloudDB, backupToCloud, restoreFromCloud, getSyncStatus, type MemoryConfig, type SyncStatus } from "./sync.js" + +// Parse interval string to milliseconds +function intervalToMs(interval: string): number | null { + const match = interval.match(/^(\d+)(m|h|d)$/) + if (!match) return null + const value = parseInt(match[1]!, 10) + const unit = match[2]! + switch (unit) { + case "m": return value * 60 * 1000 + case "h": return value * 60 * 60 * 1000 + case "d": return value * 24 * 60 * 60 * 1000 + default: return null + } +} + +// Scheduler state +let syncTimer: ReturnType | null = null +let currentConfig: MemoryConfig | null = null + +async function startScheduler(config: MemoryConfig) { + // Stop existing scheduler + if (syncTimer) { + clearInterval(syncTimer) + syncTimer = null + } + + // Manual mode - no auto sync + if (config.syncInterval === "manual" || !config.syncInterval) { + return + } + + const ms = intervalToMs(config.syncInterval) + if (!ms) return + + syncTimer = setInterval(async () => { + try { + await backupToCloud(config) + } catch (error) { + console.error("[memory-tools] Scheduled backup failed:", error) + } + }, ms) +} + +async function stopScheduler() { + if (syncTimer) { + clearInterval(syncTimer) + syncTimer = null + } +} + +// Tool definitions +const tools: Record = { + "memory.backup": tool({ + description: "Backup all local memories to Supabase cloud storage", + args: {}, + async execute(_args, context) { + if (!currentConfig) { + return "Error: memory-tools not configured. Set supabaseUrl, supabaseKey, and syncInterval in opencode.json" + } + try { + const localDB = await getLocalDB(context.directory) + const cloudDB = getCloudDB(currentConfig) + const result = await backupToCloud(currentConfig, localDB, cloudDB) + return `Backup complete: ${result.pushed} memories pushed to cloud` + } catch (error) { + return `Backup failed: ${error instanceof Error ? error.message : String(error)}` + } + }, + }), + + "memory.restore": tool({ + description: "Restore memories from Supabase cloud to local storage (merge)", + args: {}, + async execute(_args, context) { + if (!currentConfig) { + return "Error: memory-tools not configured. Set supabaseUrl, supabaseKey, and syncInterval in opencode.json" + } + try { + const localDB = await getLocalDB(context.directory) + const cloudDB = getCloudDB(currentConfig) + const result = await restoreFromCloud(currentConfig, localDB, cloudDB) + return `Restore complete: ${result.restored} memories restored from cloud` + } catch (error) { + return `Restore failed: ${error instanceof Error ? error.message : String(error)}` + } + }, + }), + + "memory.status": tool({ + description: "Show sync status between local and cloud", + args: {}, + async execute(_args, context) { + if (!currentConfig) { + return "Error: memory-tools not configured. Set supabaseUrl, supabaseKey, and syncInterval in opencode.json" + } + try { + const localDB = await getLocalDB(context.directory) + const cloudDB = getCloudDB(currentConfig) + const status = await getSyncStatus(currentConfig, localDB, cloudDB) + return formatStatus(status) + } catch (error) { + return `Status check failed: ${error instanceof Error ? error.message : String(error)}` + } + }, + }), + + "memory.schedule": tool({ + description: "Get or set the sync schedule interval. Usage: memory.schedule [interval|null]", + args: { + interval: tool.schema.string().optional().describe("Interval like '5m', '15m', '1h', '1d', or 'manual'. Omit to see current value."), + }, + async execute(args, context) { + if (!currentConfig) { + return "Error: memory-tools not configured. Set supabaseUrl, supabaseKey, and syncInterval in opencode.json" + } + + // Get current schedule + if (!args.interval) { + return `Current sync interval: ${currentConfig.syncInterval || "manual"}` + } + + // Validate interval + if (args.interval !== "manual" && !intervalToMs(args.interval)) { + return "Error: Invalid interval. Use formats like '5m', '15m', '1h', '1d', or 'manual'" + } + + // Update config + const newConfig = { ...currentConfig, syncInterval: args.interval } + await saveConfig(context.directory, newConfig) + currentConfig = newConfig + + // Restart scheduler + await startScheduler(newConfig) + + return `Sync interval updated to: ${args.interval}` + }, + }), + + "memory.configure": tool({ + description: "Configure Supabase connection. Usage: memory.configure [interval]", + args: { + url: tool.schema.string().describe("Supabase project URL"), + key: tool.schema.string().describe("Supabase service role key (PAT)"), + interval: tool.schema.string().optional().describe("Sync interval (e.g., '5m', '15m', '1h', '1d', 'manual')"), + }, + async execute(args, context) { + // Validate URL + if (!args.url.includes(".supabase.co")) { + return "Error: Invalid Supabase URL. Should be like https://xxx.supabase.co" + } + + // Validate key format (JWT) + if (!args.key.startsWith("eyJ")) { + return "Error: Invalid key format. Should be a Supabase service role key (starts with eyJ...)" + } + + const interval = args.interval || "manual" + if (interval !== "manual" && !intervalToMs(interval)) { + return "Error: Invalid interval. Use formats like '5m', '15m', '1h', '1d', or 'manual'" + } + + const config: MemoryConfig = { + supabaseUrl: args.url, + supabaseKey: args.key, + syncInterval: interval, + } + + await saveConfig(context.directory, config) + currentConfig = config + + // Start scheduler + await startScheduler(config) + + return `Configuration saved. Sync interval: ${interval}. Run 'memory.backup' to do initial backup.` + }, + }), +} + +function formatStatus(status: SyncStatus): string { + const lines = [ + "=== Memory Sync Status ===", + `Local memories: ${status.localCount}`, + `Cloud memories: ${status.cloudCount}`, + `Last sync: ${status.lastSyncTime ? new Date(status.lastSyncTime).toISOString() : "never"}`, + ] + + if (status.newerInCloud > 0) { + lines.push(`⚠️ ${status.newerInCloud} memories newer in cloud (will be restored)`) + } + if (status.newerInLocal > 0) { + lines.push(`📤 ${status.newerInLocal} memories newer in local (will be backed up)`) + } + if (status.localCount === 0 && status.cloudCount === 0) { + lines.push("No memories found. Create memories first, then backup.") + } + + return lines.join("\n") +} + +// Plugin entry point +export const MemoryToolsPlugin: Plugin = async (input: PluginInput) => { + // Load existing config + try { + currentConfig = await getConfig(input.directory) + if (currentConfig) { + // Start scheduler if interval is set + await startScheduler(currentConfig) + } + } catch { + // No config yet - user needs to configure + } + + return { + tool: tools, + } +} + +export default MemoryToolsPlugin diff --git a/packages/memory-tools/src/sync.ts b/packages/memory-tools/src/sync.ts new file mode 100644 index 000000000000..ad20f79e8929 --- /dev/null +++ b/packages/memory-tools/src/sync.ts @@ -0,0 +1,431 @@ +/** + * Cloud sync logic for AgentMemory + * + * Handles backup (local → Supabase) and restore (Supabase → local) + * with conflict resolution (newest wins by timestamp). + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js" +import { Database } from "bun:sqlite" +import path from "path" +import os from "os" +import fs from "fs" + +// Row type for SQLite queries +interface SqlRow { + id: string + project_id: string + session_id: string | null + type: string + title: string + content: string + metadata: string | null + tags: string | null + strength: number + status: string + time_created: number + time_updated: number +} + +// Platform-specific SQLite path detection +function getSQLitePath(): string { + // Try OPENCODE_DB env var first + if (process.env.OPENCODE_DB && process.env.OPENCODE_DB !== ":memory:") { + if (path.isAbsolute(process.env.OPENCODE_DB)) { + return process.env.OPENCODE_DB + } + return path.join(os.homedir(), ".opencode", process.env.OPENCODE_DB) + } + + // Detect platform and build path + if (process.platform === "win32") { + return path.join(os.homedir(), "AppData", "Local", "opencode", "opencode.db") + } else if (process.platform === "darwin") { + return path.join(os.homedir(), "Library", "Application Support", "opencode", "opencode.db") + } else { + // Linux and others + const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share") + return path.join(xdgData, "opencode", "opencode.db") + } +} + +// Config storage path +function getConfigPath(cwd: string): string { + return path.join(cwd, ".opencode", "memory-tools.json") +} + +export interface MemoryConfig { + supabaseUrl: string + supabaseKey: string + syncInterval: string +} + +export interface LocalMemory { + id: string + project_id: string + session_id: string | null + type: string + title: string + content: string + metadata: { + what?: string + why?: string + where?: string | string[] + learned?: string + } | null + tags: string[] | null + strength: number + status: string + time_created: number + time_updated: number +} + +export interface CloudMemory { + id: string + project_id: string + session_id: string | null + type: string + title: string + content: string + metadata: { + what?: string + why?: string + where?: string | string[] + learned?: string + } | null + tags: string[] | null + strength: number + status: string + time_created: number + time_updated: number +} + +export interface SyncResult { + pushed: number + restored: number + skipped: number +} + +export interface SyncStatus { + localCount: number + cloudCount: number + lastSyncTime: number | null + newerInCloud: number + newerInLocal: number +} + +// Parse interval string to milliseconds +export function parseInterval(interval: string): number | null { + const match = interval.match(/^(\d+)(m|h|d)$/) + if (!match) return null + const value = parseInt(match[1]!, 10) + switch (match[2]!) { + case "m": return value * 60 * 1000 + case "h": return value * 60 * 60 * 1000 + case "d": return value * 24 * 60 * 60 * 1000 + default: return null + } +} + +// Config management +export async function getConfig(cwd: string): Promise { + const configPath = getConfigPath(cwd) + if (!fs.existsSync(configPath)) return null + const content = await fs.promises.readFile(configPath, "utf-8") + return JSON.parse(content) +} + +export async function saveConfig(cwd: string, config: MemoryConfig): Promise { + const configPath = getConfigPath(cwd) + const dir = path.dirname(configPath) + if (!fs.existsSync(dir)) { + await fs.promises.mkdir(dir, { recursive: true }) + } + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2)) +} + +// Get local SQLite database connection +export async function getLocalDB(cwd: string): Promise { + const dbPath = getSQLitePath() + if (!fs.existsSync(dbPath)) { + throw new Error(`Local database not found at ${dbPath}. Make sure OpenCode has been run at least once.`) + } + return new LocalDatabase(dbPath) +} + +// Get Supabase client +export function getCloudDB(config: MemoryConfig): SupabaseClient { + return createClient(config.supabaseUrl, config.supabaseKey, { + auth: { + persistSession: false, + autoRefreshToken: false, + }, + }) +} + +// Read all local memories +async function getLocalMemories(db: LocalDatabase, projectId: string): Promise { + const rows = db.query(` + SELECT id, project_id, session_id, type, title, content, metadata, tags, strength, status, time_created, time_updated + FROM agent_memory + WHERE project_id = ? AND status = 'active' + `, [projectId]) + + return rows.map(row => ({ + id: row.id, + project_id: row.project_id, + session_id: row.session_id, + type: row.type, + title: row.title, + content: row.content, + metadata: row.metadata ? JSON.parse(row.metadata) : null, + tags: row.tags ? JSON.parse(row.tags) : null, + strength: row.strength, + status: row.status, + time_created: row.time_created, + time_updated: row.time_updated, + })) +} + +// Read all cloud memories +async function getCloudMemories(db: SupabaseClient, projectId: string): Promise { + const { data, error } = await db + .from("agent_memory") + .select("*") + .eq("project_id", projectId) + .eq("status", "active") + + if (error) throw new Error(`Failed to fetch cloud memories: ${error.message}`) + + return (data || []).map(row => ({ + id: row.id, + project_id: row.project_id, + session_id: row.session_id, + type: row.type, + title: row.title, + content: row.content, + metadata: row.metadata || null, + tags: row.tags || null, + strength: row.strength, + status: row.status, + time_created: row.time_created, + time_updated: row.time_updated, + })) +} + +// Backup local memories to cloud +export async function backupToCloud( + config: MemoryConfig, + localDB?: LocalDatabase, + cloudDB?: SupabaseClient, +): Promise { + const db = localDB || await getLocalDB(process.cwd()) + const supabase = cloudDB || getCloudDB(config) + + // Get all local project IDs that have memories + const projectIdRows = db.query<{ project_id: string }>(` + SELECT DISTINCT project_id FROM agent_memory WHERE status = 'active' + `) + const projectIds = projectIdRows.map(row => row.project_id) + + let pushed = 0 + let skipped = 0 + + for (const projectId of projectIds) { + const localMemories = await getLocalMemories(db, projectId) + const cloudMemories = await getCloudMemories(supabase, projectId) + const cloudById = new Map(cloudMemories.map(m => [m.id, m])) + + for (const memory of localMemories) { + const cloudMemory = cloudById.get(memory.id) + + if (!cloudMemory) { + // New memory - insert + const { error } = await supabase.from("agent_memory").insert({ + id: memory.id, + project_id: memory.project_id, + session_id: memory.session_id, + type: memory.type, + title: memory.title, + content: memory.content, + metadata: memory.metadata, + tags: memory.tags, + strength: memory.strength, + status: memory.status, + time_created: memory.time_created, + time_updated: memory.time_updated, + }) + + if (error) { + console.error(`Failed to push memory ${memory.id}:`, error) + } else { + pushed++ + } + } else if (memory.time_updated > cloudMemory.time_updated) { + // Local is newer - update + const { error } = await supabase + .from("agent_memory") + .update({ + title: memory.title, + content: memory.content, + metadata: memory.metadata, + tags: memory.tags, + strength: memory.strength, + status: memory.status, + time_updated: memory.time_updated, + }) + .eq("id", memory.id) + + if (error) { + console.error(`Failed to update memory ${memory.id}:`, error) + } else { + pushed++ + } + } else { + // Cloud is newer or same - skip + skipped++ + } + } + } + + return { pushed, restored: 0, skipped } +} + +// Restore cloud memories to local +export async function restoreFromCloud( + config: MemoryConfig, + localDB?: LocalDatabase, + cloudDB?: SupabaseClient, +): Promise { + const db = localDB || await getLocalDB(process.cwd()) + const supabase = cloudDB || getCloudDB(config) + + // Get all cloud project IDs + const { data: cloudProjects } = await supabase + .from("agent_memory") + .select("project_id") + + const projectIds = [...new Set((cloudProjects || []).map((row: any) => row.project_id))] + + let restored = 0 + let skipped = 0 + + for (const projectId of projectIds) { + const cloudMemories = await getCloudMemories(supabase, projectId) + const localMemories = await getLocalMemories(db, projectId) + const localById = new Map(localMemories.map(m => [m.id, m])) + + for (const memory of cloudMemories) { + const localMemory = localById.get(memory.id) + + if (!localMemory) { + // New in cloud - insert locally + db.run(` + INSERT INTO agent_memory (id, project_id, session_id, type, title, content, metadata, tags, strength, status, time_created, time_updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + memory.id, + memory.project_id, + memory.session_id, + memory.type, + memory.title, + memory.content, + memory.metadata ? JSON.stringify(memory.metadata) : null, + memory.tags ? JSON.stringify(memory.tags) : null, + memory.strength, + memory.status, + memory.time_created, + memory.time_updated, + ]) + restored++ + } else if (memory.time_updated > localMemory.time_updated) { + // Cloud is newer - update local + db.run(` + UPDATE agent_memory + SET title = ?, content = ?, metadata = ?, tags = ?, strength = ?, status = ?, time_updated = ? + WHERE id = ? + `, [ + memory.title, + memory.content, + memory.metadata ? JSON.stringify(memory.metadata) : null, + memory.tags ? JSON.stringify(memory.tags) : null, + memory.strength, + memory.status, + memory.time_updated, + memory.id, + ]) + restored++ + } else { + // Local is newer or same - skip + skipped++ + } + } + } + + return { pushed: 0, restored, skipped } +} + +// Get sync status +export async function getSyncStatus( + config: MemoryConfig, + localDB?: LocalDatabase, + cloudDB?: SupabaseClient, +): Promise { + const db = localDB || await getLocalDB(process.cwd()) + const supabase = cloudDB || getCloudDB(config) + + // Get local count + const localRows = db.query<{ count: number }>(` + SELECT COUNT(*) as count FROM agent_memory WHERE status = 'active' + `) + const localCount = localRows[0]?.count || 0 + + // Get cloud count + const { count: cloudCount } = await supabase + .from("agent_memory") + .select("*", { count: "exact", head: true }) + .eq("status", "active") + + // Calculate newer counts (simplified) + let newerInCloud = 0 + let newerInLocal = 0 + + // Get last sync time from config + let lastSyncTime: number | null = null + const configPath = getConfigPath(process.cwd()) + try { + const configData = JSON.parse(await fs.promises.readFile(configPath, "utf-8")) + lastSyncTime = configData.lastSyncTime || null + } catch { + // No sync yet + } + + return { + localCount, + cloudCount: cloudCount || 0, + lastSyncTime, + newerInCloud, + newerInLocal, + } +} + +// Simple SQLite wrapper for local database access +class LocalDatabase { + private db: Database + + constructor(dbPath: string) { + this.db = new Database(dbPath) + } + + query(sql: string, params?: unknown[]): T[] { + const stmt = this.db.prepare(sql) + const bindings = params as (string | number | null | bigint | boolean | Uint8Array)[] + return (params ? stmt.all(...bindings) : stmt.all()) as T[] + } + + run(sql: string, params?: unknown[]): void { + const stmt = this.db.prepare(sql) + const bindings = params as (string | number | null | bigint | boolean | Uint8Array)[] + params ? stmt.run(...bindings) : stmt.run() + } +} diff --git a/packages/memory-tools/supabase/migrations/001_agent_memory.sql b/packages/memory-tools/supabase/migrations/001_agent_memory.sql new file mode 100644 index 000000000000..abc0d8d08b39 --- /dev/null +++ b/packages/memory-tools/supabase/migrations/001_agent_memory.sql @@ -0,0 +1,46 @@ +-- Memory Tools Plugin - Supabase Migration +-- Run this SQL in your Supabase SQL Editor to create the agent_memory table +-- +-- IMPORTANT: This table mirrors the local SQLite schema. The plugin will +-- push memories from local SQLite to this cloud table. + +-- Create table +CREATE TABLE IF NOT EXISTS agent_memory ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + session_id TEXT, + type TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + metadata JSONB, + tags JSONB, + strength INTEGER DEFAULT 100 NOT NULL, + status TEXT DEFAULT 'active' NOT NULL, + time_created BIGINT NOT NULL, + time_updated BIGINT NOT NULL +); + +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_agent_memory_project_id ON agent_memory(project_id); +CREATE INDEX IF NOT EXISTS idx_agent_memory_type ON agent_memory(type); +CREATE INDEX IF NOT EXISTS idx_agent_memory_status ON agent_memory(status); +CREATE INDEX IF NOT EXISTS idx_agent_memory_project_type ON agent_memory(project_id, type); + +-- Row Level Security (optional but recommended) +-- Uncomment if you want RLS policies +-- ALTER TABLE agent_memory ENABLE ROW LEVEL SECURITY; + +-- Policy: Users can only see their own memories (based on project_id ownership) +-- Create a policy like this for each user: +-- CREATE POLICY "Users can manage own memories" ON agent_memory +-- FOR ALL USING (auth.uid() IS NOT NULL); + +-- Note: For personal use with PAT (service role key), RLS should be disabled +-- since the plugin uses the service role key. Run this if using RLS: +-- ALTER TABLE agent_memory DISABLE ROW LEVEL SECURITY; + +-- Grant permissions (adjust as needed for your setup) +-- GRANT ALL ON agent_memory TO authenticated; +-- GRANT ALL ON agent_memory TO service_role; + +COMMENT ON TABLE agent_memory IS 'Cloud backup of OpenCode AgentMemory. Mirrors local SQLite schema.'; diff --git a/packages/memory-tools/tsconfig.json b/packages/memory-tools/tsconfig.json new file mode 100644 index 000000000000..31e33db898b4 --- /dev/null +++ b/packages/memory-tools/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/opencode/migration/20260425194233_agent_memory/migration.sql b/packages/opencode/migration/20260425194233_agent_memory/migration.sql new file mode 100644 index 000000000000..85c480d39fff --- /dev/null +++ b/packages/opencode/migration/20260425194233_agent_memory/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE `agent_memory` ( + `id` text PRIMARY KEY, + `project_id` text NOT NULL, + `session_id` text, + `type` text NOT NULL, + `title` text NOT NULL, + `content` text NOT NULL, + `metadata` text, + `tags` text, + `strength` integer DEFAULT 100 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + CONSTRAINT `fk_agent_memory_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `agent_memory_project_idx` ON `agent_memory` (`project_id`);--> statement-breakpoint +CREATE INDEX `agent_memory_type_idx` ON `agent_memory` (`type`);--> statement-breakpoint +CREATE INDEX `agent_memory_status_idx` ON `agent_memory` (`status`);--> statement-breakpoint +CREATE INDEX `agent_memory_project_type_idx` ON `agent_memory` (`project_id`,`type`); \ No newline at end of file diff --git a/packages/opencode/migration/20260425194233_agent_memory/snapshot.json b/packages/opencode/migration/20260425194233_agent_memory/snapshot.json new file mode 100644 index 000000000000..5c7d0bad488d --- /dev/null +++ b/packages/opencode/migration/20260425194233_agent_memory/snapshot.json @@ -0,0 +1,1689 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "17e47a21-59d7-40e5-8970-8060b0a53e2d", + "prevIds": [ + "66cbe0d7-def0-451b-b88a-7608513a9b44" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "agent_memory", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "tags", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "100", + "generated": null, + "name": "strength", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'active'", + "generated": null, + "name": "status", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "agent_memory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_agent_memory_project_id_project_id_fk", + "entityType": "fks", + "table": "agent_memory" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "agent_memory_pk", + "table": "agent_memory", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_memory_project_idx", + "entityType": "indexes", + "table": "agent_memory" + }, + { + "columns": [ + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_memory_type_idx", + "entityType": "indexes", + "table": "agent_memory" + }, + { + "columns": [ + { + "value": "status", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_memory_status_idx", + "entityType": "indexes", + "table": "agent_memory" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "agent_memory_project_type_idx", + "entityType": "indexes", + "table": "agent_memory" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5d2b..2633ba85f96a 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + memory: "mem", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/memory.ts b/packages/opencode/src/session/memory.ts new file mode 100644 index 000000000000..1916547c22ff --- /dev/null +++ b/packages/opencode/src/session/memory.ts @@ -0,0 +1,101 @@ +import { Effect } from "effect" +import { Database, eq, and, or, like, lt } from "@/storage" +import { AgentMemoryTable } from "./session.sql" +import type { ProjectID } from "@/project/schema" +import type { SessionID } from "./schema" +import { AgentMemoryID } from "./schema" + +export interface AgentMemoryInput { + sessionID?: SessionID + type: string + title: string + content: string + metadata?: { + what?: string + why?: string + where?: string | string[] + learned?: string + } + tags?: string[] + strength?: number +} + +const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => + Effect.sync(() => Database.use(fn)) + +export const AgentMemory = { + save: Effect.fn("AgentMemory.save")(function* (projectID: ProjectID, input: AgentMemoryInput) { + const id = AgentMemoryID.descending() + yield* Effect.sync(() => + Database.transaction((tx) => { + tx.insert(AgentMemoryTable).values({ + id, + project_id: projectID, + session_id: input.sessionID ?? null, + type: input.type, + title: input.title, + content: input.content, + metadata: input.metadata ?? null, + tags: input.tags ?? null, + strength: input.strength ?? 100, + status: "active", + time_created: Date.now(), + time_updated: Date.now(), + }).run() + }), + ) + return id + }), + + search: Effect.fn("AgentMemory.search")(function* ( + projectID: ProjectID, + options?: { type?: string; keyword?: string; limit?: number }, + ) { + return yield* db((tx) => { + const conditions: (ReturnType | ReturnType | undefined)[] = [ + eq(AgentMemoryTable.project_id, projectID), + eq(AgentMemoryTable.status, "active"), + ] + if (options?.type) conditions.push(eq(AgentMemoryTable.type, options.type)) + if (options?.keyword) { + const pattern = `%${options.keyword}%` + conditions.push( + or( + like(AgentMemoryTable.title, pattern), + like(AgentMemoryTable.content, pattern), + ) as never, + ) + } + return tx + .select() + .from(AgentMemoryTable) + .where(and(...conditions.filter(Boolean as never))) + .limit(options?.limit ?? 50) + .all() + }) + }), + + consolidate: Effect.fn("AgentMemory.consolidate")(function* ( + projectID: ProjectID, + cutoffDays?: number, + ) { + const cutoff = cutoffDays + ? Date.now() - cutoffDays * 24 * 60 * 60 * 1000 + : Date.now() - 30 * 24 * 60 * 60 * 1000 + yield* Effect.sync(() => + Database.transaction((tx) => { + tx + .update(AgentMemoryTable) + .set({ status: "consolidated", time_updated: Date.now() }) + .where( + and( + eq(AgentMemoryTable.project_id, projectID), + eq(AgentMemoryTable.status, "active"), + lt(AgentMemoryTable.time_created, cutoff), + ) as never, + ) + .run() + }), + ) + }), +} diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34a73..b4351cf99d37 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -33,3 +33,15 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema( ) export type PartID = Schema.Schema.Type + +export const AgentMemoryID = Schema.String + .annotate({ [ZodOverride]: Identifier.schema("memory") }) + .pipe( + Schema.brand("AgentMemoryID"), + withStatics((s) => ({ + descending: (id?: string) => s.make(Identifier.descending("memory", id)), + zod: zod(s), + })), + ) + +export type AgentMemoryID = Schema.Schema.Type diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 35ed8fdda48a..712162ccda20 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -5,7 +5,7 @@ import type { SessionEntry } from "../v2/session-entry" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" -import type { SessionID, MessageID, PartID } from "./schema" +import type { SessionID, MessageID, PartID, AgentMemoryID } from "./schema" import type { WorkspaceID } from "../control-plane/schema" import { Timestamps } from "../storage/schema.sql" @@ -121,3 +121,34 @@ export const PermissionTable = sqliteTable("permission", { ...Timestamps, data: text({ mode: "json" }).notNull().$type(), }) + +export const AgentMemoryTable = sqliteTable( + "agent_memory", + { + id: text().$type().primaryKey(), + project_id: text() + .$type() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + session_id: text().$type(), + type: text().notNull(), + title: text().notNull(), + content: text().notNull(), + metadata: text({ mode: "json" }).$type<{ + what?: string + why?: string + where?: string | string[] + learned?: string + }>(), + tags: text({ mode: "json" }).$type(), + strength: integer().notNull().default(100), + status: text().notNull().default("active"), + ...Timestamps, + }, + (table) => [ + index("agent_memory_project_idx").on(table.project_id), + index("agent_memory_type_idx").on(table.type), + index("agent_memory_status_idx").on(table.status), + index("agent_memory_project_type_idx").on(table.project_id, table.type), + ], +) diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 0c12cee62201..09f7bd507fb1 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -1,5 +1,5 @@ export { AccountTable, AccountStateTable, ControlAccountTable } from "../account/account.sql" export { ProjectTable } from "../project/project.sql" -export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" +export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable, AgentMemoryTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" export { WorkspaceTable } from "../control-plane/workspace.sql"