Add audit and memory UI

This commit is contained in:
mirivlad 2026-05-20 23:24:12 +08:00
parent 61cdd8bbaf
commit 3d815d3ae3
6 changed files with 532 additions and 4 deletions

View File

@ -6,7 +6,7 @@ from typing import Any
import uvicorn import uvicorn
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pydantic import BaseModel from pydantic import BaseModel
@ -17,6 +17,7 @@ from duck_core.conversations.store import ConversationStore
from duck_core.events.store import EventStore from duck_core.events.store import EventStore
from duck_core.experience.recorder import ExperienceRecorder from duck_core.experience.recorder import ExperienceRecorder
from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory from duck_core.memory.vector_memory import EmbeddingsUnavailableError, VectorMemory
from duck_core.memory.store import MemoryStore
from duck_core.model_client import ModelClient from duck_core.model_client import ModelClient
from duck_core.runtime_loop import RuntimeLoop from duck_core.runtime_loop import RuntimeLoop
from duck_core.skills.registry import SkillRegistry from duck_core.skills.registry import SkillRegistry
@ -46,6 +47,15 @@ class PasswordRequest(BaseModel):
password: str password: str
class MemoryRequest(BaseModel):
text: str
workspace: str | None = None
conversation_id: str | None = None
memory_type: str = "note"
importance: float = 0.5
metadata: dict[str, Any] = {}
def create_app() -> FastAPI: def create_app() -> FastAPI:
get_settings.cache_clear() get_settings.cache_clear()
settings = get_settings() settings = get_settings()
@ -69,6 +79,7 @@ def create_app() -> FastAPI:
skills = SkillRegistry("skills") skills = SkillRegistry("skills")
experience = ExperienceRecorder(settings.db_path) experience = ExperienceRecorder(settings.db_path)
memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None) memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None)
memory_store = MemoryStore(settings.db_path)
@app.on_event("startup") @app.on_event("startup")
async def startup() -> None: async def startup() -> None:
@ -77,11 +88,16 @@ def create_app() -> FastAPI:
await conversations.init() await conversations.init()
await approvals.init() await approvals.init()
await experience.init() await experience.init()
await memory_store.init()
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse: async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html")
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
return FileResponse("favicon.ico")
@app.get("/approvals", response_class=HTMLResponse) @app.get("/approvals", response_class=HTMLResponse)
async def approvals_page(request: Request) -> HTMLResponse: async def approvals_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "approvals.html") return templates.TemplateResponse(request, "approvals.html")
@ -796,8 +812,34 @@ def create_app() -> FastAPI:
raise HTTPException(status_code=404, detail="Experience record not found") raise HTTPException(status_code=404, detail="Experience record not found")
return record.model_dump() return record.model_dump()
@app.post("/v1/memory")
async def add_memory(body: MemoryRequest) -> dict[str, Any]:
if not body.text.strip():
raise HTTPException(status_code=400, detail="Memory text is required")
record = await memory_store.add(
text=body.text,
workspace=body.workspace or settings.workspace,
conversation_id=body.conversation_id,
memory_type=body.memory_type,
importance=body.importance,
metadata=body.metadata,
)
return record.model_dump()
@app.get("/v1/memory")
async def list_memory(
workspace: str | None = None, limit: int = 50
) -> dict[str, Any]:
records = await memory_store.list(workspace=workspace, limit=limit)
return {"results": [record.model_dump() for record in records]}
@app.get("/v1/memory/search") @app.get("/v1/memory/search")
async def search_memory(q: str) -> dict[str, Any]: async def search_memory(
q: str, workspace: str | None = None, limit: int = 20
) -> dict[str, Any]:
local_results = await memory_store.search(q, workspace=workspace, limit=limit)
if local_results:
return {"results": [record.model_dump() for record in local_results]}
try: try:
return {"results": await memory.search_memory(q)} return {"results": await memory.search_memory(q)}
except EmbeddingsUnavailableError as exc: except EmbeddingsUnavailableError as exc:

179
duck_core/memory/store.py Normal file
View File

