Skip to content

Commit fa5720f

Browse files
fix(memory): deleting a memory leaves a similar memory's derived data alone (#461)
* fix(memory): deleting a memory leaves a similar memory's derived data alone The cascading delete found MEMORY.md/USER.md lines, journal lines, summaries and insight evidence by loose word overlap, so deleting one of two similar memories also stripped the other's. Fact evidence now goes by fact id only, and a text line stays when a remaining current memory says it as well or better, or names a remaining memory's id. The same guard applies when a memory becomes disputed or superseded. Closes #456 * fix(memory): only a written memory reference spares a line on delete A line is kept for naming a remaining memory only when it writes a reference like "(memory 7)", and never for a number the deleted words themselves say ("phone has memory 7 GB"). * fix(memory): weigh a memory reference per matching wording and quote A deleted wording's own numbers no longer excuse a reference in a line that repeats another deleted wording, and a quoted part of a line is still judged when the line also names a remaining memory.
1 parent 4f09f8c commit fa5720f

7 files changed

Lines changed: 259 additions & 33 deletions

File tree

‎changes/456.fixed.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Deleting a memory no longer removes what Sentient knows from a similar one.** Before, deleting "drives a red Honda City" could also take the MEMORY.md line, journal lines and user model evidence of "sold her red Honda City and now rides a blue Ather". Now only the deleted memory's own lines and evidence go.

‎docs/API.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1777,7 +1777,11 @@ attribute and no word of what it says in common ("a piano recital in July" for "
17771777
earlier wording, chat summaries made of the message it came from that repeat it (with their vectors), the nightly
17781778
journal's lines about it, the user model's evidence from it (an inferred insight with nothing else behind it goes),
17791779
the memory chips on replies and task runs that had it in mind, its USER.md bullet and the MEMORY.md lines that repeat
1780-
it. The chats themselves stay. The tidy-up's own merges and contradictions don't count as deletes.
1780+
it. The chats themselves stay. The tidy-up's own merges and contradictions don't count as deletes. The user model's
1781+
evidence from a memory is found by the memory's id only. Text found by its words (summaries, journal lines, evidence
1782+
quotes from chats, USER.md and MEMORY.md lines) stays when a memory still in use says it as well or better, or names
1783+
that memory's id, so deleting one of two similar memories leaves the other's lines alone (#456). The same holds when
1784+
a memory becomes disputed or superseded and leaves USER.md and MEMORY.md.
17811785
- Migration 0015 gives memories saved before #153 from a messaging app or a device chat their real source (found from
17821786
the review note of a held memory, the `memory_remember` call that saved it, or the one message in such a chat, up to
17831787
30 minutes before it, that says it), so forgetting that app covers them too.

‎sentient/app.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,11 @@ async def delete_chat(self, session_id: str, *, reason: str | None = None) -> No
319319
log.exception("removing the selected text of chat %s failed", session_id)
320320
self.bus.publish("session.deleted", {"session_id": session_id, **({"reason": reason} if reason else {})})
321321

322-
def _after_memory_erased(self, texts: list[str]) -> None:
323-
"""A memory was deleted (#152): its words leave USER.md and MEMORY.md, and the user model reads its insights
324-
again (some may be gone with it)."""
322+
def _after_memory_erased(self, erased: Any) -> None:
323+
"""A memory was deleted (#152): its words leave USER.md and MEMORY.md (not a remaining memory's similar lines,
324+
#456), and the user model reads its insights again (some may be gone with it)."""
325325
try:
326-
self.workspace.scrub(texts, self.config.assistant.user_name)
326+
self.workspace.scrub(erased.wordings, self.config.assistant.user_name, repeats=erased)
327327
except OSError:
328328
log.exception("removing a deleted memory from the workspace files failed")
329329
model = getattr(self, "user_model", None)

