Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/441.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Old versions of memories are cleaned up after a while.** When Sentient replaces a memory with a newer one, the old
version is now deleted for good after 180 days, together with everything made from it. Change this in Settings, or set
it to 0 to keep old versions forever. Your current memories are never touched.
1 change: 1 addition & 0 deletions desktop/src/features/settings/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const LABELS: Record<string, FieldLabel> = {
'memory.graph_link_similarity': { label: 'Graph link threshold', percent: true },
'memory.summarize_after_minutes': { label: 'Summarize conversations after', unit: 'min' },
'memory.summary_chunk_messages': { label: 'Messages per summary', unit: 'messages' },
'memory.keep_superseded_days': { label: 'Keep replaced memories for', unit: 'days', description: '0 keeps them forever. Current memories are not affected.' },
// folders: matching pieces of notes that go along with each message (#354)
'folders.prompt_notes': { label: 'Note pieces with each message', unit: 'pieces' },
'folders.prompt_min_similarity': { label: 'How closely a note must match', percent: true },
Expand Down
11 changes: 8 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Envelope: `{"type": "task.updated", "data": {...}, "ts": "..."}`
| `notification.updated` | full **Notification** after its payload changed (suggestion approved/dismissed, approval answered, task plan approved/declined: a `task` notification with `payload.event = "approval_needed"` gains `payload.status = "approved"\|"declined"`; a task question (`payload.event = "question"`) gains `payload.status = "answered"` with `payload.answer`, or `"cancelled"`) |
| `notification.read` / `notification.deleted` | `{id}` (`null` = all) |
| `integration.updated` | **Integration** (§5) |
| `memory.updated` | `{action: "ADD"\|"UPDATE"\|"DELETE", id, content?, status?: "pending", reason?, previous?, session_id?}`; `status: "pending"` means the memory waits in the review inbox (section 7), with `reason: "instruction"` when it reads like an instruction (#234); `previous` is an UPDATE's words before it and `session_id` the chat a memory was learned in (extraction after a turn, `memory_remember`): that chat shows "Remembered: ... Undo" (Undo is `DELETE /api/memories/{id}`, or `PUT` with `previous` for an update) or "Waiting for your review: ..." with Review (#234); bulk changes (import, delete by source, expiry purge) send `id: null` plus `source?`/`reason?` and `count`. Review sends `reason: "approved"` (ADD, or DELETE with `merged_into` when the same words were already remembered), `"discarded"` (DELETE) and `"review_expired"` (bulk DELETE). Memory scopes (#155, section 7): a save carries `scope` (where it was kept); moving a memory sends UPDATE with `reason: "moved"`, `scope` and `previous_scope`; resetting a scope sends a bulk DELETE with `reason: "scope_reset"`, `scope` and `count`. Forgetting a source (#153) sends a bulk DELETE with `source`, `count` (deleted) and `unlinked` (kept because another source backs them), and `reason: "forgotten"` when it came with a disconnect |
| `memory.updated` | `{action: "ADD"\|"UPDATE"\|"DELETE", id, content?, status?: "pending", reason?, previous?, session_id?}`; `status: "pending"` means the memory waits in the review inbox (section 7), with `reason: "instruction"` when it reads like an instruction (#234); `previous` is an UPDATE's words before it and `session_id` the chat a memory was learned in (extraction after a turn, `memory_remember`): that chat shows "Remembered: ... Undo" (Undo is `DELETE /api/memories/{id}`, or `PUT` with `previous` for an update) or "Waiting for your review: ..." with Review (#234); bulk changes (import, delete by source, expiry purge) send `id: null` plus `source?`/`reason?` and `count`. Superseded-history retention sends a bulk DELETE with `reason: "superseded_expired"` and `count` (the number erased). Review sends `reason: "approved"` (ADD, or DELETE with `merged_into` when the same words were already remembered), `"discarded"` (DELETE) and `"review_expired"` (bulk DELETE). Memory scopes (#155, section 7): a save carries `scope` (where it was kept); moving a memory sends UPDATE with `reason: "moved"`, `scope` and `previous_scope`; resetting a scope sends a bulk DELETE with `reason: "scope_reset"`, `scope` and `count`. Forgetting a source (#153) sends a bulk DELETE with `source`, `count` (deleted) and `unlinked` (kept because another source backs them), and `reason: "forgotten"` when it came with a disconnect |
| `memory.forgotten` | `{id, source, label, counts, at}` (`counts`: **ForgetCounts** without `source`): "Also forget what Sentient learned from it" ran when a source was disconnected (#153, section 7); the window refreshes memories, chats and Activity |
| `skill.updated` | `{name, state: "active"\|"pending_review"\|"stale"\|"archived"\|"rejected"\|"deleted"}` |
| `mcp.review` | `{server, reason?: "new"\|"changed", tools?: [tool names on the server], needed}`: an MCP server's tools wait for review, or the user answered (section 5) |
Expand Down Expand Up @@ -1771,7 +1771,11 @@ attribute and no word of what it says in common ("a piano recital in July" for "
found a newer memory that contradicts it). Only current memories are recalled into chats, tasks, proactivity, the
user model, USER.md and MEMORY.md (a memory that becomes disputed or superseded also leaves those two files); dedup
still sees disputed ones, so saying one again in a chat makes it current.
A superseded memory stays as history (out of recall, not listed unless `state` asks for it).
A superseded memory stays as history (out of recall, not listed unless `state` asks for it) until
`memory.keep_superseded_days` has elapsed since it was replaced (default 180 days; 0 disables this retention
cleanup, keeping history indefinitely unless explicitly deleted or expired). Changing its scope does not restart
the retention period. Expiry cleanup erases older superseded memories and their derived data through the delete
cascade below; this retention rule never selects current or disputed memories.
- Deleting a memory (`DELETE /api/memories/{id}`, the `memory_forget` tool, discarding one from review, resetting a
scope, forgetting a source) also removes everything derived from it: its vector, merged copies and source links, its
earlier wording, chat summaries made of the message it came from that repeat it (with their vectors), the nightly
Expand Down Expand Up @@ -1951,7 +1955,8 @@ facts of that source too. Engine: `sentient.memory.review` (`for_context(ctx)`,
Engine notes: recall used by the system prompt and `memory_recall` is hybrid (same ranking as `q` above) and
counts each recalled fact (`facts.recall_count`, `last_recalled_at`; dreaming promotes often-recalled short-term
facts). Saying a memory isn't right or is publishes UPDATE with `state` and `reason: "disputed"|"confirmed"` (#152). Bulk and consolidation changes publish `memory.updated` with `reason` (`merged` | `contradicted` (the older one is superseded, #152) |
`promoted` | `expired`) and, for merges/contradictions, `merged_into` / `superseded_by`.
`promoted` | `expired` | `superseded_expired` (bulk DELETE after superseded-history retention, with `count`)) and, for
merges/contradictions, `merged_into` / `superseded_by`.

---

Expand Down
5 changes: 5 additions & 0 deletions sentient/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,11 @@ class MemoryConfig(BaseModel):
description="Memories waiting for your review (from emails, web pages, imports or work Sentient did on its "
"own) are let go after this many days without an answer.",
)
keep_superseded_days: int = Field(
180, ge=0, le=3650,
description="When a newer memory replaces an older one, the older one is kept as history for this many days, "
"then deleted for good. Your current memories are never deleted by this. 0 keeps the history forever.",
)


# ----------------------------------------------------------------------------- tasks (owner: tasks agent)
Expand Down
41 changes: 35 additions & 6 deletions sentient/memory/facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool:
similar memories never strips the other's lines or evidence."""
await self.ensure_schema()
row = await self.store.fetchone(
"SELECT id, content, previous_content, source_ref FROM facts WHERE id = ?", (fid,)
"SELECT id, content, previous_content, source_ref, state, superseded_by FROM facts WHERE id = ?", (fid,)
)
if row is None:
return False
Expand All @@ -941,6 +941,11 @@ async def erase(self, fid: int, *, notify: bool = False) -> bool:
)
if not await self.forget(fid, notify=notify):
return False
if row["state"] == "superseded" and row["superseded_by"] is not None:
await self.store.execute(
"UPDATE facts SET previous_content = NULL WHERE id = ? AND previous_content = ?",
(row["superseded_by"], row["content"]),
)
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]
Expand Down Expand Up @@ -1226,7 +1231,9 @@ async def set_scope(self, fid: int, scope: str) -> dict:
raise KeyError(fid)
if row["scope"] != target:
await self.store.execute(
"UPDATE facts SET scope = ?, updated_at = ? WHERE id = ?", (target, now_iso(), fid)
"UPDATE facts SET scope = ?, updated_at = CASE WHEN state = 'superseded' THEN updated_at ELSE ? END"
" WHERE id = ?",
(target, now_iso(), fid),
)
self.publish("UPDATE", fid, row["content"], scope=target, previous_scope=row["scope"], reason="moved")
fact = await self.get_fact(fid)
Expand Down Expand Up @@ -1921,13 +1928,35 @@ async def graph(self, max_nodes: int = 1500) -> dict:
# ------------------------------------------------------------------ housekeeping
async def purge_expired(self) -> int:
rows = await self.store.fetchall(
"SELECT id FROM facts WHERE expires_at IS NOT NULL AND expires_at < ?", (now_iso(),)
"SELECT id, state FROM facts WHERE expires_at IS NOT NULL AND expires_at < ?", (now_iso(),)
)
for r in rows:
await self.forget(int(r["id"]))
# Superseded rows have derived history too; discard it with the same cascade as retention cleanup.
if r["state"] == "superseded":
await self.erase(int(r["id"]))
else:
await self.forget(int(r["id"]))
if rows:
self.publish("DELETE", None, None, reason="expired", count=len(rows))
return len(rows)
return len(rows) + await self.purge_superseded()

