Replace repository with DuckLM runtime

This commit is contained in:
2026-05-20 01:00:28 +08:00
parent ddc285b8f4
commit 4a84ada770
190 changed files with 7060 additions and 13602 deletions
+3
View File
@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"
+348
View File
@@ -0,0 +1,348 @@
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
import uvicorn
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from duck_core.approvals.service import ApprovalService
from duck_core.config import get_settings
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.model_client import ModelClient
from duck_core.runtime_loop import RuntimeLoop
from duck_core.skills.registry import SkillRegistry
from duck_core.tasks.store import TaskStore
logger = logging.getLogger(__name__)
class ChatRequest(BaseModel):
message: str
workspace: str | None = None
debug: bool = False
def create_app() -> FastAPI:
settings = get_settings()
if settings.api_host == "0.0.0.0":
logger.warning(
"DuckLM API is listening on 0.0.0.0. This may expose local tool execution endpoints."
)
Path(settings.workspace).mkdir(parents=True, exist_ok=True)
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="DuckLM", version="0.1.0")
templates = Jinja2Templates(directory="duck_core/web/templates")
app.mount("/static", StaticFiles(directory="duck_core/web/static"), name="static")
task_store = TaskStore(settings.db_path)
event_store = EventStore(settings.db_path)
model_client = ModelClient()
approvals = ApprovalService(settings.db_path)
runtime = RuntimeLoop(task_store, event_store, model_client, approval_service=approvals)
skills = SkillRegistry("skills")
experience = ExperienceRecorder(settings.db_path)
memory = VectorMemory(settings.qdrant_url, embeddings_base_url=None)
@app.on_event("startup")
async def startup() -> None:
await task_store.init()
await event_store.init()
await approvals.init()
await experience.init()
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "index.html")
@app.get("/approvals", response_class=HTMLResponse)
async def approvals_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "approvals.html")
@app.get("/skills", response_class=HTMLResponse)
async def skills_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "skills.html")
@app.get("/memory", response_class=HTMLResponse)
async def memory_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "memory.html")
@app.get("/experience", response_class=HTMLResponse)
async def experience_page(request: Request) -> HTMLResponse:
return templates.TemplateResponse(request, "experience.html")
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/v1/status")
async def status() -> dict[str, Any]:
return {
"name": "DuckLM",
"version": "0.1.0",
"api_host": settings.api_host,
"api_port": settings.api_port,
"workspace": settings.workspace,
"db_path": settings.db_path,
}
@app.get("/v1/models/roles")
async def roles() -> dict[str, Any]:
return model_client.list_roles()
@app.get("/v1/models/ping")
async def models_ping() -> dict[str, Any]:
return await model_client.ping()
@app.post("/v1/chat")
async def chat(body: ChatRequest) -> dict[str, Any]:
result = await runtime.run_chat(body.message, body.workspace or settings.workspace, body.debug)
return result.__dict__
def sse(event: str, payload: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def emit_tool_events(task_id: str, after_sequence: int):
events = await event_store.list_events(task_id)
visible_types = {
"tool_call_started",
"tool_call_finished",
"tool_approval_requested",
}
for event in events:
if event.sequence > after_sequence and event.event_type in visible_types:
yield sse(event.event_type, event.model_dump())
@app.post("/v1/chat/stream")
async def chat_stream(body: ChatRequest) -> StreamingResponse:
async def generator():
task = await task_store.create_task(
body.message, body.workspace or settings.workspace, body.debug
)
task_event = await event_store.append(
task.task_id,
"task_created",
{
"message": body.message,
"workspace": body.workspace or settings.workspace,
"debug": body.debug,
},
)
yield sse("task_created", task_event.model_dump())
reasoning_parts: list[str] = []
content_parts: list[str] = []
try:
messages = runtime.context_builder.build_basic_messages(task)
tool_observations = await runtime._run_action_tools(
task.task_id, messages, body.workspace or settings.workspace
)
async for tool_event in emit_tool_events(task.task_id, task_event.sequence):
yield tool_event
if any(observation.get("requires_approval") for observation in tool_observations):
await task_store.waiting_for_approval(task.task_id)
await event_store.append(
task.task_id,
"task_waiting_for_approval",
{"observations": tool_observations},
)
yield sse(
"done",
{
"task_id": task.task_id,
"status": "waiting_for_approval",
"final_response": "Waiting for approval.",
"reasoning_content": None,
},
)
return
if tool_observations:
messages = [
*messages,
{
"role": "user",
"content": "tool_observations:\n"
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
},
]
await event_store.append(task.task_id, "model_call_started", {"role": "thinker"})
async for chunk in model_client.stream_chat("thinker", messages):
delta = str(chunk.get("delta") or "")
if chunk.get("type") == "reasoning_delta":
reasoning_parts.append(delta)
yield sse(
"reasoning_delta",
{"task_id": task.task_id, "delta": delta},
)
elif chunk.get("type") == "content_delta":
content_parts.append(delta)
yield sse(
"content_delta",
{"task_id": task.task_id, "delta": delta},
)
content = "".join(content_parts)
reasoning_content = "".join(reasoning_parts) or None
await event_store.append(
task.task_id,
"cognition_response",
{
"role": "thinker",
"content": content,
"reasoning_content": reasoning_content,
},
)
await event_store.append(
task.task_id,
"model_call_finished",
{
"role": "thinker",
"model": model_client.get_role_config("thinker").model,
},
)
await task_store.complete_task(task.task_id, content)
await event_store.append(
task.task_id,
"task_completed",
{
"final_response": content,
"reasoning_content": reasoning_content,
},
)
yield sse(
"done",
{
"task_id": task.task_id,
"status": "completed",
"final_response": content,
"reasoning_content": reasoning_content,
},
)
except Exception as exc:
await task_store.fail_task(task.task_id, str(exc))
await event_store.append(task.task_id, "task_failed", {"error": str(exc)})
yield sse(
"error",
{
"task_id": task.task_id,
"status": "failed",
"error": str(exc),
},
)
return StreamingResponse(generator(), media_type="text/event-stream")
@app.post("/v1/tasks")
async def create_task(body: ChatRequest) -> dict[str, Any]:
task = await task_store.create_task(body.message, body.workspace or settings.workspace, body.debug)
await event_store.append(task.task_id, "task_created", body.model_dump())
return task.model_dump()
@app.get("/v1/tasks")
async def list_tasks() -> list[dict[str, Any]]:
return [task.model_dump() for task in await task_store.list_tasks()]
@app.get("/v1/tasks/{task_id}")
async def get_task(task_id: str) -> dict[str, Any]:
task = await task_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task.model_dump()
@app.get("/v1/tasks/{task_id}/events")
async def get_events(task_id: str) -> list[dict[str, Any]]:
return [event.model_dump() for event in await event_store.list_events(task_id)]
@app.get("/v1/tasks/{task_id}/stream")
async def stream_events(task_id: str) -> StreamingResponse:
async def generator():
sent = 0
for _ in range(30):
events = await event_store.list_events(task_id)
for event in events[sent:]:
yield f"data: {json.dumps(event.model_dump())}\n\n"
sent = len(events)
await asyncio.sleep(1)
return StreamingResponse(generator(), media_type="text/event-stream")
@app.post("/v1/tasks/{task_id}/continue")
async def continue_task(task_id: str) -> dict[str, str]:
task = await task_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
await task_store.update_status(task_id, "running")
await event_store.append(task_id, "task_continued", {})
return {"status": "running"}
@app.post("/v1/tasks/{task_id}/cancel")
async def cancel_task(task_id: str) -> dict[str, str]:
await task_store.cancel_task(task_id)
await event_store.append(task_id, "task_cancelled", {})
return {"status": "cancelled"}
@app.get("/v1/approvals/pending")
async def pending_approvals() -> list[dict[str, Any]]:
return [approval.model_dump() for approval in await approvals.pending()]
@app.post("/v1/approvals/{approval_id}/allow_once")
async def allow_once(approval_id: str) -> dict[str, str]:
await approvals.allow_once(approval_id)
return {"status": "allowed_once"}
@app.post("/v1/approvals/{approval_id}/allow_forever")
async def allow_forever(approval_id: str) -> dict[str, str]:
await approvals.allow_forever(approval_id)
return {"status": "allowed_forever"}
@app.post("/v1/approvals/{approval_id}/deny")
async def deny(approval_id: str) -> dict[str, str]:
await approvals.deny(approval_id)
return {"status": "denied"}
@app.get("/v1/skills")
async def list_skills() -> list[dict[str, Any]]:
return [skill.model_dump() for skill in skills.load_skills()]
@app.get("/v1/skills/{skill_id}")
async def get_skill(skill_id: str) -> dict[str, Any]:
skill = skills.get_skill(skill_id)
if skill is None:
raise HTTPException(status_code=404, detail="Skill not found")
return skill.model_dump()
@app.get("/v1/experience")
async def list_experience() -> list[dict[str, Any]]:
return [record.model_dump() for record in await experience.list_records()]
@app.get("/v1/experience/{record_id}")
async def get_experience(record_id: int) -> dict[str, Any]:
record = await experience.get_record(record_id)
if record is None:
raise HTTPException(status_code=404, detail="Experience record not found")
return record.model_dump()
@app.get("/v1/memory/search")
async def search_memory(q: str) -> dict[str, Any]:
try:
return {"results": await memory.search_memory(q)}
except EmbeddingsUnavailableError as exc:
return {"results": [], "warning": str(exc)}
return app
app = create_app()
if __name__ == "__main__":
settings = get_settings()
uvicorn.run("duck_core.api:app", host=settings.api_host, port=settings.api_port, reload=False)
+1
View File
@@ -0,0 +1 @@
+143
View File
@@ -0,0 +1,143 @@
import hashlib
import json
from pathlib import Path
from typing import Any
from uuid import uuid4
import aiosqlite
from pydantic import BaseModel
from duck_core.tasks.store import utc_now
class Approval(BaseModel):
id: int | None = None
approval_id: str
task_id: str
action_hash: str
normalized_action: dict[str, Any]
status: str
decision: str | None = None
created_at: str
updated_at: str
def normalize_action(action: dict[str, Any]) -> str:
return json.dumps(action, sort_keys=True, separators=(",", ":"))
def action_hash(action: dict[str, Any]) -> str:
return hashlib.sha256(normalize_action(action).encode()).hexdigest()
class ApprovalService:
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 approvals (
id integer primary key autoincrement,
approval_id text not null unique,
task_id text not null,
action_hash text not null,
normalized_action_json text not null,
status text not null,
decision text,
created_at text not null,
updated_at text not null
)
"""
)
await db.commit()
async def create_pending(self, task_id: str, action: dict[str, Any]) -> Approval:
await self.init()
now = utc_now()
approval_id = f"approval_{uuid4().hex[:12]}"
normalized = normalize_action(action)
digest = action_hash(action)
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
insert into approvals(
approval_id, task_id, action_hash, normalized_action_json,
status, created_at, updated_at
) values (?, ?, ?, ?, ?, ?, ?)
""",
(approval_id, task_id, digest, normalized, "pending", now, now),
)
await db.commit()
row_id = cursor.lastrowid
return Approval(
id=row_id,
approval_id=approval_id,
task_id=task_id,
action_hash=digest,
normalized_action=action,
status="pending",
created_at=now,
updated_at=now,
)
async def pending(self) -> list[Approval]:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from approvals where status = 'pending' order by created_at"
)
rows = await cursor.fetchall()
return [self._row_to_approval(row) for row in rows]
async def allow_once(self, approval_id: str) -> None:
await self._decide(approval_id, "resolved", "allow_once")
async def allow_forever(self, approval_id: str) -> None:
await self._decide(approval_id, "allowed_forever", "allow_forever")
async def deny(self, approval_id: str) -> None:
await self._decide(approval_id, "resolved", "deny")
async def is_allowed_forever(self, action: dict[str, Any]) -> bool:
await self.init()
digest = action_hash(action)
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
select 1 from approvals
where action_hash = ? and status = 'allowed_forever'
limit 1
""",
(digest,),
)
row = await cursor.fetchone()
return row is not None
async def _decide(self, approval_id: str, status: str, decision: str) -> None:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
update approvals set status = ?, decision = ?, updated_at = ?
where approval_id = ?
""",
(status, decision, utc_now(), approval_id),
)
await db.commit()
def _row_to_approval(self, row: aiosqlite.Row) -> Approval:
return Approval(
id=row["id"],
approval_id=row["approval_id"],
task_id=row["task_id"],
action_hash=row["action_hash"],
normalized_action=json.loads(row["normalized_action_json"]),
status=row["status"],
decision=row["decision"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
+56
View File
@@ -0,0 +1,56 @@
import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
@dataclass(frozen=True)
class Settings:
llama_server_bin: str = "llama-server"
main_model_path: str = "./models/Qwen3.6/nonMTP/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"
main_port: int = 8081
ctx_size: int = 65536
n_gpu_layers: str = "auto"
host: str = "127.0.0.1"
api_host: str = "127.0.0.1"
api_port: int = 8000
workspace: str = "./workspace"
db_path: str = "./data/duck.sqlite3"
max_input_tokens: int = 49152
max_recent_events_tokens: int = 12000
max_memory_tokens: int = 8000
max_skill_tokens: int = 6000
qdrant_url: str = "http://127.0.0.1:6333"
skip_live_llm_tests: int = 0
@property
def db_file(self) -> Path:
return Path(self.db_path)
@lru_cache
def get_settings() -> Settings:
load_dotenv()
return Settings(
llama_server_bin=os.getenv("DUCK_LLAMA_SERVER_BIN", "llama-server"),
main_model_path=os.getenv(
"DUCK_MAIN_MODEL_PATH",
"./models/Qwen3.6/nonMTP/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
),
main_port=int(os.getenv("DUCK_MAIN_PORT", "8081")),
ctx_size=int(os.getenv("DUCK_CTX_SIZE", "65536")),
n_gpu_layers=os.getenv("DUCK_N_GPU_LAYERS", "auto"),
host=os.getenv("DUCK_HOST", "127.0.0.1"),
api_host=os.getenv("DUCK_API_HOST", "127.0.0.1"),
api_port=int(os.getenv("DUCK_API_PORT", "8000")),
workspace=os.getenv("DUCK_WORKSPACE", "./workspace"),
db_path=os.getenv("DUCK_DB_PATH", "./data/duck.sqlite3"),
max_input_tokens=int(os.getenv("DUCK_MAX_INPUT_TOKENS", "49152")),
max_recent_events_tokens=int(os.getenv("DUCK_MAX_RECENT_EVENTS_TOKENS", "12000")),
max_memory_tokens=int(os.getenv("DUCK_MAX_MEMORY_TOKENS", "8000")),
max_skill_tokens=int(os.getenv("DUCK_MAX_SKILL_TOKENS", "6000")),
qdrant_url=os.getenv("QDRANT_URL", "http://127.0.0.1:6333"),
skip_live_llm_tests=int(os.getenv("DUCK_SKIP_LIVE_LLM_TESTS", "0")),
)
+11
View File
@@ -0,0 +1,11 @@
from duck_core.tasks.state import TaskState
class ContextBuilder:
def build_basic_messages(self, task: TaskState) -> list[dict[str, str]]:
return [
{
"role": "user",
"content": task.user_message,
}
]
+1
View File
@@ -0,0 +1 @@
+92
View File
@@ -0,0 +1,92 @@
import json
from pathlib import Path
from typing import Any
import aiosqlite
from pydantic import BaseModel
from duck_core.tasks.store import utc_now
class Event(BaseModel):
id: int
task_id: str
sequence: int
event_type: str
payload: dict[str, Any]
created_at: str
class EventStore:
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 events (
id integer primary key autoincrement,
task_id text not null,
sequence integer not null,
event_type text not null,
payload_json text not null,
created_at text not null
)
"""
)
await db.execute(
"""
create unique index if not exists idx_events_task_sequence
on events(task_id, sequence)
"""
)
await db.commit()
async def append(self, task_id: str, event_type: str, payload: dict[str, Any]) -> Event:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"select coalesce(max(sequence), 0) + 1 from events where task_id = ?",
(task_id,),
)
sequence = (await cursor.fetchone())[0]
created_at = utc_now()
cursor = await db.execute(
"""
insert into events(task_id, sequence, event_type, payload_json, created_at)
values (?, ?, ?, ?, ?)
""",
(task_id, sequence, event_type, json.dumps(payload), created_at),
)
await db.commit()
event_id = cursor.lastrowid
return Event(
id=event_id,
task_id=task_id,
sequence=sequence,
event_type=event_type,
payload=payload,
created_at=created_at,
)
async def list_events(self, task_id: str) -> list[Event]:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from events where task_id = ? order by sequence", (task_id,)
)
rows = await cursor.fetchall()
return [
Event(
id=row["id"],
task_id=row["task_id"],
sequence=row["sequence"],
event_type=row["event_type"],
payload=json.loads(row["payload_json"]),
created_at=row["created_at"],
)
for row in rows
]
+1
View File
@@ -0,0 +1 @@
+172
View File
@@ -0,0 +1,172 @@
import json
from pathlib import Path
import aiosqlite
from pydantic import BaseModel
from duck_core.tasks.store import utc_now
class ExperienceRecord(BaseModel):
id: int | None = None
task_id: str
skill_id: str | None = None
summary: str
result: str
what_worked: list[str] = []
what_failed: list[str] = []
reusable_lesson: str | None = None
suggested_skill_patch: str | None = None
confidence: float | None = None
created_at: str
class ExperienceRecorder:
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 experience_records (
id integer primary key autoincrement,
task_id text not null,
skill_id text,
summary text not null,
result text not null,
what_worked_json text,
what_failed_json text,
reusable_lesson text,
suggested_skill_patch text,
confidence real,
created_at text not null
)
"""
)
await db.commit()
async def record(
self,
task_id: str,
summary: str,
result: str,
skill_id: str | None = None,
what_worked: list[str] | None = None,
what_failed: list[str] | None = None,
reusable_lesson: str | None = None,
suggested_skill_patch: str | None = None,
confidence: float | None = None,
) -> ExperienceRecord:
await self.init()
now = utc_now()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"""
insert into experience_records(
task_id, skill_id, summary, result, what_worked_json,
what_failed_json, reusable_lesson, suggested_skill_patch,
confidence, created_at
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
skill_id,
summary,
result,
json.dumps(what_worked or []),
json.dumps(what_failed or []),
reusable_lesson,
suggested_skill_patch,
confidence,
now,
),
)
await db.commit()
row_id = cursor.lastrowid
if suggested_skill_patch and skill_id:
self.write_skill_update_proposal(task_id, skill_id, suggested_skill_patch)
return ExperienceRecord(
id=row_id,
task_id=task_id,
skill_id=skill_id,
summary=summary,
result=result,
what_worked=what_worked or [],
what_failed=what_failed or [],
reusable_lesson=reusable_lesson,
suggested_skill_patch=suggested_skill_patch,
confidence=confidence,
created_at=now,
)
async def list_records(self) -> list[ExperienceRecord]:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from experience_records order by created_at desc"
)
rows = await cursor.fetchall()
return [self._row_to_record(row) for row in rows]
async def get_record(self, record_id: int) -> ExperienceRecord | None:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from experience_records where id = ?", (record_id,)
)
row = await cursor.fetchone()
return self._row_to_record(row) if row else None
def write_skill_update_proposal(self, task_id: str, skill_id: str, patch: str) -> Path:
directory = Path("skills/_proposals")
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{utc_now().replace(':', '').replace('+', '_')}_{skill_id}.patch.md"
path.write_text(
"\n".join(
[
"# Skill update proposal",
"",
f"Skill: {skill_id}",
"",
"## Reason",
"",
"Reflection suggested a reusable skill improvement.",
"",
"## Proposed changes",
"",
patch,
"",
"## Evidence",
"",
f"Task id: {task_id}",
"",
"## Risk",
"",
"Low.",
"",
"## Requires human approval",
"",
"Yes.",
]
)
)
return path
def _row_to_record(self, row: aiosqlite.Row) -> ExperienceRecord:
return ExperienceRecord(
id=row["id"],
task_id=row["task_id"],
skill_id=row["skill_id"],
summary=row["summary"],
result=row["result"],
what_worked=json.loads(row["what_worked_json"] or "[]"),
what_failed=json.loads(row["what_failed_json"] or "[]"),
reusable_lesson=row["reusable_lesson"],
suggested_skill_patch=row["suggested_skill_patch"],
confidence=row["confidence"],
created_at=row["created_at"],
)
+1
View File
@@ -0,0 +1 @@
+20
View File
@@ -0,0 +1,20 @@
from pydantic import BaseModel
class MemoryDecision(BaseModel):
should_store: bool
memory_type: str
summary: str
importance: float
metadata: dict[str, str] = {}
class MemoryPolicy:
async def classify(self, summary: str, task_id: str) -> MemoryDecision:
return MemoryDecision(
should_store=False,
memory_type="event",
summary=summary,
importance=0.0,
metadata={"task_id": task_id, "source": "stub_policy"},
)
+70
View File
@@ -0,0 +1,70 @@
from typing import Any
from uuid import uuid4
import httpx
class EmbeddingsUnavailableError(RuntimeError):
pass
class VectorMemory:
def __init__(
self,
qdrant_url: str,
collection_name: str = "duck_memory",
embeddings_base_url: str | None = "http://127.0.0.1:8081/v1",
):
self.qdrant_url = qdrant_url.rstrip("/")
self.collection_name = collection_name
self.embeddings_base_url = embeddings_base_url.rstrip("/") if embeddings_base_url else None
async def add_memory(self, text: str, metadata: dict[str, Any] | None = None) -> str:
vector = await self._embed(text)
point_id = str(uuid4())
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
await client.put(
f"{self.qdrant_url}/collections/{self.collection_name}",
json={"vectors": {"size": len(vector), "distance": "Cosine"}},
)
response = await client.put(
f"{self.qdrant_url}/collections/{self.collection_name}/points",
json={
"points": [
{
"id": point_id,
"vector": vector,
"payload": {"text": text, **(metadata or {})},
}
]
},
)
response.raise_for_status()
return point_id
async def search_memory(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
vector = await self._embed(query)
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
response = await client.post(
f"{self.qdrant_url}/collections/{self.collection_name}/points/search",
json={"vector": vector, "limit": limit, "with_payload": True},
)
response.raise_for_status()
return response.json().get("result", [])
async def _embed(self, text: str) -> list[float]:
if not self.embeddings_base_url:
raise EmbeddingsUnavailableError(
"Embeddings endpoint is not configured; vector memory is explicit stub."
)
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
response = await client.post(
f"{self.embeddings_base_url}/embeddings",
json={"model": "local-main", "input": text},
)
if response.status_code >= 400:
raise EmbeddingsUnavailableError(
f"Embeddings endpoint unavailable: HTTP {response.status_code}"
)
data = response.json()["data"][0]["embedding"]
return [float(value) for value in data]
+217
View File
@@ -0,0 +1,217 @@
import json
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import httpx
import yaml
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RoleConfig:
role: str
provider: str
base_url: str
model: str
purpose: str
structured_output: bool
temperature: float
max_output_tokens: int
system_prompt: str
response_schema: str | None = None
@dataclass
class ModelResponse:
role: str
model: str
content: str
reasoning_content: str | None
raw: dict[str, Any]
latency_ms: float
prompt_tokens: int | None = None
completion_tokens: int | None = None
total_tokens: int | None = None
class ModelClient:
def __init__(self, config_path: str = "config/models.yaml", timeout: float = 120.0):
self.config_path = Path(config_path)
self.timeout = timeout
data = yaml.safe_load(self.config_path.read_text())
self.default_provider = data["default_provider"]
self._roles = {
role: RoleConfig(role=role, **settings)
for role, settings in data["models"].items()
}
def list_roles(self) -> dict[str, dict[str, Any]]:
return {
role: {
"provider": cfg.provider,
"base_url": cfg.base_url,
"model": cfg.model,
"purpose": cfg.purpose,
"structured_output": cfg.structured_output,
"temperature": cfg.temperature,
"max_output_tokens": cfg.max_output_tokens,
"system_prompt": cfg.system_prompt,
"response_schema": cfg.response_schema,
}
for role, cfg in self._roles.items()
}
def get_role_config(self, role: str) -> RoleConfig:
try:
return self._roles[role]
except KeyError as exc:
raise KeyError(f"Unknown model role: {role}") from exc
def _system_message(self, cfg: RoleConfig) -> dict[str, str] | None:
path = Path(cfg.system_prompt)
if not path.exists():
return None
return {"role": "system", "content": path.read_text()}
def _response_format(
self, cfg: RoleConfig, response_format: dict[str, Any] | None
) -> dict[str, Any] | None:
if response_format is not None:
return response_format
if not cfg.structured_output:
return None
if cfg.response_schema and Path(cfg.response_schema).exists():
schema = json.loads(Path(cfg.response_schema).read_text())
return {
"type": "json_schema",
"json_schema": {"name": "action_directive", "schema": schema, "strict": True},
}
return {"type": "json_object"}
async def chat(
self,
role: str,
messages: list[dict[str, str]],
temperature: float | None = None,
max_output_tokens: int | None = None,
response_format: dict[str, Any] | None = None,
) -> ModelResponse:
cfg = self.get_role_config(role)
outbound = list(messages)
system_message = self._system_message(cfg)
if system_message and not any(message["role"] == "system" for message in outbound):
outbound.insert(0, system_message)
payload: dict[str, Any] = {
"model": cfg.model,
"messages": outbound,
"temperature": cfg.temperature if temperature is None else temperature,
"max_tokens": cfg.max_output_tokens if max_output_tokens is None else max_output_tokens,
}
fmt = self._response_format(cfg, response_format)
if fmt is not None:
payload["response_format"] = fmt
start = time.perf_counter()
try:
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
response = await client.post(f"{cfg.base_url}/chat/completions", json=payload)
response.raise_for_status()
raw = response.json()
except httpx.HTTPError as exc:
raise ConnectionError(f"Model backend unavailable for role {role}: {exc}") from exc
latency_ms = (time.perf_counter() - start) * 1000
usage = raw.get("usage") or {}
message = raw.get("choices", [{}])[0].get("message", {})
content = message.get("content") or ""
reasoning_content = message.get("reasoning_content")
logger.info("model role=%s model=%s latency_ms=%.1f usage=%s", role, cfg.model, latency_ms, usage)
return ModelResponse(
role=role,
model=cfg.model,
content=content,
reasoning_content=reasoning_content,
raw=raw,
latency_ms=latency_ms,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
total_tokens=usage.get("total_tokens"),
)
async def stream_chat(
self,
role: str,
messages: list[dict[str, str]],
temperature: float | None = None,
max_output_tokens: int | None = None,
response_format: dict[str, Any] | None = None,
):
cfg = self.get_role_config(role)
outbound = list(messages)
system_message = self._system_message(cfg)
if system_message and not any(message["role"] == "system" for message in outbound):
outbound.insert(0, system_message)
payload: dict[str, Any] = {
"model": cfg.model,
"messages": outbound,
"temperature": cfg.temperature if temperature is None else temperature,
"max_tokens": cfg.max_output_tokens if max_output_tokens is None else max_output_tokens,
"stream": True,
}
fmt = self._response_format(cfg, response_format)
if fmt is not None:
payload["response_format"] = fmt
try:
async with httpx.AsyncClient(timeout=self.timeout, trust_env=False) as client:
async with client.stream(
"POST", f"{cfg.base_url}/chat/completions", json=payload
) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
raw_data = line.removeprefix("data: ").strip()
if raw_data == "[DONE]":
break
if not raw_data:
continue
chunk = json.loads(raw_data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
reasoning_delta = delta.get("reasoning_content")
content_delta = delta.get("content")
if reasoning_delta:
yield {"type": "reasoning_delta", "delta": reasoning_delta}
if content_delta:
yield {"type": "content_delta", "delta": content_delta}
except httpx.HTTPError as exc:
raise ConnectionError(f"Model backend unavailable for role {role}: {exc}") from exc
async def ping(self) -> dict[str, Any]:
results: dict[str, Any] = {}
async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client:
for role, cfg in self._roles.items():
try:
started = time.perf_counter()
response = await client.get(f"{cfg.base_url}/models")
response.raise_for_status()
results[role] = {
"ok": True,
"base_url": cfg.base_url,
"model": cfg.model,
"latency_ms": round((time.perf_counter() - started) * 1000, 1),
}
except httpx.HTTPError as exc:
results[role] = {
"ok": False,
"base_url": cfg.base_url,
"model": cfg.model,
"error": str(exc),
}
return results
+29
View File
@@ -0,0 +1,29 @@
from duck_core.experience.recorder import ExperienceRecorder, ExperienceRecord
from duck_core.model_client import ModelClient
class Reflection:
def __init__(self, model_client: ModelClient, recorder: ExperienceRecorder):
self.model_client = model_client
self.recorder = recorder
async def reflect(self, task_id: str, transcript: str) -> ExperienceRecord:
response = await self.model_client.chat(
"critic",
[
{
"role": "user",
"content": (
"Reflect on this DuckLM task. Cover outcome, waste, JSON/tool issues, "
f"and reusable lesson.\n\n{transcript}"
),
}
],
)
return await self.recorder.record(
task_id=task_id,
summary=response.content[:500],
result="unknown",
reusable_lesson=response.content,
confidence=0.5,
)
+197
View File
@@ -0,0 +1,197 @@
import json
from dataclasses import dataclass
from typing import Any
from duck_core.approvals.service import ApprovalService
from duck_core.context_builder import ContextBuilder
from duck_core.events.store import EventStore
from duck_core.model_client import ModelClient
from duck_core.tasks.store import TaskStore
from duck_core.tools.gateway import ToolGateway
@dataclass
class ChatResult:
task_id: str
status: str
final_response: str
reasoning_content: str | None = None
class RuntimeLoop:
def __init__(
self,
task_store: TaskStore,
event_store: EventStore,
model_client: ModelClient | None = None,
context_builder: ContextBuilder | None = None,
approval_service: ApprovalService | None = None,
):
self.task_store = task_store
self.event_store = event_store
self.model_client = model_client or ModelClient()
self.context_builder = context_builder or ContextBuilder()
self.approval_service = approval_service
async def run_chat(
self, message: str, workspace: str | None = None, debug: bool = False
) -> ChatResult:
task = await self.task_store.create_task(message, workspace, debug)
await self.event_store.append(
task.task_id,
"task_created",
{"message": message, "workspace": workspace, "debug": debug},
)
try:
messages = self.context_builder.build_basic_messages(task)
tool_observations = await self._run_action_tools(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)
await self.event_store.append(
task.task_id,
"task_waiting_for_approval",
{"observations": tool_observations},
)
return ChatResult(
task_id=task.task_id,
status="waiting_for_approval",
final_response="Waiting for approval.",
reasoning_content=None,
)
if tool_observations:
messages = [
*messages,
{
"role": "user",
"content": "tool_observations:\n"
+ json.dumps(tool_observations, ensure_ascii=False, indent=2),
},
]
await self.event_store.append(
task.task_id, "model_call_started", {"role": "thinker"}
)
response = await self.model_client.chat("thinker", messages)
await self.event_store.append(
task.task_id,
"cognition_response",
{
"role": response.role,
"content": response.content,
"reasoning_content": response.reasoning_content,
},
)
await self.event_store.append(
task.task_id,
"model_call_finished",
{
"role": response.role,
"model": response.model,
"latency_ms": response.latency_ms,
"prompt_tokens": response.prompt_tokens,
"completion_tokens": response.completion_tokens,
"total_tokens": response.total_tokens,
},
)
await self.task_store.complete_task(task.task_id, response.content)
await self.event_store.append(
task.task_id,
"task_completed",
{
"final_response": response.content,
"reasoning_content": response.reasoning_content,
},
)
return ChatResult(
task_id=task.task_id,
status="completed",
final_response=response.content,
reasoning_content=response.reasoning_content,
)
except Exception as exc:
await self.task_store.fail_task(task.task_id, str(exc))
await self.event_store.append(
task.task_id, "task_failed", {"error": str(exc)}
)
return ChatResult(
task_id=task.task_id,
status="failed",
final_response=str(exc),
reasoning_content=None,
)
async def _run_action_tools(
self, task_id: str, messages: list[dict[str, str]], workspace: str | None
) -> list[dict[str, Any]]:
try:
await self.event_store.append(task_id, "model_call_started", {"role": "action"})
response = await self.model_client.chat("action", messages)
directive = json.loads(response.content)
except Exception as exc:
await self.event_store.append(
task_id,
"action_directive_failed",
{"error": str(exc)},
)
return []
await self.event_store.append(task_id, "action_directive", directive)
actions = directive.get("actions") or []
if not isinstance(actions, list) or not actions:
return []
gateway = ToolGateway.default(workspace or ".")
observations: list[dict[str, Any]] = []
for index, action in enumerate(actions, start=1):
if not isinstance(action, dict):
observations.append(
{"index": index, "ok": False, "error": "Action must be an object"}
)
continue
tool_name = str(action.get("tool", ""))
await self.event_store.append(
task_id,
"tool_call_started",
{"index": index, "tool": tool_name, "args": action.get("args") or {}},
)
result = await gateway.run_action(action)
result_payload = result.model_dump()
if result.metadata.get("requires_approval"):
approval = None
if self.approval_service is not None:
approval = await self.approval_service.create_pending(task_id, action)
await self.event_store.append(
task_id,
"tool_approval_requested",
{
"index": index,
"tool": tool_name,
"action": action,
"approval_id": approval.approval_id if approval else None,
"reason": result.error,
},
)
observations.append(
{
"index": index,
"tool": tool_name,
"reason": action.get("reason"),
"requires_approval": True,
"approval_id": approval.approval_id if approval else None,
"result": result_payload,
}
)
break
await self.event_store.append(
task_id,
"tool_call_finished",
{"index": index, "tool": tool_name, "result": result_payload},
)
observations.append(
{
"index": index,
"tool": tool_name,
"reason": action.get("reason"),
"result": result_payload,
}
)
return observations
@@ -0,0 +1,55 @@
{
"type": "object",
"required": ["kind", "intent", "risk_level", "actions"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["action_directive"]
},
"intent": {
"type": "string",
"minLength": 1
},
"risk_level": {
"type": "string",
"enum": ["none", "low", "medium", "high", "critical"]
},
"actions": {
"type": "array",
"minItems": 0,
"items": {
"type": "object",
"required": ["tool", "args"],
"additionalProperties": false,
"properties": {
"tool": {
"type": "string",
"minLength": 1
},
"args": {
"type": "object"
},
"reason": {
"type": "string"
}
}
}
},
"memory_hints": {
"type": "array",
"items": {
"type": "string"
}
},
"expected_observations": {
"type": "array",
"items": {
"type": "string"
}
},
"stop_reason": {
"type": "string"
}
}
}
+1
View File
@@ -0,0 +1 @@
+68
View File
@@ -0,0 +1,68 @@
from pathlib import Path
import yaml
from pydantic import BaseModel
class Skill(BaseModel):
id: str
title: str
description: str
version: int
tags: list[str] = []
required_tools: list[str] = []
risk_level: str = "low"
inputs: list[str] = []
outputs: list[str] = []
success_criteria: list[str] = []
procedure: str = ""
examples: str = ""
notes: str = ""
class SkillCandidate(BaseModel):
skill: Skill
score: float
reason: str
class SkillRegistry:
def __init__(self, skills_dir: str = "skills"):
self.skills_dir = Path(skills_dir)
self._cache: dict[str, Skill] | None = None
def load_skills(self) -> list[Skill]:
skills: dict[str, Skill] = {}
if not self.skills_dir.exists():
self._cache = {}
return []
for path in sorted(self.skills_dir.glob("*/skill.yaml")):
data = yaml.safe_load(path.read_text()) or {}
root = path.parent
data["procedure"] = self._read_optional(root / "procedure.md")
data["examples"] = self._read_optional(root / "examples.md")
data["notes"] = self._read_optional(root / "notes.md")
skill = Skill(**data)
skills[skill.id] = skill
self._cache = skills
return list(skills.values())
def get_skill(self, skill_id: str) -> Skill | None:
if self._cache is None:
self.load_skills()
return (self._cache or {}).get(skill_id)
async def find_candidate_skills(self, user_request: str, limit: int = 3) -> list[SkillCandidate]:
terms = set(user_request.lower().split())
candidates: list[SkillCandidate] = []
for skill in self.load_skills():
haystack = " ".join([skill.title, skill.description, " ".join(skill.tags)]).lower()
score = sum(1 for term in terms if term in haystack)
if score:
candidates.append(
SkillCandidate(skill=skill, score=float(score), reason="keyword match")
)
return sorted(candidates, key=lambda item: item.score, reverse=True)[:limit]
def _read_optional(self, path: Path) -> str:
return path.read_text() if path.exists() else ""
+1
View File
@@ -0,0 +1 @@
+12
View File
@@ -0,0 +1,12 @@
from pydantic import BaseModel
class TaskState(BaseModel):
task_id: str
status: str
user_message: str
workspace: str | None = None
debug: bool = False
final_response: str | None = None
created_at: str
updated_at: str
+115
View File
@@ -0,0 +1,115 @@
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
import aiosqlite
from duck_core.tasks.state import TaskState
def utc_now() -> str:
return datetime.now(UTC).isoformat()
class TaskStore:
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 tasks (
task_id text primary key,
status text not null,
user_message text not null,
workspace text,
debug integer not null default 0,
final_response text,
created_at text not null,
updated_at text not null
)
"""
)
await db.commit()
async def create_task(self, user_message: str, workspace: str | None, debug: bool) -> TaskState:
await self.init()
now = utc_now()
task_id = f"task_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}_{uuid4().hex[:8]}"
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
insert into tasks(task_id, status, user_message, workspace, debug, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?)
""",
(task_id, "running", user_message, workspace, int(debug), now, now),
)
await db.commit()
return TaskState(
task_id=task_id,
status="running",
user_message=user_message,
workspace=workspace,
debug=debug,
created_at=now,
updated_at=now,
)
async def update_status(
self, task_id: str, status: str, final_response: str | None = None
) -> None:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
update tasks
set status = ?, final_response = coalesce(?, final_response), updated_at = ?
where task_id = ?
""",
(status, final_response, utc_now(), task_id),
)
await db.commit()
async def complete_task(self, task_id: str, final_response: str) -> None:
await self.update_status(task_id, "completed", final_response)
async def fail_task(self, task_id: str, message: str) -> None:
await self.update_status(task_id, "failed", message)
async def cancel_task(self, task_id: str) -> None:
await self.update_status(task_id, "cancelled")
async def waiting_for_approval(self, task_id: str) -> None:
await self.update_status(task_id, "waiting_for_approval")
async def get_task(self, task_id: str) -> TaskState | None:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("select * from tasks where task_id = ?", (task_id,))
row = await cursor.fetchone()
return self._row_to_task(row) if row else None
async def list_tasks(self, limit: int = 50) -> list[TaskState]:
await self.init()
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"select * from tasks order by created_at desc limit ?", (limit,)
)
rows = await cursor.fetchall()
return [self._row_to_task(row) for row in rows]
def _row_to_task(self, row: aiosqlite.Row) -> TaskState:
return TaskState(
task_id=row["task_id"],
status=row["status"],
user_message=row["user_message"],
workspace=row["workspace"],
debug=bool(row["debug"]),
final_response=row["final_response"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
+1
View File
@@ -0,0 +1 @@
+18
View File
@@ -0,0 +1,18 @@
from typing import Any, Protocol
from pydantic import BaseModel, Field
class ToolResult(BaseModel):
ok: bool
output: str | None = None
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class Tool(Protocol):
name: str
risk_level: str
async def run(self, args: dict[str, Any]) -> ToolResult:
...
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
from typing import Any
from duck_core.tools.base import ToolResult
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
class FileReadTool:
name = "file_read"
risk_level = "low"
def __init__(self, workspace: str, max_bytes: int = 1_000_000):
self.workspace = workspace
self.max_bytes = max_bytes
async def run(self, args: dict[str, Any]) -> ToolResult:
raw_path = str(args.get("path", ""))
try:
path = resolve_workspace_path(self.workspace, raw_path)
except WorkspacePathError as exc:
return ToolResult(ok=False, error=str(exc))
if self._requires_approval(path):
return ToolResult(ok=False, error=f"Reading {raw_path} requires explicit approval")
if not path.is_file():
return ToolResult(ok=False, error=f"File not found: {raw_path}")
if path.stat().st_size > self.max_bytes:
return ToolResult(ok=False, error=f"File exceeds max size: {self.max_bytes}")
return ToolResult(
ok=True,
output=path.read_text(errors="replace"),
metadata={"path": str(path), "bytes_read": path.stat().st_size},
)
def _requires_approval(self, path: Path) -> bool:
parts = set(path.parts)
return path.name == ".env" or ".ssh" in parts or str(path) == "/etc/shadow"
+40
View File
@@ -0,0 +1,40 @@
from typing import Any
from duck_core.tools.base import ToolResult
from duck_core.tools.paths import WorkspacePathError, resolve_workspace_path
class FileWriteTool:
name = "file_write"
risk_level = "medium"
def __init__(self, workspace: str):
self.workspace = workspace
async def run(self, args: dict[str, Any]) -> ToolResult:
raw_path = str(args.get("path", ""))
content = str(args.get("content", ""))
overwrite = bool(args.get("overwrite", False))
try:
path = resolve_workspace_path(self.workspace, raw_path)
except WorkspacePathError as exc:
return ToolResult(ok=False, error=str(exc))
if path.exists() and not overwrite:
return ToolResult(
ok=False,
error="Refusing to overwrite existing file without overwrite=true or approval",
metadata={"path": str(path)},
)
path.parent.mkdir(parents=True, exist_ok=True)
existed = path.exists()
path.write_text(content)
return ToolResult(
ok=True,
output=f"Wrote {raw_path}",
metadata={
"path": str(path),
"bytes_written": len(content.encode()),
"created": not existed,
"updated": existed,
},
)
+31
View File
@@ -0,0 +1,31 @@
from typing import Any
from duck_core.tools.base import Tool, ToolResult
from duck_core.tools.file_read import FileReadTool
from duck_core.tools.file_write import FileWriteTool
from duck_core.tools.shell_exec_safe import ShellExecSafeTool
class ToolGateway:
def __init__(self, tools: list[Tool]):
self.tools = {tool.name: tool for tool in tools}
@classmethod
def default(cls, workspace: str) -> "ToolGateway":
return cls(
[
FileReadTool(workspace),
FileWriteTool(workspace),
ShellExecSafeTool(workspace),
]
)
async def run_action(self, action: dict[str, Any]) -> ToolResult:
tool_name = str(action.get("tool", ""))
tool = self.tools.get(tool_name)
if tool is None:
return ToolResult(ok=False, error=f"Unknown tool: {tool_name}")
args = action.get("args") or {}
if not isinstance(args, dict):
return ToolResult(ok=False, error="Tool args must be an object")
return await tool.run(args)
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
class WorkspacePathError(ValueError):
pass
def resolve_workspace_path(workspace: str, relative_path: str) -> Path:
root = Path(workspace).resolve()
path = (root / relative_path).resolve()
if root != path and root not in path.parents:
raise WorkspacePathError(f"Path escapes workspace: {relative_path}")
return path
+95
View File
@@ -0,0 +1,95 @@
import shlex
import subprocess
from typing import Any
from duck_core.tools.base import ToolResult
ALLOWLIST = {
"pwd",
"ls",
"cat",
"head",
"tail",
"grep",
"find",
"pytest",
"python -m pytest",
"python3 -m pytest",
"git status",
"git diff",
"git log",
}
BLOCKLIST = {
"rm",
"sudo",
"su",
"dd",
"mkfs",
"mount",
"umount",
"shutdown",
"reboot",
"poweroff",
"systemctl",
"service",
"apt install",
"apt remove",
"pacman -S",
"pacman -R",
"pip install",
"npm install -g",
"chmod -R",
"chown -R",
"curl | sh",
"wget | sh",
}
class ShellExecSafeTool:
name = "shell_exec_safe"
risk_level = "medium"
def __init__(self, workspace: str, timeout_seconds: int = 30):
self.workspace = workspace
self.timeout_seconds = timeout_seconds
async def run(self, args: dict[str, Any]) -> ToolResult:
command = str(args.get("command", "")).strip()
allowed, reason = self._is_allowed(command)
if not allowed:
return ToolResult(ok=False, error=reason, metadata={"requires_approval": True})
try:
completed = subprocess.run(
command,
cwd=self.workspace,
shell=True,
text=True,
capture_output=True,
timeout=self.timeout_seconds,
check=False,
)
except subprocess.SubprocessError as exc:
return ToolResult(ok=False, error=str(exc))
return ToolResult(
ok=completed.returncode == 0,
output=completed.stdout,
error=completed.stderr if completed.returncode else None,
metadata={"returncode": completed.returncode, "command": command},
)
def _is_allowed(self, command: str) -> tuple[bool, str | None]:
if not command:
return False, "Empty command"
lowered = command.lower()
for blocked in BLOCKLIST:
if lowered.startswith(blocked.lower()) or blocked.lower() in lowered:
return False, f"Command is blocked: {blocked}"
parts = shlex.split(command)
prefix1 = parts[0] if parts else ""
prefix2 = " ".join(parts[:2])
prefix3 = " ".join(parts[:3])
if prefix1 in ALLOWLIST or prefix2 in ALLOWLIST or prefix3 in ALLOWLIST:
return True, None
return False, "Command is outside allowlist and requires approval"
+510
View File
@@ -0,0 +1,510 @@
const state = {
running: false,
messages: [],
};
async function jsonFetch(url, options) {
const response = await fetch(url, options);
if (!response.ok) throw new Error(await response.text());
return response.json();
}
function escapeText(value) {
return String(value ?? "");
}
function setStatus(id, text, tone = "neutral") {
const node = document.querySelector(id);
if (!node) return;
node.textContent = text;
node.dataset.tone = tone;
}
function addMessage(role, content, meta = "", options = {}) {
const list = document.querySelector("#messages");
if (!list) return;
const article = document.createElement("article");
article.className = `message ${role}`;
const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = role === "user" ? "U" : "D";
const bubble = document.createElement("div");
bubble.className = "bubble";
const messageMeta = document.createElement("div");
messageMeta.className = "message-meta";
messageMeta.innerHTML = `<strong>${role === "user" ? "You" : "DuckLM"}</strong><span>${escapeText(meta)}</span>`;
const text = document.createElement("p");
text.textContent = content;
bubble.append(messageMeta);
if (role === "assistant" && options.reasoning) {
bubble.append(createInlineReasoning());
}
bubble.append(text);
article.append(avatar, bubble);
list.append(article);
list.scrollTop = list.scrollHeight;
return article;
}
function createInlineReasoning() {
const section = document.createElement("section");
section.className = "message-reasoning is-collapsed";
const button = document.createElement("button");
button.className = "message-reasoning-toggle";
button.type = "button";
button.setAttribute("aria-expanded", "false");
const title = document.createElement("span");
title.textContent = "Размышление";
const status = document.createElement("span");
status.className = "message-reasoning-status";
status.textContent = "streaming";
button.append(title, status);
const body = document.createElement("pre");
body.hidden = true;
body.textContent = "";
section.append(button, body);
return section;
}
function createToolTerminal(eventPayload) {
const payload = eventPayload.payload || eventPayload;
const args = payload.args || {};
const terminal = document.createElement("section");
terminal.className = "tool-terminal";
terminal.dataset.toolIndex = String(payload.index || "");
const header = document.createElement("div");
header.className = "tool-terminal-header";
const dots = document.createElement("span");
dots.className = "terminal-dots";
dots.innerHTML = "<i></i><i></i><i></i>";
const title = document.createElement("span");
title.className = "tool-terminal-title";
title.textContent = formatToolCommand(payload.tool, args);
const status = document.createElement("span");
status.className = "tool-terminal-status";
status.textContent = "running";
header.append(dots, title, status);
const body = document.createElement("pre");
body.className = "tool-terminal-body";
body.textContent = formatToolStart(payload.tool, args);
terminal.append(header, body);
return terminal;
}
function formatToolCommand(tool, args) {
if (tool === "shell_exec_safe") return `$ ${args.command || tool}`;
if (tool === "file_read") return `$ file_read ${args.path || ""}`.trim();
if (tool === "file_write") return `$ file_write ${args.path || ""}`.trim();
return `$ ${tool || "tool"}`;
}
function formatToolStart(tool, args) {
const lines = [formatToolCommand(tool, args)];
const serializedArgs = JSON.stringify(args || {}, null, 2);
if (serializedArgs !== "{}") lines.push(serializedArgs);
return lines.join("\n");
}
function appendToolTerminal(article, eventPayload) {
const paragraph = article?.querySelector("p");
const terminal = createToolTerminal(eventPayload);
paragraph?.before(terminal);
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function updateToolTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`);
const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status");
const result = payload.result || {};
if (!body || !status) return;
terminal.classList.toggle("is-error", !result.ok);
status.textContent = result.ok ? "ok" : "error";
const parts = [body.textContent.trim()];
if (result.output) parts.push("\nstdout\n" + result.output.trimEnd());
if (result.error) parts.push("\nstderr\n" + result.error.trimEnd());
if (result.metadata && Object.keys(result.metadata).length) {
parts.push("\nmetadata\n" + JSON.stringify(result.metadata, null, 2));
}
body.textContent = parts.join("\n");
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function appendApprovalTerminal(article, eventPayload) {
const payload = eventPayload.payload || eventPayload;
appendToolTerminal(article, {
payload: {
index: payload.index,
tool: payload.tool,
args: payload.action?.args || {},
},
});
const terminal = article?.querySelector(`.tool-terminal[data-tool-index="${payload.index || ""}"]`);
const body = terminal?.querySelector(".tool-terminal-body");
const status = terminal?.querySelector(".tool-terminal-status");
terminal?.classList.add("is-waiting");
if (status) status.textContent = "approval";
if (body) body.textContent += `\n\napproval required\n${payload.reason || ""}`;
}
function setMessagePending(article, text) {
const paragraph = article?.querySelector("p");
if (paragraph) paragraph.textContent = text;
}
function appendMessageText(article, delta) {
const paragraph = article?.querySelector("p");
if (!paragraph) return;
paragraph.textContent += delta;
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function appendInlineReasoning(article, delta) {
const block = article?.querySelector(".message-reasoning");
const body = block?.querySelector("pre");
const status = block?.querySelector(".message-reasoning-status");
if (!body) return;
body.textContent += delta;
if (status) status.textContent = "streaming";
document.querySelector("#messages").scrollTop = document.querySelector("#messages").scrollHeight;
}
function finishInlineReasoning(article, reasoning) {
const block = article?.querySelector(".message-reasoning");
const body = block?.querySelector("pre");
const status = block?.querySelector(".message-reasoning-status");
if (!body) return;
body.textContent = reasoning?.trim() || body.textContent.trim() || "Размышления не были получены.";
if (status) status.textContent = "done";
}
async function refreshEvents(taskId) {
const events = await jsonFetch(`/v1/tasks/${taskId}/events`);
const list = document.querySelector("#events");
if (!list) return events;
list.innerHTML = "";
for (const event of events) {
const item = document.createElement("li");
const title = document.createElement("strong");
const detail = document.createElement("span");
title.textContent = `${event.sequence}. ${event.event_type}`;
detail.textContent = summarizeEvent(event.payload);
item.append(title, detail);
list.appendChild(item);
}
return events;
}
function summarizeEvent(payload) {
if (!payload || typeof payload !== "object") return "";
if (payload.role && payload.latency_ms) {
return `${payload.role} · ${Math.round(payload.latency_ms)} ms`;
}
if (payload.content) {
return payload.content.slice(0, 140);
}
if (payload.final_response) {
return payload.final_response.slice(0, 140);
}
if (payload.error) {
return payload.error;
}
return JSON.stringify(payload);
}
function toggleInlineReasoning(button) {
const block = button.closest(".message-reasoning");
const body = block?.querySelector("pre");
if (!block || !body) return;
const expanded = button.getAttribute("aria-expanded") === "true";
button.setAttribute("aria-expanded", String(!expanded));
body.hidden = expanded;
block.classList.toggle("is-collapsed", expanded);
}
function parseSseBlock(block) {
const event = {name: "message", data: ""};
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event.name = line.slice(6).trim();
if (line.startsWith("data:")) event.data += line.slice(5).trimStart();
}
if (!event.data) return null;
return {name: event.name, data: JSON.parse(event.data)};
}
async function streamChat(payload, onEvent) {
const response = await fetch("/v1/chat/stream", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await response.text());
if (!response.body) throw new Error("Streaming response is not available in this browser.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const {value, done} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const blocks = buffer.split("\n\n");
buffer = blocks.pop() || "";
for (const block of blocks) {
const event = parseSseBlock(block);
if (event) await onEvent(event);
}
}
buffer += decoder.decode();
if (buffer.trim()) {
const event = parseSseBlock(buffer);
if (event) await onEvent(event);
}
}
async function sendMessage() {
if (state.running) return;
const input = document.querySelector("#message");
const message = input.value.trim();
if (!message) return;
state.running = true;
document.querySelector("#run").disabled = true;
setStatus("#task-status", "running", "warn");
addMessage("user", message, "submitted");
input.value = "";
const pending = addMessage("assistant", "", "thinking", {reasoning: true});
let taskId = "";
let contentStarted = false;
try {
await streamChat({
message,
workspace: document.querySelector("#workspace").value,
debug: document.querySelector("#debug").checked,
}, async ({name, data}) => {
if (data.task_id) taskId = data.task_id;
if (name === "task_created") {
taskId = data.task_id;
setStatus("#task-status", taskId, "warn");
return;
}
if (name === "reasoning_delta") {
pending.querySelector(".message-meta span").textContent = "reasoning";
appendInlineReasoning(pending, data.delta || "");
return;
}
if (name === "tool_call_started") {
pending.querySelector(".message-meta span").textContent = "tool";
appendToolTerminal(pending, data);
return;
}
if (name === "tool_call_finished") {
pending.querySelector(".message-meta span").textContent = "tool";
updateToolTerminal(pending, data);
return;
}
if (name === "tool_approval_requested") {
pending.querySelector(".message-meta span").textContent = "approval";
appendApprovalTerminal(pending, data);
return;
}
if (name === "content_delta") {
if (!contentStarted) {
contentStarted = true;
setMessagePending(pending, "");
}
pending.querySelector(".message-meta span").textContent = "answering";
appendMessageText(pending, data.delta || "");
return;
}
if (name === "done") {
if (!contentStarted) {
setMessagePending(pending, data.final_response || "No final content returned.");
}
pending.querySelector(".message-meta span").textContent = data.status;
setStatus("#task-status", data.task_id, data.status === "completed" ? "ok" : "warn");
finishInlineReasoning(pending, data.reasoning_content);
await refreshEvents(data.task_id);
return;
}
if (name === "error") {
throw new Error(data.error || "Stream failed.");
}
});
} catch (error) {
if (!taskId) input.value = message;
setMessagePending(pending, error.message);
pending.querySelector(".message-meta span").textContent = "failed";
setStatus("#task-status", "failed", "bad");
if (taskId) await refreshEvents(taskId);
} finally {
state.running = false;
document.querySelector("#run").disabled = false;
input.focus();
}
}
async function checkRuntime() {
try {
await jsonFetch("/health");
setStatus("#api-status", "online", "ok");
} catch {
setStatus("#api-status", "offline", "bad");
}
try {
const roles = await jsonFetch("/v1/models/ping");
const ok = Object.values(roles).every((item) => item.ok);
setStatus("#model-status", ok ? "online" : "degraded", ok ? "ok" : "warn");
} catch {
setStatus("#model-status", "offline", "bad");
}
}
function bindChat() {
const composer = document.querySelector("#composer");
const input = document.querySelector("#message");
composer?.addEventListener("submit", (event) => {
event.preventDefault();
sendMessage();
});
input?.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
document.querySelector("#new-chat")?.addEventListener("click", () => {
const messages = document.querySelector("#messages");
messages.innerHTML = "";
addMessage("assistant", "Новая сессия готова.", "ready");
document.querySelector("#events").innerHTML = "";
setStatus("#task-status", "none");
});
document.querySelector("#messages")?.addEventListener("click", (event) => {
const button = event.target.closest(".message-reasoning-toggle");
if (button) toggleInlineReasoning(button);
});
document.querySelector("#debug")?.addEventListener("change", (event) => {
document.querySelector("#debug-panel").hidden = !event.target.checked;
});
}
async function loadSimplePages() {
const skills = document.querySelector("#skills");
if (skills) skills.textContent = JSON.stringify(await jsonFetch("/v1/skills"), null, 2);
const experience = document.querySelector("#experience");
if (experience) experience.textContent = JSON.stringify(await jsonFetch("/v1/experience"), null, 2);
const approvals = document.querySelector("#approvals");
if (approvals) await renderApprovals(approvals);
}
async function renderApprovals(container) {
const approvals = await jsonFetch("/v1/approvals/pending");
container.innerHTML = "";
if (!approvals.length) {
const empty = document.createElement("p");
empty.className = "empty-state";
empty.textContent = "No pending approvals.";
container.append(empty);
return;
}
for (const approval of approvals) {
const card = document.createElement("article");
card.className = "approval-card";
card.dataset.approvalId = approval.approval_id;
const header = document.createElement("div");
header.className = "approval-card-header";
const title = document.createElement("h2");
title.textContent = approval.normalized_action?.tool || "Tool action";
const status = document.createElement("span");
status.textContent = approval.status;
header.append(title, status);
const meta = document.createElement("dl");
meta.className = "approval-meta";
meta.append(metaRow("Task", approval.task_id));
meta.append(metaRow("Approval", approval.approval_id));
meta.append(metaRow("Created", approval.created_at));
const action = document.createElement("pre");
action.className = "approval-action";
action.textContent = JSON.stringify(approval.normalized_action, null, 2);
const actions = document.createElement("div");
actions.className = "approval-actions";
actions.append(
approvalButton("Allow once", "allow_once"),
approvalButton("Allow forever", "allow_forever"),
approvalButton("Deny", "deny", "danger"),
);
card.append(header, meta, action, actions);
container.append(card);
}
}
function metaRow(label, value) {
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = value || "";
row.append(dt, dd);
return row;
}
function approvalButton(label, action, tone = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.dataset.approvalAction = action;
if (tone) button.dataset.tone = tone;
return button;
}
document.querySelector("#approvals")?.addEventListener("click", async (event) => {
const button = event.target.closest("[data-approval-action]");
if (!button) return;
const card = button.closest(".approval-card");
const approvalId = card?.dataset.approvalId;
if (!approvalId) return;
button.disabled = true;
const action = button.dataset.approvalAction;
await jsonFetch(`/v1/approvals/${approvalId}/${action}`, {method: "POST"});
await renderApprovals(document.querySelector("#approvals"));
});
document.querySelector("#memory-search")?.addEventListener("click", async () => {
const q = document.querySelector("#memory-query").value;
document.querySelector("#memory-results").textContent =
JSON.stringify(await jsonFetch(`/v1/memory/search?q=${encodeURIComponent(q)}`), null, 2);
});
bindChat();
checkRuntime();
loadSimplePages().catch(console.error);
+673
View File
@@ -0,0 +1,673 @@
:root {
color-scheme: light;
--bg: #eef2f6;
--sidebar: #111827;
--sidebar-soft: #1f2937;
--panel: #ffffff;
--panel-strong: #f8fafc;
--text: #111827;
--muted: #64748b;
--border: #d7dee8;
--accent: #1f6feb;
--accent-strong: #174ea6;
--ok: #12805c;
--warn: #b7791f;
--bad: #b42318;
--shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
.simple-page {
max-width: 980px;
margin: 0 auto;
padding: 28px;
}
.simple-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.simple-header h1,
.simple-header p {
margin: 0;
}
.simple-header h1 {
font-size: 24px;
}
.simple-header p {
margin-top: 4px;
color: var(--muted);
}
.approval-list {
display: grid;
gap: 14px;
}
.approval-card {
display: grid;
gap: 14px;
padding: 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.approval-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.approval-card h2 {
margin: 0;
font-size: 17px;
}
.approval-card-header span {
padding: 3px 8px;
border-radius: 999px;
background: #fef3c7;
color: #854d0e;
font-size: 12px;
font-weight: 800;
}
.approval-meta {
display: grid;
gap: 6px;
}
.approval-meta div {
justify-content: flex-start;
}
.approval-meta dd {
max-width: none;
color: var(--text);
}
.approval-action {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 12px;
background: #0f172a;
border-radius: 8px;
color: #d1fae5;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.approval-actions button {
border: 0;
border-radius: 8px;
padding: 9px 12px;
background: var(--accent);
color: #ffffff;
font-weight: 750;
}
.approval-actions button[data-tone="danger"] {
background: var(--bad);
}
.approval-actions button:disabled {
cursor: wait;
opacity: 0.65;
}
.empty-state {
margin: 0;
padding: 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--muted);
}
button, input, textarea {
font: inherit;
}
button {
cursor: pointer;
}
.app-shell {
display: grid;
grid-template-columns: 292px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100vh;
padding: 22px;
background: var(--sidebar);
color: #e5edf7;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.12);
}
.brand-mark, .avatar {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-weight: 800;
}
.brand-mark {
background: #f8fafc;
color: #111827;
}
.brand h1, .brand p,
.chat-header h2, .chat-header p,
.settings-panel h2, .status-panel h2 {
margin: 0;
}
.brand h1 {
font-size: 18px;
line-height: 1.2;
}
.brand p {
margin-top: 2px;
color: #9ca3af;
font-size: 12px;
}
.side-nav {
display: grid;
gap: 6px;
}
.side-nav a {
color: #cbd5e1;
text-decoration: none;
padding: 10px 12px;
border-radius: 7px;
font-size: 14px;
}
.side-nav a:hover,
.side-nav a.active {
background: var(--sidebar-soft);
color: #ffffff;
}
.settings-panel,
.status-panel {
display: grid;
gap: 12px;
padding: 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.10);
border-radius: 8px;
}
.settings-panel h2,
.status-panel h2 {
font-size: 13px;
color: #f8fafc;
}
label {
display: grid;
gap: 7px;
font-size: 13px;
font-weight: 650;
}
.toggle-row {
grid-template-columns: auto 1fr;
align-items: center;
font-weight: 500;
color: #cbd5e1;
}
input,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
background: #ffffff;
color: var(--text);
}
.sidebar input {
border-color: rgba(255,255,255,0.16);
background: rgba(255,255,255,0.08);
color: #ffffff;
}
dl {
display: grid;
gap: 9px;
margin: 0;
}
dl div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
dt {
color: #9ca3af;
font-size: 12px;
}
dd {
margin: 0;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #e5edf7;
font-size: 12px;
}
[data-tone="ok"] { color: #86efac; }
[data-tone="warn"] { color: #fde68a; }
[data-tone="bad"] { color: #fca5a5; }
.chat-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
gap: 16px;
min-width: 0;
height: 100vh;
padding: 22px;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 20px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.chat-header h2 {
font-size: 20px;
}
.chat-header p {
margin-top: 4px;
color: var(--muted);
font-size: 13px;
}
.secondary-button,
.composer button {
border: 0;
border-radius: 8px;
padding: 10px 14px;
font-weight: 750;
}
.secondary-button {
background: #edf2f7;
color: #1f2937;
}
.messages {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow-y: auto;
padding: 18px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.message {
display: grid;
grid-template-columns: 36px minmax(0, 1fr);
gap: 10px;
max-width: 860px;
}
.message.user {
align-self: flex-end;
grid-template-columns: minmax(0, 1fr) 36px;
}
.message.user .avatar {
grid-column: 2;
grid-row: 1;
background: #dbeafe;
color: #1d4ed8;
}
.message.assistant .avatar {
background: #e5e7eb;
color: #111827;
}
.message.user .bubble {
grid-column: 1;
grid-row: 1;
background: #eff6ff;
border-color: #bfdbfe;
}
.bubble {
padding: 12px 14px;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 8px;
}
.bubble p {
margin: 8px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.5;
}
.message-reasoning {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 9px 10px;
background: #f1f5f9;
border: 1px solid #dbe3ee;
border-radius: 8px;
}
.message-reasoning.is-collapsed {
gap: 0;
}
.message-reasoning-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
border: 0;
padding: 0;
background: transparent;
color: #475569;
font-size: 12px;
font-weight: 750;
text-align: left;
}
.message-reasoning-status {
flex: 0 0 auto;
padding: 2px 7px;
border-radius: 999px;
background: #e2e8f0;
color: #64748b;
font-size: 11px;
}
.message-reasoning pre {
margin: 0;
max-height: 220px;
overflow: auto;
color: #334155;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tool-terminal {
margin-top: 10px;
overflow: hidden;
background: #0f172a;
border: 1px solid #1e293b;
border-radius: 8px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
}
.tool-terminal-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 34px;
padding: 8px 10px;
background: #111827;
border-bottom: 1px solid #1e293b;
}
.terminal-dots {
display: flex;
gap: 5px;
}
.terminal-dots i {
width: 9px;
height: 9px;
border-radius: 999px;
}
.terminal-dots i:nth-child(1) { background: #ef4444; }
.terminal-dots i:nth-child(2) { background: #f59e0b; }
.terminal-dots i:nth-child(3) { background: #22c55e; }
.tool-terminal-title {
min-width: 0;
overflow: hidden;
color: #d1d5db;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-terminal-status {
padding: 2px 7px;
border-radius: 999px;
background: #1d4ed8;
color: #dbeafe;
font-size: 11px;
font-weight: 800;
}
.tool-terminal.is-error .tool-terminal-status {
background: #7f1d1d;
color: #fecaca;
}
.tool-terminal.is-waiting .tool-terminal-status {
background: #854d0e;
color: #fef3c7;
}
.tool-terminal-body {
margin: 0;
max-height: 220px;
overflow: auto;
padding: 10px 12px;
color: #d1fae5;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.message-meta strong {
color: var(--text);
font-size: 13px;
}
.debug-panel {
display: grid;
grid-template-columns: minmax(0, 1fr);
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;
}
pre,
#events {
margin: 0;
max-height: 170px;
overflow: auto;
color: #334155;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
#events {
display: grid;
gap: 8px;
padding-left: 18px;
}
#events li strong,
#events li span {
display: block;
}
#events li span {
color: var(--muted);
}
.composer {
display: grid;
gap: 10px;
padding: 14px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.composer textarea {
min-height: 86px;
resize: vertical;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
#composer-hint {
color: var(--muted);
font-size: 12px;
}
.composer button {
min-width: 96px;
background: var(--accent);
color: #ffffff;
}
.composer button:hover {
background: var(--accent-strong);
}
.composer button:disabled {
cursor: wait;
opacity: 0.7;
}
[hidden] {
display: none !important;
}
@media (max-width: 860px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
}
.chat-shell {
height: auto;
min-height: 100vh;
}
.chat-header,
.debug-panel,
.composer-actions {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.debug-panel {
display: grid;
}
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DuckLM Approvals</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="simple-page">
<header class="simple-header">
<div>
<h1>Approvals</h1>
<p>Review pending local tool actions before DuckLM continues.</p>
</div>
<a class="secondary-button" href="/">Back to Chat</a>
</header>
<section id="approvals" class="approval-list" aria-live="polite"></section>
</main>
<script src="/static/app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Experience</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Experience</h1><pre id="experience"></pre><script src="/static/app.js"></script></main></body></html>
+99
View File
@@ -0,0 +1,99 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DuckLM WebChat</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">D</div>
<div>
<h1>DuckLM</h1>
<p>Local cognitive runtime</p>
</div>
</div>
<nav class="side-nav" aria-label="DuckLM sections">
<a href="/" class="active">Chat</a>
<a href="/approvals">Approvals</a>
<a href="/skills">Skills</a>
<a href="/memory">Memory</a>
<a href="/experience">Experience</a>
</nav>
<section class="settings-panel" aria-labelledby="settings-title">
<h2 id="settings-title">Session</h2>
<label>
Workspace
<input id="workspace" value="./workspace" autocomplete="off">
</label>
<label class="toggle-row">
<input id="debug" type="checkbox" checked>
<span>Show reasoning and events</span>
</label>
</section>
<section class="status-panel" aria-labelledby="status-title">
<h2 id="status-title">Runtime</h2>
<dl>
<div>
<dt>API</dt>
<dd id="api-status">checking</dd>
</div>
<div>
<dt>Model</dt>
<dd id="model-status">checking</dd>
</div>
<div>
<dt>Last task</dt>
<dd id="task-status">none</dd>
</div>
</dl>
</section>
</aside>
<main class="chat-shell">
<header class="chat-header">
<div>
<h2>Chat</h2>
<p>Messages are processed by the local Qwen role mapping through Duck Core.</p>
</div>
<button id="new-chat" class="secondary-button" type="button">New Chat</button>
</header>
<section id="messages" class="messages" aria-live="polite">
<article class="message assistant">
<div class="avatar">D</div>
<div class="bubble">
<div class="message-meta">
<strong>DuckLM</strong>
<span>ready</span>
</div>
<p>Готов. Напиши задачу, я отправлю её в локальный runtime и покажу ответ, reasoning и timeline.</p>
</div>
</article>
</section>
<section id="debug-panel" class="debug-panel">
<div class="debug-column">
<h3>Event Timeline</h3>
<ol id="events"></ol>
</div>
</section>
<form id="composer" class="composer">
<textarea id="message" rows="3" placeholder="Напиши сообщение DuckLM...">Скажи коротко, что ты DuckLM</textarea>
<div class="composer-actions">
<span id="composer-hint">Enter sends, Shift+Enter inserts a new line</span>
<button id="run" type="submit">Send</button>
</div>
</form>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Memory</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Memory</h1><input id="memory-query" placeholder="Search memory"><button id="memory-search">Search</button><pre id="memory-results"></pre><script src="/static/app.js"></script></main></body></html>
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Skills</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Skills</h1><pre id="skills"></pre><script src="/static/app.js"></script></main></body></html>
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>DuckLM Task</title><link rel="stylesheet" href="/static/style.css"></head><body><main class="shell"><h1>Task</h1><pre id="task"></pre></main></body></html>