nexo-brain 7.31.13 → 7.33.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/auto_close_sessions.py +38 -0
- package/src/cognitive/_search.py +13 -2
- package/src/consolidation_prep.py +380 -0
- package/src/crons/sync.py +14 -7
- package/src/db/__init__.py +3 -0
- package/src/db/_memory_v2.py +276 -0
- package/src/db/_schema.py +134 -0
- package/src/hooks/auto_capture.py +60 -18
- 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/memory_retrieval.py +96 -7
- package/src/plugins/protocol.py +71 -24
- package/src/pre_answer_router.py +116 -6
- package/src/scripts/nexo-followup-runner.py +110 -8
- package/src/scripts/nexo-postmortem-consolidator.py +44 -1
- package/src/tools_sessions.py +1 -1
- 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.33.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.33.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 1 (phase 2): memory search now finds by MEANING (observation embeddings + FTS/vector fusion), the KG/causal graph is read at answer time (kg_neighbors pre-answer source), local files are recalled via FTS5, nightly learning consolidation no longer times out, correction capture is reliable (soft), and the followup runner uses an atomic lock. Builds on v7.32.0 (causal-graph populate + workflow reaper + the 7.31.14 critical fixes).
|
|
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.33.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",
|
|
@@ -182,6 +182,43 @@ def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
|
|
|
182
182
|
return closed
|
|
183
183
|
|
|
184
184
|
|
|
185
|
+
def auto_close_abandoned_workflow_runs(conn, sid: str) -> dict:
|
|
186
|
+
"""Reap durable workflow_runs / workflow_goals abandoned by a stale session.
|
|
187
|
+
|
|
188
|
+
auto_close only reaped protocol_tasks; a session that opened a durable
|
|
189
|
+
workflow_run / workflow_goal and never closed it left a zombie 'running'
|
|
190
|
+
row forever, polluting the resume surface (M10 gap). Move non-terminal ones
|
|
191
|
+
to a terminal state when their owning session is reaped. closed_at/updated_at
|
|
192
|
+
use datetime('now') to match the workflow tables' timestamp format.
|
|
193
|
+
"""
|
|
194
|
+
note = "auto-close: stale session ended without explicit workflow close"
|
|
195
|
+
runs = conn.execute(
|
|
196
|
+
"SELECT run_id FROM workflow_runs "
|
|
197
|
+
"WHERE session_id = ? AND status IN ('open','running','blocked','waiting_approval')",
|
|
198
|
+
(sid,),
|
|
199
|
+
).fetchall()
|
|
200
|
+
for row in runs:
|
|
201
|
+
conn.execute(
|
|
202
|
+
"UPDATE workflow_runs SET status='cancelled', next_action=?, "
|
|
203
|
+
"closed_at=datetime('now'), updated_at=datetime('now') "
|
|
204
|
+
"WHERE run_id=? AND status IN ('open','running','blocked','waiting_approval')",
|
|
205
|
+
(note, row["run_id"]),
|
|
206
|
+
)
|
|
207
|
+
goals = conn.execute(
|
|
208
|
+
"SELECT goal_id FROM workflow_goals "
|
|
209
|
+
"WHERE session_id = ? AND status IN ('active','blocked')",
|
|
210
|
+
(sid,),
|
|
211
|
+
).fetchall()
|
|
212
|
+
for row in goals:
|
|
213
|
+
conn.execute(
|
|
214
|
+
"UPDATE workflow_goals SET status='abandoned', blocker_reason=?, "
|
|
215
|
+
"closed_at=datetime('now'), updated_at=datetime('now') "
|
|
216
|
+
"WHERE goal_id=? AND status IN ('active','blocked')",
|
|
217
|
+
(note, row["goal_id"]),
|
|
218
|
+
)
|
|
219
|
+
return {"runs": len(runs), "goals": len(goals)}
|
|
220
|
+
|
|
221
|
+
|
|
185
222
|
def main():
|
|
186
223
|
init_db()
|
|
187
224
|
conn = get_db()
|
|
@@ -197,6 +234,7 @@ def main():
|
|
|
197
234
|
draft = get_diary_draft(sid)
|
|
198
235
|
closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
|
|
199
236
|
closed_task_ids.extend(closed_tasks)
|
|
237
|
+
auto_close_abandoned_workflow_runs(conn, sid)
|
|
200
238
|
|
|
201
239
|
if draft:
|
|
202
240
|
promote_draft_to_diary(sid, draft, task=session.get("task", ""))
|
package/src/cognitive/_search.py
CHANGED
|
@@ -784,8 +784,19 @@ CO_ACTIVATION_MIN_STRENGTH = 0.1
|
|
|
784
784
|
|
|
785
785
|
|
|
786
786
|
def _canonical_co_id(store: str, mid: int) -> int:
|
|
787
|
-
"""Create a canonical hash ID for co-activation tracking.
|
|
788
|
-
|
|
787
|
+
"""Create a canonical, PROCESS-STABLE hash ID for co-activation tracking.
|
|
788
|
+
|
|
789
|
+
MUST be deterministic across processes. Python's builtin hash() is salted
|
|
790
|
+
per process (PYTHONHASHSEED), so co-activation links written in one MCP
|
|
791
|
+
process never matched the same memory's id in the next — fragmenting the
|
|
792
|
+
associative graph (observed ~6x distinct ids per memory) and silently
|
|
793
|
+
degrading spreading activation to within-a-single-process-lifetime. blake2b
|
|
794
|
+
is stable across processes and runs.
|
|
795
|
+
"""
|
|
796
|
+
import hashlib
|
|
797
|
+
|
|
798
|
+
digest = hashlib.blake2b(f"{store}:{mid}".encode("utf-8"), digest_size=8).digest()
|
|
799
|
+
return int.from_bytes(digest, "big") % (2**31)
|
|
789
800
|
|
|
790
801
|
|
|
791
802
|
def record_co_activation(memory_ids: list[tuple[str, int]]):
|
|
@@ -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/crons/sync.py
CHANGED
|
@@ -727,13 +727,18 @@ def install_plist(label: str, plist: dict, plist_path: Path, dry_run: bool):
|
|
|
727
727
|
log(f" DRY-RUN: would install {plist_path.name}")
|
|
728
728
|
return
|
|
729
729
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
730
|
+
# Ephemeral/test runtimes (temp NEXO_HOME or HOME, e.g. a pytest run) must
|
|
731
|
+
# NOT touch the operator's real ~/Library/LaunchAgents. The guard is checked
|
|
732
|
+
# BEFORE writing the plist file: otherwise a test run rewrites the real
|
|
733
|
+
# plists with temp-dir ProgramArguments, and one reboot/reload silently
|
|
734
|
+
# kills the whole consolidation cron fleet (cron-fleet-drift incident).
|
|
733
735
|
if not launchctl_side_effects_allowed():
|
|
734
|
-
log(f"
|
|
736
|
+
log(f" Skipped plist write in ephemeral runtime: {plist_path.name}")
|
|
735
737
|
return
|
|
736
738
|
|
|
739
|
+
with open(plist_path, "wb") as f:
|
|
740
|
+
plistlib.dump(plist, f)
|
|
741
|
+
|
|
737
742
|
result = reload_launchagent_plist(plist_path, label=label)
|
|
738
743
|
if result.get("action") == "skipped-ephemeral-runtime":
|
|
739
744
|
log(f" Installed but skipped launchctl in ephemeral runtime: {plist_path.name}")
|
|
@@ -751,8 +756,8 @@ def unload_plist(plist_path: Path, dry_run: bool):
|
|
|
751
756
|
return
|
|
752
757
|
|
|
753
758
|
if not launchctl_side_effects_allowed():
|
|
754
|
-
|
|
755
|
-
log(f"
|
|
759
|
+
# Ephemeral/test runtime: never delete the operator's real plists.
|
|
760
|
+
log(f" Skipped plist removal in ephemeral runtime: {plist_path.name}")
|
|
756
761
|
return
|
|
757
762
|
|
|
758
763
|
result = unload_launchagent_plist(plist_path)
|
|
@@ -830,7 +835,9 @@ def sync(dry_run: bool = False):
|
|
|
830
835
|
return
|
|
831
836
|
|
|
832
837
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
833
|
-
|
|
838
|
+
# In an ephemeral/test runtime, do not even create the real LaunchAgents dir.
|
|
839
|
+
if launchctl_side_effects_allowed():
|
|
840
|
+
LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
834
841
|
|
|
835
842
|
manifest_crons = load_manifest()
|
|
836
843
|
manifest_ids = {c["id"] for c in manifest_crons}
|
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,
|