@simbimbo/memory-ocmemog 0.1.11 → 0.1.13

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +83 -18
  3. package/brain/runtime/__init__.py +2 -12
  4. package/brain/runtime/config.py +1 -24
  5. package/brain/runtime/inference.py +1 -151
  6. package/brain/runtime/instrumentation.py +1 -15
  7. package/brain/runtime/memory/__init__.py +3 -13
  8. package/brain/runtime/memory/api.py +1 -1219
  9. package/brain/runtime/memory/candidate.py +1 -185
  10. package/brain/runtime/memory/conversation_state.py +1 -1823
  11. package/brain/runtime/memory/distill.py +1 -344
  12. package/brain/runtime/memory/embedding_engine.py +1 -92
  13. package/brain/runtime/memory/freshness.py +1 -112
  14. package/brain/runtime/memory/health.py +1 -40
  15. package/brain/runtime/memory/integrity.py +1 -186
  16. package/brain/runtime/memory/memory_consolidation.py +1 -58
  17. package/brain/runtime/memory/memory_links.py +1 -107
  18. package/brain/runtime/memory/memory_salience.py +1 -233
  19. package/brain/runtime/memory/memory_synthesis.py +1 -31
  20. package/brain/runtime/memory/memory_taxonomy.py +1 -33
  21. package/brain/runtime/memory/pondering_engine.py +1 -654
  22. package/brain/runtime/memory/promote.py +1 -277
  23. package/brain/runtime/memory/provenance.py +1 -406
  24. package/brain/runtime/memory/reinforcement.py +1 -71
  25. package/brain/runtime/memory/retrieval.py +1 -210
  26. package/brain/runtime/memory/semantic_search.py +1 -64
  27. package/brain/runtime/memory/store.py +1 -429
  28. package/brain/runtime/memory/unresolved_state.py +1 -91
  29. package/brain/runtime/memory/vector_index.py +1 -323
  30. package/brain/runtime/model_roles.py +1 -9
  31. package/brain/runtime/model_router.py +1 -22
  32. package/brain/runtime/providers.py +1 -66
  33. package/brain/runtime/security/redaction.py +1 -12
  34. package/brain/runtime/state_store.py +1 -23
  35. package/brain/runtime/storage_paths.py +1 -39
  36. package/docs/architecture/memory.md +20 -24
  37. package/docs/release-checklist.md +19 -6
  38. package/docs/usage.md +33 -17
  39. package/index.ts +8 -1
  40. package/ocmemog/__init__.py +11 -0
  41. package/ocmemog/doctor.py +1255 -0
  42. package/ocmemog/runtime/__init__.py +18 -0
  43. package/ocmemog/runtime/_compat_bridge.py +28 -0
  44. package/ocmemog/runtime/config.py +34 -0
  45. package/ocmemog/runtime/identity.py +115 -0
  46. package/ocmemog/runtime/inference.py +163 -0
  47. package/ocmemog/runtime/instrumentation.py +20 -0
  48. package/ocmemog/runtime/memory/__init__.py +91 -0
  49. package/ocmemog/runtime/memory/api.py +1594 -0
  50. package/ocmemog/runtime/memory/candidate.py +192 -0
  51. package/ocmemog/runtime/memory/conversation_state.py +1831 -0
  52. package/ocmemog/runtime/memory/distill.py +282 -0
  53. package/ocmemog/runtime/memory/embedding_engine.py +151 -0
  54. package/ocmemog/runtime/memory/freshness.py +114 -0
  55. package/ocmemog/runtime/memory/health.py +93 -0
  56. package/ocmemog/runtime/memory/integrity.py +208 -0
  57. package/ocmemog/runtime/memory/memory_consolidation.py +60 -0
  58. package/ocmemog/runtime/memory/memory_links.py +109 -0
  59. package/ocmemog/runtime/memory/memory_salience.py +235 -0
  60. package/ocmemog/runtime/memory/memory_synthesis.py +33 -0
  61. package/ocmemog/runtime/memory/memory_taxonomy.py +35 -0
  62. package/ocmemog/runtime/memory/pondering_engine.py +681 -0
  63. package/ocmemog/runtime/memory/promote.py +279 -0
  64. package/ocmemog/runtime/memory/provenance.py +408 -0
  65. package/ocmemog/runtime/memory/reinforcement.py +73 -0
  66. package/ocmemog/runtime/memory/retrieval.py +224 -0
  67. package/ocmemog/runtime/memory/semantic_search.py +66 -0
  68. package/ocmemog/runtime/memory/store.py +433 -0
  69. package/ocmemog/runtime/memory/unresolved_state.py +93 -0
  70. package/ocmemog/runtime/memory/vector_index.py +411 -0
  71. package/ocmemog/runtime/model_roles.py +15 -0
  72. package/ocmemog/runtime/model_router.py +28 -0
  73. package/ocmemog/runtime/providers.py +78 -0
  74. package/ocmemog/runtime/roles.py +92 -0
  75. package/ocmemog/runtime/security/__init__.py +8 -0
  76. package/ocmemog/runtime/security/redaction.py +17 -0
  77. package/ocmemog/runtime/state_store.py +32 -0
  78. package/ocmemog/runtime/storage_paths.py +70 -0
  79. package/ocmemog/sidecar/app.py +421 -60
  80. package/ocmemog/sidecar/compat.py +50 -13
  81. package/ocmemog/sidecar/transcript_watcher.py +327 -242
  82. package/openclaw.plugin.json +4 -0
  83. package/package.json +1 -1
  84. package/scripts/ocmemog-backfill-vectors.py +5 -3
  85. package/scripts/ocmemog-continuity-benchmark.py +1 -1
  86. package/scripts/ocmemog-demo.py +1 -1
  87. package/scripts/ocmemog-doctor.py +15 -0
  88. package/scripts/ocmemog-install.sh +29 -7
  89. package/scripts/ocmemog-integrated-proof.py +374 -0
  90. package/scripts/ocmemog-reindex-vectors.py +5 -3
  91. package/scripts/ocmemog-release-check.sh +330 -0
  92. package/scripts/ocmemog-sidecar.sh +4 -2
  93. package/scripts/ocmemog-test-rig.py +5 -3
  94. package/brain/runtime/memory/artifacts.py +0 -33
  95. package/brain/runtime/memory/context_builder.py +0 -112
  96. package/brain/runtime/memory/interaction_memory.py +0 -57
  97. package/brain/runtime/memory/memory_gate.py +0 -38
  98. package/brain/runtime/memory/memory_graph.py +0 -54
  99. package/brain/runtime/memory/person_identity.py +0 -83
  100. package/brain/runtime/memory/person_memory.py +0 -138
  101. package/brain/runtime/memory/sentiment_memory.py +0 -67
  102. package/brain/runtime/memory/tool_catalog.py +0 -68
