nexo-brain 7.32.0 → 7.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/consolidation_prep.py +380 -0
- package/src/db/__init__.py +5 -1
- package/src/db/_episodic.py +32 -0
- package/src/db/_memory_v2.py +276 -0
- package/src/db/_protocol.py +35 -0
- package/src/db/_schema.py +207 -0
- package/src/hooks/auto_capture.py +60 -24
- package/src/learning_resolver.py +42 -0
- package/src/local_context/api.py +237 -33
- package/src/local_context/db.py +3 -2
- package/src/local_context/usage_events.py +2 -0
- package/src/memory_retrieval.py +96 -7
- package/src/message_batch_preview.py +290 -0
- package/src/plugins/protocol.py +218 -27
- package/src/ppr.py +473 -0
- package/src/pre_answer_router.py +316 -3
- package/src/pre_answer_runtime.py +156 -1
- package/src/resolution_cache.py +1119 -0
- package/src/scripts/deep-sleep/apply_findings.py +86 -9
- package/src/scripts/deep-sleep/rewrite.py +625 -0
- package/src/scripts/nexo-deep-sleep.sh +10 -0
- package/src/scripts/nexo-followup-runner.py +110 -8
- package/src/scripts/nexo-morning-agent.py +43 -2
- package/src/scripts/nexo-postmortem-consolidator.py +44 -1
- package/src/self_error_detector.py +414 -0
- package/src/semantic_layers.py +30 -3
- package/templates/core-prompts/morning-agent.md +3 -0
- package/templates/core-prompts/postmortem-consolidator.md +29 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.34.0",
|
|
4
4
|
"description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "NEXO Brain",
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.
|
|
21
|
+
Version `7.34.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 2: a working-memory `resolution_cache` fast-path avoids re-resolving what was just resolved (never-stale, fail-closed: content-snapshot + global watermark + 15-min TTP, repo-map for code), a later action that reveals a prior self-error auto-captures a learning + prevention, the associative graph (Personalized PageRank) connects the dots multi-hop over the KG at answer time (anti-hub, fail-open, per-process cache), Deep Sleep gains a nightly phase that safely merges duplicate learnings (reversible, zero hard-delete, fail-closed backup, daily cap), and a reproducible memory-recall eval bank (recall@k/MRR) lands with a baseline. Builds on v7.33.0 (semantic recall + graph-at-answer + reliability).
|
|
22
22
|
|
|
23
23
|
Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.34.0",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
|
|
6
6
|
"homepage": "https://nexo-brain.com",
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Read-only consolidation brief builder for the nightly postmortem.
|
|
4
|
+
|
|
5
|
+
Why this module exists
|
|
6
|
+
----------------------
|
|
7
|
+
The nightly postmortem consolidator hands the LLM a tiny diary slice, but the
|
|
8
|
+
prompt's "do not duplicate / detect contradiction" steps used to make the
|
|
9
|
+
headless model pull the ENTIRE learnings corpus into its own context (via
|
|
10
|
+
nexo_learning_list / nexo_learning_search / reading MEMORY.md). At hundreds of
|
|
11
|
+
learnings the working context blows up and the timeout wrapper SIGKILLs the
|
|
12
|
+
session (exit 124).
|
|
13
|
+
|
|
14
|
+
The fix: precompute ALL corpus-wide MECHANICAL work here, in the consolidator
|
|
15
|
+
SCRIPT process, and feed the LLM only a small, hard-capped JSON brief. The LLM
|
|
16
|
+
keeps the SEMANTIC judgment it is uniquely good at (is this self-critique worth
|
|
17
|
+
a permanent rule? which precomputed contradiction is real and how to phrase the
|
|
18
|
+
canonical rule?) and loses every task that requires scanning the whole corpus.
|
|
19
|
+
|
|
20
|
+
This module is READ-ONLY by construction: it performs SELECT-only queries on its
|
|
21
|
+
own short-lived sqlite connection (mirrors apply_findings connection style) and
|
|
22
|
+
NEVER commits, inserts, updates, or deletes. The only single source of truth for
|
|
23
|
+
similarity / contradiction / dedup math remains learning_resolver — this module
|
|
24
|
+
depends only on its PUBLIC surface.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sqlite3
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import learning_resolver
|
|
33
|
+
|
|
34
|
+
try: # paths is available in the runtime; keep import defensive for odd installs
|
|
35
|
+
import paths as _paths
|
|
36
|
+
except Exception: # pragma: no cover - defensive
|
|
37
|
+
_paths = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Read learnings in bounded batches so even a 5k-row corpus stays O(n) and the
|
|
41
|
+
# helper itself never holds the whole textual corpus in a single prompt — it only
|
|
42
|
+
# emits the capped brief below.
|
|
43
|
+
_CHUNK = 200
|
|
44
|
+
|
|
45
|
+
# A learning is "weak" (stale candidate) when its weight is low, OR it lacks both
|
|
46
|
+
# reasoning and prevention (no rationale to act on), OR it claims a file scope but
|
|
47
|
+
# was never reinforced by a guard hit. Mirrors apply_findings weak-learning logic;
|
|
48
|
+
# copied here as small local predicates rather than importing apply_findings (to
|
|
49
|
+
# avoid that module's _DynamicPath side effects).
|
|
50
|
+
_WEAK_WEIGHT = 1.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_db_path() -> str:
|
|
54
|
+
for env_key in ("NEXO_TEST_DB", "NEXO_DB"):
|
|
55
|
+
value = str(os.environ.get(env_key, "") or "").strip()
|
|
56
|
+
if value:
|
|
57
|
+
return value
|
|
58
|
+
if _paths is not None:
|
|
59
|
+
try:
|
|
60
|
+
return str(_paths.resolve_db_path())
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
return ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _open_conn() -> sqlite3.Connection | None:
|
|
67
|
+
db_path = _resolve_db_path()
|
|
68
|
+
if not db_path or not os.path.isfile(db_path):
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
conn = sqlite3.connect(db_path, timeout=30)
|
|
72
|
+
conn.row_factory = sqlite3.Row
|
|
73
|
+
return conn
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
79
|
+
try:
|
|
80
|
+
return {str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
81
|
+
except Exception:
|
|
82
|
+
return set()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _preview(text: str, limit: int = 160) -> str:
|
|
86
|
+
clean = " ".join(str(text or "").split())
|
|
87
|
+
if len(clean) > limit:
|
|
88
|
+
return clean[: limit - 1].rstrip() + "…"
|
|
89
|
+
return clean
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _slugify(text: str) -> str:
|
|
93
|
+
normalized = learning_resolver._normalize_text(text)
|
|
94
|
+
tokens = [tok for tok in normalized.replace("/", " ").split() if tok]
|
|
95
|
+
return "-".join(tokens[:8])[:80] or "topic"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _critique_text(diary: dict[str, Any]) -> str:
|
|
99
|
+
parts = [
|
|
100
|
+
str(diary.get("self_critique") or ""),
|
|
101
|
+
str(diary.get("summary") or ""),
|
|
102
|
+
]
|
|
103
|
+
return " ".join(part for part in parts if part).strip()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_weak(row: dict[str, Any], columns: set[str]) -> str:
|
|
107
|
+
"""Return a non-empty weakness reason if the learning looks stale/weak."""
|
|
108
|
+
if "weight" in columns:
|
|
109
|
+
try:
|
|
110
|
+
weight = float(row.get("weight") if row.get("weight") is not None else 0.5)
|
|
111
|
+
except Exception:
|
|
112
|
+
weight = 0.5
|
|
113
|
+
if weight < _WEAK_WEIGHT:
|
|
114
|
+
return f"low_weight ({round(weight, 2)})"
|
|
115
|
+
reasoning = str(row.get("reasoning") or "").strip()
|
|
116
|
+
prevention = str(row.get("prevention") or "").strip()
|
|
117
|
+
if not reasoning and not prevention:
|
|
118
|
+
return "no_reasoning_or_prevention"
|
|
119
|
+
if "applies_to" in columns and "guard_hits" in columns:
|
|
120
|
+
applies = str(row.get("applies_to") or "").strip()
|
|
121
|
+
try:
|
|
122
|
+
guard_hits = int(row.get("guard_hits") or 0)
|
|
123
|
+
except Exception:
|
|
124
|
+
guard_hits = 0
|
|
125
|
+
if applies and guard_hits == 0:
|
|
126
|
+
return "scoped_never_guard_hit"
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _iter_active_learnings(conn: sqlite3.Connection, columns: set[str]):
|
|
131
|
+
"""Yield active learnings dicts in bounded LIMIT/OFFSET batches."""
|
|
132
|
+
status_filter = " WHERE COALESCE(status, 'active') = 'active'" if "status" in columns else ""
|
|
133
|
+
order_by = "updated_at DESC, id DESC" if "updated_at" in columns else "id DESC"
|
|
134
|
+
offset = 0
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
rows = conn.execute(
|
|
138
|
+
f"SELECT * FROM learnings{status_filter} ORDER BY {order_by} LIMIT ? OFFSET ?",
|
|
139
|
+
(_CHUNK, offset),
|
|
140
|
+
).fetchall()
|
|
141
|
+
except Exception:
|
|
142
|
+
return
|
|
143
|
+
if not rows:
|
|
144
|
+
return
|
|
145
|
+
for row in rows:
|
|
146
|
+
yield dict(row)
|
|
147
|
+
if len(rows) < _CHUNK:
|
|
148
|
+
return
|
|
149
|
+
offset += _CHUNK
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_consolidation_brief(
|
|
153
|
+
diaries_with_critique: list[dict],
|
|
154
|
+
*,
|
|
155
|
+
conn: sqlite3.Connection | None = None,
|
|
156
|
+
max_chars: int = 6000,
|
|
157
|
+
max_shortlist: int = 25,
|
|
158
|
+
max_contradictions: int = 15,
|
|
159
|
+
max_stale: int = 15,
|
|
160
|
+
) -> dict:
|
|
161
|
+
"""Build a small, hard-capped JSON brief from today's critiques + the corpus.
|
|
162
|
+
|
|
163
|
+
READ-ONLY: opens its own short-lived connection (unless one is supplied),
|
|
164
|
+
performs only SELECT queries, and never commits. The brief is the ONLY thing
|
|
165
|
+
handed to the LLM, so the model never lists the whole corpus.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
own_conn = conn is None
|
|
169
|
+
if own_conn:
|
|
170
|
+
conn = _open_conn()
|
|
171
|
+
|
|
172
|
+
brief: dict[str, Any] = {
|
|
173
|
+
"corpus_size": 0,
|
|
174
|
+
"today_topics": [],
|
|
175
|
+
"shortlist": [],
|
|
176
|
+
"contradiction_pairs": [],
|
|
177
|
+
"supersession_stubs": [],
|
|
178
|
+
"stale_candidates": [],
|
|
179
|
+
"preference_key_dupes": [],
|
|
180
|
+
"truncated": False,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Build today's topics regardless of corpus availability.
|
|
184
|
+
today_topics: list[dict[str, Any]] = []
|
|
185
|
+
for diary in diaries_with_critique or []:
|
|
186
|
+
text = _critique_text(diary)
|
|
187
|
+
if not text:
|
|
188
|
+
continue
|
|
189
|
+
title = _preview(diary.get("summary") or diary.get("self_critique") or "", 120)
|
|
190
|
+
today_topics.append(
|
|
191
|
+
{
|
|
192
|
+
"slug": _slugify(diary.get("summary") or diary.get("self_critique") or ""),
|
|
193
|
+
"title": title,
|
|
194
|
+
"_text": text,
|
|
195
|
+
"_tokens": set(learning_resolver._tokenize(text)),
|
|
196
|
+
"_applies": str(diary.get("domain") or ""),
|
|
197
|
+
"has_existing_coverage": False,
|
|
198
|
+
"covering_ids": [],
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if conn is None:
|
|
203
|
+
# No corpus available (fresh install / missing DB). Emit topics only.
|
|
204
|
+
brief["today_topics"] = [
|
|
205
|
+
{
|
|
206
|
+
"slug": t["slug"],
|
|
207
|
+
"title": t["title"],
|
|
208
|
+
"has_existing_coverage": False,
|
|
209
|
+
"covering_ids": [],
|
|
210
|
+
}
|
|
211
|
+
for t in today_topics
|
|
212
|
+
]
|
|
213
|
+
return brief
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
columns = _table_columns(conn, "learnings")
|
|
217
|
+
if not columns:
|
|
218
|
+
brief["today_topics"] = [
|
|
219
|
+
{
|
|
220
|
+
"slug": t["slug"],
|
|
221
|
+
"title": t["title"],
|
|
222
|
+
"has_existing_coverage": False,
|
|
223
|
+
"covering_ids": [],
|
|
224
|
+
}
|
|
225
|
+
for t in today_topics
|
|
226
|
+
]
|
|
227
|
+
return brief
|
|
228
|
+
|
|
229
|
+
corpus_size = 0
|
|
230
|
+
shortlist: list[dict[str, Any]] = []
|
|
231
|
+
contradiction_pairs: list[dict[str, Any]] = []
|
|
232
|
+
stale_candidates: list[dict[str, Any]] = []
|
|
233
|
+
key_buckets: dict[str, list[int]] = {}
|
|
234
|
+
seen_shortlist_ids: set[int] = set()
|
|
235
|
+
|
|
236
|
+
for row in _iter_active_learnings(conn, columns):
|
|
237
|
+
corpus_size += 1
|
|
238
|
+
row_id = int(row.get("id") or 0)
|
|
239
|
+
row_title = str(row.get("title") or "")
|
|
240
|
+
row_content = str(row.get("content") or "")
|
|
241
|
+
row_applies = str(row.get("applies_to") or "")
|
|
242
|
+
row_text = f"{row_title} {row_content}".strip()
|
|
243
|
+
|
|
244
|
+
# (5) preference-key dedup — collapse colliding normalized keys.
|
|
245
|
+
key = learning_resolver.normalized_key(row_title, row_applies)
|
|
246
|
+
if key:
|
|
247
|
+
key_buckets.setdefault(key, []).append(row_id)
|
|
248
|
+
|
|
249
|
+
# (4) stale shortlist — weak/low-weight/never-guard-hit actives.
|
|
250
|
+
if len(stale_candidates) < max_stale:
|
|
251
|
+
weakness = _is_weak(row, columns)
|
|
252
|
+
if weakness:
|
|
253
|
+
stale_candidates.append(
|
|
254
|
+
{"id": row_id, "title": _preview(row_title, 120), "weakness": weakness}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Relevance vs today's topics drives shortlist + coverage + contradiction.
|
|
258
|
+
relevant_to: list[dict[str, Any]] = []
|
|
259
|
+
for topic in today_topics:
|
|
260
|
+
related = bool(topic["_tokens"] & set(learning_resolver._tokenize(row_text)))
|
|
261
|
+
scoped = bool(
|
|
262
|
+
topic["_applies"]
|
|
263
|
+
and row_applies
|
|
264
|
+
and learning_resolver.applies_overlap(row_applies, topic["_applies"])
|
|
265
|
+
)
|
|
266
|
+
if not (related or scoped):
|
|
267
|
+
continue
|
|
268
|
+
sim = learning_resolver.candidate_similarity(topic["_text"], row_text)
|
|
269
|
+
if sim >= 0.55 or scoped:
|
|
270
|
+
relevant_to.append(topic)
|
|
271
|
+
if row_id:
|
|
272
|
+
topic["has_existing_coverage"] = True
|
|
273
|
+
# Cap example covering ids so a topic covered by hundreds of
|
|
274
|
+
# rules cannot balloon the brief; the boolean flag is what
|
|
275
|
+
# the LLM acts on.
|
|
276
|
+
if row_id not in topic["covering_ids"] and len(topic["covering_ids"]) < 10:
|
|
277
|
+
topic["covering_ids"].append(row_id)
|
|
278
|
+
|
|
279
|
+
# (6) contradiction pairs vs today-topics.
|
|
280
|
+
if len(contradiction_pairs) < max_contradictions and learning_resolver.looks_contradictory(
|
|
281
|
+
row_text, topic["_text"]
|
|
282
|
+
):
|
|
283
|
+
contradiction_pairs.append(
|
|
284
|
+
{
|
|
285
|
+
"existing_id": row_id,
|
|
286
|
+
"existing_title": _preview(row_title, 120),
|
|
287
|
+
"with": "today_topic",
|
|
288
|
+
"snippet_a": _preview(row_text, 160),
|
|
289
|
+
"snippet_b": _preview(topic["_text"], 160),
|
|
290
|
+
"similarity": round(float(sim), 4),
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if relevant_to and len(shortlist) < max_shortlist and row_id not in seen_shortlist_ids:
|
|
295
|
+
seen_shortlist_ids.add(row_id)
|
|
296
|
+
shortlist.append(
|
|
297
|
+
{
|
|
298
|
+
"id": row_id,
|
|
299
|
+
"title": _preview(row_title, 120),
|
|
300
|
+
"category": str(row.get("category") or ""),
|
|
301
|
+
"applies_to": row_applies,
|
|
302
|
+
"content_preview": _preview(row_content, 160),
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# (5) preference-key dupes — only keys with 2+ colliding ids. Cap both the
|
|
307
|
+
# number of dupe groups and the ids listed per group so a pathological
|
|
308
|
+
# corpus (hundreds of identical-title rules) cannot balloon the brief.
|
|
309
|
+
preference_key_dupes = []
|
|
310
|
+
for key, ids in key_buckets.items():
|
|
311
|
+
if len(ids) <= 1:
|
|
312
|
+
continue
|
|
313
|
+
preference_key_dupes.append({"key": key, "ids": ids[:10], "total": len(ids)})
|
|
314
|
+
if len(preference_key_dupes) >= max_stale:
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
# (3) supersession stubs — today-topics that already have higher-authority
|
|
318
|
+
# coverage are candidates to be replaced by a canonical rule.
|
|
319
|
+
supersession_stubs: list[dict[str, Any]] = []
|
|
320
|
+
for topic in today_topics:
|
|
321
|
+
for old_id in topic["covering_ids"][:1]:
|
|
322
|
+
supersession_stubs.append(
|
|
323
|
+
{
|
|
324
|
+
"old_id": old_id,
|
|
325
|
+
"old_title": next(
|
|
326
|
+
(s["title"] for s in shortlist if s["id"] == old_id),
|
|
327
|
+
"",
|
|
328
|
+
),
|
|
329
|
+
"reason": f"today topic '{topic['slug']}' may replace existing rule #{old_id}",
|
|
330
|
+
}
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
brief["corpus_size"] = corpus_size
|
|
334
|
+
brief["today_topics"] = [
|
|
335
|
+
{
|
|
336
|
+
"slug": t["slug"],
|
|
337
|
+
"title": t["title"],
|
|
338
|
+
"has_existing_coverage": bool(t["has_existing_coverage"]),
|
|
339
|
+
"covering_ids": list(t["covering_ids"]),
|
|
340
|
+
}
|
|
341
|
+
for t in today_topics
|
|
342
|
+
]
|
|
343
|
+
brief["shortlist"] = shortlist
|
|
344
|
+
brief["contradiction_pairs"] = contradiction_pairs
|
|
345
|
+
brief["supersession_stubs"] = supersession_stubs
|
|
346
|
+
brief["stale_candidates"] = stale_candidates
|
|
347
|
+
brief["preference_key_dupes"] = preference_key_dupes
|
|
348
|
+
finally:
|
|
349
|
+
if own_conn:
|
|
350
|
+
try:
|
|
351
|
+
conn.close()
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
# Enforce max_chars: drop lowest-priority items until the serialized brief is
|
|
356
|
+
# under budget. Stale candidates and supersession stubs are the first to go,
|
|
357
|
+
# then contradiction pairs (least relevant first), then shortlist tail.
|
|
358
|
+
def _size() -> int:
|
|
359
|
+
return len(json.dumps(brief, ensure_ascii=False))
|
|
360
|
+
|
|
361
|
+
if _size() > max_chars:
|
|
362
|
+
brief["truncated"] = True
|
|
363
|
+
trim_order = ("preference_key_dupes", "supersession_stubs", "stale_candidates")
|
|
364
|
+
for field in trim_order:
|
|
365
|
+
while brief[field] and _size() > max_chars:
|
|
366
|
+
brief[field].pop()
|
|
367
|
+
while len(brief["contradiction_pairs"]) > 1 and _size() > max_chars:
|
|
368
|
+
brief["contradiction_pairs"].pop()
|
|
369
|
+
while len(brief["shortlist"]) > 1 and _size() > max_chars:
|
|
370
|
+
brief["shortlist"].pop()
|
|
371
|
+
# Last resort: trim contradiction/shortlist to empty-ish.
|
|
372
|
+
while brief["contradiction_pairs"] and _size() > max_chars:
|
|
373
|
+
brief["contradiction_pairs"].pop()
|
|
374
|
+
while brief["shortlist"] and _size() > max_chars:
|
|
375
|
+
brief["shortlist"].pop()
|
|
376
|
+
|
|
377
|
+
return brief
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
__all__ = ["build_consolidation_brief"]
|
package/src/db/__init__.py
CHANGED
|
@@ -106,6 +106,9 @@ from db._memory_v2 import (
|
|
|
106
106
|
list_memory_observations,
|
|
107
107
|
search_memory_observations_fts,
|
|
108
108
|
backfill_memory_observations,
|
|
109
|
+
backfill_observation_embeddings,
|
|
110
|
+
vector_scan_observations,
|
|
111
|
+
get_memory_observations_by_uids,
|
|
109
112
|
memory_observation_health,
|
|
110
113
|
maintain_memory_observations,
|
|
111
114
|
memory_observation_stats,
|
|
@@ -165,7 +168,7 @@ from db._entities import (
|
|
|
165
168
|
# Episodic memory
|
|
166
169
|
from db._episodic import (
|
|
167
170
|
cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
|
|
168
|
-
log_change, search_changes, update_change_commit, auto_resolve_followups,
|
|
171
|
+
log_change, search_changes, get_change_watermark, update_change_commit, auto_resolve_followups,
|
|
169
172
|
cleanup_old_decisions, log_decision, update_decision_outcome,
|
|
170
173
|
get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
|
|
171
174
|
cleanup_old_diaries, write_session_diary,
|
|
@@ -194,6 +197,7 @@ from db._protocol import (
|
|
|
194
197
|
VALID_TASK_TYPES,
|
|
195
198
|
VALID_CLOSE_OUTCOMES,
|
|
196
199
|
create_protocol_task, get_protocol_task, close_protocol_task,
|
|
200
|
+
list_recent_closed_tasks,
|
|
197
201
|
set_protocol_task_guard_acknowledged,
|
|
198
202
|
create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
|
|
199
203
|
record_session_correction_requirement, list_session_correction_requirements,
|
package/src/db/_episodic.py
CHANGED
|
@@ -93,6 +93,38 @@ def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dic
|
|
|
93
93
|
return [dict(r) for r in rows]
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def get_change_watermark(sid: str | None = None) -> int:
|
|
97
|
+
"""Cheap monotonic integer that rises whenever a relevant mutation lands.
|
|
98
|
+
|
|
99
|
+
Used by the resolution cache (working memory) as the "nothing changed"
|
|
100
|
+
invalidation signal — Francisco's third rule. ``change_log`` is the ledger
|
|
101
|
+
where the PostToolUse hook records every code/config/state mutation, so
|
|
102
|
+
``MAX(id)`` is a one-SELECT, monotonic, append-only proxy for "did anything
|
|
103
|
+
change since I cached this answer?". If the watermark advanced, the cache
|
|
104
|
+
is invalidated by conservatism (prefer recomputing over serving stale).
|
|
105
|
+
|
|
106
|
+
``sid`` optionally narrows the watermark to a single session's mutations.
|
|
107
|
+
Returns 0 when the ledger is empty or unavailable (which a fresh cache
|
|
108
|
+
entry will also store, so an empty ledger never spuriously invalidates).
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
conn = get_db()
|
|
112
|
+
if sid:
|
|
113
|
+
row = conn.execute(
|
|
114
|
+
"SELECT MAX(id) FROM change_log WHERE session_id = ?", (str(sid),)
|
|
115
|
+
).fetchone()
|
|
116
|
+
else:
|
|
117
|
+
row = conn.execute("SELECT MAX(id) FROM change_log").fetchone()
|
|
118
|
+
except Exception:
|
|
119
|
+
return 0
|
|
120
|
+
if not row or row[0] is None:
|
|
121
|
+
return 0
|
|
122
|
+
try:
|
|
123
|
+
return int(row[0])
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
96
128
|
def auto_resolve_followups(change: dict) -> list[str]:
|
|
97
129
|
"""Cross-reference a change_log entry with open followups. Auto-completes matches.
|
|
98
130
|
|