feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
26
coworker/memory/__init__.py
Normal file
26
coworker/memory/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from .base import (
|
||||
INDEX_THRESHOLD_CHARS,
|
||||
MemoryItem,
|
||||
MemoryStore,
|
||||
Scope,
|
||||
format_memories,
|
||||
format_memory_index,
|
||||
render_memory_block,
|
||||
)
|
||||
from .settings import MemorySettingsStore, format_user_rules
|
||||
from .sqlite_store import SQLiteMemoryStore
|
||||
from .tools import memory_tools
|
||||
|
||||
__all__ = [
|
||||
"INDEX_THRESHOLD_CHARS",
|
||||
"MemoryItem",
|
||||
"MemoryStore",
|
||||
"MemorySettingsStore",
|
||||
"Scope",
|
||||
"format_memories",
|
||||
"format_memory_index",
|
||||
"format_user_rules",
|
||||
"render_memory_block",
|
||||
"SQLiteMemoryStore",
|
||||
"memory_tools",
|
||||
]
|
||||
136
coworker/memory/base.py
Normal file
136
coworker/memory/base.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Persistent memory — adapter interface + scopes.
|
||||
|
||||
Memory is the long-lived layer above transient conversation state: durable facts,
|
||||
preferences, task notes, summaries. Scopes: global (user-wide), workspace (per project),
|
||||
session. Backends are adapters (`SQLiteMemoryStore` now, `PostgresMemoryStore` later).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Scope(str, Enum):
|
||||
GLOBAL = "global"
|
||||
WORKSPACE = "workspace"
|
||||
SESSION = "session"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryItem:
|
||||
id: int
|
||||
scope: Scope
|
||||
content: str
|
||||
key: Optional[str] = None
|
||||
summary: Optional[str] = None
|
||||
workspace: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class MemoryStore(ABC):
|
||||
@abstractmethod
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
scope: Scope = Scope.WORKSPACE,
|
||||
key: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> MemoryItem: ...
|
||||
|
||||
@abstractmethod
|
||||
def get(self, item_id: int) -> Optional[MemoryItem]: ...
|
||||
|
||||
@abstractmethod
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
scope: Optional[Scope] = None,
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> list[MemoryItem]: ...
|
||||
|
||||
@abstractmethod
|
||||
def update(
|
||||
self, item_id: int, content: str, *, summary: Optional[str] = None
|
||||
) -> Optional[MemoryItem]: ...
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, item_id: int) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
def delete_all(self, *, scope: Optional[Scope] = None) -> int: ...
|
||||
|
||||
|
||||
# MEMORY-SPEC §7: below this rendered size, every memory is injected in full; above it,
|
||||
# the block flips to index mode (newest few in full, one-line summaries for the rest,
|
||||
# bodies fetched on demand via memory_read). ~2k tokens: a typical memory is 20-40
|
||||
# tokens, so this only trips past ~50-100 memories — and the weakest supported setup
|
||||
# (a local model with an 8k context) binds the ceiling.
|
||||
INDEX_THRESHOLD_CHARS = 8_000
|
||||
# In index mode the newest N stay in full: recent facts are disproportionately relevant,
|
||||
# which softens the two-step recall cost where it matters most.
|
||||
INDEX_FULL_NEWEST = 10
|
||||
|
||||
_INDEX_NOTE = (
|
||||
"(Some memories above show only a one-line summary. Call memory_read with the "
|
||||
"[#id]s before acting on anything a summary hints at.)"
|
||||
)
|
||||
|
||||
|
||||
def _index_line(item: MemoryItem) -> str:
|
||||
"""One-line rendering: the saved summary, or a truncated first line for rows
|
||||
written before summaries existed (no data migration)."""
|
||||
text = (item.summary or "").strip()
|
||||
if not text:
|
||||
text = item.content.strip().splitlines()[0] if item.content.strip() else ""
|
||||
if len(text) > 80:
|
||||
text = text[:77] + "..."
|
||||
return f"- [#{item.id}] {text}"
|
||||
|
||||
|
||||
def format_memories(items: list[MemoryItem]) -> str:
|
||||
"""Render memories in full for injection into the system prompt. Ids are shown so
|
||||
the agent can revise a memory (`memory_update`) or retire it (`memory_forget`)."""
|
||||
if not items:
|
||||
return ""
|
||||
lines = [f"- [#{item.id}] {item.content}" for item in items]
|
||||
return "Known memories (from earlier sessions):\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def format_memory_index(
|
||||
items: list[MemoryItem], *, full_newest: int = INDEX_FULL_NEWEST
|
||||
) -> str:
|
||||
"""Index rendering: newest `full_newest` in full, one-line summaries for the rest,
|
||||
plus the fetch-before-acting note for memory_read."""
|
||||
if not items:
|
||||
return ""
|
||||
newest = {item.id for item in sorted(items, key=lambda i: i.id)[-full_newest:]}
|
||||
lines = [
|
||||
f"- [#{item.id}] {item.content}" if item.id in newest else _index_line(item)
|
||||
for item in items
|
||||
]
|
||||
return (
|
||||
"Known memories (from earlier sessions):\n"
|
||||
+ "\n".join(lines)
|
||||
+ f"\n{_INDEX_NOTE}"
|
||||
)
|
||||
|
||||
|
||||
def render_memory_block(
|
||||
items: list[MemoryItem], *, threshold_chars: int = INDEX_THRESHOLD_CHARS
|
||||
) -> str:
|
||||
"""The injected memories block. Full mode while it's affordable; automatically and
|
||||
invisibly flips to index mode when the full rendering exceeds the threshold
|
||||
(MEMORY-SPEC §7). Evaluated once per engine build — a session is always in exactly
|
||||
one mode for its whole life."""
|
||||
full = format_memories(items)
|
||||
if len(full) <= threshold_chars:
|
||||
return full
|
||||
return format_memory_index(items)
|
||||
75
coworker/memory/settings.py
Normal file
75
coworker/memory/settings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Memory settings — the on/off switch and the user's standing rules.
|
||||
|
||||
Settings-level state, deliberately outside the memory table (MEMORY-SPEC §2, §4.3, §6):
|
||||
|
||||
- ``enabled``: off means engines are built with no memory tools, no memories block, and
|
||||
no memory guidance. Existing memories are kept but inert. Read at build time; running
|
||||
sessions finish under the mode they started with.
|
||||
- ``user_rules``: one text blob the user typed into Settings. Injected verbatim above
|
||||
auto memories; on conflict the rule wins. **The agent never writes, edits, or deletes
|
||||
this** — no tool touches it; the only writer is the Settings UI via the manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# User Rules is a bounded settings field, not a document store: big enough for any
|
||||
# real rule list, small enough that a paste-accident (or a hostile client) can't
|
||||
# bloat every future system prompt.
|
||||
MAX_USER_RULES_CHARS = 20_000
|
||||
|
||||
|
||||
class MemorySettingsStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _load(self) -> dict:
|
||||
try:
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._load().get("enabled", True)) # on by default (spec §5.4)
|
||||
|
||||
@property
|
||||
def user_rules(self) -> str:
|
||||
rules = self._load().get("user_rules", "")
|
||||
return rules if isinstance(rules, str) else ""
|
||||
|
||||
def set(
|
||||
self, *, enabled: Optional[bool] = None, user_rules: Optional[str] = None
|
||||
) -> dict:
|
||||
with self._lock:
|
||||
data = self._load()
|
||||
if enabled is not None:
|
||||
data["enabled"] = bool(enabled)
|
||||
if user_rules is not None:
|
||||
data["user_rules"] = str(user_rules)[:MAX_USER_RULES_CHARS]
|
||||
self._save(data)
|
||||
return {"enabled": self.enabled, "user_rules": self.user_rules}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return {"enabled": self.enabled, "user_rules": self.user_rules}
|
||||
|
||||
|
||||
def format_user_rules(rules: str) -> str:
|
||||
"""The system-prompt block for user rules. Empty rules -> empty string."""
|
||||
text = (rules or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return (
|
||||
"User rules (written by the user in Settings; always follow these — on any "
|
||||
f"conflict they outrank learned memories):\n{text}"
|
||||
)
|
||||
160
coworker/memory/sqlite_store.py
Normal file
160
coworker/memory/sqlite_store.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""SQLite-backed memory store (the default adapter)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .base import MemoryItem, MemoryStore, Scope
|
||||
|
||||
|
||||
class SQLiteMemoryStore(MemoryStore):
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = str(path)
|
||||
if self.path != ":memory:":
|
||||
Path(self.path).expanduser().parent.mkdir(parents=True, exist_ok=True)
|
||||
# check_same_thread=False: the server runs the WS handler on a different thread
|
||||
# than the store was created on; a lock serializes access.
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
workspace TEXT,
|
||||
session_id TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
# Databases created before the summary column existed: rows without one fall
|
||||
# back to a truncated first line of content at render time (no data migration).
|
||||
cols = {
|
||||
row["name"]
|
||||
for row in self._conn.execute("PRAGMA table_info(memories)").fetchall()
|
||||
}
|
||||
if "summary" not in cols:
|
||||
self._conn.execute("ALTER TABLE memories ADD COLUMN summary TEXT")
|
||||
self._conn.commit()
|
||||
|
||||
def add(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
scope: Scope = Scope.WORKSPACE,
|
||||
key: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> MemoryItem:
|
||||
scope = Scope(scope)
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"INSERT INTO memories (scope, key, content, summary, workspace, session_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(scope.value, key, content, summary, workspace, session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
item = self.get(cursor.lastrowid)
|
||||
assert item is not None
|
||||
return item
|
||||
|
||||
def get(self, item_id: int) -> Optional[MemoryItem]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM memories WHERE id = ?", (item_id,)
|
||||
).fetchone()
|
||||
return _row_to_item(row) if row else None
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
scope: Optional[Scope] = None,
|
||||
workspace: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
) -> list[MemoryItem]:
|
||||
query = "SELECT * FROM memories WHERE 1 = 1"
|
||||
params: list[object] = []
|
||||
if scope is not None:
|
||||
query += " AND scope = ?"
|
||||
params.append(Scope(scope).value)
|
||||
if workspace is not None:
|
||||
query += " AND workspace = ?"
|
||||
params.append(workspace)
|
||||
if session_id is not None:
|
||||
query += " AND session_id = ?"
|
||||
params.append(session_id)
|
||||
query += " ORDER BY id"
|
||||
with self._lock:
|
||||
rows = self._conn.execute(query, params).fetchall()
|
||||
return [_row_to_item(row) for row in rows]
|
||||
|
||||
def update(
|
||||
self, item_id: int, content: str, *, summary: Optional[str] = None
|
||||
) -> Optional[MemoryItem]:
|
||||
with self._lock:
|
||||
if summary is not None:
|
||||
self._conn.execute(
|
||||
"UPDATE memories SET content = ?, summary = ? WHERE id = ?",
|
||||
(content, summary, item_id),
|
||||
)
|
||||
else:
|
||||
self._conn.execute(
|
||||
"UPDATE memories SET content = ? WHERE id = ?", (content, item_id)
|
||||
)
|
||||
self._conn.commit()
|
||||
return self.get(item_id)
|
||||
|
||||
def delete(self, item_id: int) -> bool:
|
||||
with self._lock:
|
||||
cursor = self._conn.execute("DELETE FROM memories WHERE id = ?", (item_id,))
|
||||
self._conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_all(self, *, scope: Optional[Scope] = None) -> int:
|
||||
"""Delete every memory (optionally one scope). Returns the number removed."""
|
||||
with self._lock:
|
||||
if scope is not None:
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM memories WHERE scope = ?", (Scope(scope).value,)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute("DELETE FROM memories")
|
||||
self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def rekey_workspace(self, old: str, new: str) -> int:
|
||||
"""Re-key workspace-scoped memories from one project key to another — the
|
||||
twentieth-pass one-time path→git migration. Rows are independent, so a
|
||||
collision with existing rows under `new` is just a union. Returns the
|
||||
number of rows moved."""
|
||||
if old == new:
|
||||
return 0
|
||||
with self._lock:
|
||||
cursor = self._conn.execute(
|
||||
"UPDATE memories SET workspace = ? WHERE workspace = ? AND scope = ?",
|
||||
(new, old, Scope.WORKSPACE.value),
|
||||
)
|
||||
self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
|
||||
def _row_to_item(row: sqlite3.Row) -> MemoryItem:
|
||||
return MemoryItem(
|
||||
id=row["id"],
|
||||
scope=Scope(row["scope"]),
|
||||
content=row["content"],
|
||||
key=row["key"],
|
||||
summary=row["summary"],
|
||||
workspace=row["workspace"],
|
||||
session_id=row["session_id"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
143
coworker/memory/tools.py
Normal file
143
coworker/memory/tools.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Memory tools — the agent's explicit paths into memory.
|
||||
|
||||
`remember` saves a new fact; `memory_update` / `memory_forget` revise or retire one by
|
||||
the [#id] shown in the known-memories block, so corrections replace stale facts instead
|
||||
of piling up next to them. `memory_read` fetches full bodies by id — the retrieval half
|
||||
of index mode (MEMORY-SPEC §7); registered always, harmless in full mode.
|
||||
|
||||
`on_saved` is the save-notice hook (spec §5.1): the manager passes a callback that pushes
|
||||
a memory_saved event to the session's surface so it can render "I'll remember that — …
|
||||
[Undo]" inline in the transcript. It fires for `memory_update` too — the
|
||||
update-don't-duplicate rule means many saves arrive as edits to an existing memory, and
|
||||
those were invisible (owner-hit 2026-07-28) — carrying the previous text so Undo can put
|
||||
it back. Failures in the callback never fail the write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .base import MemoryItem, MemoryStore, Scope
|
||||
|
||||
_SCOPES = {s.value for s in Scope}
|
||||
|
||||
_META = dict(category="memory", risk_level="low", capabilities=["remember"])
|
||||
|
||||
|
||||
def memory_tools(
|
||||
store: MemoryStore,
|
||||
*,
|
||||
workspace: Optional[str],
|
||||
on_saved: Optional[Callable[[MemoryItem, Optional[str]], None]] = None,
|
||||
saving_enabled: Optional[Callable[[], bool]] = None,
|
||||
) -> list:
|
||||
"""The agent's memory tools.
|
||||
|
||||
`saving_enabled` is a LIVE callable checked on each write, so the Settings switch
|
||||
applies to conversations already running — in BOTH directions (owner-hit
|
||||
2026-07-28: off kept saving, then on kept refusing). The registry is fixed at
|
||||
build, so the write tools are always registered and refuse when saving is off;
|
||||
`memory_read` never gates (off = stop learning, not amnesia).
|
||||
"""
|
||||
|
||||
def _saving_off() -> bool:
|
||||
return saving_enabled is not None and not saving_enabled()
|
||||
|
||||
_OFF_ERROR = (
|
||||
"Saving memories is turned off in the user's Settings (they can turn it back "
|
||||
"on in Settings ▸ Memory). Nothing was saved — tell the user plainly instead "
|
||||
"of implying you remembered it."
|
||||
)
|
||||
|
||||
def _announce(item: MemoryItem, previous: Optional[str]) -> None:
|
||||
"""Surface the write to the user (§5.1). Best-effort: the notice is never worth
|
||||
failing a write that already succeeded."""
|
||||
if on_saved is None:
|
||||
return
|
||||
try:
|
||||
on_saved(item, previous)
|
||||
except Exception:
|
||||
pass
|
||||
def remember(content: str, summary: str = "", scope: str = "workspace") -> dict:
|
||||
"""Save a durable memory (a fact or preference) to recall in future sessions.
|
||||
Check the known-memories list first: if one already covers this, use
|
||||
memory_update instead of saving a near-duplicate.
|
||||
|
||||
Args:
|
||||
content (str): The thing to remember, with the why.
|
||||
summary (str): One-line gist (15 words max) shown in compact listings.
|
||||
scope (str): "global" (facts about the user — applies everywhere) or
|
||||
"workspace" (facts about this project only).
|
||||
"""
|
||||
if _saving_off():
|
||||
return {"saved": False, "error": _OFF_ERROR}
|
||||
chosen = Scope(scope) if scope in _SCOPES else Scope.WORKSPACE
|
||||
if chosen is Scope.SESSION: # dead scope (spec §3): never save to it
|
||||
chosen = Scope.WORKSPACE
|
||||
item = store.add(
|
||||
content,
|
||||
scope=chosen,
|
||||
summary=summary.strip() or None,
|
||||
workspace=workspace if chosen is Scope.WORKSPACE else None,
|
||||
)
|
||||
_announce(item, None)
|
||||
return {"id": item.id, "scope": item.scope.value, "saved": True}
|
||||
|
||||
def memory_read(memory_ids: list[int]) -> dict:
|
||||
"""Read the full content of memories by id (use when the known-memories list
|
||||
shows only a one-line summary and you need the details before acting).
|
||||
|
||||
Args:
|
||||
memory_ids (list[int]): The [#id]s to fetch.
|
||||
"""
|
||||
found, missing = [], []
|
||||
for mid in memory_ids:
|
||||
item = store.get(int(mid))
|
||||
if item is None:
|
||||
missing.append(int(mid))
|
||||
else:
|
||||
found.append(
|
||||
{"id": item.id, "scope": item.scope.value, "content": item.content}
|
||||
)
|
||||
result: dict = {"memories": found}
|
||||
if missing:
|
||||
result["missing"] = missing
|
||||
return result
|
||||
|
||||
def memory_update(memory_id: int, content: str, summary: str = "") -> dict:
|
||||
"""Rewrite an existing memory with corrected or refined content.
|
||||
|
||||
Args:
|
||||
memory_id (int): The memory's id, from the [#id] in the known-memories list.
|
||||
content (str): The full corrected memory text (replaces the old text).
|
||||
summary (str): Corrected one-line gist (15 words max).
|
||||
"""
|
||||
if _saving_off():
|
||||
return {"updated": False, "error": _OFF_ERROR}
|
||||
# Captured BEFORE the write so the user's Undo can restore the old wording.
|
||||
existing = store.get(memory_id)
|
||||
previous = existing.content if existing is not None else None
|
||||
item = store.update(memory_id, content, summary=summary.strip() or None)
|
||||
if item is None:
|
||||
return {"updated": False, "error": f"no memory with id {memory_id}"}
|
||||
_announce(item, previous)
|
||||
return {"updated": True, "id": item.id}
|
||||
|
||||
def memory_forget(memory_id: int) -> dict:
|
||||
"""Delete a memory that turned out to be wrong or is no longer true.
|
||||
|
||||
Args:
|
||||
memory_id (int): The memory's id, from the [#id] in the known-memories list.
|
||||
"""
|
||||
if _saving_off():
|
||||
return {"deleted": False, "error": _OFF_ERROR}
|
||||
if store.delete(memory_id):
|
||||
return {"deleted": True, "id": memory_id}
|
||||
return {"deleted": False, "error": f"no memory with id {memory_id}"}
|
||||
|
||||
return [
|
||||
ai.tool(fn, metadata=ai.ToolMetadata(**_META))
|
||||
for fn in (remember, memory_read, memory_update, memory_forget)
|
||||
]
|
||||
Reference in New Issue
Block a user