@ -0,0 +1,179 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from uuid import uuid4
import aiosqlite
from pydantic import BaseModel, Field
from duck_core.tasks.store import utc_now
class MemoryRecord(BaseModel):
id: int | None = None
memory_id: str
text: str
workspace: str
conversation_id: str | None = None
memory_type: str = "note"
importance: float = 0.5
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: str
updated_at: str
class MemoryStore:
def __init__(self, db_path: str):
self.db_path = Path(db_path)
async def init(self) -> None:
self.db_path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
create table if not exists memories (
id integer primary key autoincrement,
memory_id text not null unique,
text text not null,
workspace text not null,
conversation_id text,
memory_type text not null,
importance real not null,
metadata_json text not null,
created_at text not null,
updated_at text not null
)
"""
)
await db.execute(
"""
create index if not exists idx_memories_workspace_created
on memories(workspace, created_at)
"""
)
await db.commit()
async def add(
self,
text: str,
workspace: str,
conversation_id: str | None = None,
memory_type: str = "note",
importance: float = 0.5,
metadata: dict[str, Any] | None = None,
) -> MemoryRecord:
await self.init()
now = utc_now()
memory_id = f"mem_{uuid4().hex[:12]}"
clean_text = " ".join(text.strip().split())
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
insert into memories(
memory_id, text, workspace, conversation_id, memory_type,
importance, metadata_json, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
memory_id,
clean_text,
workspace,
conversation_id,
memory_type or "note",
max(0.0, min(float(importance), 1.0)),
json.dumps(metadata or {}, ensure_ascii=False),
now,
now,
),
)
await db.commit()
row_id = cursor.lastrowid
return MemoryRecord(
id=row_id,
memory_id=memory_id,
text=clean_text,
workspace=workspace,
conversation_id=conversation_id,
memory_type=memory_type or "note",
importance=max(0.0, min(float(importance), 1.0)),
metadata=metadata or {},
created_at=now,
updated_at=now,
)
async def list(
self, workspace: str | None = None, limit: int = 50
) -> list[MemoryRecord]:
await self.init()
bounded_limit = min(max(limit, 1), 200)
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
if workspace:
cursor = await db.execute(
"""
select * from memories
where workspace = ?
order by importance desc, created_at desc
limit ?
""",
(workspace, bounded_limit),
)
else:
cursor = await db.execute(
"""
select * from memories
order by importance desc, created_at desc
limit ?
""",
(bounded_limit,),
)
rows = await cursor.fetchall()
return [self._row_to_record(row) for row in rows]
async def search(
self, query: str, workspace: str | None = None, limit: int = 20
) -> list[MemoryRecord]:
await self.init()
bounded_limit = min(max(limit, 1), 100)
pattern = f"%{query.strip()}%"
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
if workspace:
cursor = await db.execute(
"""
select * from memories
where workspace = ?
and (text like ? or memory_type like ? or metadata_json like ?)
order by importance desc, created_at desc
limit ?
""",
(workspace, pattern, pattern, pattern, bounded_limit),
)
else:
cursor = await db.execute(
"""
select * from memories
where text like ? or memory_type like ? or metadata_json like ?
order by importance desc, created_at desc
limit ?
""",
(pattern, pattern, pattern, bounded_limit),
)
rows = await cursor.fetchall()
return [self._row_to_record(row) for row in rows]
def _row_to_record(self, row: aiosqlite.Row) -> MemoryRecord:
return MemoryRecord(
id=row["id"],
memory_id=row["memory_id"],
text=row["text"],
workspace=row["workspace"],
conversation_id=row["conversation_id"],
memory_type=row["memory_type"],
importance=float(row["importance"]),
metadata=json.loads(row["metadata_json"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
)

View File

@ -3,6 +3,7 @@ const state = {
messages: [], messages: [],
currentConversationId: "", currentConversationId: "",
conversations: [], conversations: [],
auditFilter: "all",
}; };
async function jsonFetch(url, options) { async function jsonFetch(url, options) {
@ -372,6 +373,81 @@ async function refreshEvents(taskId) {
return events; return events;
} }
async function refreshCommandAudit() {
const container = document.querySelector("#command-audit");
if (!container) return;
const events = await jsonFetch("/v1/audit/commands?limit=20");
container.innerHTML = "";
if (!events.length) {
const empty = document.createElement("p");
empty.className = "compact-empty";
empty.textContent = "No command events yet.";
container.append(empty);
return;
}
for (const event of events) {
const payload = event.payload || {};
const item = document.createElement("article");
item.className = "audit-item";
const command = document.createElement("code");
command.textContent = payload.command || "shell command";
const meta = document.createElement("div");
meta.className = "audit-meta";
meta.append(
auditBadge(payload.action_type || "shell_command"),
auditBadge(payload.risk_level || "unknown", payload.risk_level),
auditBadge(payload.ok ? "ok" : "failed", payload.ok ? "ok" : "bad"),
);
const detail = document.createElement("span");
detail.textContent = [
payload.approved ? "approved" : "direct",
payload.blocked ? "blocked" : "",
payload.returncode !== null && payload.returncode !== undefined ? `exit ${payload.returncode}` : "",
].filter(Boolean).join(" · ");
item.append(command, meta, detail);
container.append(item);
}
}
function auditBadge(text, tone = "") {
const badge = document.createElement("span");
badge.className = "audit-badge";
badge.textContent = text;
if (tone) badge.dataset.tone = tone;
return badge;
}
async function refreshMemory(query = "") {
const container = document.querySelector("#memory-list");
if (!container) return;
const workspace = document.querySelector("#workspace")?.value || "./workspace";
const url = query.trim()
? `/v1/memory/search?q=${encodeURIComponent(query.trim())}&workspace=${encodeURIComponent(workspace)}`
: `/v1/memory?workspace=${encodeURIComponent(workspace)}&limit=20`;
const payload = await jsonFetch(url);
const results = payload.results || [];
container.innerHTML = "";
if (!results.length) {
const empty = document.createElement("p");
empty.className = "compact-empty";
empty.textContent = query.trim() ? "No matching memories." : "No workspace memories.";
container.append(empty);
return;
}
for (const memory of results) {
const item = document.createElement("article");
item.className = "memory-item";
const text = document.createElement("p");
text.textContent = memory.text;
const meta = document.createElement("span");
meta.textContent = `${memory.memory_type || "note"} · ${Number(memory.importance || 0).toFixed(1)} · ${memory.created_at || ""}`;
item.append(text, meta);
container.append(item);
}
}
function summarizeEvent(payload) { function summarizeEvent(payload) {
if (!payload || typeof payload !== "object") return ""; if (!payload || typeof payload !== "object") return "";
if (payload.role && payload.latency_ms) { if (payload.role && payload.latency_ms) {
@ -496,6 +572,8 @@ async function handleAssistantStreamEvent(pending, name, data, context) {
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn"); setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
finishInlineReasoning(pending, data.reasoning_content); finishInlineReasoning(pending, data.reasoning_content);
await refreshEvents(data.task_id); await refreshEvents(data.task_id);
await refreshCommandAudit();
await refreshMemory();
await refreshConversations(); await refreshConversations();
return; return;
} }
@ -592,6 +670,7 @@ async function selectConversation(conversationId) {
for (const message of conversation.messages) addStoredMessage(message); for (const message of conversation.messages) addStoredMessage(message);
} }
await refreshConversations(); await refreshConversations();
await refreshMemory();
} }
async function createNewConversation() { async function createNewConversation() {
@ -677,6 +756,34 @@ function bindChat() {
document.querySelector("#reload-chat")?.addEventListener("click", () => { document.querySelector("#reload-chat")?.addEventListener("click", () => {
if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error); if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error);
}); });
document.querySelector("#refresh-audit")?.addEventListener("click", () => {
refreshCommandAudit().catch(console.error);
});
document.querySelector("#refresh-memory")?.addEventListener("click", () => {
refreshMemory().catch(console.error);
});
document.querySelector("#memory-search-button")?.addEventListener("click", () => {
refreshMemory(document.querySelector("#memory-search-inline")?.value || "").catch(console.error);
});
document.querySelector("#memory-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const input = document.querySelector("#memory-text");
const text = input?.value.trim() || "";
if (!text) return;
await jsonFetch("/v1/memory", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
text,
workspace: document.querySelector("#workspace")?.value || "./workspace",
conversation_id: state.currentConversationId || null,
memory_type: "note",
importance: 0.6,
}),
});
input.value = "";
await refreshMemory();
});
document.querySelector("#conversation-list")?.addEventListener("click", (event) => { document.querySelector("#conversation-list")?.addEventListener("click", (event) => {
const item = event.target.closest("[data-conversation-id]"); const item = event.target.closest("[data-conversation-id]");
if (item) selectConversation(item.dataset.conversationId).catch(console.error); if (item) selectConversation(item.dataset.conversationId).catch(console.error);
@ -798,6 +905,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () =>
bindChat(); bindChat();
checkRuntime(); checkRuntime();
loadSimplePages().catch(console.error); loadSimplePages().catch(console.error);
refreshCommandAudit().catch(console.error);
refreshMemory().catch(console.error);
refreshConversations().then(() => { refreshConversations().then(() => {
if (state.conversations[0]) { if (state.conversations[0]) {
return selectConversation(state.conversations[0].conversation_id); return selectConversation(state.conversations[0].conversation_id);

View File

@ -683,7 +683,7 @@ dd {
.debug-panel { .debug-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(260px, 0.8fr) minmax(280px, 0.85fr);
gap: 16px; gap: 16px;
min-height: 180px; min-height: 180px;
} }
@ -701,6 +701,30 @@ dd {
font-size: 13px; font-size: 13px;
} }
.debug-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.debug-heading h3 {
margin: 0;
}
.mini-button,
.memory-form button,
.memory-search-row button {
border: 0;
border-radius: 7px;
padding: 7px 9px;
background: #edf2f7;
color: #1f2937;
font-size: 12px;
font-weight: 800;
}
pre, pre,
#events { #events {
margin: 0; margin: 0;
@ -728,6 +752,97 @@ pre,
color: var(--muted); color: var(--muted);
} }
.audit-list,
.memory-list {
display: grid;
gap: 8px;
max-height: 170px;
overflow: auto;
}
.audit-item,
.memory-item {
display: grid;
gap: 6px;
padding: 9px;
background: #f8fafc;
border: 1px solid #dbe3ee;
border-radius: 8px;
}
.audit-item code {
min-width: 0;
overflow: hidden;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.audit-badge {
padding: 2px 7px;
border-radius: 999px;
background: #e2e8f0;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.audit-badge[data-tone="low"],
.audit-badge[data-tone="ok"] {
background: #dcfce7;
color: #166534;
}
.audit-badge[data-tone="medium"],
.audit-badge[data-tone="high"] {
background: #fef3c7;
color: #854d0e;
}
.audit-badge[data-tone="critical"],
.audit-badge[data-tone="bad"] {
background: #fee2e2;
color: #991b1b;
}
.audit-item > span,
.memory-item span,
.compact-empty {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.memory-form,
.memory-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 8px;
}
.memory-form input,
.memory-search-row input {
min-width: 0;
padding: 8px 9px;
font-size: 12px;
}
.memory-item p {
margin: 0;
color: #0f172a;
font-size: 12px;
line-height: 1.35;
}
.composer { .composer {
display: grid; display: grid;
gap: 10px; gap: 10px;
@ -789,7 +904,6 @@ pre,
} }
.chat-header, .chat-header,
.debug-panel,
.composer-actions { .composer-actions {
grid-template-columns: 1fr; grid-template-columns: 1fr;
flex-direction: column; flex-direction: column;
@ -798,5 +912,6 @@ pre,
.debug-panel { .debug-panel {
display: grid; display: grid;
grid-template-columns: 1fr;
} }
} }

View File

@ -91,6 +91,28 @@
<h3>Event Timeline</h3> <h3>Event Timeline</h3>
<ol id="events"></ol> <ol id="events"></ol>
</div> </div>
<div class="debug-column audit-column">
<div class="debug-heading">
<h3>Command Audit</h3>
<button id="refresh-audit" class="mini-button" type="button">Refresh</button>
</div>
<div id="command-audit" class="audit-list"></div>
</div>
<div class="debug-column memory-column">
<div class="debug-heading">
<h3>Workspace Memory</h3>
<button id="refresh-memory" class="mini-button" type="button">Refresh</button>
</div>
<form id="memory-form" class="memory-form">
<input id="memory-text" placeholder="Add memory for this workspace" autocomplete="off">
<button type="submit">Add</button>
</form>
<div class="memory-search-row">
<input id="memory-search-inline" placeholder="Search memory" autocomplete="off">
<button id="memory-search-button" class="mini-button" type="button">Search</button>
</div>
<div id="memory-list" class="memory-list"></div>
</div>
</section> </section>
<form id="composer" class="composer"> <form id="composer" class="composer">

View File

@ -0,0 +1,61 @@
from fastapi.testclient import TestClient
from duck_core.api import create_app
from duck_core.memory.store import MemoryStore
def test_memory_api_stores_workspace_scoped_notes(tmp_path, monkeypatch):
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
client = TestClient(create_app())
first = client.post(
"/v1/memory",
json={
"text": "User prefers concise Russian answers.",
"workspace": "/tmp/project-a",
"conversation_id": "chat_a",
"memory_type": "preference",
"importance": 0.8,
},
).json()
client.post(
"/v1/memory",
json={
"text": "Different workspace note.",
"workspace": "/tmp/project-b",
"conversation_id": "chat_b",
},
)
listed = client.get("/v1/memory", params={"workspace": "/tmp/project-a"}).json()
search = client.get(
"/v1/memory/search",
params={"q": "concise Russian", "workspace": "/tmp/project-a"},
).json()
assert first["memory_id"].startswith("mem_")
assert [item["text"] for item in listed["results"]] == [
"User prefers concise Russian answers."
]
assert search["results"][0]["memory_id"] == first["memory_id"]
assert search["results"][0]["memory_type"] == "preference"
assert "Different workspace note." not in str(search)
async def test_memory_store_searches_text_and_metadata(tmp_path):
store = MemoryStore(str(tmp_path / "duck.sqlite3"))
await store.init()
await store.add(
text="RX580 should use Vulkan builds.",
workspace="/tmp/duck",
conversation_id="chat_runtime",
memory_type="fact",
importance=0.9,
metadata={"topic": "gpu"},
)
results = await store.search("vulkan", workspace="/tmp/duck")
assert len(results) == 1
assert results[0].workspace == "/tmp/duck"
assert results[0].metadata["topic"] == "gpu"