From 24324a6d256d8f2300590ec0278f358f79ba425a Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sat, 17 Oct 2026 20:06:09 +0530 Subject: [PATCH 1/7] feat(memory): source spans, confidence and state, and deletes that cascade (wip) --- sentient/agent/second_look.py | 4 +- sentient/app.py | 13 + sentient/evolution/service.py | 5 +- sentient/gateway/routes/memory.py | 30 +- sentient/memory/facts.py | 347 ++++++++++++++++--- sentient/memory/origins.py | 103 +++++- sentient/memory/schema.sql | 1 + sentient/memory/usermodel.py | 2 +- sentient/memory/workspace.py | 18 + sentient/store/migrations/0015_fact_links.py | 126 +++++++ sentient/store/schema.sql | 8 +- sentient/tools/builtin/memory_tool.py | 2 +- tests/memory/test_dreaming.py | 16 +- 13 files changed, 608 insertions(+), 67 deletions(-) create mode 100644 sentient/store/migrations/0015_fact_links.py diff --git a/sentient/agent/second_look.py b/sentient/agent/second_look.py index ac0fd13bf..5cf063fc5 100644 --- a/sentient/agent/second_look.py +++ b/sentient/agent/second_look.py @@ -404,8 +404,8 @@ async def known_addresses(store: Any, items: Iterable[str]) -> set[str]: continue try: rows = await store.fetchall( - "SELECT content FROM facts WHERE status = 'active' AND (expires_at IS NULL OR expires_at > ?)" - " AND lower(content) LIKE ? LIMIT 20", + "SELECT content FROM facts WHERE status = 'active' AND state = 'current'" + " AND (expires_at IS NULL OR expires_at > ?) AND lower(content) LIKE ? LIMIT 20", (now_iso(), f"%{item.lower()}%"), ) except Exception as exc: # no memory table (bare tests): nothing is known diff --git a/sentient/app.py b/sentient/app.py index 75865c320..270821220 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -272,6 +272,17 @@ async def delete_chat(self, session_id: str, *, reason: str | None = None) -> No log.exception("removing the saved results of incognito chat %s failed", session_id) self.bus.publish("session.deleted", {"session_id": session_id, **({"reason": reason} if reason else {})}) + def _after_memory_erased(self, texts: list[str]) -> None: + """A memory was deleted (#152): its words leave USER.md and MEMORY.md, and the user model reads its insights + again (some may be gone with it).""" + try: + self.workspace.scrub(texts, self.config.assistant.user_name) + except OSError: + log.exception("removing a deleted memory from the workspace files failed") + model = getattr(self, "user_model", None) + if model is not None: + model._vectors = None + def tool_extra(self) -> dict[str, Any]: return {"app": self, "skills": self.skills, "workspace": self.workspace, "registry": self.registry} @@ -292,6 +303,8 @@ async def start(self) -> SentientApp: self.memory = FactMemory(self.store, self.llm, self.config) if self.store.vec_available else None if self.memory is None: log.warning("sqlite-vec unavailable: semantic memory disabled") + else: + self.memory.on_erase = self._after_memory_erased with as_kind(self.notifications.model_kind): await self.notifications.start() diff --git a/sentient/evolution/service.py b/sentient/evolution/service.py index 10721e88e..58fa3232f 100644 --- a/sentient/evolution/service.py +++ b/sentient/evolution/service.py @@ -895,7 +895,7 @@ async def update_profile(self, now: datetime | None = None, *, force: bool = Fal after, params = after_watermark(facts_mark, "updated_at", "id") # MEMORY.md and USER.md are personal: work and project memories never go in them (#155) new_facts = await store.fetchall( - "SELECT id, content, topics, source, updated_at FROM facts WHERE status = 'active'" + "SELECT id, content, topics, source, updated_at FROM facts WHERE status = 'active' AND state = 'current'" f" AND memory_type = 'long-term' AND {after} AND (expires_at IS NULL OR expires_at > ?)" f" AND {PROFILE_SCOPES} ORDER BY updated_at, id", (*params, nowiso), @@ -926,7 +926,8 @@ async def update_profile(self, now: datetime | None = None, *, force: bool = Fal budget = cfg.memory.workspace_budget_chars all_facts = await store.fetchall( - "SELECT content FROM facts WHERE status = 'active' AND (expires_at IS NULL OR expires_at > ?)" + "SELECT content FROM facts WHERE status = 'active' AND state = 'current'" + " AND (expires_at IS NULL OR expires_at > ?)" f" AND {PROFILE_SCOPES} ORDER BY updated_at DESC, id DESC LIMIT 80", (nowiso,), ) diff --git a/sentient/gateway/routes/memory.py b/sentient/gateway/routes/memory.py index 3a54b1001..aae641939 100644 --- a/sentient/gateway/routes/memory.py +++ b/sentient/gateway/routes/memory.py @@ -33,14 +33,16 @@ def _memory(request: Request): @router.get("") async def list_memories( request: Request, topic: str = "", q: str = "", source: str = "", limit: int = 500, offset: int = 0, - scope: str = "", + scope: str = "", state: str = "", ): s = get_core(request) if s.memory is None: return [] + if state and state not in {"all", "current", "disputed", "superseded"}: + raise HTTPException(400, "state must be current, disputed, superseded or all") return await s.memory.list_facts( max(1, min(limit, 2000)), max(0, offset), topic=topic or None, source=source or None, q=q or None, - scope=scope or None, + scope=scope or None, state=state or None, ) @@ -257,10 +259,30 @@ async def update_memory(request: Request, memory_id: int, body: dict): raise HTTPException(404, f"memory {memory_id} not found") from exc +@router.get("/{memory_id}") +async def get_memory(request: Request, memory_id: int): + """One memory with every source and exactly where it said it (#152), and what replaced it when superseded.""" + fact = await _memory(request).get_fact(memory_id) + if fact is None: + raise HTTPException(404, f"memory {memory_id} not found") + if fact.get("superseded_by"): + newer = await _memory(request).get_fact(int(fact["superseded_by"])) + fact["superseded_by_content"] = newer["content"] if newer else None + return fact + + @router.patch("/{memory_id}") async def move_memory(request: Request, memory_id: int, body: dict): - """Move a memory to another scope (#155): ``{scope}``.""" + """Move a memory to another scope (#155): ``{scope}``; or say it isn't right or is (#152): ``{state: + "disputed"|"current"}``.""" mem = _memory(request) + if "state" in body: + try: + return await mem.set_state(memory_id, str(body.get("state") or "")) + except KeyError as exc: + raise HTTPException(404, f"memory {memory_id} not found") from exc + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc scope = memory_scopes.normalize(body.get("scope")) if not scope: raise HTTPException(400, "scope is required") @@ -279,7 +301,7 @@ async def move_memory(request: Request, memory_id: int, body: dict): @router.delete("/{memory_id}") async def delete_memory(request: Request, memory_id: int): mem = _memory(request) - deleted = await mem.forget(memory_id, notify=True) + deleted = await mem.erase(memory_id, notify=True) # with everything derived from it (#152) if not deleted: raise HTTPException(404, f"memory {memory_id} not found") return {"deleted": True} diff --git a/sentient/memory/facts.py b/sentient/memory/facts.py index f63917adc..6c119b3a0 100644 --- a/sentient/memory/facts.py +++ b/sentient/memory/facts.py @@ -72,10 +72,16 @@ MEMORY_COLUMNS = ( "id, content, source, topics, memory_type, created_at, updated_at, expires_at, previous_content, status, review," - " scope" + " scope, confidence, state, superseded_by, source_ref" ) KEPT_APART_META = "memory.kept_apart" # pairs the user split again after the tidy-up merged them (#353) ACTIVE = "status = 'active'" # held memories (``pending``) are left out of every read but ``pending_facts`` +# #152: a memory is current, disputed (the user said it isn't right) or superseded (a newer one replaced it). Only +# current ones go into prompts; superseded ones are history and never count as the same thing as a new fact. +STATES = ("current", "disputed", "superseded") +IN_USE = "status = 'active' AND state = 'current'" +LIVE = "state != 'superseded'" +RELEARNED_BOOST = 0.1 # confidence gained each time another source says the same thing _TOKEN_RE = re.compile(r"[a-z0-9]+") STOPWORDS = frozenset( @@ -612,11 +618,25 @@ def _row_to_memory(r: Any, similarity: float | None = None) -> dict: "review": reviews.load(r["review"]) if "review" in r.keys() else None, # noqa: SIM118 "scope": (r["scope"] if "scope" in r.keys() else None) or memory_scopes.PERSONAL, # noqa: SIM118 } + keys = r.keys() + if "state" in keys: + d["confidence"] = round(float(r["confidence"] if r["confidence"] is not None else 0.8), 2) + d["state"] = r["state"] or "current" + d["superseded_by"] = r["superseded_by"] + d["ref"] = _load_ref(r["source_ref"]) if similarity is not None: d["similarity"] = round(similarity, 4) return d +def _load_ref(raw: Any) -> dict | None: + try: + ref = json.loads(raw) if raw else None + except (TypeError, ValueError): + return None + return ref if isinstance(ref, dict) else None + + def _in_scopes(scopes: list[str] | None, col: str = "scope") -> tuple[str, list[str]]: """SQL condition (with its parameters) that keeps facts in ``scopes``; ``("1", [])`` for every scope.""" if scopes is None: @@ -646,6 +666,9 @@ def __init__(self, store: Store, llm: LLMProvider, config: SentientConfig, bus: self.vec = VecTable(store, "facts_vec") self.episodic = EpisodicMemory(store, llm, config) self._qcache: OrderedDict[tuple[str, str], list[float]] = OrderedDict() + # set by the app: also scrub the words of erased memories from the workspace files and the user model's + # caches (#152); called with the texts that went + self.on_erase: Any = None async def ensure_schema(self) -> None: await ensure_memory_schema(self.store) @@ -691,14 +714,16 @@ async def embed_query(self, text: str) -> list[float]: # ------------------------------------------------------------------ recall async def _keyword_ids( - self, tokens: list[str], limit: int, source: str | None, scopes: list[str] | None = None + self, tokens: list[str], limit: int, source: str | None, scopes: list[str] | None = None, + current_only: bool = False, ) -> list[int]: if not tokens: return [] in_scope, scope_params = _in_scopes(scopes, "f.scope") + state = "f.state = 'current'" if current_only else "f.state != 'superseded'" sql = ( "SELECT f.id FROM facts_fts JOIN facts f ON f.id = facts_fts.rowid" - f" WHERE facts_fts MATCH ? AND f.status = 'active' AND {in_scope}" + f" WHERE facts_fts MATCH ? AND f.status = 'active' AND {state} AND {in_scope}" ) params: list[Any] = [" OR ".join(f'"{t}"' for t in tokens), *scope_params] if source is not None: @@ -713,12 +738,15 @@ async def _keyword_ids( return [] async def _search( - self, query: str, k: int, min_sim: float, source: str | None = None, scopes: list[str] | None = None + self, query: str, k: int, min_sim: float, source: str | None = None, scopes: list[str] | None = None, + current_only: bool = True, ) -> list[dict]: """Hybrid recall: vector neighbours plus FTS5 keyword matches, ranked by ``similarity + keyword_weight * share of query words present``. Embedding models compress similarities (unrelated text still scores ~0.65), so exact words help ordering. - ``scopes`` keeps facts in those scopes only (#155); None reads every scope.""" + ``scopes`` keeps facts in those scopes only (#155); None reads every scope. ``current_only``: leave out + disputed memories too (superseded ones are always left out), as everything that goes into a prompt does.""" + state = "state = 'current'" if current_only else LIVE await self.ensure_schema() qvec = await self.embed_query(query) now = now_iso() @@ -737,7 +765,7 @@ async def _search( sims = {int(r["id"]): 1.0 - float(r["distance"]) for r in rows} weight = self.config.memory.keyword_weight tokens = keyword_tokens(query) - kw_ids = await self._keyword_ids(tokens, pool, source, scopes) if weight > 0 else [] + kw_ids = await self._keyword_ids(tokens, pool, source, scopes, current_only) if weight > 0 else [] missing = [i for i in kw_ids if i not in sims] if missing: marks = ",".join("?" * len(missing)) @@ -752,7 +780,7 @@ async def _search( marks = ",".join("?" * len(sims)) in_scope, scope_params = _in_scopes(scopes) rows = await self.store.fetchall( - f"SELECT {MEMORY_COLUMNS} FROM facts WHERE id IN ({marks}) AND {ACTIVE} AND {in_scope}", + f"SELECT {MEMORY_COLUMNS} FROM facts WHERE id IN ({marks}) AND {ACTIVE} AND {state} AND {in_scope}", [*sims, *scope_params], ) qset = set(tokens) @@ -779,16 +807,18 @@ async def recall( source: str | None = None, track: bool = True, scopes: list[str] | None = None, + current_only: bool = True, ) -> list[dict]: """Relevant facts for ``query``. ``track`` counts the recall (dreaming promotes short-term facts that keep being recalled); internal lookups pass False. ``scopes``: only facts in these - scopes (``memory.scopes.recall_scopes``: a chat's scope plus always); None reads every scope.""" + scopes (``memory.scopes.recall_scopes``: a chat's scope plus always); None reads every scope. + ``current_only``: only current memories (#152), as prompts get; dedup also sees disputed ones.""" top_k = top_k if top_k is not None else self.config.memory.facts_top_k min_sim = min_similarity if min_similarity is not None else self.config.memory.min_similarity if top_k <= 0 or not query.strip(): return [] try: - out = await self._search(query, top_k, min_sim, source, scopes) + out = await self._search(query, top_k, min_sim, source, scopes, current_only) except Exception as exc: log.warning("memory recall skipped: %s", exc) return [] @@ -886,29 +916,201 @@ async def forget(self, fid: int, *, notify: bool = False) -> bool: self.publish("DELETE", fid, row["content"] if row else None) return deleted + async def erase(self, fid: int, *, notify: bool = False) -> bool: + """Delete a memory the user (or a forget) took away, with everything derived from it (#152): its vector, its + merged copies and source links (:meth:`forget`), the chat summaries made of the messages it came from that + repeat it, the nightly journal's lines about it, the user model's evidence from it (an inferred insight with + nothing else behind it goes), the memory chips on replies and task runs that had it in mind, and (through + ``on_erase``) the lines of USER.md and MEMORY.md that say it. Its own previous wording goes with it.""" + await self.ensure_schema() + row = await self.store.fetchone( + "SELECT id, content, previous_content, source_ref FROM facts WHERE id = ?", (fid,) + ) + if row is None: + return False + copies = await self.store.fetchall( + "SELECT id, content, previous_content, source_ref FROM facts WHERE merged_into = ? AND status = 'merged'", + (fid,), + ) + links = await self.store.fetchall( + "SELECT ref FROM fact_sources WHERE fact_id = ?" + " OR fact_id IN (SELECT id FROM facts WHERE merged_into = ? AND status = 'merged')", + (fid, fid), + ) + if not await self.forget(fid, notify=notify): + return False + rows = [row, *copies] + texts = list(dict.fromkeys(t for r in rows for t in (r["content"], r["previous_content"]) if t)) + refs = [_load_ref(r["source_ref"]) for r in rows] + [_load_ref(r["ref"]) for r in links] + try: + await self._cascade([int(r["id"]) for r in rows], texts, [r for r in refs if r]) + except Exception: # the memory itself is gone; a leftover is logged, never a failed delete + log.exception("cleaning up after memory %s failed", fid) + return True + + async def _cascade(self, ids: list[int], texts: list[str], refs: list[dict]) -> None: + store, user = self.store, self.config.assistant.user_name + + def repeats(text: str) -> bool: + return any(origins.says(t, text or "", user) for t in texts) + + # chat summaries made of the messages it came from, that repeat it, and their vectors + said_in = {str(r["message_id"]) for r in refs if r.get("message_id")} + if said_in: + for r in await store.fetchall("SELECT rowid, id, content, message_ids FROM summaries"): + try: + made_of = {str(m) for m in json.loads(r["message_ids"] or "[]")} + except ValueError: + continue + if made_of & said_in and repeats(r["content"]): + await store.execute("DELETE FROM summaries WHERE id = ?", (r["id"],)) + await self.episodic.vec.delete(int(r["rowid"])) + # the nightly journal's lines about it + for r in await store.fetchall("SELECT id, journal_md FROM dreams WHERE journal_md != ''"): + lines = str(r["journal_md"]).splitlines() + kept = ["- (a memory you deleted)" if line.lstrip().startswith(("-", "*")) and repeats(line) else line + for line in lines] + if kept != lines: + await store.execute("UPDATE dreams SET journal_md = ? WHERE id = ?", ("\n".join(kept), r["id"])) + # the user model's evidence from it + wanted = {str(i) for i in ids} + try: + insights = await store.fetchall("SELECT rid, id, source, evidence FROM user_insights") + except Exception: + insights = [] + for r in insights: + try: + evidence = json.loads(r["evidence"] or "[]") + except ValueError: + continue + if not isinstance(evidence, list): + continue + kept = [e for e in evidence if not (isinstance(e, dict) and ( + (e.get("kind") == "fact" and str(e.get("ref")) in wanted) or (e.get("quote") and repeats(e["quote"])) + ))] + if len(kept) == len(evidence): + continue + if not kept and r["source"] == "inferred": + await store.execute("DELETE FROM user_questions WHERE insight_id = ?", (r["id"],)) + await store.execute("DELETE FROM user_insights WHERE id = ?", (r["id"],)) + with contextlib.suppress(Exception): + await store.execute("DELETE FROM user_insights_vec WHERE rowid = ?", (int(r["rid"]),)) + else: + await store.execute("UPDATE user_insights SET evidence = ? WHERE id = ?", (json.dumps(kept), r["id"])) + # the memory chips on replies and task runs that had it in mind + for table in ("messages", "task_runs"): + for fid in ids: + try: + rows = await store.fetchall( + f"SELECT rowid, memory_sources FROM {table} WHERE memory_sources LIKE ?", (f'%"id": {fid}%',) + ) + except Exception: # no task runs table (a test store) + break + for r in rows: + try: + items = json.loads(r["memory_sources"] or "[]") + except ValueError: + continue + kept = [m for m in items if not (isinstance(m, dict) and m.get("kind") == "fact" and m.get("id") == fid)] + if len(kept) != len(items): + await store.execute( + f"UPDATE {table} SET memory_sources = ? WHERE rowid = ?", (json.dumps(kept), r["rowid"]) + ) + if self.on_erase is not None: + res = self.on_erase(texts) + if hasattr(res, "__await__"): + await res + + # ------------------------------------------------------------------ state and confidence (#152) + async def set_state(self, fid: int, state: str) -> dict: + """The user says a memory isn't right (``disputed``: kept, but out of every prompt) or is (``current``: + Sentient is as sure as it gets). KeyError when it doesn't exist, ValueError for another state.""" + if state not in {"current", "disputed"}: + raise ValueError("state must be current or disputed") + row = await self.store.fetchone("SELECT content, state, status FROM facts WHERE id = ?", (fid,)) + if row is None: + raise KeyError(fid) + if state == "current": + await self.store.execute( + "UPDATE facts SET state = 'current', superseded_by = NULL, confidence = 1.0, updated_at = ?" + " WHERE id = ?", + (now_iso(), fid), + ) + if row["state"] == "superseded" and row["status"] == "active": # back in recall + [vec] = await self._embed([row["content"]]) + await self.vec.upsert(fid, vec) + elif row["state"] != "disputed": + await self.store.execute( + "UPDATE facts SET state = 'disputed', updated_at = ? WHERE id = ?", (now_iso(), fid) + ) + if row["state"] != state: + self.publish("UPDATE", fid, row["content"], state=state, reason="confirmed" if state == "current" else "disputed") + fact = await self.get_fact(fid) + assert fact is not None + return fact + # ------------------------------------------------------------------ sources (#153) - async def link_sources(self, fid: int, sources: list[str], *, merged_from: int | None = None) -> None: + async def link_sources( + self, fid: int, sources: list[str], *, merged_from: int | None = None, refs: dict[str, dict] | None = None + ) -> None: """Record that fact ``fid`` was also learned from ``sources`` (its own ``source`` is never linked twice). ``merged_from``: the links come with fact ``merged_from`` merged into it, and :meth:`unmerge` takes exactly - those away again. A source learned for ``fid`` itself is never one a merge brought, even if a merge had.""" + those away again. A source learned for ``fid`` itself is never one a merge brought, even if a merge had. + ``refs``: exactly where each source said it (#152), kept with its link; a source that wasn't one of its + sources yet makes Sentient surer of it (``RELEARNED_BOOST``).""" await self.ensure_schema() - row = await self.store.fetchone("SELECT source FROM facts WHERE id = ?", (fid,)) + row = await self.store.fetchone("SELECT source, source_ref FROM facts WHERE id = ?", (fid,)) if row is None: return + refs = refs or {} + own = refs.get(str(row["source"])) + if own and not row["source_ref"] and str(row["source"]) in sources: + await self.store.execute("UPDATE facts SET source_ref = ? WHERE id = ?", (json.dumps(own), fid)) ts = now_iso() for src in dict.fromkeys(s for s in sources if s and s != row["source"]): + ref = json.dumps(refs[src]) if refs.get(src) else None + new = await self.store.fetchone( + "SELECT 1 AS x FROM fact_sources WHERE fact_id = ? AND source = ?", (fid, src) + ) is None if merged_from is None: await self.store.execute( - "INSERT INTO fact_sources(fact_id, source, linked_at) VALUES(?,?,?)" - " ON CONFLICT(fact_id, source) DO UPDATE SET merged_from = NULL", - (fid, src, ts), + "INSERT INTO fact_sources(fact_id, source, linked_at, ref) VALUES(?,?,?,?)" + " ON CONFLICT(fact_id, source) DO UPDATE SET merged_from = NULL," + " ref = COALESCE(fact_sources.ref, excluded.ref)", + (fid, src, ts, ref), ) + if new: + await self.store.execute( + "UPDATE facts SET confidence = MIN(1.0, confidence + ?) WHERE id = ?", (RELEARNED_BOOST, fid) + ) else: # a link it already had stays its own await self.store.execute( - "INSERT OR IGNORE INTO fact_sources(fact_id, source, linked_at, merged_from) VALUES(?,?,?,?)", - (fid, src, ts, merged_from), + "INSERT OR IGNORE INTO fact_sources(fact_id, source, linked_at, merged_from, ref) VALUES(?,?,?,?,?)", + (fid, src, ts, merged_from, ref), ) + async def source_links(self, fids: list[int]) -> dict[int, list[dict]]: + """Every source of each fact with exactly where it said it (#152): ``{id: [{source, ref, at, own}]}``, its own + source first, then the others in the order they came.""" + if not fids: + return {} + await self.ensure_schema() + marks = ",".join("?" * len(fids)) + out: dict[int, list[dict]] = {} + for r in await self.store.fetchall( + f"SELECT id, source, source_ref, created_at FROM facts WHERE id IN ({marks})", list(fids) + ): + out[int(r["id"])] = [{"source": r["source"], "ref": _load_ref(r["source_ref"]), "at": r["created_at"], + "own": True}] + for r in await self.store.fetchall( + f"SELECT fact_id, source, ref, linked_at FROM fact_sources WHERE fact_id IN ({marks}) ORDER BY rowid", + list(fids), + ): + out.setdefault(int(r["fact_id"]), []).append( + {"source": r["source"], "ref": _load_ref(r["ref"]), "at": r["linked_at"], "own": False} + ) + return out + async def _linked(self, fid: int) -> list[str]: rows = await self.store.fetchall( "SELECT source FROM fact_sources WHERE fact_id = ? ORDER BY rowid", (fid,) @@ -971,13 +1173,17 @@ async def purge_source(self, source: str, gone: list[int] | None = None) -> dict others.insert(0, str(row["source"])) if not others: deleted += 0 if merged else 1 - await self.forget(fid) + await self.erase(fid) if gone is not None: gone.append(fid) continue await self.store.execute("DELETE FROM fact_sources WHERE fact_id = ? AND source = ?", (fid, source)) - if row["source"] == source: # its next source becomes its own - await self.store.execute("UPDATE facts SET source = ? WHERE id = ?", (others[0], fid)) + if row["source"] == source: # its next source becomes its own, with where it said it + await self.store.execute( + "UPDATE facts SET source = ?, source_ref = (SELECT ref FROM fact_sources WHERE fact_id = ?" + " AND source = ?) WHERE id = ?", + (others[0], fid, others[0], fid), + ) await self.store.execute("DELETE FROM fact_sources WHERE fact_id = ? AND source = ?", (fid, others[0])) unlinked += 0 if merged else 1 return {"deleted": deleted, "unlinked": unlinked} @@ -1015,7 +1221,7 @@ async def forget_scope(self, scope: str) -> int: "SELECT id FROM facts WHERE scope = ? AND status IN ('active', 'pending')", (target,) ) for r in rows: - await self.forget(int(r["id"])) + await self.erase(int(r["id"])) if rows: self.publish("DELETE", None, None, scope=target, reason="scope_reset", count=len(rows)) return len(rows) @@ -1043,7 +1249,7 @@ async def _related_facts( return [] marks = ",".join("?" * len(ids)) rows = await self.store.fetchall( - f"SELECT {MEMORY_COLUMNS} FROM facts WHERE id IN ({marks}) AND {ACTIVE}" + f"SELECT {MEMORY_COLUMNS} FROM facts WHERE id IN ({marks}) AND {ACTIVE} AND {LIVE}" " AND (expires_at IS NULL OR expires_at > ?)", [*ids, now_iso()], ) @@ -1095,6 +1301,7 @@ async def remember( own_words: str | None = None, session_id: str | None = None, scope: str | None = None, + ref: dict | None = None, ) -> dict[str, Any]: """Store one fact with dedup / CUD logic in ``scope`` (#155; personal when None). Returns {action, id, content} (an UPDATE adds ``previous``, the words it replaced, so the save can be undone; an ADD adds ``scope``). @@ -1108,6 +1315,7 @@ async def remember( only look at facts in ``scope`` and always: a save never changes a memory in another scope.""" user = self.config.assistant.user_name scope = memory_scopes.normalize(scope, memory_scopes.PERSONAL) or memory_scopes.PERSONAL + refs: dict[str, dict] = {source: ref} if ref else {} if review is not None and own_words and reviews.grounded(fact, own_words, user): log.info("kept from the user's own words although the chat read outside content: %r", fact) review = None @@ -1125,6 +1333,7 @@ async def remember( try: first, read = await origins.of_session(self.store, session_id) source, *others = origins.sources_for(fact, first, read, held=outside, user_name=user) + refs.update(await origins.spans(self.store, fact, [source, *others], session_id, read, user)) except Exception as exc: log.debug("sources of chat %s unknown: %s", session_id, exc) if review is not None: @@ -1132,8 +1341,22 @@ async def remember( else: result = await self._remember(fact, source=source, use_llm=use_llm, scope=scope) if result.get("id") is not None and result["action"] in {"ADD", "UPDATE", "SKIP"}: + fid = int(result["id"]) + sure = origins.confidence(source, held=outside) + if result["action"] == "ADD": # where exactly it was said, and how sure Sentient is of it (#152) + await self.store.execute( + "UPDATE facts SET confidence = ?, source_ref = ? WHERE id = ?", + (sure, json.dumps(refs[source]) if refs.get(source) else None, fid), + ) + elif result["action"] == "UPDATE" or (not outside and origins.is_chat(source)): + # a correction, or the user saying it again: it is current, whatever was said about it before + await self.store.execute( + "UPDATE facts SET state = 'current', superseded_by = NULL, confidence = MAX(confidence, ?)" + " WHERE id = ? AND state != 'superseded'", + (sure, fid), + ) # learned here too: forgetting one of its sources keeps it while another still backs it (#153) - await self.link_sources(int(result["id"]), [source, *others]) + await self.link_sources(fid, [source, *others], refs=refs) if result["action"] == "ADD": result["scope"] = scope if notify and result["action"] in {"ADD", "UPDATE", "DELETE"}: @@ -1169,11 +1392,11 @@ async def _hold( family = memory_scopes.recall_scopes(scope) in_scope, scope_params = _in_scopes(family) exact = await self.store.fetchone( - f"SELECT id FROM facts WHERE content = ? COLLATE NOCASE AND {in_scope}", (fact, *scope_params) + f"SELECT id FROM facts WHERE content = ? COLLATE NOCASE AND {LIVE} AND {in_scope}", (fact, *scope_params) ) if exact: return {"action": "SKIP", "id": int(exact["id"]), "content": fact} - neighbours = await self.recall(fact, top_k=1, min_similarity=0.0, track=False, scopes=family) + neighbours = await self.recall(fact, top_k=1, min_similarity=0.0, track=False, scopes=family, current_only=False) if neighbours and self._duplicate(fact, neighbours[0]) and not self._corrects(fact, neighbours[0]): return {"action": "SKIP", "id": neighbours[0]["id"], "content": neighbours[0]["content"]} topics, memory_type, duration = await self.analyze(fact) if use_llm else ([DEFAULT_TOPIC], "long-term", None) @@ -1193,12 +1416,15 @@ async def _remember( family = memory_scopes.recall_scopes(scope) in_scope, scope_params = _in_scopes(family) exact = await self.store.fetchone( - f"SELECT id FROM facts WHERE content = ? COLLATE NOCASE AND {ACTIVE} AND {in_scope}", (fact, *scope_params) + f"SELECT id FROM facts WHERE content = ? COLLATE NOCASE AND {ACTIVE} AND {LIVE} AND {in_scope}", + (fact, *scope_params), ) if exact: return {"action": "SKIP", "id": int(exact["id"]), "content": fact} [vec] = await self._embed([fact]) - neighbours = await self.recall(fact, top_k=5, min_similarity=0.0, track=False, scopes=family) + neighbours = await self.recall( + fact, top_k=5, min_similarity=0.0, track=False, scopes=family, current_only=False + ) # "Mira starts school in July" embeds almost exactly like "... in June": a correction is never a duplicate dup = next((n for n in neighbours if self._duplicate(fact, n) and not self._corrects(fact, n)), None) if dup is not None: @@ -1316,7 +1542,7 @@ async def _remember( log.info("CUD ADD turned into UPDATE of residence fact %s for %r", peer["id"], fact) action, fid = "UPDATE", peer["id"] if action == "DELETE": - await self.forget(int(fid)) # type: ignore[arg-type] + await self.erase(int(fid)) # type: ignore[arg-type] return {"action": "DELETE", "id": int(fid), "content": content} # type: ignore[arg-type] if topics is None: topics, memory_type, duration = await self.analyze(content) if use_llm else ([DEFAULT_TOPIC], "long-term", None) @@ -1363,6 +1589,10 @@ async def update_content(self, fid: int, content: str, *, use_llm: bool = True) ) [vec] = await self._embed([content]) await self._update(fid, content, vec, topics, memory_type, duration) + # the user wrote it: as sure as it gets, and current (#152) + await self.store.execute( + "UPDATE facts SET confidence = 1.0, state = 'current', superseded_by = NULL WHERE id = ?", (fid,) + ) self.publish("UPDATE", fid, content) fact = await self.get_fact(fid) assert fact is not None @@ -1483,6 +1713,7 @@ async def flush_conversation( async def extract_and_store( self, text: str, username: str, source: str = "conversation", *, notify: bool = False, review: dict | None = None, own: bool = False, session_id: str | None = None, scope: str | None = None, + ref: dict | None = None, ) -> list[dict]: """Pull atomic facts out of text and remember each (v2 cud_memory). With ``review`` they are held for review, each with the sentence of ``text`` it most likely came from. ``own``: ``text`` is the user's own words (a chat @@ -1498,6 +1729,7 @@ async def extract_and_store( results.append(await self.remember( f, source=source, notify=notify, review=reviews.with_snippet(review, text, f), own_words=text if own else None, session_id=session_id, scope=scope, + ref={**ref, "quote": origins.quote(text, f) or None} if ref else None, )) except Exception as exc: log.warning("failed to store fact %r: %s", f, exc) @@ -1516,7 +1748,9 @@ async def import_document( counts = {"added": 0, "updated": 0, "skipped": 0, "pending": 0} review = reviews.note(path.name) for chunk in chunk_text(text, self.config.memory.import_chunk_chars): - for r in await self.extract_and_store(chunk, username, source=source, review=review, scope=scope): + for r in await self.extract_and_store( + chunk, username, source=source, review=review, scope=scope, ref={"kind": "file", "file": path.name} + ): key = {"ADD": "added", "UPDATE": "updated", "DELETE": "updated"}.get(r["action"], "skipped") counts[key] += 1 counts["pending"] += r.get("status") == "pending" @@ -1529,7 +1763,11 @@ async def import_document( # ------------------------------------------------------------------ reads async def get_fact(self, fid: int) -> dict | None: r = await self.store.fetchone(f"SELECT {MEMORY_COLUMNS} FROM facts WHERE id = ?", (fid,)) - return _row_to_memory(r) if r else None + if r is None: + return None + m = _row_to_memory(r) + m["sources"] = (await self.source_links([fid])).get(fid, []) + return m async def list_facts( self, @@ -1540,8 +1778,22 @@ async def list_facts( source: str | None = None, q: str | None = None, scope: str | None = None, + state: str | None = None, ) -> list[dict]: - """Active facts, newest first. ``scope`` keeps one scope only (the Memory screen's filter, #155).""" + """Active facts, newest first, each with its ``sources`` (#152). ``scope`` keeps one scope only (the Memory + screen's filter, #155). ``state``: current, disputed or superseded only; ``all``; None is current and + disputed (superseded ones are history). ``source`` also finds facts that source said again.""" + out = await self._list_facts(limit, offset, topic=topic, source=source, q=q, scope=scope, state=state) + links = await self.source_links([m["id"] for m in out]) + for m in out: + m["sources"] = links.get(m["id"], []) + return out + + async def _list_facts( + self, limit: int, offset: int, *, topic: str | None, source: str | None, q: str | None, scope: str | None, + state: str | None, + ) -> list[dict]: + state_sql, state_params = (LIVE, []) if state is None else ("1", []) if state == "all" else ("state = ?", [state]) if topic: topic = normalize_topics([topic])[0] one = memory_scopes.normalize(scope) @@ -1549,18 +1801,20 @@ async def list_facts( try: hits = await self._search( q, min(offset + limit, 500) * (3 if topic else 1), self.config.memory.min_similarity, source, - [one] if one else None, + [one] if one else None, current_only=False, ) if topic: hits = [h for h in hits if topic in h["topics"]] + if state not in {None, "all"}: + hits = [h for h in hits if h.get("state") == state] return hits[offset : offset + limit] except Exception as exc: log.debug("semantic list search unavailable, using keyword match: %s", exc) - where = [ACTIVE, "(expires_at IS NULL OR expires_at > ?)"] - params: list[Any] = [now_iso()] + where = [ACTIVE, state_sql, "(expires_at IS NULL OR expires_at > ?)"] + params: list[Any] = [*state_params, now_iso()] if source: - where.append("source = ?") - params.append(source) + where.append("(source = ? OR id IN (SELECT fact_id FROM fact_sources WHERE source = ?))") + params += [source, source] if one: where.append("scope = ?") params.append(one) @@ -1697,8 +1951,9 @@ async def approve(self, fid: int, content: str | None = None) -> dict | None: " WHERE id = ?", (text, row["content"], json.dumps(topics), memory_type, expires_for(memory_type, duration), fid), ) - await self.store.execute( - "UPDATE facts SET status = 'active', updated_at = ?, embedding_model = ? WHERE id = ?", + await self.store.execute( # the user looked at it and said yes: surer (#152) + "UPDATE facts SET status = 'active', updated_at = ?, embedding_model = ?, confidence = MAX(confidence, 0.9)" + " WHERE id = ?", (now_iso(), self.llm.model_for("embedding"), fid), ) await self.vec.upsert(fid, vec) @@ -1734,7 +1989,7 @@ async def reword_pending(self, fid: int, content: str, review: dict | None = Non async def discard(self, fid: int) -> bool: """The user turned a held fact down: it is deleted. False when ``fid`` is not pending.""" row = await self.store.fetchone("SELECT content FROM facts WHERE id = ? AND status = 'pending'", (fid,)) - if row is None or not await self.forget(fid): + if row is None or not await self.erase(fid): return False self.publish("DELETE", fid, row["content"], reason="discarded") return True @@ -1755,7 +2010,7 @@ async def active_facts(self, limit: int = 500) -> list[dict]: """Unexpired facts, most recently updated first, with ``recall_count`` and ``vector`` (or None).""" await self.ensure_schema() rows = await self.store.fetchall( - f"SELECT {MEMORY_COLUMNS}, recall_count FROM facts WHERE {ACTIVE}" + f"SELECT {MEMORY_COLUMNS}, recall_count FROM facts WHERE {IN_USE}" " AND (expires_at IS NULL OR expires_at > ?) ORDER BY updated_at DESC, id DESC LIMIT ?", (now_iso(), limit), ) @@ -1821,15 +2076,21 @@ async def merge_facts(self, keep_id: int, content: str, drop_ids: list[int]) -> return await self.get_fact(keep_id) async def supersede(self, keep_id: int, drop_id: int) -> bool: - """Latest wins: delete ``drop_id`` and record its text as ``previous_content`` of ``keep_id``.""" + """Latest wins: ``drop_id`` is superseded by ``keep_id`` (#152). It stays as history, out of recall and every + prompt, and its text is also kept as ``previous_content`` of ``keep_id``.""" drop = await self.store.fetchone("SELECT content FROM facts WHERE id = ?", (drop_id,)) keep = await self.store.fetchone("SELECT id FROM facts WHERE id = ?", (keep_id,)) if drop is None or keep is None: return False + ts = now_iso() + await self.store.execute( + "UPDATE facts SET previous_content = ?, updated_at = ? WHERE id = ?", (drop["content"], ts, keep_id) + ) await self.store.execute( - "UPDATE facts SET previous_content = ?, updated_at = ? WHERE id = ?", (drop["content"], now_iso(), keep_id) + "UPDATE facts SET state = 'superseded', superseded_by = ?, updated_at = ? WHERE id = ?", + (keep_id, ts, drop_id), ) - await self.forget(drop_id) + await self.vec.delete(drop_id) self.publish("DELETE", drop_id, drop["content"], reason="contradicted", superseded_by=keep_id) return True diff --git a/sentient/memory/origins.py b/sentient/memory/origins.py index e4be56225..109af6a70 100644 --- a/sentient/memory/origins.py +++ b/sentient/memory/origins.py @@ -93,10 +93,13 @@ def _folder_of(item: dict, folders: list[tuple[str, str, str]]) -> str | None: return same[0][0] if len(same) == 1 else None -def from_answer_sources(items: list, folders: list[tuple[str, str, str]]) -> list[tuple[str, str]]: +Read = tuple[str, str, dict] # (source, what was read: title and excerpt, the answer source item) + + +def from_answer_sources(items: list, folders: list[tuple[str, str, str]]) -> list[Read]: """The disconnectable sources among one reply's answer sources (``AnswerSources.items()``), each with what was - read from it (title and excerpt).""" - out: list[tuple[str, str]] = [] + read from it (title and excerpt) and the item itself.""" + out: list[Read] = [] for item in items if isinstance(items, list) else []: if not isinstance(item, dict): continue @@ -108,11 +111,11 @@ def from_answer_sources(items: list, folders: list[tuple[str, str, str]]) -> lis elif kind in {"email", "app"} and app and app not in NOT_APPS: found = f"app:{app}" if found: - out.append((found, f"{item.get('title') or ''} {item.get('snippet') or ''}")) + out.append((found, f"{item.get('title') or ''} {item.get('snippet') or ''}", item)) return out -async def of_session(store: Any, session_id: str | None) -> tuple[str, list[tuple[str, str]]]: +async def of_session(store: Any, session_id: str | None) -> tuple[str, list[Read]]: """``(the chat's own source, what it read)`` for chat ``session_id``: its app (desktop, messaging app or device), and every app and folder its replies read, each ``(source, excerpt)`` in order.""" if not session_id: @@ -134,7 +137,7 @@ async def of_session(store: Any, session_id: str | None) -> tuple[str, list[tupl " ORDER BY created_at", (session_id,), ) - read: list[tuple[str, str]] = [] + read: list[Read] = [] if rows: folders = await _folders(store) for r in rows: @@ -146,13 +149,95 @@ async def of_session(store: Any, session_id: str | None) -> tuple[str, list[tupl return first, read -def sources_for(fact: str, first: str, read: list[tuple[str, str]], *, held: bool, user_name: str = "") -> list[str]: +def sources_for(fact: str, first: str, read: list[Read], *, held: bool, user_name: str = "") -> list[str]: """The sources of ``fact`` learned in a chat (:func:`of_session`), its own first. Held for review because the chat read outside content: what it read that says it (else everything it read). Otherwise the chat's, plus every folder whose notes say it.""" - said = list(dict.fromkeys(k for k, text in read if backs(fact, text, user_name))) + said = list(dict.fromkeys(r[0] for r in read if backs(fact, r[1], user_name))) if held: - chosen = said or list(dict.fromkeys(k for k, _ in read)) + chosen = said or list(dict.fromkeys(r[0] for r in read)) if chosen: return chosen return [first, *(k for k in said if k.startswith("folder:") and k != first)] + + +# ---------------------------------------------------------------------- spans and confidence (#152) +QUOTE_CHARS = 200 +_SENTENCE = re.compile(r"(?<=[.!?])\s+|\n+") + + +def quote(text: str, fact: str) -> str: + """The sentence of ``text`` that says most of ``fact`` (its start when none does), short.""" + parts = [p.strip() for p in _SENTENCE.split(str(text or "")) if p.strip()] + if not parts: + return "" + want = _words(fact) + best = max(parts, key=lambda p: len(want & _words(p))) + best = " ".join(best.split()) + return best if len(best) <= QUOTE_CHARS else best[: QUOTE_CHARS - 1].rstrip() + "…" + + +def says(fact: str, text: str, user_name: str = "") -> bool: + """Whether ``text`` repeats ``fact``: most of its words (three in five, at least two), besides the user's own + name. Stricter than :func:`backs`: it decides what is scrubbed when a memory is deleted.""" + want = _words(fact) - _words(user_name) + if len(want) < 2: + return False + have = _words(text) + return len(want & have) >= max(2, -(-len(want) * 3 // 5)) + + +def confidence(source: str, *, held: bool = False) -> float: + """How sure Sentient is of a fact when it is first learned, from where it came: the user's own words most, what + it read in an app least.""" + if held: + return 0.6 + kind, _ = split(source) + if source in {"manual", "user"}: + return 1.0 + if is_chat(source) or source == "onboarding": + return 0.9 + if kind == "folder": + return 0.8 + if kind == "app": + return 0.6 + return 0.7 # files, imports, tasks, the user model + + +def _item_ref(item: dict, session_id: str, fact: str, folder_id: str | None = None) -> dict: + keep = {k: item.get(k) for k in ("kind", "title", "url", "id", "app", "path") if item.get(k)} + if keep.get("kind") == "file": + keep["kind"] = "note" + if folder_id: + keep["folder_id"] = folder_id + text = f"{item.get('snippet') or ''}" + return {**keep, "session_id": session_id, "quote": quote(text, fact) or None} + + +async def spans( + store: Any, fact: str, sources: list[str], session_id: str, read: list[Read], user_name: str = "" +) -> dict[str, dict]: + """Exactly where each of ``sources`` said ``fact`` in chat ``session_id``: the user's message for the chat + itself (the newest of its last 40 that says it, else the newest), the email, app result or note for what the chat + read. ``{source: ref}``; a source with nothing to point at is left out.""" + out: dict[str, dict] = {} + for src in sources: + if is_chat(src): + rows = await store.fetchall( + "SELECT id, content FROM messages WHERE session_id = ? AND role = 'user'" + " ORDER BY created_at DESC, rowid DESC LIMIT 40", + (session_id,), + ) + if not rows: + continue + best = next((r for r in rows if backs(fact, r["content"] or "", user_name)), rows[0]) + out[src] = {"kind": "message", "session_id": session_id, "message_id": best["id"], + "quote": quote(best["content"] or "", fact) or None} + continue + items = [r for r in read if r[0] == src] + if not items: + continue + item = next((r for r in reversed(items) if backs(fact, r[1], user_name)), items[-1]) + kind, ident = split(src) + out[src] = _item_ref(item[2], session_id, fact, ident if kind == "folder" else None) + return out diff --git a/sentient/memory/schema.sql b/sentient/memory/schema.sql index 02f90bef0..f4c50aa3f 100644 --- a/sentient/memory/schema.sql +++ b/sentient/memory/schema.sql @@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS fact_sources ( source TEXT NOT NULL, linked_at TEXT NOT NULL, merged_from INTEGER, -- the fact whose merge brought this link (#353): undoing that merge removes it + ref TEXT, -- JSON: exactly where this source said it (#152), as facts.source_ref PRIMARY KEY (fact_id, source) ); CREATE INDEX IF NOT EXISTS idx_fact_sources_source ON fact_sources(source); diff --git a/sentient/memory/usermodel.py b/sentient/memory/usermodel.py index 13883df61..f37310567 100644 --- a/sentient/memory/usermodel.py +++ b/sentient/memory/usermodel.py @@ -637,7 +637,7 @@ async def _gather( mark = marks.get("facts") after, params = after_watermark(mark, "updated_at", "id") rows = await store.fetchall( - f"SELECT id, content, updated_at FROM facts WHERE status = 'active' AND {after}" + f"SELECT id, content, updated_at FROM facts WHERE status = 'active' AND state = 'current' AND {after}" " AND (expires_at IS NULL OR expires_at > ?) ORDER BY updated_at DESC, id DESC LIMIT ?", (*params, now_iso(), cfg.recent_facts), ) diff --git a/sentient/memory/workspace.py b/sentient/memory/workspace.py index 2b27f1a74..a4a440d56 100644 --- a/sentient/memory/workspace.py +++ b/sentient/memory/workspace.py @@ -171,6 +171,24 @@ def remove_learned(self, facts: list[str], heading: str = "## Learned") -> int: self.user.write_text("\n".join(out) + "\n", encoding="utf-8") return removed + def scrub(self, facts: list[str], user_name: str = "") -> int: + """A memory was deleted (#152): its bullet goes from USER.md's Learned section, and every bullet of MEMORY.md + (written from memories by the nightly upkeep) that repeats it (``origins.says``). The user's own text in + USER.md is never touched. Returns how many lines went.""" + from sentient.memory import origins + + removed = self.remove_learned(facts) + if not facts or not self.memory.exists(): + return removed + lines = self.memory.read_text(encoding="utf-8", errors="replace").splitlines() + kept = [ + line for line in lines + if not (line.lstrip().startswith(("- ", "* ")) and any(origins.says(f, line, user_name) for f in facts)) + ] + if len(kept) != len(lines): + self.memory.write_text("\n".join(kept) + "\n", encoding="utf-8") + return removed + len(lines) - len(kept) + def append_note(self, text: str, day: date | None = None) -> Path: day = day or date.today() p = self.note(day) diff --git a/sentient/store/migrations/0015_fact_links.py b/sentient/store/migrations/0015_fact_links.py new file mode 100644 index 000000000..5525f7e7e --- /dev/null +++ b/sentient/store/migrations/0015_fact_links.py @@ -0,0 +1,126 @@ +"""Source links, confidence and state for memories (#152). + +Adds where exactly a fact came from (``facts.source_ref``, ``fact_sources.ref``), how sure Sentient is of it +(``confidence``) and whether it is still current (``state``, ``superseded_by``). Existing facts get a confidence from +their source. + +It also gives facts saved before #153 from a messaging app or a device chat their real source. Those say +``conversation``; the chat they came from is found in what is kept: the review note of a memory held for review, the +``memory_remember`` call that saved it, or else the one user message in a messaging-app or device chat that says the +same thing and came shortly before it. A fact no chat explains keeps ``conversation``. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from typing import Any + +CHANNELS = ("discord", "telegram", "whatsapp") # sentient.memory.origins.CHANNELS (imported late: no import cycle) +WINDOW = timedelta(minutes=30) # extraction runs in the background, a little after the message it reads +CONFIDENCE = ( + "CASE WHEN source IN ('manual', 'user') THEN 1.0" + " WHEN source IN ('conversation', 'onboarding') OR source LIKE 'channel:%' OR source LIKE 'device:%' THEN 0.9" + " WHEN status = 'pending' THEN 0.6" + " ELSE 0.7 END" +) + + +def _when(value: Any) -> datetime | None: + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def _source_of(row: Any) -> str | None: + channel = str(row["channel"] or "") + if channel in CHANNELS: + return f"channel:{channel}" + node = str(row["node_id"] or "") if "node_id" in row.keys() else "" # noqa: SIM118 - sqlite Row + return f"device:{node}" if node else None + + +async def _session(store: Any, sid: str) -> Any: + return await store.fetchone("SELECT * FROM sessions WHERE id = ?", (sid,)) + + +async def _saved_by_tool(store: Any) -> dict[int, tuple[str, str]]: + """fact id -> (session id, message id) for facts a chat's ``memory_remember`` call saved.""" + out: dict[int, tuple[str, str]] = {} + rows = await store.fetchall( + "SELECT id, session_id, content FROM messages WHERE role = 'tool' AND name = 'memory_remember'" + ) + for r in rows: + try: + res = json.loads(r["content"] or "{}") + except ValueError: + continue + if isinstance(res, dict) and isinstance(res.get("id"), int) and res.get("action") in {"ADD", "UPDATE"}: + out.setdefault(int(res["id"]), (str(r["session_id"]), "")) + return out + + +async def _backfill(store: Any) -> None: + from sentient.memory import origins + + facts = await store.fetchall( + "SELECT id, content, created_at, review FROM facts WHERE source = 'conversation' AND status IN ('active'," + " 'pending', 'merged')" + ) + if not facts: + return + by_tool = await _saved_by_tool(store) + marks = ",".join("?" * len(CHANNELS)) + said = await store.fetchall( + "SELECT m.id, m.session_id, m.content, m.created_at, s.channel, s.node_id FROM messages m" + " JOIN sessions s ON s.id = m.session_id WHERE m.role = 'user'" + f" AND (s.channel IN ({marks}) OR (s.node_id IS NOT NULL AND s.node_id != ''))", + CHANNELS, + ) + for f in facts: + source = ref = None + sid = None + try: + review = json.loads(f["review"]) if f["review"] else None + except ValueError: + review = None + if isinstance(review, dict) and review.get("session_id"): + sid = str(review["session_id"]) + elif int(f["id"]) in by_tool: + sid = by_tool[int(f["id"])][0] + if sid: + row = await _session(store, sid) + source = _source_of(row) if row is not None else None + if source: + ref = {"kind": "message", "session_id": sid} + else: + at = _when(f["created_at"]) + near = [ + m for m in said + if at is not None and (t := _when(m["created_at"])) is not None and t <= at <= t + WINDOW + and origins.backs(f["content"], m["content"] or "") + ] + if len({m["session_id"] for m in near}) == 1: # one chat says it: that's where it came from + m = max(near, key=lambda m: m["created_at"]) + source = _source_of(m) + ref = {"kind": "message", "session_id": m["session_id"], "message_id": m["id"]} + if source: + await store.execute( + "UPDATE facts SET source = ?, source_ref = COALESCE(source_ref, ?) WHERE id = ?", + (source, json.dumps(ref) if ref else None, f["id"]), + ) + + +async def migrate(store: Any) -> None: + await store.ensure_column("facts", "confidence", "REAL NOT NULL DEFAULT 0.8") + await store.ensure_column("facts", "state", "TEXT NOT NULL DEFAULT 'current'") + await store.ensure_column("facts", "superseded_by", "INTEGER") + await store.ensure_column("facts", "source_ref", "TEXT") + await store.ensure_column("fact_sources", "ref", "TEXT") + cols = await store.columns("facts") + if "confidence" not in cols: # no facts table (a very old database): the schema files make it + return + if await store.columns("sessions") and await store.columns("messages"): + await _backfill(store) + await store.execute(f"UPDATE facts SET confidence = {CONFIDENCE} WHERE confidence = 0.8") diff --git a/sentient/store/schema.sql b/sentient/store/schema.sql index a9b9082fe..f1679fedb 100644 --- a/sentient/store/schema.sql +++ b/sentient/store/schema.sql @@ -99,7 +99,13 @@ CREATE TABLE IF NOT EXISTS facts ( last_recalled_at TEXT, merged_into INTEGER, -- the memory the nightly tidy-up merged this one into (#353) merged_at TEXT, -- kept for undo - merged_by TEXT + merged_by TEXT, + source_ref TEXT, -- JSON: exactly where its source said it (#152), e.g. + -- {kind: "message", session_id, message_id, quote} + confidence REAL NOT NULL DEFAULT 0.8, -- 0..1: how sure Sentient is (its source, learned again) + state TEXT NOT NULL DEFAULT 'current', -- current | disputed | superseded (#152); only current + -- ones go into prompts + superseded_by INTEGER -- the memory that replaced it (state superseded) ); CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(scope, status); CREATE INDEX IF NOT EXISTS idx_facts_expires ON facts(expires_at) WHERE expires_at IS NOT NULL; diff --git a/sentient/tools/builtin/memory_tool.py b/sentient/tools/builtin/memory_tool.py index c4b224fb8..b3e54cf2f 100644 --- a/sentient/tools/builtin/memory_tool.py +++ b/sentient/tools/builtin/memory_tool.py @@ -171,7 +171,7 @@ async def memory_forget(ctx: ToolContext, fact_id: int) -> dict: """Permanently delete a remembered fact by id (from memory_recall). Use when the user asks you to forget something.""" if ctx.memory is None: return {"error": "memory unavailable"} - ok = await ctx.memory.forget(fact_id, notify=True) + ok = await ctx.memory.erase(fact_id, notify=True) return {"deleted": ok, "id": fact_id} diff --git a/tests/memory/test_dreaming.py b/tests/memory/test_dreaming.py index 49dcf0669..2719557bf 100644 --- a/tests/memory/test_dreaming.py +++ b/tests/memory/test_dreaming.py @@ -43,6 +43,14 @@ def test_subjects_and_families(): ] +async def superseded(mem, fid: int, *, by: int) -> bool: + """A contradiction's loser stays as history (#152): superseded by the winner, and recall never finds it.""" + old, new = await mem.get_fact(fid), await mem.get_fact(by) + hits = await mem.recall(old["content"], top_k=10, min_similarity=0.0, track=False) + return (old["state"] == "superseded" and old["superseded_by"] == by and new["state"] == "current" + and fid not in [h["id"] for h in hits]) + + @pytest.mark.parametrize(("old_text", "new_text"), EXAMPLES) async def test_contradictions_found_without_shared_wording(app, old_text, new_text): mem, llm = app.memory, app.fake @@ -54,7 +62,7 @@ async def test_contradictions_found_without_shared_wording(app, old_text, new_te llm.json_replies.append({"conflict": True, "current": "B", "change": "it changed"}) dream = await app.dreaming.run() assert dream["stats"]["contradictions_resolved"] == 1 - assert await mem.get_fact(old) is None and await mem.get_fact(new) is not None + assert await superseded(mem, old, by=new) # kept as history, out of every prompt (#152) assert _json_calls(llm) == 1 prompt = llm.calls[-1]["messages"][1]["content"] assert prompt.index(old_text) < prompt.index(new_text) # A is the older memory @@ -67,13 +75,13 @@ async def test_older_fact_wins_only_with_explicit_recent_wording(app): stale = await add_fact(mem, "Maya lives in Bengaluru") # e.g. re-imported from an old document llm.json_replies.append({"conflict": True, "current": "A"}) await app.dreaming.run() - assert await mem.get_fact(stale) is None and await mem.get_fact(older) is not None + assert await superseded(mem, stale, by=older) a = await add_fact(mem, "Riya works at Infosys", updated_at="2025-01-01T00:00:00+00:00") b = await add_fact(mem, "Riya works at Google now") llm.json_replies.append({"conflict": True, "current": "A"}) # no recent wording on A: newer still wins await app.dreaming.run() - assert await mem.get_fact(a) is None and await mem.get_fact(b) is not None + assert await superseded(mem, a, by=b) async def test_pure_duplicates_merge_without_model(app): @@ -132,7 +140,7 @@ async def test_contradiction_latest_wins_after_model_confirms(app): dream = await app.dreaming.run() events = drain(q) assert dream["stats"]["contradictions_resolved"] == 1 - assert await mem.get_fact(old) is None + assert await superseded(mem, old, by=new) assert (await mem.get_fact(new))["previous_content"] == "Maya lives in Pune" assert 'I used to remember "Maya lives in Pune", but now "Maya lives in Mumbai"' in dream["journal_md"] assert "moved from Pune to Mumbai" in dream["journal_md"] From 72a041a7fb0adcd65b5241279747c7993d7c62f1 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sat, 17 Oct 2026 20:11:48 +0530 Subject: [PATCH 2/7] feat(memory): show source links and confidence, dispute a memory, and docs --- changes/152.added.md | 6 + desktop/src/features/chat/ChatPage.tsx | 14 + desktop/src/features/chat/UserMessage.tsx | 2 +- desktop/src/features/memory/MemoryDetail.tsx | 49 +++- desktop/src/features/memory/MemoryList.tsx | 16 + desktop/src/features/memory/SourceLinks.tsx | 106 +++++++ desktop/src/features/memory/hooks.ts | 18 +- desktop/src/lib/api.ts | 4 + desktop/src/lib/types.ts | 39 +++ docs/API.md | 39 ++- sentient/memory/origins.py | 8 +- sentient/store/schema.sql | 10 +- tests/memory/test_source_links.py | 291 +++++++++++++++++++ 13 files changed, 579 insertions(+), 23 deletions(-) create mode 100644 changes/152.added.md create mode 100644 desktop/src/features/memory/SourceLinks.tsx create mode 100644 tests/memory/test_source_links.py diff --git a/changes/152.added.md b/changes/152.added.md new file mode 100644 index 000000000..b10b70fe1 --- /dev/null +++ b/changes/152.added.md @@ -0,0 +1,6 @@ +**Every memory shows where it came from, how sure Sentient is, and whether it still holds.** Open a memory to see each +place that told Sentient, with the words it said, and jump straight to that chat message, email or note. Sentient gets +surer each time something says it again, and you can mark a memory as not right so it stays out of chats and tasks. +When a newer memory replaces an old one, the old one is kept as history instead of disappearing. Deleting a memory now +also removes what was built from it: chat summaries, the nightly journal's notes, insights about you and the lines in +USER.md and MEMORY.md. Memories learned in Telegram, Discord, WhatsApp or on a device before this update now say so. diff --git a/desktop/src/features/chat/ChatPage.tsx b/desktop/src/features/chat/ChatPage.tsx index 25436cfd5..928622ad3 100644 --- a/desktop/src/features/chat/ChatPage.tsx +++ b/desktop/src/features/chat/ChatPage.tsx @@ -145,6 +145,20 @@ function ChatView({ return [...history, ...liveItems(liveSession)] }, [messages.data, liveSession]) + // ?message= (a memory's source, #152): scroll to that message once it is on screen, and mark it a moment + const focusId = new URLSearchParams(location.search).get('message') + useEffect(() => { + if (!focusId || items.length === 0) return + const t = window.setTimeout(() => { + const el = document.querySelector(`[data-message-id="${CSS.escape(focusId)}"]`) + if (!el) return + el.scrollIntoView({ block: 'center' }) + el.classList.add('ring-2', 'ring-accent/60') + window.setTimeout(() => el.classList.remove('ring-2', 'ring-accent/60'), 2400) + }, 350) + return () => window.clearTimeout(t) + }, [focusId, items.length]) + const streaming = !!liveSession?.streaming const lastAssistantIndex = items.map((i) => i.kind).lastIndexOf('assistant') diff --git a/desktop/src/features/chat/UserMessage.tsx b/desktop/src/features/chat/UserMessage.tsx index 4c68c2a51..8dc2de9e7 100644 --- a/desktop/src/features/chat/UserMessage.tsx +++ b/desktop/src/features/chat/UserMessage.tsx @@ -38,7 +38,7 @@ export function AttachmentChip({ a, className }: { a: AttachmentView; className? export const UserMessage = memo(function UserMessage({ message, onRestore }: { message: UserMessageView; onRestore?: (text: string) => void }) { const [copied, setCopied] = useState(false) return ( -
+
{message.attachments.length > 0 && (
{message.attachments.map((a) => ( diff --git a/desktop/src/features/memory/MemoryDetail.tsx b/desktop/src/features/memory/MemoryDetail.tsx index 63dcf0749..b78619de8 100644 --- a/desktop/src/features/memory/MemoryDetail.tsx +++ b/desktop/src/features/memory/MemoryDetail.tsx @@ -1,12 +1,13 @@ -import { IconCalendar, IconCheck, IconClock, IconHistory, IconHourglassHigh, IconPencil, IconTrash, IconX } from '@tabler/icons-react' +import { IconCalendar, IconCheck, IconClock, IconGauge, IconHistory, IconHourglassHigh, IconMoodConfuzed, IconPencil, IconReplace, IconRoute, IconTrash, IconX } from '@tabler/icons-react' import { useEffect, useState } from 'react' import { Alert, Button, ConfirmDialog, Select, Sheet, Textarea } from '@/components/ui' import { useMemoryScopes } from '@/hooks/memory' import { useHotkey } from '@/hooks/useHotkey' import { formatDateTime } from '@/lib/utils' -import { ExpiryBadge, ScopeIcon, scopeLabel, SourceChip, TopicPill } from './bits' +import { ExpiryBadge, ScopeIcon, scopeLabel, TopicPill } from './bits' import { useNow, useOptimisticMemoryActions } from './hooks' import { countdown, expiresInMs, recentRelative, sourceMeta, type MemoryWithHistory } from './meta' +import { Confidence, SourceLinks } from './SourceLinks' export function MemoryDetail({ memory, @@ -19,7 +20,7 @@ export function MemoryDetail({ onOpenChange: (open: boolean) => void onForgetSource: (source: string) => void }) { - const { update, remove, move } = useOptimisticMemoryActions() + const { update, remove, move, setState } = useOptimisticMemoryActions() const scopes = useMemoryScopes() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') @@ -91,6 +92,24 @@ export function MemoryDetail({
+ {memory.state === 'disputed' && ( + } title="You said this isn't right"> + Sentient keeps it here but leaves it out of chats and tasks. Edit it, forget it, or{' '} + + . + + )} + {memory.state === 'superseded' && ( + } title="Replaced by a newer memory"> + {memory.superseded_by_content ? `Now: "${memory.superseded_by_content}". ` : ''}Sentient keeps this one as history and doesn't use it.{' '} + + + )} + {memory.previous_content && memory.previous_content !== memory.content && (
@@ -100,15 +119,25 @@ export function MemoryDetail({
)} +
+

+ Where it came from +

+ +
+
- } label="Source"> -
- - -
-
{src.description}
+ )}
} label="Used in">