diff --git a/duck_core/api.py b/duck_core/api.py index 5335344..727dbca 100644 --- a/duck_core/api.py +++ b/duck_core/api.py @@ -50,6 +50,7 @@ class PasswordRequest(BaseModel): class MemoryRequest(BaseModel): text: str workspace: str | None = None + scope: str | None = None conversation_id: str | None = None memory_type: str = "note" importance: float = 0.5 @@ -145,11 +146,15 @@ def create_app() -> FastAPI: body.workspace or settings.workspace, ) history = await conversation_history(conversation.conversation_id) + memory_records = await relevant_memory( + body.message, conversation.workspace, conversation.conversation_id + ) result = await runtime.run_chat( body.message, conversation.workspace, body.debug, history_messages=history, + memory_records=memory_records, ) await conversations.add_message( conversation.conversation_id, @@ -180,6 +185,21 @@ def create_app() -> FastAPI: title = " ".join(message.strip().split()) return title[:60] or "New chat" + async def relevant_memory( + query: str, workspace: str, conversation_id: str | None + ) -> list[dict[str, str]]: + records = await memory_store.relevant( + workspace=workspace, + conversation_id=conversation_id, + query=query, + limit=8, + ) + return [ + {"scope": record.scope, "text": record.text} + for record in records + if record.text + ] + @app.get("/v1/conversations") async def list_conversations() -> list[dict[str, Any]]: return [conversation.model_dump() for conversation in await conversations.list()] @@ -226,6 +246,9 @@ def create_app() -> FastAPI: body.workspace or settings.workspace, ) history = await conversation_history(conversation.conversation_id) + memory_records = await relevant_memory( + body.message, conversation.workspace, conversation.conversation_id + ) task = await task_store.create_task( body.message, conversation.workspace, body.debug ) @@ -244,7 +267,9 @@ def create_app() -> FastAPI: reasoning_parts: list[str] = [] content_parts: list[str] = [] try: - messages = runtime.context_builder.build_basic_messages(task, history) + messages = runtime.context_builder.build_basic_messages( + task, history, memory_records + ) tool_observations = await runtime._run_action_loop( task.task_id, messages, conversation.workspace ) @@ -819,6 +844,10 @@ def create_app() -> FastAPI: record = await memory_store.add( text=body.text, workspace=body.workspace or settings.workspace, + scope=body.scope + or memory_store.infer_scope( + body.text, body.workspace or settings.workspace, body.conversation_id + ), conversation_id=body.conversation_id, memory_type=body.memory_type, importance=body.importance, diff --git a/duck_core/context_builder.py b/duck_core/context_builder.py index 961745a..8dbb2b3 100644 --- a/duck_core/context_builder.py +++ b/duck_core/context_builder.py @@ -6,8 +6,24 @@ class ContextBuilder: self, task: TaskState, history_messages: list[dict[str, str]] | None = None, + memory_records: list[dict[str, str]] | None = None, ) -> list[dict[str, str]]: + memory_messages = [] + if memory_records: + lines = [ + f"- {record.get('scope', 'memory')}: {record.get('text', '')}" + for record in memory_records + if record.get("text") + ] + if lines: + memory_messages.append( + { + "role": "system", + "content": "Relevant memory:\n" + "\n".join(lines), + } + ) return [ + *memory_messages, *(history_messages or []), { "role": "user", diff --git a/duck_core/memory/store.py b/duck_core/memory/store.py index 6e97b91..6373b7c 100644 --- a/duck_core/memory/store.py +++ b/duck_core/memory/store.py @@ -15,6 +15,7 @@ class MemoryRecord(BaseModel): id: int | None = None memory_id: str text: str + scope: str = "workspace" workspace: str conversation_id: str | None = None memory_type: str = "note" @@ -37,6 +38,7 @@ class MemoryStore: id integer primary key autoincrement, memory_id text not null unique, text text not null, + scope text not null default 'workspace', workspace text not null, conversation_id text, memory_type text not null, @@ -53,12 +55,22 @@ class MemoryStore: on memories(workspace, created_at) """ ) + await self._ensure_scope_column(db) await db.commit() + async def _ensure_scope_column(self, db: aiosqlite.Connection) -> None: + cursor = await db.execute("pragma table_info(memories)") + columns = {row[1] for row in await cursor.fetchall()} + if "scope" not in columns: + await db.execute( + "alter table memories add column scope text not null default 'workspace'" + ) + async def add( self, text: str, workspace: str, + scope: str = "workspace", conversation_id: str | None = None, memory_type: str = "note", importance: float = 0.5, @@ -72,13 +84,14 @@ class MemoryStore: cursor = await db.execute( """ insert into memories( - memory_id, text, workspace, conversation_id, memory_type, + memory_id, text, scope, workspace, conversation_id, memory_type, importance, metadata_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( memory_id, clean_text, + self._normalize_scope(scope), workspace, conversation_id, memory_type or "note", @@ -94,6 +107,7 @@ class MemoryStore: id=row_id, memory_id=memory_id, text=clean_text, + scope=self._normalize_scope(scope), workspace=workspace, conversation_id=conversation_id, memory_type=memory_type or "note", @@ -114,7 +128,7 @@ class MemoryStore: cursor = await db.execute( """ select * from memories - where workspace = ? + where workspace = ? or scope = 'global' order by importance desc, created_at desc limit ? """, @@ -164,11 +178,75 @@ class MemoryStore: rows = await cursor.fetchall() return [self._row_to_record(row) for row in rows] + async def relevant( + self, + workspace: str, + conversation_id: str | None = None, + query: str = "", + limit: int = 8, + ) -> list[MemoryRecord]: + await self.init() + bounded_limit = min(max(limit, 1), 30) + terms = [term.lower() for term in query.split() if len(term) >= 3] + async with aiosqlite.connect(self.db_path) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + """ + select * from memories + where scope = 'global' + or (scope = 'workspace' and workspace = ?) + or (scope = 'conversation' and workspace = ? and conversation_id = ?) + order by + case scope + when 'global' then 0 + when 'conversation' then 1 + else 2 + end, + importance desc, + created_at desc + limit ? + """, + (workspace, workspace, conversation_id, bounded_limit * 3), + ) + rows = await cursor.fetchall() + records = [self._row_to_record(row) for row in rows] + if not terms: + return records[:bounded_limit] + matching = [ + record + for record in records + if any(term in record.text.lower() for term in terms) + or record.scope == "global" + ] + return matching[:bounded_limit] + + def infer_scope(self, text: str, workspace: str, conversation_id: str | None) -> str: + lowered = text.lower() + global_markers = ( + "user prefers", + "пользователь предпочитает", + "отвечай", + "always", + "всегда", + "rx580", + "radeon", + "vulkan", + ) + if any(marker in lowered for marker in global_markers): + return "global" + if conversation_id and any(marker in lowered for marker in ("this chat", "этот чат", "диалог")): + return "conversation" + return "workspace" if workspace else "global" + + def _normalize_scope(self, scope: str) -> str: + return scope if scope in {"global", "workspace", "conversation"} else "workspace" + def _row_to_record(self, row: aiosqlite.Row) -> MemoryRecord: return MemoryRecord( id=row["id"], memory_id=row["memory_id"], text=row["text"], + scope=row["scope"], workspace=row["workspace"], conversation_id=row["conversation_id"], memory_type=row["memory_type"], diff --git a/duck_core/runtime_loop.py b/duck_core/runtime_loop.py index 8487e07..35e18a0 100644 --- a/duck_core/runtime_loop.py +++ b/duck_core/runtime_loop.py @@ -42,6 +42,7 @@ class RuntimeLoop: workspace: str | None = None, debug: bool = False, history_messages: list[dict[str, str]] | None = None, + memory_records: list[dict[str, str]] | None = None, ) -> ChatResult: task = await self.task_store.create_task(message, workspace, debug) await self.event_store.append( @@ -50,7 +51,9 @@ class RuntimeLoop: {"message": message, "workspace": workspace, "debug": debug}, ) try: - messages = self.context_builder.build_basic_messages(task, history_messages) + messages = self.context_builder.build_basic_messages( + task, history_messages, memory_records + ) tool_observations = await self._run_action_loop(task.task_id, messages, workspace) if any(observation.get("requires_approval") for observation in tool_observations): await self.task_store.waiting_for_approval(task.task_id) diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js index ccbc9ed..c376ba7 100644 --- a/duck_core/web/static/app.js +++ b/duck_core/web/static/app.js @@ -23,6 +23,31 @@ function setStatus(id, text, tone = "neutral") { node.dataset.tone = tone; } +function openActivity(tab = "") { + const drawer = document.querySelector("#activity-drawer"); + const backdrop = document.querySelector("#activity-backdrop"); + if (!drawer || !backdrop) return; + drawer.hidden = false; + backdrop.hidden = false; + if (tab) selectActivityTab(tab); +} + +function closeActivity() { + const drawer = document.querySelector("#activity-drawer"); + const backdrop = document.querySelector("#activity-backdrop"); + if (drawer) drawer.hidden = true; + if (backdrop) backdrop.hidden = true; +} + +function selectActivityTab(tab) { + document.querySelectorAll("[data-activity-tab]").forEach((button) => { + button.classList.toggle("active", button.dataset.activityTab === tab); + }); + document.querySelectorAll("[data-activity-panel]").forEach((panel) => { + panel.classList.toggle("active", panel.dataset.activityPanel === tab); + }); +} + function addMessage(role, content, meta = "", options = {}) { const list = document.querySelector("#messages"); if (!list) return; @@ -756,6 +781,15 @@ function bindChat() { document.querySelector("#reload-chat")?.addEventListener("click", () => { if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error); }); + document.querySelector("#activity-open")?.addEventListener("click", () => { + openActivity("events"); + }); + document.querySelector("#activity-close")?.addEventListener("click", closeActivity); + document.querySelector("#activity-backdrop")?.addEventListener("click", closeActivity); + document.querySelector("#activity-drawer")?.addEventListener("click", (event) => { + const tab = event.target.closest("[data-activity-tab]"); + if (tab) selectActivityTab(tab.dataset.activityTab); + }); document.querySelector("#refresh-audit")?.addEventListener("click", () => { refreshCommandAudit().catch(console.error); }); @@ -803,9 +837,6 @@ function bindChat() { event.preventDefault(); submitToolPassword(form).catch(console.error); }); - document.querySelector("#debug")?.addEventListener("change", (event) => { - document.querySelector("#debug-panel").hidden = !event.target.checked; - }); } async function loadSimplePages() { diff --git a/duck_core/web/static/style.css b/duck_core/web/static/style.css index 2c0394d..6eef87b 100644 --- a/duck_core/web/static/style.css +++ b/duck_core/web/static/style.css @@ -213,9 +213,22 @@ button { font-size: 12px; } +.utility-menu { + border-bottom: 1px solid rgba(255,255,255,0.12); + padding-bottom: 12px; +} + +.utility-menu summary { + cursor: pointer; + color: #cbd5e1; + font-size: 13px; + font-weight: 750; +} + .side-nav { display: grid; gap: 6px; + margin-top: 10px; } .side-nav a { @@ -381,7 +394,7 @@ dd { .chat-shell { display: grid; - grid-template-rows: auto minmax(0, 1fr) auto auto; + grid-template-rows: auto minmax(0, 1fr) auto; gap: 16px; min-width: 0; height: 100vh; @@ -418,6 +431,12 @@ dd { font-weight: 750; } +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + .secondary-button { background: #edf2f7; color: #1f2937; @@ -681,26 +700,6 @@ dd { font-size: 13px; } -.debug-panel { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, 0.8fr) minmax(280px, 0.85fr); - gap: 16px; - min-height: 180px; -} - -.debug-column { - min-width: 0; - padding: 14px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; -} - -.debug-column h3 { - margin: 0 0 10px; - font-size: 13px; -} - .debug-heading { display: flex; align-items: center; @@ -709,6 +708,97 @@ dd { margin-bottom: 10px; } +.activity-backdrop { + position: fixed; + inset: 0; + z-index: 20; + background: rgba(15, 23, 42, 0.28); +} + +.activity-drawer { + position: fixed; + top: 0; + right: 0; + z-index: 21; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + width: min(520px, 100vw); + height: 100vh; + background: var(--panel); + border-left: 1px solid var(--border); + box-shadow: -18px 0 45px rgba(15, 23, 42, 0.18); +} + +.drawer-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 18px; + border-bottom: 1px solid var(--border); +} + +.drawer-header h2, +.drawer-header p { + margin: 0; +} + +.drawer-header h2 { + font-size: 18px; +} + +.drawer-header p { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.icon-button { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 7px; + background: #edf2f7; + color: #1f2937; + font-weight: 800; +} + +.drawer-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} + +.drawer-tab { + border: 0; + border-radius: 7px; + padding: 8px 10px; + background: #edf2f7; + color: #475569; + font-size: 12px; + font-weight: 800; +} + +.drawer-tab.active { + background: var(--accent); + color: #ffffff; +} + +.drawer-section { + display: none; + min-height: 0; + overflow: auto; + padding: 14px; +} + +.drawer-section.active { + display: block; +} + .debug-heading h3 { margin: 0; } @@ -756,7 +846,7 @@ pre, .memory-list { display: grid; gap: 8px; - max-height: 170px; + max-height: none; overflow: auto; } @@ -909,9 +999,4 @@ pre, flex-direction: column; align-items: stretch; } - - .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 a6fb0c2..6021e09 100644 --- a/duck_core/web/templates/index.html +++ b/duck_core/web/templates/index.html @@ -17,13 +17,16 @@ - - Chat - Approvals - Skills - Memory - Experience - + + System + + Chat + Approvals + Skills + Memory + Experience + + @@ -41,7 +44,7 @@ - Show reasoning and events + Debug mode @@ -70,7 +73,10 @@ Chat Messages are processed by the local Qwen role mapping through Duck Core. - Reload + + Activity + Reload + @@ -86,35 +92,6 @@ - - - Event Timeline - - - - - Command Audit - Refresh - - - - - - Workspace Memory - Refresh - - - - Add - - - - Search - - - - - Скажи коротко, что ты DuckLM @@ -123,6 +100,47 @@ + + +
Messages are processed by the local Qwen role mapping through Duck Core.