Add agent memory: linked note store, MCP server, recall hook, and graph UI

The distilled knowledge layer over the raw log/transcript history, inspired by
TencentDB-Agent-Memory's memory-hub model, adapted to handler's invariants
(state lives only in the database; workers stay stateless and disposable):

- memory_notes + memory_links tables (migration 0014) with a portable DAL:
  scoped listing/search (project + global), idempotent linking, and a one-read
  graph. Deleting an agent nulls attribution but keeps its notes; deleting a
  project removes its notes and edges, leaving global knowledge intact.
- /memory API routes: reads on the normal token, note/link authoring on the
  admin token, plus GET /memory/graph for the dashboard.
- Bundled handler-memory MCP server (python -m handler.mcpserver), a
  dependency-free stdio JSON-RPC implementation injected into every launch's
  --mcp-config ahead of the DB connectors and allowlisted in generated
  settings, exposing memory_search / memory_get / memory_save / memory_link.
  Identity and DATABASE_URL arrive via the spawn env, same as hooks.
- SessionStart recall hook: injects the most recent notes in scope as
  additional context at session start, best-effort, never blocking.
- Memory page in the web UI: a hand-rolled force-directed SVG graph of the
  note web (colored by kind, hover highlights, click-through details), plus
  note/link authoring — the app's first visualization, no chart dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqkoYPX8NAo1V2KyXr1pk
