Add audit and memory UI
This commit is contained in:
parent
61cdd8bbaf
commit
3d815d3ae3
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
)
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue