From 09bdd2665816a7e5275db291769391628ed6414b Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 2 May 2026 21:38:30 -0400 Subject: [PATCH] loop: remove-memory-plugin-1 completed after 1 iterations --- README.md | 1 - backend/src/index.ts | 2 - backend/src/routes/memory.ts | 714 ------------------ backend/src/routes/settings.ts | 13 - backend/src/services/plugin-memory.ts | 334 -------- backend/test/routes/memory.test.ts | 261 ------- docs/configuration/docker.md | 1 - docs/features/memory.md | 74 -- docs/features/overview.md | 9 - docs/index.md | 9 +- frontend/src/App.tsx | 6 - frontend/src/api/memory.ts | 136 ---- frontend/src/api/settings.ts | 4 - frontend/src/api/types/settings.ts | 1 - .../src/components/memory/KvFormDialog.tsx | 186 ----- frontend/src/components/memory/KvList.tsx | 182 ----- .../components/memory/MemoryFormDialog.tsx | 147 ---- frontend/src/components/memory/MemoryList.tsx | 146 ---- .../src/components/message/PromptInput.tsx | 4 +- .../navigation/DesktopSidebar.test.tsx | 12 - .../components/navigation/DesktopSidebar.tsx | 4 +- .../navigation/MobileTabBar.test.tsx | 16 - .../components/navigation/MobileTabBar.tsx | 1 - .../components/navigation/MoreDrawer.test.tsx | 3 - .../src/components/navigation/MoreDrawer.tsx | 4 +- .../navigation/moreDrawerItems.test.ts | 105 +-- .../components/navigation/moreDrawerItems.ts | 33 +- frontend/src/components/repo/RepoLoopList.tsx | 100 --- .../settings/MemoryPluginConfig.tsx | 611 --------------- frontend/src/hooks/useLoopStatus.ts | 48 -- frontend/src/hooks/useMemories.ts | 156 ---- frontend/src/hooks/useMemoryPluginStatus.ts | 12 - frontend/src/hooks/useOpenCode.ts | 6 - frontend/src/lib/navigation.test.ts | 7 - frontend/src/lib/navigation.ts | 5 - frontend/src/pages/Memories.tsx | 164 ---- frontend/src/pages/SessionDetail.tsx | 1 - mkdocs.yml | 1 - shared/src/schemas/index.ts | 1 - shared/src/schemas/memory.ts | 166 ---- shared/src/types/index.ts | 21 - 41 files changed, 43 insertions(+), 3664 deletions(-) delete mode 100644 backend/src/routes/memory.ts delete mode 100644 backend/src/services/plugin-memory.ts delete mode 100644 backend/test/routes/memory.test.ts delete mode 100644 docs/features/memory.md delete mode 100644 frontend/src/api/memory.ts delete mode 100644 frontend/src/components/memory/KvFormDialog.tsx delete mode 100644 frontend/src/components/memory/KvList.tsx delete mode 100644 frontend/src/components/memory/MemoryFormDialog.tsx delete mode 100644 frontend/src/components/memory/MemoryList.tsx delete mode 100644 frontend/src/components/repo/RepoLoopList.tsx delete mode 100644 frontend/src/components/settings/MemoryPluginConfig.tsx delete mode 100644 frontend/src/hooks/useLoopStatus.ts delete mode 100644 frontend/src/hooks/useMemories.ts delete mode 100644 frontend/src/hooks/useMemoryPluginStatus.ts delete mode 100644 frontend/src/pages/Memories.tsx delete mode 100644 shared/src/schemas/memory.ts diff --git a/README.md b/README.md index 4172ae56..7c7846fc 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ For local development setup, see the [Development Guide](https://chriswritescode - **Files** — Directory browser with tree view, syntax highlighting, create/rename/delete, ZIP download - **Schedules** — Recurring repo jobs with reusable prompts, run history, linked sessions, markdown-rendered output - **AI & OpenCode** — Model/provider configuration, OAuth for Anthropic/GitHub Copilot, custom agents, OpenCode server supervision and proxying -- **MCP, Skills, Memory** — MCP server management, skill support, optional memory plugin with semantic search ([plugin repo](https://github.com/chriswritescode-dev/opencode-memory)) - **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) - **Mobile & Notifications** — Responsive PWA, mobile-first navigation, push notification support diff --git a/backend/src/index.ts b/backend/src/index.ts index 7a01b0c8..fc8911cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -29,7 +29,6 @@ import { createOAuthRoutes } from './routes/oauth' import { createSSERoutes } from './routes/sse' import { createSSHRoutes } from './routes/ssh' import { createNotificationRoutes } from './routes/notifications' -import { createMemoryRoutes } from './routes/memory' import { createMcpOauthProxyRoutes } from './routes/mcp-oauth-proxy' import { createAuthRoutes, createAuthInfoRoutes, syncAdminFromEnv } from './routes/auth' import { createAuth } from './auth' @@ -330,7 +329,6 @@ protectedApi.route('/stt', createSTTRoutes(db)) protectedApi.route('/sse', createSSERoutes()) protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) -protectedApi.route('/memory', createMemoryRoutes(db, openCodeClient)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) diff --git a/backend/src/routes/memory.ts b/backend/src/routes/memory.ts deleted file mode 100644 index 53244cf2..00000000 --- a/backend/src/routes/memory.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { Hono } from 'hono' -import { Database } from 'bun:sqlite' -import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs' -import { join } from 'path' -import { logger } from '../utils/logger' -import { PluginMemoryService } from '../services/plugin-memory' -import { resolveProjectId } from '../services/project-id-resolver' -import { getRepoById } from '../db/queries' -import { getWorkspacePath, getConfigPath } from '@opencode-manager/shared/config/env' -import { parseJsonc } from '@opencode-manager/shared/utils' -import type { OpenCodeClient } from '../services/opencode/client' -import { - CreateMemoryRequestSchema, - UpdateMemoryRequestSchema, - MemoryListQuerySchema, - KvListQuerySchema, - PluginConfigSchema, - CreateKvEntryRequestSchema, - UpdateKvEntryRequestSchema, - LoopStateSchema, - type PluginConfig, - type LoopState, -} from '@opencode-manager/shared/schemas' - -function resolveMemoryDataDir(): string { - return join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'memory') -} - -function resolvePluginConfigPath(): string { - return join(getConfigPath(), 'memory-config.jsonc') -} - -function resolveOldPluginConfigPath(): string { - return join(resolveMemoryDataDir(), 'config.json') -} - -function getDefaultPluginConfig(): PluginConfig { - return { - embedding: { - provider: 'local', - model: 'all-MiniLM-L6-v2', - dimensions: 384, - }, - dedupThreshold: 0.25, - } -} - -function loadPluginConfigFromDisk(): PluginConfig { - const configPath = resolvePluginConfigPath() - - if (!existsSync(configPath)) { - const oldPath = resolveOldPluginConfigPath() - if (existsSync(oldPath)) { - const configDir = getConfigPath() - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } - copyFileSync(oldPath, configPath) - } else { - return getDefaultPluginConfig() - } - } - - try { - const content = readFileSync(configPath, 'utf-8') - const parsed = parseJsonc(content) - const result = PluginConfigSchema.safeParse(parsed) - - if (!result.success) { - logger.error('Invalid plugin config:', result.error) - return getDefaultPluginConfig() - } - - return result.data - } catch (error) { - logger.error('Failed to load plugin config:', error) - return getDefaultPluginConfig() - } -} - -function savePluginConfigToDisk(config: PluginConfig): void { - const configPath = resolvePluginConfigPath() - const configDir = getConfigPath() - - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }) - } - - // Read existing content to preserve comments - let existingContent = '' - if (existsSync(configPath)) { - try { - existingContent = readFileSync(configPath, 'utf-8') - } catch { - // File doesn't exist or can't be read, will create new - } - } - - // If we have existing content, preserve comments by extracting them - // and re-adding them to the new content - if (existingContent) { - // Extract comments from existing content - const lines = existingContent.split('\n') - const commentLines: string[] = [] - - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith('//')) { - commentLines.push(line.match(/\/\/\s*(.*)/)?.[1] || '') - } - } - - // Create new JSON string - const newContent = JSON.stringify(config, null, 2) - - // If we found comments, try to preserve them - if (commentLines.length > 0) { - const newLines = newContent.split('\n') - const result: string[] = [] - - // Add comments at the beginning - for (const comment of commentLines) { - result.push(`// ${comment}`) - } - - result.push(...newLines) - writeFileSync(configPath, result.join('\n'), 'utf-8') - return - } - - writeFileSync(configPath, newContent, 'utf-8') - } else { - writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8') - } -} - -export function createMemoryRoutes(db: Database, openCodeClient: OpenCodeClient): Hono { - const app = new Hono() - const pluginMemory = new PluginMemoryService() - - app.get('/', async (c) => { - const query = c.req.query() - const parsed = MemoryListQuerySchema.safeParse({ - projectId: query.projectId, - scope: query.scope, - content: query.content, - limit: query.limit ? parseInt(query.limit, 10) : undefined, - offset: query.offset ? parseInt(query.offset, 10) : undefined, - }) - - if (!parsed.success) { - return c.json({ error: 'Invalid query parameters', details: parsed.error }, 400) - } - - const filters = parsed.data - - if (!filters.projectId) { - return c.json({ memories: [] }) - } - - const memories = pluginMemory.list(filters.projectId, { - scope: filters.scope, - content: filters.content, - limit: filters.limit, - offset: filters.offset, - }) - - return c.json({ memories }) - }) - - app.post('/', async (c) => { - const body = await c.req.json() - const parsed = CreateMemoryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - const id = pluginMemory.create(parsed.data) - const memory = pluginMemory.getById(id) - - if (!memory) { - return c.json({ error: 'Failed to retrieve created memory' }, 500) - } - - return c.json({ memory }, 201) - } catch (error) { - logger.error('Failed to create memory:', error) - return c.json({ error: 'Failed to create memory' }, 500) - } - }) - - app.get('/project-summary', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId parameter' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ projectId: null, stats: { total: 0, byScope: {} }, error: 'Repository not found' }, 404) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ projectId: null, stats: { total: 0, byScope: {} } }) - } - - const stats = pluginMemory.getStats(projectId) - const kvCount = pluginMemory.getKvCount(projectId) - - return c.json({ projectId, stats, kvCount }) - } catch (error) { - logger.error('Failed to get project summary:', error) - return c.json({ projectId: null, stats: { total: 0, byScope: {} }, error: 'Failed to get project summary' }, 500) - } - }) - - app.get('/stats', async (c) => { - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - try { - const stats = pluginMemory.getStats(projectId) - return c.json(stats) - } catch (error) { - logger.error('Failed to get memory stats:', error) - return c.json({ error: 'Failed to get stats' }, 500) - } - }) - - app.get('/resolve-project', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId parameter' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ projectId: null, error: 'Repository not found' }, 404) - } - - const projectId = await resolveProjectId(repo.fullPath) - - return c.json({ projectId }) - } catch (error) { - logger.error('Failed to resolve project ID:', error) - return c.json({ projectId: null, error: 'Failed to resolve project ID' }, 500) - } - }) - - app.get('/plugin-config', async (c) => { - try { - const config = loadPluginConfigFromDisk() - return c.json({ config }) - } catch (error) { - logger.error('Failed to get plugin config:', error) - return c.json({ error: 'Failed to get plugin config' }, 500) - } - }) - - app.put('/plugin-config', async (c) => { - try { - const body = await c.req.json() - const parsed = PluginConfigSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid config', details: parsed.error.flatten() }, 400) - } - - const config = parsed.data - config.dedupThreshold = Math.max(0.05, Math.min(0.4, config.dedupThreshold ?? 0.25)) - - savePluginConfigToDisk(config) - - return c.json({ success: true, config }) - } catch (error) { - logger.error('Failed to save plugin config:', error) - return c.json({ error: 'Failed to save plugin config' }, 500) - } - }) - - app.post('/test-embedding', async (c) => { - try { - const config = loadPluginConfigFromDisk() - - if (config.embedding.provider === 'local') { - const validModels = ['all-MiniLM-L6-v2'] - if (!validModels.includes(config.embedding.model)) { - return c.json({ - success: false, - error: `Invalid model: ${config.embedding.model}. Valid models: ${validModels.join(', ')}` - }, 400) - } - return c.json({ - success: true, - message: 'Local provider configured. Model will be loaded on server restart.', - dimensions: config.embedding.dimensions ?? 384, - }) - } - - const endpoints: Record = { - openai: 'https://api.openai.com/v1/embeddings', - voyage: 'https://api.voyageai.com/v1/embeddings', - } - - const extractHost = (url: string): string => { - const protocolEnd = url.indexOf('://') - if (protocolEnd === -1) return url - const pathStart = url.indexOf('/', protocolEnd + 3) - return pathStart === -1 ? url : url.slice(0, pathStart) - } - - const baseUrl = extractHost(config.embedding.baseUrl || '') - const endpoint = baseUrl - ? `${baseUrl}/v1/embeddings` - : endpoints[config.embedding.provider] ?? '' - - if (!endpoint) { - return c.json({ success: false, error: 'No endpoint configured' }, 400) - } - - if (!config.embedding.apiKey) { - return c.json({ success: false, error: 'API key not configured. Please save your API key first.' }, 400) - } - - const testBody = { - model: config.embedding.model, - input: ['test'], - } - - const headers: Record = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${config.embedding.apiKey}`, - } - - const response = await fetch(endpoint, { - method: 'POST', - headers, - body: JSON.stringify(testBody), - }) - - if (!response.ok) { - const errorText = await response.text() - return c.json({ - success: false, - error: `API error: ${response.status}`, - message: errorText, - }, 400) - } - - const data = await response.json() as { - data?: Array<{ embedding: number[] }> - embeddings?: Array<{ embedding: number[] }> - } - - const embeddings = data.data || data.embeddings - if (!embeddings || embeddings.length === 0 || !embeddings[0]) { - return c.json({ success: false, error: 'Invalid response from API' }, 400) - } - - const firstEmbedding = embeddings[0] - const actualDimensions = firstEmbedding.embedding.length - - return c.json({ - success: true, - message: `Embedding test successful. Generated ${actualDimensions}d embedding.`, - dimensions: actualDimensions, - }) - } catch (error) { - logger.error('Failed to test embedding config:', error) - return c.json({ - success: false, - error: 'Failed to test embedding configuration', - message: error instanceof Error ? error.message : 'Unknown error' - }, 500) - } - }) - - app.post('/reindex', async (c) => { - try { - const db = pluginMemory.getDb() - - if (!db) { - return c.json({ - error: 'Memory database not found. Make sure the memory plugin has been initialized.', - total: 0, - embedded: 0, - failed: 0 - }, 404) - } - - const memories = pluginMemory.listAll() - - if (memories.length === 0) { - return c.json({ - success: true, - message: 'No memories to reindex', - total: 0, - embedded: 0, - failed: 0 - }) - } - - try { - db.exec('DELETE FROM memory_embeddings') - } catch { - return c.json({ - success: true, - message: 'Cleared embeddings. Server restart required to regenerate embeddings with new model.', - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } - - return c.json({ - success: true, - message: `Cleared ${memories.length} embeddings. Server restart required to regenerate embeddings.`, - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } catch (error) { - logger.error('Failed to reindex memories:', error) - return c.json({ error: 'Failed to reindex memories', details: error instanceof Error ? error.message : 'Unknown error' }, 500) - } - }) - - app.get('/kv', async (c) => { - const query = c.req.query() - const parsed = KvListQuerySchema.safeParse({ - projectId: query.projectId, - prefix: query.prefix, - }) - - if (!parsed.success) { - return c.json({ error: 'Invalid query parameters', details: parsed.error }, 400) - } - - const { projectId, prefix } = parsed.data - const entries = pluginMemory.listKv(projectId, prefix) - return c.json({ entries }) - }) - - app.post('/kv', async (c) => { - const body = await c.req.json() - const parsed = CreateKvEntryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.setKv(parsed.data.projectId, parsed.data.key, parsed.data.data, parsed.data.ttlMs) - const entry = pluginMemory.getKv(parsed.data.projectId, parsed.data.key) - return c.json({ entry }, 201) - } catch (error) { - logger.error('Failed to create KV entry:', error) - return c.json({ error: 'Failed to create KV entry' }, 500) - } - }) - - app.put('/kv/:key', async (c) => { - const key = decodeURIComponent(c.req.param('key')) - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - const body = await c.req.json() - const parsed = UpdateKvEntryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.setKv(projectId, key, parsed.data.data, parsed.data.ttlMs) - const entry = pluginMemory.getKv(projectId, key) - return c.json({ entry }) - } catch (error) { - logger.error('Failed to update KV entry:', error) - return c.json({ error: 'Failed to update KV entry' }, 500) - } - }) - - app.delete('/kv/:key', async (c) => { - const key = decodeURIComponent(c.req.param('key')) - const projectId = c.req.query('projectId') - - if (!projectId) { - return c.json({ error: 'Missing projectId parameter' }, 400) - } - - try { - pluginMemory.deleteKv(projectId, key) - return c.json({ success: true }) - } catch (error) { - logger.error('Failed to delete KV entry:', error) - return c.json({ error: 'Failed to delete KV entry' }, 500) - } - }) - - app.get('/loop/status', async (c) => { - const repoIdParam = c.req.query('repoId') - - if (!repoIdParam) { - return c.json({ error: 'Missing repoId' }, 400) - } - - const repoId = parseInt(repoIdParam, 10) - - if (isNaN(repoId)) { - return c.json({ error: 'Invalid repoId' }, 400) - } - - try { - const repo = getRepoById(db, repoId) - - if (!repo) { - return c.json({ loops: [], projectId: null }) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ loops: [], projectId: null }) - } - - const entries = pluginMemory.listKv(projectId, 'loop:') - const loops = entries - .map(e => e.data) - .filter((data): data is Record => - data !== null && typeof data === 'object' && 'active' in data - ) - .map(data => { - const result = LoopStateSchema.safeParse(data) - return result.success ? result.data : null - }) - .filter((loop): loop is LoopState => loop !== null) - - return c.json({ loops, projectId }) - } catch (error) { - logger.error('Failed to get Loop:', error) - return c.json({ error: 'Failed to get Loop' }, 500) - } - }) - - app.post('/loop/cancel', async (c) => { - try { - const body = await c.req.json() - const { repoId, worktreeName, sessionId } = body - - if (!repoId || (!worktreeName && !sessionId)) { - return c.json({ error: 'Missing repoId or identifier (worktreeName or sessionId)' }, 400) - } - - const repo = getRepoById(db, parseInt(repoId, 10)) - - if (!repo) { - return c.json({ cancelled: false }) - } - - const projectId = await resolveProjectId(repo.fullPath) - - if (!projectId) { - return c.json({ cancelled: false }) - } - - let worktreeNameToUse: string | undefined - - if (worktreeName) { - worktreeNameToUse = worktreeName - } else if (sessionId) { - const sessionMappingEntry = pluginMemory.getKv(projectId, `loop-session:${sessionId}`) - if (!sessionMappingEntry) { - return c.json({ cancelled: false }) - } - worktreeNameToUse = sessionMappingEntry.data as string - } - - if (!worktreeNameToUse) { - return c.json({ cancelled: false }) - } - - const kvEntry = pluginMemory.getKv(projectId, `loop:${worktreeNameToUse}`) - if (!kvEntry) { - return c.json({ cancelled: false }) - } - - const result = LoopStateSchema.safeParse(kvEntry.data) - - if (!result.success) { - logger.warn('Failed to parse Loop state for cancel:', result.error) - return c.json({ cancelled: false }) - } - - const state = result.data - - if (!state.active) { - return c.json({ cancelled: false }) - } - - const updatedState = { - ...state, - active: false, - terminationReason: 'cancelled', - completedAt: new Date().toISOString(), - } - - pluginMemory.setKv(projectId, `loop:${worktreeNameToUse}`, updatedState) - - try { - await openCodeClient.forward({ - method: 'POST', - path: `/session/${state.sessionId}/abort`, - directory: repo.fullPath, - }) - } catch { - // Session may already be idle - } - - return c.json({ cancelled: true, worktreeName: state.worktreeName }) - } catch (error) { - logger.error('Failed to cancel Loop:', error) - return c.json({ error: 'Failed to cancel Loop' }, 500) - } - }) - - app.get('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - const memory = pluginMemory.getById(id) - - if (!memory) { - return c.json({ error: 'Memory not found' }, 404) - } - - return c.json({ memory }) - }) - - app.put('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - const body = await c.req.json() - const parsed = UpdateMemoryRequestSchema.safeParse(body) - - if (!parsed.success) { - return c.json({ error: 'Invalid request', details: parsed.error }, 400) - } - - try { - pluginMemory.update(id, parsed.data) - const memory = pluginMemory.getById(id) - return c.json({ memory }) - } catch (error) { - logger.error('Failed to update memory:', error) - return c.json({ error: 'Failed to update memory' }, 500) - } - }) - - app.delete('/:id', async (c) => { - const id = parseInt(c.req.param('id'), 10) - - if (isNaN(id)) { - return c.json({ error: 'Invalid memory ID' }, 400) - } - - try { - pluginMemory.delete(id) - return c.json({ success: true }) - } catch (error) { - logger.error('Failed to delete memory:', error) - return c.json({ error: 'Failed to delete memory' }, 500) - } - }) - - return app -} diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 5359a54a..745fa2f6 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -237,19 +237,6 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } }) - app.get('/memory-plugin-status', async (c) => { - try { - const userId = c.req.query('userId') || 'default' - const configs = settingsService.getOpenCodeConfigs(userId) - const defaultConfig = configs.configs.find((cfg: { isDefault: boolean }) => cfg.isDefault) - const isEnabled = ((defaultConfig?.content?.plugin as string[] | undefined) ?? []).includes('@opencode-manager/memory') - return c.json({ memoryPluginEnabled: isEnabled }) - } catch (error) { - logger.error('Failed to get memory plugin status:', error) - return c.json({ error: 'Failed to get memory plugin status' }, 500) - } - }) - app.patch('/', async (c) => { try { const userId = c.req.query('userId') || 'default' diff --git a/backend/src/services/plugin-memory.ts b/backend/src/services/plugin-memory.ts deleted file mode 100644 index 84db9f6a..00000000 --- a/backend/src/services/plugin-memory.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { Database } from 'bun:sqlite' -import path from 'path' -import { existsSync } from 'node:fs' -import { getWorkspacePath } from '@opencode-manager/shared/config/env' - -export interface PluginMemory { - id: number - projectId: string - scope: 'convention' | 'decision' | 'context' - content: string - filePath: string | null - accessCount: number - lastAccessedAt: number | null - createdAt: number - updatedAt: number -} - -interface DbMemoryRow { - id: number - project_id: string - scope: string - content: string - file_path: string | null - access_count: number - last_accessed_at: number | null - created_at: number - updated_at: number -} - -interface DbKvRow { - project_id: string - key: string - data: string - expires_at: number - created_at: number - updated_at: number -} - -interface MemoryFilters { - scope?: 'convention' | 'decision' | 'context' - content?: string - limit?: number - offset?: number -} - -function getPluginDbPath(): string { - return path.join(getWorkspacePath(), '.opencode', 'state', 'opencode', 'memory', 'memory.db') -} - -function mapRowToMemory(row: DbMemoryRow): PluginMemory { - return { - id: row.id, - projectId: row.project_id, - scope: row.scope as PluginMemory['scope'], - content: row.content, - filePath: row.file_path, - accessCount: row.access_count, - lastAccessedAt: row.last_accessed_at, - createdAt: row.created_at, - updatedAt: row.updated_at, - } -} - -function mapRowToKvEntry(row: DbKvRow): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } { - let data: unknown = null - try { - data = JSON.parse(row.data) - } catch { - data = row.data - } - return { - key: row.key, - data, - createdAt: row.created_at, - updatedAt: row.updated_at, - expiresAt: row.expires_at, - } -} - -export class PluginMemoryService { - private db: Database | null = null - - getDb(): Database | null { - if (this.db) return this.db - - const dbPath = getPluginDbPath() - - if (!existsSync(dbPath)) { - return null - } - - try { - this.db = new Database(dbPath) - this.db.exec('PRAGMA journal_mode = WAL') - return this.db - } catch { - return null - } - } - - list(projectId: string, filters?: MemoryFilters): PluginMemory[] { - const db = this.getDb() - if (!db) return [] - - let sql = 'SELECT * FROM memories WHERE project_id = ?' - const params: (string | number)[] = [projectId] - - if (filters?.scope) { - sql += ' AND scope = ?' - params.push(filters.scope) - } - - if (filters?.content) { - sql += ' AND content LIKE ?' - params.push(`%${filters.content}%`) - } - - sql += ' ORDER BY updated_at DESC' - - if (filters?.limit) { - sql += ' LIMIT ?' - params.push(filters.limit) - } - - if (filters?.offset) { - sql += ' OFFSET ?' - params.push(filters.offset) - } - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbMemoryRow[] - return rows.map(mapRowToMemory) - } - - listAll(filters?: { projectId?: string; scope?: string; limit?: number; offset?: number }): PluginMemory[] { - const db = this.getDb() - if (!db) return [] - - let sql = 'SELECT * FROM memories WHERE 1=1' - const params: (string | number)[] = [] - - if (filters?.projectId) { - sql += ' AND project_id = ?' - params.push(filters.projectId) - } - - if (filters?.scope) { - sql += ' AND scope = ?' - params.push(filters.scope) - } - - sql += ' ORDER BY updated_at DESC' - - if (filters?.limit) { - sql += ' LIMIT ?' - params.push(filters.limit) - } - - if (filters?.offset) { - sql += ' OFFSET ?' - params.push(filters.offset) - } - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbMemoryRow[] - return rows.map(mapRowToMemory) - } - - getById(id: number): PluginMemory | undefined { - const db = this.getDb() - if (!db) return undefined - - const stmt = db.prepare('SELECT * FROM memories WHERE id = ?') - const row = stmt.get(id) as DbMemoryRow | undefined - return row ? mapRowToMemory(row) : undefined - } - - create(input: { projectId: string; scope: string; content: string }): number { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const now = Date.now() - const stmt = db.prepare(` - INSERT INTO memories (project_id, scope, content, access_count, created_at, updated_at) - VALUES (?, ?, ?, 0, ?, ?) - `) - const result = stmt.run(input.projectId, input.scope, input.content, now, now) - return result.lastInsertRowid as number - } - - update(id: number, input: { content?: string; scope?: string }): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const updates: string[] = [] - const params: (string | number)[] = [] - - if (input.content !== undefined) { - updates.push('content = ?') - params.push(input.content) - } - - if (input.scope !== undefined) { - updates.push('scope = ?') - params.push(input.scope) - } - - if (updates.length === 0) return - - updates.push('updated_at = ?') - params.push(Date.now()) - params.push(id) - - const sql = `UPDATE memories SET ${updates.join(', ')} WHERE id = ?` - const stmt = db.prepare(sql) - stmt.run(...params) - - try { - const deleteEmbeddings = db.prepare('DELETE FROM memory_embeddings WHERE memory_id = ?') - deleteEmbeddings.run(id) - } catch { - // table may not exist - } - } - - delete(id: number): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - try { - const deleteEmbeddings = db.prepare('DELETE FROM memory_embeddings WHERE memory_id = ?') - deleteEmbeddings.run(id) - } catch { - // table may not exist - } - - const stmt = db.prepare('DELETE FROM memories WHERE id = ?') - stmt.run(id) - } - - getStats(projectId: string): { projectId: string; total: number; byScope: Record } { - const db = this.getDb() - if (!db) { - return { projectId, total: 0, byScope: {} } - } - - const totalStmt = db.prepare('SELECT COUNT(*) as count FROM memories WHERE project_id = ?') - const totalResult = totalStmt.get(projectId) as { count: number } - const total = totalResult.count - - const byScopeStmt = db.prepare('SELECT scope, COUNT(*) as count FROM memories WHERE project_id = ? GROUP BY scope') - const byScopeRows = byScopeStmt.all(projectId) as { scope: string; count: number }[] - const byScope: Record = {} - for (const row of byScopeRows) { - byScope[row.scope] = row.count - } - - return { projectId, total, byScope } - } - - listKv(projectId: string, prefix?: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number }[] { - const db = this.getDb() - if (!db) return [] - - const now = Date.now() - let sql = 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND expires_at > ?' - const params: (string | number)[] = [projectId, now] - - if (prefix) { - sql += ' AND key LIKE ?' - params.push(`${prefix}%`) - } - - sql += ' ORDER BY updated_at DESC' - - const stmt = db.prepare(sql) - const rows = stmt.all(...params) as DbKvRow[] - return rows.map(mapRowToKvEntry) - } - - getKv(projectId: string, key: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } | undefined { - const db = this.getDb() - if (!db) return undefined - - const stmt = db.prepare( - 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND key = ? AND expires_at > ?' - ) - const row = stmt.get(projectId, key, Date.now()) as DbKvRow | undefined - return row ? mapRowToKvEntry(row) : undefined - } - - setKv(projectId: string, key: string, data: unknown, ttlMs?: number): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const now = Date.now() - const expiresAt = ttlMs ? now + ttlMs : Number.MAX_SAFE_INTEGER - const serializedData = JSON.stringify(data) - - const stmt = db.prepare(` - INSERT INTO project_kv (project_id, key, data, expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(project_id, key) DO UPDATE SET - data = excluded.data, - expires_at = excluded.expires_at, - updated_at = excluded.updated_at - `) - stmt.run(projectId, key, serializedData, expiresAt, now, now) - } - - deleteKv(projectId: string, key: string): void { - const db = this.getDb() - if (!db) throw new Error('Plugin database not available') - - const stmt = db.prepare('DELETE FROM project_kv WHERE project_id = ? AND key = ?') - stmt.run(projectId, key) - } - - getKvCount(projectId: string): number { - const db = this.getDb() - if (!db) return 0 - - const stmt = db.prepare('SELECT COUNT(*) as count FROM project_kv WHERE project_id = ? AND expires_at > ?') - const result = stmt.get(projectId, Date.now()) as { count: number } - return result.count - } - - close(): void { - if (this.db) { - this.db.close() - this.db = null - } - } -} diff --git a/backend/test/routes/memory.test.ts b/backend/test/routes/memory.test.ts deleted file mode 100644 index ce4f74ff..00000000 --- a/backend/test/routes/memory.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createStubOpenCodeClient } from '../helpers/stub-opencode-client' - -vi.mock('bun:sqlite', () => ({ - Database: vi.fn().mockImplementation(() => ({})), -})) - -const mockListKv = vi.fn() - -vi.mock('../../src/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - }, -})) - -vi.mock('../../src/services/project-id-resolver', () => ({ - resolveProjectId: vi.fn(), -})) - -vi.mock('../../src/services/plugin-memory', () => ({ - PluginMemoryService: vi.fn().mockImplementation(() => ({ - listKv: mockListKv, - })), -})) - -vi.mock('../../src/db/queries', () => ({ - getRepoById: vi.fn(), -})) - -vi.mock('@opencode-manager/shared/config/env', () => ({ - getWorkspacePath: vi.fn(() => '/tmp/test-workspace'), - getReposPath: vi.fn(() => '/tmp/test-repos'), - getOpenCodeConfigFilePath: vi.fn(() => '/tmp/test-workspace/.config/opencode.json'), - getAgentsMdPath: vi.fn(() => '/tmp/test-workspace/AGENTS.md'), - getDatabasePath: vi.fn(() => ':memory:'), - getConfigPath: vi.fn(() => '/tmp/test-workspace/config'), - ENV: { - SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, - AUTH: { TRUSTED_ORIGINS: 'http://localhost:5173', SECRET: 'test-secret-for-encryption-key-32c' }, - WORKSPACE: { BASE_PATH: '/tmp/test-workspace', REPOS_DIR: 'repos', CONFIG_DIR: 'config', AUTH_FILE: 'auth.json' }, - OPENCODE: { PORT: 5551, HOST: '127.0.0.1' }, - DATABASE: { PATH: ':memory:' }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, - }, - FILE_LIMITS: { - MAX_SIZE_BYTES: 1024 * 1024, - MAX_UPLOAD_SIZE_BYTES: 10 * 1024 * 1024, - }, -})) - -vi.mock('@opencode-manager/shared/utils', () => ({ - parseJsonc: vi.fn(), -})) - -import { createMemoryRoutes } from '../../src/routes/memory' -import { resolveProjectId } from '../../src/services/project-id-resolver' -import { getRepoById } from '../../src/db/queries' - -const mockResolveProjectId = resolveProjectId as ReturnType -const mockGetRepoById = getRepoById as ReturnType - -describe('Memory Routes - Loop Status', () => { - let memoryApp: ReturnType - let testDb: any - - beforeEach(() => { - vi.clearAllMocks() - mockResolveProjectId.mockReset() - mockListKv.mockReset() - mockGetRepoById.mockReset() - - testDb = {} as any - memoryApp = createMemoryRoutes(testDb, createStubOpenCodeClient()) - }) - - describe('GET /loop/status', () => { - it('should return 400 when repoId query param is missing', async () => { - const req = new Request('http://localhost/loop/status') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(400) - const json = await res.json() as Record - expect(json.error).toBe('Missing repoId') - }) - - it('should return 400 when repoId is not a valid number', async () => { - const req = new Request('http://localhost/loop/status?repoId=abc') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(400) - const json = await res.json() as Record - expect(json.error).toBe('Invalid repoId') - }) - - it('should return 200 with empty loops when repo is not found in DB', async () => { - mockGetRepoById.mockReturnValue(null) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toEqual([]) - }) - - it('should return 200 with empty loops when resolveProjectId returns null', async () => { - mockGetRepoById.mockReturnValue({ - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - }) - mockResolveProjectId.mockResolvedValue(null) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toEqual([]) - }) - - it('should return 200 with loops array for a valid repo with active loops', async () => { - const mockRepo = { - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - } - mockGetRepoById.mockReturnValue(mockRepo) - mockResolveProjectId.mockResolvedValue('test-project-id') - mockListKv.mockReturnValue([ - { - key: 'loop:test-worktree', - data: { - active: true, - sessionId: 'session-123', - worktreeName: 'test-worktree', - worktreeDir: '/tmp/worktrees/test', - iteration: 1, - maxIterations: 10, - startedAt: '2024-01-01T00:00:00.000Z', - prompt: 'Test prompt', - phase: 'coding', - audit: false, - errorCount: 0, - auditCount: 0, - }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - ]) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toHaveLength(1) - expect((json.loops as Array>)[0]?.active).toBe(true) - }) - - it('should filter out KV entries that do not have an active field', async () => { - const mockRepo = { - id: 1, - fullPath: '/tmp/test-repo', - repoUrl: 'https://github.com/test/repo.git', - localPath: 'test-repo', - sourcePath: '', - branch: 'main', - currentBranch: 'main', - cloneStatus: 'ready', - isWorktree: false, - openCodeConfigName: 'default', - } - mockGetRepoById.mockReturnValue(mockRepo) - mockResolveProjectId.mockResolvedValue('test-project-id') - mockListKv.mockReturnValue([ - { - key: 'loop:test-worktree-1', - data: { - active: true, - sessionId: 'session-123', - worktreeName: 'test-worktree-1', - worktreeDir: '/tmp/test-worktree-1', - iteration: 1, - maxIterations: 10, - startedAt: new Date().toISOString(), - phase: 'coding', - errorCount: 0, - auditCount: 0, - completionPromise: null, - }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-2', - data: null, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-3', - data: 'string-data', - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - { - key: 'loop:test-worktree-4', - data: { sessionId: 'session-abc' }, - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + 86400000, - }, - ]) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(200) - const json = await res.json() as Record - expect(json.loops).toHaveLength(1) - expect((json.loops as Array>)[0]?.active).toBe(true) - }) - - it('should return 500 when an unexpected error is thrown', async () => { - mockGetRepoById.mockImplementation(() => { - throw new Error('Database error') - }) - - const req = new Request('http://localhost/loop/status?repoId=1') - const res = await memoryApp.fetch(req) - - expect(res.status).toBe(500) - const json = await res.json() as Record - expect(json.error).toBe('Failed to get Loop') - }) - }) -}) diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index a5430c18..7eaf2d44 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -126,7 +126,6 @@ The container entrypoint (`scripts/docker-entrypoint.sh`) automatically: 2. **Verifies OpenCode** is installed (installed at build time, fallback install if missing) 3. **Upgrades OpenCode** if below minimum version (1.0.137) 4. **Validates AUTH_SECRET** is set (required for startup) -5. **Validates memory plugin** installation (installed from npm during Docker build) ## Port Configuration diff --git a/docs/features/memory.md b/docs/features/memory.md deleted file mode 100644 index 0dc438bf..00000000 --- a/docs/features/memory.md +++ /dev/null @@ -1,74 +0,0 @@ -# Memory Plugin - -`@opencode-manager/memory` is an **optional** OpenCode plugin that stores and recalls project knowledge across sessions using vector embeddings and semantic search. - -[![npm](https://img.shields.io/npm/v/@opencode-manager/memory)](https://www.npmjs.com/package/@opencode-manager/memory) - -!!! note "Not Required" - This plugin is entirely optional. OpenCode Manager works fully without it — install it only if you want persistent project knowledge and semantic search capabilities. - -!!! tip "Works with Standalone OpenCode" - This plugin can also be used with standalone OpenCode installations outside of OpenCode Manager. Simply install the package and add it to your `opencode.json` plugins array. - -!!! info "Standalone Repository" - The memory plugin has moved to its own repository. For complete documentation including configuration, tools, agents, CLI reference, loops, and Docker sandbox, visit the **[opencode-memory repository](https://github.com/chriswritescode-dev/opencode-memory)**. - ---- - -## Installation - -```bash -pnpm add @opencode-manager/memory -``` - -Register the plugin in your `opencode.json`: - -```json -{ - "plugin": ["@opencode-manager/memory"] -} -``` - -The local embedding model downloads automatically on install. For API-based embeddings (OpenAI or Voyage), see the [configuration reference](https://github.com/chriswritescode-dev/opencode-memory#configuration). - ---- - -## Features - -- **Semantic Memory Search** — Store and retrieve project memories using vector embeddings -- **Multiple Memory Scopes** — Categorize memories as convention, decision, or context -- **Automatic Deduplication** — Prevents duplicates via exact match and semantic similarity -- **Compaction Context Injection** — Injects conventions and decisions into session compaction -- **Automatic Memory Injection** — Injects relevant memories into user messages via semantic search -- **Project KV Store** — Ephemeral key-value storage with TTL management -- **Bundled Agents** — Code, Architect, Auditor, and Librarian agents preconfigured for memory-aware workflows -- **CLI Tools** — Export, import, list, stats, cleanup, upgrade, status, and cancel via `ocm-mem` -- **Iterative Development Loops** — Autonomous coding/auditing loop with worktree isolation and session rotation -- **Docker Sandbox** — Run loops inside isolated Docker containers -- **TUI Sidebar** — Monitor loops and memory status directly in the OpenCode terminal interface - ---- - -## Configuration - -The plugin configuration lives at `~/.config/opencode/memory-config.jsonc`. It is created automatically on first run. - -For the full configuration reference including embedding providers, loop settings, sandbox options, and more, see the [standalone repository README](https://github.com/chriswritescode-dev/opencode-memory#configuration). - ---- - -## OpenCode Manager Integration - -When the memory plugin is installed, OpenCode Manager's web UI provides: - -- **Memory Browser** — View, search, create, edit, and delete project memories -- **Loop Status** — Monitor active development loops and their progress -- **Plugin Configuration** — Enable/disable the plugin and adjust settings -- **KV Store Viewer** — Browse ephemeral key-value entries - ---- - -## Links - -- **GitHub**: [chriswritescode-dev/opencode-memory](https://github.com/chriswritescode-dev/opencode-memory) -- **npm**: [@opencode-manager/memory](https://www.npmjs.com/package/@opencode-manager/memory) diff --git a/docs/features/overview.md b/docs/features/overview.md index 98c2742a..cb392f92 100644 --- a/docs/features/overview.md +++ b/docs/features/overview.md @@ -61,15 +61,6 @@ OpenCode Manager provides a comprehensive web interface for managing OpenCode AI [Learn more →](mcp.md) -### Memory Plugin (Optional) - -- Semantic Search — Store and retrieve project knowledge using vector embeddings -- Memory Scopes — Categorize as convention, decision, or context -- Iterative Development Loops — Autonomous coding/auditing with worktree isolation -- Bundled Agents — Code, Architect, Librarian, and Auditor agents - -[Learn more →](memory.md) | [Full documentation →](https://github.com/chriswritescode-dev/opencode-memory) - ### Text-to-Speech - **Browser TTS** - Built-in Web Speech API support diff --git a/docs/index.md b/docs/index.md index 87f14338..f3a80767 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ OpenCode Manager is a mobile-first web interface for [OpenCode](https://opencode - **Chat & sessions** — Real-time SSE streaming with slash commands, `@file` mentions, and Plan/Build modes - **Schedules** — Recurring repo jobs with reusable prompts, run history, and linked sessions - **AI configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents -- **MCP, Skills, Memory** — MCP server management, skill support, optional memory plugin with semantic search +- **MCP & Skills** — MCP server management and skill support - **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) - **Mobile & notifications** — Installable PWA with push notifications and mobile-first navigation @@ -46,16 +46,10 @@ OpenCode Manager runs as a pnpm workspace: - **AI Configuration** — Model/provider setup, OAuth for Anthropic/GitHub Copilot, custom agents — [Learn more](features/ai-config.md) - **MCP Servers** — Add local or remote MCP servers with OAuth support — [Learn more](features/mcp.md) - **Skills** — Skill support for extended agent capabilities — [Learn more](features/skills.md) -- **Memory Plugin** — Persistent project knowledge with semantic search ([GitHub](https://github.com/chriswritescode-dev/opencode-memory)) — [Learn more](features/memory.md) - **Mobile & PWA** — Responsive UI, installable on any device, iOS-optimized — [Learn more](features/mobile.md) - **Push Notifications** — Background alerts for agent events — [Learn more](features/notifications.md) - **Audio** — Text-to-speech and speech-to-text (browser + OpenAI-compatible) — [Learn more](features/tts.md) | [Learn more](features/stt.md) -!!! tip "Memory Plugin — Persistent Project Knowledge" - Store and retrieve project knowledge across sessions using vector embeddings and semantic search. Works as a standalone plugin with any OpenCode installation. - - **[Learn more →](features/memory.md)** | **[GitHub →](https://github.com/chriswritescode-dev/opencode-memory)** - ## Project Layout - `backend/` — Bun + Hono API routes, services, database migrations, auth, schedules, and OpenCode integration. @@ -72,5 +66,4 @@ OpenCode Manager runs as a pnpm workspace: - [Contributing](development/contributing.md) - How to contribute to OpenCode Manager - [Features Overview](features/overview.md) - Explore all features - [Schedules & Recurring Jobs](features/schedules.md) - Automate recurring repo reviews and follow-ups -- [Memory Plugin](features/memory.md) - Persistent project knowledge with semantic search ([GitHub](https://github.com/chriswritescode-dev/opencode-memory)) - [Configuration](configuration/environment.md) - Environment variables and setup diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac920766..5c91c056 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ import { Toaster } from 'sonner' import { Repos } from './pages/Repos' import { RepoDetail } from './pages/RepoDetail' import { SessionDetail } from './pages/SessionDetail' -import { Memories } from './pages/Memories' import { Schedules } from './pages/Schedules' import { GlobalSchedules } from './pages/GlobalSchedules' import { Login } from './pages/Login' @@ -225,11 +224,6 @@ const router = createBrowserRouter([ element: , loader: protectedLoader, }, - { - path: '/repos/:id/memories', - element: , - loader: protectedLoader, - }, { path: '/repos/:id/schedules', element: , diff --git a/frontend/src/api/memory.ts b/frontend/src/api/memory.ts deleted file mode 100644 index 778250ca..00000000 --- a/frontend/src/api/memory.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' -import { API_BASE_URL } from '@/config' -import type { Memory, MemoryStats, CreateMemoryRequest, UpdateMemoryRequest, PluginConfig, KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest, LoopState } from '@opencode-manager/shared/types' - -export async function listMemories(filters?: { - projectId?: string - scope?: string - content?: string - limit?: number - offset?: number -}): Promise<{ memories: Memory[] }> { - const params = new URLSearchParams() - if (filters?.projectId) params.set('projectId', filters.projectId) - if (filters?.scope) params.set('scope', filters.scope) - if (filters?.content) params.set('content', filters.content) - if (filters?.limit) params.set('limit', String(filters.limit)) - if (filters?.offset) params.set('offset', String(filters.offset)) - - const query = params.toString() - return fetchWrapper(`${API_BASE_URL}/api/memory${query ? `?${query}` : ''}`) -} - -export async function createMemory(data: CreateMemoryRequest): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function getMemory(id: number): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/${id}`) -} - -export async function updateMemory(id: number, data: UpdateMemoryRequest): Promise<{ memory: Memory }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function deleteMemory(id: number): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/memory/${id}`, { - method: 'DELETE', - }) -} - -export async function getProjectSummary( - repoId: number -): Promise<{ projectId: string | null; stats: MemoryStats; error?: string }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/project-summary?repoId=${repoId}`) -} - -export async function getPluginConfig(): Promise<{ config: PluginConfig }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/plugin-config`) -} - -export async function updatePluginConfig(config: PluginConfig): Promise<{ success: boolean; config: PluginConfig }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/plugin-config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config), - }) -} - -export interface ReindexResult { - success: boolean - message: string - total: number - embedded: number - failed: number - requiresRestart?: boolean -} - -export async function reindexMemories(): Promise { - return fetchWrapper(`${API_BASE_URL}/api/memory/reindex`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) -} - -export interface TestEmbeddingResult { - success: boolean - error?: string - message?: string - dimensions?: number -} - -export async function testEmbeddingConfig(): Promise { - return fetchWrapper(`${API_BASE_URL}/api/memory/test-embedding`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) -} - -export async function listKvEntries(projectId: string, prefix?: string): Promise<{ entries: KvEntry[] }> { - const params = new URLSearchParams({ projectId }) - if (prefix) params.set('prefix', prefix) - return fetchWrapper(`${API_BASE_URL}/api/memory/kv?${params.toString()}`) -} - -export async function deleteKvEntry(projectId: string, key: string): Promise { - return fetchWrapperVoid(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { - method: 'DELETE', - }) -} - -export async function createKvEntry(data: CreateKvEntryRequest): Promise<{ entry: KvEntry }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/kv`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function updateKvEntry(projectId: string, key: string, data: UpdateKvEntryRequest): Promise<{ entry: KvEntry }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }) -} - -export async function getLoopStatus(repoId: number): Promise<{ loops: LoopState[]; projectId?: string | null }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/loop/status?repoId=${repoId}`) -} - -export async function cancelLoop(repoId: number, sessionId: string): Promise<{ cancelled: boolean; worktreeName?: string }> { - return fetchWrapper(`${API_BASE_URL}/api/memory/loop/cancel`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ repoId, sessionId }), - }) -} diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index b5f3c06b..5bdf3546 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -229,10 +229,6 @@ export const settingsApi = { return fetchWrapper(`${API_BASE_URL}/api/health/version`) }, - getMemoryPluginStatus: async (): Promise<{ memoryPluginEnabled: boolean }> => { - return fetchWrapper(`${API_BASE_URL}/api/settings/memory-plugin-status`) - }, - listManagedSkills: async (repoId?: number): Promise => { const params = repoId ? `?repoId=${repoId}` : '' return fetchWrapper(`${API_BASE_URL}/api/settings/skills${params}`) diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index 7d35ab3d..32598617 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -63,7 +63,6 @@ export interface UserPreferences { notifications?: NotificationPreferences repoOrder?: number[] repoSortMode?: 'recent' | 'manual' | 'name' - memoryDedupThreshold?: number } export interface SettingsResponse { diff --git a/frontend/src/components/memory/KvFormDialog.tsx b/frontend/src/components/memory/KvFormDialog.tsx deleted file mode 100644 index 8cfed190..00000000 --- a/frontend/src/components/memory/KvFormDialog.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useState } from 'react' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { useCreateKvEntry, useUpdateKvEntry } from '@/hooks/useMemories' -import type { KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest } from '@opencode-manager/shared/types' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Label } from '@/components/ui/label' - -const kvSchema = z.object({ - key: z.string().min(1, 'Key is required'), - data: z.string().min(1, 'Data is required'), - ttlHours: z.number().optional(), -}) - -type KvFormData = z.infer - -interface KvFormDialogProps { - entry?: KvEntry - projectId?: string - open: boolean - onOpenChange: (open: boolean) => void -} - -export function KvFormDialog({ entry, projectId, open, onOpenChange }: KvFormDialogProps) { - const createMutation = useCreateKvEntry() - const updateMutation = useUpdateKvEntry() - - const [jsonError, setJsonError] = useState() - - const { - register, - handleSubmit, - reset, - formState: { errors }, - } = useForm({ - resolver: zodResolver(kvSchema), - defaultValues: { - key: '', - data: '', - ttlHours: undefined, - }, - }) - - useEffect(() => { - if (open) { - if (entry) { - const ttlMs = entry.expiresAt - entry.updatedAt - const ttlHoursValue = ttlMs > 0 && ttlMs < Number.MAX_SAFE_INTEGER - ? Math.round(ttlMs / (1000 * 60 * 60)) - : undefined - - reset({ - key: entry.key, - data: JSON.stringify(entry.data, null, 2), - ttlHours: ttlHoursValue, - }) - } else { - reset({ - key: '', - data: '', - ttlHours: undefined, - }) - } - setJsonError(undefined) - } - }, [open, entry, reset]) - - const validateJson = (value: string): boolean => { - try { - JSON.parse(value) - setJsonError(undefined) - return true - } catch (e) { - setJsonError(e instanceof Error ? e.message : 'Invalid JSON') - return false - } - } - - const onSubmit = async (data: KvFormData) => { - if (!validateJson(data.data)) { - return - } - - const parsedData = JSON.parse(data.data) - const ttlMs = data.ttlHours ? data.ttlHours * 1000 * 60 * 60 : undefined - - if (entry) { - const updateData: UpdateKvEntryRequest = { - data: parsedData, - ttlMs, - } - await updateMutation.mutateAsync({ projectId: projectId!, key: entry.key, data: updateData }) - } else if (projectId) { - const createData: CreateKvEntryRequest = { - projectId, - key: data.key, - data: parsedData, - ttlMs, - } - await createMutation.mutateAsync(createData) - } - onOpenChange(false) - } - - const isLoading = createMutation.isPending || updateMutation.isPending - - return ( - - - - {entry ? 'Edit KV Entry' : 'Create KV Entry'} - - {entry - ? 'Update the KV entry data and TTL.' - : 'Add a new key-value entry to store project data.'} - - - -
-
- - - {errors.key && ( -

{errors.key.message}

- )} -
- -
- -