async def purge_superseded(self) -> int:
"""Erase memories that were superseded more than ``memory.keep_superseded_days`` ago, with everything derived
from them (:meth:`erase`). 0 keeps them forever. Current and disputed memories are never touched."""
days = self.config.memory.keep_superseded_days
if days <= 0:
return 0
cutoff = (datetime.now(UTC) - timedelta(days=days)).isoformat()
rows = await self.store.fetchall(
"SELECT id FROM facts WHERE state = 'superseded' AND updated_at < ?", (cutoff,)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
itsskofficial marked this conversation as resolved.
)
erased = 0
for r in rows:
if await self.erase(int(r["id"])):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
itsskofficial marked this conversation as resolved.
erased += 1
if erased:
self.publish("DELETE", None, None, reason="superseded_expired", count=erased)
return erased

async def count(self) -> int:
row = await self.store.fetchone(f"SELECT COUNT(*) AS n FROM facts WHERE {ACTIVE}")
Expand Down Expand Up @@ -2205,7 +2234,7 @@ async def promote_recalled(self, min_recalls: int, limit: int = 50) -> list[dict
await self.ensure_schema()
rows = await self.store.fetchall(
f"SELECT {MEMORY_COLUMNS} FROM facts WHERE {ACTIVE} AND memory_type = 'short-term' AND recall_count >= ?"
" AND (expires_at IS NULL OR expires_at > ?) ORDER BY recall_count DESC LIMIT ?",
" AND state != 'superseded' AND (expires_at IS NULL OR expires_at > ?) ORDER BY recall_count DESC LIMIT ?",
(min_recalls, now_iso(), limit),
)
out = []
Expand Down
Loading
Loading