@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.
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/brain/__init__.py +1 -0
- package/brain/runtime/__init__.py +13 -0
- package/brain/runtime/config.py +21 -0
- package/brain/runtime/inference.py +83 -0
- package/brain/runtime/instrumentation.py +17 -0
- package/brain/runtime/memory/__init__.py +13 -0
- package/brain/runtime/memory/api.py +152 -0
- package/brain/runtime/memory/artifacts.py +33 -0
- package/brain/runtime/memory/candidate.py +89 -0
- package/brain/runtime/memory/context_builder.py +87 -0
- package/brain/runtime/memory/conversation_state.py +1825 -0
- package/brain/runtime/memory/distill.py +198 -0
- package/brain/runtime/memory/embedding_engine.py +94 -0
- package/brain/runtime/memory/freshness.py +91 -0
- package/brain/runtime/memory/health.py +42 -0
- package/brain/runtime/memory/integrity.py +170 -0
- package/brain/runtime/memory/interaction_memory.py +57 -0
- package/brain/runtime/memory/memory_consolidation.py +60 -0
- package/brain/runtime/memory/memory_gate.py +38 -0
- package/brain/runtime/memory/memory_graph.py +54 -0
- package/brain/runtime/memory/memory_links.py +109 -0
- package/brain/runtime/memory/memory_salience.py +235 -0
- package/brain/runtime/memory/memory_synthesis.py +33 -0
- package/brain/runtime/memory/memory_taxonomy.py +35 -0
- package/brain/runtime/memory/person_identity.py +83 -0
- package/brain/runtime/memory/person_memory.py +138 -0
- package/brain/runtime/memory/pondering_engine.py +577 -0
- package/brain/runtime/memory/promote.py +237 -0
- package/brain/runtime/memory/provenance.py +356 -0
- package/brain/runtime/memory/reinforcement.py +73 -0
- package/brain/runtime/memory/retrieval.py +153 -0
- package/brain/runtime/memory/semantic_search.py +66 -0
- package/brain/runtime/memory/sentiment_memory.py +67 -0
- package/brain/runtime/memory/store.py +400 -0
- package/brain/runtime/memory/tool_catalog.py +68 -0
- package/brain/runtime/memory/unresolved_state.py +93 -0
- package/brain/runtime/memory/vector_index.py +270 -0
- package/brain/runtime/model_roles.py +11 -0
- package/brain/runtime/model_router.py +22 -0
- package/brain/runtime/providers.py +59 -0
- package/brain/runtime/security/__init__.py +3 -0
- package/brain/runtime/security/redaction.py +14 -0
- package/brain/runtime/state_store.py +25 -0
- package/brain/runtime/storage_paths.py +41 -0
- package/docs/architecture/memory.md +118 -0
- package/docs/release-checklist.md +34 -0
- package/docs/reports/ocmemog-code-audit-2026-03-14.md +155 -0
- package/docs/usage.md +223 -0
- package/index.ts +726 -0
- package/ocmemog/__init__.py +1 -0
- package/ocmemog/sidecar/__init__.py +1 -0
- package/ocmemog/sidecar/app.py +1068 -0
- package/ocmemog/sidecar/compat.py +74 -0
- package/ocmemog/sidecar/transcript_watcher.py +425 -0
- package/openclaw.plugin.json +18 -0
- package/package.json +60 -0
- package/scripts/install-ocmemog.sh +277 -0
- package/scripts/launchagents/com.openclaw.ocmemog.guard.plist +22 -0
- package/scripts/launchagents/com.openclaw.ocmemog.ponder.plist +22 -0
- package/scripts/launchagents/com.openclaw.ocmemog.sidecar.plist +27 -0
- package/scripts/ocmemog-context.sh +15 -0
- package/scripts/ocmemog-continuity-benchmark.py +178 -0
- package/scripts/ocmemog-demo.py +122 -0
- package/scripts/ocmemog-failover-test.sh +17 -0
- package/scripts/ocmemog-guard.sh +11 -0
- package/scripts/ocmemog-install.sh +93 -0
- package/scripts/ocmemog-load-test.py +106 -0
- package/scripts/ocmemog-ponder.sh +30 -0
- package/scripts/ocmemog-recall-test.py +58 -0
- package/scripts/ocmemog-reindex-vectors.py +14 -0
- package/scripts/ocmemog-reliability-soak.py +177 -0
- package/scripts/ocmemog-sidecar.sh +46 -0
- package/scripts/ocmemog-soak-report.py +58 -0
- package/scripts/ocmemog-soak-test.py +44 -0
- package/scripts/ocmemog-test-rig.py +345 -0
- package/scripts/ocmemog-transcript-append.py +45 -0
- package/scripts/ocmemog-transcript-watcher.py +8 -0
- 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)
|