‎sentient/memory/facts.py‎

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ def __init__(self, store: Store, llm: LLMProvider, config: SentientConfig, bus:
667667
self.episodic = EpisodicMemory(store, llm, config)
668668
self._qcache: OrderedDict[tuple[str, str], list[float]] = OrderedDict()
669669
# set by the app: also scrub the words of erased memories from the workspace files and the user model's
670-
# caches (#152); called with the texts that went
670+
# caches (#152); called with an ``origins.Erased``: the texts that went, and which lines repeat them (#456)
671671
self.on_erase: Any = None
672672

673673
async def ensure_schema(self) -> None:
@@ -921,7 +921,9 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool:
921921
merged copies and source links (:meth:`forget`), the chat summaries made of the messages it came from that
922922
repeat it, the nightly journal's lines about it, the user model's evidence from it (an inferred insight with
923923
nothing else behind it goes), the memory chips on replies and task runs that had it in mind, and (through
924-
``on_erase``) the lines of USER.md and MEMORY.md that say it. Its own previous wording goes with it."""
924+
``on_erase``) the lines of USER.md and MEMORY.md that say it. Its own previous wording goes with it. Text found
925+
by its words is left alone when a memory still in use says it as well or better (#456): deleting one of two
926+
similar memories never strips the other's lines or evidence."""
925927
await self.ensure_schema()
926928
row = await self.store.fetchone(
927929
"SELECT id, content, previous_content, source_ref FROM facts WHERE id = ?", (fid,)
@@ -948,11 +950,17 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool:
948950
log.exception("cleaning up after memory %s failed", fid)
949951
return True
950952

951-
async def _cascade(self, ids: list[int], texts: list[str], refs: list[dict]) -> None:
952-
store, user = self.store, self.config.assistant.user_name
953+
async def _erased(self, texts: list[str], ids: list[int]) -> origins.Erased:
954+
"""What ``texts`` said, against every memory still in use (#456): what derived data repeats them goes,
955+
unless a remaining memory says it as well or better (:class:`origins.Erased`)."""
956+
rows = await self.store.fetchall(f"SELECT id, content FROM facts WHERE {IN_USE}")
957+
gone = set(ids)
958+
remaining = [(int(r["id"]), str(r["content"])) for r in rows if int(r["id"]) not in gone]
959+
return origins.Erased(texts, remaining, self.config.assistant.user_name)
953960

954-
def repeats(text: str) -> bool:
955-
return any(origins.says(t, text or "", user) for t in texts)
961+
async def _cascade(self, ids: list[int], texts: list[str], refs: list[dict]) -> None:
962+
store = self.store
963+
repeats = await self._erased(texts, ids)
956964

957965
# chat summaries made of the messages it came from, that repeat it, and their vectors
958966
said_in = {str(r["message_id"]) for r in refs if r.get("message_id")}
@@ -972,7 +980,7 @@ def repeats(text: str) -> bool:
972980
for line in lines]
973981
if kept != lines:
974982
await store.execute("UPDATE dreams SET journal_md = ? WHERE id = ?", ("\n".join(kept), r["id"]))
975-
# the user model's evidence from it
983+
# the user model's evidence from it: a memory's by its id only, a quote of a chat or summary when it says it
976984
wanted = {str(i) for i in ids}
977985
try:
978986
insights = await store.fetchall("SELECT rid, id, source, evidence FROM user_insights")
@@ -986,7 +994,7 @@ def repeats(text: str) -> bool:
986994
if not isinstance(evidence, list):
987995
continue
988996
kept = [e for e in evidence if not (isinstance(e, dict) and (
989-
(e.get("kind") == "fact" and str(e.get("ref")) in wanted) or (e.get("quote") and repeats(e["quote"]))
997+
str(e.get("ref")) in wanted if e.get("kind") == "fact" else bool(e.get("quote")) and repeats(e["quote"])
990998
))]
991999
if len(kept) == len(evidence):
9921000
continue
@@ -1017,17 +1025,17 @@ def repeats(text: str) -> bool:
10171025
f"UPDATE {table} SET memory_sources = ? WHERE rowid = ?", (json.dumps(kept), r["rowid"])
10181026
)
10191027
if self.on_erase is not None:
1020-
res = self.on_erase(texts)
1028+
res = self.on_erase(repeats)
10211029
if hasattr(res, "__await__"):
10221030
await res
10231031

