feat: sync with upstream — v1.2.0, in-app updates, Projects, modules

Brings the public tree back in line with the development repo after several
weeks of drift caused by a stale publish include list.

New:
- In-app update path: GET /update/check compares the checkout against
  origin/main and POST /update/apply runs `ncp upgrade` detached (pull,
  rebuild, restart). The sidebar shows the version, checks on click, and
  offers an "update available" pill.
- Projects: a project workspace groups chats and RAG documents, with
  per-project instructions and document retrieval scoped to the active
  project. Replaces the standalone Documents page.
- modules/: auto-discovered feature plugins (mail, network) with their
  frontend counterparts and tests.
- Memory curation runs in-process (synapse/memory/curator.py) on the chat
  model when a conversation goes idle. The separate memory service on :8001
  is gone, along with the launcher lines that started it.

Also: the KDE theme, panel and Promethean terminal assets, the full test
suite, and VERSION 1.2.0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
janvanwan
2026-08-25 09:13:55 -05:00
parent fe5d18afa7
commit 42eaed647a
88 changed files with 4280 additions and 1888 deletions
+69 -18
View File
@@ -19,7 +19,7 @@ from synapse.nexus_config import DEFAULT_CHAT_MODEL, DEFAULT_MEMORY_MODEL
from synapse.ollama_manager import OllamaManager
from synapse import ollama_manager
from synapse.icons.compositor import _is_allowed_path
from synapse.playbook_manager import PlaybookManager
from synapse import playbook_manager
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -162,9 +162,9 @@ def test_first_playbook_is_the_system_prompt(tmp_path, monkeypatch):
instructions="Answer briefly.", order=0))
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
assert PlaybookManager.get_main_playbook().id == "main"
assert [p.id for p in PlaybookManager.get_context_playbooks()] == ["ctx"]
assert PlaybookManager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
assert playbook_manager.get_main_playbook().id == "main"
assert [p.id for p in playbook_manager.get_context_playbooks()] == ["ctx"]
assert playbook_manager.get_system_prompt() == "Be useful.\n\nAnswer briefly."
def test_settings_round_trip_over_defaults(tmp_path):
@@ -180,10 +180,10 @@ def test_past_conversations_are_searchable(tmp_path):
# silently drops the assistant's recall of past chats.
store = PersistentMemoryStore(tmp_path / "memory.db")
store.create_conversation("c1")
store.add_message("c1", "user", "how do I mount the Wingdrive?")
store.add_message("c1", "user", "how do I mount the backup drive?")
store.add_message("c1", "assistant", "use rsync over ssh")
assert store.search_conversations("wingdrive") # case-insensitive substring
assert store.search_conversations("BACKUP drive") # case-insensitive substring
assert store.search_conversations("nothing here") == []
assert store.search_conversations(" ") == []
@@ -271,10 +271,10 @@ def test_sync_compare_detects_direction(tmp_path):
def write(rows):
db.unlink(missing_ok=True)
with sq.connect(db) as conn:
# updated_at REAL, matching the production schema in store.py. A TEXT
# column here hid a real TypeError for months: the comparison in
# _extra() ran str-vs-str in the test and str-vs-float in the field.
conn.executescript(
# updated_at REAL, matching the production schema in store.py. A TEXT
# column here hid a real TypeError for months: the comparison in
# _extra() ran str-vs-str in the test and str-vs-float in the field.
"create table conversations (id text primary key, updated_at real not null);"
"create table memory (id text primary key);"
)
@@ -352,6 +352,15 @@ def test_genmon_configs_are_written_with_the_panel_down():
assert quit_at < copy_at < start_at, "genmon rc copy must happen with the panel stopped"
def test_plank_is_actually_launched():
"""Restoring ~/.config/plank only brings back the dock's launchers - nothing
in it starts Plank. The primary-follow watcher is what launches and revives
it, so it needs an autostart entry or a fresh box has no dock at all."""
desktop = REPO_ROOT / "management" / "autostart" / "plank.desktop"
assert "plank-primary-watch.sh" in desktop.read_text()
assert "plank.desktop" in (REPO_ROOT / "bin" / "panel" / "install.sh").read_text()
_VULKANINFO_IGPU_AND_LLVMPIPE = """\
Devices:
========
@@ -455,30 +464,72 @@ def test_dump_round_trips_a_db_holding_vec_tables(tmp_path):
def test_curator_drops_fabricated_facts():
"""The curator model invents two classes of fact no prompt wording stopped
(verified against mistral:7b), and both reached the real memory DB: absence
claims read off the existing-memory block ("Jon does not have any pets",
claims read off the existing-memory block ("the user does not have any pets",
which contradicted four cats on file) and specifics lifted from the
ASSISTANT's reply ("Jon's main development machine is a MacBook Pro", from
ASSISTANT's reply ("the user's main development machine is a MacBook Pro", from
the user message "What am I developing on?"). Deterministic guard, so it
holds whatever the model does."""
from synapse.memory.extractor import _reject_reason
# Absence claims are never facts.
assert _reject_reason("Jon does not have any pets", "do i have any pets?")
assert _reject_reason("Jon's favorite episode is unknown", "what's my favorite episode?")
assert _reject_reason("Jon has not specified an interest", "tell me about stargate")
assert _reject_reason("the user does not have any pets", "do i have any pets?")
assert _reject_reason("the user's favorite episode is unknown", "what's my favorite episode?")
assert _reject_reason("the user has not specified an interest", "tell me about stargate")
# Specifics the user never typed came from the assistant.
assert _reject_reason("Jon's main dev machine is a MacBook Pro", "What am I developing on?")
assert _reject_reason("the user's main dev machine is a MacBook Pro", "What am I developing on?")
# ...but the same shape grounded in the user's own words must survive.
assert _reject_reason(
"Jon owns a 2000 Ford Ranger with a 3.0L V6",
"the user owns a 2000 Ford Ranger with a 3.0L V6",
"i also have a 2000 Ford Ranger, it's a five-speed with a 3.0L V6") is None
assert _reject_reason(
"Jon has a beagle named Biscuit",
"the user has a beagle named Biscuit",
"i just adopted a dog named Biscuit, he's a beagle") is None
# A fact carrying no proper nouns or numbers can't be grounding-checked;
# the prompt owns that case, so the guard must let it through.
assert _reject_reason(
"Jon prefers short answers over long explanations",
"the user prefers short answers over long explanations",
"i really prefer short answers over long explanations") is None
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
from synapse import main
# Fake git so the test never touches the network. Behind → the remote
# VERSION file, not this checkout's, is what the UI advertises.
calls = {
("rev-list", "--count", "HEAD..origin/main"): "3",
("show", "origin/main:VERSION"): "9.9.9\n",
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
}
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
# An unreachable remote must not 500 the sidebar.
def boom(*a, **kw):
raise RuntimeError("could not resolve host")
monkeypatch.setattr(main, "_git", boom)
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 0 and "could not resolve host" in body["error"]
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
import subprocess
from synapse import main
seen = {}
def fake_popen(argv, **kw):
seen["argv"], seen["kw"] = argv, kw
return object()
monkeypatch.setattr(main, "_update_running", False)
monkeypatch.setattr(subprocess, "Popen", fake_popen)
client = TestClient(app)
assert client.post("/update/apply").json()["started"] is True
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
# Double-click must not launch a second pull/rebuild over the first.
assert client.post("/update/apply").json()["started"] is False