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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "3.2.0",
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.2.0",
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",
@@ -275,6 +275,7 @@ def _headless_env(env: dict | None = None) -> dict:
275
275
  if env:
276
276
  merged.update(env)
277
277
  merged["NEXO_HEADLESS"] = "1"
278
+ merged["NEXO_AUTOMATION"] = "1"
278
279
  merged.pop("CLAUDECODE", None)
279
280
  merged.pop("CLAUDE_CODE", None)
280
281
  return merged
@@ -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...")
@@ -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 = "") -> dict:
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
- db.execute("UPDATE claims SET confidence = ?, updated_at = datetime('now') WHERE id = ?",
102
- (new_conf, dup["id"]))
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
- d = dict(row)
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
  }
@@ -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.utcnow() - timedelta(days=days)).isoformat()
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
+ }
@@ -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
  # ---------------------------------------------------------------------------