@@ -1,10 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import atexit
5
+ import faulthandler
4
6
  import os
5
7
  import re
6
8
  import threading
9
+ import tempfile
7
10
  import time
11
+ import sys
12
+ from contextlib import asynccontextmanager
8
13
  from pathlib import Path
9
14
  from typing import Any, Dict, Iterable, List, Optional
10
15
 
@@ -13,17 +18,126 @@ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
13
18
  from pydantic import BaseModel, Field
14
19
  from datetime import datetime, timedelta
15
20
 
16
- from brain.runtime import state_store
17
- from brain.runtime.memory import api, conversation_state, distill, health, memory_links, pondering_engine, provenance, reinforcement, retrieval, store
21
+ from ocmemog import __version__
22
+ from ocmemog.runtime import state_store
23
+ from ocmemog.runtime.memory import (
24
+ api,
25
+ conversation_state,
26
+ distill,
27
+ health,
28
+ memory_links,
29
+ pondering_engine,
30
+ provenance,
31
+ reinforcement,
32
+ retrieval,
33
+ store,
34
+ )
18
35
  from ocmemog.sidecar.compat import flatten_results, probe_runtime
19
36
  from ocmemog.sidecar.transcript_watcher import watch_forever
20
37
 
21
38
  DEFAULT_CATEGORIES = tuple(store.MEMORY_TABLES)
22
39
 
23
- app = FastAPI(title="ocmemog sidecar", version="0.1.10")
24
-
25
40
  API_TOKEN = os.environ.get("OCMEMOG_API_TOKEN")
41
+ _GOVERNANCE_REVIEW_CACHE_TTL_SECONDS = 15.0
42
+ _governance_review_cache: Dict[str, Any] = {"key": None, "expires_at": 0.0, "payload": None}
43
+
44
+
45
+ _BOOL_TRUE_VALUES = {"1", "true", "yes", "on", "y", "t"}
46
+ _BOOL_FALSE_VALUES = {"0", "false", "no", "off", "n", "f"}
47
+
48
+
49
+ def _parse_bool_env_value(raw: Any | None, default: bool = False) -> tuple[bool, bool]:
50
+ """Return ``(value, valid)``, where ``valid`` indicates parser confidence."""
51
+ if raw is None:
52
+ return default, True
53
+
54
+ raw_value = str(raw).strip().lower()
55
+ if raw_value in _BOOL_TRUE_VALUES:
56
+ return True, True
57
+ if raw_value in _BOOL_FALSE_VALUES:
58
+ return False, True
59
+ if not raw_value:
60
+ return default, False
61
+ return default, False
62
+
63
+
64
+ def _parse_bool_env(name: str, default: bool = False) -> bool:
65
+ raw = os.environ.get(name)
66
+ value, _ = _parse_bool_env_value(raw, default=default)
67
+ return value
68
+
69
+
70
+ def _parse_float_env(name: str, default: float, minimum: float | None = None) -> float:
71
+ raw = os.environ.get(name)
72
+ try:
73
+ value = float(raw if raw is not None else default)
74
+ except Exception:
75
+ print(
76
+ f"[ocmemog][config] invalid float env value: {name}={raw!r}; using default {default}",
77
+ file=sys.stderr,
78
+ )
79
+ return default
80
+ if minimum is not None and value < minimum:
81
+ print(
82
+ f"[ocmemog][config] env value below minimum: {name}={value}; using default {default}",
83
+ file=sys.stderr,
84
+ )
85
+ return default
86
+ return value
87
+
88
+
89
+ def _parse_int_env(name: str, default: int, minimum: int | None = None) -> int:
90
+ raw = os.environ.get(name)
91
+ try:
92
+ value = int(raw if raw is not None else default)
93
+ except Exception:
94
+ print(
95
+ f"[ocmemog][config] invalid int env value: {name}={raw!r}; using default {default}",
96
+ file=sys.stderr,
97
+ )
98
+ return default
99
+ if minimum is not None and value < minimum:
100
+ print(
101
+ f"[ocmemog][config] env value below minimum: {name}={value}; using default {default}",
102
+ file=sys.stderr,
103
+ )
104
+ return default
105
+ return value
106
+
107
+
108
+ _SHUTDOWN_TIMING = _parse_bool_env("OCMEMOG_SHUTDOWN_TIMING", default=True)
109
+
26
110
 
