@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.
@@ -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
- return _post_json(endpoint, payload, stop_event=stop_event)
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(stop_event: Optional[threading.Event] = None) -> None:
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 = (Path.home() / ".openclaw" / "workspace" / "memory" / "transcripts").expanduser().resolve()
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 = (Path.home() / ".openclaw" / "agents" / "main" / "sessions").expanduser().resolve()
339
+ session_target = _default_session_target()
271
340
 
272
- current_file: Optional[Path] = None
273
- position = 0
274
- current_line_number = 0
275
- session_file: Optional[Path] = None
276
- session_pos = 0
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
- position = 0
370
- current_line_number = 0
371
- if start_at_end:
372
- try:
373
- position = current_file.stat().st_size
374
- except Exception:
375
- position = 0
376
- try:
377
- current_line_number = _count_lines(current_file)
378
- except Exception:
379
- current_line_number = 0
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
- session_pos = 0
475
- if start_at_end:
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(turn_endpoint, turn_payload, stop_event=stopper):
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
- now = time.time()
592
- if transcript_buffer and (now - last_transcript_flush) >= batch_seconds:
593
- ok = _flush_buffer(
594
- transcript_buffer,
595
- source_label=source,
596
- transcript_path=transcript_last_path,
597
- timestamp=transcript_last_timestamp,
598
- start_line=transcript_start_line,
599
- end_line=transcript_end_line,
600
- stop_event=stopper,
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
- return
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simbimbo/memory-ocmemog",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Advanced OpenClaw memory plugin with durable recall, transcript-backed continuity, and sidecar APIs",
5
5
  "license": "MIT",
6
6
  "repository": {