@simbimbo/memory-ocmemog 0.1.4

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 (81) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/LICENSE +21 -0
  3. package/README.md +223 -0
  4. package/brain/__init__.py +1 -0
  5. package/brain/runtime/__init__.py +13 -0
  6. package/brain/runtime/config.py +21 -0
  7. package/brain/runtime/inference.py +83 -0
  8. package/brain/runtime/instrumentation.py +17 -0
  9. package/brain/runtime/memory/__init__.py +13 -0
  10. package/brain/runtime/memory/api.py +152 -0
  11. package/brain/runtime/memory/artifacts.py +33 -0
  12. package/brain/runtime/memory/candidate.py +89 -0
  13. package/brain/runtime/memory/context_builder.py +87 -0
  14. package/brain/runtime/memory/conversation_state.py +1825 -0
  15. package/brain/runtime/memory/distill.py +198 -0
  16. package/brain/runtime/memory/embedding_engine.py +94 -0
  17. package/brain/runtime/memory/freshness.py +91 -0
  18. package/brain/runtime/memory/health.py +42 -0
  19. package/brain/runtime/memory/integrity.py +170 -0
  20. package/brain/runtime/memory/interaction_memory.py +57 -0
  21. package/brain/runtime/memory/memory_consolidation.py +60 -0
  22. package/brain/runtime/memory/memory_gate.py +38 -0
  23. package/brain/runtime/memory/memory_graph.py +54 -0
  24. package/brain/runtime/memory/memory_links.py +109 -0
  25. package/brain/runtime/memory/memory_salience.py +235 -0
  26. package/brain/runtime/memory/memory_synthesis.py +33 -0
  27. package/brain/runtime/memory/memory_taxonomy.py +35 -0
  28. package/brain/runtime/memory/person_identity.py +83 -0
  29. package/brain/runtime/memory/person_memory.py +138 -0
  30. package/brain/runtime/memory/pondering_engine.py +577 -0
  31. package/brain/runtime/memory/promote.py +237 -0
  32. package/brain/runtime/memory/provenance.py +356 -0
  33. package/brain/runtime/memory/reinforcement.py +73 -0
  34. package/brain/runtime/memory/retrieval.py +153 -0
  35. package/brain/runtime/memory/semantic_search.py +66 -0
  36. package/brain/runtime/memory/sentiment_memory.py +67 -0
  37. package/brain/runtime/memory/store.py +400 -0
  38. package/brain/runtime/memory/tool_catalog.py +68 -0
  39. package/brain/runtime/memory/unresolved_state.py +93 -0
  40. package/brain/runtime/memory/vector_index.py +270 -0
  41. package/brain/runtime/model_roles.py +11 -0
  42. package/brain/runtime/model_router.py +22 -0
  43. package/brain/runtime/providers.py +59 -0
  44. package/brain/runtime/security/__init__.py +3 -0
  45. package/brain/runtime/security/redaction.py +14 -0
  46. package/brain/runtime/state_store.py +25 -0
  47. package/brain/runtime/storage_paths.py +41 -0
  48. package/docs/architecture/memory.md +118 -0
  49. package/docs/release-checklist.md +34 -0
  50. package/docs/reports/ocmemog-code-audit-2026-03-14.md +155 -0
  51. package/docs/usage.md +223 -0
  52. package/index.ts +726 -0
  53. package/ocmemog/__init__.py +1 -0
  54. package/ocmemog/sidecar/__init__.py +1 -0
  55. package/ocmemog/sidecar/app.py +1068 -0
  56. package/ocmemog/sidecar/compat.py +74 -0
  57. package/ocmemog/sidecar/transcript_watcher.py +425 -0
  58. package/openclaw.plugin.json +18 -0
  59. package/package.json +60 -0
  60. package/scripts/install-ocmemog.sh +277 -0
  61. package/scripts/launchagents/com.openclaw.ocmemog.guard.plist +22 -0
  62. package/scripts/launchagents/com.openclaw.ocmemog.ponder.plist +22 -0
  63. package/scripts/launchagents/com.openclaw.ocmemog.sidecar.plist +27 -0
  64. package/scripts/ocmemog-context.sh +15 -0
  65. package/scripts/ocmemog-continuity-benchmark.py +178 -0
  66. package/scripts/ocmemog-demo.py +122 -0
  67. package/scripts/ocmemog-failover-test.sh +17 -0
  68. package/scripts/ocmemog-guard.sh +11 -0
  69. package/scripts/ocmemog-install.sh +93 -0
  70. package/scripts/ocmemog-load-test.py +106 -0
  71. package/scripts/ocmemog-ponder.sh +30 -0
  72. package/scripts/ocmemog-recall-test.py +58 -0
  73. package/scripts/ocmemog-reindex-vectors.py +14 -0
  74. package/scripts/ocmemog-reliability-soak.py +177 -0
  75. package/scripts/ocmemog-sidecar.sh +46 -0
  76. package/scripts/ocmemog-soak-report.py +58 -0
  77. package/scripts/ocmemog-soak-test.py +44 -0
  78. package/scripts/ocmemog-test-rig.py +345 -0
  79. package/scripts/ocmemog-transcript-append.py +45 -0
  80. package/scripts/ocmemog-transcript-watcher.py +8 -0
  81. package/scripts/ocmemog-transcript-watcher.sh +7 -0
