neobrain-cli 0.1.0__py3-none-any.whl

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.
neobrain/api.py ADDED
@@ -0,0 +1,789 @@
1
+ """neoBrain read/ingest API (FastAPI) + daemon wiring.
2
+
3
+ Run: .venv/bin/uvicorn neobrain.api:app --host 0.0.0.0 --port 9192
4
+ neobrain start # the CLI wrapper (config.settings.bind)
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from collections import deque
14
+ from contextlib import asynccontextmanager
15
+ from datetime import datetime
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from fastapi import FastAPI, HTTPException, Query, Request
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.staticfiles import StaticFiles
22
+ from pydantic import BaseModel
23
+
24
+ from . import config, db, mind
25
+ from .models import iso, new, now_ms, to_ms
26
+
27
+ # PORT-NOTE: the old in-API ingest scheduler (INGEST_INTERVAL / _ingest_loop) is
28
+ # GONE — SPEC §6 puts cadence in the life loop (life.py), not in OS/API timers.
29
+ # "The daemon starts the life loop" is the only background work here; ingestion
30
+ # is a life-loop phase (and is still reachable one-shot via POST /api/ingest/run
31
+ # and the `neobrain ingest` CLI).
32
+
33
+
34
+ def _life_enabled() -> bool:
35
+ """NEOBRAIN_LIFE_ENABLED: any of 0/false/no/off disables the loop."""
36
+ return str(config.settings.life_enabled).strip().lower() not in ("0", "false", "no", "off")
37
+
38
+
39
+ def _announce_empty_mind(conn) -> None:
40
+ """SPEC §14 S6 hardening: a brand-new empty mind must never boot silently.
41
+
42
+ Real-data validation found a wrong ``NEOBRAIN_DATA_DIR`` silently
43
+ auto-created an empty DB, and the dashboard looked "empty" with no hint of
44
+ why. The boot itself is now a durable, dashboard-visible event (source
45
+ ``neobrain``). It is emitted only while the event log is empty, so a healthy
46
+ restart never re-announces.
47
+ """
48
+ try:
49
+ total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
50
+ except Exception as exc: # noqa: BLE001 - announcement must never block boot
51
+ print(f"[startup] empty-mind check failed: {exc}", flush=True)
52
+ return
53
+ if total:
54
+ return
55
+ ev = new(
56
+ ts=now_ms(),
57
+ source="neobrain",
58
+ lane="notice",
59
+ category="boot",
60
+ actor="daemon:neobrain",
61
+ title="neoBrain started with an empty mind — no events and no memories yet",
62
+ source_ref="boot",
63
+ severity="notice",
64
+ detail=json.dumps(
65
+ {
66
+ "data_dir": str(config.DATA_DIR),
67
+ "db_path": str(config.DB_PATH),
68
+ "hint": "if this path is wrong, stop the daemon and set NEOBRAIN_DATA_DIR",
69
+ },
70
+ ensure_ascii=False,
71
+ ),
72
+ refs={"data_dir": str(config.DATA_DIR)},
73
+ )
74
+ db.upsert_events(conn, [ev])
75
+ print(f"[startup] empty mind announced (data dir {config.DATA_DIR})", flush=True)
76
+
77
+
78
+ def _life_thread(loop: Any, stop: threading.Event) -> None:
79
+ """Tick the life loop until ``stop`` is set (the Run-forever body, stoppable)."""
80
+ interval = max(1, config.settings.life_tick_seconds)
81
+ try:
82
+ while not stop.is_set():
83
+ try:
84
+ loop.tick()
85
+ except Exception as exc: # noqa: BLE001 - a bad tick must not kill the daemon
86
+ print(f"[life] tick error: {exc}", flush=True)
87
+ stop.wait(interval)
88
+ finally:
89
+ # PORT-NOTE: LifeLoop's sqlite handle is thread-affine (created inside
90
+ # this thread), so it must be closed here — closing it from the lifespan
91
+ # thread raises `SQLite objects created in a thread can only be used in
92
+ # that same thread` and fails application shutdown.
93
+ try:
94
+ loop.close()
95
+ except Exception as exc: # noqa: BLE001 - shutdown must still complete
96
+ print(f"[life] close error: {exc}", flush=True)
97
+
98
+
99
+ @asynccontextmanager
100
+ async def lifespan(app: FastAPI):
101
+ # ensure the store exists before serving
102
+ try:
103
+ conn = db.connect()
104
+ db.init_db(conn)
105
+ _announce_empty_mind(conn)
106
+ conn.close()
107
+ except Exception as exc:
108
+ print(f"[startup] store init failed: {exc}", flush=True)
109
+
110
+ # PORT-NOTE: the daemon (not the CLI process, not a systemd unit) owns the
111
+ # in-process life loop (SPEC §6). It runs in a daemon thread; shutdown sets
112
+ # the event and joins so the loop's SQLite handle is closed cleanly.
113
+ stop_event = threading.Event()
114
+ thread: Optional[threading.Thread] = None
115
+ loop: Any = None
116
+ if _life_enabled():
117
+ from .life import LifeLoop
118
+
119
+ loop = LifeLoop()
120
+ thread = threading.Thread(
121
+ target=_life_thread, args=(loop, stop_event), name="neobrain-life", daemon=True
122
+ )
123
+ thread.start()
124
+ print(f"[startup] life loop started (tick {config.settings.life_tick_seconds}s)", flush=True)
125
+ else:
126
+ print("[startup] life loop disabled (NEOBRAIN_LIFE_ENABLED=0)", flush=True)
127
+
128
+ try:
129
+ yield
130
+ finally:
131
+ stop_event.set()
132
+ if thread is not None:
133
+ # The thread closes the loop's own sqlite handle (see _life_thread).
134
+ thread.join(timeout=10)
135
+
136
+
137
+ app = FastAPI(
138
+ # PORT-NOTE: product rename Timeline -> neoBrain (S1 contract).
139
+ title="neoBrain",
140
+ version="0.1.0",
141
+ docs_url="/api/schema",
142
+ redoc_url=None,
143
+ openapi_url="/api/openapi.json",
144
+ lifespan=lifespan,
145
+ )
146
+ app.add_middleware(
147
+ CORSMiddleware,
148
+ allow_origins=["*"],
149
+ allow_methods=["*"],
150
+ allow_headers=["*"],
151
+ )
152
+
153
+ # Debug page (SPEC §5 visibility): rolling trace of every /api call — who called
154
+ # what, when, with what session and latency. In-memory only (lost on restart,
155
+ # which is fine: it answers "is the plugin firing right now", not forensics).
156
+ _TRACE: deque = deque(maxlen=500)
157
+
158
+
159
+ @app.middleware("http")
160
+ async def _trace_requests(request: Request, call_next):
161
+ # Skip non-API and the debug page's own polling: it refreshes every 5s and
162
+ # would drown the plugin lanes the trace exists to watch.
163
+ if not request.url.path.startswith("/api/") or request.url.path.startswith("/api/debug"):
164
+ return await call_next(request)
165
+ t0 = time.perf_counter()
166
+ response = await call_next(request)
167
+ _TRACE.append(
168
+ {
169
+ "ts": int(time.time() * 1000),
170
+ "method": request.method,
171
+ "path": request.url.path,
172
+ "query": request.url.query,
173
+ "status": response.status_code,
174
+ "ms": round((time.perf_counter() - t0) * 1000, 1),
175
+ "session": request.query_params.get("session") or "",
176
+ }
177
+ )
178
+ return response
179
+
180
+
181
+ def _conn():
182
+ return db.connect(readonly=True)
183
+
184
+
185
+ def _serialize(ev: dict) -> dict:
186
+ ev = dict(ev)
187
+ ev["ts_iso"] = iso(ev.get("ts"))
188
+ if ev.get("ts_end"):
189
+ ev["ts_end_iso"] = iso(ev["ts_end"])
190
+ return ev
191
+
192
+
193
+ def _parse_time(value: Optional[str]) -> Optional[int]:
194
+ if not value:
195
+ return None
196
+ ms = to_ms(value)
197
+ if ms is not None:
198
+ return ms
199
+ try: # date-only -> local start/end of that day handled by caller
200
+ return int(datetime.strptime(value, "%Y-%m-%d").timestamp() * 1000)
201
+ except ValueError:
202
+ return None
203
+
204
+
205
+ def _day_bounds(date_str: str) -> tuple[int, int]:
206
+ dt = datetime.strptime(date_str, "%Y-%m-%d").astimezone()
207
+ start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
208
+ end = dt.replace(hour=23, minute=59, second=59, microsecond=999000)
209
+ return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
210
+
211
+
212
+ @app.get("/api/health")
213
+ def health() -> dict:
214
+ conn = _conn()
215
+ try:
216
+ total = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
217
+ states = [
218
+ dict(r) for r in conn.execute("SELECT source, cursor, updated_at FROM ingest_state")
219
+ ]
220
+ except Exception as exc: # store not initialised yet
221
+ raise HTTPException(503, f"store not ready: {exc}")
222
+ finally:
223
+ conn.close()
224
+ return {"status": "ok", "events": total, "sources": states}
225
+
226
+
227
+ @app.get("/api/stats")
228
+ def stats() -> dict:
229
+ conn = _conn()
230
+ try:
231
+ s = db.stats(conn)
232
+ finally:
233
+ conn.close()
234
+ if s.get("range"):
235
+ s["range"]["min_iso"] = iso(s["range"].get("min"))
236
+ s["range"]["max_iso"] = iso(s["range"].get("max"))
237
+ return s
238
+
239
+
240
+ @app.get("/api/events")
241
+ def list_events(
242
+ start: Optional[str] = None,
243
+ end: Optional[str] = None,
244
+ day: Optional[str] = None,
245
+ lane: Optional[str] = None,
246
+ source: Optional[str] = None,
247
+ category: Optional[str] = None,
248
+ project: Optional[str] = None,
249
+ session: Optional[str] = None,
250
+ q: Optional[str] = None,
251
+ limit: int = Query(200, le=20000),
252
+ offset: int = 0,
253
+ ) -> dict:
254
+ s, e = _parse_time(start), _parse_time(end)
255
+ if day:
256
+ s, e = _day_bounds(day)
257
+ conn = _conn()
258
+ try:
259
+ rows = db.events(
260
+ conn,
261
+ start=s,
262
+ end=e,
263
+ lane=lane,
264
+ source=source,
265
+ category=category,
266
+ project_id=project,
267
+ session_id=session,
268
+ q=q,
269
+ limit=limit,
270
+ offset=offset,
271
+ )
272
+ finally:
273
+ conn.close()
274
+ return {"count": len(rows), "offset": offset, "events": [_serialize(r) for r in rows]}
275
+
276
+
277
+ @app.get("/api/events/{event_id}")
278
+ def get_event(event_id: str) -> dict:
279
+ conn = _conn()
280
+ try:
281
+ ev = db.get_event(conn, event_id)
282
+ finally:
283
+ conn.close()
284
+ if not ev:
285
+ raise HTTPException(404, "event not found")
286
+ return _serialize(ev)
287
+
288
+
289
+ @app.get("/api/day/{date}")
290
+ def day(date: str) -> dict:
291
+ s, e = _day_bounds(date)
292
+ conn = _conn()
293
+ try:
294
+ rows = db.events(conn, start=s, end=e, limit=2000)
295
+ finally:
296
+ conn.close()
297
+ by_lane: dict[str, list[dict]] = {}
298
+ for r in rows:
299
+ by_lane.setdefault(r["lane"], []).append(_serialize(r))
300
+ return {"date": date, "count": len(rows), "lanes": by_lane}
301
+
302
+
303
+ @app.get("/api/sessions")
304
+ def sessions() -> dict:
305
+ conn = _conn()
306
+ try:
307
+ rows = [
308
+ dict(r)
309
+ for r in conn.execute(
310
+ "SELECT * FROM sessions ORDER BY ts_created DESC"
311
+ )
312
+ ]
313
+ finally:
314
+ conn.close()
315
+ for r in rows:
316
+ r["ts_created_iso"] = iso(r.get("ts_created"))
317
+ return {"count": len(rows), "sessions": rows}
318
+
319
+
320
+ @app.get("/api/sessions/{session_id}")
321
+ def session_timeline(session_id: str, limit: int = Query(2000, le=5000)) -> dict:
322
+ conn = _conn()
323
+ try:
324
+ rows = db.events(conn, session_id=session_id, limit=limit)
325
+ finally:
326
+ conn.close()
327
+ return {"session_id": session_id, "count": len(rows), "events": [_serialize(r) for r in rows]}
328
+
329
+
330
+ @app.get("/api/search")
331
+ def search(q: str, limit: int = Query(100, le=1000)) -> dict:
332
+ conn = _conn()
333
+ try:
334
+ rows = db.events(conn, q=q, limit=limit)
335
+ finally:
336
+ conn.close()
337
+ return {"query": q, "count": len(rows), "events": [_serialize(r) for r in rows]}
338
+
339
+
340
+ @app.get("/api/chain/{event_id}")
341
+ def chain(event_id: str) -> dict:
342
+ conn = _conn()
343
+ try:
344
+ ev = db.get_event(conn, event_id)
345
+ if not ev:
346
+ raise HTTPException(404, "event not found")
347
+ ch = db.chain(conn, event_id)
348
+ for direction in ("in", "out"):
349
+ for link in ch[direction]:
350
+ other = link["from_id"] if direction == "in" else link["to_id"]
351
+ oe = db.get_event(conn, other)
352
+ link["event"] = _serialize(oe) if oe else None
353
+ finally:
354
+ conn.close()
355
+ return {"event": _serialize(ev), **ch}
356
+
357
+
358
+ @app.get("/api/docs")
359
+ def docs_index() -> dict:
360
+ conn = _conn()
361
+ try:
362
+ rows = [
363
+ dict(r)
364
+ for r in conn.execute(
365
+ """SELECT doc_path, COUNT(*) AS versions, MAX(ts) AS last_ts
366
+ FROM doc_versions GROUP BY doc_path ORDER BY last_ts DESC"""
367
+ )
368
+ ]
369
+ finally:
370
+ conn.close()
371
+ for r in rows:
372
+ r["last_iso"] = iso(r.get("last_ts"))
373
+ return {"count": len(rows), "docs": rows}
374
+
375
+
376
+ @app.get("/api/docs/history")
377
+ def docs_history(path: str) -> dict:
378
+ conn = _conn()
379
+ try:
380
+ rows = [
381
+ {k: v for k, v in dict(r).items() if k != "content"}
382
+ for r in conn.execute(
383
+ "SELECT * FROM doc_versions WHERE doc_path = ? ORDER BY ts DESC", (path,)
384
+ )
385
+ ]
386
+ finally:
387
+ conn.close()
388
+ for r in rows:
389
+ r["ts_iso"] = iso(r.get("ts"))
390
+ return {"path": path, "count": len(rows), "versions": rows}
391
+
392
+
393
+ @app.post("/api/ingest/run")
394
+ def ingest_run(only: Optional[str] = None) -> dict:
395
+ # PORT-NOTE: one-shot ingest stays available; the periodic scheduler that
396
+ # used to call this is gone (SPEC §6, life loop owns cadence).
397
+ from .ingest import runner
398
+
399
+ conn = db.connect()
400
+ try:
401
+ db.init_db(conn)
402
+ sources = tuple(only.split(",")) if only else ("opencode", "git", "docs")
403
+ return runner.run(conn, sources)
404
+ finally:
405
+ conn.close()
406
+
407
+
408
+ # --- mind model (M1) ----------------------------------------------------
409
+ @app.get("/api/mind/stats")
410
+ def mind_stats() -> dict:
411
+ conn = _conn()
412
+ try:
413
+ return mind.stats(conn)
414
+ finally:
415
+ conn.close()
416
+
417
+
418
+ @app.get("/api/mind/graph")
419
+ def mind_graph() -> dict:
420
+ conn = _conn()
421
+ try:
422
+ return mind.graph(conn)
423
+ finally:
424
+ conn.close()
425
+
426
+
427
+ @app.get("/api/mind/atom/{atom_id}")
428
+ def mind_atom(atom_id: str) -> dict:
429
+ conn = _conn()
430
+ try:
431
+ a = mind.atom_detail(conn, atom_id)
432
+ finally:
433
+ conn.close()
434
+ if not a:
435
+ raise HTTPException(404, "atom not found")
436
+ a["created_iso"] = iso(a.get("created"))
437
+ return a
438
+
439
+
440
+ @app.get("/api/mind/activity")
441
+ def mind_activity(limit: int = Query(60, le=200)) -> dict:
442
+ conn = _conn()
443
+ try:
444
+ return {"ops": mind.activity(conn, limit)}
445
+ finally:
446
+ conn.close()
447
+
448
+
449
+ @app.get("/api/mind/memories")
450
+ def mind_memories(
451
+ q: Optional[str] = None,
452
+ type: Optional[list[str]] = Query(None),
453
+ hub: Optional[list[str]] = Query(None),
454
+ limit: int = Query(500, le=5000),
455
+ offset: int = 0,
456
+ ) -> dict:
457
+ """The Memory feed: every atom in the mind, newest first, filterable."""
458
+ conn = _conn()
459
+ try:
460
+ return mind.memories(conn, q=q, types=type, hubs=hub, limit=limit, offset=offset)
461
+ finally:
462
+ conn.close()
463
+
464
+
465
+ @app.post("/api/mind/import")
466
+ def mind_import() -> dict:
467
+ conn = db.connect()
468
+ try:
469
+ db.init_db(conn)
470
+ return mind.import_mind(conn)
471
+ finally:
472
+ conn.close()
473
+
474
+
475
+ class RememberBody(BaseModel):
476
+ text: str
477
+ type: str = "observation"
478
+ hubs: Optional[list[str]] = None
479
+ links: Optional[dict[str, list[str]]] = None
480
+ source: Optional[str] = None
481
+ session: Optional[str] = None
482
+ label: Optional[str] = None
483
+ dedupe: bool = False
484
+
485
+
486
+ @app.post("/api/mind/remember")
487
+ def mind_remember(body: RememberBody) -> dict:
488
+ conn = db.connect()
489
+ try:
490
+ db.init_db(conn)
491
+ return mind.remember(
492
+ conn, body.text, atype=body.type, hubs=body.hubs, links=body.links,
493
+ source=body.source, session_id=body.session, label=body.label,
494
+ dedupe=body.dedupe,
495
+ )
496
+ finally:
497
+ conn.close()
498
+
499
+
500
+ class FeedbackBody(BaseModel):
501
+ atom_id: str
502
+ signal: str
503
+ source: Optional[str] = None
504
+ session: Optional[str] = None
505
+
506
+
507
+ @app.post("/api/mind/feedback")
508
+ def mind_feedback(body: FeedbackBody) -> dict:
509
+ """Record a memory quality signal (used|useful|noise); log best-effort."""
510
+ conn = db.connect()
511
+ try:
512
+ db.init_db(conn)
513
+ try:
514
+ return mind.feedback(
515
+ conn, body.atom_id, body.signal,
516
+ source=body.source, session_id=body.session,
517
+ )
518
+ except ValueError as exc:
519
+ raise HTTPException(400, str(exc))
520
+ finally:
521
+ conn.close()
522
+
523
+
524
+ @app.get("/api/mind/recall")
525
+ def mind_recall(q: str, limit: int = Query(8, le=50), session: Optional[str] = None) -> dict:
526
+ conn = db.connect()
527
+ try:
528
+ db.init_db(conn)
529
+ return mind.recall(conn, q, limit=limit, session_id=session)
530
+ finally:
531
+ conn.close()
532
+
533
+
534
+ @app.get("/api/mind/wakeup")
535
+ def mind_wakeup(session: Optional[str] = None) -> dict:
536
+ conn = db.connect()
537
+ try:
538
+ db.init_db(conn)
539
+ return mind.wake_up(conn, session)
540
+ finally:
541
+ conn.close()
542
+
543
+
544
+ @app.get("/api/mind/directives")
545
+ def mind_directives(limit: int = Query(8, le=25)) -> dict:
546
+ """Standing directives the OpenCode adapter re-injects on every model call."""
547
+ conn = db.connect()
548
+ try:
549
+ db.init_db(conn)
550
+ return mind.directives(conn, limit=limit)
551
+ finally:
552
+ conn.close()
553
+
554
+
555
+ class PinBody(BaseModel):
556
+ atom_id: str
557
+ pinned: bool = True
558
+ session: Optional[str] = None
559
+
560
+
561
+ @app.post("/api/mind/pin")
562
+ def mind_pin(body: PinBody) -> dict:
563
+ """Pin/unpin an atom as a standing directive."""
564
+ conn = db.connect()
565
+ try:
566
+ db.init_db(conn)
567
+ try:
568
+ return mind.set_pin(conn, body.atom_id, body.pinned, body.session)
569
+ except ValueError as exc:
570
+ raise HTTPException(400, str(exc))
571
+ finally:
572
+ conn.close()
573
+
574
+
575
+ @app.post("/api/mind/consolidate")
576
+ def mind_consolidate(session: Optional[str] = None) -> dict:
577
+ conn = db.connect()
578
+ try:
579
+ db.init_db(conn)
580
+ return mind.consolidate(conn, session)
581
+ finally:
582
+ conn.close()
583
+
584
+
585
+ @app.get("/api/mind/dreams")
586
+ def mind_dreams(limit: int = Query(30, le=200)) -> dict:
587
+ conn = _conn()
588
+ try:
589
+ return mind.dreams(conn, limit)
590
+ finally:
591
+ conn.close()
592
+
593
+
594
+ @app.get("/api/mind/reader")
595
+ def mind_reader(group: str) -> dict:
596
+ conn = _conn()
597
+ try:
598
+ return mind.reader(conn, group)
599
+ except ValueError as exc:
600
+ raise HTTPException(400, str(exc))
601
+ finally:
602
+ conn.close()
603
+
604
+
605
+ # --- static UI ----------------------------------------------------------
606
+ # ---------------------------------------------------------------------------
607
+ # Debug page (SPEC §5 visibility): one aggregate view of what the daemon and
608
+ # the plugin actually did. Read-only, cheap, no auth change — same as the rest
609
+ # of the read API.
610
+ # ---------------------------------------------------------------------------
611
+
612
+
613
+ @app.get("/api/debug/requests")
614
+ def debug_requests(limit: int = Query(200, le=500)) -> dict:
615
+ return {"requests": list(_TRACE)[-limit:], "buffer": len(_TRACE), "maxlen": _TRACE.maxlen}
616
+
617
+
618
+ # Client-side lane pushes: the OpenCode plugin reports every injection it makes
619
+ # into the system prompt (wakeup / persona / directives / recall / gate notice).
620
+ # The daemon cannot observe these otherwise — they are its one blind spot — so
621
+ # the plugin POSTs them here and the debug page renders them. In-memory ring,
622
+ # deliberately NOT the DB: one write per model call must never fight the mind
623
+ # for the SQLite writer lock (and /api/debug is untraced, so no self-noise).
624
+ _INJECTIONS: deque = deque(maxlen=500)
625
+
626
+
627
+ class InjectionBody(BaseModel):
628
+ session: Optional[str] = None
629
+ lane: str
630
+ stage: str = "context" # context | compaction
631
+ chars: int = 0
632
+ note: Optional[str] = None
633
+
634
+
635
+ @app.post("/api/debug/injections")
636
+ def debug_injection(body: InjectionBody) -> dict:
637
+ _INJECTIONS.append(
638
+ {
639
+ "ts": now_ms(),
640
+ "session": body.session,
641
+ "lane": body.lane,
642
+ "stage": body.stage,
643
+ "chars": body.chars,
644
+ "note": body.note,
645
+ }
646
+ )
647
+ return {"ok": True, "buffered": len(_INJECTIONS)}
648
+
649
+
650
+ @app.get("/api/debug/injections")
651
+ def debug_injections(limit: int = Query(200, le=500)) -> dict:
652
+ return {"injections": list(_INJECTIONS)[-limit:], "buffer": len(_INJECTIONS), "maxlen": _INJECTIONS.maxlen}
653
+
654
+
655
+ @app.get("/api/debug/overview")
656
+ def debug_overview() -> dict:
657
+ conn = _conn()
658
+ conn.row_factory = sqlite3.Row
659
+ now_ms_ = now_ms()
660
+ day_start = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp() * 1000)
661
+ one = lambda sql: conn.execute(sql).fetchone()[0] # noqa: E731
662
+
663
+ counts = {
664
+ "atoms": one("SELECT COUNT(*) FROM m_atoms"),
665
+ "hubs": one("SELECT COUNT(*) FROM m_hubs"),
666
+ "edges": one("SELECT COUNT(*) FROM m_edges"),
667
+ "events": one("SELECT COUNT(*) FROM events"),
668
+ "ops_today": one(f"SELECT COUNT(*) FROM m_ops WHERE ts >= {day_start}"),
669
+ "feedback_24h": one(f"SELECT COUNT(*) FROM m_feedback WHERE ts >= {now_ms_ - 86_400_000}"),
670
+ }
671
+ rank_states = [dict(r) for r in conn.execute("SELECT state, COUNT(*) AS n FROM m_rank GROUP BY state ORDER BY n DESC")]
672
+ atom_types = [dict(r) for r in conn.execute("SELECT type, COUNT(*) AS n FROM m_atoms GROUP BY type ORDER BY n DESC")]
673
+
674
+ # Mind op log: the closest thing to a brain-side trace of the plugin lanes.
675
+ ops_today = [dict(r) for r in conn.execute(f"SELECT op, COUNT(*) AS n FROM m_ops WHERE ts >= {day_start} GROUP BY op ORDER BY n DESC")]
676
+ recent_ops = [
677
+ dict(r) for r in conn.execute("SELECT ts, op, atom_id, label, query, session_id FROM m_ops ORDER BY id DESC LIMIT 40")
678
+ ]
679
+ feedback = [
680
+ dict(r) for r in conn.execute("SELECT ts, atom_id, signal, source, session_id FROM m_feedback ORDER BY id DESC LIMIT 20")
681
+ ]
682
+ ingest = [
683
+ dict(r)
684
+ for r in conn.execute(
685
+ "SELECT started_at, finished_at, source, added, updated, errors, note FROM ingest_runs ORDER BY id DESC LIMIT 12"
686
+ )
687
+ ]
688
+
689
+ # Life loop: phases are due-based; state is fully derivable from the events
690
+ # the loop already emits (actor agent:neobrain, category life, title "life: <phase>").
691
+ s = config.settings
692
+ hour = datetime.now().hour
693
+ quiet_now = hour >= int(s.life_quiet_start) or hour < int(s.life_quiet_end)
694
+ phases = []
695
+ for name, interval in (
696
+ ("perceive", s.life_perceive_interval_minutes),
697
+ ("reflect", s.life_reflect_interval_minutes),
698
+ ("act", s.life_act_interval_minutes),
699
+ ):
700
+ row = conn.execute(
701
+ "SELECT ts, detail FROM events WHERE actor='agent:neobrain' AND category='life' AND title=? ORDER BY ts DESC LIMIT 1",
702
+ (f"life: {name}",),
703
+ ).fetchone()
704
+ last = None
705
+ if row:
706
+ detail = {}
707
+ try:
708
+ detail = json.loads(row["detail"] or "{}")
709
+ except Exception:
710
+ pass
711
+ last = {
712
+ "ts": row["ts"],
713
+ "summary": detail.get("summary") or "",
714
+ "next_due": detail.get("next_due"),
715
+ }
716
+ phases.append({"phase": name, "every_minutes": interval, "last": last})
717
+ last_dream = conn.execute("SELECT ts, label FROM m_ops WHERE op='dream' ORDER BY id DESC LIMIT 1").fetchone()
718
+
719
+ # Self / Soul trace: what the agent did to itself. Every ingest versions the
720
+ # persona docs (IDENTITY/SOUL/USER/AGENTS) and the mind stores the agent's
721
+ # preference rules — the MRI view of self-maintenance.
722
+ self_docs = [
723
+ dict(r)
724
+ for r in conn.execute(
725
+ "SELECT doc_path, ts, content_hash FROM doc_versions WHERE "
726
+ "doc_path LIKE '%AGENTS.md' OR doc_path LIKE '%USER.md' OR doc_path LIKE '%SOUL.md' OR doc_path LIKE '%IDENTITY.md' "
727
+ "ORDER BY ts DESC LIMIT 12"
728
+ )
729
+ ]
730
+ self_doc_counts = [
731
+ dict(r)
732
+ for r in conn.execute(
733
+ "SELECT doc_path, COUNT(*) AS versions, MAX(ts) AS last_ts FROM doc_versions WHERE "
734
+ "doc_path LIKE '%AGENTS.md' OR doc_path LIKE '%USER.md' OR doc_path LIKE '%SOUL.md' OR doc_path LIKE '%IDENTITY.md' "
735
+ "GROUP BY doc_path"
736
+ )
737
+ ]
738
+ prefs = [
739
+ dict(r)
740
+ for r in conn.execute(
741
+ "SELECT id, substr(label, 1, 90) AS label, created FROM m_atoms WHERE type='preference' ORDER BY created DESC LIMIT 8"
742
+ )
743
+ ]
744
+
745
+ config_view = {
746
+ "life_enabled": _life_enabled(),
747
+ "quiet_hours": f"{s.life_quiet_start:02d}:00–{s.life_quiet_end:02d}:00",
748
+ "quiet_now": quiet_now,
749
+ "dream_hour": f"{s.life_dream_hour:02d}:00",
750
+ "reflect_weekdays": getattr(s, "reflect_weekdays", "?"),
751
+ "tick_seconds": s.life_tick_seconds,
752
+ "rank_half_life_days": s.rank_half_life_days,
753
+ "llm_model_cheap": s.llm_model_cheap,
754
+ "llm_model_strong": s.llm_model_strong,
755
+ "bind": s.bind,
756
+ "data_dir": str(config.DATA_DIR),
757
+ }
758
+ conn.close()
759
+ return {
760
+ "now": now_ms_,
761
+ "counts": counts,
762
+ "rank_states": rank_states,
763
+ "atom_types": atom_types,
764
+ "ops_today": ops_today,
765
+ "recent_ops": recent_ops,
766
+ "feedback": feedback,
767
+ "ingest": ingest,
768
+ "phases": phases,
769
+ "quiet_now": quiet_now,
770
+ "last_dream": dict(last_dream) if last_dream else None,
771
+ "self_trace": {"docs": self_docs, "doc_counts": self_doc_counts, "preferences": prefs},
772
+ "config": config_view,
773
+ }
774
+
775
+
776
+ # Built SPA lives in web/dist; mounted last so /api/* always wins.
777
+ #
778
+ # PORT-NOTE: the old app/ layout had the package one level under the repo root
779
+ # (parent.parent), but this package sits at src/neobrain, so the repo root is
780
+ # parents[2]. NEOBRAIN_WEB_DIST overrides the location without a config.py key
781
+ # (config.py is frozen for this sprint) — resolved right here.
782
+ _web_dist = os.environ.get("NEOBRAIN_WEB_DIST")
783
+ DIST = (
784
+ Path(_web_dist).expanduser().resolve()
785
+ if _web_dist
786
+ else Path(__file__).resolve().parents[2] / "web" / "dist"
787
+ )
788
+ if DIST.is_dir():
789
+ app.mount("/", StaticFiles(directory=str(DIST), html=True), name="ui")