forked from enderofwings/NexusOS
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:
+55
-20
@@ -1,5 +1,12 @@
|
||||
"""Memory extraction — asks Mistral to evaluate a conversation exchange and
|
||||
decide if it contains a new permanent personal fact worth saving."""
|
||||
"""Memory extraction — asks the curator model to read a finished conversation
|
||||
and decide which new permanent personal facts it contains.
|
||||
|
||||
Reads the whole transcript, not a single exchange. A fact is rarely complete in
|
||||
the turn that introduces it ("I have a Honda" at turn 3 becomes a 2006 Accord
|
||||
with 312k miles by turn 7), and the store is append-only for the curator, so
|
||||
extracting per-exchange could only ever accumulate fragments of the same fact.
|
||||
Waiting until the conversation is idle is also the only reliable signal that a
|
||||
statement is finished rather than half-said."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,13 +24,19 @@ _TR = "┅" * 55
|
||||
_PROMPT = """\
|
||||
You are a memory curator for a personal AI assistant named Nexus.
|
||||
|
||||
Extract EVERY new, permanent personal fact the USER stated in this exchange.
|
||||
There may be SEVERAL facts in one message — output one JSON object for each.
|
||||
Extract EVERY new, permanent personal fact the USER stated in this conversation.
|
||||
There may be SEVERAL facts across the conversation — output one JSON object for
|
||||
each.
|
||||
|
||||
ONLY the USER line is a source of facts. The ASSISTANT line and the existing
|
||||
memory below are context to help you understand the USER line — never extract
|
||||
ONLY the USER lines are a source of facts. The ASSISTANT lines and the existing
|
||||
memory below are context to help you understand the USER lines — never extract
|
||||
anything from them. If the assistant said it and the user did not, it is NOT a
|
||||
fact. Copy what the user actually said; do not infer, embellish, or add a
|
||||
fact.
|
||||
|
||||
You are reading the WHOLE conversation, so record the FINAL, most complete form
|
||||
of each fact. If the user gives a detail early and refines it later, output one
|
||||
object with the refined version — never one per stage. If the user corrects or
|
||||
retracts something, keep only what they ended up saying. Copy what the user actually said; do not infer, embellish, or add a
|
||||
judgement the user did not make (never call something their "favorite",
|
||||
"main", or "best" unless the user used that word).
|
||||
|
||||
@@ -56,8 +69,7 @@ Only invent a new section if none fit, and make it a SHORT single word
|
||||
(e.g. Hobbies, Pets, Health). Never use a sentence or long phrase as a section.
|
||||
|
||||
---
|
||||
USER: {user_message}
|
||||
ASSISTANT: {assistant_response}
|
||||
{transcript}
|
||||
---
|
||||
|
||||
Respond with JSON only — no prose, no markdown fences. Output one object PER
|
||||
@@ -95,11 +107,11 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
prompt is worded (verified against mistral:7b):
|
||||
|
||||
1. Absence claims. It reads the existing-memory block and writes things like
|
||||
"Jon does not have any pets" - which contradicted four cats already on
|
||||
"the user does not have any pets" - which contradicted four cats already on
|
||||
file. Silence is not a fact.
|
||||
2. Assistant-sourced specifics. It lifts names the ASSISTANT said and
|
||||
attributes them to the user: a reply that echoed a stale memory row
|
||||
produced "Jon's main development machine is a MacBook Pro" off the user
|
||||
produced "the user's main development machine is a MacBook Pro" off the user
|
||||
message "What am I developing on?".
|
||||
|
||||
The grounding test only fires when a fact carries distinctive tokens and
|
||||
@@ -114,20 +126,44 @@ def _reject_reason(fact: str, user_message: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def render_transcript(messages: list[dict]) -> str:
|
||||
"""The conversation as the curator sees it. Assistant turns are truncated
|
||||
hard: they are context for reading the user's lines, never a fact source,
|
||||
and a long reply would otherwise crowd out the lines that matter."""
|
||||
lines = []
|
||||
for m in messages:
|
||||
role = (m.get("role") or "").upper()
|
||||
if role not in ("USER", "ASSISTANT"):
|
||||
continue
|
||||
text = (m.get("content") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
lines.append(f"{role}: {text[:3000] if role == 'USER' else text[:600]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def extract_memory(
|
||||
user_message: str,
|
||||
assistant_response: str,
|
||||
messages: list[dict],
|
||||
existing_sections: list[str],
|
||||
existing_texts: list[str],
|
||||
ollama_manager,
|
||||
model: str = DEFAULT_MEMORY_MODEL,
|
||||
num_gpu: int | None = 0,
|
||||
) -> list[dict]:
|
||||
"""Ask Mistral to extract saveable memory facts from a conversation exchange.
|
||||
"""Extract saveable memory facts from a whole conversation.
|
||||
|
||||
Returns a list of {"section": ..., "text": ...} — possibly empty. A single
|
||||
exchange can hold several facts, and Mistral emits one JSON object per fact.
|
||||
`messages` is the transcript as [{role, content}]. Returns a list of
|
||||
{"section": ..., "text": ...} — possibly empty. One conversation can hold
|
||||
several facts, and the model emits one JSON object per fact.
|
||||
"""
|
||||
transcript = render_transcript(messages)
|
||||
if not transcript:
|
||||
return []
|
||||
# Grounding is checked against everything the user actually typed, so a fact
|
||||
# assembled from details spread across several of their turns still passes.
|
||||
user_text = "\n".join(
|
||||
(m.get("content") or "") for m in messages if (m.get("role") or "") == "user"
|
||||
)
|
||||
if existing_texts:
|
||||
# ponytail: only the 12 most-recent facts go in the dedup context, not all
|
||||
# ~40. On a CPU-bound curator (num_gpu=0) prompt-eval dominates, and 40
|
||||
@@ -148,8 +184,7 @@ async def extract_memory(
|
||||
prompt = _PROMPT.format(
|
||||
existing_texts=texts_block,
|
||||
existing_sections=sections_line,
|
||||
user_message=user_message[:3000],
|
||||
assistant_response=assistant_response[:800],
|
||||
transcript=transcript,
|
||||
)
|
||||
# MindTrace: curator pre-flight (full prompt) so its reasoning is visible in
|
||||
# the same console as the frontline model, not just Python warnings on failure.
|
||||
@@ -194,7 +229,7 @@ async def extract_memory(
|
||||
def _keep(o):
|
||||
if isinstance(o, dict) and o.get("save") and o.get("section") and o.get("text"):
|
||||
fact = str(o["text"]).strip()
|
||||
reason = _reject_reason(fact, user_message)
|
||||
reason = _reject_reason(fact, user_text)
|
||||
if reason:
|
||||
_synapse_trace(f"◆ CURATOR DROPPED ({reason}): {fact}\n")
|
||||
return
|
||||
@@ -217,7 +252,7 @@ async def extract_memory(
|
||||
else:
|
||||
_keep(obj)
|
||||
if not results:
|
||||
_log.warning("memory: nothing saved. mistral said: %.300r", text)
|
||||
_log.warning("memory: nothing saved. curator said: %.300r", text)
|
||||
_synapse_trace(f"◆ CURATOR VERDICT: nothing to save\n{_TR}\n\n")
|
||||
else:
|
||||
_facts = "; ".join(f"[{r['section']}] {r['text']}" for r in results)
|
||||
|
||||
Reference in New Issue
Block a user