1024-
async def _out_of_prompts(self, texts: list[str]) -> None:
1032+
async def _out_of_prompts(self, texts: list[str], ids: list[int]) -> None:
10251033
"""A memory left the prompts (disputed or superseded): its lines leave USER.md and MEMORY.md too, which go
1026-
into every prompt (``on_erase``)."""
1034+
into every prompt (``on_erase``), unless a memory still in use says them as well (#456)."""
10271035
if self.on_erase is None:
10281036
return
10291037
try:
1030-
res = self.on_erase(texts)
1038+
res = self.on_erase(await self._erased(texts, ids))
10311039
if hasattr(res, "__await__"):
10321040
await res
10331041
except Exception:
@@ -1055,7 +1063,7 @@ async def set_state(self, fid: int, state: str) -> dict:
10551063
await self.store.execute(
10561064
"UPDATE facts SET state = 'disputed', updated_at = ? WHERE id = ?", (now_iso(), fid)
10571065
)
1058-
await self._out_of_prompts([row["content"]])
1066+
await self._out_of_prompts([row["content"]], [fid])
10591067
if row["state"] != state:
10601068
self.publish("UPDATE", fid, row["content"], state=state, reason="confirmed" if state == "current" else "disputed")
10611069
fact = await self.get_fact(fid)
@@ -2104,7 +2112,7 @@ async def supersede(self, keep_id: int, drop_id: int) -> bool:
21042112
(keep_id, ts, drop_id),
21052113
)
21062114
await self.vec.delete(drop_id)
2107-
await self._out_of_prompts([drop["content"]])
2115+
await self._out_of_prompts([drop["content"]], [drop_id])
21082116
self.publish("DELETE", drop_id, drop["content"], reason="contradicted", superseded_by=keep_id)
21092117
return True
21102118

‎sentient/memory/origins.py‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,14 +183,69 @@ def quote(text: str, fact: str) -> str:
183183
return best if len(best) <= QUOTE_CHARS else best[: QUOTE_CHARS - 1].rstrip() + "…"
184184

185185