111
+ @asynccontextmanager
112
+ async def _sidecar_lifespan(_: FastAPI):
113
+ _startup_started = time.perf_counter()
114
+ try:
115
+ _start_transcript_watcher()
116
+ _start_ingest_worker()
117
+ if _SHUTDOWN_TIMING:
118
+ print(
119
+ f"[ocmemog][shutdown] lifespan_startup elapsed={time.perf_counter()-_startup_started:.3f}s",
120
+ file=sys.stderr,
121
+ )
122
+ yield
123
+ finally:
124
+ shutdown_started = time.perf_counter()
125
+ _stop_background_workers()
126
+ if _SHUTDOWN_TIMING:
127
+ print(
128
+ f"[ocmemog][shutdown] lifespan_shutdown elapsed={time.perf_counter()-shutdown_started:.3f}s",
129
+ file=sys.stderr,
130
+ )
131
+
132
+
133
+ app = FastAPI(title="ocmemog sidecar", version=__version__, lifespan=_sidecar_lifespan)
134
+
135
+ _INGEST_WORKER_STOP = threading.Event()
136
+ _INGEST_WORKER_THREAD: threading.Thread | None = None
137
+ _INGEST_WORKER_LOCK = threading.Lock()
138
+ _WATCHER_STOP = threading.Event()
139
+ _WATCHER_THREAD: threading.Thread | None = None
140
+ _WATCHER_LOCK = threading.Lock()
27
141
  QUEUE_LOCK = threading.Lock()
28
142
  QUEUE_PROCESS_LOCK = threading.Lock()
