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/__init__.py +3 -0
- neobrain/api.py +789 -0
- neobrain/cli.py +444 -0
- neobrain/config.py +275 -0
- neobrain/db.py +284 -0
- neobrain/dreams.py +883 -0
- neobrain/embeddings.py +398 -0
- neobrain/heuristics.py +87 -0
- neobrain/ingest/__init__.py +5 -0
- neobrain/ingest/docs.py +155 -0
- neobrain/ingest/gitrepo.py +137 -0
- neobrain/ingest/opencode.py +364 -0
- neobrain/ingest/runner.py +239 -0
- neobrain/life.py +581 -0
- neobrain/mind.py +1621 -0
- neobrain/models.py +149 -0
- neobrain/rank.py +436 -0
- neobrain/runtime.py +283 -0
- neobrain/schema.py +240 -0
- neobrain_cli-0.1.0.dist-info/METADATA +284 -0
- neobrain_cli-0.1.0.dist-info/RECORD +24 -0
- neobrain_cli-0.1.0.dist-info/WHEEL +4 -0
- neobrain_cli-0.1.0.dist-info/entry_points.txt +2 -0
- neobrain_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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")
|