186+
def _share(want: set[str], have: set[str]) -> float:
187+
if len(want) < 2:
188+
return 0.0
189+
shared = len(want & have)
190+
return shared / len(want) if shared >= max(2, -(-len(want) * 3 // 5)) else 0.0
191+
192+
193+
def match(fact: str, text: str, user_name: str = "") -> float:
194+
"""How fully ``text`` repeats ``fact``: the share of the fact's words it has (besides the user's own name) when
195+
it :func:`says` the fact, else 0."""
196+
return _share(_words(fact) - _words(user_name), _words(text))
197+
198+
186199
def says(fact: str, text: str, user_name: str = "") -> bool:
187200
"""Whether ``text`` repeats ``fact``: most of its words (three in five, at least two), besides the user's own
188-
name. Stricter than :func:`backs`: it decides what is scrubbed when a memory is deleted."""
189-
want = _words(fact) - _words(user_name)
190-
if len(want) < 2:
191-
return False
192-
have = _words(text)
193-
return len(want & have) >= max(2, -(-len(want) * 3 // 5))
201+
name. Stricter than :func:`backs`: it decides what is scrubbed when a memory is deleted (with :class:`Erased`)."""
202+
return match(fact, text, user_name) > 0
203+
204+
205+
_QUOTED = re.compile(r'"([^"\n]+)"|“([^”\n]+)”')
206+
# a reference to a memory by its id, as derived text writes one: "(memory 7)", "(id: 7)", "[fact #7]"
207+
_NAMES_ID = re.compile(r"[(\[]\s*(?:id|memory|fact)\s*[:#]?\s*(\d+)\s*[)\]]", re.IGNORECASE)
208+
_NUMBER = re.compile(r"\d+")
209+
210+
211+
class Erased:
212+
"""What deleted memories said, weighed against the memories that remain (#456).
213+
214+
Called with a line of derived text (a MEMORY.md bullet, a journal line, a summary, an evidence quote), it says
215+
whether the line repeats a deleted wording (:func:`says`) and so goes. A line stays when a remaining current
216+
memory says it as well or better, or when it names a remaining memory's id ("(memory 7)", unless the deleted
217+
wording it repeats says that number itself): then it is that memory's line, not the deleted one's. Each quoted
218+
part of a line is weighed on its own as well, so a journal line like ``I used to remember "A", but now "B"`` goes
219+
when A (or B) is deleted, even though B (or A) remains."""
220+
221+
def __init__(self, texts: list[str], remaining: list[tuple[int, str]], user_name: str = "") -> None:
222+
self.user = _words(user_name)
223+
self.texts = [t for t in dict.fromkeys(texts) if t]
224+
self._gone = [(_words(t) - self.user, set(_NUMBER.findall(t))) for t in self.texts]
225+
self._left = [_words(c) - self.user for _, c in remaining]
226+
self._ids = {str(i) for i, _ in remaining}
227+
self._held = {" ".join(str(c).lower().split()) for _, c in remaining}
228+
229+
@property
230+
def wordings(self) -> list[str]:
231+
"""The deleted wordings no remaining memory says word for word (an exact USER.md line of one of those is
232+
the remaining memory's line too)."""
233+
return [t for t in self.texts if " ".join(t.lower().split()) not in self._held]
234+
235+
def _repeats(self, part: str) -> bool:
236+
have = _words(part)
237+
said = [(_share(w, have), nums) for w, nums in self._gone]
238+
best = max((share for share, _ in said), default=0.0)
239+
if best == 0:
240+
return False
241+
own = {n for share, nums in said if share for n in nums} # numbers the matching deleted words say
242+
if any(m.group(1) in self._ids and m.group(1) not in own for m in _NAMES_ID.finditer(part)):
243+
return False
244+
return not any(_share(w, have) >= best for w in self._left)
245+
246+
def __call__(self, text: str) -> bool:
247+
text = str(text or "")
248+
return any(self._repeats(p) for p in (text, *(a or b for a, b in _QUOTED.findall(text))))
194249

195250

196251
def confidence(source: str, *, held: bool = False) -> float:

‎sentient/memory/workspace.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from __future__ import annotations
1414

15+
from collections.abc import Callable
1516
from datetime import date, timedelta
1617
from pathlib import Path
1718

@@ -171,20 +172,19 @@ def remove_learned(self, facts: list[str], heading: str = "## Learned") -> int:
171172
self.user.write_text("\n".join(out) + "\n", encoding="utf-8")
172173
return removed
173174

174-
def scrub(self, facts: list[str], user_name: str = "") -> int:
175+
def scrub(self, facts: list[str], user_name: str = "", repeats: Callable[[str], bool] | None = None) -> int:
175176
"""A memory was deleted (#152): its bullet goes from USER.md's Learned section, and every bullet of MEMORY.md
176-
(written from memories by the nightly upkeep) that repeats it (``origins.says``). The user's own text in
177-
USER.md is never touched. Returns how many lines went."""
177+
(written from memories by the nightly upkeep) that repeats it: ``repeats`` (an ``origins.Erased``, which spares
178+
a remaining memory's similar lines, #456), else ``origins.says``. The user's own text in USER.md is never
179+
touched. Returns how many lines went."""
178180
from sentient.memory import origins
179181

180182
removed = self.remove_learned(facts)
181-
if not facts or not self.memory.exists():
183+
if not self.memory.exists() or not (facts or repeats is not None):
182184
return removed
185+
said = repeats or (lambda line: any(origins.says(f, line, user_name) for f in facts))
183186
lines = self.memory.read_text(encoding="utf-8", errors="replace").splitlines()
184-
kept = [
185-
line for line in lines
186-
if not (line.lstrip().startswith(("- ", "* ")) and any(origins.says(f, line, user_name) for f in facts))
187-
]
187+
kept = [line for line in lines if not (line.lstrip().startswith(("- ", "* ")) and said(line))]
188188
if len(kept) != len(lines):
189189
self.memory.write_text("\n".join(kept) + "\n", encoding="utf-8")
190190
return removed + len(lines) - len(kept)

0 commit comments

Comments
 (0)