Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/app/api/workspaces/[id]/memory/route.ts
Original file line number Diff line number Diff line change
@@ -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: <workspace.cwd>/.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 },
)
}
}
25 changes: 25 additions & 0 deletions src/app/workspace/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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 (
<div className="flex flex-col gap-10">
<section>
Expand All @@ -43,6 +50,24 @@ export default async function WorkspacePage({
</div>
</section>

<section>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-neutral-500">
Shared memory
</h2>
<p className="mt-1 max-w-2xl text-xs text-neutral-500">
Prepended to every agent&apos;s system prompt in this workspace.
Lives at{' '}
<code className="font-mono text-neutral-400">.argus/MEMORY.md</code>{' '}
inside the workspace directory. Save empty to clear.
</p>
<div className="mt-3 max-w-3xl">
<SharedMemoryForm
workspaceId={workspace.id}
initialContent={memoryContent}
/>
</div>
</section>

<section>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-neutral-500">
Skills
Expand Down
87 changes: 87 additions & 0 deletions src/components/SharedMemoryForm.tsx
Original file line number Diff line number Diff line change
@@ -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<Status>({ 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 (
<div className="flex flex-col gap-2">
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value)
if (status.kind !== 'idle' && status.kind !== 'saving') {
setStatus({ kind: 'idle' })
}
}}
placeholder="Notes, conventions, code style. Prepended to every agent's system prompt in this workspace."
rows={6}
className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 font-mono text-xs leading-relaxed focus:border-neutral-600 focus:outline-none"
/>
<div className="flex items-center gap-3">
<button
onClick={save}
disabled={status.kind === 'saving'}
className="rounded bg-amber-500 px-3 py-1.5 text-xs font-medium text-neutral-950 transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{status.kind === 'saving' ? 'saving...' : 'Save memory'}
</button>
{status.kind === 'ok' && (
<span className="text-xs text-neutral-500">{status.message}</span>
)}
{status.kind === 'error' && (
<span className="text-xs text-red-400">{status.message}</span>
)}
<span className="ml-auto font-mono text-[10px] text-neutral-600">
.argus/MEMORY.md
</span>
</div>
</div>
)
}
39 changes: 37 additions & 2 deletions src/lib/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,20 @@ export type AgentRunOptions = {
*/
export async function* runAgent(opts: AgentRunOptions) {
const args = ['-p', opts.prompt, '--output-format=stream-json', '--verbose']
if (opts.systemPrompt && opts.systemPrompt.trim()) {
args.push('--append-system-prompt', opts.systemPrompt)

// Compose the effective system prompt: workspace shared memory
// (if any) first, then the agent's own system prompt. Wrapped in
// labeled fences so the agent can tell where each layer ends.
const memory = await readWorkspaceMemory(opts.cwd)
const own = (opts.systemPrompt ?? '').trim()
const parts: string[] = []
if (memory) {
parts.push(`# workspace memory\n${memory}\n# end workspace memory`)
}
if (own) parts.push(own)
const effectiveSystemPrompt = parts.join('\n\n')
if (effectiveSystemPrompt) {
args.push('--append-system-prompt', effectiveSystemPrompt)
}
if (opts.model && opts.model.trim()) {
args.push('--model', opts.model)
Expand Down Expand Up @@ -169,6 +181,29 @@ export async function* runAgent(opts: AgentRunOptions) {
yield { type: 'exit', code: exitCode ?? 0 }
}

/**
* Path on disk where a workspace's shared memory lives. Sibling to
* .claude/, scoped to argus so it doesn't collide with anything else
* the workspace might be using.
*/
export function workspaceMemoryPath(cwd: string): string {
return path.join(cwd, '.argus', 'MEMORY.md')
}

/**
* Read the workspace's shared memory file. Returns an empty string if
* the file is missing, empty, or unreadable. Trimmed so an all-blank
* file behaves the same as no file.
*/
export async function readWorkspaceMemory(cwd: string): Promise<string> {
try {
const raw = await fs.readFile(workspaceMemoryPath(cwd), 'utf8')
return raw.trim()
} catch {
return ''
}
}

/**
* Write a per-agent .claude/settings.local.json into the workspace
* cwd that enables the listed skills. Best-effort: if writing fails
Expand Down
Loading