@simbimbo/memory-ocmemog 0.1.10 → 0.1.11

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11 — 2026-03-20
4
+
5
+ Watcher reliability and release-quality follow-up.
6
+
7
+ ### Highlights
8
+ - prevented duplicate transcript/session turn ingestion in the watcher path
9
+ - propagated `OCMEMOG_API_TOKEN` auth headers on watcher HTTP posts
10
+ - restored persisted queue stats on sidecar startup
11
+ - added durable watcher error logging instead of silent failure swallowing
12
+ - preserved multi-part text content from session message arrays
13
+ - fixed transcript target handling for both directory mode and file mode
14
+ - hardened retry behavior so failed delivery does not silently drop buffered content and session retries preserve transcript provenance without duplicate transcript rows
15
+ - declared `pytest` as a test extra and refreshed release-facing docs/checklists for current validation flow
16
+
3
17
  ## 0.1.10 — 2026-03-19
4
18
 
5
19
  Release alignment follow-up.
package/README.md CHANGED
@@ -162,21 +162,23 @@ launchctl bootstrap gui/$UID scripts/launchagents/com.openclaw.ocmemog.guard.pli
162
162
 
163
163
  ## Recent changes
164
164
 
165
- ### 0.1.6 (current main)
165
+ ### 0.1.11 (current main)
166
166
 
167
- Package ownership + runtime safety release:
168
- - Publish package under `@simbimbo/memory-ocmemog` instead of the unauthorized `@openclaw` scope
169
- - Keep `memory-ocmemog` as the plugin id for OpenClaw config and enable flows
170
- - Make `before_message_write` ingest sync-safe for OpenClaw's synchronous hook contract
171
- - Default auto prompt hydration to opt-in via `OCMEMOG_AUTO_HYDRATION=true`
172
- - Preserve prior continuity self-healing and polluted-wrapper cleanup behavior
167
+ Current main includes the recent memory-quality, governance, and watcher hardening work, including:
168
+ - transcript watcher duplicate-ingest prevention
169
+ - watcher auth propagation for `OCMEMOG_API_TOKEN`
170
+ - persisted queue stats restored on startup
171
+ - durable watcher error logging
172
+ - multi-part text preservation for session content arrays
173
+ - retry-safe session/transcript handling with transcript provenance preserved
174
+ - pytest declared as a repo test extra and available in the local project venv
173
175
 
174
176
  ## Release prep / publish
175
177
 
176
- Current intended ClawHub publish command:
178
+ Example ClawHub publish command (update version + changelog first; do not reuse stale release text blindly):
177
179
 
178
180
  ```bash
179
- clawhub publish . --slug memory-ocmemog --name "ocmemog" --version 0.1.4 --changelog "Package ownership fix: publish under @simbimbo scope plus runtime safety hardening for sync-safe ingest and auto-hydration guard"
181
+ clawhub publish . --slug memory-ocmemog --name "ocmemog" --version <next-version> --changelog "<concise release summary>"
180
182
  ```
181
183
 
182
184
  ## Install from npm (after publish)
@@ -13,7 +13,7 @@ Use this checklist before publishing an ocmemog release.
13
13
  - [ ] `bash -n scripts/ocmemog-install.sh`
14
14
  - [ ] `./scripts/install-ocmemog.sh --help`
15
15
  - [ ] `./scripts/install-ocmemog.sh --dry-run`
16
- - [ ] `python -m unittest tests.test_regressions`
16
+ - [ ] `./.venv/bin/python -m pytest -q tests/test_regressions.py tests/test_governance_queue.py tests/test_promotion_governance_integration.py tests/test_hybrid_retrieval.py`
17
17
  - [ ] `npm pack --dry-run`
18
18
 
19
19
  ## Install flow
@@ -102,6 +102,7 @@ async def _auth_middleware(request: Request, call_next):
102
102
 
103
103
  @app.on_event("startup")
104
104
  def _start_transcript_watcher() -> None:
105
+ _load_queue_stats()
105
106
  enabled = os.environ.get("OCMEMOG_TRANSCRIPT_WATCHER", "").lower() in {"1", "true", "yes"}
106
107
  if not enabled:
107
108
  return
@@ -3,10 +3,13 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import time
6
+ from collections import deque
6
7
  from pathlib import Path
7
8
  from typing import Optional
8
9
  from urllib import request as urlrequest
9
10
 
