diff --git a/duck_core/api.py b/duck_core/api.py index dfbd252..5335344 100644 --- a/duck_core/api.py +++ b/duck_core/api.py @@ -6,7 +6,7 @@ from typing import Any import uvicorn 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.templating import Jinja2Templates 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.experience.recorder import ExperienceRecorder 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.runtime_loop import RuntimeLoop from duck_core.skills.registry import SkillRegistry @@ -46,6 +47,15 @@ class PasswordRequest(BaseModel): 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: get_settings.cache_clear() settings = get_settings() @@ -69,6 +79,7 @@ def create_app() -> FastAPI: skills = SkillRegistry("skills") experience = ExperienceRecorder(settings.db_path) memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None) + memory_store = MemoryStore(settings.db_path) @app.on_event("startup") async def startup() -> None: @@ -77,11 +88,16 @@ def create_app() -> FastAPI: await conversations.init() await approvals.init() await experience.init() + await memory_store.init() @app.get("/", response_class=HTMLResponse) async def index(request: Request) -> HTMLResponse: 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) async def approvals_page(request: Request) -> HTMLResponse: return templates.TemplateResponse(request, "approvals.html") @@ -796,8 +812,34 @@ def create_app() -> FastAPI: raise HTTPException(status_code=404, detail="Experience record not found") 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") - 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: return {"results": await memory.search_memory(q)} except EmbeddingsUnavailableError as exc: diff --git a/duck_core/memory/store.py b/duck_core/memory/store.py new file mode 100644 index 0000000..6e97b91 --- /dev/null +++ b/duck_core/memory/store.py @@ -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"], + ) diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js index 960e849..ccbc9ed 100644 --- a/duck_core/web/static/app.js +++ b/duck_core/web/static/app.js @@ -3,6 +3,7 @@ const state = { messages: [], currentConversationId: "", conversations: [], + auditFilter: "all", }; async function jsonFetch(url, options) { @@ -372,6 +373,81 @@ async function refreshEvents(taskId) { 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) { if (!payload || typeof payload !== "object") return ""; 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"); finishInlineReasoning(pending, data.reasoning_content); await refreshEvents(data.task_id); + await refreshCommandAudit(); + await refreshMemory(); await refreshConversations(); return; } @@ -592,6 +670,7 @@ async function selectConversation(conversationId) { for (const message of conversation.messages) addStoredMessage(message); } await refreshConversations(); + await refreshMemory(); } async function createNewConversation() { @@ -677,6 +756,34 @@ function bindChat() { document.querySelector("#reload-chat")?.addEventListener("click", () => { 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) => { const item = event.target.closest("[data-conversation-id]"); if (item) selectConversation(item.dataset.conversationId).catch(console.error); @@ -798,6 +905,8 @@ document.querySelector("#memory-search")?.addEventListener("click", async () => bindChat(); checkRuntime(); loadSimplePages().catch(console.error); +refreshCommandAudit().catch(console.error); +refreshMemory().catch(console.error); refreshConversations().then(() => { if (state.conversations[0]) { return selectConversation(state.conversations[0].conversation_id); diff --git a/duck_core/web/static/style.css b/duck_core/web/static/style.css index 76e1a11..2c0394d 100644 --- a/duck_core/web/static/style.css +++ b/duck_core/web/static/style.css @@ -683,7 +683,7 @@ dd { .debug-panel { display: grid; - grid-template-columns: minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.8fr) minmax(280px, 0.85fr); gap: 16px; min-height: 180px; } @@ -701,6 +701,30 @@ dd { 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, #events { margin: 0; @@ -728,6 +752,97 @@ pre, 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 { display: grid; gap: 10px; @@ -789,7 +904,6 @@ pre, } .chat-header, - .debug-panel, .composer-actions { grid-template-columns: 1fr; flex-direction: column; @@ -798,5 +912,6 @@ pre, .debug-panel { display: grid; + grid-template-columns: 1fr; } } diff --git a/duck_core/web/templates/index.html b/duck_core/web/templates/index.html index 0529cad..a6fb0c2 100644 --- a/duck_core/web/templates/index.html +++ b/duck_core/web/templates/index.html @@ -91,6 +91,28 @@

Event Timeline

    +
    +
    +

    Command Audit

    + +
    +
    +
    +
    +
    +

    Workspace Memory

    + +
    +
    + + +
    +
    + + +
    +
    +
    diff --git a/tests/smoke/test_memory_store.py b/tests/smoke/test_memory_store.py new file mode 100644 index 0000000..6f22434 --- /dev/null +++ b/tests/smoke/test_memory_store.py @@ -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"