feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: 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:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

View 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
View 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)

View 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}"
)

View 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
View 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)
]