diff --git a/src/app/api/workspaces/[id]/memory/route.ts b/src/app/api/workspaces/[id]/memory/route.ts new file mode 100644 index 0000000..55d61d9 --- /dev/null +++ b/src/app/api/workspaces/[id]/memory/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from 'next/server' +import { db, schema } from '@/lib/db' +import { eq } from 'drizzle-orm' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { workspaceMemoryPath } from '@/lib/claude' + +export const runtime = 'nodejs' + +/** + * GET / POST shared memory file for a workspace. + * + * Storage: /.argus/MEMORY.md on the user's disk + * (created on first non-empty save, deleted when saved empty). + * + * On every agent run, the file's contents are prepended to the + * agent's system prompt via --append-system-prompt. See claude.ts. + */ + +async function loadWorkspace(id: string) { + const [row] = await db + .select() + .from(schema.workspaces) + .where(eq(schema.workspaces.id, id)) + return row ?? null +} + +async function assertWritableCwd( + cwd: string, +): Promise<{ ok: true } | { ok: false; status: number; error: string }> { + if (!path.isAbsolute(cwd)) { + return { + ok: false, + status: 400, + error: 'workspace cwd is not an absolute path', + } + } + try { + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) { + return { + ok: false, + status: 400, + error: 'workspace cwd is not a directory', + } + } + } catch { + return { + ok: false, + status: 400, + error: 'workspace cwd does not exist on disk', + } + } + return { ok: true } +} + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const workspace = await loadWorkspace(id) + if (!workspace) { + return NextResponse.json({ error: 'workspace not found' }, { status: 404 }) + } + const check = await assertWritableCwd(workspace.cwd) + if (!check.ok) { + return NextResponse.json({ error: check.error }, { status: check.status }) + } + try { + const content = await fs.readFile(workspaceMemoryPath(workspace.cwd), 'utf8') + return NextResponse.json({ content }) + } catch { + // File doesn't exist yet, return empty. + return NextResponse.json({ content: '' }) + } +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const workspace = await loadWorkspace(id) + if (!workspace) { + return NextResponse.json({ error: 'workspace not found' }, { status: 404 }) + } + const check = await assertWritableCwd(workspace.cwd) + if (!check.ok) { + return NextResponse.json({ error: check.error }, { status: check.status }) + } + + const body = await req.json().catch(() => null) + const content = + body && typeof body.content === 'string' ? body.content : '' + const target = workspaceMemoryPath(workspace.cwd) + + try { + if (content.trim() === '') { + // Empty save clears the file. Treat missing-file as success. + try { + await fs.unlink(target) + } catch { + // already gone, fine + } + return NextResponse.json({ ok: true, cleared: true }) + } + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(target, content, 'utf8') + return NextResponse.json({ ok: true }) + } catch (err) { + return NextResponse.json( + { + error: err instanceof Error ? err.message : 'write failed', + }, + { status: 500 }, + ) + } +} diff --git a/src/app/workspace/[id]/page.tsx b/src/app/workspace/[id]/page.tsx index a599337..548e5fc 100644 --- a/src/app/workspace/[id]/page.tsx +++ b/src/app/workspace/[id]/page.tsx @@ -5,6 +5,8 @@ import { desc, eq } from 'drizzle-orm' import { CreateAgentForm } from '@/components/CreateAgentForm' import { AgentPanel } from '@/components/AgentPanel' import { SkillsGrid } from '@/components/SkillsGrid' +import { SharedMemoryForm } from '@/components/SharedMemoryForm' +import { readWorkspaceMemory } from '@/lib/claude' export const dynamic = 'force-dynamic' @@ -26,6 +28,11 @@ export default async function WorkspacePage({ .where(eq(schema.agents.workspaceId, id)) .orderBy(desc(schema.agents.createdAt)) + // Pre-load the workspace's shared memory so the editor renders + // with the current contents on first paint. Missing file -> empty + // string, handled silently by readWorkspaceMemory. + const memoryContent = await readWorkspaceMemory(workspace.cwd) + return (
@@ -43,6 +50,24 @@ export default async function WorkspacePage({
+
+

+ Shared memory +

+

+ Prepended to every agent's system prompt in this workspace. + Lives at{' '} + .argus/MEMORY.md{' '} + inside the workspace directory. Save empty to clear. +

+
+ +
+
+

Skills diff --git a/src/components/SharedMemoryForm.tsx b/src/components/SharedMemoryForm.tsx new file mode 100644 index 0000000..29d2c1a --- /dev/null +++ b/src/components/SharedMemoryForm.tsx @@ -0,0 +1,87 @@ +'use client' + +import { useState } from 'react' + +type Status = + | { kind: 'idle' } + | { kind: 'saving' } + | { kind: 'ok'; message: string } + | { kind: 'error'; message: string } + +/** + * Editor for a workspace's shared MEMORY.md. Writes through the + * /api/workspaces/[id]/memory endpoint. Empty save clears the file. + */ +export function SharedMemoryForm({ + workspaceId, + initialContent, +}: { + workspaceId: string + initialContent: string +}) { + const [content, setContent] = useState(initialContent) + const [status, setStatus] = useState({ kind: 'idle' }) + + async function save() { + setStatus({ kind: 'saving' }) + try { + const res = await fetch(`/api/workspaces/${workspaceId}/memory`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + setStatus({ + kind: 'error', + message: body.error || 'save failed', + }) + return + } + setStatus({ + kind: 'ok', + message: content.trim() ? 'saved' : 'cleared', + }) + } catch (err) { + setStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'save failed', + }) + } + } + + return ( +
+