11
+ from brain.runtime import state_store
12
+
10
13
  DEFAULT_ENDPOINT = "http://127.0.0.1:17891/memory/ingest_async"
11
14
  DEFAULT_GLOB = "*.log"
12
15
  DEFAULT_SESSION_GLOB = "*.jsonl"
@@ -30,6 +33,23 @@ DEFAULT_REINFORCE_NEGATIVE = [
30
33
  "disappointed",
31
34
  "frustrated",
32
35
  ]
36
+ WATCHER_ERROR_LOG = state_store.reports_dir() / "ocmemog_transcript_watcher_errors.jsonl"
37
+
38
+
39
+ def _log_watcher_error(kind: str, endpoint: str, payload: dict, exc: Exception) -> None:
40
+ try:
41
+ WATCHER_ERROR_LOG.parent.mkdir(parents=True, exist_ok=True)
42
+ with WATCHER_ERROR_LOG.open("a", encoding="utf-8") as handle:
43
+ handle.write(json.dumps({
44
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
45
+ "kind": kind,
46
+ "endpoint": endpoint,
47
+ "error_type": type(exc).__name__,
48
+ "error": str(exc),
49
+ "payload_preview": str(payload)[:500],
50
+ }, ensure_ascii=False) + "\n")
51
+ except Exception:
52
+ return
33
53
 
34
54
 
35
55
  def _pick_latest(path: Path, pattern: str) -> Optional[Path]:
@@ -41,30 +61,42 @@ def _pick_latest(path: Path, pattern: str) -> Optional[Path]:
41
61
  return files[-1] if files else None
42
62
 
43
63
 
44
- def _post_ingest(endpoint: str, payload: dict) -> None:
64
+ def _apply_auth_headers(req: urlrequest.Request) -> None:
65
+ token = os.environ.get("OCMEMOG_API_TOKEN", "").strip()
66
+ if token:
67
+ req.add_header("x-ocmemog-token", token)
68
+
69
+
70
+ def _post_ingest(endpoint: str, payload: dict) -> bool:
45
71
  data = json.dumps(payload).encode("utf-8")
46
72
  req = urlrequest.Request(endpoint, data=data, method="POST")
47
73
  req.add_header("Content-Type", "application/json")
74
+ _apply_auth_headers(req)
48
75
  try:
49
76
  with urlrequest.urlopen(req, timeout=10) as resp:
50
77
  resp.read()
51
- except Exception:
52
- return
78
+ return True
79
+ except Exception as exc:
80
+ _log_watcher_error("ingest", endpoint, payload, exc)
81
+ return False
53
82
 
54
83
 
55
- def _post_json(endpoint: str, payload: dict) -> None:
84
+ def _post_json(endpoint: str, payload: dict) -> bool:
56
85
  data = json.dumps(payload).encode("utf-8")
57
86
  req = urlrequest.Request(endpoint, data=data, method="POST")
58
87
  req.add_header("Content-Type", "application/json")
88
+ _apply_auth_headers(req)
59
89
  try:
60
90
  with urlrequest.urlopen(req, timeout=10) as resp:
61
91
  resp.read()
62
- except Exception:
63
- return
92
+ return True
93
+ except Exception as exc:
94
+ _log_watcher_error("json", endpoint, payload, exc)
95
+ return False
64
96
 
65
97
 
66
- def _post_turn(endpoint: str, payload: dict) -> None:
67
- _post_json(endpoint, payload)
98
+ def _post_turn(endpoint: str, payload: dict) -> bool:
99
+ return _post_json(endpoint, payload)
68
100
 
69
101
 
70
102
  def _extract_user_text(text: str) -> str:
@@ -101,6 +133,21 @@ def _extract_conversation_info(text: str) -> dict:
101
133
  return payload if isinstance(payload, dict) else {}
102
134
 
103
135
 
136
+ def _extract_message_text(content: object) -> str:
137
+ if isinstance(content, list):
138
+ parts: list[str] = []
139
+ for item in content:
140
+ if not isinstance(item, dict):
141
+ continue
142
+ if item.get("type") != "text":
143
+ continue
144
+ text = str(item.get("text") or "").strip()
145
+ if text:
146
+ parts.append(text)
147
+ return "\n".join(parts)
148
+ return str(content or "")
149
+
150
+
104
151
  def _parse_transcript_line(text: str) -> tuple[Optional[str], str]:
105
152
  stripped = text.strip()
106
153
  if not stripped:
@@ -122,10 +169,14 @@ def _count_lines(path: Path) -> int:
122
169
 
123
170
 
124
171
 
125
- def _append_transcript(transcripts_dir: Path, timestamp: str, role: str, text: str) -> tuple[Path, int]:
126
- date = timestamp.split("T")[0] if "T" in timestamp else time.strftime("%Y-%m-%d")
127
- path = transcripts_dir / f"{date}.log"
128
- transcripts_dir.mkdir(parents=True, exist_ok=True)
172
+ def _append_transcript(transcript_target: Path, timestamp: str, role: str, text: str) -> tuple[Path, int]:
173
+ if transcript_target.suffix:
174
+ path = transcript_target
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ else:
177
+ date = timestamp.split("T")[0] if "T" in timestamp else time.strftime("%Y-%m-%d")
178
+ path = transcript_target / f"{date}.log"
179
+ transcript_target.mkdir(parents=True, exist_ok=True)
129
180
  line_no = _count_lines(path) + 1
130
181
  with path.open("a", encoding="utf-8") as handle:
131
182
  handle.write(f"{timestamp} [{role}] {text}\n")
@@ -185,6 +236,8 @@ def watch_forever() -> None:
185
236
  transcript_end_line: Optional[int] = None
186
237
  session_start_line: Optional[int] = None
187
238
  session_end_line: Optional[int] = None
239
+ recent_session_transcript_lines: deque[tuple[str, int]] = deque(maxlen=max(batch_max * 8, 128))
240
+ pending_session_turns: dict[tuple[str, int], dict[str, object]] = {}
188
241
  last_transcript_flush = time.time()
189
242
  last_session_flush = time.time()
190
243
 
@@ -196,9 +249,9 @@ def watch_forever() -> None:
196
249
  timestamp: Optional[str],
197
250
  start_line: Optional[int],
198
251
  end_line: Optional[int],
199
- ) -> None:
252
+ ) -> bool:
200
253
  if not buffer:
201
- return
254
+ return True
202
255
  payload = {
203
256
  "content": "\n".join(buffer),
204
257
  "kind": kind,
@@ -213,8 +266,10 @@ def watch_forever() -> None:
213
266
  payload["transcript_end_offset"] = end_line
214
267
  if timestamp:
215
268
  payload["timestamp"] = timestamp.replace("T", " ")[:19]
216
- _post_ingest(endpoint, payload)
217
- buffer.clear()
269
+ ok = _post_ingest(endpoint, payload)
270
+ if ok:
271
+ buffer.clear()
272
+ return ok
218
273
 
219
274
  def _maybe_reinforce(text: str, timestamp: str) -> None:
220
275
  if not reinforce_enabled:
@@ -266,36 +321,64 @@ def watch_forever() -> None:
266
321
  try:
267
322
  with current_file.open("r", encoding="utf-8", errors="ignore") as handle:
268
323
  handle.seek(position)
269
- for line in handle:
324
+ committed_position = position
325
+ committed_line_number = current_line_number
326
+ while True:
327
+ line_start = handle.tell()
328
+ line = handle.readline()
329
+ if not line:
330
+ position = committed_position
331
+ current_line_number = committed_line_number
332
+ break
270
333
  text = line.rstrip("\n")
271
- current_line_number += 1
334
+ next_line_number = committed_line_number + 1
272
335
  if not text.strip():
336
+ committed_position = handle.tell()
337
+ committed_line_number = next_line_number
338
+ position = committed_position
339
+ current_line_number = committed_line_number
340
+ continue
341
+ current_marker = (str(current_file), next_line_number)
342
+ if current_marker in recent_session_transcript_lines:
343
+ committed_position = handle.tell()
344
+ committed_line_number = next_line_number
345
+ position = committed_position
346
+ current_line_number = committed_line_number
273
347
  continue
274
348
  transcript_buffer.append(text)
275
349
  transcript_last_path = current_file
276
350
  if transcript_start_line is None:
277
- transcript_start_line = current_line_number
278
- transcript_end_line = current_line_number
351
+ transcript_start_line = next_line_number
352
+ transcript_end_line = next_line_number
279
353
  timestamp_value = None
280
354
  if text and " " in text:
281
355
  timestamp_value = text.split(" ", 1)[0]
282
356
  transcript_last_timestamp = timestamp_value
283
357
  role, turn_text = _parse_transcript_line(text)
284
358
  if role and turn_text:
285
- _post_turn(
359
+ ok = _post_turn(
286
360
  turn_endpoint,
287
361
  {
288
362
  "role": role,
289
363
  "content": turn_text,
290
364
  "source": source,
291
365
  "transcript_path": str(current_file),
292
- "transcript_offset": current_line_number,
293
- "transcript_end_offset": current_line_number,
366
+ "transcript_offset": next_line_number,
367
+ "transcript_end_offset": next_line_number,
294
368
  "timestamp": timestamp_value.replace("T", " ")[:19] if timestamp_value else None,
295
369
  },
296
370
  )
371
+ if not ok:
372
+ if transcript_buffer:
373
+ transcript_buffer.pop()
374
+ if transcript_start_line == next_line_number:
375
+ transcript_start_line = None
376
+ transcript_end_line = committed_line_number if transcript_start_line is not None else None
377
+ position = line_start
378
+ current_line_number = committed_line_number
379
+ break
297
380
  if len(transcript_buffer) >= batch_max:
298
- _flush_buffer(
381
+ ok = _flush_buffer(
299
382
  transcript_buffer,
300
383
  source_label=source,
301
384
  transcript_path=transcript_last_path,
@@ -303,10 +386,17 @@ def watch_forever() -> None:
303
386
  start_line=transcript_start_line,
304
387
  end_line=transcript_end_line,
305
388
  )
389
+ if not ok:
390
+ position = line_start
391
+ current_line_number = committed_line_number
392
+ break
306
393
  transcript_start_line = None
307
394
  transcript_end_line = None
308
395
  last_transcript_flush = time.time()
309
- position = handle.tell()
396
+ committed_position = handle.tell()
397
+ committed_line_number = next_line_number
398
+ position = committed_position
399
+ current_line_number = committed_line_number
310
400
  except Exception:
311
401
  pass
312
402
 
@@ -324,40 +414,52 @@ def watch_forever() -> None:
324
414
  try:
325
415
  with session_file.open("r", encoding="utf-8", errors="ignore") as handle:
326
416
  handle.seek(session_pos)
327
- for line in handle:
417
+ committed_session_pos = session_pos
418
+ while True:
419
+ line_start = handle.tell()
420
+ line = handle.readline()
421
+ if not line:
422
+ session_pos = committed_session_pos
423
+ break
328
424
  try:
329
425
  entry = json.loads(line)
330
426
  except Exception:
427
+ committed_session_pos = handle.tell()
428
+ session_pos = committed_session_pos
331
429
  continue
332
430
  if entry.get("type") != "message":
431
+ committed_session_pos = handle.tell()
432
+ session_pos = committed_session_pos
333
433
  continue
334
434
  msg = entry.get("message") or {}
335
435
  role = msg.get("role")
336
436
  if role not in {"user", "assistant"}:
437
+ committed_session_pos = handle.tell()
438
+ session_pos = committed_session_pos
337
439
  continue
338
440
  content = msg.get("content")
339
- if isinstance(content, list):
340
- text = next((c.get("text") for c in content if c.get("type") == "text"), "")
341
- else:
342
- text = content or ""
343
- text = str(text).strip()
441
+ text = _extract_message_text(content).strip()
344
442
  conversation_info = _extract_conversation_info(text)
345
443
  if role == "user":
346
444
  text = _extract_user_text(text)
347
445
  text = text.replace("\n", " ").strip()
348
446
  if not text:
447
+ committed_session_pos = handle.tell()
448
+ session_pos = committed_session_pos
349
449
  continue
350
450
  timestamp = entry.get("timestamp") or time.strftime("%Y-%m-%dT%H:%M:%S")
351
451
  if role == "user":
352
452
  _maybe_reinforce(text, timestamp)
353
- transcript_path, transcript_line_no = _append_transcript(transcript_target, timestamp, role, text)
354
453
  session_id = session_file.stem if session_file is not None else None
355
454
  message_id = entry.get("id") or conversation_info.get("message_id")
356
455
  conversation_id = conversation_info.get("conversation_id") or session_id
357
456
  thread_id = conversation_info.get("thread_id") or session_id
358
- _post_turn(
359
- turn_endpoint,
360
- {
457
+ transcript_line = f"{timestamp} [{role}] {text}"
458
+ retry_key = (str(session_file), line_start)
459
+ pending = pending_session_turns.get(retry_key)
460
+ if pending is None:
461
+ transcript_path, transcript_line_no = _append_transcript(transcript_target, timestamp, role, text)
462
+ turn_payload = {
361
463
  "role": role,
362
464
  "content": text,
363
465
  "conversation_id": conversation_id,
@@ -365,23 +467,38 @@ def watch_forever() -> None:
365
467
  "thread_id": thread_id,
366
468
  "message_id": message_id,
367
469
  "source": "session",
470
+ "timestamp": timestamp.replace("T", " ")[:19],
368
471
  "transcript_path": str(transcript_path),
369
472
  "transcript_offset": transcript_line_no,
370
473
  "transcript_end_offset": transcript_line_no,
371
- "timestamp": timestamp.replace("T", " ")[:19],
372
474
  "metadata": {
373
475
  "parent_message_id": entry.get("parentId"),
374
476
  },
375
- },
376
- )
377
- session_buffer.append(f"{timestamp} [{role}] {text}")
477
+ }
478
+ pending_session_turns[retry_key] = {
479
+ "payload": dict(turn_payload),
480
+ "transcript_line": transcript_line,
481
+ "transcript_path": transcript_path,
482
+ "transcript_line_no": transcript_line_no,
483
+ }
484
+ else:
485
+ turn_payload = dict(pending["payload"])
486
+ transcript_line = str(pending["transcript_line"])
487
+ transcript_path = Path(str(pending["transcript_path"]))
488
+ transcript_line_no = int(pending["transcript_line_no"])
489
+ if not _post_turn(turn_endpoint, turn_payload):
490
+ session_pos = line_start
491
+ break
492
+ pending_session_turns.pop(retry_key, None)
493
+ recent_session_transcript_lines.append((str(transcript_path), transcript_line_no))
494
+ session_buffer.append(transcript_line)
378
495
  session_last_path = transcript_path
379
496
  session_last_timestamp = timestamp
380
497
  if session_start_line is None:
381
498
  session_start_line = transcript_line_no
382
499
  session_end_line = transcript_line_no
383
500
  if len(session_buffer) >= batch_max:
384
- _flush_buffer(
501
+ ok = _flush_buffer(
385
502
  session_buffer,
386
503
  source_label="session",
387
504
  transcript_path=session_last_path,
@@ -389,16 +506,20 @@ def watch_forever() -> None:
389
506
  start_line=session_start_line,
390
507
  end_line=session_end_line,
391
508
  )
509
+ if not ok:
510
+ session_pos = line_start
511
+ break
392
512
  session_start_line = None
393
513
  session_end_line = None
394
514
  last_session_flush = time.time()
395
- session_pos = handle.tell()
515
+ committed_session_pos = handle.tell()
516
+ session_pos = committed_session_pos
396
517
  except Exception:
397
518
  pass
398
519
 
399
520
  now = time.time()
400
521
  if transcript_buffer and (now - last_transcript_flush) >= batch_seconds:
401
- _flush_buffer(
522
+ ok = _flush_buffer(
402
523
  transcript_buffer,
403
524
  source_label=source,
404
525
  transcript_path=transcript_last_path,
@@ -406,11 +527,12 @@ def watch_forever() -> None:
406
527
  start_line=transcript_start_line,
407
528
  end_line=transcript_end_line,
408
529
  )
409
- transcript_start_line = None
410
- transcript_end_line = None
411
- last_transcript_flush = now
530
+ if ok:
531
+ transcript_start_line = None
532
+ transcript_end_line = None
533
+ last_transcript_flush = now
412
534
  if session_buffer and (now - last_session_flush) >= batch_seconds:
413
- _flush_buffer(
535
+ ok = _flush_buffer(
414
536
  session_buffer,
415
537
  source_label="session",
416
538
  transcript_path=session_last_path,
@@ -418,8 +540,9 @@ def watch_forever() -> None:
418
540
  start_line=session_start_line,
419
541
  end_line=session_end_line,
420
542
  )
421
- session_start_line = None
422
- session_end_line = None
423
- last_session_flush = now
543
+ if ok:
544
+ session_start_line = None
545
+ session_end_line = None
546
+ last_session_flush = now
424
547
 
425
548
  time.sleep(poll_seconds)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simbimbo/memory-ocmemog",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Advanced OpenClaw memory plugin with durable recall, transcript-backed continuity, and sidecar APIs",
5
5
  "license": "MIT",
6
6
  "repository": {