29
143
  QUEUE_STATS = {
@@ -33,6 +147,8 @@ QUEUE_STATS = {
33
147
  "last_error": None,
34
148
  "last_batch": 0,
35
149
  }
150
+ _POSTPROCESS_TASK_KEY = "_ocmemog_task"
151
+ _POSTPROCESS_TASK_VALUE = "postprocess_memory"
36
152
 
37
153
 
38
154
  _REFLECTION_RECLASSIFY_PREFERENCE_PATTERNS = (
@@ -85,9 +201,14 @@ def _load_queue_stats() -> None:
85
201
 
86
202
  def _save_queue_stats() -> None:
87
203
  path = _queue_stats_path()
88
- tmp = path.with_suffix('.tmp')
89
- tmp.write_text(json.dumps(QUEUE_STATS, indent=2, sort_keys=True), encoding='utf-8')
90
- tmp.replace(path)
204
+ path.parent.mkdir(parents=True, exist_ok=True)
205
+ payload = json.dumps(QUEUE_STATS, indent=2, sort_keys=True)
206
+ with tempfile.NamedTemporaryFile('w', encoding='utf-8', dir=str(path.parent), prefix='queue_stats.', suffix='.tmp', delete=False) as handle:
207
+ handle.write(payload)
208
+ handle.flush()
209
+ os.fsync(handle.fileno())
210
+ tmp_name = handle.name
211
+ Path(tmp_name).replace(path)
91
212
 
92
213
 
93
214
  @app.middleware("http")
@@ -100,14 +221,23 @@ async def _auth_middleware(request: Request, call_next):
100
221
  return await call_next(request)
101
222
 
102
223
 
103
- @app.on_event("startup")
104
224
  def _start_transcript_watcher() -> None:
225
+ global _WATCHER_THREAD
105
226
  _load_queue_stats()
106
- enabled = os.environ.get("OCMEMOG_TRANSCRIPT_WATCHER", "").lower() in {"1", "true", "yes"}
227
+ enabled = _parse_bool_env("OCMEMOG_TRANSCRIPT_WATCHER")
107
228
  if not enabled:
108
229
  return
109
- thread = threading.Thread(target=watch_forever, daemon=True)
110
- thread.start()
230
+ with _WATCHER_LOCK:
231
+ if _WATCHER_THREAD and _WATCHER_THREAD.is_alive():
232
+ return
233
+ _WATCHER_STOP.clear()
234
+ _WATCHER_THREAD = threading.Thread(
235
+ target=watch_forever,
236
+ args=(_WATCHER_STOP,),
237
+ daemon=True,
238
+ name="ocmemog-transcript-watcher",
239
+ )
240
+ _WATCHER_THREAD.start()
111
241
 
112
242
 
113
243
  def _queue_path() -> Path:
@@ -186,6 +316,24 @@ def _enqueue_payload(payload: Dict[str, Any]) -> int:
186
316
  return _queue_depth()
187
317
 
188
318
 
319
+ def _enqueue_postprocess(reference: str, *, skip_embedding_provider: bool = True) -> int:
320
+ return _enqueue_payload({
321
+ _POSTPROCESS_TASK_KEY: _POSTPROCESS_TASK_VALUE,
322
+ "reference": reference,
323
+ "skip_embedding_provider": bool(skip_embedding_provider),
324
+ })
325
+
326
+
327
+ def _run_postprocess_payload(payload: Dict[str, Any]) -> None:
328
+ reference = str(payload.get("reference") or "").strip()
329
+ if not reference:
330
+ raise ValueError("missing_reference")
331
+ skip_embedding_provider = bool(payload.get("skip_embedding_provider", True))
332
+ result = api.postprocess_stored_memory(reference, skip_embedding_provider=skip_embedding_provider)
333
+ if not result.get("ok"):
334
+ raise RuntimeError(str(result.get("error") or "postprocess_failed"))
335
+
336
+
189
337
 
190
338
  def _process_queue(limit: Optional[int] = None) -> Dict[str, Any]:
191
339
  processed = 0
@@ -208,8 +356,11 @@ def _process_queue(limit: Optional[int] = None) -> Dict[str, Any]:
208
356
  acknowledged = 0
209
357
  for line_no, payload in batch:
210
358
  try:
211
- req = IngestRequest(**payload)
212
- _ingest_request(req)
359
+ if isinstance(payload, dict) and payload.get(_POSTPROCESS_TASK_KEY) == _POSTPROCESS_TASK_VALUE:
360
+ _run_postprocess_payload(payload)
361
+ else:
362
+ req = IngestRequest(**payload)
363
+ _ingest_request(req)
213
364
  processed += 1
214
365
  batch_processed += 1
215
366
  acknowledged = line_no
@@ -240,15 +391,16 @@ def _process_queue(limit: Optional[int] = None) -> Dict[str, Any]:
240
391
 
241
392
 
242
393
  def _ingest_worker() -> None:
243
- enabled = os.environ.get("OCMEMOG_INGEST_ASYNC_WORKER", "true").lower() in {"1", "true", "yes"}
394
+ enabled = _parse_bool_env("OCMEMOG_INGEST_ASYNC_WORKER", default=True)
244
395
  if not enabled:
245
396
  return
246
- poll_seconds = float(os.environ.get("OCMEMOG_INGEST_ASYNC_POLL_SECONDS", "5"))
247
- batch_max = int(os.environ.get("OCMEMOG_INGEST_ASYNC_BATCH_MAX", "25"))
397
+ poll_seconds = _parse_float_env("OCMEMOG_INGEST_ASYNC_POLL_SECONDS", default=5.0, minimum=0.0)
398
+ batch_max = _parse_int_env("OCMEMOG_INGEST_ASYNC_BATCH_MAX", default=25, minimum=1)
248
399
 
249
- while True:
400
+ while not _INGEST_WORKER_STOP.is_set():
250
401
  _process_queue(batch_max)
251
- time.sleep(poll_seconds)
402
+ if _INGEST_WORKER_STOP.wait(poll_seconds):
403
+ break
252
404
 
253
405
 
254
406
 
@@ -256,10 +408,122 @@ def _drain_queue(limit: Optional[int] = None) -> Dict[str, Any]:
256
408
  return _process_queue(limit)
257
409
 
258
410
 
259
- @app.on_event("startup")
260
411
  def _start_ingest_worker() -> None:
261
- thread = threading.Thread(target=_ingest_worker, daemon=True)
262
- thread.start()
412
+ global _INGEST_WORKER_THREAD
413
+ with _INGEST_WORKER_LOCK:
414
+ if _INGEST_WORKER_THREAD and _INGEST_WORKER_THREAD.is_alive():
415
+ return
416
+ _INGEST_WORKER_STOP.clear()
417
+ _INGEST_WORKER_THREAD = threading.Thread(
418
+ target=_ingest_worker,
419
+ daemon=True,
420
+ name="ocmemog-ingest-worker",
421
+ )
422
+ _INGEST_WORKER_THREAD.start()
423
+
424
+
425
+ def _stop_background_workers() -> None:
426
+ global _INGEST_WORKER_THREAD, _WATCHER_THREAD
427
+ shutdown_start = time.perf_counter()
428
+ if _SHUTDOWN_TIMING:
429
+ print(f"[ocmemog][shutdown] shutdown_begin", file=sys.stderr)
430
+ timeout = _parse_float_env(
431
+ "OCMEMOG_WORKER_SHUTDOWN_TIMEOUT_SECONDS",
432
+ default=0.35,
433
+ minimum=0.0,
434
+ )
435
+ if _SHUTDOWN_TIMING:
436
+ print(f"[ocmemog][shutdown] shutdown_config timeout={timeout:.3f}s", file=sys.stderr)
437
+
438
+ queue_drain_requested = _parse_bool_env("OCMEMOG_SHUTDOWN_DRAIN_QUEUE")
439
+ if queue_drain_requested and _queue_depth() > 0:
440
+ _queue_drain_start = time.perf_counter()
441
+ drain_stats = _drain_queue()
442
+ if _SHUTDOWN_TIMING:
443
+ print(
444
+ f"[ocmemog][shutdown] queue_drain elapsed={time.perf_counter()-_queue_drain_start:.3f}s processed={drain_stats.get('processed', 0)} errors={drain_stats.get('errors', 0)}",
445
+ file=sys.stderr,
446
+ )
447
+ _INGEST_WORKER_STOP.set()
448
+ _WATCHER_STOP.set()
449
+ if _SHUTDOWN_TIMING:
450
+ print(
451
+ f"[ocmemog][shutdown] stop_signals_set elapsed={time.perf_counter()-shutdown_start:.3f}s",
452
+ file=sys.stderr,
453
+ )
454
+
455
+ if _parse_bool_env("OCMEMOG_SHUTDOWN_DUMP_THREADS"):
456
+ _dump_thread_dump("post-stop requested")
457
+
458
+ with _INGEST_WORKER_LOCK:
459
+ ingest_worker = _INGEST_WORKER_THREAD
460
+ if ingest_worker is not None and ingest_worker.is_alive():
461
+ ingest_join_start = time.perf_counter()
462
+ ingest_worker.join(timeout=timeout)
463
+ if _SHUTDOWN_TIMING:
464
+ print(
465
+ f"[ocmemog][shutdown] ingest_worker_join elapsed={time.perf_counter()-ingest_join_start:.3f}s alive={ingest_worker.is_alive()}",
466
+ file=sys.stderr,
467
+ )
468
+ if _parse_bool_env("OCMEMOG_SHUTDOWN_DUMP_THREADS"):
469
+ _dump_join_result("ingest-worker", ingest_worker, timeout)
470
+ if not ingest_worker.is_alive():
471
+ with _INGEST_WORKER_LOCK:
472
+ if _INGEST_WORKER_THREAD is ingest_worker:
473
+ _INGEST_WORKER_THREAD = None
474
+
475
+ with _WATCHER_LOCK:
476
+ watcher_thread = _WATCHER_THREAD
477
+ if watcher_thread is not None and watcher_thread.is_alive():
478
+ watcher_join_start = time.perf_counter()
479
+ watcher_thread.join(timeout=timeout)
480
+ if _SHUTDOWN_TIMING:
481
+ print(
482
+ f"[ocmemog][shutdown] transcript_watcher_join elapsed={time.perf_counter()-watcher_join_start:.3f}s alive={watcher_thread.is_alive()}",
483
+ file=sys.stderr,
484
+ )
485
+ if _parse_bool_env("OCMEMOG_SHUTDOWN_DUMP_THREADS"):
486
+ _dump_join_result("transcript-watcher", watcher_thread, timeout)
487
+ if not watcher_thread.is_alive():
488
+ with _WATCHER_LOCK:
489
+ if _WATCHER_THREAD is watcher_thread:
490
+ _WATCHER_THREAD = None
491
+ if _SHUTDOWN_TIMING:
492
+ print(
493
+ f"[ocmemog][shutdown] shutdown_complete elapsed={time.perf_counter()-shutdown_start:.3f}s",
494
+ file=sys.stderr,
495
+ )
496
+
497
+
498
+ def _dump_thread_dump(context: str) -> None:
499
+ print(f"[ocmemog][thread-dump:{context}]", file=sys.stderr)
500
+ _dump_thread_states()
501
+ faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
502
+
503
+
504
+ def _dump_join_result(thread_label: str, thread: threading.Thread, timeout: float) -> None:
505
+ if thread.is_alive():
506
+ print(
507
+ f"[ocmemog][shutdown] {thread_label} still alive after join timeout={timeout:.3f}s",
508
+ file=sys.stderr,
509
+ )
510
+ _dump_thread_dump(thread_label)
511
+ else:
512
+ print(
513
+ f"[ocmemog][shutdown] {thread_label} joined cleanly",
514
+ file=sys.stderr,
515
+ )
516
+
517
+
518
+ def _dump_thread_states() -> None:
519
+ for thread in threading.enumerate():
520
+ print(
521
+ f"[ocmemog][thread-state] name={thread.name} alive={thread.is_alive()} daemon={thread.daemon} ident={thread.ident}",
522
+ file=sys.stderr,
523
+ )
524
+
525
+
526
+ atexit.register(_stop_background_workers)
263
527
 
264
528
 
265
529
  class SearchRequest(BaseModel):
@@ -290,6 +554,7 @@ class GovernanceReviewRequest(BaseModel):
290
554
  categories: Optional[List[str]] = None
291
555
  limit: int = Field(default=100, ge=1, le=500)
292
556
  context_depth: int = Field(default=1, ge=0, le=2)
557
+ scan_limit: int = Field(default=3000, ge=1, le=10000)
293
558
 
294
559
 
295
560
  class GovernanceDecisionRequest(BaseModel):
@@ -481,6 +746,8 @@ def _runtime_payload() -> Dict[str, Any]:
481
746
  return {
482
747
  "mode": status.mode,
483
748
  "missingDeps": status.missing_deps,
749
+ "identity": status.identity,
750
+ "capabilities": status.capabilities,
484
751
  "todo": status.todo,
485
752
  "warnings": status.warnings,
486
753
  }
@@ -679,6 +946,7 @@ def _read_transcript_snippet(path: Path, line_start: Optional[int], line_end: Op
679
946
  def healthz() -> dict[str, Any]:
680
947
  payload = _runtime_payload()
681
948
  payload["ok"] = True
949
+ payload["ready"] = payload.get("mode") == "ready"
682
950
  return payload
683
951
 
684
952
 
@@ -686,14 +954,35 @@ def healthz() -> dict[str, Any]:
686
954
  def memory_search(request: SearchRequest) -> dict[str, Any]:
687
955
  categories = _normalize_categories(request.categories)
688
956
  runtime = _runtime_payload()
957
+ started = time.perf_counter()
958
+ query = request.query or ""
959
+ skip_vector_provider = _parse_bool_env("OCMEMOG_SEARCH_SKIP_EMBEDDING_PROVIDER", default=True)
689
960
  try:
690
- results = retrieval.retrieve_for_queries([request.query], limit=request.limit, categories=categories)
961
+ results = retrieval.retrieve_for_queries(
962
+ [query],
963
+ limit=request.limit,
964
+ categories=categories,
965
+ skip_vector_provider=skip_vector_provider,
966
+ )
691
967
  flattened = flatten_results(results)
968
+ if len(flattened) > request.limit:
969
+ flattened = flattened[: request.limit]
692
970
  used_fallback = False
693
971
  except Exception as exc:
694
972
  flattened = _fallback_search(request.query, request.limit, categories)
695
973
  used_fallback = True
696
974
  runtime["warnings"] = [*runtime["warnings"], f"search fallback enabled: {exc}"]
975
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
976
+ if elapsed_ms >= 10:
977
+ print(
978
+ f"[ocmemog][route] memory_search elapsed_ms={elapsed_ms:.3f} limit={request.limit} categories={','.join(categories)} fallback={used_fallback}",
979
+ file=sys.stderr,
980
+ )
981
+ if elapsed_ms >= 200:
982
+ print(
983
+ f"[ocmemog][route] memory_search slow_path query={query[:128]!r} result_count={len(flattened)}",
984
+ file=sys.stderr,
985
+ )
697
986
 
698
987
  return {
699
988
  "ok": True,
@@ -764,17 +1053,59 @@ def memory_governance_review(request: GovernanceReviewRequest) -> dict[str, Any]
764
1053
  categories=request.categories,
765
1054
  limit=request.limit,
766
1055
  context_depth=request.context_depth,
1056
+ scan_limit=request.scan_limit,
767
1057
  )
768
1058
  return {
769
1059
  "ok": True,
770
1060
  "categories": request.categories,
771
1061
  "limit": request.limit,
772
1062
  "context_depth": request.context_depth,
1063
+ "scan_limit": request.scan_limit,
773
1064
  "items": items,
774
1065
  **runtime,
775
1066
  }
776
1067
 
777
1068
 
1069
+ @app.post("/memory/governance/review/summary")
1070
+ def memory_governance_review_summary(request: GovernanceReviewRequest) -> dict[str, Any]:
1071
+ runtime = _runtime_payload()
1072
+ limit = min(int(request.limit or 25), 50)
1073
+ scan_limit = min(int(request.scan_limit or max(limit * 10, 250)), 500)
1074
+ cache_key = json.dumps(
1075
+ {
1076
+ "categories": sorted(request.categories or []),
1077
+ "limit": limit,
1078
+ "context_depth": 0,
1079
+ "scan_limit": scan_limit,
1080
+ },
1081
+ sort_keys=True,
1082
+ )
1083
+ now = time.time()
1084
+ if _governance_review_cache.get("key") == cache_key and float(_governance_review_cache.get("expires_at") or 0.0) > now:
1085
+ cached_payload = _governance_review_cache.get("payload") or {}
1086
+ return {**cached_payload, **runtime, "cached": True}
1087
+
1088
+ items = api.list_governance_review_items(
1089
+ categories=request.categories,
1090
+ limit=limit,
1091
+ context_depth=0,
1092
+ scan_limit=scan_limit,
1093
+ )
1094
+ payload = {
1095
+ "ok": True,
1096
+ "categories": request.categories,
1097
+ "limit": limit,
1098
+ "context_depth": 0,
1099
+ "scan_limit": scan_limit,
1100
+ "items": items,
1101
+ "cached": False,
1102
+ }
1103
+ _governance_review_cache.update(
1104
+ {"key": cache_key, "expires_at": now + _GOVERNANCE_REVIEW_CACHE_TTL_SECONDS, "payload": payload}
1105
+ )
1106
+ return {**payload, **runtime}
1107
+
1108
+
778
1109
  @app.post("/memory/governance/decision")
779
1110
  def memory_governance_decision(request: GovernanceDecisionRequest) -> dict[str, Any]:
780
1111
  runtime = _runtime_payload()
@@ -1197,8 +1528,10 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1197
1528
  source=request.source,
1198
1529
  metadata=metadata,
1199
1530
  timestamp=request.timestamp,
1531
+ post_process=False,
1200
1532
  )
1201
1533
  reference = f"{memory_type}:{memory_id}"
1534
+ _enqueue_postprocess(reference, skip_embedding_provider=_parse_bool_env("OCMEMOG_POSTPROCESS_SKIP_EMBEDDING_PROVIDER", default=True))
1202
1535
  if request.conversation_id:
1203
1536
  memory_links.add_memory_link(reference, "conversation", f"conversation:{request.conversation_id}")
1204
1537
  if request.session_id:
@@ -1295,7 +1628,11 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1295
1628
 
1296
1629
  @app.post("/memory/ingest")
1297
1630
  def memory_ingest(request: IngestRequest) -> dict[str, Any]:
1298
- return _ingest_request(request)
1631
+ started = time.perf_counter()
1632
+ payload = _ingest_request(request)
1633
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
1634
+ print(f"[ocmemog][route] memory_ingest elapsed_ms={elapsed_ms:.3f} kind={request.kind} reference={payload.get('reference', '')}", file=sys.stderr)
1635
+ return payload
1299
1636
 
1300
1637
 
1301
1638
  @app.post("/memory/ingest_async")
@@ -1344,32 +1681,35 @@ def memory_distill(request: DistillRequest) -> dict[str, Any]:
1344
1681
  @app.get("/metrics")
1345
1682
  def metrics() -> dict[str, Any]:
1346
1683
  runtime = _runtime_payload()
1347
- payload = health.get_memory_health()
1684
+ payload = health.get_memory_health_fast()
1348
1685
  counts = payload.get("counts", {})
1349
1686
  counts["queue_depth"] = _queue_depth()
1350
1687
  counts["queue_processed"] = QUEUE_STATS.get("processed", 0)
1351
1688
  counts["queue_errors"] = QUEUE_STATS.get("errors", 0)
1352
1689
  payload["counts"] = counts
1690
+
1353
1691
  coverage_tables = list(store.MEMORY_TABLES)
1354
1692
  conn = store.connect()
1355
1693
  try:
1694
+ vector_counts: Dict[str, int] = {str(row[0]): int(row[1] or 0) for row in conn.execute("SELECT source_type, COUNT(*) FROM vector_embeddings GROUP BY source_type")}
1356
1695
  payload["coverage"] = [
1357
1696
  {
1358
1697
  "table": table,
1359
1698
  "rows": int(counts.get(table, 0) or 0),
1360
- "vectors": int(conn.execute("SELECT COUNT(*) FROM vector_embeddings WHERE source_type=?", (table,)).fetchone()[0] or 0),
1361
- "missing": max(int(counts.get(table, 0) or 0) - int(conn.execute("SELECT COUNT(*) FROM vector_embeddings WHERE source_type=?", (table,)).fetchone()[0] or 0), 0),
1699
+ "vectors": int(vector_counts.get(table, 0) or 0),
1700
+ "missing": max(int(counts.get(table, 0) or 0) - int(vector_counts.get(table, 0) or 0), 0),
1362
1701
  }
1363
1702
  for table in coverage_tables
1364
1703
  ]
1365
1704
  finally:
1366
1705
  conn.close()
1706
+
1367
1707
  payload["queue"] = QUEUE_STATS
1368
1708
  return {"ok": True, "metrics": payload, **runtime}
1369
1709
 
1370
1710
 
1371
1711
  def _event_stream():
1372
- path = state_store.reports_dir() / "brain_memory.log.jsonl"
1712
+ path = state_store.report_log_path()
1373
1713
  path.parent.mkdir(parents=True, exist_ok=True)
1374
1714
  if not path.exists():
1375
1715
  path.write_text("")
@@ -1389,33 +1729,55 @@ def events() -> StreamingResponse:
1389
1729
 
1390
1730
 
1391
1731
  def _tail_events(limit: int = 50) -> str:
1392
- path = state_store.reports_dir() / "brain_memory.log.jsonl"
1732
+ path = state_store.report_log_path()
1393
1733
  if not path.exists():
1394
1734
  return ""
1395
1735
  try:
1396
- lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
1397
- except Exception:
1736
+ size = path.stat().st_size
1737
+ # Read only the trailing chunk to avoid loading very large logs.
1738
+ # This bounds dashboard latency even when the report log grows huge.
1739
+ max_bytes = 256 * 1024
1740
+ with path.open("rb") as handle:
1741
+ if size > max_bytes:
1742
+ handle.seek(-max_bytes, 2)
1743
+ data = handle.read()
1744
+ text = data.decode("utf-8", errors="ignore")
1745
+ lines = text.splitlines()
1746
+ except Exception as exc:
1747
+ print(f"[ocmemog][events] tail_read_failed path={path} error={exc!r}", file=sys.stderr)
1398
1748
  return ""
1399
1749
  return "\n".join(lines[-limit:])
1400
1750
 
1401
1751
 
1402
1752
  @app.get("/dashboard")
1403
1753
  def dashboard() -> HTMLResponse:
1404
- metrics_payload = health.get_memory_health()
1754
+ metrics_payload = health.get_memory_health_fast()
1405
1755
  counts = metrics_payload.get("counts", {})
1406
1756
  coverage_tables = list(store.MEMORY_TABLES)
1407
1757
  conn = store.connect()
1408
1758
  try:
1759
+ cursor = conn.execute("SELECT source_type, COUNT(*) FROM vector_embeddings GROUP BY source_type")
1760
+ try:
1761
+ vector_rows = list(cursor)
1762
+ except TypeError:
1763
+ fetchall = getattr(cursor, "fetchall", None)
1764
+ if callable(fetchall):
1765
+ vector_rows = fetchall()
1766
+ else:
1767
+ fetchone = getattr(cursor, "fetchone", None)
1768
+ row = fetchone() if callable(fetchone) else None
1769
+ vector_rows = [row] if row is not None else []
1770
+ vector_counts: Dict[str, int] = {}
1771
+ for row in vector_rows:
1772
+ if not isinstance(row, (list, tuple)) or len(row) < 2:
1773
+ continue
1774
+ vector_counts[str(row[0])] = int(row[1] or 0)
1775
+ if hasattr(cursor, "close"):
1776
+ cursor.close()
1409
1777
  coverage_rows = []
1410
1778
  for table in coverage_tables:
1411
1779
  total = int(counts.get(table, 0) or 0)
1412
- vectors = int(
1413
- conn.execute(
1414
- "SELECT COUNT(*) FROM vector_embeddings WHERE source_type=?",
1415
- (table,),
1416
- ).fetchone()[0]
1417
- or 0
1418
- )
1780
+ vectors = int(vector_counts.get(table, 0) or 0)
1419
1781
  missing = max(total - vectors, 0)
1420
1782
  coverage_rows.append({"table": table, "rows": total, "vectors": vectors, "missing": missing})
1421
1783
  finally:
@@ -1517,15 +1879,12 @@ def dashboard() -> HTMLResponse:
1517
1879
  <thead>
1518
1880
  <tr>
1519
1881
  <th>Priority</th>
1520
- <th>Kind</th>
1521
- <th>Source</th>
1522
- <th>Target</th>
1523
- <th>Summary</th>
1882
+ <th>Review</th>
1524
1883
  <th>Actions</th>
1525
1884
  </tr>
1526
1885
  </thead>
1527
1886
  <tbody id="review-table-body">
1528
- <tr><td colspan="6" class="muted">Loading...</td></tr>
1887
+ <tr><td colspan="3" class="muted">Loading...</td></tr>
1529
1888
  </tbody>
1530
1889
  </table>
1531
1890
  </div>
@@ -1591,6 +1950,17 @@ def dashboard() -> HTMLResponse:
1591
1950
  return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
1592
1951
  }}
