diff --git a/.env.example b/.env.example index 193b2654..30b3eb7d 100644 --- a/.env.example +++ b/.env.example @@ -123,3 +123,8 @@ PASSKEY_ORIGIN=http://localhost:5003 # VITE_OPENCODE_PORT=5551 # VITE_MAX_FILE_SIZE_MB=50 # VITE_MAX_UPLOAD_SIZE_MB=50 + +# ============================================ +# Plugins +# ============================================ +INSTALL_MEMORY_PLUGIN=true diff --git a/Dockerfile b/Dockerfile index e49218b5..d4eed6d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,6 @@ COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml ./ COPY --chown=node:node shared/package.json ./shared/ COPY --chown=node:node backend/package.json ./backend/ COPY --chown=node:node frontend/package.json ./frontend/ -COPY --chown=node:node packages/memory ./packages/memory/ RUN pnpm install --frozen-lockfile @@ -53,10 +52,8 @@ COPY backend ./backend COPY frontend/src ./frontend/src COPY frontend/public ./frontend/public COPY frontend/index.html frontend/vite.config.ts frontend/tsconfig*.json frontend/components.json frontend/eslint.config.js ./frontend/ -COPY packages/memory ./packages/memory RUN pnpm --filter frontend build -RUN pnpm --filter @opencode-manager/memory build FROM base AS runner @@ -84,31 +81,20 @@ ENV OPENCODE_SERVER_PORT=5551 ENV DATABASE_PATH=/app/data/opencode.db ENV WORKSPACE_PATH=/workspace ENV NODE_PATH=/opt/opencode-plugins/node_modules +ENV INSTALL_MEMORY_PLUGIN=true COPY --from=deps --chown=node:node /app/node_modules ./node_modules COPY --from=builder /app/shared ./shared COPY --from=builder /app/backend ./backend COPY --from=builder /app/frontend/dist ./frontend/dist COPY package.json pnpm-workspace.yaml ./ - -RUN mkdir -p /app/backend/node_modules/@opencode-manager && \ - ln -s /app/shared /app/backend/node_modules/@opencode-manager/shared - -COPY --from=builder /app/packages/memory /opt/opencode-plugins/src - -RUN cd /opt/opencode-plugins/src && npm install - -RUN mkdir -p /opt/opencode-plugins/node_modules/@opencode-manager/memory && \ - cp -r /opt/opencode-plugins/src/dist/* /opt/opencode-plugins/node_modules/@opencode-manager/memory/ && \ - cp /opt/opencode-plugins/src/package.json /opt/opencode-plugins/node_modules/@opencode-manager/memory/ && \ - cp /opt/opencode-plugins/src/config.jsonc /opt/opencode-plugins/node_modules/@opencode-manager/memory/config.jsonc 2>/dev/null || true && \ - cp -r /opt/opencode-plugins/src/node_modules/* /opt/opencode-plugins/node_modules/ 2>/dev/null || true +COPY --from=deps --chown=node:node /app/backend/node_modules ./backend/node_modules COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh -RUN mkdir -p /workspace /app/data && \ - chown -R node:node /workspace /app/data +RUN mkdir -p /workspace /app/data /opt/opencode-plugins && \ + chown -R node:node /workspace /app/data /opt/opencode-plugins EXPOSE 5003 5100 5101 5102 5103 diff --git a/backend/src/services/proxy.ts b/backend/src/services/proxy.ts index d7bfb3ba..a963f4c2 100644 --- a/backend/src/services/proxy.ts +++ b/backend/src/services/proxy.ts @@ -118,13 +118,16 @@ export async function proxyRequest(request: Request) { }) const responseHeaders: Record = {} + const skipHeaders = new Set(['connection', 'transfer-encoding', 'content-encoding', 'content-length']) response.headers.forEach((value, key) => { - if (!['connection', 'transfer-encoding'].includes(key.toLowerCase())) { + if (!skipHeaders.has(key.toLowerCase())) { responseHeaders[key] = value } }) - return new Response(response.body, { + const body = await response.text() + + return new Response(body, { status: response.status, statusText: response.statusText, headers: responseHeaders, @@ -159,13 +162,16 @@ export async function proxyToOpenCodeWithDirectory( }) const responseHeaders: Record = {} + const skipHeaders = new Set(['connection', 'transfer-encoding', 'content-encoding', 'content-length']) response.headers.forEach((value, key) => { - if (!['connection', 'transfer-encoding'].includes(key.toLowerCase())) { + if (!skipHeaders.has(key.toLowerCase())) { responseHeaders[key] = value } }) - return new Response(response.body, { + const responseBody = await response.text() + + return new Response(responseBody, { status: response.status, statusText: response.statusText, headers: responseHeaders, diff --git a/docker-compose.yml b/docker-compose.yml index 88f4caf7..6cf22e11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} - VAPID_SUBJECT=${VAPID_SUBJECT:-} + - INSTALL_MEMORY_PLUGIN=${INSTALL_MEMORY_PLUGIN:-true} volumes: - opencode-workspace:/workspace - opencode-data:/app/data diff --git a/docs/features/memory.md b/docs/features/memory.md index 6a946ae5..d81cdd2e 100644 --- a/docs/features/memory.md +++ b/docs/features/memory.md @@ -82,6 +82,11 @@ The file is only created if it does not already exist. The config is validated o "model": "", "minAudits": 1, "stallTimeoutMs": 60000 + }, + "tui": { + "sidebar": true, + "showLoops": true, + "showVersion": true } } ``` @@ -140,6 +145,9 @@ Set `baseUrl` to point at any OpenAI-compatible self-hosted service (vLLM, Ollam | `loop.model` | Model override for loop sessions (`provider/model`), falls back to `executionModel` | — | | `loop.minAudits` | Minimum audit iterations required before completion | `1` | | `loop.stallTimeoutMs` | Watchdog stall detection timeout (ms) | `60000` | +| `tui.sidebar` | Show the Memory plugin sidebar in the TUI | `true` | +| `tui.showLoops` | Show active loops in the sidebar | `true` | +| `tui.showVersion` | Show version number in sidebar title | `true` | !!! note "Deprecated Options" The `ralph.*` prefix is deprecated but still accepted for backward compatibility. Use `loop.*` instead. diff --git a/frontend/package.json b/frontend/package.json index 809c8e4b..ad1671cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@eslint/js": "^9.36.0", + "@standard-schema/spec": "^1.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^14.6.1", diff --git a/frontend/src/components/memory/KvFormDialog.tsx b/frontend/src/components/memory/KvFormDialog.tsx index 8cfed190..22597527 100644 --- a/frontend/src/components/memory/KvFormDialog.tsx +++ b/frontend/src/components/memory/KvFormDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useCreateKvEntry, useUpdateKvEntry } from '@/hooks/useMemories' import type { KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest } from '@opencode-manager/shared/types' @@ -44,7 +44,7 @@ export function KvFormDialog({ entry, projectId, open, onOpenChange }: KvFormDia reset, formState: { errors }, } = useForm({ - resolver: zodResolver(kvSchema), + resolver: standardSchemaResolver(kvSchema), defaultValues: { key: '', data: '', diff --git a/frontend/src/components/memory/MemoryFormDialog.tsx b/frontend/src/components/memory/MemoryFormDialog.tsx index 2b38f8d4..4aa99441 100644 --- a/frontend/src/components/memory/MemoryFormDialog.tsx +++ b/frontend/src/components/memory/MemoryFormDialog.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useCreateMemory, useUpdateMemory } from '@/hooks/useMemories' import type { Memory, CreateMemoryRequest, UpdateMemoryRequest } from '@opencode-manager/shared/types' @@ -43,7 +43,7 @@ export function MemoryFormDialog({ memory, projectId, open, onOpenChange }: Memo reset, formState: { errors }, } = useForm({ - resolver: zodResolver(memorySchema), + resolver: standardSchemaResolver(memorySchema), defaultValues: { content: '', scope: 'context', diff --git a/frontend/src/components/settings/AgentDialog.tsx b/frontend/src/components/settings/AgentDialog.tsx index 20dc7d82..dcf5160f 100644 --- a/frontend/src/components/settings/AgentDialog.tsx +++ b/frontend/src/components/settings/AgentDialog.tsx @@ -1,5 +1,5 @@ import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useMemo, useEffect } from 'react' import { useQuery } from '@tanstack/react-query' @@ -110,7 +110,7 @@ export function AgentDialog({ open, onOpenChange, onSubmit, editingAgent }: Agen } const form = useForm({ - resolver: zodResolver(agentFormSchema), + resolver: standardSchemaResolver(agentFormSchema), defaultValues: getDefaultValues(editingAgent) }) diff --git a/frontend/src/components/settings/CommandDialog.tsx b/frontend/src/components/settings/CommandDialog.tsx index e1b1e763..c30298d1 100644 --- a/frontend/src/components/settings/CommandDialog.tsx +++ b/frontend/src/components/settings/CommandDialog.tsx @@ -1,5 +1,5 @@ import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; +import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { @@ -63,7 +63,7 @@ export function CommandDialog({ editingCommand, }: CommandDialogProps) { const form = useForm({ - resolver: zodResolver(commandFormSchema), + resolver: standardSchemaResolver(commandFormSchema), defaultValues: { name: editingCommand?.name || "", template: editingCommand?.command.template || "", diff --git a/frontend/src/components/settings/MemoryPluginConfig.tsx b/frontend/src/components/settings/MemoryPluginConfig.tsx index e8f9c16a..9a21fa83 100644 --- a/frontend/src/components/settings/MemoryPluginConfig.tsx +++ b/frontend/src/components/settings/MemoryPluginConfig.tsx @@ -189,7 +189,7 @@ export function MemoryPluginConfig({ memoryPluginEnabled, onToggle }: MemoryPlug }) } - const handleNestedChange = ( + const handleNestedChange = ( section: K, field: string, value: string | number | boolean | undefined, @@ -560,6 +560,40 @@ export function MemoryPluginConfig({ memoryPluginEnabled, onToggle }: MemoryPlug /> + +
+
+ + TUI +
+ +
+ + handleNestedChange('tui', 'sidebar', checked)} + /> +
+ +
+ + handleNestedChange('tui', 'showLoops', checked)} + /> +
+ +
+ + handleNestedChange('tui', 'showVersion', checked)} + /> +
+
{displayConfig.dataDir && ( diff --git a/frontend/src/components/settings/STTSettings.tsx b/frontend/src/components/settings/STTSettings.tsx index 3826dfb2..9a135dc6 100644 --- a/frontend/src/components/settings/STTSettings.tsx +++ b/frontend/src/components/settings/STTSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useSettings } from '@/hooks/useSettings' import { useSTT } from '@/hooks/useSTT' @@ -50,7 +50,7 @@ export function STTSettings() { const isWebSpeechAvailable = isWebRecognitionSupported() const form = useForm({ - resolver: zodResolver(sttFormSchema), + resolver: standardSchemaResolver(sttFormSchema), defaultValues: { ...DEFAULT_STT_CONFIG, model: DEFAULT_STT_CONFIG.model || 'whisper-1', diff --git a/frontend/src/components/settings/SkillDialog.tsx b/frontend/src/components/settings/SkillDialog.tsx index 14115b0d..067ba89b 100644 --- a/frontend/src/components/settings/SkillDialog.tsx +++ b/frontend/src/components/settings/SkillDialog.tsx @@ -1,5 +1,5 @@ import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' @@ -58,7 +58,7 @@ export function SkillDialog({ open, onOpenChange, onSubmit, editingSkill }: Skil } const form = useForm({ - resolver: zodResolver(skillFormSchema), + resolver: standardSchemaResolver(skillFormSchema), defaultValues: getDefaultValues(editingSkill) }) diff --git a/frontend/src/components/settings/TTSSettings.tsx b/frontend/src/components/settings/TTSSettings.tsx index fa8923ca..6f823e3c 100644 --- a/frontend/src/components/settings/TTSSettings.tsx +++ b/frontend/src/components/settings/TTSSettings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'react' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useSettings } from '@/hooks/useSettings' import { useTTS } from '@/hooks/useTTS' @@ -85,7 +85,7 @@ export function TTSSettings() { const lastSavedDataRef = useRef(null) const form = useForm({ - resolver: zodResolver(ttsFormSchema), + resolver: standardSchemaResolver(ttsFormSchema), defaultValues: DEFAULT_TTS_CONFIG, }) diff --git a/frontend/src/hooks/useOpenCode.ts b/frontend/src/hooks/useOpenCode.ts index 3ec7d99f..7714d175 100644 --- a/frontend/src/hooks/useOpenCode.ts +++ b/frontend/src/hooks/useOpenCode.ts @@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useMemo, useRef, useEffect, useCallback, useState } from "react"; import { OpenCodeClient } from "../api/opencode"; import { API_BASE_URL } from "../config"; -import { fetchWrapper } from "../api/fetchWrapper"; +import { fetchWrapper, FetchError } from "../api/fetchWrapper"; import { cancelLoop } from "../api/memory"; import type { Message, @@ -355,10 +355,8 @@ export const useSendPrompt = (opcodeUrl: string | null | undefined, directory?: const { sessionID } = variables; const messagesQueryKey = ["opencode", "messages", opcodeUrl, sessionID, directory]; - const axiosError = error as { code?: string; response?: unknown }; - const isNetworkError = axiosError.code === 'ECONNABORTED' || - axiosError.code === 'ERR_NETWORK' || - !axiosError.response; + const isNetworkError = error instanceof TypeError || + (error instanceof FetchError && error.code === 'TIMEOUT'); if (isNetworkError) { return; diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index c1fb9a05..b1d433ff 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -308,8 +308,8 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, }) } } catch (err) { - if (err instanceof Error && !err.message.includes('aborted')) { - throw err + if (err instanceof Error && !err.message.includes('aborted') && import.meta.env.DEV) { + console.warn('Failed to fetch initial session data:', err) } } }, [client, setSessionStatus]) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 90ad4b60..420ed92e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useLoaderData } from 'react-router-dom' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useAuth } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' @@ -33,7 +33,7 @@ export function Login() { handleSubmit, formState: { errors }, } = useForm({ - resolver: zodResolver(loginSchema), + resolver: standardSchemaResolver(loginSchema), }) const onSubmit = async (data: LoginFormData) => { diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 1d0edea7..bee5bced 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Link, useLoaderData } from 'react-router-dom' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useAuth } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' @@ -36,7 +36,7 @@ export function Register() { handleSubmit, formState: { errors }, } = useForm({ - resolver: zodResolver(registerSchema), + resolver: standardSchemaResolver(registerSchema), }) const onSubmit = async (data: RegisterFormData) => { diff --git a/frontend/src/pages/Setup.tsx b/frontend/src/pages/Setup.tsx index 08e87686..390497cd 100644 --- a/frontend/src/pages/Setup.tsx +++ b/frontend/src/pages/Setup.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema' import { z } from 'zod' import { useAuth } from '@/hooks/useAuth' import { useTheme } from '@/hooks/useTheme' @@ -29,7 +29,7 @@ export function Setup() { handleSubmit, formState: { errors }, } = useForm({ - resolver: zodResolver(setupSchema), + resolver: standardSchemaResolver(setupSchema), }) const onSubmit = async (data: SetupFormData) => { diff --git a/package.json b/package.json index 627cacf2..d69f2bbf 100644 --- a/package.json +++ b/package.json @@ -5,21 +5,18 @@ "type": "module", "packageManager": "pnpm@10.28.1", "scripts": { - "memory": "pnpm --filter @opencode-manager/memory cli", "predev": "bash scripts/setup-dev.sh", "dev": "concurrently \"pnpm:dev:backend\" \"pnpm:dev:frontend\"", "dev:backend": "bun --watch-path backend/src --watch backend/src/index.ts", "dev:frontend": "pnpm --filter frontend dev", - "build": "pnpm run build:memory && pnpm run build:backend && pnpm run build:frontend", - "build:memory": "pnpm --filter @opencode-manager/memory build", + "build": "pnpm run build:backend && pnpm run build:frontend", "build:backend": "pnpm --filter backend build", "build:frontend": "pnpm --filter frontend build", "typecheck": "pnpm run typecheck:frontend && pnpm run typecheck:backend", "typecheck:frontend": "pnpm --filter frontend typecheck", "typecheck:backend": "pnpm --filter backend typecheck", - "test": "pnpm run test:backend && pnpm run test:memory", + "test": "pnpm run test:backend", "test:backend": "pnpm --filter backend test", - "test:memory": "pnpm --filter @opencode-manager/memory test", "lint": "pnpm run lint:frontend && pnpm run lint:backend", "lint:frontend": "pnpm --filter frontend lint", "lint:backend": "pnpm --filter backend lint", diff --git a/packages/memory/config.jsonc b/packages/memory/config.jsonc index d456c9ee..a00a7d33 100644 --- a/packages/memory/config.jsonc +++ b/packages/memory/config.jsonc @@ -28,6 +28,13 @@ }, "executionModel": "", "auditorModel": "", + // Per-agent overrides (temperature range: 0.0 - 2.0) + // "agents": { + // "architect": { "temperature": 0.0 }, + // "librarian": { "temperature": 0.0 }, + // "auditor": { "temperature": 0.0 }, + // "code": {} + // }, "loop": { "enabled": true, "defaultMaxIterations": 15, @@ -36,5 +43,10 @@ "model": "", "minAudits": 1, "stallTimeoutMs": 60000 + }, + "tui": { + "sidebar": true, + "showLoops": true, + "showVersion": true } } diff --git a/packages/memory/package.json b/packages/memory/package.json index b7ce9a77..bdae5c28 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -1,13 +1,25 @@ { "name": "@opencode-manager/memory", - "version": "0.0.27", + "version": "0.0.28", "type": "module", + "oc-plugin": [ + "server", + "tui" + ], "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" + }, + "./server": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./tui": { + "import": "./dist/tui.js", + "types": "./dist/tui.d.ts" } }, "files": [ @@ -37,11 +49,23 @@ "homepage": "https://chriswritescode-dev.github.io/opencode-manager/features/memory/", "dependencies": { "@huggingface/transformers": "^3.8.1", - "@opencode-ai/plugin": "^1.2.16", + "@opencode-ai/plugin": "^1.3.5", "@opencode-ai/sdk": "^1.2.26", "jsonc-parser": "^3.3.1", "sqlite-vec": "0.1.7-alpha.2" }, + "peerDependencies": { + "@opentui/core": ">=0.1.92", + "@opentui/solid": ">=0.1.92" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + }, "optionalDependencies": { "sqlite-vec-darwin-arm64": "0.1.7-alpha.2", "sqlite-vec-darwin-x64": "0.1.7-alpha.2", @@ -50,11 +74,14 @@ "sqlite-vec-windows-x64": "0.1.7-alpha.2" }, "devDependencies": { + "@opentui/core": "0.1.92", + "@opentui/solid": "0.1.92", "bun-types": "latest", + "solid-js": "^1.9.12", "typescript": "^5.7.3" }, "scripts": { - "build": "bun scripts/inject-version.ts && tsc -p tsconfig.build.json", + "build": "bun scripts/build.ts", "postinstall": "node scripts/download-models.js", "prepublishOnly": "pnpm build", "test": "bun test", diff --git a/packages/memory/scripts/build.ts b/packages/memory/scripts/build.ts new file mode 100644 index 00000000..cc468763 --- /dev/null +++ b/packages/memory/scripts/build.ts @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync } from 'fs' +import { join } from 'path' +import { execSync } from 'child_process' +import solidPlugin from '@opentui/solid/bun-plugin' + +const packageJsonPath = join(__dirname, '..', 'package.json') +const versionPath = join(__dirname, '..', 'src', 'version.ts') + +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) +const version = packageJson.version as string + +const versionContent = `export const VERSION = '${version}'\n` +writeFileSync(versionPath, versionContent, 'utf-8') + +console.log(`Version ${version} written to src/version.ts`) + +console.log('Compiling main code...') +execSync('tsc -p tsconfig.build.json', { + cwd: join(__dirname, '..'), + stdio: 'inherit' +}) + +console.log('Compiling TUI plugin...') +const result = await Bun.build({ + entrypoints: [join(__dirname, '..', 'src', 'tui.tsx')], + outdir: join(__dirname, '..', 'dist'), + target: 'node', + plugins: [solidPlugin], + external: ['@opentui/solid', '@opencode-ai/plugin/tui', 'solid-js'], +}) + +if (!result.success) { + for (const log of result.logs) { + console.error(log) + } + process.exit(1) +} + +console.log('Build complete!') diff --git a/packages/memory/scripts/inject-version.ts b/packages/memory/scripts/inject-version.ts deleted file mode 100644 index fbec9b0e..00000000 --- a/packages/memory/scripts/inject-version.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { readFileSync, writeFileSync } from 'fs' -import { join } from 'path' - -const packageJsonPath = join(__dirname, '..', 'package.json') -const versionPath = join(__dirname, '..', 'src', 'version.ts') - -const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) -const version = packageJson.version as string - -const versionContent = `export const VERSION = '${version}'\n` - -writeFileSync(versionPath, versionContent, 'utf-8') - -console.log(`Version ${version} written to src/version.ts`) diff --git a/packages/memory/src/agents/architect.ts b/packages/memory/src/agents/architect.ts index bfe0eb37..24aec02b 100644 --- a/packages/memory/src/agents/architect.ts +++ b/packages/memory/src/agents/architect.ts @@ -8,7 +8,6 @@ export const architectAgent: AgentDefinition = { description: 'Memory-aware planning agent that researches, designs, and persists implementation plans', mode: 'primary', color: '#ef4444', - temperature: 0.0, permission: { question: 'allow', edit: { @@ -100,6 +99,11 @@ KV entries are scoped to the current project and expire after 7 days. Use this f Present plans with: - **Objective**: What we're building and why - **Phases**: Ordered implementation steps, each with specific files to create/modify, what changes to make, and acceptance criteria +- **Verification**: Concrete, runnable commands that prove the plan is complete. Every plan MUST include at least one verification step. Examples: + - Test commands: \`pnpm test\`, \`vitest run src/path/to/test.ts\` + - Type checking: \`pnpm tsc --noEmit\`, \`pnpm lint\` + - Runtime checks: curl commands, specific assertions about output + Plans without verification steps are incomplete. If no existing tests cover the changes, the plan MUST include a phase to write tests. - **Decisions**: Architectural choices made during planning with rationale - **Conventions**: Existing project conventions that must be followed - **Key Context**: Relevant code patterns, file locations, integration points, and dependencies discovered during research diff --git a/packages/memory/src/agents/auditor.ts b/packages/memory/src/agents/auditor.ts index 925e4f6e..4fe91e6b 100644 --- a/packages/memory/src/agents/auditor.ts +++ b/packages/memory/src/agents/auditor.ts @@ -57,6 +57,10 @@ Diffs alone are not enough. After getting the diff: **Behavior Changes** — If a behavioral change is introduced, raise it (especially if possibly unintentional). +**Plan Compliance** — When reviewing loop iterations, check whether the implementation satisfies the plan's stated acceptance criteria and verification steps. +- If the task context includes verification commands (test, lint, type check), check whether they were run and passed +- If acceptance criteria from the plan are not met, report as a **warning** with the specific unmet criterion + ## Before You Flag Something Be certain. If you're going to call something a bug, you need to be confident it actually is one. @@ -128,10 +132,10 @@ If a memory seems outdated, flag it for the calling agent. After completing a review, store each **bug** and **warning** finding in the project KV store so it can be retrieved in subsequent reviews. Do NOT store suggestions — only actionable issues. -Use \`memory-kv-set\` with a structured key and JSON value: +Use the memory-kv-set tool with a structured key and JSON value: **Key pattern**: \`review-finding::\` -**Value**: JSON object with the finding details. Include the current branch name (via \`git branch --show-current\`) in the \`branch\` field. +**Value**: JSON object with the finding details. The \`branch\` field is auto-set by the tool — you do not need to include it. Example: \`\`\`json @@ -142,24 +146,23 @@ Example: "description": "Missing null check on user.session before accessing .token — throws TypeError when session expires mid-request.", "scenario": "User's session expires between the auth check and token access on line 45.", "status": "open", - "date": "2026-03-07", - "branch": "feature/auth-refactor" + "date": "2026-03-07" } \`\`\` The KV store upserts by key, so storing a finding for the same file:line automatically updates the previous entry. No dedup checks needed. -When a previously open finding has been addressed by the current changes, **delete it** using \`memory-kv-delete\` with the same key. Do not re-store resolved findings — removing them keeps the KV store clean and avoids extending the TTL on stale data. +When a previously open finding has been addressed by the current changes, **delete it** using the memory-kv-delete tool with the same key. Do not re-store resolved findings — removing them keeps the KV store clean and avoids extending the TTL on stale data. Findings expire after 7 days automatically. If an issue persists, the next review will re-discover it. ## Retrieving Past Findings At the start of every review, before analyzing the diff: -1. Call \`memory-kv-list\` to get all active KV entries for the project +1. Use the memory-kv-list tool to get all active KV entries for the project 2. Filter entries with keys starting with \`review-finding:\` that match files in the current diff 3. If open findings exist for files being changed, include them under a "### Previously Identified Issues" heading before new findings -4. Check if any previously open findings have been addressed by the current changes — if so, delete them via \`memory-kv-delete\` with the same key +4. Check if any previously open findings have been addressed by the current changes — if so, delete them via the memory-kv-delete tool with the same key ${getInjectedMemory('auditor')} `, diff --git a/packages/memory/src/agents/librarian.ts b/packages/memory/src/agents/librarian.ts index c7636c92..86c4bb2b 100644 --- a/packages/memory/src/agents/librarian.ts +++ b/packages/memory/src/agents/librarian.ts @@ -7,9 +7,8 @@ export const librarianAgent: AgentDefinition = { displayName: 'librarian', description: 'Expert agent for managing project memory - storing and retrieving conventions, decisions, context, and session progress', mode: 'subagent', - temperature: 0.0, tools: { - exclude: ['memory-plan-execute', 'memory-loop', 'memory-health', 'memory-kv-set', 'memory-kv-get', 'memory-kv-list'], + exclude: ['memory-plan-execute', 'memory-loop', 'memory-health', 'memory-kv-set', 'memory-kv-get', 'memory-kv-list', 'memory-kv-delete'], }, systemPrompt: `You are a memory management agent. Your purpose is to capture, organize, and retrieve knowledge that persists across sessions. diff --git a/packages/memory/src/cli/commands/status.ts b/packages/memory/src/cli/commands/status.ts index 5ec63e1b..49f6707a 100644 --- a/packages/memory/src/cli/commands/status.ts +++ b/packages/memory/src/cli/commands/status.ts @@ -29,26 +29,46 @@ export interface StatusArgs { export async function run(argv: StatusArgs): Promise { const db = openDatabase(argv.dbPath) + function createStatusClient(serverUrl: string, directory: string) { + const url = new URL(serverUrl) + const password = url.password || process.env['OPENCODE_SERVER_PASSWORD'] + const cleanUrl = new URL(url.toString()) + cleanUrl.username = '' + cleanUrl.password = '' + const clientConfig: Parameters[0] = { baseUrl: cleanUrl.toString(), directory } + if (password) { + clientConfig.headers = { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`, + } + } + return createOpencodeClient(clientConfig) + } + async function tryFetchSessionOutput(serverUrl: string, sessionId: string, directory: string) { try { - const url = new URL(serverUrl) - const password = url.password || process.env['OPENCODE_SERVER_PASSWORD'] - const cleanUrl = new URL(url.toString()) - cleanUrl.username = '' - cleanUrl.password = '' - const clientConfig: Parameters[0] = { baseUrl: cleanUrl.toString(), directory } - if (password) { - clientConfig.headers = { - Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}`, - } - } - const client = createOpencodeClient(clientConfig) + const client = createStatusClient(serverUrl, directory) return await fetchSessionOutput(client, sessionId, directory) } catch { return null } } + async function tryFetchSessionStatus(serverUrl: string, sessionId: string, directory: string): Promise { + try { + const client = createStatusClient(serverUrl, directory) + const statusResult = await client.session.status({ directory }) + const statuses = (statusResult.data ?? {}) as Record + const status = statuses[sessionId] + if (!status) return 'unknown' + if (status.type === 'retry') { + return `retry (attempt ${status.attempt}, next in ${Math.round(((status.next ?? 0) - Date.now()) / 1000)}s)` + } + return status.type + } catch { + return 'unavailable' + } + } + try { if (argv.listWorktrees) { const rows = db.prepare('SELECT key, data FROM project_kv WHERE key LIKE ? AND expires_at > ?').all('loop:%', Date.now()) as Array<{ key: string; data: string }> @@ -201,6 +221,8 @@ export async function run(argv: StatusArgs): Promise { console.log(` Error Count: ${state.errorCount ?? 0}`) console.log(` Audit Count: ${state.auditCount ?? 0}`) console.log(` Started: ${new Date(startedAt).toISOString()}`) + const sessionStatus = await tryFetchSessionStatus(argv.server ?? 'http://localhost:5551', state.sessionId, state.worktreeDir!) + console.log(` Status: ${sessionStatus}`) if (state.completionPromise) { console.log(` Completion: ${state.completionPromise}`) } diff --git a/packages/memory/src/config.ts b/packages/memory/src/config.ts index e4620470..f222ba92 100644 --- a/packages/memory/src/config.ts +++ b/packages/memory/src/config.ts @@ -1,4 +1,5 @@ import type { AgentRole, AgentDefinition, AgentConfig } from './agents' +import type { PluginConfig } from './types' const REPLACED_BUILTIN_AGENTS = ['build', 'plan'] @@ -21,19 +22,90 @@ const PLUGIN_COMMANDS: Record) { +export function createConfigHandler( + agents: Record, + agentOverrides?: Record +) { return async (config: Record) => { - const agentConfigs = createAgentConfigs(agents) + const effectiveAgents = { ...agents } + if (agentOverrides) { + for (const [name, overrides] of Object.entries(agentOverrides)) { + const role = Object.keys(effectiveAgents).find( + (r) => effectiveAgents[r as AgentRole].displayName === name + ) as AgentRole | undefined + if (role) { + effectiveAgents[role] = { ...effectiveAgents[role], ...overrides } + } + } + } + const agentConfigs = createAgentConfigs(effectiveAgents) const userAgentConfigs = config.agent as Record | undefined const mergedAgents = { ...agentConfigs } diff --git a/packages/memory/src/hooks/loop.ts b/packages/memory/src/hooks/loop.ts index 52d4887a..d06217cf 100644 --- a/packages/memory/src/hooks/loop.ts +++ b/packages/memory/src/hooks/loop.ts @@ -28,6 +28,18 @@ export function createLoopEventHandler( const lastActivityTime = new Map() const stallWatchdogs = new Map() const consecutiveStalls = new Map() + const stateLocks = new Map>() + + function withStateLock(worktreeName: string, fn: () => Promise): Promise { + const prev = stateLocks.get(worktreeName) ?? Promise.resolve() + const next = prev.then(fn, fn).finally(() => { + if (stateLocks.get(worktreeName) === next) { + stateLocks.delete(worktreeName) + } + }) + stateLocks.set(worktreeName, next) + return next + } async function commitAndCleanupWorktree(state: LoopState): Promise<{ committed: boolean; cleaned: boolean }> { if (!state.worktree) { @@ -115,7 +127,7 @@ export function createLoopEventHandler( const sessionId = state.sessionId try { - const statusResult = await v2Client.session.status() + const statusResult = await v2Client.session.status({ directory: state.worktreeDir }) const statuses = (statusResult.data ?? {}) as Record const status = statuses[sessionId]?.type @@ -194,6 +206,35 @@ export function createLoopEventHandler( logger.log(`Loop terminated: reason="${reason}", worktree="${state.worktreeName}", iteration=${state.iteration}`) + if (v2Client.tui) { + const toastVariant = reason === 'completed' ? 'success' + : reason === 'cancelled' || reason === 'user_aborted' ? 'info' + : reason === 'max_iterations' ? 'warning' + : 'error' + + const toastMessage = reason === 'completed' ? `Completed after ${state.iteration} iteration${state.iteration !== 1 ? 's' : ''}` + : reason === 'cancelled' ? 'Loop cancelled' + : reason === 'max_iterations' ? `Reached max iterations (${state.maxIterations})` + : reason === 'stall_timeout' ? `Stalled after ${state.iteration} iteration${state.iteration !== 1 ? 's' : ''}` + : reason === 'user_aborted' ? 'Loop aborted by user' + : `Loop ended: ${reason}` + + v2Client.tui.publish({ + directory: state.worktreeDir, + body: { + type: 'tui.toast.show', + properties: { + title: state.worktreeName, + message: toastMessage, + variant: toastVariant, + duration: reason === 'completed' ? 5000 : 3000, + }, + }, + }).catch((err) => { + logger.error('Loop: failed to publish toast notification', err) + }) + } + let commitResult: { committed: boolean; cleaned: boolean } | undefined if (reason === 'completed' || reason === 'cancelled') { commitResult = await commitAndCleanupWorktree(state) @@ -268,10 +309,13 @@ export function createLoopEventHandler( async function rotateSession(worktreeName: string, state: LoopState): Promise { const oldSessionId = state.sessionId - const createResult = await v2Client.session.create({ + + const createParams = { title: state.worktreeName, directory: state.worktreeDir, - }) + } + + const createResult = await v2Client.session.create(createParams) if (createResult.error || !createResult.data) { throw new Error(`Failed to create new session: ${createResult.error}`) @@ -296,6 +340,13 @@ export function createLoopEventHandler( }) logger.log(`Loop: rotated session ${oldSessionId} → ${newSessionId}`) + + if (!state.worktree && v2Client.tui) { + v2Client.tui.selectSession({ sessionID: newSessionId }).catch((err) => { + logger.error(`Loop: failed to navigate TUI to rotated session`, err) + }) + } + return newSessionId } @@ -333,16 +384,21 @@ export function createLoopEventHandler( if (textContent && currentState.completionPromise && loopService.checkCompletionPromise(textContent, currentState.completionPromise)) { const currentAuditCount = currentState.auditCount ?? 0 if (!currentState.audit || currentAuditCount >= minAudits) { - await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) - return + if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { + logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) + } else { + await terminateLoop(worktreeName, currentState, 'completed') + logger.log(`Loop completed: detected ${currentState.completionPromise} at iteration ${currentState.iteration} (${currentAuditCount}/${minAudits} audits)`) + return + } + } else { + logger.log(`Loop: completion promise detected but only ${currentAuditCount}/${minAudits} audits performed, continuing`) } - logger.log(`Loop: completion promise detected but only ${currentAuditCount}/${minAudits} audits performed, continuing`) } } if (!assistantErrorDetected && currentState.errorCount && currentState.errorCount > 0) { - loopService.setState(worktreeName, { ...currentState, errorCount: 0 }) + loopService.setState(worktreeName, { ...currentState, errorCount: 0, modelFailed: false }) logger.log(`Loop: resetting error count after successful retry in coding phase`) currentState = loopService.getActiveState(worktreeName)! } @@ -401,6 +457,7 @@ export function createLoopEventHandler( sessionId: activeSessionId, iteration: nextIteration, errorCount: assistantErrorDetected ? currentState.errorCount : 0, + modelFailed: assistantErrorDetected ? currentState.modelFailed : false, }) const continuationPrompt = loopService.buildContinuationPrompt({ ...currentState, iteration: nextIteration }) @@ -505,7 +562,7 @@ export function createLoopEventHandler( } if (!assistantErrorDetected && currentState.errorCount && currentState.errorCount > 0) { - loopService.setState(worktreeName, { ...currentState, errorCount: 0 }) + loopService.setState(worktreeName, { ...currentState, errorCount: 0, modelFailed: false }) logger.log(`Loop: resetting error count after successful retry in auditing phase`) currentState = loopService.getActiveState(worktreeName)! } @@ -519,13 +576,17 @@ export function createLoopEventHandler( if (currentState.completionPromise && auditText) { if (loopService.checkCompletionPromise(auditText, currentState.completionPromise)) { - // Check if minimum audits have been performed if (!currentState.audit || newAuditCount >= minAudits) { - await terminateLoop(worktreeName, currentState, 'completed') - logger.log(`Loop completed: detected ${currentState.completionPromise} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) - return + if (loopService.hasOutstandingFindings(currentState.worktreeBranch)) { + logger.log(`Loop: completion promise detected but outstanding review findings remain, continuing`) + } else { + await terminateLoop(worktreeName, currentState, 'completed') + logger.log(`Loop completed: detected ${currentState.completionPromise} in audit at iteration ${currentState.iteration} (${newAuditCount}/${minAudits} audits)`) + return + } + } else { + logger.log(`Loop: completion promise detected but only ${newAuditCount}/${minAudits} audits performed, continuing`) } - logger.log(`Loop: completion promise detected but only ${newAuditCount}/${minAudits} audits performed, continuing`) } } @@ -549,6 +610,7 @@ export function createLoopEventHandler( lastAuditResult: auditFindings, auditCount: newAuditCount, errorCount: assistantErrorDetected ? currentState.errorCount : 0, + modelFailed: assistantErrorDetected ? currentState.modelFailed : false, }) const continuationPrompt = loopService.buildContinuationPrompt( @@ -686,28 +748,30 @@ export function createLoopEventHandler( const worktreeName = loopService.resolveWorktreeName(sessionId) if (!worktreeName) return - const state = loopService.getActiveState(worktreeName) - if (!state || !state.active) return + await withStateLock(worktreeName, async () => { + const state = loopService.getActiveState(worktreeName) + if (!state || !state.active) return - try { - // Re-check state right before calling phase handler as extra safety - const freshState = loopService.getActiveState(worktreeName) - if (!freshState?.active) { - logger.log(`Loop: loop ${worktreeName} was terminated, skipping phase handler`) - return - } - - startWatchdog(worktreeName) - - if (freshState.phase === 'auditing') { - await handleAuditingPhase(worktreeName, freshState) - } else { - await handleCodingPhase(worktreeName, freshState) + try { + // Re-check state right before calling phase handler as extra safety + const freshState = loopService.getActiveState(worktreeName) + if (!freshState?.active) { + logger.log(`Loop: loop ${worktreeName} was terminated, skipping phase handler`) + return + } + + startWatchdog(worktreeName) + + if (freshState.phase === 'auditing') { + await handleAuditingPhase(worktreeName, freshState) + } else { + await handleCodingPhase(worktreeName, freshState) + } + } catch (err) { + const freshState = loopService.getActiveState(worktreeName) + await handlePromptError(worktreeName, freshState ?? state, `unhandled error in ${(freshState ?? state).phase} phase`, err) } - } catch (err) { - const freshState = loopService.getActiveState(worktreeName) - await handlePromptError(worktreeName, freshState ?? state, `unhandled error in ${(freshState ?? state).phase} phase`, err) - } + }) } function terminateAll(): void { @@ -725,6 +789,7 @@ export function createLoopEventHandler( } lastActivityTime.clear() consecutiveStalls.clear() + stateLocks.clear() logger.log('Loop: cleared all retry timeouts') } diff --git a/packages/memory/src/hooks/memory-injection.ts b/packages/memory/src/hooks/memory-injection.ts index 7a1749d5..bcb7e20e 100644 --- a/packages/memory/src/hooks/memory-injection.ts +++ b/packages/memory/src/hooks/memory-injection.ts @@ -19,6 +19,7 @@ interface MemoryInjectionDeps { export interface MemoryInjectionHook { handler: (userText: string) => Promise + clearCache: () => Promise destroy: () => void } @@ -146,6 +147,7 @@ export function createMemoryInjectionHook(deps: MemoryInjectionDeps): MemoryInje return { handler, + clearCache: () => cache.invalidatePattern('memory-injection:'), destroy: () => cache.destroy(), } } diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 98b3450d..9944e158 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -3,261 +3,22 @@ import { tool } from '@opencode-ai/plugin' import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import { agents } from './agents' import { createConfigHandler } from './config' -import { VERSION } from './version' import { createSessionHooks, createMemoryInjectionHook, createLoopEventHandler } from './hooks' -import { join, resolve } from 'path' -import { initializeDatabase, resolveDataDir, closeDatabase, createMetadataQuery } from './storage' -import type { MemoryService } from './services/memory' -import { createVecService } from './storage/vec' -import { createEmbeddingProvider, checkServerHealth, isServerRunning, killEmbeddingServer } from './embedding' +import { initializeDatabase, resolveDataDir, closeDatabase } from './storage' +import { createVecService, createNoopVecService } from './storage/vec' +import { createEmbeddingProvider, killEmbeddingServer } from './embedding' import { createMemoryService } from './services/memory' import { createEmbeddingSyncService } from './services/embedding-sync' import { createKvService } from './services/kv' -import { createLoopService, type LoopState, fetchSessionOutput, type LoopSessionOutput, migrateRalphKeys } from './services/loop' -import { findPartialMatch } from './utils/partial-match' +import { createLoopService, migrateRalphKeys } from './services/loop' import { loadPluginConfig } from './setup' import { resolveLogPath } from './storage' -import { createLogger, slugify } from './utils/logger' -import { stripPromiseTags } from './utils/strip-promise-tags' -import { truncate } from './cli/utils' -import { formatSessionOutput, formatAuditResult } from './utils/loop-format' -import type { Database } from 'bun:sqlite' -import type { PluginConfig, CompactionConfig, HealthStatus, Logger } from './types' -import type { EmbeddingProvider } from './embedding' +import { createLogger } from './utils/logger' +import type { PluginConfig, CompactionConfig } from './types' +import { createTools, createToolExecuteBeforeHook, createToolExecuteAfterHook, autoValidateOnLoad, scopeEnum } from './tools' +import type { DimensionMismatchState, InitState, ToolContext } from './tools' import type { VecService } from './storage/vec-types' -import { createNoopVecService } from './storage/vec' -import { checkForUpdate, formatUpgradeCheck, performUpgrade } from './utils/upgrade' -import { MAX_RETRIES } from './services/loop' -import { parseModelString, retryWithModelFallback } from './utils/model-fallback' -import { execSync, spawnSync } from 'child_process' -import { existsSync } from 'fs' -const z = tool.schema - -const DEFAULT_PLAN_COMPLETION_PROMISE = 'All phases of the plan have been completed successfully' - -async function getHealthStatus( - projectId: string, - db: Database, - config: PluginConfig, - provider: EmbeddingProvider, - dataDir: string, -): Promise { - const socketPath = join(dataDir, 'embedding.sock') - - let dbStatus: 'ok' | 'error' = 'ok' - let memoryCount = 0 - try { - db.prepare('SELECT 1').get() - const row = db.prepare("SELECT COUNT(*) as count FROM memories WHERE project_id = ?").get(projectId) as { count: number } - memoryCount = row.count - } catch { - dbStatus = 'error' - } - - let operational = false - try { - operational = await provider.test() - } catch { - operational = false - } - - let serverRunning = false - let serverHealth: { status: string; clients: number; uptime: number } | null = null - try { - serverRunning = await isServerRunning(dataDir) - if (serverRunning) { - serverHealth = await checkServerHealth(socketPath) - } - } catch { - serverRunning = false - } - - const configuredModel = { - model: config.embedding.model, - dimensions: config.embedding.dimensions ?? provider.dimensions, - } - - let currentModel: { model: string; dimensions: number } | null = null - try { - const metadata = createMetadataQuery(db) - const stored = metadata.getEmbeddingModel() - if (stored) { - currentModel = { model: stored.model, dimensions: stored.dimensions } - } - } catch { - // Ignore - } - - const needsReindex = !currentModel || - currentModel.model !== configuredModel.model || - currentModel.dimensions !== configuredModel.dimensions - - const overallStatus: 'ok' | 'degraded' | 'error' = dbStatus === 'error' - ? 'error' - : !operational - ? 'degraded' - : 'ok' - - return { - dbStatus, - memoryCount, - operational, - serverRunning, - serverHealth, - configuredModel, - currentModel, - needsReindex, - overallStatus, - } -} - -function formatHealthStatus(status: HealthStatus, provider: EmbeddingProvider): string { - const { dbStatus, memoryCount, operational, serverRunning, serverHealth, configuredModel, currentModel, needsReindex, overallStatus } = status - - const embeddingStatus: 'ok' | 'error' = operational ? 'ok' : 'error' - - const lines: string[] = [ - `Memory Plugin v${VERSION}`, - `Status: ${overallStatus.toUpperCase()}`, - '', - `Embedding: ${embeddingStatus}`, - ` Provider: ${provider.name} (${provider.dimensions}d)`, - ` Operational: ${operational}`, - ` Server running: ${serverRunning}`, - ] - - if (serverHealth) { - lines.push(` Clients: ${serverHealth.clients}, Uptime: ${Math.round(serverHealth.uptime / 1000)}s`) - } - - lines.push('') - lines.push(`Database: ${dbStatus}`) - lines.push(` Total memories: ${memoryCount}`) - lines.push('') - lines.push(`Model: ${needsReindex ? 'drift' : 'ok'}`) - lines.push(` Configured: ${configuredModel.model} (${configuredModel.dimensions}d)`) - if (currentModel) { - lines.push(` Indexed: ${currentModel.model} (${currentModel.dimensions}d)`) - } else { - lines.push(' Indexed: none') - } - if (needsReindex) { - lines.push(' Reindex required - run memory-health with action "reindex"') - } else { - lines.push(' In sync') - } - - return lines.join('\n') -} - -async function executeHealthCheck( - projectId: string, - db: Database, - config: PluginConfig, - provider: EmbeddingProvider, - dataDir: string, -): Promise { - const status = await getHealthStatus(projectId, db, config, provider, dataDir) - return formatHealthStatus(status, provider) -} - -interface DimensionMismatchState { - detected: boolean - expected: number | null - actual: number | null -} - -async function executeReindex( - projectId: string, - memoryService: MemoryService, - db: Database, - config: PluginConfig, - provider: EmbeddingProvider, - mismatchState: DimensionMismatchState, - vec: VecService, -): Promise { - const configuredModel = config.embedding.model - const configuredDimensions = config.embedding.dimensions ?? provider.dimensions - - let operational = false - try { - operational = await provider.test() - } catch { - operational = false - } - - if (!operational) { - return 'Reindex failed: embedding provider is not operational. Check your API key and model configuration.' - } - - const tableInfo = await vec.getDimensions() - if (tableInfo.exists && tableInfo.dimensions !== null && tableInfo.dimensions !== configuredDimensions) { - await vec.recreateTable(configuredDimensions) - } - - const result = await memoryService.reindex(projectId) - - if (result.success > 0 || result.total === 0) { - const metadata = createMetadataQuery(db) - metadata.setEmbeddingModel(configuredModel, configuredDimensions) - } - - if (result.failed === 0) { - mismatchState.detected = false - mismatchState.expected = null - mismatchState.actual = null - } - - const lines: string[] = [ - 'Reindex complete', - '', - `Total memories: ${result.total}`, - `Embedded: ${result.success}`, - `Failed: ${result.failed}`, - '', - `Model: ${configuredModel} (${configuredDimensions}d)`, - ] - - if (result.failed > 0) { - lines.push(`WARNING: ${result.failed} memories failed to embed`) - } - - return lines.join('\n') -} - -async function autoValidateOnLoad( - projectId: string, - memoryService: MemoryService, - db: Database, - config: PluginConfig, - provider: EmbeddingProvider, - dataDir: string, - mismatchState: DimensionMismatchState, - vec: VecService, - logger: Logger, -): Promise { - const status = await getHealthStatus(projectId, db, config, provider, dataDir) - - if (status.overallStatus === 'error') { - logger.log('Auto-validate: unhealthy (db error), skipping') - return - } - - if (!status.needsReindex) { - logger.log('Auto-validate: healthy, no action needed') - return - } - - if (!status.operational) { - logger.log('Auto-validate: reindex needed but provider not operational, skipping') - return - } - - logger.log('Auto-validate: model drift detected, starting reindex') - await executeReindex(projectId, memoryService, db, config, provider, mismatchState, vec) - logger.log('Auto-validate: reindex complete') -} export function createMemoryPlugin(config: PluginConfig): Plugin { return async (input: PluginInput): Promise => { @@ -312,7 +73,11 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const kvService = createKvService(db, logger, config.defaultKvTtlMs) const loopService = createLoopService(kvService, projectId, logger, config.loop) - await migrateRalphKeys(kvService, projectId, logger) + migrateRalphKeys(kvService, projectId, logger).catch(() => {}) + const reconciledCount = loopService.reconcileStale() + if (reconciledCount > 0) { + logger.log(`Reconciled ${reconciledCount} stale loop(s) from previous session`) + } const loopHandler = createLoopEventHandler(loopService, client, v2, logger, () => config) const mismatchState: DimensionMismatchState = { @@ -381,27 +146,17 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { }) const injectedMessageIds = new Set() - const scopeEnum = z.enum(['convention', 'decision', 'context']) - - function withDimensionWarning(result: string): string { - if (!mismatchState.detected) return result - return `${result}\n\n---\nWarning: Embedding dimension mismatch detected (config: ${mismatchState.expected}d, database: ${mismatchState.actual}d). Semantic search is disabled.\n- If you changed your embedding model intentionally, run memory-health with action "reindex" to rebuild embeddings.\n- If this was accidental, revert your embedding config to match the existing model.` - } - let cleaned = false const cleanup = async () => { if (cleaned) return cleaned = true logger.log('Cleaning up plugin resources...') - // First, stop all active memory loops loopHandler.terminateAll() logger.log('Memory loop: all active loops terminated') - // Clear all retry timeouts to prevent callbacks after cleanup loopHandler.clearAllRetryTimeouts() - // Then proceed with remaining cleanup memoryInjection.destroy() await memoryService.destroy() closeDatabase(db) @@ -414,958 +169,39 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const getCleanup = cleanup - interface LoopSetupOptions { - prompt: string - sessionTitle: string - worktreeName?: string - completionPromise: string | null - maxIterations: number - audit: boolean - agent?: string - model?: { providerID: string; modelID: string } - worktree?: boolean - onLoopStarted?: (worktreeName: string) => void - } - - async function setupLoop(options: LoopSetupOptions): Promise { - const autoWorktreeName = options.worktreeName ?? `loop-${slugify(options.sessionTitle.replace(/^Loop:\s*/i, ''))}` - const projectDir = directory - const maxIter = options.maxIterations ?? config.loop?.defaultMaxIterations ?? 0 - - interface LoopContext { - sessionId: string - directory: string - branch?: string - workspaceId?: string - worktree: boolean - } - - let loopContext: LoopContext - - if (!options.worktree) { - let currentBranch: string | undefined - try { - currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectDir, encoding: 'utf-8' }).trim() - } catch (err) { - logger.log(`loop: no git branch detected, running without branch info`) - } - - const createResult = await v2.session.create({ - title: options.sessionTitle, - directory: projectDir, - }) - - if (createResult.error || !createResult.data) { - logger.error(`loop: failed to create session`, createResult.error) - return 'Failed to create loop session.' - } - - loopContext = { - sessionId: createResult.data.id, - directory: projectDir, - branch: currentBranch, - worktree: false, - } - } else { - const worktreeResult = await v2.worktree.create({ - worktreeCreateInput: { name: autoWorktreeName }, - }) - - if (worktreeResult.error || !worktreeResult.data) { - logger.error(`loop: failed to create worktree`, worktreeResult.error) - return 'Failed to create worktree.' - } - - const worktreeInfo = worktreeResult.data - logger.log(`loop: worktree created at ${worktreeInfo.directory} (branch: ${worktreeInfo.branch})`) - - const createResult = await v2.session.create({ - title: options.sessionTitle, - directory: worktreeInfo.directory, - }) - - if (createResult.error || !createResult.data) { - logger.error(`loop: failed to create session`, createResult.error) - try { - await v2.worktree.remove({ worktreeRemoveInput: { directory: worktreeInfo.directory } }) - } catch (cleanupErr) { - logger.error(`loop: failed to cleanup worktree`, cleanupErr) - } - return 'Failed to create loop session.' - } - - loopContext = { - sessionId: createResult.data.id, - directory: worktreeInfo.directory, - branch: worktreeInfo.branch, - workspaceId: `wrk-${autoWorktreeName}`, - worktree: true, - } - } - - const state: LoopState = { - active: true, - sessionId: loopContext.sessionId, - worktreeName: autoWorktreeName, - worktreeDir: loopContext.directory, - worktreeBranch: loopContext.branch, - workspaceId: loopContext.workspaceId ?? '', - iteration: 1, - maxIterations: maxIter, - completionPromise: options.completionPromise, - startedAt: new Date().toISOString(), - prompt: options.prompt, - phase: 'coding', - audit: options.audit, - errorCount: 0, - auditCount: 0, - worktree: options.worktree, - } - - loopService.setState(autoWorktreeName, state) - loopService.registerSession(loopContext.sessionId, autoWorktreeName) - logger.log(`loop: state stored for worktree=${autoWorktreeName}`) - - let promptText = options.prompt - if (options.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: ${options.completionPromise}\n\nDo NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` - } - - const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( - () => v2.session.promptAsync({ - sessionID: loopContext.sessionId, - directory: loopContext.directory, - parts: [{ type: 'text' as const, text: promptText }], - ...(options.agent && { agent: options.agent }), - model: options.model!, - }), - () => v2.session.promptAsync({ - sessionID: loopContext.sessionId, - directory: loopContext.directory, - parts: [{ type: 'text' as const, text: promptText }], - ...(options.agent && { agent: options.agent }), - }), - options.model, - logger, - ) - - if (promptResult.error) { - logger.error(`loop: failed to send prompt`, promptResult.error) - loopService.deleteState(autoWorktreeName) - if (options.worktree && loopContext.workspaceId) { - try { - await v2.worktree.remove({ worktreeRemoveInput: { directory: loopContext.directory } }) - } catch (cleanupErr) { - logger.error(`loop: failed to cleanup worktree`, cleanupErr) - } - } - return !options.worktree - ? 'Loop session created but failed to send prompt.' - : 'Loop session created but failed to send prompt. Cleaned up.' - } - - options.onLoopStarted?.(autoWorktreeName) - - if (!options.worktree) { - v2.tui.selectSession({ sessionID: loopContext.sessionId }).catch((err) => { - logger.error('loop: failed to navigate TUI to new session', err) - }) - } - - const maxInfo = maxIter > 0 ? maxIter.toString() : 'unlimited' - const auditInfo = options.audit ? 'enabled' : 'disabled' - const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' - - const lines: string[] = [ - !options.worktree ? 'Memory loop activated! (in-place mode)' : 'Memory loop activated!', - '', - `Session: ${loopContext.sessionId}`, - `Title: ${options.sessionTitle}`, - ] - - if (!options.worktree) { - lines.push(`Directory: ${loopContext.directory}`) - if (loopContext.branch) { - lines.push(`Branch: ${loopContext.branch} (in-place)`) - } - } else { - lines.push(`Workspace: ${loopContext.workspaceId}`) - lines.push(`Worktree name: ${autoWorktreeName}`) - lines.push(`Worktree: ${loopContext.directory}`) - lines.push(`Branch: ${loopContext.branch}`) - } - - lines.push( - `Model: ${modelInfo}`, - `Max iterations: ${maxInfo}`, - `Completion promise: ${options.completionPromise ?? 'none'}`, - `Audit: ${auditInfo}`, - '', - 'The loop will automatically continue when the session goes idle.', - 'Your job is done — just confirm to the user that the loop has been launched.', - 'The user can run memory-loop-status or memory-loop-cancel later if needed.', - ) - - return lines.join('\n') - } - - const LOOP_BLOCKED_TOOLS: Record = { - question: 'The question tool is not available during a memory loop. Do not ask questions — continue working on the task autonomously.', - 'memory-plan-execute': 'The memory-plan-execute tool is not available during a memory loop. Focus on executing the current plan.', - 'memory-loop': 'The memory-loop tool is not available during a memory loop. Focus on executing the current plan.', + const ctx: ToolContext = { + projectId, + directory, + config, + logger, + db, + provider, + dataDir, + memoryService, + kvService, + loopService, + loopHandler, + memoryInjection, + v2, + mismatchState, + initState, + getCurrentVec: () => currentVec, + cleanup, + input, } - const PLAN_APPROVAL_LABELS = ['New session', 'Execute here', 'Loop (worktree)', 'Loop'] - - const PLAN_APPROVAL_DIRECTIVES: Record = { - 'New session': ` -The user selected "New session". You MUST now call memory-plan-execute in this response with: -- plan: The FULL self-contained implementation plan (the code agent starts with zero context) -- title: A short descriptive title for the session -- worktree: true (or omit) -Do NOT output text without also making this tool call. -`, - 'Execute here': ` -The user selected "Execute here". You MUST now call memory-plan-execute in this response with: -- plan: "Execute the implementation plan from this conversation. Review all phases above and implement each one." -- title: A short descriptive title for the session -- worktree: false -Do NOT output text without also making this tool call. -`, - 'Loop (worktree)': ` -The user selected "Loop (worktree)". You MUST now call memory-loop in this response with: -- plan: The FULL self-contained implementation plan (runs in an isolated worktree with no prior context) -- title: A short descriptive title for the session -- worktree: true -Do NOT output text without also making this tool call. -`, - 'Loop': ` -The user selected "Loop". You MUST now call memory-loop in this response with: -- plan: The FULL self-contained implementation plan (runs in the current directory with no prior context) -- title: A short descriptive title for the session -- worktree: false -Do NOT output text without also making this tool call. -`, - } + const tools = createTools(ctx) + const toolExecuteBeforeHook = createToolExecuteBeforeHook(ctx) + const toolExecuteAfterHook = createToolExecuteAfterHook(ctx) return { getCleanup, - tool: { - 'memory-read': tool({ - description: 'Search and retrieve project memories', - args: { - query: z.string().optional().describe('Semantic search query'), - scope: scopeEnum.optional().describe('Filter by scope'), - limit: z.number().optional().default(10).describe('Max results'), - }, - execute: async (args) => { - logger.log(`memory-read: query="${args.query ?? 'none'}", scope=${args.scope}, limit=${args.limit}`) - - let results - if (args.query) { - const searchResults = await memoryService.search(args.query, projectId, { - scope: args.scope, - limit: args.limit, - }) - results = searchResults.map((r) => r.memory) - } else { - results = memoryService.listByProject(projectId, { - scope: args.scope, - limit: args.limit, - }) - } - - logger.log(`memory-read: returned ${results.length} results`) - if (results.length === 0) { - return withDimensionWarning('No memories found.') - } - - const formatted = results.map( - (m: any) => `[${m.id}] (${m.scope}) - Created ${new Date(m.createdAt).toISOString().split('T')[0]}\n${m.content}` - ) - return withDimensionWarning(`Found ${results.length} memories:\n\n${formatted.join('\n\n')}`) - }, - }), - 'memory-write': tool({ - description: 'Store a new project memory', - args: { - content: z.string().describe('The memory content to store'), - scope: scopeEnum.describe('Memory scope category'), - }, - execute: async (args) => { - logger.log(`memory-write: scope=${args.scope}, content="${args.content?.substring(0, 80)}"`) - - const result = await memoryService.create({ - projectId, - scope: args.scope, - content: args.content, - }) - - logger.log(`memory-write: created id=${result.id}, deduplicated=${result.deduplicated}`) - return withDimensionWarning(`Memory stored (ID: #${result.id}, scope: ${args.scope}).${result.deduplicated ? ' (matched existing memory)' : ''}`) - }, - }), - 'memory-edit': tool({ - description: 'Edit an existing project memory', - args: { - id: z.number().describe('The memory ID to edit'), - content: z.string().describe('The updated memory content'), - scope: scopeEnum.optional().describe('Change the scope category'), - }, - execute: async (args) => { - logger.log(`memory-edit: id=${args.id}, content="${args.content?.substring(0, 80)}"`) - - const memory = memoryService.getById(args.id) - if (!memory || memory.projectId !== projectId) { - logger.log(`memory-edit: id=${args.id} not found`) - return withDimensionWarning(`Memory #${args.id} not found.`) - } - - await memoryService.update(args.id, { - content: args.content, - ...(args.scope && { scope: args.scope }), - }) - - logger.log(`memory-edit: updated id=${args.id}`) - return withDimensionWarning(`Updated memory #${args.id} (scope: ${args.scope ?? memory.scope}).`) - }, - }), - 'memory-delete': tool({ - description: 'Delete a project memory', - args: { - id: z.number().describe('The memory ID to delete'), - }, - execute: async (args) => { - const id = args.id - logger.log(`memory-delete: id=${id}`) - - const memory = memoryService.getById(id) - if (!memory || memory.projectId !== projectId) { - logger.log(`memory-delete: id=${id} not found`) - return withDimensionWarning(`Memory #${id} not found.`) - } - - await memoryService.delete(id) - logger.log(`memory-delete: deleted id=${id}`) - return withDimensionWarning(`Deleted memory #${id}: "${memory.content.substring(0, 50)}..." (${memory.scope})`) - }, - }), - 'memory-health': tool({ - description: 'Check memory plugin health or trigger a reindex of all embeddings. Use action "check" (default) to view status, "reindex" to regenerate all embeddings when model has changed or embeddings are missing, or "upgrade" to update the plugin to the latest version. Always report the plugin version from the output. Never run reindex unless the user explicitly asks for it.', - args: { - action: z.enum(['check', 'reindex', 'upgrade']).optional().default('check').describe('Action to perform: "check" for health status, "reindex" to regenerate embeddings, "upgrade" to update plugin'), - }, - execute: async (args) => { - if (args.action === 'upgrade') { - const result = await performUpgrade(async (cacheDir, version) => { - const pkg = `@opencode-manager/memory@${version}` - const output = await input.$`bun add --force --no-cache --exact --cwd ${cacheDir} ${pkg}`.nothrow().quiet() - return { exitCode: output.exitCode, stderr: output.stderr.toString() } - }) - return result.message - } - if (args.action === 'reindex') { - if (!currentVec.available) { - return 'Reindex unavailable: vector service is still initializing. Try again in a few seconds.' - } - return executeReindex(projectId, memoryService, db, config, provider, mismatchState, currentVec) - } - const [healthResult, updateCheck] = await Promise.all([ - executeHealthCheck(projectId, db, config, provider, dataDir), - checkForUpdate(), - ]) - const versionLine = formatUpgradeCheck(updateCheck) - const initInfo = `\nInit: ${initState.vecReady ? 'vec ready' : 'vec pending'}${initState.syncRunning ? ', sync in progress' : initState.syncComplete ? ', sync complete' : ''}` - return withDimensionWarning(healthResult + initInfo + '\n' + versionLine) - }, - }), - 'memory-plan-execute': tool({ - description: 'Send the plan to the Code agent for execution. By default creates a new session. Set inPlace to true to switch to the code agent in the current session (plan is already in context).', - args: { - plan: z.string().describe('The full implementation plan to send to the Code agent'), - title: z.string().describe('Short title for the session (shown in session list)'), - inPlace: z.boolean().optional().default(false).describe('Execute in the current session as a subtask instead of creating a new session'), - }, - execute: async (args, context) => { - logger.log(`memory-plan-execute: ${args.inPlace ? 'switching to code agent' : 'creating session'} titled "${args.title}"`) - - const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title - const executionModel = parseModelString(config.executionModel) - - if (args.inPlace) { - const inPlacePrompt = `The architect agent has created an implementation plan in this conversation above. You are now the code agent taking over this session. Your job is to execute the plan — edit files, run commands, create tests, and implement every phase. Do NOT just describe or summarize the changes. Actually make them.\n\nPlan reference: ${args.plan}` - - const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( - () => v2.session.promptAsync({ - sessionID: context.sessionID, - directory, - agent: 'code', - parts: [{ type: 'text' as const, text: inPlacePrompt }], - ...(executionModel ? { model: executionModel } : {}), - }), - () => v2.session.promptAsync({ - sessionID: context.sessionID, - directory, - agent: 'code', - parts: [{ type: 'text' as const, text: inPlacePrompt }], - }), - executionModel, - logger, - ) - - if (promptResult.error) { - logger.error(`memory-plan-execute: in-place agent switch failed`, promptResult.error) - return `Failed to switch to code agent. Error: ${JSON.stringify(promptResult.error)}` - } - - const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' - return `Switching to code agent for execution.\n\nTitle: ${sessionTitle}\nModel: ${modelInfo}\nAgent: code` - } - - const { cleaned: planText, stripped } = stripPromiseTags(args.plan) - if (stripped) { - logger.log(`memory-plan-execute: stripped tags from plan text`) - } - - const createResult = await v2.session.create({ - title: sessionTitle, - directory, - }) - - if (createResult.error || !createResult.data) { - logger.error(`memory-plan-execute: failed to create session`, createResult.error) - return 'Failed to create new session.' - } - - const newSessionId = createResult.data.id - logger.log(`memory-plan-execute: created session=${newSessionId}`) - - const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( - () => v2.session.promptAsync({ - sessionID: newSessionId, - directory, - parts: [{ type: 'text' as const, text: planText }], - agent: 'code', - model: executionModel!, - }), - () => v2.session.promptAsync({ - sessionID: newSessionId, - directory, - parts: [{ type: 'text' as const, text: planText }], - agent: 'code', - }), - executionModel, - logger, - ) - - if (promptResult.error) { - logger.error(`memory-plan-execute: failed to prompt session`, promptResult.error) - return `Session created (${newSessionId}) but failed to send plan. Switch to it and paste the plan manually.` - } - - logger.log(`memory-plan-execute: prompted session=${newSessionId}`) - - v2.tui.selectSession({ sessionID: newSessionId }).catch((err) => { - logger.error('memory-plan-execute: failed to navigate TUI to new session', err) - }) - - const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' - return `Implementation session created and plan sent.\n\nSession: ${newSessionId}\nTitle: ${sessionTitle}\nModel: ${modelInfo}\n\nNavigated to the new session. You can change the model from the session dropdown.` - }, - }), - 'memory-loop': tool({ - description: 'Execute a plan using an iterative development loop. Default runs in current directory. Set worktree to true for isolated git worktree.', - args: { - plan: z.string().describe('The full implementation plan to send to the Code agent'), - title: z.string().describe('Short title for the session (shown in session list)'), - worktree: z.boolean().optional().default(false).describe('Run in isolated git worktree instead of current directory'), - }, - execute: async (args, context) => { - if (config.loop?.enabled === false) { - return 'Loops are disabled in plugin config. Use memory-plan-execute instead.' - } - - logger.log(`memory-loop: creating worktree for plan="${args.title}"`) - - const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title - const loopModel = parseModelString(config.loop?.model) ?? parseModelString(config.executionModel) - const audit = config.loop?.defaultAudit ?? true - - return setupLoop({ - prompt: args.plan, - sessionTitle: `Loop: ${sessionTitle}`, - completionPromise: DEFAULT_PLAN_COMPLETION_PROMISE, - maxIterations: config.loop?.defaultMaxIterations ?? 0, - audit: audit, - agent: 'code', - model: loopModel, - worktree: args.worktree, - onLoopStarted: (id) => loopHandler.startWatchdog(id), - }) - }, - }), - 'memory-kv-set': tool({ - description: 'Store a key-value pair for the current project. Values expire after 7 days by default. Use for ephemeral project state like planning progress, code review patterns, or session context.', - args: { - key: z.string().describe('The key to store the value under'), - value: z.string().describe('The value to store (JSON string)'), - ttlMs: z.number().optional().describe('Time-to-live in milliseconds (default: 7 days)'), - }, - execute: async (args) => { - logger.log(`memory-kv-set: key="${args.key}"`) - let parsed: unknown - try { - parsed = JSON.parse(args.value) - } catch { - parsed = args.value - } - kvService.set(projectId, args.key, parsed, args.ttlMs) - const expiresAt = new Date(Date.now() + (args.ttlMs ?? 7 * 24 * 60 * 60 * 1000)) - logger.log(`memory-kv-set: stored key="${args.key}", expires=${expiresAt.toISOString()}`) - return `Stored key "${args.key}" (expires ${expiresAt.toISOString()})` - }, - }), - 'memory-kv-get': tool({ - description: 'Retrieve a value by key for the current project.', - args: { - key: z.string().describe('The key to retrieve'), - }, - execute: async (args) => { - logger.log(`memory-kv-get: key="${args.key}"`) - const value = kvService.get(projectId, args.key) - if (value === null) { - logger.log(`memory-kv-get: key="${args.key}" not found`) - return `No value found for key "${args.key}"` - } - logger.log(`memory-kv-get: key="${args.key}" found`) - return typeof value === 'string' ? value : JSON.stringify(value, null, 2) - }, - }), - 'memory-kv-list': tool({ - description: 'List all active key-value pairs for the current project. Optionally filter by key prefix.', - args: { - prefix: z.string().optional().describe('Filter entries by key prefix (e.g. "review-finding:")'), - }, - execute: async (args) => { - logger.log(`memory-kv-list: prefix="${args.prefix ?? 'none'}"`) - const entries = args.prefix - ? kvService.listByPrefix(projectId, args.prefix) - : kvService.list(projectId) - if (entries.length === 0) { - logger.log('memory-kv-list: no entries') - return 'No active KV entries for this project.' - } - const formatted = entries.map((e) => { - const expiresIn = Math.round((e.expiresAt - Date.now()) / 60000) - const dataStr = typeof e.data === 'string' ? e.data : JSON.stringify(e.data) - const preview = dataStr.substring(0, 50).replace(/\n/g, ' ') - return `- **${e.key}** (expires in ${expiresIn}m): ${preview}${dataStr.length > 50 ? '...' : ''}` - }) - logger.log(`memory-kv-list: ${entries.length} entries`) - return `${entries.length} active KV entries:\n\n${formatted.join('\n')}` - }, - }), - 'memory-kv-delete': tool({ - description: 'Delete a key-value pair for the current project.', - args: { - key: z.string().describe('The key to delete'), - }, - execute: async (args) => { - logger.log(`memory-kv-delete: key="${args.key}"`) - kvService.delete(projectId, args.key) - return `Deleted key "${args.key}"` - }, - }), - - 'memory-loop-cancel': tool({ - description: 'Cancel an active memory loop and optionally clean up the worktree.', - args: { - name: z.string().optional().describe('Worktree name of the memory loop to cancel'), - }, - execute: async (args) => { - let state: LoopState | null = null - - if (args.name) { - const name = args.name - state = loopService.findByWorktreeName(name) - if (!state) { - const candidates = loopService.findCandidatesByPartialName(name) - if (candidates.length > 0) { - return `Multiple loops match "${name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` - } - const recent = loopService.listRecent() - const foundRecent = recent.find((s) => s.worktreeName === name || (s.worktreeBranch && s.worktreeBranch.toLowerCase().includes(name.toLowerCase()))) - if (foundRecent) { - return `Memory loop "${foundRecent.worktreeName}" has already completed.` - } - return `No active memory loop found for worktree "${name}".` - } - if (!state.active) { - return `Memory loop "${state.worktreeName}" has already completed.` - } - } else { - const active = loopService.listActive() - if (active.length === 0) return 'No active memory loops.' - if (active.length === 1) { - state = active[0] - } else { - return `Multiple active memory loops. Specify a name:\n${active.map((s) => `- ${s.worktreeName} (iteration ${s.iteration})`).join('\n')}` - } - } - - await loopHandler.cancelBySessionId(state.sessionId) - logger.log(`memory-loop-cancel: cancelled loop for session=${state.sessionId} at iteration ${state.iteration}`) - - if (config.loop?.cleanupWorktree && state.worktree && state.worktreeDir) { - try { - const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim() - const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..') - const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' }) - if (removeResult.status !== 0) { - throw new Error(removeResult.stderr || 'git worktree remove failed') - } - logger.log(`memory-loop-cancel: removed worktree ${state.worktreeDir}`) - } catch (err) { - logger.error(`memory-loop-cancel: failed to remove worktree`, err) - } - } - - const modeInfo = !state.worktree ? ' (in-place)' : '' - const branchInfo = state.worktreeBranch ? `\nBranch: ${state.worktreeBranch}` : '' - return `Cancelled memory loop "${state.worktreeName}"${modeInfo} (was at iteration ${state.iteration}).\nDirectory: ${state.worktreeDir}${branchInfo}` - }, - }), - 'memory-loop-status': tool({ - description: 'Check the status of memory loops. With no arguments, lists all active loops for the current project. Pass a worktree name for detailed status of a specific loop. Use restart to resume an inactive loop.', - args: { - name: z.string().optional().describe('Worktree name to check for detailed status'), - restart: z.boolean().optional().describe('Restart an inactive loop by name'), - }, - execute: async (args) => { - const active = loopService.listActive() - - if (args.restart) { - if (!args.name) { - return 'Specify a loop name to restart. Use memory-loop-status to see available loops.' - } - - const recent = loopService.listRecent() - const allStates = [...active, ...recent] - const { match: stoppedState, candidates } = findPartialMatch(args.name, allStates, (s) => [s.worktreeName, s.worktreeBranch]) - if (!stoppedState && candidates.length > 0) { - return `Multiple loops match "${args.name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` - } - if (!stoppedState) { - const available = [...active, ...recent].map((s) => `- ${s.worktreeName}`).join('\n') - return `No memory loop found for "${args.name}".\n\nAvailable loops:\n${available}` - } - - if (stoppedState.active) { - return `Loop "${stoppedState.worktreeName}" is already active. Nothing to restart.` - } - - if (stoppedState.terminationReason === 'completed') { - return `Loop "${stoppedState.worktreeName}" completed successfully and cannot be restarted.` - } - - if (!stoppedState.worktree && stoppedState.worktreeDir) { - if (!existsSync(stoppedState.worktreeDir)) { - return `Cannot restart "${stoppedState.worktreeName}": worktree directory no longer exists at ${stoppedState.worktreeDir}. The worktree may have been cleaned up.` - } - } - - const createResult = await v2.session.create({ - title: stoppedState.worktreeName!, - directory: stoppedState.worktreeDir!, - }) - - if (createResult.error || !createResult.data) { - logger.error(`memory-loop-restart: failed to create session`, createResult.error) - return `Failed to create new session for restart.` - } - - const newSessionId = createResult.data.id - - loopService.deleteState(stoppedState.worktreeName!) - - const newState: LoopState = { - active: true, - sessionId: newSessionId, - worktreeName: stoppedState.worktreeName!, - worktreeDir: stoppedState.worktreeDir!, - worktreeBranch: stoppedState.worktreeBranch, - workspaceId: stoppedState.workspaceId, - iteration: stoppedState.iteration!, - maxIterations: stoppedState.maxIterations!, - completionPromise: stoppedState.completionPromise, - startedAt: new Date().toISOString(), - prompt: stoppedState.prompt, - phase: 'coding', - audit: stoppedState.audit, - errorCount: 0, - auditCount: 0, - worktree: stoppedState.worktree, - } - - loopService.setState(stoppedState.worktreeName!, newState) - loopService.registerSession(newSessionId, stoppedState.worktreeName!) - - let promptText = stoppedState.prompt ?? '' - if (stoppedState.completionPromise) { - promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: ${stoppedState.completionPromise}\n\nDo NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` - } - - const loopModel = parseModelString(config.loop?.model) ?? parseModelString(config.executionModel) - - const { result: promptResult } = await retryWithModelFallback( - () => v2.session.promptAsync({ - sessionID: newSessionId, - directory: stoppedState.worktreeDir!, - parts: [{ type: 'text' as const, text: promptText }], - agent: 'code', - model: loopModel!, - }), - () => v2.session.promptAsync({ - sessionID: newSessionId, - directory: stoppedState.worktreeDir!, - parts: [{ type: 'text' as const, text: promptText }], - agent: 'code', - }), - loopModel, - logger, - ) - - if (promptResult.error) { - logger.error(`memory-loop-restart: failed to send prompt`, promptResult.error) - loopService.deleteState(stoppedState.worktreeName!) - return `Restart failed: could not send prompt to new session.` - } - - loopHandler.startWatchdog(stoppedState.worktreeName!) - - const modeInfo = stoppedState.worktree ? ' (in-place)' : '' - const branchInfo = stoppedState.worktreeBranch ? `\nBranch: ${stoppedState.worktreeBranch}` : '' - return [ - `Restarted memory loop "${stoppedState.worktreeName}"${modeInfo}`, - '', - `New session: ${newSessionId}`, - `Continuing from iteration: ${stoppedState.iteration}`, - `Previous termination: ${stoppedState.terminationReason}`, - `Directory: ${stoppedState.worktreeDir}${branchInfo}`, - `Audit: ${stoppedState.audit ? 'enabled' : 'disabled'}`, - ].join('\n') - } - - if (!args.name) { - const recent = loopService.listRecent() - - if (active.length === 0) { - if (recent.length === 0) return 'No memory loops found.' - - const lines: string[] = ['Recently Completed Memory Loops', ''] - recent.forEach((s, i) => { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` - lines.push(`${i + 1}. ${s.worktreeName}`) - lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) - lines.push('') - }) - lines.push('Use memory-loop-status for detailed info.') - return lines.join('\n') - } - - let statuses: Record = {} - try { - const statusResult = await v2.session.status() - statuses = (statusResult.data ?? {}) as typeof statuses - } catch { - } - - const lines: string[] = [`Active Memory Loops (${active.length})`, ''] - active.forEach((s, i) => { - const elapsed = s.startedAt ? Math.round((Date.now() - new Date(s.startedAt).getTime()) / 1000) : 0 - const minutes = Math.floor(elapsed / 60) - const seconds = elapsed % 60 - const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` - const iterInfo = s.maxIterations && s.maxIterations > 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` - const sessionStatus = statuses[s.sessionId]?.type ?? 'unknown' - const modeIndicator = !s.worktree ? ' (in-place)' : '' - const stallInfo = loopHandler.getStallInfo(s.worktreeName) - const stallCount = stallInfo?.consecutiveStalls ?? 0 - const stallSuffix = stallCount > 0 ? ` | Stalls: ${stallCount}` : '' - lines.push(`${i + 1}. ${s.worktreeName}${modeIndicator}`) - lines.push(` Phase: ${s.phase} | Iteration: ${iterInfo} | Duration: ${duration} | Status: ${sessionStatus}${stallSuffix}`) - lines.push('') - }) - - if (recent.length > 0) { - lines.push('Recently Completed:') - lines.push('') - const limitedRecent = recent.slice(0, 10) - limitedRecent.forEach((s, i) => { - const duration = s.completedAt && s.startedAt - ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` - lines.push(`${i + 1}. ${s.worktreeName}`) - lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) - lines.push('') - }) - if (recent.length > 10) { - lines.push(` ... and ${recent.length - 10} more. Use memory-loop-status for details.`) - lines.push('') - } - } - - lines.push('Use memory-loop-status for detailed info, or memory-loop-cancel to stop a loop.') - return lines.join('\n') - } - - const state = loopService.findByWorktreeName(args.name) - if (!state) { - const candidates = loopService.findCandidatesByPartialName(args.name) - if (candidates.length > 0) { - return `Multiple loops match "${args.name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` - } - return `No loop found for worktree "${args.name}".` - } - - if (!state.active) { - const maxInfo = state.maxIterations && state.maxIterations > 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` - const duration = state.completedAt && state.startedAt - ? Math.round((new Date(state.completedAt).getTime() - new Date(state.startedAt).getTime()) / 1000) - : 0 - const minutes = Math.floor(duration / 60) - const seconds = duration % 60 - const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` - - const statusLines: string[] = [ - 'Loop Status (Inactive)', - '', - `Name: ${state.worktreeName}`, - `Session: ${state.sessionId}`, - ] - if (!state.worktree) { - statusLines.push(`Mode: in-place | Directory: ${state.worktreeDir}`) - } else { - statusLines.push(`Workspace: ${state.workspaceId}`) - statusLines.push(`Worktree: ${state.worktreeDir}`) - } - statusLines.push( - `Iteration: ${maxInfo}`, - `Duration: ${durationStr}`, - `Reason: ${state.terminationReason ?? 'unknown'}`, - ) - if (state.worktreeBranch) { - statusLines.push(`Branch: ${state.worktreeBranch}`) - } - statusLines.push( - `Started: ${state.startedAt}`, - ...(state.completedAt ? [`Completed: ${state.completedAt}`] : []), - ) - - if (state.lastAuditResult) { - statusLines.push(...formatAuditResult(state.lastAuditResult)) - } - - const sessionOutput = state.worktreeDir ? await fetchSessionOutput(v2, state.sessionId, state.worktreeDir, logger) : null - if (sessionOutput) { - statusLines.push('') - statusLines.push('Session Output:') - statusLines.push(...formatSessionOutput(sessionOutput)) - } - - return statusLines.join('\n') - } - - const maxInfo = state.maxIterations && state.maxIterations > 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` - const promptPreview = state.prompt && state.prompt.length > 100 ? `${state.prompt.substring(0, 97)}...` : (state.prompt ?? '') - - let sessionStatus = 'unknown' - try { - const statusResult = await v2.session.status() - const statuses = statusResult.data as Record | undefined - const status = statuses?.[state.sessionId] - if (status) { - sessionStatus = status.type === 'retry' - ? `retry (attempt ${status.attempt}, next in ${Math.round(((status.next ?? 0) - Date.now()) / 1000)}s)` - : status.type - } - } catch { - sessionStatus = 'unavailable' - } - - const elapsed = state.startedAt ? Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000) : 0 - const minutes = Math.floor(elapsed / 60) - const seconds = elapsed % 60 - const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` - - const stallInfo = loopHandler.getStallInfo(state.worktreeName) - const secondsSinceActivity = stallInfo - ? Math.round((Date.now() - stallInfo.lastActivityTime) / 1000) - : null - const stallCount = stallInfo?.consecutiveStalls ?? 0 - - const statusLines: string[] = [ - 'Loop Status', - '', - `Name: ${state.worktreeName}`, - `Session: ${state.sessionId}`, - ] - if (!state.worktree) { - statusLines.push(`Mode: in-place | Directory: ${state.worktreeDir}`) - } else { - statusLines.push(`Workspace: ${state.workspaceId}`) - statusLines.push(`Worktree: ${state.worktreeDir}`) - } - statusLines.push( - `Status: ${sessionStatus}`, - `Phase: ${state.phase}`, - `Iteration: ${maxInfo}`, - `Duration: ${duration}`, - `Audit: ${state.audit ? 'enabled' : 'disabled'}`, - ) - if (state.worktreeBranch) { - statusLines.push(`Branch: ${state.worktreeBranch}`) - } - statusLines.push( - `Completion promise: ${state.completionPromise ?? 'none'}`, - `Started: ${state.startedAt}`, - ...(state.errorCount && state.errorCount > 0 ? [`Error count: ${state.errorCount} (retries before termination: ${MAX_RETRIES})`] : []), - `Audit count: ${state.auditCount ?? 0}`, - `Model: ${config.loop?.model || config.executionModel || 'default'}`, - `Auditor model: ${config.auditorModel || 'default'}`, - ...(stallCount > 0 ? [`Stalls: ${stallCount}`] : []), - ...(secondsSinceActivity !== null ? [`Last activity: ${secondsSinceActivity}s ago`] : []), - '', - `Prompt: ${promptPreview}`, - ) - - if (state.lastAuditResult) { - statusLines.push(...formatAuditResult(state.lastAuditResult)) - } - - const sessionOutput = state.worktreeDir ? await fetchSessionOutput(v2, state.sessionId, state.worktreeDir, logger) : null - if (sessionOutput) { - statusLines.push('') - statusLines.push('Session Output:') - statusLines.push(...formatSessionOutput(sessionOutput)) - } - - return statusLines.join('\n') - }, - }), - }, + tool: tools, config: createConfigHandler( config.auditorModel ? { ...agents, auditor: { ...agents.auditor, defaultModel: config.auditorModel } } - : agents + : agents, + config.agents ), 'chat.message': async (input, output) => { await sessionHooks.onMessage(input, output) @@ -1379,53 +215,8 @@ Do NOT output text without also making this tool call. await loopHandler.onEvent(eventInput) await sessionHooks.onEvent(eventInput) }, - 'tool.execute.before': async ( - input: { tool: string; sessionID: string; callID: string }, - output: { args: unknown } - ) => { - const worktreeName = loopService.resolveWorktreeName(input.sessionID) - const state = worktreeName ? loopService.getActiveState(worktreeName) : null - if (!state?.active) return - - if (!(input.tool in LOOP_BLOCKED_TOOLS)) return - - logger.log(`Loop: blocking ${input.tool} tool before execution in ${state.phase} phase for session ${input.sessionID}`) - - throw new Error(LOOP_BLOCKED_TOOLS[input.tool]!) - }, - 'tool.execute.after': async ( - input: { tool: string; sessionID: string; callID: string; args: unknown }, - output: { title: string; output: string; metadata: unknown } - ) => { - if (input.tool === 'question') { - const args = input.args as { questions?: Array<{ options?: Array<{ label: string }> }> } | undefined - const options = args?.questions?.[0]?.options - if (options) { - const labels = options.map((o) => o.label) - const isPlanApproval = PLAN_APPROVAL_LABELS.every((l) => labels.includes(l)) - if (isPlanApproval) { - const metadata = output.metadata as { answers?: string[][] } | undefined - const answer = metadata?.answers?.[0]?.[0]?.trim() ?? output.output.trim() - const matchedLabel = PLAN_APPROVAL_LABELS.find((l) => answer === l || answer.startsWith(l)) - const directive = matchedLabel ? PLAN_APPROVAL_DIRECTIVES[matchedLabel] : '\nThe user provided a custom response instead of selecting a predefined option. Review their answer and respond accordingly. If they want to proceed with execution, use the appropriate tool (memory-plan-execute or memory-loop) based on their intent. If they want to cancel or revise the plan, help them with that instead.\n' - output.output = `${output.output}\n\n${directive}` - logger.log(`Plan approval: detected "${matchedLabel ?? 'cancel/custom'}" answer, injected directive`) - } - } - return - } - - const worktreeName = loopService.resolveWorktreeName(input.sessionID) - const state = worktreeName ? loopService.getActiveState(worktreeName) : null - if (!state?.active) return - - if (!(input.tool in LOOP_BLOCKED_TOOLS)) return - - logger.log(`Loop: blocked ${input.tool} tool in ${state.phase} phase for session ${input.sessionID}`) - - output.title = 'Tool blocked' - output.output = LOOP_BLOCKED_TOOLS[input.tool]! - }, + 'tool.execute.before': toolExecuteBeforeHook, + 'tool.execute.after': toolExecuteAfterHook, 'permission.ask': async (input, output) => { const req = input as unknown as { sessionID: string; patterns: string[] } const worktreeName = loopService.resolveWorktreeName(req.sessionID) @@ -1503,7 +294,7 @@ Do NOT output text without also making this tool call. text: ` Plan mode is active. You MUST NOT make any file edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. -You may ONLY: observe, analyze, plan, and use memory tools (memory-read, memory-write, memory-edit, memory-delete, memory-kv-set, memory-kv-get, memory-kv-list), the question tool, memory-plan-execute, and memory-loop. +You may ONLY: observe, analyze, plan, and use memory tools (memory-read, memory-write, memory-edit, memory-delete, memory-kv-set, memory-kv-get, memory-kv-list, memory-kv-delete), the question tool, memory-plan-execute, and memory-loop. You MUST always present your plan to the user for explicit approval before proceeding. Never execute a plan without approval. Use the question tool to collect approval — never ask for approval via plain text output. `, @@ -1520,5 +311,11 @@ const plugin: Plugin = async (input: PluginInput): Promise => { return factory(input) } -export default plugin +const pluginModule = { + id: '@opencode-manager/memory', + server: plugin, +} + +export default pluginModule export type { PluginConfig, CompactionConfig } from './types' +export { VERSION } from './version' diff --git a/packages/memory/src/services/loop.ts b/packages/memory/src/services/loop.ts index 11c8fc94..62be83f1 100644 --- a/packages/memory/src/services/loop.ts +++ b/packages/memory/src/services/loop.ts @@ -1,4 +1,4 @@ -import type { KvService } from './kv' +import type { KvService, KvEntry } from './kv' import type { Logger, LoopConfig } from '../types' import type { OpencodeClient } from '@opencode-ai/sdk/v2' import { findPartialMatch } from '../utils/partial-match' @@ -43,7 +43,6 @@ export interface LoopState { worktreeName: string worktreeDir: string worktreeBranch?: string - workspaceId?: string iteration: number maxIterations: number completionPromise: string | null @@ -78,6 +77,9 @@ export interface LoopService { getStallTimeoutMs(): number getMinAudits(): number terminateAll(): void + reconcileStale(): number + hasOutstandingFindings(branch?: string): boolean + getOutstandingFindings(branch?: string): KvEntry[] } export function createLoopService( @@ -133,7 +135,7 @@ export function createLoopService( let systemLine = `Loop iteration ${state.iteration ?? 0}` if (state.completionPromise) { - systemLine += ` | To stop: output ${state.completionPromise} (ONLY when all requirements are met)` + systemLine += ` | To stop: output ${state.completionPromise} (ONLY after all verification steps pass)` } else if ((state.maxIterations ?? 0) > 0) { systemLine += ` / ${state.maxIterations}` } else { @@ -149,6 +151,12 @@ export function createLoopService( prompt += `\n\n---\nThe code auditor reviewed your changes. You MUST address all bugs and convention violations below — do not dismiss findings as unrelated to the task. Fix them directly without creating a plan or asking for approval.\n\n${auditFindings}${completionInstruction}` } + const outstandingFindings = getOutstandingFindings(state.worktreeBranch) + if (outstandingFindings.length > 0) { + const findingKeys = outstandingFindings.map((f) => `- \`${f.key}\``).join('\n') + prompt += `\n\n---\n⚠️ Outstanding Review Findings (${outstandingFindings.length})\n\nThese review findings are blocking loop completion. Fix these issues so they pass the next audit review.\n\n${findingKeys}` + } + return prompt } @@ -167,7 +175,7 @@ export function createLoopService( 'If you find bugs in related code that affect the correctness of this task, report them — even if the buggy code was not directly modified.', 'If everything looks good, state "No issues found." clearly.', '', - 'Before reviewing, retrieve all existing review findings from the KV store using `memory-kv-list` with prefix `review-finding:`. For each existing finding, verify whether the issue has been resolved in the current code. Delete resolved findings using `memory-kv-delete`. Report any unresolved findings that still apply.', + 'Before reviewing, retrieve all existing review findings by calling the memory-kv-list tool with the prefix parameter set to "review-finding:". For each existing finding, verify whether the issue has been resolved in the current code. Delete resolved findings by calling the memory-kv-delete tool. Report any unresolved findings that still apply.', '', 'This is an automated loop — do not direct the agent to "create a plan" or "present for approval." Just report findings directly.', ].join('\n') @@ -227,6 +235,33 @@ export function createLoopService( logger.log(`Loop: terminated ${active.length} active loop(s)`) } + function reconcileStale(): number { + const active = listActive() + for (const state of active) { + setState(state.worktreeName, { + ...state, + active: false, + completedAt: new Date().toISOString(), + terminationReason: 'shutdown', + }) + logger.log(`Reconciled stale active loop: ${state.worktreeName} (was at iteration ${state.iteration})`) + } + return active.length + } + + function getOutstandingFindings(branch?: string): KvEntry[] { + const findings = kvService.listByPrefix(projectId, 'review-finding:') + if (!branch) return findings + return findings.filter((f) => { + const data = f.data as Record | null + return data && data.branch === branch + }) + } + + function hasOutstandingFindings(branch?: string): boolean { + return getOutstandingFindings(branch).length > 0 + } + return { getActiveState, getAnyState, @@ -245,6 +280,9 @@ export function createLoopService( getStallTimeoutMs, getMinAudits, terminateAll, + reconcileStale, + hasOutstandingFindings, + getOutstandingFindings, } } diff --git a/packages/memory/src/setup.ts b/packages/memory/src/setup.ts index 431751ce..e3066a55 100644 --- a/packages/memory/src/setup.ts +++ b/packages/memory/src/setup.ts @@ -139,6 +139,8 @@ function normalizeConfig(config: PluginConfig): PluginConfig { executionModel: config.executionModel, auditorModel: config.auditorModel, loop: config.loop ?? config.ralph, + tui: config.tui, + agents: config.agents, } if (config.ralph && !config.loop) { diff --git a/packages/memory/src/tools/health.ts b/packages/memory/src/tools/health.ts new file mode 100644 index 00000000..a492887e --- /dev/null +++ b/packages/memory/src/tools/health.ts @@ -0,0 +1,281 @@ +import { tool } from '@opencode-ai/plugin' +import { join } from 'path' +import type { Database } from 'bun:sqlite' +import type { ToolContext, DimensionMismatchState } from './types' +import { withDimensionWarning } from './types' +import { VERSION } from '../version' +import type { HealthStatus, PluginConfig } from '../types' +import type { EmbeddingProvider } from '../embedding' +import { isServerRunning, checkServerHealth } from '../embedding' +import { createMetadataQuery } from '../storage' +import { checkForUpdate, formatUpgradeCheck, performUpgrade } from '../utils/upgrade' +import type { VecService } from '../storage/vec-types' +import type { MemoryService } from '../services/memory' + +const z = tool.schema + +async function getHealthStatus( + projectId: string, + db: Database, + config: PluginConfig, + provider: EmbeddingProvider, + dataDir: string, +): Promise { + const socketPath = join(dataDir, 'embedding.sock') + + let dbStatus: 'ok' | 'error' = 'ok' + let memoryCount = 0 + try { + db.prepare('SELECT 1').get() + const row = db.prepare("SELECT COUNT(*) as count FROM memories WHERE project_id = ?").get(projectId) as { count: number } + memoryCount = row.count + } catch { + dbStatus = 'error' + } + + let operational = false + try { + operational = await provider.test() + } catch { + operational = false + } + + let serverRunning = false + let serverHealth: { status: string; clients: number; uptime: number } | null = null + try { + serverRunning = await isServerRunning(dataDir) + if (serverRunning) { + serverHealth = await checkServerHealth(socketPath) + } + } catch { + serverRunning = false + } + + const configuredModel = { + model: config.embedding.model, + dimensions: config.embedding.dimensions ?? provider.dimensions, + } + + let currentModel: { model: string; dimensions: number } | null = null + try { + const metadata = createMetadataQuery(db) + const stored = metadata.getEmbeddingModel() + if (stored) { + currentModel = { model: stored.model, dimensions: stored.dimensions } + } + } catch { + // Ignore + } + + const needsReindex = !currentModel || + currentModel.model !== configuredModel.model || + currentModel.dimensions !== configuredModel.dimensions + + const overallStatus: 'ok' | 'degraded' | 'error' = dbStatus === 'error' + ? 'error' + : !operational + ? 'degraded' + : 'ok' + + return { + dbStatus, + memoryCount, + operational, + serverRunning, + serverHealth, + configuredModel, + currentModel, + needsReindex, + overallStatus, + } +} + +function formatHealthStatus(status: HealthStatus, provider: EmbeddingProvider): string { + const { dbStatus, memoryCount, operational, serverRunning, serverHealth, configuredModel, currentModel, needsReindex, overallStatus } = status + + const embeddingStatus: 'ok' | 'error' = operational ? 'ok' : 'error' + + const lines: string[] = [ + `Memory Plugin v${VERSION}`, + `Status: ${overallStatus.toUpperCase()}`, + '', + `Embedding: ${embeddingStatus}`, + ` Provider: ${provider.name} (${provider.dimensions}d)`, + ` Operational: ${operational}`, + ` Server running: ${serverRunning}`, + ] + + if (serverHealth) { + lines.push(` Clients: ${serverHealth.clients}, Uptime: ${Math.round(serverHealth.uptime / 1000)}s`) + } + + lines.push('') + lines.push(`Database: ${dbStatus}`) + lines.push(` Total memories: ${memoryCount}`) + lines.push('') + lines.push(`Model: ${needsReindex ? 'drift' : 'ok'}`) + lines.push(` Configured: ${configuredModel.model} (${configuredModel.dimensions}d)`) + if (currentModel) { + lines.push(` Indexed: ${currentModel.model} (${currentModel.dimensions}d)`) + } else { + lines.push(' Indexed: none') + } + if (needsReindex) { + lines.push(' Reindex needed - run memory-health with action "reindex"') + } else { + lines.push(' In sync') + } + + return lines.join('\n') +} + +async function executeHealthCheck( + projectId: string, + db: Database, + config: PluginConfig, + provider: EmbeddingProvider, + dataDir: string, +): Promise { + const status = await getHealthStatus(projectId, db, config, provider, dataDir) + return formatHealthStatus(status, provider) +} + +async function executeReindex( + projectId: string, + memoryService: MemoryService, + db: Database, + config: PluginConfig, + provider: EmbeddingProvider, + mismatchState: DimensionMismatchState, + vec: VecService, +): Promise { + const configuredModel = config.embedding.model + const configuredDimensions = config.embedding.dimensions ?? provider.dimensions + + let operational = false + try { + operational = await provider.test() + } catch { + operational = false + } + + if (!operational) { + return 'Reindex failed: embedding provider is not operational. Check your API key and model configuration.' + } + + const tableInfo = await vec.getDimensions() + if (tableInfo.exists && tableInfo.dimensions !== null && tableInfo.dimensions !== configuredDimensions) { + await vec.recreateTable(configuredDimensions) + } + + const result = await memoryService.reindex(projectId) + + if (result.success > 0 || result.total === 0) { + const metadata = createMetadataQuery(db) + metadata.setEmbeddingModel(configuredModel, configuredDimensions) + } + + if (result.failed === 0) { + mismatchState.detected = false + mismatchState.expected = null + mismatchState.actual = null + } + + const lines: string[] = [ + 'Reindex complete', + '', + `Total memories: ${result.total}`, + `Embedded: ${result.success}`, + `Failed: ${result.failed}`, + '', + `Model: ${configuredModel} (${configuredDimensions}d)`, + ] + + if (result.failed > 0) { + lines.push(`WARNING: ${result.failed} memories failed to embed`) + } + + return lines.join('\n') +} + +export async function autoValidateOnLoad( + projectId: string, + memoryService: MemoryService, + db: Database, + config: PluginConfig, + provider: EmbeddingProvider, + dataDir: string, + mismatchState: DimensionMismatchState, + vec: VecService, + logger: { log: (message: string) => void }, +): Promise { + const status = await getHealthStatus(projectId, db, config, provider, dataDir) + + if (status.overallStatus === 'error') { + logger.log('Auto-validate: unhealthy (db error), skipping') + return + } + + if (!status.needsReindex) { + logger.log('Auto-validate: healthy, no action needed') + return + } + + if (!status.operational) { + logger.log('Auto-validate: reindex needed but provider not operational, skipping') + return + } + + logger.log('Auto-validate: model drift detected, starting reindex') + await executeReindex(projectId, memoryService, db, config, provider, mismatchState, vec) + logger.log('Auto-validate: reindex complete') +} + +export function createHealthTools(ctx: ToolContext): Record> { + const { projectId, db, config, provider, dataDir, memoryService, logger, cleanup, input, mismatchState, initState } = ctx + const getCurrentVec = ctx.getCurrentVec + + return { + 'memory-health': tool({ + description: 'Check memory plugin health or trigger a reindex of all embeddings. Use action "check" (default) to view status, "reindex" to regenerate all embeddings when model has changed or embeddings are missing, "upgrade" to update the plugin to the latest version, or "reload" to reload the plugin without restarting OpenCode. Always report the plugin version from the output. Never run reindex unless the user explicitly asks for it.', + args: { + action: z.enum(['check', 'reindex', 'upgrade', 'reload']).optional().default('check').describe('Action to perform: "check" for health status, "reindex" to regenerate embeddings, "upgrade" to update plugin, "reload" to reload the plugin without restarting OpenCode'), + }, + execute: async (args) => { + if (args.action === 'reload') { + logger.log('memory-health: reload triggered via health tool') + await cleanup() + ctx.v2.instance.dispose().catch(() => {}) + return 'Plugin reload triggered. The instance will reinitialize on next interaction.' + } + if (args.action === 'upgrade') { + const result = await performUpgrade(async (cacheDir, version) => { + const pkg = `@opencode-manager/memory@${version}` + const output = await input.$`bun add --force --no-cache --exact --cwd ${cacheDir} ${pkg}`.nothrow().quiet() + return { exitCode: output.exitCode, stderr: output.stderr.toString() } + }) + if (result.upgraded) { + logger.log(`memory-health: upgrade successful (${result.from} -> ${result.to}), triggering reload`) + await cleanup() + ctx.v2.instance.dispose().catch(() => {}) + return `${result.message}. Reloading plugin — new version will be active on next interaction.` + } + return result.message + } + if (args.action === 'reindex') { + if (!getCurrentVec().available) { + return 'Reindex unavailable: vector service is still initializing. Try again in a few seconds.' + } + return executeReindex(projectId, memoryService, db, config, provider, mismatchState, getCurrentVec()) + } + const [healthResult, updateCheck] = await Promise.all([ + executeHealthCheck(projectId, db, config, provider, dataDir), + checkForUpdate(), + ]) + const versionLine = formatUpgradeCheck(updateCheck) + const initInfo = `\nInit: ${initState.vecReady ? 'vec ready' : 'vec pending'}${initState.syncRunning ? ', sync in progress' : initState.syncComplete ? ', sync complete' : ''}` + return withDimensionWarning(mismatchState, healthResult + initInfo + '\n' + versionLine) + }, + }), + } +} diff --git a/packages/memory/src/tools/index.ts b/packages/memory/src/tools/index.ts new file mode 100644 index 00000000..07a92ea2 --- /dev/null +++ b/packages/memory/src/tools/index.ts @@ -0,0 +1,22 @@ +import { tool } from '@opencode-ai/plugin' +import { createMemoryTools } from './memory' +import { createKvTools } from './kv' +import { createHealthTools } from './health' +import { createPlanExecuteTools } from './plan-execute' +import { createLoopTools } from './loop' +import type { ToolContext } from './types' + +export { autoValidateOnLoad } from './health' +export { createToolExecuteBeforeHook, createToolExecuteAfterHook } from './plan-approval' +export { scopeEnum } from './types' +export type { ToolContext, DimensionMismatchState, InitState } from './types' + +export function createTools(ctx: ToolContext): Record> { + return { + ...createMemoryTools(ctx), + ...createKvTools(ctx), + ...createHealthTools(ctx), + ...createPlanExecuteTools(ctx), + ...createLoopTools(ctx), + } +} diff --git a/packages/memory/src/tools/kv.ts b/packages/memory/src/tools/kv.ts new file mode 100644 index 00000000..f93a506a --- /dev/null +++ b/packages/memory/src/tools/kv.ts @@ -0,0 +1,105 @@ +import { tool } from '@opencode-ai/plugin' +import type { ToolContext } from './types' +import { execSync } from 'child_process' + +const z = tool.schema + +export function createKvTools(ctx: ToolContext): Record> { + const { kvService, projectId, logger, loopService } = ctx + + return { + 'memory-kv-set': tool({ + description: 'Store a key-value pair for the current project. Values expire after 7 days by default. Keys prefixed with "review-finding:" get an automatic "branch" field injected.', + args: { + key: z.string().describe('The key to store the value under'), + value: z.string().describe('The value to store (JSON string)'), + ttlMs: z.number().optional().describe('Time-to-live in milliseconds (default: 7 days)'), + }, + execute: async (args, context) => { + logger.log(`memory-kv-set: key="${args.key}"`) + let parsed: unknown + try { + parsed = JSON.parse(args.value) + } catch { + parsed = args.value + } + + if (args.key.startsWith('review-finding:') && typeof parsed === 'object' && parsed !== null) { + const active = loopService.listActive() + const loop = active.find((s) => s.worktreeDir === context.directory) + if (loop?.worktreeBranch) { + ;(parsed as Record).branch = loop.worktreeBranch + } else { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: context.directory, encoding: 'utf-8' }).trim() + if (branch) { + ;(parsed as Record).branch = branch + } + } catch { + // git not available or not a repo + } + } + } + + kvService.set(projectId, args.key, parsed, args.ttlMs) + const expiresAt = new Date(Date.now() + (args.ttlMs ?? 7 * 24 * 60 * 60 * 1000)) + logger.log(`memory-kv-set: stored key="${args.key}", expires=${expiresAt.toISOString()}`) + return `Stored key "${args.key}" (expires ${expiresAt.toISOString()})` + }, + }), + + 'memory-kv-get': tool({ + description: 'Retrieve a value by key for the current project.', + args: { + key: z.string().describe('The key to retrieve'), + }, + execute: async (args) => { + logger.log(`memory-kv-get: key="${args.key}"`) + const value = kvService.get(projectId, args.key) + if (value === null) { + logger.log(`memory-kv-get: key="${args.key}" not found`) + return `No value found for key "${args.key}"` + } + logger.log(`memory-kv-get: key="${args.key}" found`) + return typeof value === 'string' ? value : JSON.stringify(value, null, 2) + }, + }), + + 'memory-kv-list': tool({ + description: 'List all active key-value pairs for the current project. Optionally filter by key prefix.', + args: { + prefix: z.string().optional().describe('Filter entries by key prefix (e.g. "review-finding:")'), + }, + execute: async (args) => { + logger.log(`memory-kv-list: prefix="${args.prefix ?? 'none'}"`) + const entries = args.prefix + ? kvService.listByPrefix(projectId, args.prefix) + : kvService.list(projectId) + if (entries.length === 0) { + logger.log('memory-kv-list: no entries') + return 'No active KV entries for this project.' + } + const formatted = entries.map((e) => { + const expiresIn = Math.round((e.expiresAt - Date.now()) / 60000) + const dataStr = typeof e.data === 'string' ? e.data : JSON.stringify(e.data) + const preview = dataStr.substring(0, 50).replace(/\n/g, ' ') + return `- **${e.key}** (expires in ${expiresIn}m): ${preview}${dataStr.length > 50 ? '...' : ''}` + }) + logger.log(`memory-kv-list: ${entries.length} entries`) + return `${entries.length} active KV entries:\n\n${formatted.join('\n')}` + }, + }), + + 'memory-kv-delete': tool({ + description: 'Delete a key-value pair for the current project.', + args: { + key: z.string().describe('The key to delete'), + }, + execute: async (args) => { + logger.log(`memory-kv-delete: key="${args.key}"`) + kvService.delete(projectId, args.key) + return `Deleted key "${args.key}"` + }, + }), + } +} diff --git a/packages/memory/src/tools/loop.ts b/packages/memory/src/tools/loop.ts new file mode 100644 index 00000000..587a140f --- /dev/null +++ b/packages/memory/src/tools/loop.ts @@ -0,0 +1,648 @@ +import { tool } from '@opencode-ai/plugin' +import { execSync, spawnSync } from 'child_process' +import { existsSync } from 'fs' +import { resolve } from 'path' +import type { ToolContext } from './types' +import { withDimensionWarning } from './types' +import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import { slugify } from '../utils/logger' +import { findPartialMatch } from '../utils/partial-match' +import { formatSessionOutput, formatAuditResult } from '../utils/loop-format' +import { fetchSessionOutput, MAX_RETRIES, type LoopState, type LoopSessionOutput } from '../services/loop' + +const z = tool.schema +const DEFAULT_PLAN_COMPLETION_PROMISE = 'ALL_PHASES_COMPLETE' + +interface LoopSetupOptions { + prompt: string + sessionTitle: string + worktreeName?: string + completionPromise: string | null + maxIterations: number + audit: boolean + agent?: string + model?: { providerID: string; modelID: string } + worktree?: boolean + onLoopStarted?: (worktreeName: string) => void +} + +async function setupLoop( + ctx: ToolContext, + options: LoopSetupOptions, +): Promise { + const { v2, directory, config, loopService, loopHandler, logger } = ctx + const autoWorktreeName = options.worktreeName ?? `loop-${slugify(options.sessionTitle.replace(/^Loop:\s*/i, ''))}` + const projectDir = directory + const maxIter = options.maxIterations ?? config.loop?.defaultMaxIterations ?? 0 + + interface LoopContext { + sessionId: string + directory: string + branch?: string + worktree: boolean + } + + let loopContext: LoopContext + + if (!options.worktree) { + let currentBranch: string | undefined + try { + currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectDir, encoding: 'utf-8' }).trim() + } catch (err) { + logger.log(`loop: no git branch detected, running without branch info`) + } + + const createResult = await v2.session.create({ + title: options.sessionTitle, + directory: projectDir, + }) + + if (createResult.error || !createResult.data) { + logger.error(`loop: failed to create session`, createResult.error) + return 'Failed to create loop session.' + } + + loopContext = { + sessionId: createResult.data.id, + directory: projectDir, + branch: currentBranch, + worktree: false, + } + } else { + const worktreeResult = await v2.worktree.create({ + worktreeCreateInput: { name: autoWorktreeName }, + }) + + if (worktreeResult.error || !worktreeResult.data) { + logger.error(`loop: failed to create worktree`, worktreeResult.error) + return 'Failed to create worktree.' + } + + const worktreeInfo = worktreeResult.data + logger.log(`loop: worktree created at ${worktreeInfo.directory} (branch: ${worktreeInfo.branch})`) + + const createResult = await v2.session.create({ + title: options.sessionTitle, + directory: worktreeInfo.directory, + }) + + if (createResult.error || !createResult.data) { + logger.error(`loop: failed to create session`, createResult.error) + try { + await v2.worktree.remove({ worktreeRemoveInput: { directory: worktreeInfo.directory } }) + } catch (cleanupErr) { + logger.error(`loop: failed to cleanup worktree`, cleanupErr) + } + return 'Failed to create loop session.' + } + + loopContext = { + sessionId: createResult.data.id, + directory: worktreeInfo.directory, + branch: worktreeInfo.branch, + worktree: true, + } + } + + const state: LoopState = { + active: true, + sessionId: loopContext.sessionId, + worktreeName: autoWorktreeName, + worktreeDir: loopContext.directory, + worktreeBranch: loopContext.branch, + iteration: 1, + maxIterations: maxIter, + completionPromise: options.completionPromise, + startedAt: new Date().toISOString(), + prompt: options.prompt, + phase: 'coding', + audit: options.audit, + errorCount: 0, + auditCount: 0, + worktree: options.worktree, + } + + loopService.setState(autoWorktreeName, state) + loopService.registerSession(loopContext.sessionId, autoWorktreeName) + logger.log(`loop: state stored for worktree=${autoWorktreeName}`) + + let promptText = options.prompt + if (options.completionPromise) { + promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: ${options.completionPromise}\n\nDo NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` + } + + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: loopContext.sessionId, + directory: loopContext.directory, + parts: [{ type: 'text' as const, text: promptText }], + ...(options.agent && { agent: options.agent }), + model: options.model!, + }), + () => v2.session.promptAsync({ + sessionID: loopContext.sessionId, + directory: loopContext.directory, + parts: [{ type: 'text' as const, text: promptText }], + ...(options.agent && { agent: options.agent }), + }), + options.model, + logger, + ) + + if (promptResult.error) { + logger.error(`loop: failed to send prompt`, promptResult.error) + loopService.deleteState(autoWorktreeName) + if (options.worktree) { + try { + await v2.worktree.remove({ worktreeRemoveInput: { directory: loopContext.directory } }) + } catch (cleanupErr) { + logger.error(`loop: failed to cleanup worktree`, cleanupErr) + } + } + return !options.worktree + ? 'Loop session created but failed to send prompt.' + : 'Loop session created but failed to send prompt. Cleaned up.' + } + + options.onLoopStarted?.(autoWorktreeName) + + if (!options.worktree) { + v2.tui.selectSession({ sessionID: loopContext.sessionId }).catch((err) => { + logger.error('loop: failed to navigate TUI to new session', err) + }) + } + + const maxInfo = maxIter > 0 ? maxIter.toString() : 'unlimited' + const auditInfo = options.audit ? 'enabled' : 'disabled' + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' + + const lines: string[] = [ + !options.worktree ? 'Memory loop activated! (in-place mode)' : 'Memory loop activated!', + '', + `Session: ${loopContext.sessionId}`, + `Title: ${options.sessionTitle}`, + ] + + if (!options.worktree) { + lines.push(`Directory: ${loopContext.directory}`) + if (loopContext.branch) { + lines.push(`Branch: ${loopContext.branch} (in-place)`) + } + } else { + lines.push(`Worktree name: ${autoWorktreeName}`) + lines.push(`Worktree: ${loopContext.directory}`) + lines.push(`Branch: ${loopContext.branch}`) + } + + lines.push( + `Model: ${modelInfo}`, + `Max iterations: ${maxInfo}`, + `Completion promise: ${options.completionPromise ?? 'none'}`, + `Audit: ${auditInfo}`, + '', + 'The loop will automatically continue when the session goes idle.', + 'Your job is done — just confirm to the user that the loop has been launched.', + 'The user can run memory-loop-status or memory-loop-cancel later if needed.', + ) + + return lines.join('\n') +} + +export function createLoopTools(ctx: ToolContext): Record> { + const { v2, loopService, loopHandler, config, directory, logger } = ctx + + return { + 'memory-loop': tool({ + description: 'Execute a plan using an iterative development loop. Default runs in current directory. Set worktree to true for isolated git worktree.', + args: { + plan: z.string().describe('The full implementation plan to send to the Code agent'), + title: z.string().describe('Short title for the session (shown in session list)'), + worktree: z.boolean().optional().default(false).describe('Run in isolated git worktree instead of current directory'), + }, + execute: async (args, context) => { + if (config.loop?.enabled === false) { + return 'Loops are disabled in plugin config. Use memory-plan-execute instead.' + } + + logger.log(`memory-loop: creating worktree for plan="${args.title}"`) + + const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title + const loopModel = parseModelString(config.loop?.model) ?? parseModelString(config.executionModel) + const audit = config.loop?.defaultAudit ?? true + + return setupLoop(ctx, { + prompt: args.plan, + sessionTitle: `Loop: ${sessionTitle}`, + completionPromise: DEFAULT_PLAN_COMPLETION_PROMISE, + maxIterations: config.loop?.defaultMaxIterations ?? 0, + audit: audit, + agent: 'code', + model: loopModel, + worktree: args.worktree, + onLoopStarted: (id) => loopHandler.startWatchdog(id), + }) + }, + }), + + 'memory-loop-cancel': tool({ + description: 'Cancel an active memory loop and optionally clean up the worktree.', + args: { + name: z.string().optional().describe('Worktree name of the memory loop to cancel'), + }, + execute: async (args) => { + let state: LoopState | null = null + + if (args.name) { + const name = args.name + state = loopService.findByWorktreeName(name) + if (!state) { + const candidates = loopService.findCandidatesByPartialName(name) + if (candidates.length > 0) { + return `Multiple loops match "${name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` + } + const recent = loopService.listRecent() + const foundRecent = recent.find((s) => s.worktreeName === name || (s.worktreeBranch && s.worktreeBranch.toLowerCase().includes(name.toLowerCase()))) + if (foundRecent) { + return `Memory loop "${foundRecent.worktreeName}" has already completed.` + } + return `No active memory loop found for worktree "${name}".` + } + if (!state.active) { + return `Memory loop "${state.worktreeName}" has already completed.` + } + } else { + const active = loopService.listActive() + if (active.length === 0) return 'No active memory loops.' + if (active.length === 1) { + state = active[0] + } else { + return `Multiple active memory loops. Specify a name:\n${active.map((s) => `- ${s.worktreeName} (iteration ${s.iteration})`).join('\n')}` + } + } + + await loopHandler.cancelBySessionId(state.sessionId) + logger.log(`memory-loop-cancel: cancelled loop for session=${state.sessionId} at iteration ${state.iteration}`) + + if (config.loop?.cleanupWorktree && state.worktree && state.worktreeDir) { + try { + const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim() + const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..') + const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' }) + if (removeResult.status !== 0) { + throw new Error(removeResult.stderr || 'git worktree remove failed') + } + logger.log(`memory-loop-cancel: removed worktree ${state.worktreeDir}`) + } catch (err) { + logger.error(`memory-loop-cancel: failed to remove worktree`, err) + } + } + + const modeInfo = !state.worktree ? ' (in-place)' : '' + const branchInfo = state.worktreeBranch ? `\nBranch: ${state.worktreeBranch}` : '' + return `Cancelled memory loop "${state.worktreeName}"${modeInfo} (was at iteration ${state.iteration}).\nDirectory: ${state.worktreeDir}${branchInfo}` + }, + }), + + 'memory-loop-status': tool({ + description: 'Check the status of memory loops. With no arguments, lists all active loops for the current project. Pass a worktree name for detailed status of a specific loop. Use restart to resume an inactive loop.', + args: { + name: z.string().optional().describe('Worktree name to check for detailed status'), + restart: z.boolean().optional().describe('Restart an inactive loop by name'), + }, + execute: async (args) => { + const active = loopService.listActive() + + if (args.restart) { + if (!args.name) { + return 'Specify a loop name to restart. Use memory-loop-status to see available loops.' + } + + const recent = loopService.listRecent() + const allStates = [...active, ...recent] + const { match: stoppedState, candidates } = findPartialMatch(args.name, allStates, (s) => [s.worktreeName, s.worktreeBranch]) + if (!stoppedState && candidates.length > 0) { + return `Multiple loops match "${args.name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` + } + if (!stoppedState) { + const available = [...active, ...recent].map((s) => `- ${s.worktreeName}`).join('\n') + return `No memory loop found for "${args.name}".\n\nAvailable loops:\n${available}` + } + + if (stoppedState.active) { + return `Loop "${stoppedState.worktreeName}" is already active. Nothing to restart.` + } + + if (stoppedState.terminationReason === 'completed') { + return `Loop "${stoppedState.worktreeName}" completed successfully and cannot be restarted.` + } + + if (!stoppedState.worktree && stoppedState.worktreeDir) { + if (!existsSync(stoppedState.worktreeDir)) { + return `Cannot restart "${stoppedState.worktreeName}": worktree directory no longer exists at ${stoppedState.worktreeDir}. The worktree may have been cleaned up.` + } + } + + const createParams = { + title: stoppedState.worktreeName!, + directory: stoppedState.worktreeDir!, + } + + const createResult = await v2.session.create(createParams) + + if (createResult.error || !createResult.data) { + logger.error(`memory-loop-restart: failed to create session`, createResult.error) + return `Failed to create new session for restart.` + } + + const newSessionId = createResult.data.id + + loopService.deleteState(stoppedState.worktreeName!) + + const newState: LoopState = { + active: true, + sessionId: newSessionId, + worktreeName: stoppedState.worktreeName!, + worktreeDir: stoppedState.worktreeDir!, + worktreeBranch: stoppedState.worktreeBranch, + iteration: stoppedState.iteration!, + maxIterations: stoppedState.maxIterations!, + completionPromise: stoppedState.completionPromise, + startedAt: new Date().toISOString(), + prompt: stoppedState.prompt, + phase: 'coding', + audit: stoppedState.audit, + errorCount: 0, + auditCount: 0, + worktree: stoppedState.worktree, + } + + loopService.setState(stoppedState.worktreeName!, newState) + loopService.registerSession(newSessionId, stoppedState.worktreeName!) + + let promptText = stoppedState.prompt ?? '' + if (stoppedState.completionPromise) { + promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: ${stoppedState.completionPromise}\n\nDo NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` + } + + const loopModel = parseModelString(config.loop?.model) ?? parseModelString(config.executionModel) + + const { result: promptResult } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory: stoppedState.worktreeDir!, + parts: [{ type: 'text' as const, text: promptText }], + agent: 'code', + model: loopModel!, + }), + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory: stoppedState.worktreeDir!, + parts: [{ type: 'text' as const, text: promptText }], + agent: 'code', + }), + loopModel, + logger, + ) + + if (promptResult.error) { + logger.error(`memory-loop-restart: failed to send prompt`, promptResult.error) + loopService.deleteState(stoppedState.worktreeName!) + return `Restart failed: could not send prompt to new session.` + } + + loopHandler.startWatchdog(stoppedState.worktreeName!) + + const modeInfo = !stoppedState.worktree ? ' (in-place)' : '' + const branchInfo = stoppedState.worktreeBranch ? `\nBranch: ${stoppedState.worktreeBranch}` : '' + return [ + `Restarted memory loop "${stoppedState.worktreeName}"${modeInfo}`, + '', + `New session: ${newSessionId}`, + `Continuing from iteration: ${stoppedState.iteration}`, + `Previous termination: ${stoppedState.terminationReason}`, + `Directory: ${stoppedState.worktreeDir}${branchInfo}`, + `Audit: ${stoppedState.audit ? 'enabled' : 'disabled'}`, + ].join('\n') + } + + if (!args.name) { + const recent = loopService.listRecent() + + if (active.length === 0) { + if (recent.length === 0) return 'No memory loops found.' + + const lines: string[] = ['Recently Completed Memory Loops', ''] + recent.forEach((s, i) => { + const duration = s.completedAt && s.startedAt + ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + lines.push(`${i + 1}. ${s.worktreeName}`) + lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) + lines.push('') + }) + lines.push('Use memory-loop-status for detailed info.') + return lines.join('\n') + } + + let statuses: Record = {} + try { + const uniqueDirs = [...new Set(active.map((s) => s.worktreeDir).filter(Boolean))] + const results = await Promise.allSettled( + uniqueDirs.map((dir) => v2.session.status({ directory: dir })), + ) + for (const result of results) { + if (result.status === 'fulfilled' && result.value.data) { + Object.assign(statuses, result.value.data) + } + } + } catch { + } + + const lines: string[] = [`Active Memory Loops (${active.length})`, ''] + active.forEach((s, i) => { + const elapsed = s.startedAt ? Math.round((Date.now() - new Date(s.startedAt).getTime()) / 1000) : 0 + const minutes = Math.floor(elapsed / 60) + const seconds = elapsed % 60 + const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const iterInfo = s.maxIterations && s.maxIterations > 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` + const sessionStatus = statuses[s.sessionId]?.type ?? 'unavailable' + const modeIndicator = !s.worktree ? ' (in-place)' : '' + const stallInfo = loopHandler.getStallInfo(s.worktreeName) + const stallCount = stallInfo?.consecutiveStalls ?? 0 + const stallSuffix = stallCount > 0 ? ` | Stalls: ${stallCount}` : '' + lines.push(`${i + 1}. ${s.worktreeName}${modeIndicator}`) + lines.push(` Phase: ${s.phase} | Iteration: ${iterInfo} | Duration: ${duration} | Status: ${sessionStatus}${stallSuffix}`) + lines.push('') + }) + + if (recent.length > 0) { + lines.push('Recently Completed:') + lines.push('') + const limitedRecent = recent.slice(0, 10) + limitedRecent.forEach((s, i) => { + const duration = s.completedAt && s.startedAt + ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + lines.push(`${i + 1}. ${s.worktreeName}`) + lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt ?? 'unknown'}`) + lines.push('') + }) + if (recent.length > 10) { + lines.push(` ... and ${recent.length - 10} more. Use memory-loop-status for details.`) + lines.push('') + } + } + + lines.push('Use memory-loop-status for detailed info, or memory-loop-cancel to stop a loop.') + return lines.join('\n') + } + + const state = loopService.findByWorktreeName(args.name) + if (!state) { + const candidates = loopService.findCandidatesByPartialName(args.name) + if (candidates.length > 0) { + return `Multiple loops match "${args.name}":\n${candidates.map((s) => `- ${s.worktreeName}`).join('\n')}\n\nBe more specific.` + } + return `No loop found for worktree "${args.name}".` + } + + if (!state.active) { + const maxInfo = state.maxIterations && state.maxIterations > 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` + const duration = state.completedAt && state.startedAt + ? Math.round((new Date(state.completedAt).getTime() - new Date(state.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + + const statusLines: string[] = [ + 'Loop Status (Inactive)', + '', + `Name: ${state.worktreeName}`, + `Session: ${state.sessionId}`, + ] + if (!state.worktree) { + statusLines.push(`Mode: in-place | Directory: ${state.worktreeDir}`) + } else { + statusLines.push(`Worktree: ${state.worktreeDir}`) + } + statusLines.push( + `Iteration: ${maxInfo}`, + `Duration: ${durationStr}`, + `Reason: ${state.terminationReason ?? 'unknown'}`, + ) + if (state.worktreeBranch) { + statusLines.push(`Branch: ${state.worktreeBranch}`) + } + statusLines.push( + `Started: ${state.startedAt}`, + ...(state.completedAt ? [`Completed: ${state.completedAt}`] : []), + ) + + if (state.lastAuditResult) { + statusLines.push(...formatAuditResult(state.lastAuditResult)) + } + + const sessionOutput = state.worktreeDir ? await fetchSessionOutput(v2, state.sessionId, state.worktreeDir, logger) : null + if (sessionOutput) { + statusLines.push('') + statusLines.push('Session Output:') + statusLines.push(...formatSessionOutput(sessionOutput)) + } + + return statusLines.join('\n') + } + + const maxInfo = state.maxIterations && state.maxIterations > 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` + const promptPreview = state.prompt && state.prompt.length > 100 ? `${state.prompt.substring(0, 97)}...` : (state.prompt ?? '') + + let sessionStatus = 'unknown' + try { + const statusResult = await v2.session.status({ directory: state.worktreeDir }) + const statuses = statusResult.data as Record | undefined + const status = statuses?.[state.sessionId] + if (status) { + sessionStatus = status.type === 'retry' + ? `retry (attempt ${status.attempt}, next in ${Math.round(((status.next ?? 0) - Date.now()) / 1000)}s)` + : status.type + } + } catch { + sessionStatus = 'unavailable' + } + + const elapsed = state.startedAt ? Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000) : 0 + const minutes = Math.floor(elapsed / 60) + const seconds = elapsed % 60 + const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + + const stallInfo = loopHandler.getStallInfo(state.worktreeName) + const secondsSinceActivity = stallInfo + ? Math.round((Date.now() - stallInfo.lastActivityTime) / 1000) + : null + const stallCount = stallInfo?.consecutiveStalls ?? 0 + + const statusLines: string[] = [ + 'Loop Status', + '', + `Name: ${state.worktreeName}`, + `Session: ${state.sessionId}`, + ] + if (!state.worktree) { + statusLines.push(`Mode: in-place | Directory: ${state.worktreeDir}`) + } else { + statusLines.push(`Worktree: ${state.worktreeDir}`) + } + statusLines.push( + `Status: ${sessionStatus}`, + `Phase: ${state.phase}`, + `Iteration: ${maxInfo}`, + `Duration: ${duration}`, + `Audit: ${state.audit ? 'enabled' : 'disabled'}`, + ) + if (state.worktreeBranch) { + statusLines.push(`Branch: ${state.worktreeBranch}`) + } + + let sessionOutput: LoopSessionOutput | null = null + if (state.worktreeDir) { + try { + sessionOutput = await fetchSessionOutput(v2, state.sessionId, state.worktreeDir, logger) + } catch { + // Silently ignore fetch errors to avoid cluttering output + } + } + if (sessionOutput) { + statusLines.push('') + statusLines.push('Session Output:') + statusLines.push(...formatSessionOutput(sessionOutput)) + } + + if (state.lastAuditResult) { + statusLines.push(...formatAuditResult(state.lastAuditResult)) + } + + statusLines.push( + '', + `Completion promise: ${state.completionPromise ?? 'none'}`, + `Started: ${state.startedAt}`, + ...(state.errorCount && state.errorCount > 0 ? [`Error count: ${state.errorCount} (retries before termination: ${MAX_RETRIES})`] : []), + `Audit count: ${state.auditCount ?? 0}`, + `Model: ${config.loop?.model || config.executionModel || 'default'}`, + `Auditor model: ${config.auditorModel || 'default'}`, + ...(stallCount > 0 ? [`Stalls: ${stallCount}`] : []), + ...(secondsSinceActivity !== null ? [`Last activity: ${secondsSinceActivity}s ago`] : []), + '', + `Prompt: ${promptPreview}`, + ) + + return statusLines.join('\n') + }, + }), + } +} diff --git a/packages/memory/src/tools/memory.ts b/packages/memory/src/tools/memory.ts new file mode 100644 index 00000000..0444c05a --- /dev/null +++ b/packages/memory/src/tools/memory.ts @@ -0,0 +1,127 @@ +import { tool } from '@opencode-ai/plugin' +import type { ToolContext } from './types' +import { withDimensionWarning } from './types' + +const z = tool.schema +const scopeEnum = z.enum(['convention', 'decision', 'context']) + +interface MemoryResult { + id: number + projectId: string + scope: string + content: string + createdAt: number + deduplicated?: boolean +} + +export function createMemoryTools(ctx: ToolContext): Record> { + const { memoryService, projectId, logger, memoryInjection } = ctx + + return { + 'memory-read': tool({ + description: 'Search and retrieve project memories', + args: { + query: z.string().optional().describe('Semantic search query'), + scope: scopeEnum.optional().describe('Filter by scope'), + limit: z.number().optional().default(10).describe('Max results'), + }, + execute: async (args) => { + logger.log(`memory-read: query="${args.query ?? 'none'}", scope=${args.scope}, limit=${args.limit}`) + + let results: MemoryResult[] + if (args.query) { + const searchResults = await memoryService.search(args.query, projectId, { + scope: args.scope, + limit: args.limit, + }) + results = searchResults.map((r) => r.memory) + } else { + results = memoryService.listByProject(projectId, { + scope: args.scope, + limit: args.limit, + }) + } + + logger.log(`memory-read: returned ${results.length} results`) + if (results.length === 0) { + return withDimensionWarning(ctx.mismatchState, 'No memories found.') + } + + const formatted = results.map( + (m) => `[${m.id}] (${m.scope}) - Created ${new Date(m.createdAt).toISOString().split('T')[0]}\n${m.content}` + ) + return withDimensionWarning(ctx.mismatchState, `Found ${results.length} memories:\n\n${formatted.join('\n\n')}`) + }, + }), + + 'memory-write': tool({ + description: 'Store a new project memory', + args: { + content: z.string().describe('The memory content to store'), + scope: scopeEnum.describe('Memory scope category'), + }, + execute: async (args) => { + logger.log(`memory-write: scope=${args.scope}, content="${args.content?.substring(0, 80)}"`) + + const result = await memoryService.create({ + projectId, + scope: args.scope, + content: args.content, + }) + + logger.log(`memory-write: created id=${result.id}, deduplicated=${result.deduplicated}`) + await memoryInjection.clearCache() + return withDimensionWarning(ctx.mismatchState, `Memory stored (ID: #${result.id}, scope: ${args.scope}).${result.deduplicated ? ' (matched existing memory)' : ''}`) + }, + }), + + 'memory-edit': tool({ + description: 'Edit an existing project memory', + args: { + id: z.number().describe('The memory ID to edit'), + content: z.string().describe('The updated memory content'), + scope: scopeEnum.optional().describe('Change the scope category'), + }, + execute: async (args) => { + logger.log(`memory-edit: id=${args.id}, content="${args.content?.substring(0, 80)}"`) + + const memory = memoryService.getById(args.id) + if (!memory || memory.projectId !== projectId) { + logger.log(`memory-edit: id=${args.id} not found`) + return withDimensionWarning(ctx.mismatchState, `Memory #${args.id} not found.`) + } + + await memoryService.update(args.id, { + content: args.content, + ...(args.scope && { scope: args.scope }), + }) + + logger.log(`memory-edit: updated id=${args.id}`) + await memoryInjection.clearCache() + return withDimensionWarning(ctx.mismatchState, `Updated memory #${args.id} (scope: ${args.scope ?? memory.scope}).`) + }, + }), + + 'memory-delete': tool({ + description: 'Delete a project memory', + args: { + id: z.number().describe('The memory ID to delete'), + }, + execute: async (args) => { + const id = args.id + logger.log(`memory-delete: id=${id}`) + + const memory = memoryService.getById(id) + if (!memory || memory.projectId !== projectId) { + logger.log(`memory-delete: id=${id} not found`) + return withDimensionWarning(ctx.mismatchState, `Memory #${id} not found.`) + } + + await memoryService.delete(id) + await memoryInjection.clearCache() + logger.log(`memory-delete: deleted id=${id}`) + return withDimensionWarning(ctx.mismatchState, `Deleted memory #${id}: "${memory.content.substring(0, 50)}..." (${memory.scope})`) + }, + }), + } +} diff --git a/packages/memory/src/tools/plan-approval.ts b/packages/memory/src/tools/plan-approval.ts new file mode 100644 index 00000000..7fbbcefd --- /dev/null +++ b/packages/memory/src/tools/plan-approval.ts @@ -0,0 +1,100 @@ +import type { ToolContext } from './types' +import type { Hooks } from '@opencode-ai/plugin' + +const LOOP_BLOCKED_TOOLS: Record = { + question: 'The question tool is not available during a memory loop. Do not ask questions — continue working on the task autonomously.', + 'memory-plan-execute': 'The memory-plan-execute tool is not available during a memory loop. Focus on executing the current plan.', + 'memory-loop': 'The memory-loop tool is not available during a memory loop. Focus on executing the current plan.', +} + +const PLAN_APPROVAL_LABELS = ['New session', 'Execute here', 'Loop (worktree)', 'Loop'] + +const PLAN_APPROVAL_DIRECTIVES: Record = { + 'New session': ` +The user selected "New session". You MUST now call memory-plan-execute in this response with: +- plan: The FULL self-contained implementation plan (the code agent starts with zero context) +- title: A short descriptive title for the session +- worktree: true (or omit) +Do NOT output text without also making this tool call. +`, + 'Execute here': ` +The user selected "Execute here". You MUST now call memory-plan-execute in this response with: +- plan: "Execute the implementation plan from this conversation. Review all phases above and implement each one." +- title: A short descriptive title for the session +- worktree: false +Do NOT output text without also making this tool call. +`, + 'Loop (worktree)': ` +The user selected "Loop (worktree)". You MUST now call memory-loop in this response with: +- plan: The FULL self-contained implementation plan (runs in an isolated worktree with no prior context) +- title: A short descriptive title for the session +- worktree: true +Do NOT output text without also making this tool call. +`, + 'Loop': ` +The user selected "Loop". You MUST now call memory-loop in this response with: +- plan: The FULL self-contained implementation plan (runs in the current directory with no prior context) +- title: A short descriptive title for the session +- worktree: false +Do NOT output text without also making this tool call. +`, +} + +export { LOOP_BLOCKED_TOOLS, PLAN_APPROVAL_LABELS, PLAN_APPROVAL_DIRECTIVES } + +export function createToolExecuteBeforeHook(ctx: ToolContext): Hooks['tool.execute.before'] { + const { loopService, logger } = ctx + + return async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: unknown } + ) => { + const worktreeName = loopService.resolveWorktreeName(input.sessionID) + const state = worktreeName ? loopService.getActiveState(worktreeName) : null + if (!state?.active) return + + if (!(input.tool in LOOP_BLOCKED_TOOLS)) return + + logger.log(`Loop: blocking ${input.tool} tool before execution in ${state.phase} phase for session ${input.sessionID}`) + + throw new Error(LOOP_BLOCKED_TOOLS[input.tool]!) + } +} + +export function createToolExecuteAfterHook(ctx: ToolContext): Hooks['tool.execute.after'] { + const { loopService, logger } = ctx + + return async ( + input: { tool: string; sessionID: string; callID: string; args: unknown }, + output: { title: string; output: string; metadata: unknown } + ) => { + if (input.tool === 'question') { + const args = input.args as { questions?: Array<{ options?: Array<{ label: string }> }> } | undefined + const options = args?.questions?.[0]?.options + if (options) { + const labels = options.map((o) => o.label) + const isPlanApproval = PLAN_APPROVAL_LABELS.every((l) => labels.includes(l)) + if (isPlanApproval) { + const metadata = output.metadata as { answers?: string[][] } | undefined + const answer = metadata?.answers?.[0]?.[0]?.trim() ?? output.output.trim() + const matchedLabel = PLAN_APPROVAL_LABELS.find((l) => answer === l || answer.startsWith(l)) + const directive = matchedLabel ? PLAN_APPROVAL_DIRECTIVES[matchedLabel] : '\nThe user provided a custom response instead of selecting a predefined option. Review their answer and respond accordingly. If they want to proceed with execution, use the appropriate tool (memory-plan-execute or memory-loop) based on their intent. If they want to cancel or revise the plan, help them with that instead.\n' + output.output = `${output.output}\n\n${directive}` + logger.log(`Plan approval: detected "${matchedLabel ?? 'cancel/custom'}" answer, injected directive`) + } + } + return + } + + const worktreeName = loopService.resolveWorktreeName(input.sessionID) + const state = worktreeName ? loopService.getActiveState(worktreeName) : null + if (!state?.active) return + + if (!(input.tool in LOOP_BLOCKED_TOOLS)) return + + logger.log(`Loop: blocked ${input.tool} tool in ${state.phase} phase for session ${input.sessionID}`) + + output.title = 'Tool blocked' + output.output = LOOP_BLOCKED_TOOLS[input.tool]! + } +} diff --git a/packages/memory/src/tools/plan-execute.ts b/packages/memory/src/tools/plan-execute.ts new file mode 100644 index 00000000..4b1e8f37 --- /dev/null +++ b/packages/memory/src/tools/plan-execute.ts @@ -0,0 +1,107 @@ +import { tool } from '@opencode-ai/plugin' +import type { ToolContext } from './types' +import { parseModelString, retryWithModelFallback } from '../utils/model-fallback' +import { stripPromiseTags } from '../utils/strip-promise-tags' + +const z = tool.schema + +export function createPlanExecuteTools(ctx: ToolContext): Record> { + const { directory, config, logger, v2 } = ctx + + return { + 'memory-plan-execute': tool({ + description: 'Send the plan to the Code agent for execution. By default creates a new session. Set inPlace to true to switch to the code agent in the current session (plan is already in context).', + args: { + plan: z.string().describe('The full implementation plan to send to the Code agent'), + title: z.string().describe('Short title for the session (shown in session list)'), + inPlace: z.boolean().optional().default(false).describe('Execute in the current session, instead of creating a new session'), + }, + execute: async (args, context) => { + logger.log(`memory-plan-execute: ${args.inPlace ? 'switching to code agent' : 'creating session'} titled "${args.title}"`) + + const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title + const executionModel = parseModelString(config.executionModel) + + if (args.inPlace) { + const inPlacePrompt = `The architect agent has created an implementation plan in this conversation above. You are now the code agent taking over this session. Your job is to execute the plan — edit files, run commands, create tests, and implement every phase. Do NOT just describe or summarize the changes. Actually make them.\n\nPlan reference: ${args.plan}` + + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: context.sessionID, + directory, + agent: 'code', + parts: [{ type: 'text' as const, text: inPlacePrompt }], + ...(executionModel ? { model: executionModel } : {}), + }), + () => v2.session.promptAsync({ + sessionID: context.sessionID, + directory, + agent: 'code', + parts: [{ type: 'text' as const, text: inPlacePrompt }], + }), + executionModel, + logger, + ) + + if (promptResult.error) { + logger.error(`memory-plan-execute: in-place agent switch failed`, promptResult.error) + return `Failed to switch to code agent. Error: ${JSON.stringify(promptResult.error)}` + } + + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' + return `Switching to code agent for execution.\n\nTitle: ${sessionTitle}\nModel: ${modelInfo}\nAgent: code` + } + + const { cleaned: planText, stripped } = stripPromiseTags(args.plan) + if (stripped) { + logger.log(`memory-plan-execute: stripped tags from plan text`) + } + + const createResult = await v2.session.create({ + title: sessionTitle, + directory, + }) + + if (createResult.error || !createResult.data) { + logger.error(`memory-plan-execute: failed to create session`, createResult.error) + return 'Failed to create new session.' + } + + const newSessionId = createResult.data.id + logger.log(`memory-plan-execute: created session=${newSessionId}`) + + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory, + parts: [{ type: 'text' as const, text: planText }], + agent: 'code', + model: executionModel!, + }), + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory, + parts: [{ type: 'text' as const, text: planText }], + agent: 'code', + }), + executionModel, + logger, + ) + + if (promptResult.error) { + logger.error(`memory-plan-execute: failed to prompt session`, promptResult.error) + return `Session created (${newSessionId}) but failed to send plan. Switch to it and paste the plan manually.` + } + + logger.log(`memory-plan-execute: prompted session=${newSessionId}`) + + v2.tui.selectSession({ sessionID: newSessionId }).catch((err) => { + logger.error('memory-plan-execute: failed to navigate TUI to new session', err) + }) + + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' + return `Implementation session created and plan sent.\n\nSession: ${newSessionId}\nTitle: ${sessionTitle}\nModel: ${modelInfo}\n\nNavigated to the new session. You can change the model from the session dropdown.` + }, + }), + } +} diff --git a/packages/memory/src/tools/types.ts b/packages/memory/src/tools/types.ts new file mode 100644 index 00000000..0b7e9c87 --- /dev/null +++ b/packages/memory/src/tools/types.ts @@ -0,0 +1,53 @@ +import { tool } from '@opencode-ai/plugin' +import type { Database } from 'bun:sqlite' +import type { PluginConfig, Logger, MemoryScope } from '../types' +import type { EmbeddingProvider } from '../embedding' +import type { VecService } from '../storage/vec-types' +import type { MemoryService } from '../services/memory' +import type { createKvService } from '../services/kv' +import type { createLoopService } from '../services/loop' +import type { createLoopEventHandler } from '../hooks' +import type { createMemoryInjectionHook } from '../hooks' +import type { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' +import type { PluginInput } from '@opencode-ai/plugin' + +const z = tool.schema +export const scopeEnum = z.enum(['convention', 'decision', 'context']) as any + +export interface DimensionMismatchState { + detected: boolean + expected: number | null + actual: number | null +} + +export interface InitState { + vecReady: boolean + syncRunning: boolean + syncComplete: boolean +} + +export interface ToolContext { + projectId: string + directory: string + config: PluginConfig + logger: Logger + db: Database + provider: EmbeddingProvider + dataDir: string + memoryService: MemoryService + kvService: ReturnType + loopService: ReturnType + loopHandler: ReturnType + memoryInjection: ReturnType + v2: ReturnType + mismatchState: DimensionMismatchState + initState: InitState + getCurrentVec: () => VecService + cleanup: () => Promise + input: PluginInput +} + +export function withDimensionWarning(mismatchState: DimensionMismatchState, result: string): string { + if (!mismatchState.detected) return result + return `${result}\n\n---\nWarning: Embedding dimension mismatch detected (config: ${mismatchState.expected}d, database: ${mismatchState.actual}d). Semantic search is disabled.\n- If you changed your embedding model intentionally, run memory-health with action "reindex" to rebuild embeddings.\n- If this was accidental, revert your embedding config to match the existing model.` +} diff --git a/packages/memory/src/tui.tsx b/packages/memory/src/tui.tsx new file mode 100644 index 00000000..282fee54 --- /dev/null +++ b/packages/memory/src/tui.tsx @@ -0,0 +1,247 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui' +import { createMemo, createSignal, onCleanup, Show, For } from 'solid-js' +import { readFileSync, existsSync } from 'fs' +import { homedir, platform } from 'os' +import { join } from 'path' +import { execSync } from 'child_process' +import { Database } from 'bun:sqlite' +import { VERSION } from './version' +import { compareVersions } from './utils/upgrade' + +type TuiOptions = { + sidebar: boolean + showLoops: boolean + showVersion: boolean +} + +type TuiConfig = { + sidebar?: boolean + showLoops?: boolean + showVersion?: boolean +} + +type LoopInfo = { + name: string + phase: string + iteration: number + maxIterations: number + sessionId: string + active: boolean + startedAt?: string + completedAt?: string + terminationReason?: string + worktreeBranch?: string + worktree?: boolean + worktreeDir?: string +} + +function loadTuiConfig(): TuiConfig | undefined { + try { + const defaultBase = join(homedir(), platform() === 'win32' ? 'AppData' : '.config') + const configDir = process.env['XDG_CONFIG_HOME'] || defaultBase + const raw = readFileSync(join(configDir, 'opencode', 'memory-config.jsonc'), 'utf-8') + const stripped = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '') + const parsed = JSON.parse(stripped) + return parsed?.tui + } catch { + return undefined + } +} + +function resolveProjectId(directory: string): string | null { + const cachePath = join(directory, '.git', 'opencode') + if (existsSync(cachePath)) { + try { + const id = readFileSync(cachePath, 'utf-8').trim() + if (id) return id + } catch {} + } + try { + const output = execSync('git rev-list --max-parents=0 --all', { cwd: directory, encoding: 'utf-8' }).trim() + const commits = output.split('\n').filter(Boolean).sort() + if (commits[0]) return commits[0] + } catch {} + return null +} + +function readLoopStates(projectId: string): LoopInfo[] { + const defaultBase = join(homedir(), platform() === 'win32' ? 'AppData' : '.local', 'share') + const xdgDataHome = process.env['XDG_DATA_HOME'] || defaultBase + const dbPath = join(xdgDataHome, 'opencode', 'memory', 'memory.db') + + if (!existsSync(dbPath)) return [] + + let db: Database | null = null + try { + db = new Database(dbPath, { readonly: true }) + const now = Date.now() + const stmt = db.prepare('SELECT key, data FROM project_kv WHERE project_id = ? AND key LIKE ? AND expires_at > ?') + const rows = stmt.all(projectId, 'loop:%', now) as Array<{ key: string; data: string }> + + const loops: LoopInfo[] = [] + for (const row of rows) { + try { + const state = JSON.parse(row.data) + if (!state.worktreeName || !state.sessionId) continue + loops.push({ + name: state.worktreeName, + phase: state.phase ?? 'coding', + iteration: state.iteration ?? 0, + maxIterations: state.maxIterations ?? 0, + sessionId: state.sessionId, + active: state.active ?? false, + startedAt: state.startedAt, + completedAt: state.completedAt, + terminationReason: state.terminationReason, + worktreeBranch: state.worktreeBranch, + worktree: state.worktree, + worktreeDir: state.worktreeDir, + }) + } catch {} + } + return loops + } catch { + return [] + } finally { + try { db?.close() } catch {} + } +} + +function Sidebar(props: { api: TuiPluginApi; opts: TuiOptions }) { + const [open, setOpen] = createSignal(true) + const [loops, setLoops] = createSignal([]) + const theme = () => props.api.theme.current + const directory = props.api.state.path.directory + const pid = resolveProjectId(directory) + + const title = createMemo(() => { + return props.opts.showVersion ? `Memory v${VERSION}` : 'Memory' + }) + + const dot = (loop: LoopInfo) => { + if (!loop.active) { + if (loop.terminationReason === 'completed') return theme().success + if (loop.terminationReason === 'cancelled' || loop.terminationReason === 'user_aborted') return theme().textMuted + return theme().error + } + if (loop.phase === 'auditing') return theme().warning + return theme().success + } + + const statusText = (loop: LoopInfo) => { + const max = loop.maxIterations > 0 ? `/${loop.maxIterations}` : '' + if (loop.active) return `${loop.phase} · iter ${loop.iteration}${max}` + if (loop.terminationReason === 'completed') return `completed · ${loop.iteration} iter${loop.iteration !== 1 ? 's' : ''}` + return loop.terminationReason?.replace(/_/g, ' ') ?? 'ended' + } + + function refreshLoops() { + if (!pid) return + + const states = readLoopStates(pid) + const cutoff = Date.now() - 5 * 60 * 1000 + const visible = states.filter(l => + l.active || (l.completedAt && new Date(l.completedAt).getTime() > cutoff) + ) + visible.sort((a, b) => { + if (a.active && !b.active) return -1 + if (!a.active && b.active) return 1 + const aTime = a.completedAt ?? a.startedAt ?? '' + const bTime = b.completedAt ?? b.startedAt ?? '' + return bTime.localeCompare(aTime) + }) + setLoops(visible) + } + + const unsub = props.api.event.on('session.status', () => { + refreshLoops() + }) + + refreshLoops() + + onCleanup(() => { + unsub() + }) + + const hasContent = createMemo(() => { + if (props.opts.showLoops && loops().length > 0) return true + return false + }) + + const activeCount = createMemo(() => { + return loops().filter(l => l.active).length + }) + + return ( + + + hasContent() && setOpen((x) => !x)}> + + {open() ? '▼' : '▶'} + + + {title()} + 0}> + + {' '}({activeCount()} active) + + + + + 0}> + + {(loop) => ( + { + props.api.client.tui.selectSession({ + sessionID: loop.sessionId, + ...(loop.worktreeDir ? { directory: loop.worktreeDir } : {}), + }).catch(() => {}) + }} + > + + + {loop.name}{' '} + {statusText(loop)} + + + )} + + + + + ) +} + +const id = '@opencode-manager/memory' +const MIN_OPENCODE_VERSION = '1.3.5' + +const tui: TuiPlugin = async (api) => { + const v = api.app.version + if (v !== 'local' && compareVersions(v, MIN_OPENCODE_VERSION) < 0) return + + const tuiConfig = loadTuiConfig() + const opts: TuiOptions = { + sidebar: tuiConfig?.sidebar ?? true, + showLoops: tuiConfig?.showLoops ?? true, + showVersion: tuiConfig?.showVersion ?? true, + } + + if (!opts.sidebar) return + + api.slots.register({ + order: 150, + slots: { + sidebar_content() { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index 4fbbc035..546f0829 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -69,22 +69,6 @@ export interface LoopConfig { minAudits?: number } -export interface PluginConfig { - dataDir?: string - embedding: EmbeddingConfig - dedupThreshold?: number - logging?: LoggingConfig - compaction?: CompactionConfig - memoryInjection?: MemoryInjectionConfig - messagesTransform?: MessagesTransformConfig - executionModel?: string - auditorModel?: string - loop?: LoopConfig - /** @deprecated Use `loop` instead */ - ralph?: LoopConfig - defaultKvTtlMs?: number -} - export interface ListMemoriesFilter { scope?: MemoryScope limit?: number @@ -110,6 +94,34 @@ export interface MessagesTransformConfig { debug?: boolean } +export interface TuiConfig { + sidebar?: boolean + showLoops?: boolean + showVersion?: boolean +} + +export interface AgentOverrideConfig { + temperature?: number +} + +export interface PluginConfig { + dataDir?: string + embedding: EmbeddingConfig + dedupThreshold?: number + logging?: LoggingConfig + compaction?: CompactionConfig + memoryInjection?: MemoryInjectionConfig + messagesTransform?: MessagesTransformConfig + executionModel?: string + auditorModel?: string + loop?: LoopConfig + /** @deprecated Use `loop` instead */ + ralph?: LoopConfig + defaultKvTtlMs?: number + tui?: TuiConfig + agents?: Record +} + export interface HealthStatus { dbStatus: 'ok' | 'error' memoryCount: number diff --git a/packages/memory/src/utils/loop-format.ts b/packages/memory/src/utils/loop-format.ts index 015900dc..52e62367 100644 --- a/packages/memory/src/utils/loop-format.ts +++ b/packages/memory/src/utils/loop-format.ts @@ -7,6 +7,16 @@ export function formatTokens(n: number): string { export function formatSessionOutput(sessionOutput: LoopSessionOutput): string[] { const lines: string[] = [] + + if (sessionOutput.messages.length > 0) { + lines.push('Recent Activity:') + for (const msg of sessionOutput.messages) { + const preview = truncate(msg.text.replace(/\n/g, ' ').trim(), 1000) + lines.push(` [assistant] ${preview}`) + } + lines.push('') + } + const costStr = `$${sessionOutput.totalCost.toFixed(4)}` const t = sessionOutput.totalTokens const tokensStr = `${formatTokens(t.input)} in / ${formatTokens(t.output)} out / ${formatTokens(t.reasoning)} reasoning / ${formatTokens(t.cacheRead)} cache read / ${formatTokens(t.cacheWrite)} cache write` @@ -17,15 +27,6 @@ export function formatSessionOutput(sessionOutput: LoopSessionOutput): string[] lines.push(` Files changed: ${fc.files} (+${fc.additions}/-${fc.deletions} lines)`) } - if (sessionOutput.messages.length > 0) { - lines.push('') - lines.push('Recent Activity:') - for (const msg of sessionOutput.messages) { - const preview = truncate(msg.text.replace(/\n/g, ' ').trim(), 200) - lines.push(` [assistant] ${preview}`) - } - } - return lines } diff --git a/packages/memory/src/utils/upgrade.ts b/packages/memory/src/utils/upgrade.ts index 229a003f..ebeefe7c 100644 --- a/packages/memory/src/utils/upgrade.ts +++ b/packages/memory/src/utils/upgrade.ts @@ -46,7 +46,7 @@ export interface UpgradeCheckResult { updateAvailable: boolean } -function compareVersions(a: string, b: string): number { +export function compareVersions(a: string, b: string): number { const aParts = a.split('.').map(Number) const bParts = b.split('.').map(Number) for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { diff --git a/packages/memory/src/version.ts b/packages/memory/src/version.ts index 7cb5d48b..83bb8127 100644 --- a/packages/memory/src/version.ts +++ b/packages/memory/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.0.27' +export const VERSION = '0.0.28' diff --git a/packages/memory/test/hooks.test.ts b/packages/memory/test/hooks.test.ts index 876a461f..82da86ea 100644 --- a/packages/memory/test/hooks.test.ts +++ b/packages/memory/test/hooks.test.ts @@ -276,3 +276,52 @@ describe('SessionHooks', () => { expect(promptCalled).toBe(false) }) }) + +describe('MemoryInjectionHook', () => { + test('clearCache invalidates cached results', async () => { + const { createMemoryInjectionHook } = require('../src/hooks/memory-injection') + const { InMemoryCacheService } = require('../src/cache/memory-cache') + + const mockMemoryService = { + search: async () => [], + } as any + + const mockLogger = { + log: () => {}, + error: () => {}, + debug: () => {}, + } + + const config = { + enabled: true, + debug: false, + maxResults: 5, + distanceThreshold: 0.5, + maxTokens: 2000, + cacheTtlMs: 30000, + } + + const hook = createMemoryInjectionHook({ + projectId: 'test-project', + memoryService: mockMemoryService, + logger: mockLogger, + config, + }) + + let searchCallCount = 0 + mockMemoryService.search = async () => { + searchCallCount++ + return [] + } + + await hook.handler('test query') + expect(searchCallCount).toBe(1) + + await hook.clearCache() + + await hook.handler('test query') + expect(searchCallCount).toBe(2) + + hook.destroy() + }) +}) diff --git a/packages/memory/test/loop-format.test.ts b/packages/memory/test/loop-format.test.ts index 08aee788..0ef00433 100644 --- a/packages/memory/test/loop-format.test.ts +++ b/packages/memory/test/loop-format.test.ts @@ -167,7 +167,7 @@ describe('formatSessionOutput', () => { const lines = formatSessionOutput(sessionOutput) const messageLine = lines.find((line) => line.includes('[assistant]')) expect(messageLine).toBeDefined() - expect(messageLine!.length).toBeLessThanOrEqual(220) + expect(messageLine!.length).toBeLessThanOrEqual(1020) }) test('handles multiline messages', () => { diff --git a/packages/memory/test/loop.test.ts b/packages/memory/test/loop.test.ts index f68ecf74..06170101 100644 --- a/packages/memory/test/loop.test.ts +++ b/packages/memory/test/loop.test.ts @@ -54,10 +54,9 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -86,7 +85,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 0, completionPromise: null, @@ -109,23 +107,23 @@ describe('LoopService', () => { }) test('checkCompletionPromise matches exact promise', () => { - const text = 'Some response text DONE more text' - expect(loopService.checkCompletionPromise(text, 'DONE')).toBe(true) + const text = 'Some response text ALL_PHASES_COMPLETE more text' + expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(true) }) test('checkCompletionPromise returns false when no promise tags', () => { const text = 'Some response text without promise tags' - expect(loopService.checkCompletionPromise(text, 'DONE')).toBe(false) + expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) test('checkCompletionPromise returns false when promise does not match', () => { - const text = 'Some response NOT_DONE text' - expect(loopService.checkCompletionPromise(text, 'DONE')).toBe(false) + const text = 'Some response NOT_COMPLETE text' + expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE')).toBe(false) }) test('checkCompletionPromise handles whitespace normalization', () => { - const text = 'Response DONE WITH SPACES text' - expect(loopService.checkCompletionPromise(text, 'DONE WITH SPACES')).toBe(true) + const text = 'Response ALL_PHASES_COMPLETE WITH SPACES text' + expect(loopService.checkCompletionPromise(text, 'ALL_PHASES_COMPLETE WITH SPACES')).toBe(true) }) test('checkCompletionPromise matches first promise tag when multiple present', () => { @@ -146,7 +144,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 3, maxIterations: 0, completionPromise: null, @@ -170,7 +167,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 0, completionPromise: 'COMPLETE_TASK', @@ -183,7 +179,7 @@ describe('LoopService', () => { } const prompt = loopService.buildContinuationPrompt(state) - expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY when all requirements are met)]') + expect(prompt).toContain('[Loop iteration 1 | To stop: output COMPLETE_TASK (ONLY after all verification steps pass)]') }) test('buildContinuationPrompt includes max iterations when no promise', () => { @@ -193,7 +189,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 2, maxIterations: 10, completionPromise: null, @@ -216,7 +211,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 0, completionPromise: null, @@ -239,7 +233,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 5, maxIterations: 10, completionPromise: 'PERSIST_TEST', @@ -267,7 +260,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 0, completionPromise: null, @@ -293,7 +285,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 2, maxIterations: 0, completionPromise: null, @@ -321,7 +312,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 2, maxIterations: 0, completionPromise: null, @@ -346,10 +336,9 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 2, maxIterations: 0, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -371,7 +360,6 @@ describe('LoopService', () => { worktreeName: 'worktree-1', worktreeDir: '/path/to/worktree1', worktreeBranch: 'opencode/loop-worktree-1', - workspaceId: 'wrk-worktree-1', iteration: 1, maxIterations: 0, completionPromise: null, @@ -389,7 +377,6 @@ describe('LoopService', () => { worktreeName: 'worktree-2', worktreeDir: '/path/to/worktree2', worktreeBranch: 'opencode/loop-worktree-2', - workspaceId: 'loop-worktree-2', iteration: 2, maxIterations: 0, completionPromise: null, @@ -407,7 +394,6 @@ describe('LoopService', () => { worktreeName: 'worktree-3', worktreeDir: '/path/to/worktree3', worktreeBranch: 'opencode/loop-worktree-3', - workspaceId: 'loop-worktree-3', iteration: 1, maxIterations: 0, completionPromise: null, @@ -437,7 +423,6 @@ describe('LoopService', () => { worktreeName: 'unique-worktree-name', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-unique-worktree-name', - workspaceId: 'wrk-unique-worktree-name', iteration: 1, maxIterations: 0, completionPromise: null, @@ -465,10 +450,9 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -490,7 +474,6 @@ describe('LoopService', () => { worktreeName: 'test-worktree', worktreeDir: '/path/to/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 0, completionPromise: null, @@ -514,10 +497,9 @@ describe('LoopService', () => { worktreeName: 'inplace-worktree', worktreeDir: '/path/to/project', worktreeBranch: 'main', - workspaceId: '', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'In-place test prompt', phase: 'coding' as const, @@ -529,7 +511,6 @@ describe('LoopService', () => { loopService.setState('session-inplace', inPlaceState) const retrieved = loopService.getActiveState('session-inplace') expect(retrieved?.worktree).toBe(false) - expect(retrieved?.workspaceId).toBe('') expect(retrieved?.worktreeDir).toBe('/path/to/project') }) @@ -540,7 +521,6 @@ describe('LoopService', () => { worktreeName: 'unique-inplace-name', worktreeDir: '/path/to/project', worktreeBranch: 'develop', - workspaceId: '', iteration: 2, maxIterations: 0, completionPromise: null, @@ -565,7 +545,6 @@ describe('LoopService', () => { worktreeName: 'inplace-prompt-test', worktreeDir: '/path/to/project', worktreeBranch: 'main', - workspaceId: '', iteration: 3, maxIterations: 0, completionPromise: 'COMPLETE', @@ -590,7 +569,6 @@ describe('LoopService', () => { worktreeName: 'inplace-audit-test', worktreeDir: '/path/to/project', worktreeBranch: 'main', - workspaceId: '', iteration: 2, maxIterations: 0, completionPromise: null, @@ -702,7 +680,6 @@ describe('Stall Detection', () => { worktreeName, worktreeDir: '/tmp/test', worktreeBranch: 'main', - workspaceId: '', iteration: 1, maxIterations: 0, completionPromise: null, @@ -766,7 +743,6 @@ describe('Stall Detection', () => { worktreeName: 'test', worktreeDir: '/tmp/test', worktreeBranch: 'main', - workspaceId: '', iteration: 1, maxIterations: 0, completionPromise: null, @@ -839,7 +815,6 @@ describe('Stall Detection', () => { worktreeName: 'test', worktreeDir: '/tmp/test', worktreeBranch: 'main', - workspaceId: '', iteration: 1, maxIterations: 0, completionPromise: null, @@ -911,7 +886,6 @@ describe('Stall Detection', () => { worktreeName: 'test', worktreeDir: '/tmp/test', worktreeBranch: 'main', - workspaceId: '', iteration: 1, maxIterations: 0, completionPromise: null, @@ -935,8 +909,254 @@ describe('Minimum Audits', () => { test('getMinAudits returns configured value', () => { const db = createTestDb() const kvService = createKvService(db) - const loopService = createLoopService(kvService, 'test-project', createMockLogger(), { minAudits: 2 }) - expect(loopService.getMinAudits()).toBe(2) + const loopService = createLoopService(kvService, 'test-project', createMockLogger(), { minAudits: 3 }) + expect(loopService.getMinAudits()).toBe(3) + }) +}) + +describe('reconcileStale', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + test('marks active loops as shutdown', () => { + const state = { + active: true, + sessionId: 'session-stale', + worktreeName: 'stale-worktree', + worktreeDir: '/tmp/stale', + worktreeBranch: 'main', + iteration: 3, + maxIterations: 10, + completionPromise: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + auditCount: 0, + } + loopService.setState('stale-worktree', state) + expect(loopService.listActive()).toHaveLength(1) + + const count = loopService.reconcileStale() + expect(count).toBe(1) + expect(loopService.listActive()).toHaveLength(0) + + const recent = loopService.listRecent() + expect(recent).toHaveLength(1) + expect(recent[0].terminationReason).toBe('shutdown') + expect(recent[0].completedAt).toBeTruthy() + }) + + test('returns 0 when no stale loops exist', () => { + expect(loopService.reconcileStale()).toBe(0) + }) +}) + +describe('hasOutstandingFindings', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + test('returns false when no findings exist', () => { + expect(loopService.hasOutstandingFindings()).toBe(false) + }) + + test('returns true when findings exist', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'test-branch' }) + expect(loopService.hasOutstandingFindings()).toBe(true) + }) + + test('returns false after findings are deleted', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'test-branch' }) + expect(loopService.hasOutstandingFindings()).toBe(true) + kvService.delete(projectId, 'review-finding:src/index.ts:42') + expect(loopService.hasOutstandingFindings()).toBe(false) + }) + + test('getOutstandingFindings returns empty array when no findings exist', () => { + expect(loopService.getOutstandingFindings()).toEqual([]) + }) + + test('getOutstandingFindings returns entries when findings exist', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'test-branch' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'missing error handling', branch: 'test-branch' }) + const findings = loopService.getOutstandingFindings() + expect(findings).toHaveLength(2) + expect(findings.map((f) => f.key)).toContain('review-finding:src/index.ts:42') + expect(findings.map((f) => f.key)).toContain('review-finding:src/utils.ts:10') + }) + + test('returns false when findings exist on a different branch', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'other-branch' }) + expect(loopService.hasOutstandingFindings('feature/main')).toBe(false) + }) + + test('returns true only for findings on the specified branch', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'feature/main' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'bug', branch: 'other-branch' }) + expect(loopService.hasOutstandingFindings('feature/main')).toBe(true) + }) + + test('returns all findings when no branch specified', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'branch-a' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'bug', branch: 'branch-b' }) + expect(loopService.hasOutstandingFindings()).toBe(true) + }) + + test('getOutstandingFindings filters by branch', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'feature/main' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'bug', branch: 'other-branch' }) + const findings = loopService.getOutstandingFindings('feature/main') + expect(findings).toHaveLength(1) + expect(findings[0].key).toBe('review-finding:src/index.ts:42') + }) + + test('getOutstandingFindings returns all when no branch specified', () => { + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'branch-a' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'bug', branch: 'branch-b' }) + expect(loopService.getOutstandingFindings()).toHaveLength(2) + }) +}) + +describe('buildContinuationPrompt with outstanding findings', () => { + let db: Database + let kvService: ReturnType + let loopService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + loopService = createLoopService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + test('buildContinuationPrompt includes outstanding findings when present', () => { + const state = { + active: true, + sessionId: 'session-findings', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 3, + maxIterations: 0, + completionPromise: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + auditCount: 0, + } + + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'opencode/loop-test' }) + kvService.set(projectId, 'review-finding:src/utils.ts:10', { description: 'missing error handling', branch: 'opencode/loop-test' }) + + const prompt = loopService.buildContinuationPrompt(state) + expect(prompt).toContain('Outstanding Review Findings (2)') + expect(prompt).toContain('blocking loop completion') + expect(prompt).toContain('`review-finding:src/index.ts:42`') + expect(prompt).toContain('`review-finding:src/utils.ts:10`') + }) + + test('buildContinuationPrompt excludes findings section when no findings exist', () => { + const state = { + active: true, + sessionId: 'session-no-findings', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 2, + maxIterations: 0, + completionPromise: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + auditCount: 0, + } + + const prompt = loopService.buildContinuationPrompt(state) + expect(prompt).not.toContain('Outstanding Review Findings') + }) + + test('buildContinuationPrompt includes both audit findings and outstanding findings', () => { + const state = { + active: true, + sessionId: 'session-both', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 3, + maxIterations: 0, + completionPromise: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + auditCount: 0, + } + + kvService.set(projectId, 'review-finding:src/api.ts:8', { description: 'logic error', branch: 'opencode/loop-test' }) + + const prompt = loopService.buildContinuationPrompt(state, 'Found a bug in line 10') + expect(prompt).toContain('The code auditor reviewed your changes') + expect(prompt).toContain('Found a bug in line 10') + expect(prompt).toContain('Outstanding Review Findings (1)') + expect(prompt).toContain('`review-finding:src/api.ts:8`') + }) + + test('buildContinuationPrompt excludes findings from other branches', () => { + const state = { + active: true, + sessionId: 'session-branch-filter', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/loop-test', + iteration: 2, + maxIterations: 0, + completionPromise: 'ALL_PHASES_COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + auditCount: 0, + } + + kvService.set(projectId, 'review-finding:src/index.ts:42', { description: 'unused import', branch: 'other-branch' }) + + const prompt = loopService.buildContinuationPrompt(state) + expect(prompt).not.toContain('Outstanding Review Findings') }) }) @@ -995,7 +1215,6 @@ describe('session rotation', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1080,7 +1299,6 @@ describe('session rotation', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1147,7 +1365,6 @@ describe('session rotation', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1240,10 +1457,9 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -1315,7 +1531,6 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1380,7 +1595,6 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1450,7 +1664,6 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1528,7 +1741,6 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, completionPromise: null, @@ -1553,6 +1765,70 @@ describe('Assistant Error Detection', () => { expect(modelUsed).toBeUndefined() }) + test('modelFailed resets after successful iteration in coding phase', async () => { + const { createLoopEventHandler } = require('../src/hooks/loop') + const sessionId = 'model-reset-session' + + const mockClient = { + session: { + promptAsync: async () => ({ data: undefined, error: undefined }), + create: async () => ({ data: { id: sessionId }, error: undefined }), + messages: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async () => ({ data: undefined, error: undefined }), + }, + worktree: { + create: async () => ({ data: { id: 'wt-1', directory: '/tmp/wt', branch: 'main' }, error: undefined }), + remove: async () => ({ data: undefined, error: undefined }), + }, + } as any + + const mockV2Client = { + session: { + create: async () => ({ data: { id: sessionId }, error: undefined }), + delete: async () => ({ data: undefined, error: undefined }), + promptAsync: async () => ({ data: undefined, error: undefined }), + messages: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async () => ({ data: undefined, error: undefined }), + }, + } as any + + const mockGetConfig = () => ({ loop: {}, executionModel: undefined, auditorModel: undefined }) + const handler = createLoopEventHandler(loopService, mockClient, mockV2Client, createMockLogger(), mockGetConfig) + + const state = { + active: true, + sessionId, + worktreeName: 'model-reset-test', + worktreeDir: '/tmp/model-reset', + worktreeBranch: 'main', + iteration: 2, + maxIterations: 10, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 1, + auditCount: 0, + modelFailed: true, + } + + loopService.setState('model-reset-test', state) + loopService.registerSession(sessionId, 'model-reset-test') + + await handler.onEvent({ + event: { + type: 'session.idle', + properties: { sessionID: sessionId }, + }, + }) + + const updatedState = loopService.getActiveState('model-reset-test') + expect(updatedState?.modelFailed).toBe(false) + }) + test('three consecutive errors terminate loop', async () => { const { createLoopEventHandler } = require('../src/hooks/loop') const sessionId = 'three-errors-session' @@ -1601,10 +1877,9 @@ describe('Assistant Error Detection', () => { worktreeName: 'test-worktree', worktreeDir: '/tmp/test-worktree', worktreeBranch: 'main', - workspaceId: 'wrk-test', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/packages/memory/test/memory-service.test.ts b/packages/memory/test/memory-service.test.ts index ac9134a4..893c4c90 100644 --- a/packages/memory/test/memory-service.test.ts +++ b/packages/memory/test/memory-service.test.ts @@ -192,7 +192,9 @@ describe('MemoryService', () => { const results = await memoryService.search('React UI framework', TEST_PROJECT_ID) expect(results.length).toBeGreaterThan(0) - expect(results[0]?.memory.scope).toBe('context') + const reactMemory = results.find(r => r.memory.content.includes('React')) + expect(reactMemory).toBeDefined() + expect(reactMemory?.memory.scope).toBe('context') }) test('stats — verify counts by scope', async () => { diff --git a/packages/memory/test/plan-approval.test.ts b/packages/memory/test/plan-approval.test.ts index 25833165..3715fae7 100644 --- a/packages/memory/test/plan-approval.test.ts +++ b/packages/memory/test/plan-approval.test.ts @@ -95,10 +95,9 @@ Do NOT output text without also making this tool call. worktreeName: 'test-worktree', worktreeDir: '/test/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/packages/memory/test/strip-promise-tags.test.ts b/packages/memory/test/strip-promise-tags.test.ts index 3c1585c8..7a8efc6e 100644 --- a/packages/memory/test/strip-promise-tags.test.ts +++ b/packages/memory/test/strip-promise-tags.test.ts @@ -39,7 +39,7 @@ Do something ## Phase 2 Do something else -DONE` +ALL_PHASES_COMPLETE` const { cleaned, stripped } = stripPromiseTags(plan) expect(cleaned).toContain('## Phase 1') expect(cleaned).toContain('## Phase 2') diff --git a/packages/memory/test/tool-blocking.test.ts b/packages/memory/test/tool-blocking.test.ts index dba62aa6..391d4144 100644 --- a/packages/memory/test/tool-blocking.test.ts +++ b/packages/memory/test/tool-blocking.test.ts @@ -55,10 +55,9 @@ describe('Tool Blocking Logic', () => { worktreeName: 'test-worktree', worktreeDir: '/test/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, @@ -86,10 +85,9 @@ describe('Tool Blocking Logic', () => { worktreeName: 'test-worktree', worktreeDir: '/test/worktree', worktreeBranch: 'opencode/loop-test', - workspaceId: 'wrk-test-worktree', iteration: 1, maxIterations: 5, - completionPromise: 'DONE', + completionPromise: 'ALL_PHASES_COMPLETE', startedAt: new Date().toISOString(), prompt: 'Test prompt', phase: 'coding' as const, diff --git a/packages/memory/tsconfig.build.json b/packages/memory/tsconfig.build.json index 11959def..cb369e8b 100644 --- a/packages/memory/tsconfig.build.json +++ b/packages/memory/tsconfig.build.json @@ -8,6 +8,6 @@ "sourceMap": true, "types": ["bun-types"] }, - "include": ["src/**/*"], - "exclude": ["test/**/*", "node_modules", "dist"] + "include": ["src/**/*.ts"], + "exclude": ["test/**/*", "node_modules", "dist", "src/tui.tsx"] } diff --git a/packages/memory/tsconfig.json b/packages/memory/tsconfig.json index 46a8ee8d..7ab45d39 100644 --- a/packages/memory/tsconfig.json +++ b/packages/memory/tsconfig.json @@ -11,6 +11,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "@opentui/solid", "types": ["bun-types"] }, "include": ["src/**/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e40396d1..3b052345 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@hono/node-server': specifier: ^1.19.5 version: 1.19.7(hono@4.11.7) @@ -35,7 +35,7 @@ importers: version: 7.0.1 better-auth: specifier: ^1.4.17 - version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4) croner: specifier: ^10.0.1 version: 10.0.1 @@ -99,7 +99,7 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.4.17 - version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) + version: 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -153,7 +153,7 @@ importers: version: 5.90.16(react@19.2.3) better-auth: specifier: ^1.4.17 - version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + version: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -224,6 +224,9 @@ importers: '@eslint/js': specifier: ^9.36.0 version: 9.39.2 + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -285,47 +288,6 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) - packages/memory: - dependencies: - '@huggingface/transformers': - specifier: ^3.8.1 - version: 3.8.1 - '@opencode-ai/plugin': - specifier: ^1.2.16 - version: 1.2.16 - '@opencode-ai/sdk': - specifier: ^1.2.26 - version: 1.2.26 - jsonc-parser: - specifier: ^3.3.1 - version: 3.3.1 - sqlite-vec: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - devDependencies: - bun-types: - specifier: latest - version: 1.3.10 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - optionalDependencies: - sqlite-vec-darwin-arm64: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - sqlite-vec-darwin-x64: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - sqlite-vec-linux-arm64: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - sqlite-vec-linux-x64: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - sqlite-vec-windows-x64: - specifier: 0.1.7-alpha.2 - version: 0.1.7-alpha.2 - shared: dependencies: jsonc-parser: @@ -565,9 +527,6 @@ packages: peerDependencies: react: '>=16.8.0' - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -800,13 +759,6 @@ packages: peerDependencies: react-hook-form: ^7.55.0 - '@huggingface/jinja@0.5.5': - resolution: {integrity: sha512-xRlzazC+QZwr6z4ixEqYHo9fgwhTZ3xNSdljlKfUFGZSdlvt166DljRELFUfFytlYOYvo3vTisA/AFOuOAzFQQ==} - engines: {node: '>=18'} - - '@huggingface/transformers@3.8.1': - resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} - '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -829,151 +781,10 @@ packages: '@iconify/utils@3.1.0': resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -1021,15 +832,6 @@ packages: resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} engines: {node: '>= 20.19.0'} - '@opencode-ai/plugin@1.2.16': - resolution: {integrity: sha512-9Kb7BQIC2P3oKCvI8K3thP5YP0vE7yLvcmBmgyACUIqc3e5UL6U+4umLpTvgQa2eQdjxtOXznuGTNwgcGMHUHg==} - - '@opencode-ai/sdk@1.2.16': - resolution: {integrity: sha512-y9ae9VnCcuog0GaI4DveX1HB6DBoZgGN3EuJVlRFbBCPwhzkls6fCfHSb5+VnTS6Fy0OWFUL28VBCmixL/D+/Q==} - - '@opencode-ai/sdk@1.2.26': - resolution: {integrity: sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==} - '@peculiar/asn1-android@2.6.0': resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} @@ -1074,36 +876,6 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.4': - resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} - - '@protobufjs/eventemitter@1.1.0': - resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} - - '@protobufjs/fetch@1.1.0': - resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/inquire@1.1.0': - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.0': - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2259,10 +2031,6 @@ packages: bn.js@4.12.2: resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==} - boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2343,10 +2111,6 @@ packages: chevrotain@11.0.3: resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2642,14 +2406,6 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -2667,9 +2423,6 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2716,20 +2469,9 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -2871,9 +2613,6 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatbuffers@25.9.23: - resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} - flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -2909,10 +2648,6 @@ packages: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} hasBin: true - global-agent@3.0.0: - resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} - engines: {node: '>=10.0'} - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -2921,20 +2656,9 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - guid-typescript@1.0.9: - resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -2942,9 +2666,6 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} @@ -3159,9 +2880,6 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3294,9 +3012,6 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3348,10 +3063,6 @@ packages: engines: {node: '>= 20'} hasBin: true - matcher@3.0.0: - resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} - engines: {node: '>=10'} - mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -3516,10 +3227,6 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} - minizlib@3.1.0: - resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} - engines: {node: '>= 18'} - mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -3552,23 +3259,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - onnxruntime-common@1.21.0: - resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} - - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: - resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==} - - onnxruntime-node@1.21.0: - resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} - os: [win32, darwin, linux] - - onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: - resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} - openapi-typescript@7.10.1: resolution: {integrity: sha512-rBcU8bjKGGZQT4K2ekSTY2Q5veOQbVG/lTKZ49DeCyT9z62hM2Vj/LLHjDHC9W7LJG8YMHcdXpRZDqC1ojB/lw==} hasBin: true @@ -3642,9 +3332,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - platform@1.3.6: - resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3680,10 +3367,6 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} - protobufjs@7.5.4: - resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} - engines: {node: '>=12.0.0'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3817,10 +3500,6 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - roarr@2.15.4: - resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} - engines: {node: '>=8.0'} - robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -3857,9 +3536,6 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - semver-compare@1.0.0: - resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3869,17 +3545,19 @@ packages: engines: {node: '>=10'} hasBin: true - serialize-error@7.0.1: - resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + seroval-plugins@1.5.1: + resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.1: + resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3903,6 +3581,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -3923,37 +3604,6 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - - sqlite-vec-darwin-arm64@0.1.7-alpha.2: - resolution: {integrity: sha512-raIATOqFYkeCHhb/t3r7W7Cf2lVYdf4J3ogJ6GFc8PQEgHCPEsi+bYnm2JT84MzLfTlSTIdxr4/NKv+zF7oLPw==} - cpu: [arm64] - os: [darwin] - - sqlite-vec-darwin-x64@0.1.7-alpha.2: - resolution: {integrity: sha512-jeZEELsQjjRsVojsvU5iKxOvkaVuE+JYC8Y4Ma8U45aAERrDYmqZoHvgSG7cg1PXL3bMlumFTAmHynf1y4pOzA==} - cpu: [x64] - os: [darwin] - - sqlite-vec-linux-arm64@0.1.7-alpha.2: - resolution: {integrity: sha512-6Spj4Nfi7tG13jsUG+W7jnT0bCTWbyPImu2M8nWp20fNrd1SZ4g3CSlDAK8GBdavX7wRlbBHCZ+BDa++rbDewA==} - cpu: [arm64] - os: [linux] - - sqlite-vec-linux-x64@0.1.7-alpha.2: - resolution: {integrity: sha512-IcgrbHaDccTVhXDf8Orwdc2+hgDLAFORl6OBUhcvlmwswwBP1hqBTSEhovClG4NItwTOBNgpwOoQ7Qp3VDPWLg==} - cpu: [x64] - os: [linux] - - sqlite-vec-windows-x64@0.1.7-alpha.2: - resolution: {integrity: sha512-TRP6hTjAcwvQ6xpCZvjP00pdlda8J38ArFy1lMYhtQWXiIBmWnhMaMbq4kaeCYwvTTddfidatRS+TJrwIKB/oQ==} - cpu: [x64] - os: [win32] - - sqlite-vec@0.1.7-alpha.2: - resolution: {integrity: sha512-rNgRCv+4V4Ed3yc33Qr+nNmjhtrMnnHzXfLVPeGb28Dx5mmDL3Ngw/Wk8vhCGjj76+oC6gnkmMG8y73BZWGBwQ==} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4039,10 +3689,6 @@ packages: tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - tar@7.5.9: - resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} - engines: {node: '>=18'} - terser@5.46.0: resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} engines: {node: '>=10'} @@ -4134,10 +3780,6 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - type-fest@0.13.1: - resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} - engines: {node: '>=10'} - type-fest@4.41.0: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} @@ -4391,10 +4033,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -4414,9 +4052,6 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} - zod@4.1.8: - resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} - zod@4.3.2: resolution: {integrity: sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==} @@ -4611,14 +4246,14 @@ snapshots: nanostores: 1.1.0 zod: 4.3.5 - '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': + '@better-auth/passkey@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4))(better-call@1.1.8(zod@4.3.2))(nanostores@1.1.0)': dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@simplewebauthn/browser': 13.2.2 '@simplewebauthn/server': 13.2.2 - better-auth: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4) + better-auth: 1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4) better-call: 1.1.8(zod@4.3.2) nanostores: 1.1.0 zod: 4.3.5 @@ -4699,11 +4334,6 @@ snapshots: react: 19.2.3 tslib: 2.8.1 - '@emnapi/runtime@1.8.1': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.27.2': optional: true @@ -4858,15 +4488,6 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.69.0(react@19.2.3) - '@huggingface/jinja@0.5.5': {} - - '@huggingface/transformers@3.8.1': - dependencies: - '@huggingface/jinja': 0.5.5 - onnxruntime-node: 1.21.0 - onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 - sharp: 0.34.5 - '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -4886,102 +4507,6 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.0 - '@img/colour@1.0.0': {} - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.8.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -4991,10 +4516,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.2 - '@istanbuljs/schema@0.1.3': {} '@jridgewell/gen-mapping@0.3.13': @@ -5043,15 +4564,6 @@ snapshots: '@noble/hashes@2.0.1': {} - '@opencode-ai/plugin@1.2.16': - dependencies: - '@opencode-ai/sdk': 1.2.16 - zod: 4.1.8 - - '@opencode-ai/sdk@1.2.16': {} - - '@opencode-ai/sdk@1.2.26': {} - '@peculiar/asn1-android@2.6.0': dependencies: '@peculiar/asn1-schema': 2.6.0 @@ -5153,29 +4665,6 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.4': {} - - '@protobufjs/eventemitter@1.1.0': {} - - '@protobufjs/fetch@1.1.0': - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/inquire': 1.1.0 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/inquire@1.1.0': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.0': {} - '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -6297,7 +5786,7 @@ snapshots: baseline-browser-mapping@2.9.11: {} - better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4): + better-auth@1.4.17(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.12)(vitest@3.2.4): dependencies: '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.2))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) @@ -6314,6 +5803,7 @@ snapshots: optionalDependencies: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + solid-js: 1.9.12 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(terser@5.46.0) better-call@1.1.8(zod@4.3.2): @@ -6340,8 +5830,6 @@ snapshots: bn.js@4.12.2: {} - boolean@3.2.0: {} - brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6426,8 +5914,6 @@ snapshots: '@chevrotain/utils': 11.0.3 lodash-es: 4.17.21 - chownr@3.0.0: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -6746,18 +6232,6 @@ snapshots: deep-is@0.1.4: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - defu@6.1.4: {} delaunator@5.0.1: @@ -6770,8 +6244,6 @@ snapshots: detect-node-es@1.1.0: {} - detect-node@2.1.0: {} - devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -6811,14 +6283,8 @@ snapshots: entities@6.0.1: {} - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} - es6-error@4.1.1: {} - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -6986,8 +6452,6 @@ snapshots: flatted: 3.3.3 keyv: 4.5.4 - flatbuffers@25.9.23: {} - flatted@3.3.3: {} foreground-child@3.3.1: @@ -7019,38 +6483,16 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - global-agent@3.0.0: - dependencies: - boolean: 3.2.0 - es6-error: 4.1.1 - matcher: 3.0.0 - roarr: 2.15.4 - semver: 7.7.3 - serialize-error: 7.0.1 - globals@14.0.0: {} globals@16.5.0: {} - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - graceful-fs@4.2.11: {} - guid-typescript@1.0.9: {} - hachure-fill@0.5.2: {} has-flag@4.0.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.4 @@ -7306,8 +6748,6 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json-stringify-safe@5.0.1: {} - json5@2.2.3: {} jsonc-parser@3.3.1: {} @@ -7417,8 +6857,6 @@ snapshots: lodash@4.17.21: {} - long@5.3.2: {} - longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -7463,10 +6901,6 @@ snapshots: marked@16.4.2: {} - matcher@3.0.0: - dependencies: - escape-string-regexp: 4.0.0 - mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -7860,10 +7294,6 @@ snapshots: minipass@7.1.2: {} - minizlib@3.1.0: - dependencies: - minipass: 7.1.2 - mlly@1.8.0: dependencies: acorn: 8.15.0 @@ -7890,27 +7320,6 @@ snapshots: normalize-path@3.0.0: {} - object-keys@1.1.1: {} - - onnxruntime-common@1.21.0: {} - - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} - - onnxruntime-node@1.21.0: - dependencies: - global-agent: 3.0.0 - onnxruntime-common: 1.21.0 - tar: 7.5.9 - - onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: - dependencies: - flatbuffers: 25.9.23 - guid-typescript: 1.0.9 - long: 5.3.2 - onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4 - platform: 1.3.6 - protobufjs: 7.5.4 - openapi-typescript@7.10.1(typescript@5.9.3): dependencies: '@redocly/openapi-core': 1.34.6(supports-color@10.2.2) @@ -7995,8 +7404,6 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - platform@1.3.6: {} - pluralize@8.0.0: {} points-on-curve@0.2.0: {} @@ -8028,21 +7435,6 @@ snapshots: property-information@7.1.0: {} - protobufjs@7.5.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.4 - '@protobufjs/eventemitter': 1.1.0 - '@protobufjs/fetch': 1.1.0 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.0 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.4 - long: 5.3.2 - punycode@2.3.1: {} pvtsutils@1.3.6: @@ -8208,15 +7600,6 @@ snapshots: resolve-from@4.0.0: {} - roarr@2.15.4: - dependencies: - boolean: 3.2.0 - detect-node: 2.1.0 - globalthis: 1.0.4 - json-stringify-safe: 5.0.1 - semver-compare: 1.0.0 - sprintf-js: 1.1.3 - robust-predicates@3.0.2: {} rollup@4.54.0: @@ -8274,48 +7657,19 @@ snapshots: scheduler@0.27.0: {} - semver-compare@1.0.0: {} - semver@6.3.1: {} semver@7.7.3: {} - serialize-error@7.0.1: + seroval-plugins@1.5.1(seroval@1.5.1): dependencies: - type-fest: 0.13.1 + seroval: 1.5.1 + optional: true - set-cookie-parser@2.7.2: {} + seroval@1.5.1: + optional: true - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.3 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 + set-cookie-parser@2.7.2: {} shebang-command@2.0.0: dependencies: @@ -8335,6 +7689,13 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.1 + seroval-plugins: 1.5.1(seroval@1.5.1) + optional: true + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: react: 19.2.3 @@ -8353,31 +7714,6 @@ snapshots: space-separated-tokens@2.0.2: {} - sprintf-js@1.1.3: {} - - sqlite-vec-darwin-arm64@0.1.7-alpha.2: - optional: true - - sqlite-vec-darwin-x64@0.1.7-alpha.2: - optional: true - - sqlite-vec-linux-arm64@0.1.7-alpha.2: - optional: true - - sqlite-vec-linux-x64@0.1.7-alpha.2: - optional: true - - sqlite-vec-windows-x64@0.1.7-alpha.2: - optional: true - - sqlite-vec@0.1.7-alpha.2: - optionalDependencies: - sqlite-vec-darwin-arm64: 0.1.7-alpha.2 - sqlite-vec-darwin-x64: 0.1.7-alpha.2 - sqlite-vec-linux-arm64: 0.1.7-alpha.2 - sqlite-vec-linux-x64: 0.1.7-alpha.2 - sqlite-vec-windows-x64: 0.1.7-alpha.2 - stackback@0.0.2: {} state-local@1.0.7: {} @@ -8473,14 +7809,6 @@ snapshots: - bare-abort-controller - react-native-b4a - tar@7.5.9: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.2 - minizlib: 3.1.0 - yallist: 5.0.0 - terser@5.46.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -8558,8 +7886,6 @@ snapshots: dependencies: prelude-ls: 1.2.1 - type-fest@0.13.1: {} - type-fest@4.41.0: {} typescript-eslint@8.51.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): @@ -8816,8 +8142,6 @@ snapshots: yallist@3.1.1: {} - yallist@5.0.0: {} - yaml-ast-parser@0.0.43: {} yargs-parser@21.1.1: {} @@ -8840,8 +8164,6 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod@4.1.8: {} - zod@4.3.2: {} zod@4.3.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dee67c14..9444fd49 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,5 @@ packages: - 'shared' - 'backend' - 'frontend' - - 'packages/memory' - - '!workspace/**' \ No newline at end of file + - '!workspace/**' + diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index d3fca942..8e7957d1 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -59,10 +59,18 @@ fi echo "🔍 Checking memory plugin..." -if [ -d "$NODE_PATH/@opencode-manager/memory" ]; then - echo "✅ Memory plugin found at $NODE_PATH/@opencode-manager/memory" +if [ "$INSTALL_MEMORY_PLUGIN" = "false" ] || [ "$INSTALL_MEMORY_PLUGIN" = "0" ]; then + echo "⏭️ Memory plugin install disabled (INSTALL_MEMORY_PLUGIN=$INSTALL_MEMORY_PLUGIN)" +elif [ -d "$NODE_PATH/@opencode-manager/memory" ]; then + echo "✅ Memory plugin found at $NODE_PATH/@opencode-manager/memory" else - echo "⚠️ Memory plugin not found at $NODE_PATH/@opencode-manager/memory" + echo "📦 Installing memory plugin..." + npm install --global-style --prefix /opt/opencode-plugins @opencode-manager/memory@latest 2>&1 || true + if [ -d "$NODE_PATH/@opencode-manager/memory" ]; then + echo "✅ Memory plugin installed successfully" + else + echo "⚠️ Memory plugin installation failed" + fi fi echo "🚀 Starting OpenCode Manager Backend..." diff --git a/shared/src/schemas/memory.ts b/shared/src/schemas/memory.ts index e3f31288..525bc053 100644 --- a/shared/src/schemas/memory.ts +++ b/shared/src/schemas/memory.ts @@ -86,6 +86,18 @@ export const MessagesTransformConfigSchema = z.object({ }) export type MessagesTransformConfig = z.infer +export const TuiConfigSchema = z.object({ + sidebar: z.boolean().optional(), + showLoops: z.boolean().optional(), + showVersion: z.boolean().optional(), +}) +export type TuiConfig = z.infer + +export const AgentOverrideConfigSchema = z.object({ + temperature: z.number().min(0).max(2).optional(), +}) +export type AgentOverrideConfig = z.infer + export const LoopConfigSchema = z.object({ enabled: z.boolean().optional(), defaultMaxIterations: z.number().optional(), @@ -109,6 +121,8 @@ export const PluginConfigSchema = z.object({ auditorModel: z.string().optional(), loop: LoopConfigSchema.optional(), ralph: LoopConfigSchema.optional(), + tui: TuiConfigSchema.optional(), + agents: z.record(z.string(), AgentOverrideConfigSchema).optional(), }) export type PluginConfig = z.infer @@ -147,7 +161,6 @@ export const LoopStateSchema = z.object({ worktreeName: z.string(), worktreeDir: z.string(), worktreeBranch: z.string().optional(), - workspaceId: z.string().optional(), iteration: z.number(), maxIterations: z.number(), completionPromise: z.string().nullable().optional(),