@simbimbo/memory-ocmemog 0.1.14 → 0.1.16
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 +18 -0
- package/README.md +18 -8
- package/index.ts +215 -14
- package/ocmemog/__init__.py +1 -1
- package/ocmemog/runtime/memory/conversation_state.py +138 -32
- package/ocmemog/runtime/memory/retrieval.py +135 -6
- package/ocmemog/sidecar/app.py +249 -13
- package/ocmemog/sidecar/transcript_watcher.py +191 -61
- package/package.json +1 -1
- package/scripts/ocmemog-hydrate-stress.py +628 -0
- package/scripts/ocmemog-release-check.sh +35 -1
- package/scripts/ocmemog-sidecar.sh +24 -2
- package/scripts/ocmemog-test-rig.py +15 -1
- package/scripts/ocmemog-transcript-append.py +17 -2
|
@@ -13,6 +13,36 @@ from urllib import request as urlrequest
|
|
|
13
13
|
from ocmemog.runtime import state_store
|
|
14
14
|
|
|
15
15
|
DEFAULT_ENDPOINT = "http://127.0.0.1:17891/memory/ingest_async"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_openclaw_home() -> Path:
|
|
19
|
+
explicit = os.environ.get("OPENCLAW_HOME", "").strip() or os.environ.get("OCMEMOG_OPENCLAW_HOME", "").strip()
|
|
20
|
+
if explicit:
|
|
21
|
+
return Path(explicit).expanduser().resolve()
|
|
22
|
+
xdg = os.environ.get("XDG_DATA_HOME", "").strip()
|
|
23
|
+
if xdg:
|
|
24
|
+
return (Path(xdg).expanduser() / "openclaw").resolve()
|
|
25
|
+
if os.name == "nt":
|
|
26
|
+
appdata = os.environ.get("APPDATA", "").strip() or os.environ.get("LOCALAPPDATA", "").strip()
|
|
27
|
+
if appdata:
|
|
28
|
+
return (Path(appdata).expanduser() / "OpenClaw").resolve()
|
|
29
|
+
return (Path.home() / ".openclaw").resolve()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _default_transcript_target() -> Path:
|
|
33
|
+
home = _default_openclaw_home()
|
|
34
|
+
legacy = (Path.home() / ".openclaw" / "workspace" / "memory" / "transcripts").resolve()
|
|
35
|
+
if home == legacy.parent.parent.parent:
|
|
36
|
+
return legacy
|
|
37
|
+
return home / "workspace" / "memory" / "transcripts"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default_session_target() -> Path:
|
|
41
|
+
home = _default_openclaw_home()
|
|
42
|
+
legacy = (Path.home() / ".openclaw" / "agents" / "main" / "sessions").resolve()
|
|
43
|
+
if home == legacy.parent.parent.parent:
|
|
44
|
+
return legacy
|
|
45
|
+
return home / "agents" / "main" / "sessions"
|
|
16
46
|
DEFAULT_GLOB = "*.log"
|
|
17
47
|
DEFAULT_SESSION_GLOB = "*.jsonl"
|
|
18
48
|
DEFAULT_REINFORCE_POSITIVE = [
|
|
@@ -36,6 +66,7 @@ DEFAULT_REINFORCE_NEGATIVE = [
|
|
|
36
66
|
"frustrated",
|
|
37
67
|
]
|
|
38
68
|
WATCHER_ERROR_LOG = state_store.reports_dir() / "ocmemog_transcript_watcher_errors.jsonl"
|
|
69
|
+
WATCHER_CURSOR_PATH = state_store.data_dir() / "transcript_watcher_cursor.json"
|
|
39
70
|
_SHUTDOWN_TRACE = os.environ.get("OCMEMOG_SHUTDOWN_TIMING", "true").lower() in {"1", "true", "yes", "on"}
|
|
40
71
|
_WATCHER_REQUEST_TIMEOUT_SECONDS = 10.0
|
|
41
72
|
_WATCHER_SHUTDOWN_REQUEST_TIMEOUT_SECONDS = 1.0
|
|
@@ -144,7 +175,22 @@ def _post_json(endpoint: str, payload: dict, *, stop_event: threading.Event | No
|
|
|
144
175
|
|
|
145
176
|
|
|
146
177
|
def _post_turn(endpoint: str, payload: dict, *, stop_event: threading.Event | None = None) -> bool:
|
|
147
|
-
|
|
178
|
+
started = time.perf_counter()
|
|
179
|
+
ok = _post_json(endpoint, payload, stop_event=stop_event)
|
|
180
|
+
elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
|
|
181
|
+
trace_turn = str(os.environ.get("OCMEMOG_TRACE_WATCHER_TURN", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
182
|
+
warn_ms_raw = os.environ.get("OCMEMOG_TRACE_WATCHER_TURN_WARN_MS", "20").strip()
|
|
183
|
+
try:
|
|
184
|
+
warn_ms = max(0.0, float(warn_ms_raw))
|
|
185
|
+
except Exception:
|
|
186
|
+
warn_ms = 20.0
|
|
187
|
+
if trace_turn or elapsed_ms >= warn_ms:
|
|
188
|
+
print(
|
|
189
|
+
"[ocmemog][watcher] post_turn "
|
|
190
|
+
f"elapsed_ms={elapsed_ms:.3f} ok={ok} role={payload.get('role')} session_id={payload.get('session_id') or '-'}",
|
|
191
|
+
file=sys.stderr,
|
|
192
|
+
)
|
|
193
|
+
return ok
|
|
148
194
|
|
|
149
195
|
|
|
150
196
|
def _extract_user_text(text: str) -> str:
|
|
@@ -216,6 +262,26 @@ def _count_lines(path: Path) -> int:
|
|
|
216
262
|
return sum(1 for _ in handle)
|
|
217
263
|
|
|
218
264
|
|
|
265
|
+
def _load_cursor_state() -> dict:
|
|
266
|
+
try:
|
|
267
|
+
raw = WATCHER_CURSOR_PATH.read_text(encoding="utf-8")
|
|
268
|
+
payload = json.loads(raw)
|
|
269
|
+
return payload if isinstance(payload, dict) else {}
|
|
270
|
+
except Exception:
|
|
271
|
+
return {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _save_cursor_state(payload: dict) -> None:
|
|
276
|
+
try:
|
|
277
|
+
WATCHER_CURSOR_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
278
|
+
temp = WATCHER_CURSOR_PATH.with_suffix(".tmp")
|
|
279
|
+
temp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
280
|
+
temp.replace(WATCHER_CURSOR_PATH)
|
|
281
|
+
except Exception:
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
|
|
219
285
|
|
|
220
286
|
def _append_transcript(transcript_target: Path, timestamp: str, role: str, text: str) -> tuple[Path, int]:
|
|
221
287
|
if transcript_target.suffix:
|
|
@@ -231,7 +297,10 @@ def _append_transcript(transcript_target: Path, timestamp: str, role: str, text:
|
|
|
231
297
|
return path, line_no
|
|
232
298
|
|
|
233
299
|
|
|
234
|
-
def watch_forever(
|
|
300
|
+
def watch_forever(
|
|
301
|
+
stop_event: Optional[threading.Event] = None,
|
|
302
|
+
turn_ingest_callable: Callable[[dict], bool] | None = None,
|
|
303
|
+
) -> None:
|
|
235
304
|
global _WATCHER_STOP_EVENT
|
|
236
305
|
transcript_path = os.environ.get("OCMEMOG_TRANSCRIPT_PATH", "").strip()
|
|
237
306
|
transcript_dir = os.environ.get("OCMEMOG_TRANSCRIPT_DIR", "").strip()
|
|
@@ -262,18 +331,22 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
262
331
|
if transcript_path or transcript_dir:
|
|
263
332
|
transcript_target = Path(transcript_path or transcript_dir).expanduser().resolve()
|
|
264
333
|
else:
|
|
265
|
-
transcript_target = (
|
|
334
|
+
transcript_target = _default_transcript_target()
|
|
266
335
|
|
|
267
336
|
if session_dir:
|
|
268
337
|
session_target = Path(session_dir).expanduser().resolve()
|
|
269
338
|
else:
|
|
270
|
-
session_target = (
|
|
339
|
+
session_target = _default_session_target()
|
|
271
340
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
341
|
+
cursor_state = _load_cursor_state()
|
|
342
|
+
transcript_cursor = cursor_state.get("transcript") if isinstance(cursor_state.get("transcript"), dict) else {}
|
|
343
|
+
session_cursor = cursor_state.get("session") if isinstance(cursor_state.get("session"), dict) else {}
|
|
344
|
+
|
|
345
|
+
current_file: Optional[Path] = Path(str(transcript_cursor.get("path"))).resolve() if transcript_cursor.get("path") else None
|
|
346
|
+
position = int(transcript_cursor.get("position") or 0)
|
|
347
|
+
current_line_number = int(transcript_cursor.get("line_number") or 0)
|
|
348
|
+
session_file: Optional[Path] = Path(str(session_cursor.get("path"))).resolve() if session_cursor.get("path") else None
|
|
349
|
+
session_pos = int(session_cursor.get("position") or 0)
|
|
277
350
|
|
|
278
351
|
transcript_buffer: list[str] = []
|
|
279
352
|
session_buffer: list[str] = []
|
|
@@ -289,6 +362,8 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
289
362
|
pending_session_turns: dict[tuple[str, int], dict[str, object]] = {}
|
|
290
363
|
last_transcript_flush = time.time()
|
|
291
364
|
last_session_flush = time.time()
|
|
365
|
+
cursor_dirty = False
|
|
366
|
+
last_cursor_flush = 0.0
|
|
292
367
|
stopper: threading.Event
|
|
293
368
|
if isinstance(stop_event, threading.Event):
|
|
294
369
|
stopper = stop_event
|
|
@@ -297,6 +372,30 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
297
372
|
stopper.clear()
|
|
298
373
|
_WATCHER_STOP_EVENT = stopper
|
|
299
374
|
|
|
375
|
+
def _persist_cursors(*, force: bool = False) -> None:
|
|
376
|
+
nonlocal cursor_dirty, last_cursor_flush
|
|
377
|
+
now = time.time()
|
|
378
|
+
if not force and not cursor_dirty:
|
|
379
|
+
return
|
|
380
|
+
if not force and (now - last_cursor_flush) < cursor_flush_seconds:
|
|
381
|
+
return
|
|
382
|
+
_save_cursor_state(
|
|
383
|
+
{
|
|
384
|
+
"transcript": {
|
|
385
|
+
"path": str(current_file) if current_file is not None else None,
|
|
386
|
+
"position": position,
|
|
387
|
+
"line_number": current_line_number,
|
|
388
|
+
},
|
|
389
|
+
"session": {
|
|
390
|
+
"path": str(session_file) if session_file is not None else None,
|
|
391
|
+
"position": session_pos,
|
|
392
|
+
},
|
|
393
|
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
394
|
+
}
|
|
395
|
+
)
|
|
396
|
+
cursor_dirty = False
|
|
397
|
+
last_cursor_flush = now
|
|
398
|
+
|
|
300
399
|
def _flush_buffer(
|
|
301
400
|
buffer: list[str],
|
|
302
401
|
*,
|
|
@@ -328,6 +427,7 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
328
427
|
ok = _post_ingest(endpoint, payload, stop_event=stop_event)
|
|
329
428
|
if ok:
|
|
330
429
|
buffer.clear()
|
|
430
|
+
_persist_cursors()
|
|
331
431
|
return ok
|
|
332
432
|
|
|
333
433
|
def _maybe_reinforce(text: str, timestamp: str) -> None:
|
|
@@ -366,17 +466,23 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
366
466
|
if latest is not None:
|
|
367
467
|
if current_file is None or latest != current_file:
|
|
368
468
|
current_file = latest
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
469
|
+
reuse_cursor = transcript_cursor.get("path") and Path(str(transcript_cursor.get("path"))).resolve() == current_file
|
|
470
|
+
if reuse_cursor:
|
|
471
|
+
position = int(transcript_cursor.get("position") or 0)
|
|
472
|
+
current_line_number = int(transcript_cursor.get("line_number") or 0)
|
|
473
|
+
else:
|
|
474
|
+
position = 0
|
|
475
|
+
current_line_number = 0
|
|
476
|
+
if start_at_end:
|
|
477
|
+
try:
|
|
478
|
+
position = current_file.stat().st_size
|
|
479
|
+
except Exception:
|
|
480
|
+
position = 0
|
|
481
|
+
try:
|
|
482
|
+
current_line_number = _count_lines(current_file)
|
|
483
|
+
except Exception:
|
|
484
|
+
current_line_number = 0
|
|
485
|
+
_persist_cursors()
|
|
380
486
|
|
|
381
487
|
try:
|
|
382
488
|
with current_file.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
@@ -399,6 +505,7 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
399
505
|
committed_line_number = next_line_number
|
|
400
506
|
position = committed_position
|
|
401
507
|
current_line_number = committed_line_number
|
|
508
|
+
_persist_cursors()
|
|
402
509
|
continue
|
|
403
510
|
current_marker = (str(current_file), next_line_number)
|
|
404
511
|
if current_marker in recent_session_transcript_lines:
|
|
@@ -406,6 +513,7 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
406
513
|
committed_line_number = next_line_number
|
|
407
514
|
position = committed_position
|
|
408
515
|
current_line_number = committed_line_number
|
|
516
|
+
_persist_cursors()
|
|
409
517
|
continue
|
|
410
518
|
transcript_buffer.append(text)
|
|
411
519
|
transcript_last_path = current_file
|
|
@@ -463,20 +571,27 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
463
571
|
committed_line_number = next_line_number
|
|
464
572
|
position = committed_position
|
|
465
573
|
current_line_number = committed_line_number
|
|
574
|
+
_persist_cursors()
|
|
466
575
|
except Exception:
|
|
467
576
|
pass
|
|
468
577
|
|
|
469
578
|
# 2) Watch OpenClaw session jsonl (verbatim capture)
|
|
470
579
|
session_latest = _pick_latest(session_target, session_glob)
|
|
580
|
+
debug_session = str(os.environ.get("OCMEMOG_TRACE_WATCHER_SESSION_STATE", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
471
581
|
if session_latest is not None:
|
|
472
582
|
if session_file is None or session_latest != session_file:
|
|
473
583
|
session_file = session_latest
|
|
474
|
-
|
|
475
|
-
if
|
|
584
|
+
reuse_session_cursor = session_cursor.get("path") and Path(str(session_cursor.get("path"))).resolve() == session_file
|
|
585
|
+
if reuse_session_cursor:
|
|
586
|
+
session_pos = int(session_cursor.get("position") or 0)
|
|
587
|
+
else:
|
|
588
|
+
session_pos = 0
|
|
476
589
|
try:
|
|
477
590
|
session_pos = session_file.stat().st_size
|
|
478
591
|
except Exception:
|
|
479
592
|
session_pos = 0
|
|
593
|
+
cursor_dirty = True
|
|
594
|
+
_persist_cursors(force=True)
|
|
480
595
|
try:
|
|
481
596
|
with session_file.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
482
597
|
handle.seek(session_pos)
|
|
@@ -488,22 +603,26 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
488
603
|
line = handle.readline()
|
|
489
604
|
if not line:
|
|
490
605
|
session_pos = committed_session_pos
|
|
606
|
+
_persist_cursors()
|
|
491
607
|
break
|
|
492
608
|
try:
|
|
493
609
|
entry = json.loads(line)
|
|
494
610
|
except Exception:
|
|
495
611
|
committed_session_pos = handle.tell()
|
|
496
612
|
session_pos = committed_session_pos
|
|
613
|
+
_persist_cursors()
|
|
497
614
|
continue
|
|
498
615
|
if entry.get("type") != "message":
|
|
499
616
|
committed_session_pos = handle.tell()
|
|
500
617
|
session_pos = committed_session_pos
|
|
618
|
+
_persist_cursors()
|
|
501
619
|
continue
|
|
502
620
|
msg = entry.get("message") or {}
|
|
503
621
|
role = msg.get("role")
|
|
504
622
|
if role not in {"user", "assistant"}:
|
|
505
623
|
committed_session_pos = handle.tell()
|
|
506
624
|
session_pos = committed_session_pos
|
|
625
|
+
_persist_cursors()
|
|
507
626
|
continue
|
|
508
627
|
content = msg.get("content")
|
|
509
628
|
text = _extract_message_text(content).strip()
|
|
@@ -514,6 +633,7 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
514
633
|
if not text:
|
|
515
634
|
committed_session_pos = handle.tell()
|
|
516
635
|
session_pos = committed_session_pos
|
|
636
|
+
_persist_cursors()
|
|
517
637
|
continue
|
|
518
638
|
timestamp = entry.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
519
639
|
if role == "user":
|
|
@@ -556,8 +676,14 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
556
676
|
transcript_line_no = int(pending["transcript_line_no"])
|
|
557
677
|
if stopper.is_set():
|
|
558
678
|
break
|
|
559
|
-
if not _post_turn(
|
|
679
|
+
if not _post_turn(
|
|
680
|
+
turn_endpoint,
|
|
681
|
+
turn_payload,
|
|
682
|
+
stop_event=stopper,
|
|
683
|
+
turn_ingest_callable=turn_ingest_callable,
|
|
684
|
+
):
|
|
560
685
|
session_pos = line_start
|
|
686
|
+
_persist_cursors()
|
|
561
687
|
break
|
|
562
688
|
pending_session_turns.pop(retry_key, None)
|
|
563
689
|
recent_session_transcript_lines.append((str(transcript_path), transcript_line_no))
|
|
@@ -585,49 +711,53 @@ def watch_forever(stop_event: Optional[threading.Event] = None) -> None:
|
|
|
585
711
|
last_session_flush = time.time()
|
|
586
712
|
committed_session_pos = handle.tell()
|
|
587
713
|
session_pos = committed_session_pos
|
|
714
|
+
cursor_dirty = True
|
|
715
|
+
_persist_cursors()
|
|
588
716
|
except Exception:
|
|
589
717
|
pass
|
|
590
718
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
)
|
|
602
|
-
if ok:
|
|
603
|
-
transcript_start_line = None
|
|
604
|
-
transcript_end_line = None
|
|
605
|
-
last_transcript_flush = now
|
|
606
|
-
if session_buffer and (now - last_session_flush) >= batch_seconds:
|
|
607
|
-
ok = _flush_buffer(
|
|
608
|
-
session_buffer,
|
|
609
|
-
source_label="session",
|
|
610
|
-
transcript_path=session_last_path,
|
|
611
|
-
timestamp=session_last_timestamp,
|
|
612
|
-
start_line=session_start_line,
|
|
613
|
-
end_line=session_end_line,
|
|
614
|
-
stop_event=stopper,
|
|
615
|
-
)
|
|
616
|
-
if ok:
|
|
617
|
-
session_start_line = None
|
|
618
|
-
session_end_line = None
|
|
619
|
-
last_session_flush = now
|
|
620
|
-
|
|
621
|
-
poll_started = time.perf_counter()
|
|
622
|
-
if stopper.wait(poll_seconds):
|
|
623
|
-
if _SHUTDOWN_TRACE:
|
|
624
|
-
print(
|
|
625
|
-
f"[ocmemog][watcher-poll] stop_wait timeout={poll_seconds:.3f}s elapsed={time.perf_counter()-poll_started:.3f}s",
|
|
626
|
-
file=sys.stderr,
|
|
719
|
+
now = time.time()
|
|
720
|
+
if transcript_buffer and (now - last_transcript_flush) >= batch_seconds:
|
|
721
|
+
ok = _flush_buffer(
|
|
722
|
+
transcript_buffer,
|
|
723
|
+
source_label=source,
|
|
724
|
+
transcript_path=transcript_last_path,
|
|
725
|
+
timestamp=transcript_last_timestamp,
|
|
726
|
+
start_line=transcript_start_line,
|
|
727
|
+
end_line=transcript_end_line,
|
|
728
|
+
stop_event=stopper,
|
|
627
729
|
)
|
|
628
|
-
|
|
730
|
+
if ok:
|
|
731
|
+
transcript_start_line = None
|
|
732
|
+
transcript_end_line = None
|
|
733
|
+
last_transcript_flush = now
|
|
734
|
+
if session_buffer and (now - last_session_flush) >= batch_seconds:
|
|
735
|
+
ok = _flush_buffer(
|
|
736
|
+
session_buffer,
|
|
737
|
+
source_label="session",
|
|
738
|
+
transcript_path=session_last_path,
|
|
739
|
+
timestamp=session_last_timestamp,
|
|
740
|
+
start_line=session_start_line,
|
|
741
|
+
end_line=session_end_line,
|
|
742
|
+
stop_event=stopper,
|
|
743
|
+
)
|
|
744
|
+
if ok:
|
|
745
|
+
session_start_line = None
|
|
746
|
+
session_end_line = None
|
|
747
|
+
last_session_flush = now
|
|
748
|
+
|
|
749
|
+
_persist_cursors()
|
|
750
|
+
poll_started = time.perf_counter()
|
|
751
|
+
if stopper.wait(poll_seconds):
|
|
752
|
+
if _SHUTDOWN_TRACE:
|
|
753
|
+
print(
|
|
754
|
+
f"[ocmemog][watcher-poll] stop_wait timeout={poll_seconds:.3f}s elapsed={time.perf_counter()-poll_started:.3f}s",
|
|
755
|
+
file=sys.stderr,
|
|
756
|
+
)
|
|
757
|
+
break
|
|
758
|
+
|
|
629
759
|
finally:
|
|
760
|
+
_persist_cursors(force=True)
|
|
630
761
|
_WATCHER_STOP_EVENT = None
|
|
631
762
|
if _SHUTDOWN_TRACE:
|
|
632
763
|
print("[ocmemog][watcher] shutdown loop exiting", file=sys.stderr)
|
|
633
|
-
# no return value
|
package/package.json
CHANGED