1593
1952
 
1953
+ function summarizeReviewItem(item) {{
1954
+ const sourceRef = item.source?.reference || item.reference || 'source memory';
1955
+ const targetRef = item.target?.reference || item.target_reference || 'target memory';
1956
+ const sourceText = item.source?.content || sourceRef;
1957
+ const targetText = item.target?.content || targetRef;
1958
+ const relation = item.relationship || (item.kind_label || item.kind || 'relationship').toLowerCase();
1959
+ const when = item.timestamp ? ` Reviewed signal from ${{formatTimestamp(item.timestamp)}}.` : '';
1960
+ const signal = item.signal ? ` Signal score: ${{item.signal}}.` : '';
1961
+ return `${{sourceRef}} may ${{relation.replaceAll('_', ' ')}} ${{targetRef}}. Source: “${{sourceText}}” Target: “${{targetText}}”.${{signal}}${{when}}`;
1962
+ }}
1963
+
1594
1964
  function renderReviewTable() {{
1595
1965
  const kindFilter = reviewKindFilterEl.value;
1596
1966
  const priorityFilter = reviewPriorityFilterEl.value;
@@ -1608,29 +1978,20 @@ def dashboard() -> HTMLResponse:
1608
1978
  reviewNoteEl.textContent = `${{filtered.length}} items shown${{reviewItems.length !== filtered.length ? ` of ${{reviewItems.length}}` : ''}} • Last refresh: ${{reviewLastRefresh ? formatTimestamp(reviewLastRefresh) : 'n/a'}}`;
1609
1979
 
1610
1980
  if (!filtered.length) {{
1611
- reviewTableBodyEl.innerHTML = '<tr><td colspan="6" class="muted">No review items match the current filters.</td></tr>';
1981
+ reviewTableBodyEl.innerHTML = '<tr><td colspan="3" class="muted">No review items match the current filters.</td></tr>';
1612
1982
  return;
1613
1983
  }}
1614
1984
 
1615
1985
  reviewTableBodyEl.innerHTML = filtered.map((item) => {{
1616
1986
  const disabled = pendingReviewIds.has(item.review_id) ? 'disabled' : '';
1617
- const sourceContent = item.source?.content || item.reference;
1618
- const targetContent = item.target?.content || item.target_reference;
1987
+ const reviewText = summarizeReviewItem(item);
1988
+ const summaryBits = [item.kind_label || item.kind, item.summary].filter(Boolean).join(' • ');
1619
1989
  return `
1620
1990
  <tr>
1621
1991
  <td>${{escapeHtml(item.priority)}}</td>
1622
- <td>${{escapeHtml(item.kind_label || item.kind)}}</td>
1623
- <td>
1624
- <strong>${{escapeHtml(item.reference)}}</strong><br/>
1625
- <span class="muted">${{escapeHtml(sourceContent)}}</span>
1626
- </td>
1627
- <td>
1628
- <strong>${{escapeHtml(item.target_reference)}}</strong><br/>
1629
- <span class="muted">${{escapeHtml(targetContent)}}</span>
1630
- </td>
1631
1992
  <td>
1632
- <strong>${{escapeHtml(item.summary || '')}}</strong><br/>
1633
- <span class="muted">${{escapeHtml(item.relationship || '')}}${{item.signal ? ` • signal ${{item.signal}}` : ''}}</span>
1993
+ <strong>${{escapeHtml(summaryBits || 'Governance review item')}}</strong><br/>
1994
+ <span class="muted">${{escapeHtml(reviewText)}}</span>
1634
1995
  </td>
1635
1996
  <td>
1636
1997
  <button type="button" data-review-id="${{escapeHtml(item.review_id)}}" data-approved="true" ${{disabled}}>Approve</button>
@@ -1675,10 +2036,10 @@ def dashboard() -> HTMLResponse:
1675
2036
  async function refreshGovernanceReview() {{
1676
2037
  reviewErrorEl.textContent = '';
1677
2038
  try {{
1678
- const res = await fetch('/memory/governance/review', {{
2039
+ const res = await fetch('/memory/governance/review/summary', {{
1679
2040
  method: 'POST',
1680
2041
  headers: {{ 'Content-Type': 'application/json' }},
1681
- body: JSON.stringify({{ limit: 100, context_depth: 1 }}),
2042
+ body: JSON.stringify({{ limit: 20, context_depth: 0, scan_limit: 250 }}),
1682
2043
  }});
1683
2044
  const data = await res.json();
1684
2045
  if (!res.ok || !data.ok) {{
@@ -1689,7 +2050,7 @@ def dashboard() -> HTMLResponse:
1689
2050
  renderReviewTable();
1690
2051
  }} catch (error) {{
1691
2052
  reviewErrorEl.textContent = error instanceof Error ? error.message : String(error);
1692
- reviewTableBodyEl.innerHTML = '<tr><td colspan="6" class="muted">Unable to load review items.</td></tr>';
2053
+ reviewTableBodyEl.innerHTML = '<tr><td colspan="3" class="muted">Unable to load review items.</td></tr>';
1693
2054
  }}
1694
2055
  }}
1695
2056