@@ -0,0 +1,1068 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Iterable, List, Optional
9
+
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
12
+ from pydantic import BaseModel, Field
13
+ from datetime import datetime, timedelta
14
+
15
+ from brain.runtime import state_store
16
+ from brain.runtime.memory import api, conversation_state, distill, health, memory_links, pondering_engine, provenance, reinforcement, retrieval, store
17
+ from ocmemog.sidecar.compat import flatten_results, probe_runtime
18
+ from ocmemog.sidecar.transcript_watcher import watch_forever
19
+
20
+ DEFAULT_CATEGORIES = ("knowledge", "reflections", "directives", "tasks", "runbooks", "lessons")
21
+
22
+ app = FastAPI(title="ocmemog sidecar", version="0.0.1")
23
+
24
+ API_TOKEN = os.environ.get("OCMEMOG_API_TOKEN")
25
+
26
+ QUEUE_LOCK = threading.Lock()
27
+ QUEUE_PROCESS_LOCK = threading.Lock()
28
+ QUEUE_STATS = {
29
+ "last_run": None,
30
+ "processed": 0,
31
+ "errors": 0,
32
+ "last_error": None,
33
+ "last_batch": 0,
34
+ }
35
+
36
+
37
+ @app.middleware("http")
38
+ async def _auth_middleware(request: Request, call_next):
39
+ if API_TOKEN:
40
+ header = request.headers.get("x-ocmemog-token") or request.headers.get("authorization", "")
41
+ token = header.replace("Bearer ", "") if header else ""
42
+ if token != API_TOKEN:
43
+ return JSONResponse(status_code=401, content={"ok": False, "error": "unauthorized"})
44
+ return await call_next(request)
45
+
46
+
47
+ @app.on_event("startup")
48
+ def _start_transcript_watcher() -> None:
49
+ enabled = os.environ.get("OCMEMOG_TRANSCRIPT_WATCHER", "").lower() in {"1", "true", "yes"}
50
+ if not enabled:
51
+ return
52
+ thread = threading.Thread(target=watch_forever, daemon=True)
53
+ thread.start()
54
+
55
+
56
+ def _queue_path() -> Path:
57
+ path = state_store.data_dir() / "ingest_queue.jsonl"
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ if not path.exists():
60
+ path.write_text("")
61
+ return path
62
+
63
+
64
+ def _queue_depth() -> int:
65
+ path = _queue_path()
66
+ try:
67
+ with path.open("r", encoding="utf-8", errors="ignore") as handle:
68
+ return sum(1 for line in handle if line.strip())
69
+ except Exception:
70
+ return 0
71
+
72
+
73
+ def _write_queue_lines(lines: List[str]) -> None:
74
+ path = _queue_path()
75
+ temp = path.with_suffix(".jsonl.tmp")
76
+ with temp.open("w", encoding="utf-8") as handle:
77
+ for line in lines:
78
+ if line.strip():
79
+ handle.write(line.rstrip("\n") + "\n")
80
+ handle.flush()
81
+ os.fsync(handle.fileno())
82
+ temp.replace(path)
83
+
84
+
85
+ def _read_queue_lines() -> List[str]:
86
+ path = _queue_path()
87
+ try:
88
+ with path.open("r", encoding="utf-8", errors="ignore") as handle:
89
+ return [line.rstrip("\n") for line in handle if line.strip()]
90
+ except Exception:
91
+ return []
92
+
93
+
94
+ def _peek_queue_batch(limit: Optional[int] = None) -> tuple[List[tuple[int, Dict[str, Any]]], int]:
95
+ lines = _read_queue_lines()
96
+ if not lines:
97
+ return [], 0
98
+ batch: List[tuple[int, Dict[str, Any]]] = []
99
+ consumed_lines = 0
100
+ max_items = limit if limit is not None and limit > 0 else len(lines)
101
+ for line in lines:
102
+ if len(batch) >= max_items:
103
+ break
104
+ consumed_lines += 1
105
+ try:
106
+ batch.append((consumed_lines, json.loads(line)))
107
+ except Exception:
108
+ QUEUE_STATS["errors"] += 1
109
+ QUEUE_STATS["last_error"] = "invalid_queue_payload"
110
+ return batch, consumed_lines
111
+
112
+
113
+ def _ack_queue_batch(consumed_lines: int) -> None:
114
+ if consumed_lines <= 0:
115
+ return
116
+ with QUEUE_LOCK:
117
+ lines = _read_queue_lines()
118
+ _write_queue_lines(lines[consumed_lines:])
119
+
120
+
121
+ def _enqueue_payload(payload: Dict[str, Any]) -> int:
122
+ path = _queue_path()
123
+ line = json.dumps(payload, ensure_ascii=False)
124
+ with QUEUE_LOCK:
125
+ with path.open("a", encoding="utf-8") as handle:
126
+ handle.write(line + "\n")
127
+ handle.flush()
128
+ os.fsync(handle.fileno())
129
+ return _queue_depth()
130
+
131
+
132
+
133
+ def _process_queue(limit: Optional[int] = None) -> Dict[str, Any]:
134
+ processed = 0
135
+ errors = 0
136
+ last_error = None
137
+ batch_limit = limit if limit is not None and limit > 0 else None
138
+
139
+ with QUEUE_PROCESS_LOCK:
140
+ while True:
141
+ remaining_budget = None
142
+ if batch_limit is not None:
143
+ remaining_budget = batch_limit - processed
144
+ if remaining_budget <= 0:
145
+ break
146
+ batch, consumed_lines = _peek_queue_batch(remaining_budget)
147
+ if consumed_lines <= 0:
148
+ break
149
+
150
+ batch_processed = 0
151
+ acknowledged = 0
152
+ for line_no, payload in batch:
153
+ try:
154
+ req = IngestRequest(**payload)
155
+ _ingest_request(req)
156
+ processed += 1
157
+ batch_processed += 1
158
+ acknowledged = line_no
159
+ except Exception as exc:
160
+ errors += 1
161
+ last_error = str(exc)
162
+ break
163
+
164
+ if not errors:
165
+ acknowledged = consumed_lines
166
+ _ack_queue_batch(acknowledged)
167
+
168
+ if errors:
169
+ break
170
+ if not batch:
171
+ break
172
+
173
+ QUEUE_STATS["last_run"] = time.strftime("%Y-%m-%d %H:%M:%S")
174
+ QUEUE_STATS["last_batch"] = processed
175
+ QUEUE_STATS["processed"] += processed
176
+ if errors:
177
+ QUEUE_STATS["errors"] += errors
178
+ if last_error:
179
+ QUEUE_STATS["last_error"] = last_error
180
+ return {"processed": processed, "errors": errors, "last_error": last_error}
181
+
182
+
183
+
184
+ def _ingest_worker() -> None:
185
+ enabled = os.environ.get("OCMEMOG_INGEST_ASYNC_WORKER", "true").lower() in {"1", "true", "yes"}
186
+ if not enabled:
187
+ return
188
+ poll_seconds = float(os.environ.get("OCMEMOG_INGEST_ASYNC_POLL_SECONDS", "5"))
189
+ batch_max = int(os.environ.get("OCMEMOG_INGEST_ASYNC_BATCH_MAX", "25"))
190
+
191
+ while True:
192
+ _process_queue(batch_max)
193
+ time.sleep(poll_seconds)
194
+
195
+
196
+
197
+ def _drain_queue(limit: Optional[int] = None) -> Dict[str, Any]:
198
+ return _process_queue(limit)
199
+
200
+
201
+ @app.on_event("startup")
202
+ def _start_ingest_worker() -> None:
203
+ thread = threading.Thread(target=_ingest_worker, daemon=True)
204
+ thread.start()
205
+
206
+
207
+ class SearchRequest(BaseModel):
208
+ query: str = Field(default="")
209
+ limit: int = Field(default=5, ge=1, le=50)
210
+ categories: Optional[List[str]] = None
211
+
212
+
213
+ class GetRequest(BaseModel):
214
+ reference: str
215
+
216
+
217
+ class ContextRequest(BaseModel):
218
+ reference: str
219
+ radius: int = Field(default=10, ge=0, le=200)
220
+
221
+
222
+ class RecentRequest(BaseModel):
223
+ categories: Optional[List[str]] = Field(default=None, description="Filter by memory categories")
224
+ limit: int = Field(default=12, ge=1, le=100, description="Maximum items per category")
225
+ hours: Optional[int] = Field(default=36, ge=1, le=168, description="Lookback window in hours")
226
+
227
+
228
+ class PonderRequest(BaseModel):
229
+ max_items: int = Field(default=5, ge=1, le=50)
230
+
231
+
232
+ class IngestRequest(BaseModel):
233
+ content: str
234
+ kind: str = Field(default="experience", description="experience or memory")
235
+ memory_type: Optional[str] = Field(default=None, description="knowledge|reflections|directives|tasks|runbooks|lessons")
236
+ source: Optional[str] = None
237
+ task_id: Optional[str] = None
238
+ conversation_id: Optional[str] = None
239
+ session_id: Optional[str] = None
240
+ thread_id: Optional[str] = None
241
+ message_id: Optional[str] = None
242
+ role: Optional[str] = None
243
+ source_reference: Optional[str] = None
244
+ source_references: Optional[List[str]] = None
245
+ source_label: Optional[str] = None
246
+ source_labels: Optional[List[str]] = None
247
+ transcript_path: Optional[str] = None
248
+ transcript_offset: Optional[int] = None
249
+ transcript_end_offset: Optional[int] = None
250
+ timestamp: Optional[str] = None
251
+
252
+
253
+ class ConversationTurnRequest(BaseModel):
254
+ role: str
255
+ content: str
256
+ conversation_id: Optional[str] = None
257
+ session_id: Optional[str] = None
258
+ thread_id: Optional[str] = None
259
+ message_id: Optional[str] = None
260
+ source: Optional[str] = None
261
+ transcript_path: Optional[str] = None
262
+ transcript_offset: Optional[int] = None
263
+ transcript_end_offset: Optional[int] = None
264
+ timestamp: Optional[str] = None
265
+ metadata: Optional[Dict[str, Any]] = None
266
+
267
+
268
+ class ConversationHydrateRequest(BaseModel):
269
+ conversation_id: Optional[str] = None
270
+ session_id: Optional[str] = None
271
+ thread_id: Optional[str] = None
272
+ turns_limit: int = Field(default=12, ge=1, le=100)
273
+ memory_limit: int = Field(default=8, ge=1, le=50)
274
+
275
+
276
+ class ConversationCheckpointRequest(BaseModel):
277
+ conversation_id: Optional[str] = None
278
+ session_id: Optional[str] = None
279
+ thread_id: Optional[str] = None
280
+ upto_turn_id: Optional[int] = None
281
+ turns_limit: int = Field(default=24, ge=1, le=200)
282
+ checkpoint_kind: str = Field(default="manual")
283
+
284
+
285
+ class ConversationCheckpointListRequest(BaseModel):
286
+ conversation_id: Optional[str] = None
287
+ session_id: Optional[str] = None
288
+ thread_id: Optional[str] = None
289
+ limit: int = Field(default=20, ge=1, le=100)
290
+
291
+
292
+ class ConversationCheckpointExpandRequest(BaseModel):
293
+ checkpoint_id: int = Field(ge=1)
294
+ radius_turns: int = Field(default=0, ge=0, le=25)
295
+ turns_limit: int = Field(default=100, ge=1, le=300)
296
+
297
+
298
+ class ConversationTurnExpandRequest(BaseModel):
299
+ turn_id: int = Field(ge=1)
300
+ radius_turns: int = Field(default=4, ge=0, le=25)
301
+ turns_limit: int = Field(default=80, ge=1, le=300)
302
+
303
+
304
+ class DistillRequest(BaseModel):
305
+ limit: int = Field(default=10, ge=1, le=100)
306
+
307
+
308
+ class ReinforceRequest(BaseModel):
309
+ task_id: str
310
+ outcome: str
311
+ reward_score: float = Field(default=1.0, ge=0.0, le=1.0)
312
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
313
+ memory_reference: str = Field(default="feedback")
314
+ experience_type: str = Field(default="reinforcement")
315
+ source_module: str = Field(default="sidecar")
316
+ note: Optional[str] = None
317
+
318
+
319
+ def _fetch_recent(category: str, limit: int, since: Optional[str]) -> List[Dict[str, Any]]:
320
+ conn = store.connect()
321
+ items: List[Dict[str, Any]] = []
322
+ try:
323
+ if since:
324
+ rows = conn.execute(
325
+ f"SELECT id, content, metadata_json, timestamp FROM {category} WHERE timestamp >= ? ORDER BY timestamp DESC LIMIT ?",
326
+ (since, limit),
327
+ ).fetchall()
328
+ else:
329
+ rows = conn.execute(
330
+ f"SELECT id, content, metadata_json, timestamp FROM {category} ORDER BY timestamp DESC LIMIT ?",
331
+ (limit,),
332
+ ).fetchall()
333
+ for row in rows:
334
+ try:
335
+ meta = json.loads(row["metadata_json"] or "{}")
336
+ except Exception:
337
+ meta = {}
338
+ items.append({
339
+ "reference": f"{category}:{row['id']}",
340
+ "timestamp": row["timestamp"],
341
+ "content": row["content"],
342
+ "metadata": meta,
343
+ })
344
+ finally:
345
+ conn.close()
346
+ return items
347
+
348
+
349
+ def _normalize_categories(categories: Optional[Iterable[str]]) -> List[str]:
350
+ selected = [item for item in (categories or DEFAULT_CATEGORIES) if item in DEFAULT_CATEGORIES]
351
+ return selected or list(DEFAULT_CATEGORIES)
352
+
353
+
354
+ def _runtime_payload() -> Dict[str, Any]:
355
+ status = probe_runtime()
356
+ return {
357
+ "mode": status.mode,
358
+ "missingDeps": status.missing_deps,
359
+ "todo": status.todo,
360
+ "warnings": status.warnings,
361
+ }
362
+
363
+
364
+ def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict[str, Any]]:
365
+ conn = store.connect()
366
+ try:
367
+ results: List[Dict[str, Any]] = []
368
+ for table in categories:
369
+ rows = conn.execute(
370
+ f"SELECT id, content, confidence FROM {table} WHERE content LIKE ? ORDER BY id DESC LIMIT ?",
371
+ (f"%{query}%", limit),
372
+ ).fetchall()
373
+ for row in rows:
374
+ results.append(
375
+ {
376
+ "bucket": table,
377
+ "reference": f"{table}:{row['id']}",
378
+ "table": table,
379
+ "id": str(row["id"]),
380
+ "content": str(row["content"] or ""),
381
+ "score": float(row["confidence"] or 0.0),
382
+ "links": [],
383
+ }
384
+ )
385
+ results.sort(key=lambda item: item["score"], reverse=True)
386
+ return results[:limit]
387
+ finally:
388
+ conn.close()
389
+
390
+
391
+ def _get_row(reference: str) -> Optional[Dict[str, Any]]:
392
+ return provenance.hydrate_reference(reference, depth=2)
393
+
394
+
395
+ def _ingest_conversation_turn(request: ConversationTurnRequest) -> dict[str, Any]:
396
+ runtime = _runtime_payload()
397
+ try:
398
+ turn_id = conversation_state.record_turn(
399
+ role=request.role,
400
+ content=request.content,
401
+ conversation_id=request.conversation_id,
402
+ session_id=request.session_id,
403
+ thread_id=request.thread_id,
404
+ message_id=request.message_id,
405
+ transcript_path=request.transcript_path,
406
+ transcript_offset=request.transcript_offset,
407
+ transcript_end_offset=request.transcript_end_offset,
408
+ source=request.source,
409
+ timestamp=request.timestamp,
410
+ metadata=request.metadata,
411
+ )
412
+ except ValueError as exc:
413
+ if str(exc) == "internal_continuity_turn":
414
+ return {
415
+ "ok": True,
416
+ "ignored": True,
417
+ "reason": "internal_continuity_turn",
418
+ **runtime,
419
+ }
420
+ raise
421
+ return {
422
+ "ok": True,
423
+ "turn_id": turn_id,
424
+ "reference": f"conversation_turns:{turn_id}",
425
+ **runtime,
426
+ }
427
+
428
+
429
+ def _parse_transcript_target(target: str) -> tuple[Path, Optional[int], Optional[int]] | None:
430
+ if not target.startswith("transcript:"):
431
+ return None
432
+ raw = target[len("transcript:"):]
433
+ line_start: Optional[int] = None
434
+ line_end: Optional[int] = None
435
+ if "#L" in raw:
436
+ path_str, anchor = raw.split("#L", 1)
437
+ if "-L" in anchor:
438
+ start_str, end_str = anchor.split("-L", 1)
439
+ try:
440
+ line_start = int(start_str)
441
+ except Exception:
442
+ line_start = None
443
+ try:
444
+ line_end = int(end_str)
445
+ except Exception:
446
+ line_end = None
447
+ else:
448
+ try:
449
+ line_start = int(anchor)
450
+ except Exception:
451
+ line_start = None
452
+ else:
453
+ path_str = raw
454
+ path = Path(path_str).expanduser()
455
+ return (path, line_start, line_end)
456
+
457
+
458
+ def _allowed_transcript_roots() -> list[Path]:
459
+ raw = os.environ.get("OCMEMOG_TRANSCRIPT_ROOTS")
460
+ if raw:
461
+ roots = [Path(item).expanduser().resolve() for item in raw.split(",") if item.strip()]
462
+ else:
463
+ roots = [Path.home() / ".openclaw" / "workspace" / "memory"]
464
+ return roots
465
+
466
+
467
+ def _read_transcript_snippet(path: Path, line_start: Optional[int], line_end: Optional[int], radius: int) -> Dict[str, Any]:
468
+ path = path.expanduser().resolve()
469
+ allowed = _allowed_transcript_roots()
470
+ if not any(path.is_relative_to(root) for root in allowed):
471
+ return {"ok": False, "error": "transcript_path_not_allowed", "path": str(path)}
472
+ if not path.exists() or not path.is_file():
473
+ return {"ok": False, "error": "missing_transcript", "path": str(path)}
474
+
475
+ anchor_start = line_start if line_start and line_start > 0 else None
476
+ anchor_end = line_end if line_end and line_end > 0 else anchor_start
477
+ if anchor_start is not None and anchor_end is not None and anchor_end < anchor_start:
478
+ anchor_end = anchor_start
479
+
480
+ if anchor_start is None:
481
+ start_line = 1
482
+ end_line = max(1, radius)
483
+ else:
484
+ start_line = max(1, anchor_start - radius)
485
+ end_line = max(anchor_end or anchor_start, anchor_start) + radius
486
+
487
+ snippet_lines = []
488
+ with path.open("r", encoding="utf-8", errors="ignore") as handle:
489
+ for idx, line in enumerate(handle, start=1):
490
+ if idx < start_line:
491
+ continue
492
+ if idx > end_line:
493
+ break
494
+ snippet_lines.append(line.rstrip("\n"))
495
+
496
+ if not snippet_lines:
497
+ return {"ok": False, "error": "empty_transcript", "path": str(path)}
498
+
499
+ return {
500
+ "ok": True,
501
+ "path": str(path),
502
+ "start_line": start_line,
503
+ "end_line": start_line + len(snippet_lines) - 1,
504
+ "anchor_start_line": anchor_start,
505
+ "anchor_end_line": anchor_end,
506
+ "snippet": "\n".join(snippet_lines),
507
+ }
508
+
509
+
510
+ @app.get("/healthz")
511
+ def healthz() -> dict[str, Any]:
512
+ payload = _runtime_payload()
513
+ payload["ok"] = True
514
+ return payload
515
+
516
+
517
+ @app.post("/memory/search")
518
+ def memory_search(request: SearchRequest) -> dict[str, Any]:
519
+ categories = _normalize_categories(request.categories)
520
+ runtime = _runtime_payload()
521
+ try:
522
+ results = retrieval.retrieve_for_queries([request.query], limit=request.limit, categories=categories)
523
+ flattened = flatten_results(results)
524
+ used_fallback = False
525
+ except Exception as exc:
526
+ flattened = _fallback_search(request.query, request.limit, categories)
527
+ used_fallback = True
528
+ runtime["warnings"] = [*runtime["warnings"], f"search fallback enabled: {exc}"]
529
+
530
+ return {
531
+ "ok": True,
532
+ "query": request.query,
533
+ "limit": request.limit,
534
+ "categories": categories,
535
+ "results": flattened,
536
+ "usedFallback": used_fallback,
537
+ **runtime,
538
+ }
539
+
540
+
541
+ @app.post("/memory/get")
542
+ def memory_get(request: GetRequest) -> dict[str, Any]:
543
+ runtime = _runtime_payload()
544
+ row = _get_row(request.reference)
545
+ if row is None:
546
+ return {
547
+ "ok": False,
548
+ "error": "TODO: memory reference was not found or is not yet supported by the sidecar.",
549
+ "reference": request.reference,
550
+ **runtime,
551
+ }
552
+
553
+ return {
554
+ "ok": True,
555
+ "reference": request.reference,
556
+ "memory": row,
557
+ **runtime,
558
+ }
559
+
560
+
561
+ @app.post("/memory/context")
562
+ def memory_context(request: ContextRequest) -> dict[str, Any]:
563
+ runtime = _runtime_payload()
564
+ links = memory_links.get_memory_links(request.reference)
565
+ transcript = None
566
+ for link in links:
567
+ target = link.get("target_reference", "")
568
+ parsed = _parse_transcript_target(target)
569
+ if parsed:
570
+ path, line_start, line_end = parsed
571
+ transcript = _read_transcript_snippet(path, line_start, line_end, request.radius)
572
+ break
573
+ return {
574
+ "ok": True,
575
+ "reference": request.reference,
576
+ "links": links,
577
+ "transcript": transcript,
578
+ "provenance": provenance.hydrate_reference(request.reference, depth=2),
579
+ **runtime,
580
+ }
581
+
582
+
583
+ @app.post("/memory/recent")
584
+ def memory_recent(request: RecentRequest) -> dict[str, Any]:
585
+ runtime = _runtime_payload()
586
+ categories = _normalize_categories(request.categories)
587
+ since = None
588
+ if request.hours:
589
+ since = (datetime.utcnow() - timedelta(hours=request.hours)).strftime("%Y-%m-%d %H:%M:%S")
590
+ results = {category: _fetch_recent(category, request.limit, since) for category in categories}
591
+ return {
592
+ "ok": True,
593
+ "categories": categories,
594
+ "since": since,
595
+ "limit": request.limit,
596
+ "results": results,
597
+ **runtime,
598
+ }
599
+
600
+
601
+ @app.post("/conversation/ingest_turn")
602
+ def conversation_ingest_turn(request: ConversationTurnRequest) -> dict[str, Any]:
603
+ return _ingest_conversation_turn(request)
604
+
605
+
606
+ @app.post("/conversation/hydrate")
607
+ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
608
+ runtime = _runtime_payload()
609
+ turns = conversation_state.get_recent_turns(
610
+ conversation_id=request.conversation_id,
611
+ session_id=request.session_id,
612
+ thread_id=request.thread_id,
613
+ limit=request.turns_limit,
614
+ )
615
+ linked_memories = conversation_state.get_linked_memories(
616
+ conversation_id=request.conversation_id,
617
+ session_id=request.session_id,
618
+ thread_id=request.thread_id,
619
+ limit=request.memory_limit,
620
+ )
621
+ link_targets: List[Dict[str, Any]] = []
622
+ if request.thread_id:
623
+ link_targets.extend(memory_links.get_memory_links_for_thread(request.thread_id))
624
+ if request.session_id:
625
+ link_targets.extend(memory_links.get_memory_links_for_session(request.session_id))
626
+ if request.conversation_id:
627
+ link_targets.extend(memory_links.get_memory_links_for_conversation(request.conversation_id))
628
+ latest_checkpoint = conversation_state.get_latest_checkpoint(
629
+ conversation_id=request.conversation_id,
630
+ session_id=request.session_id,
631
+ thread_id=request.thread_id,
632
+ )
633
+ unresolved_items = conversation_state.list_relevant_unresolved_state(
634
+ conversation_id=request.conversation_id,
635
+ session_id=request.session_id,
636
+ thread_id=request.thread_id,
637
+ limit=10,
638
+ )
639
+ summary = conversation_state.infer_hydration_payload(
640
+ turns,
641
+ conversation_id=request.conversation_id,
642
+ session_id=request.session_id,
643
+ thread_id=request.thread_id,
644
+ unresolved_items=unresolved_items,
645
+ latest_checkpoint=latest_checkpoint,
646
+ linked_memories=linked_memories,
647
+ )
648
+ state_payload = conversation_state.refresh_state(
649
+ conversation_id=request.conversation_id,
650
+ session_id=request.session_id,
651
+ thread_id=request.thread_id,
652
+ tolerate_write_failure=True,
653
+ )
654
+ state_meta = (state_payload or {}).get("metadata") if isinstance((state_payload or {}).get("metadata"), dict) else {}
655
+ state_status = str(state_meta.get("state_status") or "")
656
+ if state_status == "stale_persisted":
657
+ runtime["warnings"] = [*runtime["warnings"], "hydrate returned persisted state while state refresh was delayed"]
658
+ elif state_status == "derived_not_persisted":
659
+ runtime["warnings"] = [*runtime["warnings"], "hydrate returned derived state while state refresh was delayed"]
660
+ return {
661
+ "ok": True,
662
+ "conversation_id": request.conversation_id,
663
+ "session_id": request.session_id,
664
+ "thread_id": request.thread_id,
665
+ "recent_turns": turns,
666
+ "summary": summary,
667
+ "turn_counts": conversation_state.get_turn_counts(
668
+ conversation_id=request.conversation_id,
669
+ session_id=request.session_id,
670
+ thread_id=request.thread_id,
671
+ ),
672
+ "linked_memories": linked_memories,
673
+ "linked_references": link_targets,
674
+ "checkpoint_graph": summary.get("checkpoint_graph"),
675
+ "active_branch": summary.get("active_branch"),
676
+ "state": state_payload,
677
+ **runtime,
678
+ }
679
+
680
+
681
+ @app.post("/conversation/checkpoint")
682
+ def conversation_checkpoint(request: ConversationCheckpointRequest) -> dict[str, Any]:
683
+ runtime = _runtime_payload()
684
+ checkpoint = conversation_state.create_checkpoint(
685
+ conversation_id=request.conversation_id,
686
+ session_id=request.session_id,
687
+ thread_id=request.thread_id,
688
+ upto_turn_id=request.upto_turn_id,
689
+ turns_limit=request.turns_limit,
690
+ checkpoint_kind=request.checkpoint_kind,
691
+ )
692
+ if checkpoint is None:
693
+ return {"ok": False, "error": "no_turns_available", **runtime}
694
+ return {"ok": True, "checkpoint": checkpoint, **runtime}
695
+
696
+
697
+ @app.post("/conversation/checkpoints")
698
+ def conversation_checkpoints(request: ConversationCheckpointListRequest) -> dict[str, Any]:
699
+ runtime = _runtime_payload()
700
+ checkpoints = conversation_state.list_checkpoints(
701
+ conversation_id=request.conversation_id,
702
+ session_id=request.session_id,
703
+ thread_id=request.thread_id,
704
+ limit=request.limit,
705
+ )
706
+ return {
707
+ "ok": True,
708
+ "conversation_id": request.conversation_id,
709
+ "session_id": request.session_id,
710
+ "thread_id": request.thread_id,
711
+ "checkpoints": checkpoints,
712
+ **runtime,
713
+ }
714
+
715
+
716
+ @app.post("/conversation/checkpoint_expand")
717
+ def conversation_checkpoint_expand(request: ConversationCheckpointExpandRequest) -> dict[str, Any]:
718
+ runtime = _runtime_payload()
719
+ expanded = conversation_state.expand_checkpoint(
720
+ request.checkpoint_id,
721
+ radius_turns=request.radius_turns,
722
+ turns_limit=request.turns_limit,
723
+ )
724
+ if expanded is None:
725
+ return {"ok": False, "error": "checkpoint_not_found", "checkpoint_id": request.checkpoint_id, **runtime}
726
+ return {"ok": True, **expanded, **runtime}
727
+
728
+
729
+ @app.post("/conversation/turn_expand")
730
+ def conversation_turn_expand(request: ConversationTurnExpandRequest) -> dict[str, Any]:
731
+ runtime = _runtime_payload()
732
+ expanded = conversation_state.expand_turn(
733
+ request.turn_id,
734
+ radius_turns=request.radius_turns,
735
+ turns_limit=request.turns_limit,
736
+ )
737
+ if expanded is None:
738
+ return {"ok": False, "error": "turn_not_found", "turn_id": request.turn_id, **runtime}
739
+ return {"ok": True, **expanded, **runtime}
740
+
741
+
742
+ @app.post("/memory/ponder")
743
+ def memory_ponder(request: PonderRequest) -> dict[str, Any]:
744
+ runtime = _runtime_payload()
745
+ results = pondering_engine.run_ponder_cycle(max_items=request.max_items)
746
+ return {"ok": True, "results": results, **runtime}
747
+
748
+
749
+ @app.get("/memory/ponder/latest")
750
+ def memory_ponder_latest(limit: int = 5) -> dict[str, Any]:
751
+ runtime = _runtime_payload()
752
+ conn = store.connect()
753
+ rows = conn.execute(
754
+ "SELECT id, content, metadata_json, timestamp FROM reflections WHERE source='ponder' ORDER BY id DESC LIMIT ?",
755
+ (min(max(limit, 1), 20),),
756
+ ).fetchall()
757
+ conn.close()
758
+ items = []
759
+ for row in rows:
760
+ try:
761
+ meta = json.loads(row["metadata_json"] or "{}")
762
+ except Exception:
763
+ meta = {}
764
+ items.append({
765
+ "reference": f"reflections:{row['id']}",
766
+ "timestamp": row["timestamp"],
767
+ "summary": row["content"],
768
+ "recommendation": meta.get("recommendation"),
769
+ "source_reference": meta.get("source_reference") or ((meta.get("provenance") or {}).get("source_reference") if isinstance(meta.get("provenance"), dict) else None),
770
+ "provenance": provenance.preview_from_metadata(meta),
771
+ })
772
+ return {"ok": True, "items": items, **runtime}
773
+
774
+
775
+ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
776
+ runtime = _runtime_payload()
777
+ content = request.content.strip() if isinstance(request.content, str) else ""
778
+ if not content:
779
+ return {"ok": False, "error": "empty_content", **runtime}
780
+
781
+ kind = (request.kind or "experience").lower()
782
+ if kind == "memory":
783
+ memory_type = (request.memory_type or "knowledge").lower()
784
+ allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
785
+ if memory_type not in allowed:
786
+ memory_type = "knowledge"
787
+ metadata = {
788
+ "conversation_id": request.conversation_id,
789
+ "session_id": request.session_id,
790
+ "thread_id": request.thread_id,
791
+ "message_id": request.message_id,
792
+ "role": request.role,
793
+ "source_reference": request.source_reference,
794
+ "source_references": request.source_references,
795
+ "source_label": request.source_label,
796
+ "source_labels": request.source_labels,
797
+ "transcript_path": request.transcript_path,
798
+ "transcript_offset": request.transcript_offset,
799
+ "transcript_end_offset": request.transcript_end_offset,
800
+ "derived_via": "ingest",
801
+ }
802
+ memory_id = api.store_memory(
803
+ memory_type,
804
+ content,
805
+ source=request.source,
806
+ metadata=metadata,
807
+ timestamp=request.timestamp,
808
+ )
809
+ reference = f"{memory_type}:{memory_id}"
810
+ if request.conversation_id:
811
+ memory_links.add_memory_link(reference, "conversation", f"conversation:{request.conversation_id}")
812
+ if request.session_id:
813
+ memory_links.add_memory_link(reference, "session", f"session:{request.session_id}")
814
+ if request.thread_id:
815
+ memory_links.add_memory_link(reference, "thread", f"thread:{request.thread_id}")
816
+ if request.message_id:
817
+ memory_links.add_memory_link(reference, "message", f"message:{request.message_id}")
818
+ if request.transcript_path:
819
+ if request.transcript_offset and request.transcript_end_offset and request.transcript_end_offset >= request.transcript_offset:
820
+ suffix = f"#L{request.transcript_offset}-L{request.transcript_end_offset}"
821
+ elif request.transcript_offset:
822
+ suffix = f"#L{request.transcript_offset}"
823
+ else:
824
+ suffix = ""
825
+ memory_links.add_memory_link(reference, "transcript", f"transcript:{request.transcript_path}{suffix}")
826
+ if request.role:
827
+ turn_response = _ingest_conversation_turn(
828
+ ConversationTurnRequest(
829
+ role=request.role,
830
+ content=content,
831
+ conversation_id=request.conversation_id,
832
+ session_id=request.session_id,
833
+ thread_id=request.thread_id,
834
+ message_id=request.message_id,
835
+ source=request.source,
836
+ transcript_path=request.transcript_path,
837
+ transcript_offset=request.transcript_offset,
838
+ transcript_end_offset=request.transcript_end_offset,
839
+ timestamp=request.timestamp,
840
+ metadata={"memory_reference": reference},
841
+ )
842
+ )
843
+ else:
844
+ turn_response = None
845
+ if turn_response and turn_response.get("reference"):
846
+ provenance.update_memory_metadata(
847
+ reference,
848
+ {
849
+ "source_references": [
850
+ *([request.source_reference] if request.source_reference else []),
851
+ *(request.source_references or []),
852
+ str(turn_response.get("reference") or ""),
853
+ ]
854
+ },
855
+ )
856
+ return {"ok": True, "kind": "memory", "memory_type": memory_type, "reference": reference, "turn": turn_response, **runtime}
857
+
858
+ # experience ingest
859
+ experience_metadata = provenance.normalize_metadata(
860
+ {
861
+ "conversation_id": request.conversation_id,
862
+ "session_id": request.session_id,
863
+ "thread_id": request.thread_id,
864
+ "message_id": request.message_id,
865
+ "role": request.role,
866
+ "source_reference": request.source_reference,
867
+ "source_references": request.source_references,
868
+ "source_label": request.source_label,
869
+ "source_labels": request.source_labels,
870
+ "transcript_path": request.transcript_path,
871
+ "transcript_offset": request.transcript_offset,
872
+ "transcript_end_offset": request.transcript_end_offset,
873
+ "task_id": request.task_id,
874
+ "derived_via": "ingest",
875
+ },
876
+ source=request.source or "sidecar",
877
+ )
878
+
879
+ def _write_experience() -> None:
880
+ conn = store.connect()
881
+ try:
882
+ conn.execute(
883
+ "INSERT INTO experiences (task_id, outcome, reward_score, confidence, experience_type, source_module, metadata_json, schema_version) "
884
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
885
+ (
886
+ request.task_id,
887
+ content,
888
+ None,
889
+ 1.0,
890
+ "ingest",
891
+ request.source or "sidecar",
892
+ json.dumps(experience_metadata, ensure_ascii=False),
893
+ store.SCHEMA_VERSION,
894
+ ),
895
+ )
896
+ conn.commit()
897
+ finally:
898
+ conn.close()
899
+
900
+ store.submit_write(_write_experience, timeout=30.0)
901
+ return {"ok": True, "kind": "experience", **runtime}
902
+
903
+
904
+ @app.post("/memory/ingest")
905
+ def memory_ingest(request: IngestRequest) -> dict[str, Any]:
906
+ return _ingest_request(request)
907
+
908
+
909
+ @app.post("/memory/ingest_async")
910
+ def memory_ingest_async(request: IngestRequest) -> dict[str, Any]:
911
+ payload = request.dict()
912
+ payload["queued_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
913
+ depth = _enqueue_payload(payload)
914
+ return {"ok": True, "queued": True, "queueDepth": depth}
915
+
916
+
917
+ @app.get("/memory/ingest_status")
918
+ def memory_ingest_status() -> dict[str, Any]:
919
+ return {"ok": True, "queueDepth": _queue_depth(), **QUEUE_STATS}
920
+
921
+
922
+ @app.post("/memory/ingest_flush")
923
+ def memory_ingest_flush(limit: int = 0) -> dict[str, Any]:
924
+ stats = _drain_queue(limit if limit > 0 else None)
925
+ return {"ok": True, "queueDepth": _queue_depth(), **stats}
926
+
927
+
928
+ @app.post("/memory/reinforce")
929
+ def memory_reinforce(request: ReinforceRequest) -> dict[str, Any]:
930
+ runtime = _runtime_payload()
931
+ result = reinforcement.log_experience(
932
+ task_id=request.task_id,
933
+ outcome=request.outcome,
934
+ confidence=request.confidence,
935
+ reward_score=request.reward_score,
936
+ memory_reference=request.memory_reference,
937
+ experience_type=request.experience_type,
938
+ source_module=request.source_module,
939
+ )
940
+ if request.note:
941
+ api.record_reinforcement(request.task_id, request.outcome, request.note, source_module=request.source_module)
942
+ return {"ok": True, "result": result, **runtime}
943
+
944
+
945
+ @app.post("/memory/distill")
946
+ def memory_distill(request: DistillRequest) -> dict[str, Any]:
947
+ runtime = _runtime_payload()
948
+ results = distill.distill_experiences(limit=request.limit)
949
+ return {"ok": True, "count": len(results), "results": results, **runtime}
950
+
951
+
952
+ @app.get("/metrics")
953
+ def metrics() -> dict[str, Any]:
954
+ runtime = _runtime_payload()
955
+ payload = health.get_memory_health()
956
+ counts = payload.get("counts", {})
957
+ counts["queue_depth"] = _queue_depth()
958
+ counts["queue_processed"] = QUEUE_STATS.get("processed", 0)
959
+ counts["queue_errors"] = QUEUE_STATS.get("errors", 0)
960
+ payload["counts"] = counts
961
+ payload["queue"] = QUEUE_STATS
962
+ return {"ok": True, "metrics": payload, **runtime}
963
+
964
+
965
+ def _event_stream():
966
+ path = state_store.reports_dir() / "brain_memory.log.jsonl"
967
+ path.parent.mkdir(parents=True, exist_ok=True)
968
+ if not path.exists():
969
+ path.write_text("")
970
+ with path.open("r", encoding="utf-8", errors="ignore") as handle:
971
+ handle.seek(0, 2)
972
+ while True:
973
+ line = handle.readline()
974
+ if not line:
975
+ time.sleep(0.5)
976
+ continue
977
+ yield f"data: {line.strip()}\n\n"
978
+
979
+
980
+ @app.get("/events")
981
+ def events() -> StreamingResponse:
982
+ return StreamingResponse(_event_stream(), media_type="text/event-stream")
983
+
984
+
985
+ def _tail_events(limit: int = 50) -> str:
986
+ path = state_store.reports_dir() / "brain_memory.log.jsonl"
987
+ if not path.exists():
988
+ return ""
989
+ try:
990
+ lines = path.read_text(encoding="utf-8", errors="ignore").splitlines()
991
+ except Exception:
992
+ return ""
993
+ return "\n".join(lines[-limit:])
994
+
995
+
996
+ @app.get("/dashboard")
997
+ def dashboard() -> HTMLResponse:
998
+ metrics_payload = health.get_memory_health()
999
+ counts = metrics_payload.get("counts", {})
1000
+ metrics_html = "".join(
1001
+ f"<div class='card'><strong>{key}</strong><br/>{value}</div>" for key, value in counts.items()
1002
+ )
1003
+ events_html = _tail_events()
1004
+
1005
+ html = f"""
1006
+ <!doctype html>
1007
+ <html>
1008
+ <head>
1009
+ <meta charset='utf-8'/>
1010
+ <title>ocmemog realtime</title>
1011
+ <style>
1012
+ body {{ font-family: system-ui, sans-serif; padding: 20px; }}
1013
+ .metrics {{ display: flex; gap: 12px; flex-wrap: wrap; }}
1014
+ .card {{ border: 1px solid #ddd; padding: 10px 14px; border-radius: 8px; min-width: 140px; }}
1015
+ pre {{ background: #f7f7f7; padding: 10px; height: 320px; overflow: auto; }}
1016
+ </style>
1017
+ </head>
1018
+ <body>
1019
+ <h2>ocmemog realtime</h2>
1020
+ <div class="metrics" id="metrics">{metrics_html}</div>
1021
+ <h3>Ponder recommendations</h3>
1022
+ <div id="ponder-meta" style="margin-bottom:8px; color:#666;"></div>
1023
+ <div id="ponder"></div>
1024
+ <h3>Live events</h3>
1025
+ <pre id="events">{events_html}</pre>
1026
+ <script>
1027
+ const metricsEl = document.getElementById('metrics');
1028
+ const ponderEl = document.getElementById('ponder');
1029
+ const ponderMetaEl = document.getElementById('ponder-meta');
1030
+ const eventsEl = document.getElementById('events');
1031
+
1032
+ async function refreshMetrics() {{
1033
+ const res = await fetch('/metrics');
1034
+ const data = await res.json();
1035
+ const counts = data.metrics?.counts || {{}};
1036
+ metricsEl.innerHTML = Object.entries(counts).map(([k,v]) =>
1037
+ `<div class=\"card\"><strong>${{k}}</strong><br/>${{v}}</div>`
1038
+ ).join('');
1039
+ }}
1040
+
1041
+ async function refreshPonder() {{
1042
+ const res = await fetch('/memory/ponder/latest?limit=5');
1043
+ const data = await res.json();
1044
+ const items = data.items || [];
1045
+ const lastTs = items.length ? (items[0].timestamp || 'n/a') : 'n/a';
1046
+ const warnings = (data.warnings || []).join('; ');
1047
+ const mode = data.mode || 'n/a';
1048
+ ponderMetaEl.textContent = `Last update: ${{lastTs}} • Mode: ${{mode}}${{warnings ? ' • ' + warnings : ''}}`;
1049
+ ponderEl.innerHTML = items.map((item) =>
1050
+ `<div class=\"card\"><strong>${{item.summary}}</strong><br/><em>${{item.recommendation || ''}}</em><br/><small>${{item.timestamp || ''}} • ${{item.reference || ''}}</small></div>`
1051
+ ).join('');
1052
+ }}
1053
+
1054
+ refreshMetrics();
1055
+ refreshPonder();
1056
+ setInterval(refreshMetrics, 5000);
1057
+ setInterval(refreshPonder, 10000);
1058
+
1059
+ const es = new EventSource('/events');
1060
+ es.onmessage = (ev) => {{
1061
+ eventsEl.textContent += ev.data + "\\n";
1062
+ eventsEl.scrollTop = eventsEl.scrollHeight;
1063
+ }};
1064
+ </script>
1065
+ </body>
1066
+ </html>
1067
+ """
1068
+ return HTMLResponse(html)