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:
@@ -68,6 +68,45 @@ def test_conversation_project_binding():
|
||||
assert s.conversation_project("c2") == "" # unscoped
|
||||
|
||||
|
||||
def test_conversations_move_between_projects():
|
||||
# The Projects page lists chats by project_id and moves them with a PATCH;
|
||||
# if all_conversations() drops the column the list is silently empty.
|
||||
s = _store()
|
||||
s.create_conversation("c1", "projX")
|
||||
s.create_conversation("c2")
|
||||
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "projX", "c2": ""}
|
||||
|
||||
s.set_conversation_project("c2", "projX")
|
||||
s.set_conversation_project("c1", "") # removed from the project
|
||||
assert {c.id: c.project_id for c in s.all_conversations()} == {"c1": "", "c2": "projX"}
|
||||
|
||||
|
||||
def test_project_instructions_and_scoped_memory():
|
||||
# The chat system prompt takes the project's instructions plus global facts
|
||||
# and this project's facts only — another project's must never leak in.
|
||||
from synapse.memory.store import MemoryItem
|
||||
|
||||
s = _store()
|
||||
p = s.create_project("Roof rebuild")
|
||||
assert s.project_instructions(p["id"]) == "" # default: no instructions
|
||||
assert s.project_instructions("ghost") == "" # unknown project
|
||||
assert s.set_project_instructions(p["id"], "answer as a roofer")
|
||||
assert not s.set_project_instructions("ghost", "x")
|
||||
assert s.project_instructions(p["id"]) == "answer as a roofer"
|
||||
|
||||
s.add(MemoryItem(id="g", text="lives in Ohio")) # global
|
||||
s.add(MemoryItem(id="a", text="uses metal panels", project_id=p["id"])) # this project
|
||||
s.add(MemoryItem(id="b", text="prefers Lua", project_id="other")) # elsewhere
|
||||
in_scope = [m.id for m in s.all() if m.project_id in ("", p["id"])]
|
||||
assert in_scope == ["g", "a"]
|
||||
|
||||
# Deleting a project keeps its chats and facts, unscoped.
|
||||
s.create_conversation("c1", p["id"])
|
||||
s.delete_project(p["id"])
|
||||
assert s.conversation_project("c1") == ""
|
||||
assert s.get("a").project_id == ""
|
||||
|
||||
|
||||
def test_conversation_recall_uses_vec_and_matches_brute_force():
|
||||
s = _store()
|
||||
if not s.vec_enabled:
|
||||
@@ -178,3 +217,64 @@ def test_extract_text_by_type():
|
||||
out = _extract_text("blank.pdf", buf.getvalue())
|
||||
assert isinstance(out, str) # blank page -> "" or whitespace, never raises
|
||||
assert PdfReader(io.BytesIO(buf.getvalue())).pages # sanity: it was a valid PDF
|
||||
|
||||
|
||||
def test_delete_conversation_takes_its_embeddings_with_it(tmp_path, monkeypatch):
|
||||
"""Stale vectors are inert — the search joins messages — but they still
|
||||
occupy slots in the ANN over-fetch, so recall of the surviving
|
||||
conversations quietly thins out as deleted ones pile up."""
|
||||
import sqlite3
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
db = tmp_path / "t.db"
|
||||
s = PersistentMemoryStore(db)
|
||||
s.create_conversation("keep", "")
|
||||
s.create_conversation("drop", "")
|
||||
kept = s.add_message("keep", "user", "hello")
|
||||
doomed = s.add_message("drop", "user", "goodbye")
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
for mid in (kept, doomed):
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO message_vectors (message_id, embedding) VALUES (?, ?)",
|
||||
(mid, "[0.0, 1.0]"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
s.delete_conversation("drop")
|
||||
|
||||
left = {r[0] for r in conn.execute("SELECT message_id FROM message_vectors")}
|
||||
assert left == {kept}, left
|
||||
|
||||
|
||||
def test_extraction_watermark_is_idempotent(tmp_path):
|
||||
"""The curator reads a conversation when it goes idle, so the watermark is
|
||||
what stops a restart (or a second sweep) from re-reading messages and
|
||||
re-saving the facts it already saved."""
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||
s.create_conversation("c", "")
|
||||
s.add_message("c", "user", "i bought a bike")
|
||||
last = s.add_message("c", "assistant", "nice")
|
||||
|
||||
pending, mark = s.pending_extraction("c")
|
||||
assert [m["role"] for m in pending] == ["user", "assistant"]
|
||||
assert mark == last
|
||||
|
||||
s.set_extracted_through("c", mark)
|
||||
assert s.pending_extraction("c") == ([], 0) # nothing new -> no model call
|
||||
|
||||
s.add_message("c", "user", "a 2019 trek")
|
||||
pending, _ = s.pending_extraction("c")
|
||||
assert [m["content"] for m in pending] == ["a 2019 trek"] # only the unread tail
|
||||
|
||||
|
||||
def test_idle_sweep_only_claims_quiet_conversations(tmp_path):
|
||||
from synapse.memory.store import PersistentMemoryStore
|
||||
|
||||
s = PersistentMemoryStore(tmp_path / "t.db")
|
||||
s.create_conversation("fresh", "")
|
||||
s.add_message("fresh", "user", "still typing")
|
||||
assert s.conversations_awaiting_extraction(3600) == [] # too recent to be "over"
|
||||
assert s.conversations_awaiting_extraction(0) == ["fresh"]
|
||||
|
||||
Reference in New Issue
Block a user