nexo-brain 3.2.0 → 4.0.1
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 +10 -0
- package/package.json +1 -1
- package/src/agent_runner.py +1 -0
- package/src/auto_update.py +53 -0
- package/src/claim_graph.py +128 -15
- package/src/cognitive/_trust.py +2 -2
- package/src/compaction_memory.py +227 -0
- package/src/dashboard/app.py +15 -12
- package/src/doctor/providers/runtime.py +140 -11
- package/src/hook_guardrails.py +147 -9
- package/src/hooks/pre-compact.sh +18 -0
- package/src/media_memory.py +303 -0
- package/src/memory_backends.py +71 -0
- package/src/plugins/claims_tools.py +119 -0
- package/src/plugins/cognitive_memory.py +16 -1
- package/src/plugins/media_memory_tools.py +98 -0
- package/src/plugins/memory_export.py +196 -0
- package/src/plugins/user_state_tools.py +43 -0
- package/src/script_registry.py +31 -14
- package/src/scripts/deep-sleep/collect.py +6 -1
- package/src/server.py +1 -0
- package/src/system_catalog.py +383 -16
- package/src/tools_sessions.py +69 -0
- package/src/user_state_model.py +170 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
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
|
@@ -87,6 +87,16 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
|
|
|
87
87
|
- when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
|
|
88
88
|
- NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
|
|
89
89
|
|
|
90
|
+
Version `4.0.1` keeps the 4.0 release aligned across channels while preserving the next memory-surface gap closure:
|
|
91
|
+
|
|
92
|
+
- non-text artifacts now have a first-class multimodal reference layer instead of living outside the memory model
|
|
93
|
+
- pre-compaction auto-flush now persists actionable session state before context compression can erase it
|
|
94
|
+
- the claim graph now behaves like a public knowledge wiki with evidence, freshness, verification state, and linting
|
|
95
|
+
- operators can export a readable markdown memory bundle instead of trusting only opaque database state
|
|
96
|
+
- user adaptation now uses a richer inspectable user-state model instead of leaning only on shallow sentiment heuristics
|
|
97
|
+
- retrieval exposes more public knobs for hybrid weighting, decomposition, dreams, and dormant-memory handling
|
|
98
|
+
- newer memory layers now declare an explicit backend contract rather than silently hardcoding storage assumptions forever
|
|
99
|
+
|
|
90
100
|
### Client Capability Matrix
|
|
91
101
|
|
|
92
102
|
| Capability | Claude Code | Codex | Claude Desktop |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
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",
|
package/src/agent_runner.py
CHANGED
package/src/auto_update.py
CHANGED
|
@@ -1211,6 +1211,28 @@ def _runtime_flat_files(base_dir: Path) -> list[str]:
|
|
|
1211
1211
|
return ordered
|
|
1212
1212
|
|
|
1213
1213
|
|
|
1214
|
+
def _installed_scripts_classification(dest: Path) -> dict[str, str]:
|
|
1215
|
+
scripts_dest = dest / "scripts"
|
|
1216
|
+
if dest != NEXO_HOME or not scripts_dest.is_dir():
|
|
1217
|
+
return {}
|
|
1218
|
+
try:
|
|
1219
|
+
from script_registry import classify_scripts_dir
|
|
1220
|
+
|
|
1221
|
+
entries = classify_scripts_dir().get("entries", [])
|
|
1222
|
+
except Exception as e:
|
|
1223
|
+
_log(f"script ownership inspection skipped: {e}")
|
|
1224
|
+
return {}
|
|
1225
|
+
|
|
1226
|
+
ownership: dict[str, str] = {}
|
|
1227
|
+
for entry in entries:
|
|
1228
|
+
path_value = entry.get("path")
|
|
1229
|
+
classification = str(entry.get("classification", "") or "")
|
|
1230
|
+
if not path_value or not classification:
|
|
1231
|
+
continue
|
|
1232
|
+
ownership[Path(str(path_value)).name] = classification
|
|
1233
|
+
return ownership
|
|
1234
|
+
|
|
1235
|
+
|
|
1214
1236
|
def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
|
|
1215
1237
|
timestamp = time.strftime("%Y-%m-%d-%H%M%S")
|
|
1216
1238
|
backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
|
|
@@ -1258,6 +1280,9 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
|
|
|
1258
1280
|
flat_files = _runtime_flat_files(src_dir)
|
|
1259
1281
|
copied_packages = 0
|
|
1260
1282
|
copied_files = 0
|
|
1283
|
+
copied_scripts = 0
|
|
1284
|
+
script_conflicts: list[dict[str, str]] = []
|
|
1285
|
+
installed_script_classes = _installed_scripts_classification(dest)
|
|
1261
1286
|
|
|
1262
1287
|
_emit_progress(progress_fn, "Copying core packages...")
|
|
1263
1288
|
for pkg in packages:
|
|
@@ -1303,9 +1328,27 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
|
|
|
1303
1328
|
shutil.rmtree(str(dst), ignore_errors=True)
|
|
1304
1329
|
shutil.copytree(str(item), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
|
|
1305
1330
|
elif item.is_file():
|
|
1331
|
+
existing_class = installed_script_classes.get(item.name, "")
|
|
1332
|
+
if dst.exists() and existing_class in {"personal", "non-script"}:
|
|
1333
|
+
script_conflicts.append(
|
|
1334
|
+
{
|
|
1335
|
+
"name": item.name,
|
|
1336
|
+
"path": str(dst),
|
|
1337
|
+
"classification": existing_class,
|
|
1338
|
+
"reason": "existing runtime entry is not core-managed",
|
|
1339
|
+
}
|
|
1340
|
+
)
|
|
1341
|
+
continue
|
|
1306
1342
|
shutil.copy2(str(item), str(dst))
|
|
1307
1343
|
if item.suffix == ".sh":
|
|
1308
1344
|
dst.chmod(0o755)
|
|
1345
|
+
copied_scripts += 1
|
|
1346
|
+
|
|
1347
|
+
if script_conflicts:
|
|
1348
|
+
_emit_progress(
|
|
1349
|
+
progress_fn,
|
|
1350
|
+
f"Preserved {len(script_conflicts)} personal runtime script collision(s); core scripts were not overwritten.",
|
|
1351
|
+
)
|
|
1309
1352
|
|
|
1310
1353
|
_emit_progress(progress_fn, "Copying templates and version metadata...")
|
|
1311
1354
|
templates_src = repo_dir / "templates"
|
|
@@ -1345,6 +1388,8 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
|
|
|
1345
1388
|
return {
|
|
1346
1389
|
"packages": copied_packages,
|
|
1347
1390
|
"files": copied_files,
|
|
1391
|
+
"scripts": copied_scripts,
|
|
1392
|
+
"script_conflicts": script_conflicts,
|
|
1348
1393
|
"source": str(src_dir),
|
|
1349
1394
|
"repo": str(repo_dir),
|
|
1350
1395
|
}
|
|
@@ -1597,10 +1642,18 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
|
|
|
1597
1642
|
"updated": True,
|
|
1598
1643
|
"packages": copy_stats["packages"],
|
|
1599
1644
|
"files": copy_stats["files"],
|
|
1645
|
+
"scripts": copy_stats.get("scripts", 0),
|
|
1600
1646
|
"actions": actions,
|
|
1647
|
+
"warnings": [],
|
|
1648
|
+
"script_conflicts": copy_stats.get("script_conflicts", []),
|
|
1601
1649
|
"source": copy_stats["source"],
|
|
1602
1650
|
"repo": copy_stats["repo"],
|
|
1603
1651
|
})
|
|
1652
|
+
if copy_stats.get("script_conflicts"):
|
|
1653
|
+
sync_result["actions"].append(f"preserved-personal-scripts:{len(copy_stats['script_conflicts'])}")
|
|
1654
|
+
sync_result["warnings"].append(
|
|
1655
|
+
f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/scripts"
|
|
1656
|
+
)
|
|
1604
1657
|
_emit_progress(progress_fn, "Runtime update completed.")
|
|
1605
1658
|
except Exception as e:
|
|
1606
1659
|
_emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
|
package/src/claim_graph.py
CHANGED
|
@@ -39,6 +39,72 @@ def _blob_to_array(blob: bytes) -> np.ndarray:
|
|
|
39
39
|
return np.frombuffer(blob, dtype=np.float32)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _table_columns(table_name: str) -> set[str]:
|
|
43
|
+
db = _get_db()
|
|
44
|
+
rows = db.execute(f"PRAGMA table_info({table_name})").fetchall()
|
|
45
|
+
return {str(row["name"]) for row in rows}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ensure_column(table_name: str, column_sql: str) -> None:
|
|
49
|
+
name = column_sql.split()[0]
|
|
50
|
+
if name in _table_columns(table_name):
|
|
51
|
+
return
|
|
52
|
+
db = _get_db()
|
|
53
|
+
db.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_sql}")
|
|
54
|
+
db.commit()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_timestamp(raw: str | None) -> datetime | None:
|
|
58
|
+
if not raw:
|
|
59
|
+
return None
|
|
60
|
+
value = str(raw).strip().replace("Z", "+00:00")
|
|
61
|
+
try:
|
|
62
|
+
dt = datetime.fromisoformat(value)
|
|
63
|
+
except Exception:
|
|
64
|
+
try:
|
|
65
|
+
dt = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
if dt.tzinfo is None:
|
|
69
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
70
|
+
return dt.astimezone(timezone.utc)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compute_freshness(row: dict) -> tuple[float, str, int]:
|
|
74
|
+
freshness_days = max(1, int(row.get("freshness_days") or 30))
|
|
75
|
+
status = str(row.get("verification_status") or "unverified")
|
|
76
|
+
anchor = (
|
|
77
|
+
_parse_timestamp(row.get("verified_at"))
|
|
78
|
+
or _parse_timestamp(row.get("last_reviewed_at"))
|
|
79
|
+
or _parse_timestamp(row.get("updated_at"))
|
|
80
|
+
or _parse_timestamp(row.get("created_at"))
|
|
81
|
+
or datetime.now(timezone.utc)
|
|
82
|
+
)
|
|
83
|
+
age_days = max(0, int((datetime.now(timezone.utc) - anchor).total_seconds() // 86400))
|
|
84
|
+
score = max(0.0, 1.0 - (age_days / float(freshness_days)))
|
|
85
|
+
if status == "contradicted":
|
|
86
|
+
score = min(score, 0.05)
|
|
87
|
+
elif status == "outdated":
|
|
88
|
+
score = min(score, 0.25)
|
|
89
|
+
if age_days > freshness_days:
|
|
90
|
+
state = "stale"
|
|
91
|
+
elif age_days > max(1, freshness_days // 2):
|
|
92
|
+
state = "aging"
|
|
93
|
+
else:
|
|
94
|
+
state = "fresh"
|
|
95
|
+
return round(score, 3), state, age_days
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _claim_with_derived_fields(row: dict) -> dict:
|
|
99
|
+
item = dict(row)
|
|
100
|
+
item.pop("embedding", None)
|
|
101
|
+
score, state, age_days = _compute_freshness(item)
|
|
102
|
+
item["freshness_score"] = score
|
|
103
|
+
item["freshness_state"] = state
|
|
104
|
+
item["age_days"] = age_days
|
|
105
|
+
return item
|
|
106
|
+
|
|
107
|
+
|
|
42
108
|
def init_tables():
|
|
43
109
|
"""Create claim graph tables if they don't exist."""
|
|
44
110
|
db = _get_db()
|
|
@@ -55,6 +121,10 @@ def init_tables():
|
|
|
55
121
|
verification_status TEXT DEFAULT 'unverified',
|
|
56
122
|
verified_at TEXT,
|
|
57
123
|
domain TEXT DEFAULT '',
|
|
124
|
+
evidence TEXT DEFAULT '',
|
|
125
|
+
freshness_days INTEGER DEFAULT 30,
|
|
126
|
+
freshness_score REAL DEFAULT 1.0,
|
|
127
|
+
last_reviewed_at TEXT,
|
|
58
128
|
created_at TEXT DEFAULT (datetime('now')),
|
|
59
129
|
updated_at TEXT DEFAULT (datetime('now'))
|
|
60
130
|
);
|
|
@@ -75,12 +145,17 @@ def init_tables():
|
|
|
75
145
|
CREATE INDEX IF NOT EXISTS idx_claim_links_source ON claim_links(source_claim_id);
|
|
76
146
|
CREATE INDEX IF NOT EXISTS idx_claim_links_target ON claim_links(target_claim_id);
|
|
77
147
|
""")
|
|
148
|
+
_ensure_column("claims", "evidence TEXT DEFAULT ''")
|
|
149
|
+
_ensure_column("claims", "freshness_days INTEGER DEFAULT 30")
|
|
150
|
+
_ensure_column("claims", "freshness_score REAL DEFAULT 1.0")
|
|
151
|
+
_ensure_column("claims", "last_reviewed_at TEXT")
|
|
78
152
|
db.commit()
|
|
79
153
|
|
|
80
154
|
|
|
81
155
|
def add_claim(text: str, source_type: str = "", source_id: str = "",
|
|
82
156
|
source_memory_store: str = "", source_memory_id: int = 0,
|
|
83
|
-
confidence: float = 1.0, domain: str = ""
|
|
157
|
+
confidence: float = 1.0, domain: str = "",
|
|
158
|
+
evidence: str = "", freshness_days: int = 30) -> dict:
|
|
84
159
|
"""Add an atomic claim to the graph.
|
|
85
160
|
|
|
86
161
|
Returns the claim dict with id, or existing claim if duplicate detected.
|
|
@@ -98,17 +173,23 @@ def add_claim(text: str, source_type: str = "", source_id: str = "",
|
|
|
98
173
|
# Update confidence if new source provides additional evidence
|
|
99
174
|
dup = existing[0]
|
|
100
175
|
new_conf = min(1.0, dup["confidence"] + 0.1)
|
|
101
|
-
|
|
102
|
-
|
|
176
|
+
merged_evidence = str(dup.get("evidence") or "").strip()
|
|
177
|
+
new_evidence = str(evidence or "").strip()
|
|
178
|
+
if new_evidence and new_evidence not in merged_evidence:
|
|
179
|
+
merged_evidence = f"{merged_evidence}\n{new_evidence}".strip()
|
|
180
|
+
db.execute(
|
|
181
|
+
"UPDATE claims SET confidence = ?, evidence = ?, freshness_days = ?, freshness_score = 1.0, updated_at = datetime('now') WHERE id = ?",
|
|
182
|
+
(new_conf, merged_evidence, max(1, int(freshness_days or 30)), dup["id"]),
|
|
183
|
+
)
|
|
103
184
|
db.commit()
|
|
104
185
|
return {"id": dup["id"], "action": "merged", "confidence": new_conf}
|
|
105
186
|
|
|
106
187
|
cursor = db.execute(
|
|
107
188
|
"""INSERT INTO claims (text, embedding, source_type, source_id,
|
|
108
|
-
source_memory_store, source_memory_id, confidence, domain)
|
|
109
|
-
VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
189
|
+
source_memory_store, source_memory_id, confidence, domain, evidence, freshness_days, freshness_score)
|
|
190
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1.0)""",
|
|
110
191
|
(text, blob, source_type, source_id, source_memory_store,
|
|
111
|
-
source_memory_id, confidence, domain)
|
|
192
|
+
source_memory_id, confidence, domain, str(evidence or "").strip(), max(1, int(freshness_days or 30)))
|
|
112
193
|
)
|
|
113
194
|
db.commit()
|
|
114
195
|
return {"id": cursor.lastrowid, "action": "added", "confidence": confidence}
|
|
@@ -131,8 +212,7 @@ def find_similar_claims(text: str, threshold: float = 0.8, limit: int = 10) -> l
|
|
|
131
212
|
vec = _blob_to_array(row["embedding"])
|
|
132
213
|
score = _cosine_similarity(query_vec, vec)
|
|
133
214
|
if score >= threshold:
|
|
134
|
-
d = dict(row)
|
|
135
|
-
d.pop("embedding", None)
|
|
215
|
+
d = _claim_with_derived_fields(dict(row))
|
|
136
216
|
d["similarity"] = round(score, 4)
|
|
137
217
|
results.append(d)
|
|
138
218
|
|
|
@@ -225,15 +305,13 @@ def verify_claim(claim_id: int, status: str = "confirmed") -> dict:
|
|
|
225
305
|
|
|
226
306
|
db.execute(
|
|
227
307
|
"UPDATE claims SET verification_status = ?, verified_at = datetime('now'), "
|
|
228
|
-
"updated_at = datetime('now') WHERE id = ?",
|
|
308
|
+
"last_reviewed_at = datetime('now'), freshness_score = 1.0, updated_at = datetime('now') WHERE id = ?",
|
|
229
309
|
(status, claim_id)
|
|
230
310
|
)
|
|
231
311
|
db.commit()
|
|
232
312
|
row = db.execute("SELECT * FROM claims WHERE id = ?", (claim_id,)).fetchone()
|
|
233
313
|
if row:
|
|
234
|
-
|
|
235
|
-
d.pop("embedding", None)
|
|
236
|
-
return d
|
|
314
|
+
return _claim_with_derived_fields(dict(row))
|
|
237
315
|
return {"error": f"Claim {claim_id} not found"}
|
|
238
316
|
|
|
239
317
|
|
|
@@ -246,8 +324,7 @@ def get_claim(claim_id: int) -> Optional[dict]:
|
|
|
246
324
|
if not row:
|
|
247
325
|
return None
|
|
248
326
|
|
|
249
|
-
d = dict(row)
|
|
250
|
-
d.pop("embedding", None)
|
|
327
|
+
d = _claim_with_derived_fields(dict(row))
|
|
251
328
|
|
|
252
329
|
# Get links
|
|
253
330
|
outgoing = db.execute(
|
|
@@ -290,7 +367,41 @@ def search_claims(query: str = "", domain: str = "", status: str = "",
|
|
|
290
367
|
f"domain, created_at FROM claims WHERE {where} ORDER BY created_at DESC LIMIT ?",
|
|
291
368
|
params + [limit]
|
|
292
369
|
).fetchall()
|
|
293
|
-
return [dict(r) for r in rows]
|
|
370
|
+
return [_claim_with_derived_fields(dict(r)) for r in rows]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def lint_claims(max_age_days: int = 30, limit: int = 20) -> list[dict]:
|
|
374
|
+
"""Return stale, weak, or contradictory claims that need review."""
|
|
375
|
+
db = _get_db()
|
|
376
|
+
init_tables()
|
|
377
|
+
|
|
378
|
+
rows = db.execute(
|
|
379
|
+
"SELECT * FROM claims ORDER BY updated_at DESC, created_at DESC LIMIT 500"
|
|
380
|
+
).fetchall()
|
|
381
|
+
results = []
|
|
382
|
+
for row in rows:
|
|
383
|
+
item = _claim_with_derived_fields(dict(row))
|
|
384
|
+
reasons = []
|
|
385
|
+
if item["verification_status"] == "unverified" and item["age_days"] >= max_age_days:
|
|
386
|
+
reasons.append("unverified-too-old")
|
|
387
|
+
if item["freshness_state"] == "stale":
|
|
388
|
+
reasons.append("stale")
|
|
389
|
+
if item["verification_status"] in {"contradicted", "outdated"}:
|
|
390
|
+
reasons.append(item["verification_status"])
|
|
391
|
+
if not str(item.get("evidence") or "").strip():
|
|
392
|
+
reasons.append("missing-evidence")
|
|
393
|
+
if reasons:
|
|
394
|
+
item["lint_reasons"] = reasons
|
|
395
|
+
results.append(item)
|
|
396
|
+
results.sort(
|
|
397
|
+
key=lambda item: (
|
|
398
|
+
"contradicted" not in item["lint_reasons"],
|
|
399
|
+
"stale" not in item["lint_reasons"],
|
|
400
|
+
item["freshness_score"],
|
|
401
|
+
-item["age_days"],
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
return results[: max(1, int(limit or 20))]
|
|
294
405
|
|
|
295
406
|
|
|
296
407
|
def stats() -> dict:
|
|
@@ -313,6 +424,7 @@ def stats() -> dict:
|
|
|
313
424
|
contradictions = db.execute(
|
|
314
425
|
"SELECT COUNT(*) FROM claim_links WHERE relation = 'contradicts'"
|
|
315
426
|
).fetchone()[0]
|
|
427
|
+
stale = len(lint_claims(max_age_days=30, limit=10000))
|
|
316
428
|
|
|
317
429
|
return {
|
|
318
430
|
"total_claims": total,
|
|
@@ -320,4 +432,5 @@ def stats() -> dict:
|
|
|
320
432
|
"by_domain": by_domain,
|
|
321
433
|
"total_links": links,
|
|
322
434
|
"contradictions": contradictions,
|
|
435
|
+
"lint_attention": stale,
|
|
323
436
|
}
|
package/src/cognitive/_trust.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""NEXO Cognitive — Trust scoring, sentiment, dissonance."""
|
|
2
2
|
import re
|
|
3
3
|
import numpy as np
|
|
4
|
-
from datetime import datetime, timedelta
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
5
|
from cognitive._core import _get_db, embed, cosine_similarity, _blob_to_array
|
|
6
6
|
from cognitive._core import POSITIVE_SIGNALS, NEGATIVE_SIGNALS, URGENCY_SIGNALS
|
|
7
7
|
|
|
@@ -412,7 +412,7 @@ def adjust_trust(event: str, context: str = "", custom_delta: float = None) -> d
|
|
|
412
412
|
def get_trust_history(days: int = 7) -> dict:
|
|
413
413
|
"""Get trust score history and sentiment summary."""
|
|
414
414
|
db = _get_db()
|
|
415
|
-
cutoff = (datetime.
|
|
415
|
+
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
416
416
|
|
|
417
417
|
# Trust events
|
|
418
418
|
events = db.execute(
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Pre-compaction auto-flush helpers."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from db import get_db
|
|
9
|
+
from db._hot_context import capture_context_event
|
|
10
|
+
from memory_backends import get_backend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_tables() -> None:
|
|
14
|
+
conn = get_db()
|
|
15
|
+
conn.executescript(
|
|
16
|
+
"""
|
|
17
|
+
CREATE TABLE IF NOT EXISTS session_auto_flush (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
session_id TEXT NOT NULL,
|
|
20
|
+
task TEXT DEFAULT '',
|
|
21
|
+
current_goal TEXT DEFAULT '',
|
|
22
|
+
summary TEXT DEFAULT '',
|
|
23
|
+
next_step TEXT DEFAULT '',
|
|
24
|
+
metadata TEXT DEFAULT '{}',
|
|
25
|
+
source TEXT DEFAULT 'pre-compact-hook',
|
|
26
|
+
backend_key TEXT DEFAULT 'sqlite',
|
|
27
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_session_auto_flush_sid ON session_auto_flush(session_id);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_session_auto_flush_created ON session_auto_flush(created_at);
|
|
31
|
+
"""
|
|
32
|
+
)
|
|
33
|
+
conn.commit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_tool_entries(log_file: str = "", last_diary_ts: str = "") -> list[dict]:
|
|
37
|
+
path = Path(log_file).expanduser()
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return []
|
|
40
|
+
entries: list[dict] = []
|
|
41
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
42
|
+
for raw_line in handle:
|
|
43
|
+
raw_line = raw_line.strip()
|
|
44
|
+
if not raw_line:
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
item = json.loads(raw_line)
|
|
48
|
+
except Exception:
|
|
49
|
+
continue
|
|
50
|
+
ts = str(item.get("timestamp", "") or "")
|
|
51
|
+
if last_diary_ts and ts and ts < last_diary_ts:
|
|
52
|
+
continue
|
|
53
|
+
entries.append(item)
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _derive_bundle(task: str, current_goal: str, entries: list[dict]) -> dict:
|
|
58
|
+
tool_counts: dict[str, int] = {}
|
|
59
|
+
modified_files: list[str] = []
|
|
60
|
+
git_actions: list[str] = []
|
|
61
|
+
last_briefs: list[str] = []
|
|
62
|
+
for entry in entries:
|
|
63
|
+
name = str(entry.get("tool_name", "?") or "?")
|
|
64
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
65
|
+
payload = entry.get("tool_input") or {}
|
|
66
|
+
if isinstance(payload, dict):
|
|
67
|
+
file_path = str(payload.get("file_path") or payload.get("path") or "").strip()
|
|
68
|
+
if file_path:
|
|
69
|
+
modified_files.append(file_path.split("/")[-1])
|
|
70
|
+
if name == "Bash":
|
|
71
|
+
cmd = str(payload.get("command") or "").strip()
|
|
72
|
+
if cmd:
|
|
73
|
+
if "git " in cmd:
|
|
74
|
+
git_actions.append(cmd[:120])
|
|
75
|
+
if len(last_briefs) < 5:
|
|
76
|
+
last_briefs.append(cmd[:120])
|
|
77
|
+
else:
|
|
78
|
+
for _, value in list(payload.items())[:1]:
|
|
79
|
+
text = str(value).strip()
|
|
80
|
+
if text:
|
|
81
|
+
last_briefs.append(text[:120])
|
|
82
|
+
break
|
|
83
|
+
top_tools = sorted(tool_counts.items(), key=lambda item: (-item[1], item[0]))[:5]
|
|
84
|
+
top_tools_str = ", ".join(f"{name} x{count}" for name, count in top_tools) or "no tool activity"
|
|
85
|
+
unique_files = sorted({name for name in modified_files if name})[:12]
|
|
86
|
+
file_str = ", ".join(unique_files) if unique_files else "no file writes detected"
|
|
87
|
+
next_step = (current_goal or "").strip() or (task or "").strip() or "Resume from tool logs and hot context."
|
|
88
|
+
summary = (
|
|
89
|
+
f"Auto-flush captured {len(entries)} tool calls. "
|
|
90
|
+
f"Top tools: {top_tools_str}. "
|
|
91
|
+
f"Files: {file_str}."
|
|
92
|
+
)
|
|
93
|
+
return {
|
|
94
|
+
"summary": summary,
|
|
95
|
+
"next_step": next_step[:400],
|
|
96
|
+
"metadata": {
|
|
97
|
+
"entry_count": len(entries),
|
|
98
|
+
"top_tools": top_tools,
|
|
99
|
+
"modified_files": unique_files,
|
|
100
|
+
"git_actions": git_actions[:10],
|
|
101
|
+
"recent_inputs": last_briefs[:10],
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def record_auto_flush(
|
|
107
|
+
*,
|
|
108
|
+
session_id: str,
|
|
109
|
+
task: str = "",
|
|
110
|
+
current_goal: str = "",
|
|
111
|
+
log_file: str = "",
|
|
112
|
+
last_diary_ts: str = "",
|
|
113
|
+
source: str = "pre-compact-hook",
|
|
114
|
+
) -> dict:
|
|
115
|
+
init_tables()
|
|
116
|
+
entries = _load_tool_entries(log_file=log_file, last_diary_ts=last_diary_ts)
|
|
117
|
+
if not entries and not task.strip() and not current_goal.strip():
|
|
118
|
+
return {"skipped": True, "reason": "no task and no tool activity"}
|
|
119
|
+
|
|
120
|
+
bundle = _derive_bundle(task, current_goal, entries)
|
|
121
|
+
conn = get_db()
|
|
122
|
+
backend = get_backend()
|
|
123
|
+
cursor = conn.execute(
|
|
124
|
+
"""
|
|
125
|
+
INSERT INTO session_auto_flush (
|
|
126
|
+
session_id, task, current_goal, summary, next_step, metadata, source, backend_key
|
|
127
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
128
|
+
""",
|
|
129
|
+
(
|
|
130
|
+
session_id.strip() or "unknown",
|
|
131
|
+
task.strip(),
|
|
132
|
+
current_goal.strip(),
|
|
133
|
+
bundle["summary"],
|
|
134
|
+
bundle["next_step"],
|
|
135
|
+
json.dumps(bundle["metadata"], ensure_ascii=True, sort_keys=True),
|
|
136
|
+
source.strip() or "pre-compact-hook",
|
|
137
|
+
backend.key,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
conn.commit()
|
|
141
|
+
flush_id = int(cursor.lastrowid)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
capture_context_event(
|
|
145
|
+
event_type="auto_flush",
|
|
146
|
+
title=(task or current_goal or f"auto-flush {session_id}")[:160],
|
|
147
|
+
summary=bundle["summary"][:600],
|
|
148
|
+
body=bundle["next_step"][:1600],
|
|
149
|
+
context_key=f"session:{session_id}",
|
|
150
|
+
context_title=(task or current_goal or session_id)[:160],
|
|
151
|
+
context_summary=bundle["summary"][:600],
|
|
152
|
+
context_type="session",
|
|
153
|
+
state="active",
|
|
154
|
+
actor="system",
|
|
155
|
+
source_type="session",
|
|
156
|
+
source_id=session_id,
|
|
157
|
+
session_id=session_id,
|
|
158
|
+
metadata={"auto_flush_id": flush_id, **bundle["metadata"]},
|
|
159
|
+
)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
import cognitive
|
|
165
|
+
|
|
166
|
+
cognitive.ingest(
|
|
167
|
+
f"Auto-flush for session {session_id}. {bundle['summary']} Next step: {bundle['next_step']}",
|
|
168
|
+
"auto_flush",
|
|
169
|
+
f"AF{flush_id}",
|
|
170
|
+
(task or current_goal or f"auto-flush {session_id}")[:120],
|
|
171
|
+
"nexo",
|
|
172
|
+
)
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
row = conn.execute("SELECT * FROM session_auto_flush WHERE id = ?", (flush_id,)).fetchone()
|
|
177
|
+
result = dict(row) if row else {"id": flush_id}
|
|
178
|
+
try:
|
|
179
|
+
result["metadata"] = json.loads(result.get("metadata") or "{}")
|
|
180
|
+
except Exception:
|
|
181
|
+
result["metadata"] = {}
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def list_auto_flushes(session_id: str = "", limit: int = 20) -> list[dict]:
|
|
186
|
+
init_tables()
|
|
187
|
+
conn = get_db()
|
|
188
|
+
if session_id.strip():
|
|
189
|
+
rows = conn.execute(
|
|
190
|
+
"SELECT * FROM session_auto_flush WHERE session_id = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
191
|
+
(session_id.strip(), max(1, int(limit or 20))),
|
|
192
|
+
).fetchall()
|
|
193
|
+
else:
|
|
194
|
+
rows = conn.execute(
|
|
195
|
+
"SELECT * FROM session_auto_flush ORDER BY created_at DESC, id DESC LIMIT ?",
|
|
196
|
+
(max(1, int(limit or 20)),),
|
|
197
|
+
).fetchall()
|
|
198
|
+
results = []
|
|
199
|
+
for row in rows:
|
|
200
|
+
item = dict(row)
|
|
201
|
+
try:
|
|
202
|
+
item["metadata"] = json.loads(item.get("metadata") or "{}")
|
|
203
|
+
except Exception:
|
|
204
|
+
item["metadata"] = {}
|
|
205
|
+
results.append(item)
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def auto_flush_stats(days: int = 7) -> dict:
|
|
210
|
+
init_tables()
|
|
211
|
+
conn = get_db()
|
|
212
|
+
rows = conn.execute(
|
|
213
|
+
"SELECT source, COUNT(*) AS cnt FROM session_auto_flush WHERE created_at >= datetime('now', ?) GROUP BY source",
|
|
214
|
+
(f"-{max(1, int(days or 7))} days",),
|
|
215
|
+
).fetchall()
|
|
216
|
+
total = int(
|
|
217
|
+
conn.execute(
|
|
218
|
+
"SELECT COUNT(*) FROM session_auto_flush WHERE created_at >= datetime('now', ?)",
|
|
219
|
+
(f"-{max(1, int(days or 7))} days",),
|
|
220
|
+
).fetchone()[0]
|
|
221
|
+
)
|
|
222
|
+
return {
|
|
223
|
+
"window_days": max(1, int(days or 7)),
|
|
224
|
+
"total": total,
|
|
225
|
+
"by_source": {row["source"]: row["cnt"] for row in rows},
|
|
226
|
+
"backend": get_backend().key,
|
|
227
|
+
}
|
package/src/dashboard/app.py
CHANGED
|
@@ -14,6 +14,7 @@ import subprocess
|
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
16
|
import webbrowser
|
|
17
|
+
from contextlib import asynccontextmanager
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Optional
|
|
19
20
|
|
|
@@ -30,27 +31,16 @@ if _PARENT not in sys.path:
|
|
|
30
31
|
|
|
31
32
|
from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
|
|
32
33
|
|
|
33
|
-
app = FastAPI(title="NEXO Brain Dashboard", version="3.0.1")
|
|
34
|
-
|
|
35
34
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|
36
35
|
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
|
37
36
|
|
|
38
|
-
# Mount static files
|
|
39
|
-
STATIC_DIR.mkdir(exist_ok=True)
|
|
40
|
-
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
41
|
-
|
|
42
37
|
# Jinja2 environment
|
|
43
38
|
jinja_env = Environment(
|
|
44
39
|
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
45
40
|
autoescape=True,
|
|
46
41
|
)
|
|
47
42
|
|
|
48
|
-
|
|
49
|
-
# Startup — create dashboard_notes table
|
|
50
|
-
# ---------------------------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
@app.on_event("startup")
|
|
53
|
-
async def create_tables():
|
|
43
|
+
def _create_tables() -> None:
|
|
54
44
|
db = _db()
|
|
55
45
|
conn = db.get_db()
|
|
56
46
|
conn.execute("""
|
|
@@ -71,6 +61,19 @@ async def create_tables():
|
|
|
71
61
|
conn.commit()
|
|
72
62
|
|
|
73
63
|
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def _dashboard_lifespan(_: FastAPI):
|
|
66
|
+
_create_tables()
|
|
67
|
+
yield
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
app = FastAPI(title="NEXO Brain Dashboard", version="3.0.1", lifespan=_dashboard_lifespan)
|
|
71
|
+
|
|
72
|
+
# Mount static files
|
|
73
|
+
STATIC_DIR.mkdir(exist_ok=True)
|
|
74
|
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
|
75
|
+
|
|
76
|
+
|
|
74
77
|
# ---------------------------------------------------------------------------
|
|
75
78
|
# Lazy imports — modules live in the parent source directory
|
|
76
79
|
# ---------------------------------------------------------------------------
|