This commit is contained in:
Claude
2026-08-04 13:49:47 +00:00
parent 682ea20ffc
commit dc80e7bc28
53 changed files with 1949 additions and 54 deletions
+18
View File
@@ -362,6 +362,21 @@ All routes require `Authorization: Bearer <AUTH_TOKEN>`. `GET /health` is unauth
| `GET /shared/log` | Cross-project feed of entries explicitly marked `global` |
| `GET /shared/context` · `GET /shared/context/:key` | Read shared key/value facts |
| `PUT /shared/context/:key` | Write a shared fact — requires the shared-write token |
| `GET /memory/notes` · `GET /memory/notes/:id` | Memory notes (`?project_id=` scopes, `?q=` searches) |
| `GET /memory/graph` | The whole note graph (notes + links) in one read |
| `POST`/`PATCH`/`DELETE /memory/notes…` · `POST`/`DELETE /memory/links…` | Author notes/links — requires the admin token |
## Agent memory
The distilled, linked knowledge layer over the raw log/transcript history: **notes**
(facts, decisions, gotchas, runbooks) scoped to a project or global, connected by
**links** into a graph the dashboard's Memory page draws. Every launch gets the bundled
`handler-memory` MCP server (injected into `--mcp-config` as
`python -m handler.mcpserver`, no operator setup), exposing `memory_search`,
`memory_get`, `memory_save`, and `memory_link`; a `SessionStart` hook injects the most
recent notes in scope, so knowledge from earlier runs arrives without being asked.
Notes live only in the database — like everything else, they survive disposable
workers by construction — and deleting an agent never deletes what it learned.
## Hooks
@@ -382,6 +397,9 @@ Wired into each agent as `python -m handler.hooks <event>`:
is denied on the first failure.
- **`Notification`** — POSTs a small JSON payload to `WEBHOOK_URL` (no-op when unset).
Never blocks the agent on delivery failure.
- **`SessionStart`** — memory recall: injects the most recent memory notes in the
agent's scope (its project + global) as additional context, plus a pointer at the
handler-memory MCP tools. Best-effort; never blocks the session.
Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`,
`HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `HANDLER_AGENT_ROLE`, `DATABASE_URL`), since
+13
View File
@@ -0,0 +1,13 @@
/* Memory page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { MemorySection } from "@/components/sections/MemorySection";
export default function MemoryPage() {
return (
<div className="main-scroll">
<MemorySection />
</div>
);
}
@@ -0,0 +1,508 @@
/* Memory — the distilled knowledge agents leave behind, drawn as the web of notes.
* Nodes are notes (colored by kind), edges are the links agents/operators asserted
* between them. The layout is a small hand-rolled force simulation — deliberately no
* chart library, matching the rest of the hand-rolled UI. Reads come from
* GET /memory/graph via the store; writes are direct admin-token calls. */
"use client";
import { useMemo, useState } from "react";
import type { MemoryLink, MemoryNote, NoteKind } from "@/lib/api";
import { useDashboard, type MemoryNoteBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Textarea } from "@/components/ui";
import { fmtFull } from "@/lib/format";
const KIND_COLORS: Record<string, string> = {
fact: "var(--lw-blue-400)",
decision: "var(--lw-indigo-400)",
gotcha: "var(--lw-warning-fg)",
runbook: "var(--lw-success-fg)",
};
const KINDS: NoteKind[] = ["fact", "decision", "gotcha", "runbook"];
const W = 900;
const H = 560;
interface Pos {
x: number;
y: number;
}
/* Deterministic force layout: seed on a circle (stable order = stable picture), then
* relax with link springs, pairwise repulsion, and a centering pull. Runs to a fixed
* iteration budget synchronously — the graphs here are distilled notes, not big data. */
function computeLayout(notes: MemoryNote[], links: MemoryLink[]): Map<number, Pos> {
const pos = new Map<number, Pos>();
const n = notes.length;
if (n === 0) return pos;
const cx = W / 2;
const cy = H / 2;
const seedR = Math.min(W, H) / 2 - 80;
notes.forEach((note, i) => {
const a = (2 * Math.PI * i) / n;
pos.set(note.id, { x: cx + seedR * Math.cos(a), y: cy + seedR * Math.sin(a) });
});
const ids = notes.map((note) => note.id);
const iterations = n > 150 ? 80 : 250;
const springLen = 120;
for (let it = 0; it < iterations; it++) {
const temp = 1 - it / iterations; // cool down
const force = new Map<number, Pos>(ids.map((id) => [id, { x: 0, y: 0 }]));
// Pairwise repulsion.
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = pos.get(ids[i])!;
const b = pos.get(ids[j])!;
let dx = a.x - b.x;
let dy = a.y - b.y;
const d2 = Math.max(dx * dx + dy * dy, 1);
const d = Math.sqrt(d2);
const rep = 2600 / d2;
dx = (dx / d) * rep;
dy = (dy / d) * rep;
const fa = force.get(ids[i])!;
const fb = force.get(ids[j])!;
fa.x += dx;
fa.y += dy;
fb.x -= dx;
fb.y -= dy;
}
}
// Link springs.
for (const ln of links) {
const a = pos.get(ln.src_note_id);
const b = pos.get(ln.dst_note_id);
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const pull = (d - springLen) * 0.02;
const fa = force.get(ln.src_note_id)!;
const fb = force.get(ln.dst_note_id)!;
fa.x += (dx / d) * pull;
fa.y += (dy / d) * pull;
fb.x -= (dx / d) * pull;
fb.y -= (dy / d) * pull;
}
// Centering pull + apply.
for (const id of ids) {
const p = pos.get(id)!;
const f = force.get(id)!;
f.x += (cx - p.x) * 0.005;
f.y += (cy - p.y) * 0.005;
p.x += Math.max(-12, Math.min(12, f.x)) * temp;
p.y += Math.max(-12, Math.min(12, f.y)) * temp;
p.x = Math.max(30, Math.min(W - 30, p.x));
p.y = Math.max(24, Math.min(H - 24, p.y));
}
}
return pos;
}
const emptyForm: MemoryNoteBody = { title: "", body: "", kind: "fact", project_id: "", tags: [] };
export function MemorySection() {
const s = useDashboard();
const [projectFilter, setProjectFilter] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const [hoverId, setHoverId] = useState<number | null>(null);
const [form, setForm] = useState<MemoryNoteBody>(emptyForm);
const [tagsText, setTagsText] = useState("");
const [editing, setEditing] = useState(false);
const [linkDst, setLinkDst] = useState("");
const [linkRelation, setLinkRelation] = useState("relates_to");
const notes = useMemo(() => {
if (!projectFilter) return s.memory.notes;
if (projectFilter === "global") return s.memory.notes.filter((n) => !n.project_id);
return s.memory.notes.filter(
(n) => n.project_id === projectFilter || !n.project_id,
);
}, [s.memory.notes, projectFilter]);
const links = useMemo(() => {
const inScope = new Set(notes.map((n) => n.id));
return s.memory.links.filter(
(ln) => inScope.has(ln.src_note_id) && inScope.has(ln.dst_note_id),
);
}, [s.memory.links, notes]);
/* Recompute only when the graph's shape actually changes, not on every poll. */
const layoutKey = useMemo(
() =>
notes.map((n) => n.id).join(",") +
"|" +
links.map((ln) => ln.id).join(","),
[notes, links],
);
const layout = useMemo(
() => computeLayout(notes, links),
// eslint-disable-next-line react-hooks/exhaustive-deps
[layoutKey],
);
const degree = useMemo(() => {
const d = new Map<number, number>();
for (const ln of links) {
d.set(ln.src_note_id, (d.get(ln.src_note_id) ?? 0) + 1);
d.set(ln.dst_note_id, (d.get(ln.dst_note_id) ?? 0) + 1);
}
return d;
}, [links]);
const selected = notes.find((n) => n.id === selectedId) ?? null;
const focusId = hoverId ?? selectedId;
const neighborhood = useMemo(() => {
if (focusId == null) return null;
const set = new Set<number>([focusId]);
for (const ln of links) {
if (ln.src_note_id === focusId) set.add(ln.dst_note_id);
if (ln.dst_note_id === focusId) set.add(ln.src_note_id);
}
return set;
}, [focusId, links]);
const selectedLinks = useMemo(
() =>
selected
? links.filter(
(ln) => ln.src_note_id === selected.id || ln.dst_note_id === selected.id,
)
: [],
[selected, links],
);
const titleOf = (id: number) => s.memory.notes.find((n) => n.id === id)?.title ?? `#${id}`;
const parseTags = (text: string) =>
text
.split(",")
.map((t) => t.trim())
.filter(Boolean);
const saveNote = async () => {
const body = { ...form, tags: parseTags(tagsText) };
const ok =
editing && selected
? await s.updateMemoryNote(selected.id, body)
: await s.createMemoryNote(body);
if (ok) {
setForm(emptyForm);
setTagsText("");
setEditing(false);
}
};
const startEdit = () => {
if (!selected) return;
setForm({
title: selected.title,
body: selected.body,
kind: (KINDS.includes(selected.kind as NoteKind) ? selected.kind : "fact") as NoteKind,
project_id: selected.project_id ?? "",
tags: selected.tags ?? [],
});
setTagsText((selected.tags ?? []).join(", "));
setEditing(true);
};
const addLink = async () => {
if (!selected || !linkDst) return;
const ok = await s.createMemoryLink(selected.id, Number(linkDst), linkRelation.trim());
if (ok) setLinkDst("");
};
const projectOptions = [
{ value: "", label: "All projects" },
{ value: "global", label: "Global only" },
...s.projects.map((p) => ({ value: p.id, label: p.id })),
];
return (
<>
<div className="section-head">
<div className="section-title">Memory</div>
<div className="section-desc">
The web of notes agents and operators leave behind facts, decisions, gotchas,
runbooks and how they connect.
</div>
</div>
<div className="section-body">
<div className="hstack wrap" style={{ alignItems: "flex-end", gap: 12 }}>
<div style={{ width: 220 }}>
<Select
label="Scope"
value={projectFilter}
onChange={setProjectFilter}
options={projectOptions}
/>
</div>
<div className="hstack" style={{ gap: 8, paddingBottom: 6 }}>
{KINDS.map((k) => (
<span key={k} className="hstack" style={{ gap: 5, alignItems: "center" }}>
<span
style={{
width: 9,
height: 9,
borderRadius: "50%",
background: KIND_COLORS[k],
display: "inline-block",
}}
/>
<span className="faint" style={{ fontSize: "var(--text-xs)" }}>
{k}
</span>
</span>
))}
</div>
</div>
{notes.length === 0 ? (
<div className="empty">
No memory notes yet. Agents write them with the handler-memory MCP tools
(memory_save / memory_link); you can add one below.
</div>
) : (
<Card>
<svg
viewBox={`0 0 ${W} ${H}`}
style={{ width: "100%", height: "auto", display: "block" }}
role="img"
aria-label="Graph of memory notes and their links"
>
{links.map((ln) => {
const a = layout.get(ln.src_note_id);
const b = layout.get(ln.dst_note_id);
if (!a || !b) return null;
const focused =
focusId != null &&
(ln.src_note_id === focusId || ln.dst_note_id === focusId);
return (
<g key={ln.id}>
<line
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke={focused ? "var(--lw-blue-300)" : "var(--border-strong)"}
strokeWidth={focused ? 1.6 : 1}
opacity={neighborhood && !focused ? 0.25 : 0.8}
/>
{focused && (
<text
x={(a.x + b.x) / 2}
y={(a.y + b.y) / 2 - 4}
textAnchor="middle"
fill="var(--text-faint)"
fontSize={10}
>
{ln.relation}
</text>
)}
</g>
);
})}
{notes.map((n) => {
const p = layout.get(n.id);
if (!p) return null;
const r = 6 + Math.min(degree.get(n.id) ?? 0, 6);
const dimmed = neighborhood != null && !neighborhood.has(n.id);
const isSelected = n.id === selectedId;
return (
<g
key={n.id}
transform={`translate(${p.x},${p.y})`}
style={{ cursor: "pointer" }}
opacity={dimmed ? 0.3 : 1}
onMouseEnter={() => setHoverId(n.id)}
onMouseLeave={() => setHoverId(null)}
onClick={() => {
setSelectedId(n.id === selectedId ? null : n.id);
setEditing(false);
}}
>
<circle
r={r}
fill={KIND_COLORS[n.kind] ?? "var(--lw-neutral-fg)"}
stroke={isSelected ? "var(--lw-white)" : "var(--surface-page)"}
strokeWidth={isSelected ? 2 : 1}
/>
<text
y={r + 12}
textAnchor="middle"
fill={dimmed ? "var(--text-faint)" : "var(--text-muted)"}
fontSize={10.5}
>
{n.title.length > 24 ? n.title.slice(0, 24) + "…" : n.title}
</text>
</g>
);
})}
</svg>
</Card>
)}
{selected && (
<Card>
<div className="card-head" style={{ marginBottom: 10 }}>
<span
className="card-title"
style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}
>
{selected.title}
</span>
<span className="hstack" style={{ gap: 8 }}>
<Badge>{selected.kind}</Badge>
<Badge>{selected.project_id ?? "global"}</Badge>
</span>
</div>
<p style={{ whiteSpace: "pre-wrap", margin: 0 }}>{selected.body}</p>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{selected.agent_id != null ? `written by agent ${selected.agent_id}` : "operator-authored"}
{" · updated "}
{fmtFull(selected.updated_at)}
{selected.tags?.length ? ` · tags: ${selected.tags.join(", ")}` : ""}
</p>
{selectedLinks.length > 0 && (
<div style={{ marginTop: 12 }}>
<div className="eyebrow" style={{ marginBottom: 6 }}>
Links
</div>
{selectedLinks.map((ln) => {
const otherId =
ln.src_note_id === selected.id ? ln.dst_note_id : ln.src_note_id;
const outgoing = ln.src_note_id === selected.id;
return (
<div key={ln.id} className="hstack" style={{ gap: 8, marginBottom: 4 }}>
<span className="faint" style={{ fontSize: "var(--text-xs)" }}>
{outgoing ? `${ln.relation}` : `${ln.relation}`}
</span>
<a
href="#"
onClick={(e) => {
e.preventDefault();
setSelectedId(otherId);
}}
>
{titleOf(otherId)}
</a>
<Button size="sm" variant="ghost" onClick={() => s.deleteMemoryLink(ln.id)}>
remove
</Button>
</div>
);
})}
</div>
)}
<div className="hstack wrap mt14" style={{ gap: 8, alignItems: "flex-end" }}>
<div style={{ width: 260 }}>
<Select
label="Link to"
value={linkDst}
onChange={setLinkDst}
options={[
{ value: "", label: "— pick a note —" },
...notes
.filter((n) => n.id !== selected.id)
.map((n) => ({ value: String(n.id), label: n.title })),
]}
/>
</div>
<div style={{ width: 160 }}>
<Input label="Relation" value={linkRelation} onChange={setLinkRelation} />
</div>
<Button disabled={!linkDst} onClick={addLink}>
Link
</Button>
<Button variant="secondary" onClick={startEdit}>
Edit
</Button>
<Button
variant="danger"
onClick={async () => {
await s.deleteMemoryNote(selected.id);
setSelectedId(null);
}}
>
Delete
</Button>
</div>
</Card>
)}
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span
className="card-title"
style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}
>
{editing && selected ? `Edit note: ${selected.title}` : "New note"}
</span>
</div>
<div className="form-grid">
<Input
label="Title"
value={form.title}
onChange={(v) => setForm((f) => ({ ...f, title: v }))}
placeholder="Short, searchable headline"
/>
<Select
label="Kind"
value={form.kind}
onChange={(v) => setForm((f) => ({ ...f, kind: v as NoteKind }))}
options={KINDS.map((k) => ({ value: k, label: k }))}
/>
<Select
label="Project"
value={form.project_id}
onChange={(v) => setForm((f) => ({ ...f, project_id: v }))}
options={[
{ value: "", label: "Global" },
...s.projects.map((p) => ({ value: p.id, label: p.id })),
]}
/>
<Input
label="Tags (comma-separated)"
value={tagsText}
onChange={setTagsText}
placeholder="auth, deploy"
/>
</div>
<Textarea
label="Body"
value={form.body}
onChange={(v) => setForm((f) => ({ ...f, body: v }))}
rows={5}
placeholder="The knowledge itself — written for a reader with no context."
/>
<div className="hstack mt14" style={{ gap: 8 }}>
<Button
variant="primary"
disabled={!form.title.trim() || !form.body.trim()}
onClick={saveNote}
>
{editing ? "Save changes" : "Add note"}
</Button>
{editing && (
<Button
variant="ghost"
onClick={() => {
setEditing(false);
setForm(emptyForm);
setTagsText("");
}}
>
Cancel
</Button>
)}
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
Writes require the admin token. Agents add notes themselves through the bundled
handler-memory MCP server.
</p>
</Card>
</div>
</>
);
}
+126 -2
View File
@@ -29,6 +29,8 @@ import {
type Command,
type Host,
type LogEntry,
type MemoryGraph,
type NoteKind,
type Project,
type Schedule,
type SharedContext,
@@ -43,6 +45,7 @@ export type Section =
| "servers"
| "activity"
| "shared"
| "memory"
| "claude";
/* The claude web-login flow, driven through the login_start / login_submit commands.
@@ -98,6 +101,8 @@ interface StoreValue {
commands: Command[];
schedules: Schedule[];
shared: { log: LogEntry[]; context: SharedContext[] };
/* The whole note graph (scoped client-side by the Memory section's project filter). */
memory: MemoryGraph;
cmd: CmdState;
lastError: string;
@@ -124,6 +129,13 @@ interface StoreValue {
pollCi: () => Promise<void>;
setSharedKey: (key: string, value: string) => Promise<boolean>;
// Memory (notes + links; direct writes like the Claude management pages)
createMemoryNote: (b: MemoryNoteBody) => Promise<boolean>;
updateMemoryNote: (id: number, b: Partial<MemoryNoteBody>) => Promise<boolean>;
deleteMemoryNote: (id: number) => Promise<void>;
createMemoryLink: (src: number, dst: number, relation: string) => Promise<boolean>;
deleteMemoryLink: (id: number) => Promise<void>;
// Claude web-login
claudeLogin: ClaudeLoginState;
startClaudeLogin: () => Promise<void>;
@@ -252,6 +264,15 @@ export interface HostBody {
generate_ssh_key: boolean;
}
export interface MemoryNoteBody {
title: string;
body: string;
kind: NoteKind;
/* Project scope as a select value; "" = a global note. */
project_id: string;
tags: string[];
}
const Ctx = createContext<StoreValue | null>(null);
export function useDashboard(): StoreValue {
@@ -302,6 +323,7 @@ export function DashboardProvider({
log: [],
context: [],
});
const [memory, setMemory] = useState<MemoryGraph>({ notes: [], links: [] });
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
const [lastError, setLastError] = useState("");
const [loading, setLoading] = useState(true);
@@ -453,6 +475,14 @@ export function DashboardProvider({
}
}, []);
const loadMemory = useCallback(async () => {
try {
setMemory(await clientRef.current.api<MemoryGraph>("/memory/graph"));
} catch (e) {
swallow(e);
}
}, []);
/* Models load separately from the rest of the Claude page: the Agents section's spawn
* dropdown needs them too, without dragging skills/connectors along. */
const loadClaudeModels = useCallback(async () => {
@@ -505,8 +535,9 @@ export function DashboardProvider({
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
if (s === "memory") await loadMemory();
if (s === "claude") await loadClaude();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels]);
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
@@ -536,9 +567,10 @@ export function DashboardProvider({
if (s === "activity") void loadCommands();
if (s === "schedules") void loadSchedules();
if (s === "shared") void loadShared();
if (s === "memory") void loadMemory();
if (s === "claude") void loadClaude();
},
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels],
);
const selectProject = useCallback(
@@ -1281,6 +1313,92 @@ export function DashboardProvider({
[loadShared],
);
// ---- memory actions (direct writes, admin token; mirror the shared/claude style) ----
const memoryWrite = useCallback(
async (fn: () => Promise<unknown>, okText: string) => {
try {
await fn();
setCmd({ text: okText, error: false, busy: false });
await loadMemory();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadMemory],
);
const createMemoryNote = useCallback(
(b: MemoryNoteBody) =>
memoryWrite(
() =>
clientRef.current.api("/memory/notes", {
body: {
title: b.title,
body: b.body,
kind: b.kind,
project_id: b.project_id || null,
tags: b.tags.length ? b.tags : null,
},
}),
"note saved",
),
[memoryWrite],
);
const updateMemoryNote = useCallback(
(id: number, b: Partial<MemoryNoteBody>) =>
memoryWrite(
() =>
clientRef.current.api(`/memory/notes/${id}`, {
method: "PATCH",
body: {
...(b.title !== undefined ? { title: b.title } : {}),
...(b.body !== undefined ? { body: b.body } : {}),
...(b.kind !== undefined ? { kind: b.kind } : {}),
...(b.project_id !== undefined ? { project_id: b.project_id || null } : {}),
...(b.tags !== undefined ? { tags: b.tags.length ? b.tags : null } : {}),
},
}),
"note updated",
),
[memoryWrite],
);
const deleteMemoryNote = useCallback(
async (id: number) => {
await memoryWrite(
() => clientRef.current.api(`/memory/notes/${id}`, { method: "DELETE" }),
"note deleted",
);
},
[memoryWrite],
);
const createMemoryLink = useCallback(
(src: number, dst: number, relation: string) =>
memoryWrite(
() =>
clientRef.current.api("/memory/links", {
body: { src_note_id: src, dst_note_id: dst, relation: relation || "relates_to" },
}),
"notes linked",
),
[memoryWrite],
);
const deleteMemoryLink = useCallback(
async (id: number) => {
await memoryWrite(
() => clientRef.current.api(`/memory/links/${id}`, { method: "DELETE" }),
"link removed",
);
},
[memoryWrite],
);
const value: StoreValue = {
section,
setSection,
@@ -1301,6 +1419,7 @@ export function DashboardProvider({
commands,
schedules,
shared,
memory,
cmd,
lastError,
loading,
@@ -1322,6 +1441,11 @@ export function DashboardProvider({
deleteSchedule,
pollCi,
setSharedKey,
createMemoryNote,
updateMemoryNote,
deleteMemoryNote,
createMemoryLink,
deleteMemoryLink,
claudeLogin,
startClaudeLogin,
submitClaudeCode,
+31
View File
@@ -209,6 +209,37 @@ export interface ClaudePermissions {
base_allow: string[];
}
/* ---- agent memory (the Memory page's note graph) ---- */
export type NoteKind = "fact" | "decision" | "gotcha" | "runbook";
export interface MemoryNote {
id: number;
project_id?: string | null; // null = global note
agent_id?: number | null; // authoring agent; null = operator-authored
title: string;
body: string;
kind: string;
tags?: string[] | null;
created_at: string;
updated_at: string;
}
export interface MemoryLink {
id: number;
src_note_id: number;
dst_note_id: number;
relation: string;
created_by_agent_id?: number | null;
created_at: string;
}
/* Everything the graph view draws, in one response (GET /memory/graph). */
export interface MemoryGraph {
notes: MemoryNote[];
links: MemoryLink[];
}
export interface SharedContext {
key: string;
value: string;
+1
View File
@@ -18,6 +18,7 @@ export const NAV_ROUTES: NavRoute[] = [
{ key: "servers", href: "/servers", label: "Git Servers" },
{ key: "activity", href: "/activity", label: "Activity" },
{ key: "shared", href: "/shared", label: "Shared" },
{ key: "memory", href: "/memory", label: "Memory" },
{ key: "claude", href: "/claude", label: "Claude" },
];
+2
View File
@@ -22,6 +22,7 @@ from .routes import (
hosts,
interaction,
login,
memory,
projects,
schedules,
shared,
@@ -53,6 +54,7 @@ def create_app() -> FastAPI:
app.include_router(hosts.router)
app.include_router(schedules.router)
app.include_router(shared.router)
app.include_router(memory.router)
# Optional CORS, only for operators who host the UI on a different origin than the
# API. Empty CORS_ORIGINS => middleware never added => behaviour identical to headless.
+130
View File
@@ -0,0 +1,130 @@
"""Agent memory: the distilled note store and its link graph.
Agents write here through the bundled handler-memory MCP server (direct DB, like the
hooks); these routes are the dashboard's window plus the operator's editing surface
no worker round-trip, nothing touches a live process, same trust model as the Claude
management pages. Reads take the normal token; writes take the admin token (the notes
feed every future agent's context, so authoring them is a control-surface action).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Connection
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import (
MemoryGraphOut,
MemoryLinkIn,
MemoryLinkOut,
MemoryNoteIn,
MemoryNoteOut,
MemoryNoteUpdateIn,
)
router = APIRouter(prefix="/memory", tags=["memory"], dependencies=[Depends(require_auth)])
def _note_or_404(conn: Connection, note_id: int) -> dict:
note = repo.get_memory_note(conn, note_id)
if note is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found")
return note
def _project_or_400(conn: Connection, project_id: str | None) -> None:
if project_id is not None and repo.get_project(conn, project_id) is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"project '{project_id}' not found"
)
@router.get("/notes", response_model=list[MemoryNoteOut])
def list_notes(
project_id: str | None = Query(None),
q: str | None = Query(None),
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
conn: Connection = Depends(db_conn),
) -> list[dict]:
"""Notes in scope, newest first; ``q`` switches to substring search (all terms)."""
if q:
return repo.search_memory_notes(conn, q, project_id=project_id, limit=limit)
return repo.list_memory_notes(conn, project_id=project_id, limit=limit, offset=offset)
@router.get("/graph", response_model=MemoryGraphOut)
def graph(
project_id: str | None = Query(None),
conn: Connection = Depends(db_conn),
) -> dict:
"""The whole web of notes in one read — what the Memory page draws."""
return repo.memory_graph(conn, project_id=project_id)
@router.get("/notes/{note_id}", response_model=MemoryNoteOut)
def get_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict:
return _note_or_404(conn, note_id)
@router.post(
"/notes",
response_model=MemoryNoteOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_note(body: MemoryNoteIn, conn: Connection = Depends(db_conn)) -> dict:
_project_or_400(conn, body.project_id)
return repo.create_memory_note(
conn,
title=body.title,
body=body.body,
kind=body.kind,
project_id=body.project_id,
agent_id=None, # operator-authored; agents write via the MCP server
tags=body.tags,
)
@router.patch(
"/notes/{note_id}", response_model=MemoryNoteOut, dependencies=[Depends(require_admin)]
)
def update_note(
note_id: int, body: MemoryNoteUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
_note_or_404(conn, note_id)
fields = body.model_dump(exclude_unset=True)
if "project_id" in fields:
_project_or_400(conn, fields["project_id"])
return repo.update_memory_note(conn, note_id, **fields)
@router.delete("/notes/{note_id}", dependencies=[Depends(require_admin)])
def delete_note(note_id: int, conn: Connection = Depends(db_conn)) -> dict:
note = _note_or_404(conn, note_id)
repo.delete_memory_note(conn, note_id)
return {"deleted": note["id"]}
@router.post(
"/links",
response_model=MemoryLinkOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_link(body: MemoryLinkIn, conn: Connection = Depends(db_conn)) -> dict:
if body.src_note_id == body.dst_note_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="a note cannot link to itself")
_note_or_404(conn, body.src_note_id)
_note_or_404(conn, body.dst_note_id)
return repo.create_memory_link(
conn, body.src_note_id, body.dst_note_id, relation=body.relation, agent_id=None
)
@router.delete("/links/{link_id}", dependencies=[Depends(require_admin)])
def delete_link(link_id: int, conn: Connection = Depends(db_conn)) -> dict:
if not repo.delete_memory_link(conn, link_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"link {link_id} not found")
return {"deleted": link_id}
+61
View File
@@ -610,3 +610,64 @@ class SharedContextOut(BaseModel):
value: str
set_by_agent_id: int | None = None
updated_at: datetime
# ---- agent memory (the /memory page + the handler-memory MCP server's store) ----
NoteKind = Literal["fact", "decision", "gotcha", "runbook"]
class MemoryNoteIn(BaseModel):
title: str = Field(min_length=1, max_length=500)
body: str = Field(min_length=1)
kind: NoteKind = "fact"
project_id: str | None = None # null = global note
tags: list[str] | None = None
class MemoryNoteUpdateIn(BaseModel):
"""Editable note columns; omit a field to leave it unchanged."""
title: str | None = Field(default=None, min_length=1, max_length=500)
body: str | None = Field(default=None, min_length=1)
kind: NoteKind | None = None
project_id: str | None = None
tags: list[str] | None = None
class MemoryNoteOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
project_id: str | None = None
agent_id: int | None = None
title: str
body: str
kind: str
tags: list[str] | None = None
created_at: datetime
updated_at: datetime
class MemoryLinkIn(BaseModel):
src_note_id: int
dst_note_id: int
relation: str = Field(default="relates_to", min_length=1, max_length=100)
class MemoryLinkOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
src_note_id: int
dst_note_id: int
relation: str
created_by_agent_id: int | None = None
created_at: datetime
class MemoryGraphOut(BaseModel):
"""Everything the graph view draws, in one response."""
notes: list[MemoryNoteOut]
links: list[MemoryLinkOut]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3471:function(e,a,n){Promise.resolve().then(n.t.bind(n,7960,23)),Promise.resolve().then(n.bind(n,5520))},5520:function(e,a,n){"use strict";n.d(a,{AppFrame:function(){return p}});var t=n(7437),s=n(2265),l=n(9376),r=n(171),o=n(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{key:"servers",href:"/servers",label:"Git Servers"},{key:"activity",href:"/activity",label:"Activity"},{key:"shared",href:"/shared",label:"Shared"},{key:"memory",href:"/memory",label:"Memory"},{key:"claude",href:"/claude",label:"Claude"}];function c(e){var a,n;let t=e.replace(/\/+$/,"")||"/";return"/login"===t?"claude":null!==(n=null===(a=i.find(e=>e.href===t))||void 0===a?void 0:a.key)&&void 0!==n?n:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},claude:{count:e=>e.claudeSkills.length+e.claudeConnectors.length+e.claudePlugins.length,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:a,children:n}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,s.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var a,n,s;let l=u[e.key],r=null!==(n=null==l?void 0:l.count(d))&&void 0!==n?n:0,i=null!==(s=null==l?void 0:null===(a=l.accent)||void 0===a?void 0:a.call(l,d))&&void 0!==s&&s;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:a,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),n]})]})}function h(e){let{error:a,onSubmit:n}=e,[l,r]=(0,s.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let a=l.trim();a&&n(a)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),a&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:a}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:a}=e,[n,o]=(0,s.useState)(null),[i,u]=(0,s.useState)(""),p=(0,l.usePathname)();(0,s.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let g=(0,s.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),v=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return n?(0,t.jsx)(r._,{token:n,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:v,children:a})}):(0,t.jsx)(h,{error:i,onSubmit:g})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=3471)}),_N_E=e.O()}]);
@@ -1 +0,0 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3471:function(e,a,n){Promise.resolve().then(n.t.bind(n,7960,23)),Promise.resolve().then(n.bind(n,5520))},5520:function(e,a,n){"use strict";n.d(a,{AppFrame:function(){return p}});var t=n(7437),s=n(2265),l=n(9376),r=n(171),o=n(7648);let i=[{key:"runs",href:"/",label:"Runs"},{key:"repositories",href:"/repositories",label:"Repositories"},{key:"agents",href:"/agents",label:"Agents"},{key:"schedules",href:"/schedules",label:"Schedules"},{key:"approvals",href:"/approvals",label:"Approvals"},{key:"servers",href:"/servers",label:"Git Servers"},{key:"activity",href:"/activity",label:"Activity"},{key:"shared",href:"/shared",label:"Shared"},{key:"claude",href:"/claude",label:"Claude"}];function c(e){var a,n;let t=e.replace(/\/+$/,"")||"/";return"/login"===t?"claude":null!==(n=null===(a=i.find(e=>e.href===t))||void 0===a?void 0:a.key)&&void 0!==n?n:"runs"}let u={runs:{count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},repositories:{count:e=>e.projects.length},agents:{count:e=>e.agents.length},schedules:{count:e=>e.schedules.length},approvals:{count:e=>e.approvals.length},servers:{count:e=>e.hosts.length},activity:{count:e=>e.commands.length},shared:{count:e=>e.shared.context.length},claude:{count:e=>e.claudeSkills.length+e.claudeConnectors.length+e.claudePlugins.length,accent:e=>"done"!==e.claudeLogin.status}};function d(e){let{onSignOut:a,children:n}=e,d=(0,r.Q)(),h=c((0,l.usePathname)()),{setSection:m}=d;return(0,s.useEffect)(()=>{m(h)},[h,m]),(0,t.jsxs)("div",{className:"app",children:[(0,t.jsxs)("aside",{className:"sidebar",children:[(0,t.jsxs)("div",{className:"brand",children:[(0,t.jsx)("span",{className:"logo"}),"Claude Monitor"]}),i.map(e=>{var a,n,s;let l=u[e.key],r=null!==(n=null==l?void 0:l.count(d))&&void 0!==n?n:0,i=null!==(s=null==l?void 0:null===(a=l.accent)||void 0===a?void 0:a.call(l,d))&&void 0!==s&&s;return(0,t.jsxs)(o.default,{href:e.href,className:"nav-item".concat(h===e.key?" active":""),children:[(0,t.jsx)("span",{children:e.label}),(0,t.jsx)("span",{className:"count",style:i?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,t.jsx)("div",{className:"sidebar-spacer"}),(0,t.jsxs)("div",{className:"sidebar-foot",children:[(0,t.jsxs)("button",{className:"nav-item",onClick:d.refresh,title:"Refresh now",children:[(0,t.jsx)("span",{children:"Refresh"}),(0,t.jsx)("span",{className:"count",children:"↻"})]}),(0,t.jsx)("button",{className:"nav-item",onClick:a,title:"Sign out / change token",children:(0,t.jsx)("span",{children:"Sign out"})})]})]}),(0,t.jsxs)("main",{className:"main",children:[d.cmd.text&&(0,t.jsx)("p",{className:"banner ".concat(d.cmd.error?"err":"ok"),style:{marginTop:16},children:d.cmd.text}),d.lastError&&(0,t.jsx)("p",{className:"banner err",style:{marginTop:12},children:d.lastError}),n]})]})}function h(e){let{error:a,onSubmit:n}=e,[l,r]=(0,s.useState)("");return(0,t.jsx)("div",{className:"gate",children:(0,t.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let a=l.trim();a&&n(a)},children:[(0,t.jsxs)("div",{className:"gate-brand",children:[(0,t.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,t.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,t.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:l,onChange:e=>r(e.target.value),autoFocus:!0}),a&&(0,t.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:a}),(0,t.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let m="handler_token";function p(e){let{children:a}=e,[n,o]=(0,s.useState)(null),[i,u]=(0,s.useState)(""),p=(0,l.usePathname)();(0,s.useEffect)(()=>{let e=window.localStorage.getItem(m);e&&o(e)},[]);let g=(0,s.useCallback)(e=>{window.localStorage.setItem(m,e),u(""),o(e)},[]),v=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null)},[]),f=(0,s.useCallback)(()=>{window.localStorage.removeItem(m),o(null),u("Invalid token — please try again.")},[]);return n?(0,t.jsx)(r._,{token:n,onUnauthorized:f,initialSection:c(p),children:(0,t.jsx)(d,{onSignOut:v,children:a})}):(0,t.jsx)(h,{error:i,onSubmit:g})}},7960:function(){}},function(e){e.O(0,[587,258,171,971,117,744],function(){return e(e.s=3471)}),_N_E=e.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[7839,["171","static/chunks/171-19de960b70cee25e.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
3:I[7839,["171","static/chunks/171-7adbcaabf9d14cdf.js","263","static/chunks/app/activity/page-809b415dd18cb14e.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["activity",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["activity",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","activity","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2506,["171","static/chunks/171-19de960b70cee25e.js","718","static/chunks/app/agents/page-65cc70b94cb9abd3.js"],"default",1]
3:I[2506,["171","static/chunks/171-7adbcaabf9d14cdf.js","718","static/chunks/app/agents/page-65cc70b94cb9abd3.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["agents",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["agents",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","agents","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[2651,["171","static/chunks/171-19de960b70cee25e.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
3:I[2651,["171","static/chunks/171-7adbcaabf9d14cdf.js","163","static/chunks/app/approvals/page-8d5622be330f4d01.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["approvals",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["approvals",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","approvals","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[6529,["171","static/chunks/171-19de960b70cee25e.js","877","static/chunks/app/claude/page-235e137771d99edc.js"],"default",1]
3:I[6529,["171","static/chunks/171-7adbcaabf9d14cdf.js","877","static/chunks/app/claude/page-235e137771d99edc.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["claude",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["claude",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","claude","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3807,["171","static/chunks/171-19de960b70cee25e.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
3:I[3807,["171","static/chunks/171-7adbcaabf9d14cdf.js","931","static/chunks/app/page-cf68f34e95b90a40.js"],"default",1]
4:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
5:I[4707,[],""]
6:I[6423,[],""]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"children":["$","$L5",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -2,7 +2,7 @@
3:I[6374,["626","static/chunks/app/login/page-b08c6695be5632dd.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["login",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["login",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","login","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9244,["171","static/chunks/171-7adbcaabf9d14cdf.js","872","static/chunks/app/memory/page-2d205a5c1ee769c1.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["memory",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["memory",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","memory","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3641,["171","static/chunks/171-19de960b70cee25e.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
3:I[3641,["171","static/chunks/171-7adbcaabf9d14cdf.js","27","static/chunks/app/repositories/page-fb0c29b8dc7ed817.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["repositories",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["repositories",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","repositories","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[5124,["171","static/chunks/171-19de960b70cee25e.js","95","static/chunks/app/schedules/page-4398c9c829e1b9fd.js"],"default",1]
3:I[5124,["171","static/chunks/171-7adbcaabf9d14cdf.js","95","static/chunks/app/schedules/page-4398c9c829e1b9fd.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["schedules",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["schedules",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","schedules","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[4646,["171","static/chunks/171-19de960b70cee25e.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
3:I[4646,["171","static/chunks/171-7adbcaabf9d14cdf.js","664","static/chunks/app/servers/page-7cfc66d88412e3d1.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["servers",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["servers",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","servers","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,8 +1,8 @@
2:I[9107,[],"ClientPageRoot"]
3:I[9475,["171","static/chunks/171-19de960b70cee25e.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
3:I[9475,["171","static/chunks/171-7adbcaabf9d14cdf.js","856","static/chunks/app/shared/page-dc7068fcf86ab8e4.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-19de960b70cee25e.js","185","static/chunks/app/layout-c7c117ef7b86bf6f.js"],"AppFrame"]
0:["4cEQAcmEL0RnY9yw8HjTQ",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
6:I[5520,["258","static/chunks/258-022159d3c3089cd0.js","171","static/chunks/171-7adbcaabf9d14cdf.js","185","static/chunks/app/layout-21ca3d760368e2b4.js"],"AppFrame"]
0:["2k28Zwu9jDxUf-5jNvRk4",[[["",{"children":["shared",{"children":["__PAGE__",{}]}]},"$undefined","$undefined",true],["",{"children":["shared",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[null,["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children","shared","children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","notFoundStyles":"$undefined"}]],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L6",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]}]],null],null],["$L7",null]]]]
7:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]]
1:null
+16 -1
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import json
import os
import shutil
import sys
from sqlalchemy import Connection
@@ -65,6 +66,20 @@ def write_mcp_config(working_dir: str, connectors: list[dict]) -> str | None:
return path
def memory_server_connector() -> dict:
"""The built-in handler-memory MCP server, shaped like a connector row so it flows
through ``write_mcp_config`` unchanged. Runs under the worker's own interpreter (the
subprocess inherits the agent's spawn env, so identity + DATABASE_URL arrive the
same way they do for hooks). Injected ahead of the DB connectors an operator row
named ``handler-memory`` deliberately overrides it."""
return {
"name": "handler-memory",
"transport": "stdio",
"command": sys.executable,
"args": ["-m", "handler.mcpserver"],
}
def _skills_root(home: str | None = None) -> str:
return os.path.join(home or os.path.expanduser("~"), ".claude", "skills")
@@ -144,6 +159,6 @@ def apply(working_dir: str, conn: Connection | None = None) -> dict:
else:
connectors = repo.list_claude_connectors(conn, enabled_only=True)
skills = _load_skills(conn)
mcp_path = write_mcp_config(working_dir, connectors)
mcp_path = write_mcp_config(working_dir, [memory_server_connector()] + connectors)
written = sync_user_skills(skills)
return {"mcp_config": mcp_path, "skills_written": len(written)}
+9
View File
@@ -48,6 +48,11 @@ def build_settings(conn: Connection | None = None) -> dict:
"SessionEnd": [
{"hooks": [{"type": "command", "command": _hook_command("session_end")}]}
],
# Memory recall: injects the project's recent memory notes as context at
# session start, so knowledge from earlier runs arrives without being asked.
"SessionStart": [
{"hooks": [{"type": "command", "command": _hook_command("session_start")}]}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|Bash",
@@ -77,6 +82,10 @@ def build_settings(conn: Connection | None = None) -> dict:
deny = list(stored.get("deny", []))
ask = list(stored.get("ask", []))
plugins = repo.list_claude_plugins(conn, enabled_only=True)
# The bundled memory MCP server's tools are always usable — memory is only worth
# anything if agents can actually reach it under auto-deny.
if "mcp__handler-memory" not in allow:
allow.append("mcp__handler-memory")
permissions: dict = {"defaultMode": mode, "allow": allow}
if deny:
permissions["deny"] = deny
+202
View File
@@ -36,6 +36,8 @@ from .tables import (
commands,
forge_hosts,
log_entries,
memory_links,
memory_notes,
projects,
runtime_secrets,
schedules,
@@ -383,6 +385,18 @@ def _purge_agent_dependents(conn: Connection, agent_ids: list[int]) -> None:
.where(shared_context.c.set_by_agent_id.in_(agent_ids))
.values(set_by_agent_id=None)
)
# Memory outlives its authors (that's the point) — null the attribution like
# shared_context, never delete the notes.
conn.execute(
memory_notes.update()
.where(memory_notes.c.agent_id.in_(agent_ids))
.values(agent_id=None)
)
conn.execute(
memory_links.update()
.where(memory_links.c.created_by_agent_id.in_(agent_ids))
.values(created_by_agent_id=None)
)
conn.execute(approvals.delete().where(approvals.c.approved_by_agent_id.in_(agent_ids)))
conn.execute(checkmarks.delete().where(checkmarks.c.agent_id.in_(agent_ids)))
conn.execute(log_entries.delete().where(log_entries.c.agent_id.in_(agent_ids)))
@@ -409,6 +423,22 @@ def delete_project(conn: Connection, project_id: str) -> bool:
).all()
]
_purge_agent_dependents(conn, agent_ids)
# Project-scoped memory goes with the project (links first — SQLite doesn't always
# enforce the ON DELETE CASCADE, so clear them explicitly). Global notes stay.
note_ids = [
row[0]
for row in conn.execute(
select(memory_notes.c.id).where(memory_notes.c.project_id == project_id)
).all()
]
if note_ids:
conn.execute(
memory_links.delete().where(
memory_links.c.src_note_id.in_(note_ids)
| memory_links.c.dst_note_id.in_(note_ids)
)
)
conn.execute(memory_notes.delete().where(memory_notes.c.id.in_(note_ids)))
conn.execute(schedules.delete().where(schedules.c.project_id == project_id))
conn.execute(approvals.delete().where(approvals.c.project_id == project_id))
conn.execute(commands.delete().where(commands.c.project_id == project_id))
@@ -1227,3 +1257,175 @@ def get_latest_claimed_command(conn: Connection, type: str) -> dict | None:
.limit(1)
).first()
return _row_to_dict(row)
# ------------------------------------------------------------------ agent memory (notes)
def list_memory_notes(
conn: Connection,
project_id: str | None = None,
include_global: bool = True,
limit: int = 200,
offset: int = 0,
) -> list[dict]:
"""Notes in scope, newest first. ``project_id=None`` means everything (the dashboard
graph); a project id narrows to that project plus the global notes unless told not
to (the MCP server's read scope: my project + what everyone shares)."""
stmt = select(memory_notes)
if project_id is not None:
scope = memory_notes.c.project_id == project_id
if include_global:
scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope)
rows = conn.execute(
stmt.order_by(memory_notes.c.id.desc()).limit(limit).offset(offset)
).all()
return [dict(r._mapping) for r in rows]
def get_memory_note(conn: Connection, note_id: int) -> dict | None:
row = conn.execute(select(memory_notes).where(memory_notes.c.id == note_id)).first()
return _row_to_dict(row)
def search_memory_notes(
conn: Connection,
query: str,
project_id: str | None = None,
include_global: bool = True,
limit: int = 20,
) -> list[dict]:
"""Case-insensitive substring search over title/body/kind, every term required.
Deliberately plain LIKE, not FTS: it renders identically on Postgres and SQLite and
is comfortably fast at the scale of a distilled note store. An empty query returns
the most recent notes in scope 'what's new' is a real question agents ask.
"""
terms = [t for t in query.split() if t]
stmt = select(memory_notes)
if project_id is not None:
scope = memory_notes.c.project_id == project_id
if include_global:
scope = scope | memory_notes.c.project_id.is_(None)
stmt = stmt.where(scope)
for term in terms:
pattern = f"%{term}%"
stmt = stmt.where(
memory_notes.c.title.ilike(pattern)
| memory_notes.c.body.ilike(pattern)
| memory_notes.c.kind.ilike(pattern)
)
rows = conn.execute(stmt.order_by(memory_notes.c.id.desc()).limit(limit)).all()
return [dict(r._mapping) for r in rows]
def create_memory_note(
conn: Connection,
title: str,
body: str,
kind: str = "fact",
project_id: str | None = None,
agent_id: int | None = None,
tags: list[str] | None = None,
) -> dict:
now = _now()
result = conn.execute(
memory_notes.insert().values(
project_id=project_id,
agent_id=agent_id,
title=title,
body=body,
kind=kind,
tags=tags,
created_at=now,
updated_at=now,
)
)
return get_memory_note(conn, result.inserted_primary_key[0])
def update_memory_note(conn: Connection, note_id: int, **fields: Any) -> dict | None:
allowed = {"title", "body", "kind", "tags", "project_id"}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
values["updated_at"] = _now()
conn.execute(memory_notes.update().where(memory_notes.c.id == note_id).values(**values))
return get_memory_note(conn, note_id)
def delete_memory_note(conn: Connection, note_id: int) -> bool:
"""Remove a note and its edges (both directions — explicit, not trusting CASCADE
on SQLite)."""
conn.execute(
memory_links.delete().where(
(memory_links.c.src_note_id == note_id) | (memory_links.c.dst_note_id == note_id)
)
)
result = conn.execute(memory_notes.delete().where(memory_notes.c.id == note_id))
return result.rowcount > 0
def list_memory_links(conn: Connection, note_ids: list[int] | None = None) -> list[dict]:
"""All edges, or only those touching the given notes (either endpoint)."""
stmt = select(memory_links)
if note_ids is not None:
if not note_ids:
return []
stmt = stmt.where(
memory_links.c.src_note_id.in_(note_ids) | memory_links.c.dst_note_id.in_(note_ids)
)
rows = conn.execute(stmt.order_by(memory_links.c.id)).all()
return [dict(r._mapping) for r in rows]
def get_memory_link(conn: Connection, link_id: int) -> dict | None:
row = conn.execute(select(memory_links).where(memory_links.c.id == link_id)).first()
return _row_to_dict(row)
def create_memory_link(
conn: Connection,
src_note_id: int,
dst_note_id: int,
relation: str = "relates_to",
agent_id: int | None = None,
) -> dict:
"""Create an edge; saying the same thing twice returns the existing edge instead of
erroring agents re-assert connections and that must stay cheap and idempotent."""
existing = conn.execute(
select(memory_links).where(
memory_links.c.src_note_id == src_note_id,
memory_links.c.dst_note_id == dst_note_id,
memory_links.c.relation == relation,
)
).first()
if existing is not None:
return dict(existing._mapping)
result = conn.execute(
memory_links.insert().values(
src_note_id=src_note_id,
dst_note_id=dst_note_id,
relation=relation,
created_by_agent_id=agent_id,
created_at=_now(),
)
)
return get_memory_link(conn, result.inserted_primary_key[0])
def delete_memory_link(conn: Connection, link_id: int) -> bool:
result = conn.execute(memory_links.delete().where(memory_links.c.id == link_id))
return result.rowcount > 0
def memory_graph(conn: Connection, project_id: str | None = None) -> dict:
"""The whole graph in one read — what the /memory page draws. Scoping to a project
keeps its notes plus the global ones, and only edges with both endpoints in scope."""
notes = list_memory_notes(conn, project_id=project_id, limit=1000)
ids = [n["id"] for n in notes]
links = list_memory_links(conn, note_ids=ids)
in_scope = set(ids)
links = [
ln for ln in links if ln["src_note_id"] in in_scope and ln["dst_note_id"] in in_scope
]
return {"notes": notes, "links": links}
+51
View File
@@ -440,6 +440,57 @@ claude_config = Table(
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
)
# ---- Agent memory (the distilled, linked layer over the raw transcript/log history).
# Notes are the durable knowledge agents and operators leave behind — facts, decisions,
# gotchas, runbooks — scoped to a project or global, written by agents through the
# bundled handler-memory MCP server (or by the operator from the dashboard) and read
# back at the next launch. Links make the notes a graph the /memory page can draw.
NOTE_KINDS = ("fact", "decision", "gotcha", "runbook")
memory_notes = Table(
"memory_notes",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("project_id", String, ForeignKey("projects.id")), # null = global note
# The authoring agent, when an agent wrote it; null = the operator (dashboard).
# Nulled (not cascaded) when the agent is deleted — memory outlives its author.
Column("agent_id", BigInteger, ForeignKey("agents.id")),
Column("title", String, nullable=False),
Column("body", String, nullable=False),
Column("kind", String, nullable=False, server_default="fact"),
Column("tags", PortableJSON), # list of strings, for lightweight filtering
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()),
CheckConstraint(_in("kind", NOTE_KINDS), name="ck_memory_notes_kind"),
Index("ix_memory_notes_project_id", "project_id", "id"),
)
# Note-to-note edges. ``relation`` is a free label ("relates_to", "supersedes",
# "caused_by", …) — kept as TEXT, not a vocabulary, because agents coin them.
memory_links = Table(
"memory_links",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column(
"src_note_id",
BigInteger,
ForeignKey("memory_notes.id", ondelete="CASCADE"),
nullable=False,
),
Column(
"dst_note_id",
BigInteger,
ForeignKey("memory_notes.id", ondelete="CASCADE"),
nullable=False,
),
Column("relation", String, nullable=False, server_default="relates_to"),
Column("created_by_agent_id", BigInteger, ForeignKey("agents.id")),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
UniqueConstraint("src_note_id", "dst_note_id", "relation", name="uq_memory_links_edge"),
Index("ix_memory_links_src", "src_note_id"),
Index("ix_memory_links_dst", "dst_note_id"),
)
# Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues
# an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity
# audit trail like any other control action). Agent names must be unique per project, so
+6 -3
View File
@@ -1,6 +1,7 @@
"""Hook dispatch: ``python -m handler.hooks <event>``.
Events: ``stop``, ``session_end``, ``pre_tool_use``, ``notification``. Reads the event
Events: ``stop``, ``session_end``, ``session_start``, ``pre_tool_use``,
``notification``. Reads the event
JSON on stdin, resolves the acting agent, dispatches, and exits 0. A resolution failure
or unexpected error exits nonzero with a stderr message but never crashes the agent's
turn in a way that loses data.
@@ -11,10 +12,10 @@ from __future__ import annotations
import sys
from ..db.engine import connection
from . import checkpoint, gate, notify
from . import checkpoint, gate, memory_ctx, notify
from .context import read_input, resolve_identity
_EVENTS = {"stop", "session_end", "pre_tool_use", "notification"}
_EVENTS = {"stop", "session_end", "session_start", "pre_tool_use", "notification"}
def main(argv: list[str] | None = None) -> int:
@@ -34,6 +35,8 @@ def main(argv: list[str] | None = None) -> int:
if event in ("stop", "session_end"):
checkpoint.handle(conn, ident, hook_input)
elif event == "session_start":
memory_ctx.handle(conn, ident, hook_input)
elif event == "pre_tool_use":
gate.handle(conn, ident, hook_input)
elif event == "notification":
+53
View File
@@ -0,0 +1,53 @@
"""SessionStart — memory recall.
Injects the most recent memory notes in the agent's scope (its project + global) as
additional context, so knowledge distilled by earlier runs arrives without the agent
having to ask. Deterministic and cheap: titles + truncated bodies of the newest few
notes, plus a pointer at the handler-memory MCP tools for deeper search and for
writing new notes. Never blocks the session a memory failure must not stop work.
"""
from __future__ import annotations
from sqlalchemy import Connection
from ..db import repository as repo
from .context import HookInput, Identity, emit
_NOTE_LIMIT = 8
_BODY_CHARS = 400
def _render(notes: list[dict]) -> str:
lines = [
"## Team memory",
"Knowledge left by earlier agent runs and the operator. Search it with the "
"`memory_search` tool before re-deriving how something works; save durable "
"findings (facts, decisions, gotchas, runbooks) with `memory_save`, and "
"connect related notes with `memory_link`.",
]
if notes:
lines.append("")
lines.append("Most recent notes in scope (of the ones you can search):")
for n in notes:
scope = n["project_id"] or "global"
body = " ".join(n["body"].split())
if len(body) > _BODY_CHARS:
body = body[:_BODY_CHARS] + ""
lines.append(f"- [#{n['id']} · {n['kind']} · {scope}] {n['title']}: {body}")
return "\n".join(lines)
def handle(conn: Connection, ident: Identity, hook_input: HookInput) -> dict:
try:
notes = repo.list_memory_notes(conn, project_id=ident.project_id, limit=_NOTE_LIMIT)
except Exception:
notes = [] # recall is best-effort; the session must start regardless
result = {
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": _render(notes),
}
}
emit(result)
return result
+305
View File
@@ -0,0 +1,305 @@
"""The bundled ``handler-memory`` MCP server — the agents' read/write path to memory.
A deliberately dependency-free stdio MCP server (newline-delimited JSON-RPC 2.0, the
same wire shape as any ``.mcp.json`` stdio entry): ``claude_gen`` injects it into every
launch's ``--mcp-config`` as ``python -m handler.mcpserver``, and the subprocess
inherits the agent's spawn environment, so identity (``HANDLER_AGENT_ID`` /
``HANDLER_PROJECT_ID``) and ``DATABASE_URL`` arrive exactly the way they do for hooks.
It talks straight to the database memory is rows, workers stay stateless.
Tools: ``memory_search`` (substring search over the agent's project + global notes;
empty query = most recent), ``memory_get`` (one note with its links), ``memory_save``
(create, or update with ``note_id``), ``memory_link`` (connect two notes, idempotent).
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from typing import Any
PROTOCOL_VERSION = "2025-06-18"
SERVER_INFO = {"name": "handler-memory", "version": "0.1.0"}
_NOTE_KINDS = ["fact", "decision", "gotcha", "runbook"]
TOOLS: list[dict] = [
{
"name": "memory_search",
"description": (
"Search the team memory store (notes left by earlier agent runs and the "
"operator) for your project plus global notes. Every whitespace-separated "
"term must match the title, body, or kind (case-insensitive). An empty "
"query returns the most recent notes. Use this BEFORE re-deriving how "
"something works — an earlier run may have written it down."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search terms; empty = recent notes"},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
},
},
},
{
"name": "memory_get",
"description": "Fetch one memory note in full, including its links to other notes.",
"inputSchema": {
"type": "object",
"properties": {"note_id": {"type": "integer"}},
"required": ["note_id"],
},
},
{
"name": "memory_save",
"description": (
"Save durable knowledge for future agent runs: a fact about the system, a "
"decision and its rationale, a gotcha that cost you time, or a runbook. "
"Write it for a reader with no context from this session. Pass note_id to "
"update an existing note instead of creating a new one; pass global=true "
"only for knowledge that applies across every project."
),
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short, searchable headline"},
"body": {"type": "string", "description": "The knowledge itself, markdown ok"},
"kind": {"type": "string", "enum": _NOTE_KINDS, "default": "fact"},
"tags": {"type": "array", "items": {"type": "string"}},
"note_id": {"type": "integer", "description": "Update this note instead"},
"global": {
"type": "boolean",
"description": "Store unscoped (visible to every project)",
"default": False,
},
},
"required": ["title", "body"],
},
},
{
"name": "memory_link",
"description": (
"Connect two memory notes so the knowledge graph shows how they relate "
"(e.g. a gotcha caused_by a decision, a runbook supersedes an older one). "
"Idempotent: repeating an existing link is fine."
),
"inputSchema": {
"type": "object",
"properties": {
"src_note_id": {"type": "integer"},
"dst_note_id": {"type": "integer"},
"relation": {"type": "string", "default": "relates_to"},
},
"required": ["src_note_id", "dst_note_id"],
},
},
]
def _iso(v: Any) -> Any:
return v.isoformat() if isinstance(v, datetime) else v
def _note_json(note: dict, snippet: bool = False) -> dict:
out = {k: _iso(v) for k, v in note.items()}
if snippet and isinstance(out.get("body"), str) and len(out["body"]) > 300:
out["body"] = out["body"][:300] + ""
return out
class MemoryServer:
"""Tool dispatch against the handler database. One short connection per call —
the process lives as long as the agent's session, but holds nothing in memory."""
def __init__(self, agent_id: int | None, project_id: str | None):
self.agent_id = agent_id
self.project_id = project_id
def _connection(self):
from ..db.engine import connection
return connection()
# ---- tool implementations ----
def memory_search(self, args: dict) -> dict:
from ..db import repository as repo
query = (args.get("query") or "").strip()
limit = int(args.get("limit") or 10)
with self._connection() as conn:
notes = repo.search_memory_notes(
conn, query, project_id=self.project_id, limit=limit
)
return {
"count": len(notes),
"notes": [_note_json(n, snippet=True) for n in notes],
}
def memory_get(self, args: dict) -> dict:
from ..db import repository as repo
note_id = int(args["note_id"])
with self._connection() as conn:
note = repo.get_memory_note(conn, note_id)
if note is None:
raise ValueError(f"note {note_id} not found")
links = repo.list_memory_links(conn, note_ids=[note_id])
other_ids = {
(ln["dst_note_id"] if ln["src_note_id"] == note_id else ln["src_note_id"])
for ln in links
}
titles = {
n["id"]: n["title"]
for n in (repo.get_memory_note(conn, i) for i in other_ids)
if n is not None
}
return {
"note": _note_json(note),
"links": [
{
"id": ln["id"],
"src_note_id": ln["src_note_id"],
"dst_note_id": ln["dst_note_id"],
"relation": ln["relation"],
"other_title": titles.get(
ln["dst_note_id"] if ln["src_note_id"] == note_id else ln["src_note_id"]
),
}
for ln in links
],
}
def memory_save(self, args: dict) -> dict:
from ..db import repository as repo
title = (args.get("title") or "").strip()
body = (args.get("body") or "").strip()
if not title or not body:
raise ValueError("title and body are required")
kind = args.get("kind") or "fact"
if kind not in _NOTE_KINDS:
raise ValueError(f"kind must be one of {_NOTE_KINDS}")
tags = args.get("tags")
with self._connection() as conn:
if args.get("note_id"):
note_id = int(args["note_id"])
if repo.get_memory_note(conn, note_id) is None:
raise ValueError(f"note {note_id} not found")
note = repo.update_memory_note(
conn, note_id, title=title, body=body, kind=kind, tags=tags
)
return {"updated": True, "note": _note_json(note)}
project_id = None if args.get("global") else self.project_id
note = repo.create_memory_note(
conn,
title=title,
body=body,
kind=kind,
project_id=project_id,
agent_id=self.agent_id,
tags=tags,
)
return {"created": True, "note": _note_json(note)}
def memory_link(self, args: dict) -> dict:
from ..db import repository as repo
src, dst = int(args["src_note_id"]), int(args["dst_note_id"])
if src == dst:
raise ValueError("a note cannot link to itself")
relation = (args.get("relation") or "relates_to").strip() or "relates_to"
with self._connection() as conn:
for note_id in (src, dst):
if repo.get_memory_note(conn, note_id) is None:
raise ValueError(f"note {note_id} not found")
link = repo.create_memory_link(
conn, src, dst, relation=relation, agent_id=self.agent_id
)
return {"link": {k: _iso(v) for k, v in link.items()}}
def call_tool(self, name: str, args: dict) -> dict:
handlers = {
"memory_search": self.memory_search,
"memory_get": self.memory_get,
"memory_save": self.memory_save,
"memory_link": self.memory_link,
}
if name not in handlers:
raise ValueError(f"unknown tool '{name}'")
return handlers[name](args)
def handle_message(server: MemoryServer, msg: dict) -> dict | None:
"""One JSON-RPC message in, one response out (None for notifications)."""
method = msg.get("method")
msg_id = msg.get("id")
if msg_id is None:
return None # a notification (initialized, cancelled, …) — nothing to answer
if not method:
return _error(msg_id, -32600, "invalid request: no method")
if method == "initialize":
client_version = (msg.get("params") or {}).get("protocolVersion") or PROTOCOL_VERSION
return _result(
msg_id,
{
"protocolVersion": client_version,
"capabilities": {"tools": {}},
"serverInfo": SERVER_INFO,
},
)
if method == "ping":
return _result(msg_id, {})
if method == "tools/list":
return _result(msg_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params") or {}
name = params.get("name") or ""
args = params.get("arguments") or {}
try:
payload = server.call_tool(name, args)
content = [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
return _result(msg_id, {"content": content, "isError": False})
except Exception as exc: # tool errors go back in-band, per MCP
content = [{"type": "text", "text": f"error: {exc}"}]
return _result(msg_id, {"content": content, "isError": True})
return _error(msg_id, -32601, f"method '{method}' not supported")
def _result(msg_id: Any, result: dict) -> dict:
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def _error(msg_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def serve(stdin=None, stdout=None) -> int:
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately."""
import os
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
server = MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
)
for line in stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except ValueError:
print(
json.dumps(_error(None, -32700, "parse error")), file=stdout, flush=True
)
continue
response = handle_message(server, msg)
if response is not None:
print(json.dumps(response, ensure_ascii=False), file=stdout, flush=True)
return 0
+10
View File
@@ -0,0 +1,10 @@
"""``python -m handler.mcpserver`` — run the bundled handler-memory MCP server."""
from __future__ import annotations
import sys
from . import serve
if __name__ == "__main__":
sys.exit(serve())
@@ -0,0 +1,73 @@
"""agent memory: notes + links
Revision ID: 0014_agent_memory
Revises: 0013_schedule_model
Create Date: 2026-08-04
The distilled, linked knowledge layer over the raw transcript/log history. Agents write
notes (facts, decisions, gotchas, runbooks) through the bundled handler-memory MCP
server; operators write them from the dashboard's Memory page. Links make the notes a
graph the web of "how everything is connected" the /memory page draws. Scoped per
project or global; state lives only here, so it survives disposable workers by
construction.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp
revision: str = "0014_agent_memory"
down_revision: str | None = "0013_schedule_model"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"memory_notes",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("project_id", sa.String(), sa.ForeignKey("projects.id")),
sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id")),
sa.Column("title", sa.String(), nullable=False),
sa.Column("body", sa.String(), nullable=False),
sa.Column("kind", sa.String(), nullable=False, server_default="fact"),
sa.Column("tags", PortableJSON),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.CheckConstraint(
"kind IN ('fact', 'decision', 'gotcha', 'runbook')", name="ck_memory_notes_kind"
),
)
op.create_index("ix_memory_notes_project_id", "memory_notes", ["project_id", "id"])
op.create_table(
"memory_links",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column(
"src_note_id",
sa.BigInteger(),
sa.ForeignKey("memory_notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"dst_note_id",
sa.BigInteger(),
sa.ForeignKey("memory_notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("relation", sa.String(), nullable=False, server_default="relates_to"),
sa.Column("created_by_agent_id", sa.BigInteger(), sa.ForeignKey("agents.id")),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("src_note_id", "dst_note_id", "relation", name="uq_memory_links_edge"),
)
op.create_index("ix_memory_links_src", "memory_links", ["src_note_id"])
op.create_index("ix_memory_links_dst", "memory_links", ["dst_note_id"])
def downgrade() -> None:
op.drop_table("memory_links")
op.drop_table("memory_notes")
+4 -2
View File
@@ -434,9 +434,11 @@ def test_spawn_applies_managed_config(env, fake_launch):
agent = spawn.spawn("proj", "api", task="do it")
wd = agent["working_dir"]
# Disabled connectors are excluded from the generated --mcp-config file.
# Disabled connectors are excluded from the generated --mcp-config file; the
# built-in handler-memory server is always injected ahead of the DB connectors.
mcp = json.loads(open(claude_gen.mcp_config_path(wd)).read())
assert list(mcp["mcpServers"]) == ["github"]
assert list(mcp["mcpServers"]) == ["handler-memory", "github"]
assert mcp["mcpServers"]["handler-memory"]["args"] == ["-m", "handler.mcpserver"]
# Skills synced to the (test-scoped) user-level dir.
skill = os.path.join(str(env["tmp"]), ".claude", "skills", "deploy", "SKILL.md")
assert os.path.exists(skill)
+8 -2
View File
@@ -79,11 +79,17 @@ def test_spawn_creates_agent_settings_and_run(env, fake_launch):
with get_engine().begin() as conn:
assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"]
# settings.json wires all four hook events AND the headless permission allowlist
# settings.json wires all five hook events AND the headless permission allowlist
# (claude -p auto-denies anything that would prompt; the allowlist is what lets
# normal work proceed — the hooks stay the hard gate).
settings = json.loads((root / ".claude" / "settings.json").read_text())
assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"}
assert set(settings["hooks"]) == {
"Stop",
"SessionEnd",
"SessionStart",
"PreToolUse",
"Notification",
}
pre = settings["hooks"]["PreToolUse"][0]
assert pre["matcher"] == "AskUserQuestion|Bash"
assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"]
+268
View File
@@ -0,0 +1,268 @@
"""Agent memory: the notes/links DAL, the /memory API, the bundled handler-memory MCP
server, and the SessionStart recall hook."""
from __future__ import annotations
import json
import pytest
from handler.db import repository as repo
from handler.db.engine import get_engine
@pytest.fixture
def seeded(env):
"""Two projects, one agent, and a small note graph."""
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_project(conn, "other", "/tmp/other")
agent = repo.create_agent(conn, "proj", "api", "/tmp/proj", "working")
n1 = repo.create_memory_note(
conn, "Auth flow", "Tokens are minted in deps.py", "fact",
project_id="proj", agent_id=agent["id"], tags=["auth"],
)
n2 = repo.create_memory_note(
conn, "Use SQLite fallback", "Postgres is default, SQLite for tests",
"decision", project_id="proj",
)
n3 = repo.create_memory_note(
conn, "Global runbook", "How to rotate tokens", "runbook",
)
n4 = repo.create_memory_note(
conn, "Other-project note", "Not visible from proj scope", "fact",
project_id="other",
)
link = repo.create_memory_link(
conn, n1["id"], n2["id"], relation="caused_by", agent_id=agent["id"]
)
return {"agent": agent, "n1": n1, "n2": n2, "n3": n3, "n4": n4, "link": link}
# --- DAL -------------------------------------------------------------------------------
def test_scoped_listing_and_search(seeded):
with get_engine().begin() as conn:
# Project scope = its notes + global, newest first; never another project's.
notes = repo.list_memory_notes(conn, project_id="proj")
ids = [n["id"] for n in notes]
assert seeded["n4"]["id"] not in ids
assert {seeded["n1"]["id"], seeded["n2"]["id"], seeded["n3"]["id"]} <= set(ids)
# Every term must match; case-insensitive over title/body/kind.
hits = repo.search_memory_notes(conn, "auth tokens", project_id="proj")
assert [h["id"] for h in hits] == [seeded["n1"]["id"]]
# Empty query = recent notes in scope.
assert repo.search_memory_notes(conn, "", project_id="proj")
# kind matches too.
assert any(
h["id"] == seeded["n3"]["id"]
for h in repo.search_memory_notes(conn, "runbook", project_id="proj")
)
def test_link_idempotent_and_graph_scope(seeded):
with get_engine().begin() as conn:
again = repo.create_memory_link(
conn, seeded["n1"]["id"], seeded["n2"]["id"], relation="caused_by"
)
assert again["id"] == seeded["link"]["id"] # re-asserting is not a new edge
# A cross-scope link's edge drops out of a scoped graph when one endpoint is out.
repo.create_memory_link(conn, seeded["n1"]["id"], seeded["n4"]["id"])
graph = repo.memory_graph(conn, project_id="proj")
graph_ids = {n["id"] for n in graph["notes"]}
assert seeded["n4"]["id"] not in graph_ids
assert [ln["id"] for ln in graph["links"]] == [seeded["link"]["id"]]
def test_delete_note_removes_edges(seeded):
with get_engine().begin() as conn:
assert repo.delete_memory_note(conn, seeded["n2"]["id"])
assert repo.get_memory_note(conn, seeded["n2"]["id"]) is None
assert repo.list_memory_links(conn, note_ids=[seeded["n1"]["id"]]) == []
def test_agent_delete_keeps_notes_project_delete_removes_them(seeded):
with get_engine().begin() as conn:
# Deleting the authoring agent orphans the attribution, never the knowledge.
assert repo.delete_agent(conn, "proj", "api")
note = repo.get_memory_note(conn, seeded["n1"]["id"])
assert note is not None and note["agent_id"] is None
link = repo.get_memory_link(conn, seeded["link"]["id"])
assert link is not None and link["created_by_agent_id"] is None
# Deleting the project takes its notes (and their edges); global notes stay.
assert repo.delete_project(conn, "proj")
assert repo.get_memory_note(conn, seeded["n1"]["id"]) is None
assert repo.get_memory_note(conn, seeded["n2"]["id"]) is None
assert repo.get_memory_note(conn, seeded["n3"]["id"]) is not None
# --- API -------------------------------------------------------------------------------
def test_api_note_crud_and_graph(client, auth, seeded):
r = client.post(
"/memory/notes",
json={"title": "From the dashboard", "body": "operator wisdom", "kind": "gotcha"},
headers=auth,
)
assert r.status_code == 201
note = r.json()
assert note["agent_id"] is None and note["project_id"] is None
r = client.patch(f"/memory/notes/{note['id']}", json={"kind": "runbook"}, headers=auth)
assert r.status_code == 200 and r.json()["kind"] == "runbook"
r = client.get("/memory/notes?q=wisdom", headers=auth)
assert [n["id"] for n in r.json()] == [note["id"]]
r = client.get("/memory/graph?project_id=proj", headers=auth)
graph = r.json()
assert {n["id"] for n in graph["notes"]} >= {seeded["n1"]["id"], seeded["n3"]["id"]}
assert graph["links"][0]["relation"] == "caused_by"
assert client.delete(f"/memory/notes/{note['id']}", headers=auth).status_code == 200
assert client.get(f"/memory/notes/{note['id']}", headers=auth).status_code == 404
def test_api_validation(client, auth, seeded):
n1 = seeded["n1"]["id"]
# Unknown project scope on create; self-links; missing endpoints.
r = client.post(
"/memory/notes",
json={"title": "x", "body": "y", "project_id": "nope"},
headers=auth,
)
assert r.status_code == 400
r = client.post(
"/memory/links", json={"src_note_id": n1, "dst_note_id": n1}, headers=auth
)
assert r.status_code == 400
r = client.post(
"/memory/links", json={"src_note_id": n1, "dst_note_id": 999999}, headers=auth
)
assert r.status_code == 404
# Bad kind is a 422 straight from the schema.
r = client.post("/memory/notes", json={"title": "x", "body": "y", "kind": "poem"}, headers=auth)
assert r.status_code == 422
# No token, no memory.
assert client.get("/memory/notes").status_code == 401
# --- MCP server ------------------------------------------------------------------------
def _rpc(server, method, params=None, msg_id=1):
from handler.mcpserver import handle_message
return handle_message(server, {"jsonrpc": "2.0", "id": msg_id, "method": method,
"params": params or {}})
def _call_tool(server, name, args):
resp = _rpc(server, "tools/call", {"name": name, "arguments": args})
result = resp["result"]
return result["isError"], json.loads(result["content"][0]["text"]) if not result[
"isError"
] else result["content"][0]["text"]
def test_mcp_protocol_basics(seeded):
from handler.mcpserver import MemoryServer, handle_message
server = MemoryServer(agent_id=seeded["agent"]["id"], project_id="proj")
init = _rpc(server, "initialize", {"protocolVersion": "2025-06-18"})
assert init["result"]["serverInfo"]["name"] == "handler-memory"
assert init["result"]["protocolVersion"] == "2025-06-18"
# Notifications get no response; unknown methods error.
assert handle_message(server, {"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
assert _rpc(server, "resources/list")["error"]["code"] == -32601
tools = _rpc(server, "tools/list")["result"]["tools"]
assert {t["name"] for t in tools} == {
"memory_search", "memory_get", "memory_save", "memory_link",
}
def test_mcp_tools_roundtrip(seeded):
from handler.mcpserver import MemoryServer
server = MemoryServer(agent_id=seeded["agent"]["id"], project_id="proj")
err, saved = _call_tool(server, "memory_save", {
"title": "Worker heartbeats", "body": "Reaper marks stale workers crashed",
"kind": "fact", "tags": ["workers"],
})
assert not err and saved["created"]
assert saved["note"]["project_id"] == "proj"
assert saved["note"]["agent_id"] == seeded["agent"]["id"]
err, found = _call_tool(server, "memory_search", {"query": "heartbeats"})
assert not err and found["count"] == 1
# Search sees global notes; never the other project's.
err, found = _call_tool(server, "memory_search", {"query": "rotate tokens"})
assert not err and found["count"] == 1
err, found = _call_tool(server, "memory_search", {"query": "Other-project"})
assert not err and found["count"] == 0
err, linked = _call_tool(server, "memory_link", {
"src_note_id": saved["note"]["id"], "dst_note_id": seeded["n1"]["id"],
"relation": "relates_to",
})
assert not err and linked["link"]["created_by_agent_id"] == seeded["agent"]["id"]
err, got = _call_tool(server, "memory_get", {"note_id": saved["note"]["id"]})
assert not err
assert got["links"][0]["other_title"] == "Auth flow"
# Update in place via note_id.
err, updated = _call_tool(server, "memory_save", {
"note_id": saved["note"]["id"], "title": "Worker heartbeats",
"body": "expanded", "kind": "gotcha",
})
assert not err and updated["updated"]
# Tool errors come back in-band, not as protocol errors.
err, msg = _call_tool(server, "memory_get", {"note_id": 424242})
assert err and "not found" in msg
def test_mcp_global_save(seeded):
from handler.mcpserver import MemoryServer
server = MemoryServer(agent_id=None, project_id="proj")
err, saved = _call_tool(server, "memory_save", {
"title": "For everyone", "body": "x", "global": True,
})
assert not err and saved["note"]["project_id"] is None
# --- SessionStart recall hook + launch wiring ------------------------------------------
def test_session_start_hook_injects_notes(seeded, capsys):
from handler.hooks import memory_ctx
from handler.hooks.context import HookInput, Identity
ident = Identity(seeded["agent"]["id"], "proj", "api", "/tmp/proj")
with get_engine().begin() as conn:
result = memory_ctx.handle(conn, ident, HookInput(raw={}, event="session_start"))
ctx = result["hookSpecificOutput"]["additionalContext"]
assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart"
assert "Auth flow" in ctx and "Global runbook" in ctx
assert "Other-project note" not in ctx
assert "memory_search" in ctx # the pointer at the tools
# The hook wrote its JSON response to stdout for claude to consume.
assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]
def test_settings_wire_session_start_and_memory_allow(env):
from handler.control import settings_gen
settings = settings_gen.build_settings()
hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert hook.endswith("-m handler.hooks session_start")
assert "mcp__handler-memory" in settings["permissions"]["allow"]