@simbimbo/memory-ocmemog 0.1.17 → 0.1.19

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
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.19 — 2026-03-29
6
+
7
+ Hydrate/resume hardening, unresolved-state main-DB consolidation, and retrieval/rehydration source-of-truth completion.
8
+
9
+ ### Highlights
10
+ - eliminated expensive hydrate hot-path scans by adding release-critical indexes for linked-memory and unresolved-state lookups
11
+ - moved unresolved state into the main SQLite memory DB with compatibility import from legacy `unresolved_state.db`
12
+ - removed inline self-heal from `/conversation/hydrate` and kept hydrate read-mostly/fast by default
13
+ - disabled predictive brief generation on hydrate by default and added long-session hydrate guardrails for oversized scopes
14
+ - surfaced hydrate budget/warning metadata for long sessions and added doctor visibility for stale legacy unresolved-state DB residue
15
+ - completed the stranded retrieval/rehydration hardening lane: preserved canonical operator-facing `selected_because` semantics while retaining richer ranking signals, and validated canonical source-of-truth retrieval behavior
16
+ - added regression coverage for resume-latency query plans, long-session guardrails, unresolved-state main-DB migration, and updated retrieval explanation semantics
17
+ - validation passed across the combined retrieval + hydrate + migration + doctor suite, and the canonical `./scripts/ocmemog-release-check.sh` gate passed
18
+
5
19
  ## 0.1.17 — 2026-03-26
6
20
 
7
21
  Promotion/governance observability, anti-cruft hardening, queue/runtime summary parity, and release validation recovery.
package/ocmemog/doctor.py CHANGED
@@ -279,6 +279,8 @@ def _run_sqlite_schema(_: None) -> CheckResult:
279
279
  tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
280
280
  missing = sorted(required - tables)
281
281
  quick = str(conn.execute("PRAGMA quick_check(1)").fetchone()[0] or "unknown")
282
+ legacy_unresolved_state_path = state_store.data_dir() / "unresolved_state.db"
283
+ legacy_unresolved_state_exists = legacy_unresolved_state_path.exists()
282
284
  for table in sorted(required):
283
285
  if table in missing:
284
286
  continue
@@ -319,6 +321,7 @@ def _run_sqlite_schema(_: None) -> CheckResult:
319
321
  "schema_version_expected": store.SCHEMA_VERSION,
320
322
  "schema_versions": version_map,
321
323
  "schema_version_issues": version_issues,
324
+ "legacy_unresolved_state_db": str(legacy_unresolved_state_path) if legacy_unresolved_state_exists else None,
322
325
  }
323
326
  else:
324
327
  details = {
@@ -329,6 +332,7 @@ def _run_sqlite_schema(_: None) -> CheckResult:
329
332
  "schema_version_expected": store.SCHEMA_VERSION,
330
333
  "schema_versions": version_map,
331
334
  "schema_version_issues": version_issues,
335
+ "legacy_unresolved_state_db": str(legacy_unresolved_state_path) if legacy_unresolved_state_exists else None,
332
336
  }
333
337
  if version_issues:
334
338
  details["schema_version_issues"] = version_issues
@@ -361,11 +365,14 @@ def _run_sqlite_schema(_: None) -> CheckResult:
361
365
  message="Schema metadata includes unexpected versions or schema column issues.",
362
366
  details=details,
363
367
  )
368
+ message = "SQLite schema and DB open state are healthy."
369
+ if legacy_unresolved_state_exists:
370
+ message = "SQLite schema and DB open state are healthy, but legacy unresolved_state.db still exists and should be cleaned up after migration verification."
364
371
  return CheckResult(
365
372
  key="sqlite/schema-access",
366
373
  label="sqlite and schema",
367
374
  status="ok",
368
- message="SQLite schema and DB open state are healthy.",
375
+ message=message,
369
376
  details=details,
370
377
  )
371
378
 
@@ -29,6 +29,10 @@ _SHORT_REPLY_NORMALIZED = {
29
29
  "okay",
30
30
  "do it",
31
31
  "go ahead",
32
+ "proceed",
33
+ "get on it",
34
+ "lets go",
35
+ "let us go",
32
36
  "sounds good",
33
37
  "lets do it",
34
38
  "let us do it",
@@ -395,7 +399,10 @@ def _infer_short_reply_resolution(
395
399
  if not referent_content:
396
400
  return None
397
401
  decision = "decline" if normalized in _NEGATIVE_SHORT_REPLY_NORMALIZED else "confirm"
398
- effective_summary = turn_content.strip()
402
+ if decision == "confirm":
403
+ effective_summary = f"Continue approved step: {referent_content}"
404
+ else:
405
+ effective_summary = f"Decline proposed step: {referent_content}"
399
406
  return {
400
407
  "kind": "short_reply_reference",
401
408
  "decision": decision,
@@ -447,10 +454,17 @@ def _enrich_turn_metadata(
447
454
  enriched["resolution"] = resolution
448
455
  if reply_target is None:
449
456
  reply_target = _get_turn_by_id(resolution.get("resolved_turn_id"))
457
+ lane_pivot = role == "user" and _looks_like_lane_pivot(content)
450
458
  if reply_target:
451
459
  reply_meta = _turn_meta(reply_target)
452
- branch_root_turn_id = int(reply_meta.get("branch_root_turn_id") or reply_target.get("id") or 0) or None
453
- branch_id = str(reply_meta.get("branch_id") or f"branch:{branch_root_turn_id or reply_target.get('id')}")
460
+ if lane_pivot:
461
+ branch_root_turn_id = int(reply_target.get("id") or 0) or None
462
+ branch_id = f"pivot:{branch_root_turn_id or (message_id or 'unknown')}"
463
+ enriched["lane_pivot"] = True
464
+ enriched["lane_pivot_from_turn_id"] = int(reply_target.get("id") or 0) or None
465
+ else:
466
+ branch_root_turn_id = int(reply_meta.get("branch_root_turn_id") or reply_target.get("id") or 0) or None
467
+ branch_id = str(reply_meta.get("branch_id") or f"branch:{branch_root_turn_id or reply_target.get('id')}")
454
468
  enriched["reply_to_turn_id"] = int(reply_target.get("id") or 0) or None
455
469
  enriched["reply_to_reference"] = reply_target.get("reference")
456
470
  if reply_target.get("message_id"):
@@ -458,7 +472,7 @@ def _enrich_turn_metadata(
458
472
  if branch_root_turn_id:
459
473
  enriched["branch_root_turn_id"] = branch_root_turn_id
460
474
  enriched["branch_id"] = branch_id
461
- enriched["branch_depth"] = int(reply_meta.get("branch_depth") or 0) + 1
475
+ enriched["branch_depth"] = int(reply_meta.get("branch_depth") or 0) + 1 if not lane_pivot else 1
462
476
  elif message_id and "branch_id" not in enriched:
463
477
  enriched["branch_id"] = f"message:{message_id}"
464
478
  enriched["branch_depth"] = 0
@@ -478,6 +492,30 @@ def _effective_turn_content(turn: Optional[Dict[str, Any]]) -> Optional[str]:
478
492
  return content or None
479
493
 
480
494
 
495
+ def _looks_like_lane_pivot(text: str) -> bool:
496
+ lowered = _normalize_conversation_text(text).lower()
497
+ if not lowered:
498
+ return False
499
+ return any(
500
+ token in lowered
501
+ for token in (
502
+ "before we continue",
503
+ "let's pause",
504
+ "lets pause",
505
+ "back to",
506
+ "move back",
507
+ "return to",
508
+ "failing us",
509
+ "before we move back",
510
+ "pause for one second",
511
+ "task list for",
512
+ "can you show me",
513
+ "what did we just fix",
514
+ "what is the task list",
515
+ )
516
+ )
517
+
518
+
481
519
  def _reply_chain_for_turn(turn: Optional[Dict[str, Any]], turns: Sequence[Dict[str, Any]], *, limit: int = 6) -> List[Dict[str, Any]]:
482
520
  if not turn:
483
521
  return []
@@ -494,7 +532,21 @@ def _reply_chain_for_turn(turn: Optional[Dict[str, Any]], turns: Sequence[Dict[s
494
532
  break
495
533
  seen.add(reply_to_turn_id)
496
534
  current = lookup.get(reply_to_turn_id) or _get_turn_by_id(reply_to_turn_id)
497
- return list(reversed(chain))
535
+ chain = list(reversed(chain))
536
+
537
+ # Trim temporary side-answer prefixes when the later cluster clearly returns to the foreground lane.
538
+ if len(chain) >= 4:
539
+ for idx in range(len(chain) - 3):
540
+ first = chain[idx]
541
+ second = chain[idx + 1]
542
+ third = chain[idx + 2]
543
+ first_text = _normalize_conversation_text(str(first.get("content") or "").strip()).lower()
544
+ second_text = _normalize_conversation_text(str(second.get("content") or "").strip()).lower()
545
+ third_text = _normalize_conversation_text(str(third.get("content") or "").strip()).lower()
546
+ if first.get("role") == "assistant" and second.get("role") == "user" and third.get("role") == "assistant":
547
+ if any(token in first_text for token in ("recent", "repo work", "list", "includes", "task list", "show me")) and any(token in second_text for token in ("great", "proceed", "task list", "back to", "continue")):
548
+ return chain[idx + 1 :]
549
+ return chain
498
550
 
499
551
 
500
552
  def _active_branch_payload(turns: Sequence[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
@@ -512,13 +564,56 @@ def _active_branch_payload(turns: Sequence[Dict[str, Any]]) -> Optional[Dict[str
512
564
  ]
513
565
  if not branch_turns:
514
566
  branch_turns = [latest_turn]
567
+
568
+ reply_chain = _reply_chain_for_turn(latest_turn, turns_list, limit=8)
569
+ reply_chain_ids = [int(item.get("id") or 0) for item in reply_chain if int(item.get("id") or 0) > 0]
570
+ latest_turn_id = int(latest_turn.get("id") or 0)
571
+
572
+ # When a later user/assistant cluster explicitly pivots or returns to a lane,
573
+ # prefer the suffix of the branch starting at the most recent non-reply turn that
574
+ # follows the earlier adjacent lane. This keeps fluid topic switches from dragging
575
+ # previous foreground work into the active branch payload.
576
+ suffix_start_id = reply_chain_ids[0] if reply_chain_ids else latest_turn_id
577
+ for turn in reversed(branch_turns):
578
+ turn_id = int(turn.get("id") or 0)
579
+ if turn_id <= 0 or turn_id >= latest_turn_id:
580
+ continue
581
+ meta = _turn_meta(turn)
582
+ if meta.get("reply_to_turn_id"):
583
+ continue
584
+ if turn.get("role") != "user":
585
+ continue
586
+ turn_text = _normalize_conversation_text(str(turn.get("content") or "").strip()).lower()
587
+ if any(token in turn_text for token in ("before we continue", "let's pause", "move back", "back to", "failing us", "return to", "resume", "task list for", "can you show me", "what did we just fix", "what is the task list")):
588
+ suffix_start_id = turn_id
589
+ break
590
+
591
+ # If the latest reply chain is the user explicitly returning after a temporary side answer,
592
+ # do not keep the side-answer assistant turn as foreground branch context.
593
+ if len(reply_chain_ids) >= 2:
594
+ first_reply_id = reply_chain_ids[0]
595
+ first_reply_turn = next((turn for turn in branch_turns if int(turn.get("id") or 0) == first_reply_id), None)
596
+ if first_reply_turn and first_reply_turn.get("role") == "assistant":
597
+ first_reply_text = _normalize_conversation_text(str(first_reply_turn.get("content") or "").strip()).lower()
598
+ if any(token in first_reply_text for token in ("recent", "repo work", "list", "includes", "task list", "show me")):
599
+ user_followup_id = reply_chain_ids[1] if len(reply_chain_ids) > 1 else None
600
+ if user_followup_id:
601
+ suffix_start_id = max(suffix_start_id, user_followup_id)
602
+
603
+ filtered_branch_turns = [
604
+ turn for turn in branch_turns
605
+ if int(turn.get("id") or 0) >= suffix_start_id or int(turn.get("id") or 0) in reply_chain_ids
606
+ ]
607
+ if filtered_branch_turns:
608
+ branch_turns = filtered_branch_turns
609
+
515
610
  return {
516
611
  "branch_id": branch_id,
517
612
  "root_turn_id": root_turn_id or latest_turn.get("id"),
518
613
  "latest_turn": _turn_anchor(latest_turn),
519
614
  "turn_ids": [int(turn.get("id") or 0) for turn in branch_turns],
520
615
  "turns": [_turn_anchor(turn) for turn in branch_turns[-8:]],
521
- "reply_chain": _reply_chain_for_turn(latest_turn, turns_list, limit=8),
616
+ "reply_chain": reply_chain,
522
617
  }
523
618
 
524
619
 
@@ -46,6 +46,12 @@ def _ensure_table(conn) -> None:
46
46
  "CREATE UNIQUE INDEX IF NOT EXISTS idx_memory_links_unique ON memory_links(source_reference, link_type, target_reference)"
47
47
  )
48
48
  conn.commit()
49
+ conn.execute(
50
+ "CREATE INDEX IF NOT EXISTS idx_memory_links_target_created_source ON memory_links(target_reference, created_at DESC, source_reference DESC)"
51
+ )
52
+ conn.execute(
53
+ "CREATE INDEX IF NOT EXISTS idx_memory_links_source_created_target ON memory_links(source_reference, created_at DESC, target_reference)"
54
+ )
49
55
 
50
56
 
51
57
  def add_memory_link(source_reference: str, link_type: str, target_reference: str) -> None:
@@ -49,7 +49,26 @@ def _should_promote(confidence: float, threshold: float | None = None) -> bool:
49
49
 
50
50
  def _destination_table(summary: str) -> str:
51
51
  lowered = summary.lower()
52
- if "runbook" in lowered or "procedure" in lowered or "steps" in lowered:
52
+ procedural_markers = (
53
+ "runbook",
54
+ "procedure",
55
+ "steps",
56
+ "checklist",
57
+ "how to",
58
+ "how-do-i",
59
+ "upgrade",
60
+ "recover",
61
+ "recovery",
62
+ "rollback",
63
+ "restart",
64
+ "validate",
65
+ "verification",
66
+ "diagnose",
67
+ "troubleshoot",
68
+ "fix by",
69
+ "safe way",
70
+ )
71
+ if any(marker in lowered for marker in procedural_markers):
53
72
  return "runbooks"
54
73
  if "lesson" in lowered or "postmortem" in lowered or "learned" in lowered:
55
74
  return "lessons"
@@ -64,6 +83,46 @@ def _normalized_text(text: str) -> str:
64
83
  return " ".join((text or "").strip().lower().split())
65
84
 
66
85
 
86
+ def _looks_like_changelog_or_release_notes(text: str) -> bool:
87
+ lowered = _normalized_text(text)
88
+ if not lowered:
89
+ return False
90
+ changelog_markers = (
91
+ "thanks @",
92
+ "(#",
93
+ "ghsa-",
94
+ "release notes",
95
+ "changelog",
96
+ "breaking change",
97
+ "bootstrap:",
98
+ "security/",
99
+ "agents/",
100
+ "telegram:",
101
+ "discord/",
102
+ "slack/",
103
+ "providers/",
104
+ "install/",
105
+ "docker/",
106
+ )
107
+ bulletish = lowered.count(" - ") >= 2 or lowered.startswith("-")
108
+ return bulletish and any(marker in lowered for marker in changelog_markers)
109
+
110
+
111
+ def _looks_like_docs_index_link_list(text: str) -> bool:
112
+ lowered = _normalized_text(text)
113
+ if not lowered:
114
+ return False
115
+ markers = (
116
+ "start with the docs index",
117
+ "architecture overview",
118
+ "full configuration reference",
119
+ "run the gateway by the book",
120
+ "learn how the control ui/web surfaces work",
121
+ "https://docs.openclaw.ai",
122
+ )
123
+ return lowered.count("https://docs.openclaw.ai") >= 2 and any(marker in lowered for marker in markers)
124
+
125
+
67
126
  def _is_redundant_generic_candidate(summary_text: str) -> bool:
68
127
  normalized = _normalized_text(summary_text)
69
128
  if not normalized:
@@ -83,6 +142,8 @@ def _is_redundant_generic_candidate(summary_text: str) -> bool:
83
142
 
84
143
 
85
144
  def _should_reject_as_cruft(*, confidence: float, threshold: float, destination: str, summary_text: str) -> bool:
145
+ if destination == "runbooks" and (_looks_like_changelog_or_release_notes(summary_text) or _looks_like_docs_index_link_list(summary_text)):
146
+ return True
86
147
  if destination != "knowledge" or confidence >= threshold:
87
148
  return False
88
149
  return bool(_normalized_text(summary_text))
@@ -189,6 +250,17 @@ def promote_candidate(candidate: Dict[str, Any]) -> Dict[str, Any]:
189
250
  candidate_metadata["candidate_id"] = candidate_id
190
251
  candidate_metadata["derived_from_candidate_id"] = candidate_id
191
252
  candidate_metadata["derived_via"] = "promotion"
253
+ # Preserve rehydration-critical anchors whenever available.
254
+ transcript_anchor = candidate_metadata.get("transcript_anchor")
255
+ if isinstance(transcript_anchor, dict):
256
+ path_value = transcript_anchor.get("path")
257
+ if path_value and not candidate_metadata.get("source_path"):
258
+ candidate_metadata["source_path"] = path_value
259
+ if not candidate_metadata.get("source_type"):
260
+ candidate_metadata["source_type"] = "transcript"
261
+ source_refs = candidate_metadata.get("source_references")
262
+ if isinstance(source_refs, list) and source_refs and not candidate_metadata.get("source_type"):
263
+ candidate_metadata["source_type"] = "derived"
192
264
 
193
265
  conn = store.connect()
194
266
  promotion_id = None
@@ -100,11 +100,267 @@ def _recency_score(timestamp: str | None) -> float:
100
100
 
101
101
  MEMORY_BUCKETS: Tuple[str, ...] = tuple(store.MEMORY_TABLES)
102
102
 
103
+ _PROCEDURAL_QUERY_MARKERS: Tuple[str, ...] = (
104
+ "how do i",
105
+ "how to",
106
+ "procedure",
107
+ "steps",
108
+ "checklist",
109
+ "safely",
110
+ "safe way",
111
+ "upgrade",
112
+ "recover",
113
+ "recovery",
114
+ "rollback",
115
+ "restart",
116
+ "validate",
117
+ "verification",
118
+ "diagnose",
119
+ "troubleshoot",
120
+ )
121
+
103
122
 
104
123
  def _empty_results() -> Dict[str, List[Dict[str, Any]]]:
105
124
  return {bucket: [] for bucket in MEMORY_BUCKETS}
106
125
 
107
126
 
127
+ def _query_intent(prompt: str) -> str:
128
+ lowered = str(prompt or "").strip().lower()
129
+ if not lowered:
130
+ return "generic"
131
+ if any(marker in lowered for marker in _PROCEDURAL_QUERY_MARKERS):
132
+ return "procedural"
133
+ return "generic"
134
+
135
+
136
+ def _bucket_intent_bonus(bucket: str, prompt: str, metadata: Dict[str, Any]) -> float:
137
+ intent = _query_intent(prompt)
138
+ if intent == "procedural":
139
+ if bucket == "runbooks":
140
+ return 0.28
141
+ if bucket == "knowledge":
142
+ # Some procedures are still stored as knowledge; keep them competitive.
143
+ lowered = str(metadata).lower()
144
+ if any(marker in lowered for marker in _PROCEDURAL_QUERY_MARKERS):
145
+ return 0.12
146
+ if bucket in {"tasks", "reflections"}:
147
+ return -0.12
148
+ return 0.0
149
+
150
+
151
+ def _source_authority_bonus(bucket: str, metadata: Dict[str, Any], content: str, prompt: str) -> float:
152
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
153
+ origin_source = str(prov.get("origin_source") or metadata.get("source_path") or "").lower()
154
+ source_label = str(metadata.get("source_label") or "").lower()
155
+ text = f"{metadata} {content}".lower()
156
+ qtype = _query_type(prompt)
157
+ bonus = 0.0
158
+ is_policy_doc = "policy established" in text or "anti-rediscovery" in text or "authoritative recall memory" in text
159
+ is_audit_doc = "scorecard" in text or "results —" in text or "results -" in text or "audit" in text
160
+ is_continuity_doc = "current-work-continuity-source-of-truth" in origin_source or str(metadata.get("source_label") or "").lower() == "canonical-current-work-continuity"
161
+ is_user_pref_doc = "preferred development model" in text or "messaging auth safety" in text or "default model" in text
162
+ is_infra_doc = "truth plane" in text or "librenms" in text or "10.5.56.130" in text or "192.168.1.x" in text or "10.x" in text
163
+ is_transcript = origin_source == "transcript" or "memory/transcripts/" in text
164
+ is_canonical_file = "/docs/" in origin_source or str(metadata.get("canonical") or "").lower() == "true"
165
+
166
+ if bucket in {"runbooks", "knowledge", "directives"}:
167
+ if "docs/ops/" in text or "runtime-authority-map.md" in text:
168
+ bonus += 0.22
169
+ if "docs/runbooks/" in text or "openclaw-upgrade-validation-runbook.md" in text or "canonical-openclaw-upgrade-runbook" in source_label or "/docs/runbooks/" in origin_source:
170
+ bonus += 0.55
171
+ if "workspace/memory/" in text or "transcript:" in text:
172
+ bonus -= 0.05
173
+
174
+ if qtype != "policy" and is_policy_doc:
175
+ bonus -= 0.45
176
+ if qtype != "policy" and is_audit_doc:
177
+ bonus -= 0.22
178
+
179
+ if qtype == "infra_fact":
180
+ if is_infra_doc:
181
+ bonus += 0.38
182
+ if not is_infra_doc and (is_policy_doc or is_audit_doc):
183
+ bonus -= 0.18
184
+ if qtype == "user_preference":
185
+ if is_user_pref_doc:
186
+ bonus += 0.42
187
+ if not is_user_pref_doc and (is_policy_doc or is_audit_doc):
188
+ bonus -= 0.16
189
+ if qtype == "continuity":
190
+ if bucket == "reflections" and is_transcript:
191
+ bonus -= 0.30
192
+ if is_continuity_doc and (bucket in {"knowledge", "directives"} or is_canonical_file):
193
+ bonus += 0.70
194
+ if is_policy_doc or is_audit_doc:
195
+ bonus -= 0.18
196
+
197
+ if bucket == "runbooks":
198
+ if "readme.md" in origin_source or "repos/openclaw/readme.md" in origin_source or "start with the docs index" in text:
199
+ bonus -= 0.55
200
+ if "changelog.md" in text or "release notes" in text or "thanks @" in text or "(#" in text:
201
+ bonus -= 0.35
202
+ if "quarantine/" in text or "/backups/" in text or "memory/candidates/" in text:
203
+ bonus -= 0.25
204
+ return bonus
205
+
206
+
207
+ def _doc_type(metadata: Dict[str, Any], bucket: str, content: str) -> str:
208
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
209
+ origin_source = str(prov.get("origin_source") or metadata.get("source_path") or "").lower()
210
+ source_label = str(metadata.get("source_label") or "").lower()
211
+ text = f"{metadata} {content}".lower()
212
+ if "canonical-openclaw-upgrade-runbook" in source_label or "/docs/runbooks/" in origin_source or "openclaw-upgrade-validation-runbook.md" in origin_source:
213
+ return "runbook"
214
+ if "user-preferences-source-of-truth" in origin_source or "gpt 5.2 codex (ultra)" in text or "never send authentication, challenge, or verification prompts" in text:
215
+ return "user_preference"
216
+ if "tbc-truth-plane-source-of-truth" in origin_source or "librenms on 10.5.56.130" in text or "primary truth plane" in text:
217
+ return "infra_fact"
218
+ if "current-work-continuity-source-of-truth" in origin_source:
219
+ return "continuity"
220
+ if "runtime authority" in text or "launchagent" in text or "authoritative source tree" in text:
221
+ return "authority_map"
222
+ if "policy established" in text or "anti-rediscovery" in text or "authoritative recall memory" in text:
223
+ return "policy"
224
+ if "audit" in text or "scorecard" in text or "results —" in text or "results -" in text:
225
+ return "audit"
226
+ if "how to" in text or "procedure" in text or "checklist" in text or "safely upgrade" in text or "validate logs" in text:
227
+ return "runbook"
228
+ if "truth plane" in text or "librenms" in text or "10.5.56.130" in text or "192.168.1.x" in text or "10.x" in text:
229
+ return "infra_fact"
230
+ if "preferred development model" in text or "default model" in text or "messaging auth safety" in text:
231
+ return "user_preference"
232
+ if bucket != "runbooks" and bucket != "directives" and ("work unit" in text or "checkpoint" in text or "current work lane" in text or "next step" in text):
233
+ return "continuity"
234
+ return "generic"
235
+
236
+
237
+ def _artifact_family(metadata: Dict[str, Any], bucket: str, content: str) -> str:
238
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
239
+ origin_source = str(prov.get("origin_source") or metadata.get("source_path") or "").lower()
240
+ text = f"{metadata} {content}".lower()
241
+ if "/docs/runbooks/" in origin_source:
242
+ return "canonical_runbook"
243
+ if "user-preferences-source-of-truth" in origin_source:
244
+ return "user_preference"
245
+ if "tbc-truth-plane-source-of-truth" in origin_source:
246
+ return "infra_fact"
247
+ if "current-work-continuity-source-of-truth" in origin_source:
248
+ return "continuity_summary"
249
+ if "runtime-authority-map" in origin_source or "ocmemog-operational-memory-policy" in origin_source or "docs/ops/" in origin_source:
250
+ return "ops_doc"
251
+ if "audit" in origin_source or "manifest" in origin_source or "inventory" in origin_source or "almost-trash" in origin_source:
252
+ return "cleanup_or_audit_meta"
253
+ if origin_source in {"transcript", "session"} or "memory/transcripts/" in text:
254
+ return "transcript_or_session_artifact"
255
+ if "readme.md" in origin_source:
256
+ return "readme_pseudorunbook"
257
+ if bucket == "reflections":
258
+ return "reflection"
259
+ if bucket == "directives":
260
+ if "preferred development model" in text or "messaging auth safety" in text or "never send authentication" in text:
261
+ return "user_preference"
262
+ if "current work lane" in text or "what still fails" in text or "next priority order" in text:
263
+ return "continuity_summary"
264
+ return "generic_process_directive"
265
+ if bucket == "runbooks" and str(content or "").startswith("After any substantial work block"):
266
+ return "generic_process_directive"
267
+ return "generic"
268
+
269
+
270
+ _LEGACY_BAD_CONTINUITY_RUNBOOKS = {
271
+ 'runbooks:177', 'runbooks:165', 'runbooks:151', 'runbooks:132', 'runbooks:126', 'runbooks:99', 'runbooks:75', 'runbooks:65', 'runbooks:45'
272
+ }
273
+
274
+
275
+ def _artifact_family_penalty(prompt: str, metadata: Dict[str, Any], bucket: str, content: str) -> float:
276
+ qtype = _query_type(prompt)
277
+ family = _artifact_family(metadata, bucket, content)
278
+ ref = str(metadata.get('reference') or '')
279
+ lowered = str(content or '').lower()
280
+ if qtype == "infra_fact":
281
+ if family in {"canonical_runbook", "cleanup_or_audit_meta", "transcript_or_session_artifact", "readme_pseudorunbook", "generic_process_directive"}:
282
+ return -0.45
283
+ if qtype == "user_preference":
284
+ if family in {"canonical_runbook", "cleanup_or_audit_meta", "transcript_or_session_artifact", "readme_pseudorunbook", "generic_process_directive"}:
285
+ return -0.45
286
+ if qtype == "continuity":
287
+ if ref in _LEGACY_BAD_CONTINUITY_RUNBOOKS:
288
+ return -1.25
289
+ if family in {"cleanup_or_audit_meta", "transcript_or_session_artifact", "readme_pseudorunbook", "generic_process_directive", "canonical_runbook", "ops_doc"}:
290
+ return -0.55
291
+ if family == "reflection":
292
+ return -0.18
293
+ if bucket == 'tasks' and ('name: steven imbimbo' in lowered or 'what to call them:' in lowered or 'timezone:' in lowered):
294
+ return -0.65
295
+ return 0.0
296
+
297
+
298
+ def _query_type(prompt: str) -> str:
299
+ lowered = str(prompt or "").strip().lower()
300
+ if not lowered:
301
+ return "generic"
302
+ if "policy" in lowered or "rule" in lowered or "anti-rediscovery" in lowered:
303
+ return "policy"
304
+ if "what launches" in lowered or "runtime authority" in lowered or "authoritative source tree" in lowered or "health endpoint" in lowered:
305
+ return "authority_map"
306
+ if any(marker in lowered for marker in _PROCEDURAL_QUERY_MARKERS):
307
+ return "runbook"
308
+ if "truth plane" in lowered or "librenms" in lowered or "192.168.1.x" in lowered or "10.x" in lowered:
309
+ return "infra_fact"
310
+ if "preferred development model" in lowered or "auth safety rule" in lowered or "preference" in lowered:
311
+ return "user_preference"
312
+ if "current work lane" in lowered or "checkpoint" in lowered or "next step" in lowered:
313
+ return "continuity"
314
+ return "generic"
315
+
316
+
317
+ def _doc_type_bonus(prompt: str, metadata: Dict[str, Any], bucket: str, content: str) -> float:
318
+ qtype = _query_type(prompt)
319
+ dtype = _doc_type(metadata, bucket, content)
320
+ if qtype == "generic":
321
+ return 0.0
322
+ if qtype == dtype:
323
+ return 0.35
324
+ compatible = {
325
+ ("runbook", "authority_map"): -0.16,
326
+ ("runbook", "policy"): -0.28,
327
+ ("runbook", "audit"): -0.24,
328
+ ("infra_fact", "audit"): -0.16,
329
+ ("infra_fact", "policy"): -0.12,
330
+ ("policy", "audit"): -0.06,
331
+ ("policy", "runbook"): -0.12,
332
+ ("authority_map", "audit"): -0.10,
333
+ ("continuity", "audit"): -0.08,
334
+ ("user_preference", "policy"): -0.06,
335
+ }
336
+ if (qtype, dtype) in compatible:
337
+ return compatible[(qtype, dtype)]
338
+ if dtype in {"policy", "audit", "authority_map"} and qtype not in {dtype, "generic"}:
339
+ return -0.10
340
+ return 0.0
341
+
342
+
343
+ def _derivative_penalty(bucket: str, metadata: Dict[str, Any], content: str, governance: Dict[str, Any]) -> float:
344
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
345
+ source_refs = prov.get("source_references") if isinstance(prov.get("source_references"), list) else []
346
+ derived_via = str(prov.get("derived_via") or "").strip().lower()
347
+ origin_source = str(prov.get("origin_source") or "").strip().lower()
348
+ lowered = str(content or "").lower()
349
+ penalty = 0.0
350
+ if bucket == "reflections":
351
+ if source_refs:
352
+ penalty -= 0.28
353
+ if derived_via in {"ponder", "distill", "artifact_distill"}:
354
+ penalty -= 0.12
355
+ if lowered.startswith("potential durable learning:") or lowered.startswith("consolidated pattern:"):
356
+ penalty -= 0.18
357
+ if governance.get("canonical_reference") and str(governance.get("canonical_reference")) != "" and str(governance.get("canonical_reference")) != str(metadata.get("reference") or ""):
358
+ penalty -= 0.10
359
+ if origin_source == "transcript" and bucket == "reflections":
360
+ penalty -= 0.06
361
+ return penalty
362
+
363
+
108
364
  def _parse_metadata(raw: Any) -> Dict[str, Any]:
109
365
  if isinstance(raw, dict):
110
366
  return raw
@@ -369,7 +625,7 @@ def retrieve(
369
625
  if source_type in selected_categories and source_id:
370
626
  semantic_scores[f"{source_type}:{source_id}"] = float(item.get("score") or 0.0)
371
627
 
372
- def score_record(*, content: str, memory_ref: str, promo_conf: float, timestamp: str | None, metadata_payload: Dict[str, Any]) -> tuple[float, Dict[str, float]]:
628
+ def score_record(*, bucket: str, content: str, memory_ref: str, promo_conf: float, timestamp: str | None, metadata_payload: Dict[str, Any]) -> tuple[float, Dict[str, float]]:
373
629
  keyword = _match_score(content, prompt)
374
630
  semantic = float(semantic_scores.get(memory_ref, 0.0))
375
631
  reinf = reinforcement.get(memory_ref, {})
@@ -377,7 +633,12 @@ def retrieve(
377
633
  promo_score = float(promo_conf) * 0.2
378
634
  recency = _recency_score(timestamp)
379
635
  lane_bonus = _lane_bonus(metadata_payload, active_lane)
380
- score = round((keyword * 0.45) + (semantic * 0.35) + reinf_score + promo_score + recency + lane_bonus, 3)
636
+ intent_bonus = _bucket_intent_bonus(bucket, prompt, metadata_payload)
637
+ authority_bonus = _source_authority_bonus(bucket, metadata_payload, content, prompt)
638
+ doc_type_bonus = _doc_type_bonus(prompt, metadata_payload, bucket, content)
639
+ family_penalty = _artifact_family_penalty(prompt, metadata_payload, bucket, content)
640
+ derivative_penalty = _derivative_penalty(bucket, metadata_payload, content, _governance_summary(_governance_state(metadata_payload)[1]))
641
+ score = round((keyword * 0.45) + (semantic * 0.35) + reinf_score + promo_score + recency + lane_bonus + intent_bonus + authority_bonus + doc_type_bonus + family_penalty + derivative_penalty, 3)
381
642
  return score, {
382
643
  "keyword": round(keyword, 3),
383
644
  "semantic": round(semantic, 3),
@@ -389,6 +650,11 @@ def retrieve(
389
650
  "promotion": round(promo_score, 3),
390
651
  "recency": round(recency, 3),
391
652
  "lane_bonus": round(lane_bonus, 3),
653
+ "intent_bonus": round(intent_bonus, 3),
654
+ "authority_bonus": round(authority_bonus, 3),
655
+ "doc_type_bonus": round(doc_type_bonus, 3),
656
+ "family_penalty": round(family_penalty, 3),
657
+ "derivative_penalty": round(derivative_penalty, 3),
392
658
  }
393
659
 
394
660
  for table in selected_categories:
@@ -425,11 +691,16 @@ def retrieve(
425
691
  bucket_counts[memory_status] = int(bucket_counts.get(memory_status) or 0) + 1
426
692
  continue
427
693
  metadata = provenance.fetch_reference(mem_ref)
428
- score, signals = score_record(content=content, memory_ref=mem_ref, promo_conf=promo_conf, timestamp=timestamp, metadata_payload=metadata_payload)
694
+ score, signals = score_record(bucket=table, content=content, memory_ref=mem_ref, promo_conf=promo_conf, timestamp=timestamp, metadata_payload=metadata_payload)
429
695
  if memory_status == "contested":
430
696
  score = round(max(0.0, score - 0.15), 3)
431
697
  signals["contradiction_penalty"] = 0.15
432
- selected_because = max(signals, key=signals.get) if signals else "keyword"
698
+ primary_reason_order = ("keyword", "semantic", "reinforcement", "promotion", "recency")
699
+ primary_reason_signals = {
700
+ key: float(signals.get(key) or 0.0)
701
+ for key in primary_reason_order
702
+ }
703
+ selected_because = max(primary_reason_signals, key=primary_reason_signals.get) if primary_reason_signals else "keyword"
433
704
  reinforcement_count = float(signals.get("reinforcement_count") or 0.0)
434
705
  negative_penalty = float(signals.get("reinforcement_negative_penalty") or 0.0)
435
706
  if reinforcement_count > 0.0 or negative_penalty > 0.0:
@@ -279,6 +279,17 @@ CREATE TABLE IF NOT EXISTS conversation_state (
279
279
  CREATE INDEX IF NOT EXISTS idx_conversation_state_conversation ON conversation_state(conversation_id);
280
280
  CREATE INDEX IF NOT EXISTS idx_conversation_state_session ON conversation_state(session_id);
281
281
  CREATE INDEX IF NOT EXISTS idx_conversation_state_thread ON conversation_state(thread_id);
282
+
283
+ CREATE TABLE IF NOT EXISTS unresolved_state (
284
+ state_id INTEGER PRIMARY KEY AUTOINCREMENT,
285
+ state_type TEXT NOT NULL,
286
+ reference TEXT,
287
+ summary TEXT,
288
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
289
+ resolved INTEGER NOT NULL DEFAULT 0
290
+ );
291
+
292
+ CREATE INDEX IF NOT EXISTS idx_unresolved_state_resolved_reference_created ON unresolved_state(resolved, reference, created_at DESC);
282
293
  """
283
294
 
284
295
  _WRITE_QUEUE: "queue.Queue[tuple]" = queue.Queue()
@@ -2,10 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import sqlite3
4
4
  import time
5
+ from pathlib import Path
5
6
  from typing import Dict, List
6
7
 
7
8
  from ocmemog.runtime import state_store
8
9
  from ocmemog.runtime.instrumentation import emit_event
10
+ from ocmemog.runtime.memory import store
9
11
 
10
12
  LOGFILE = state_store.report_log_path()
11
13
 
@@ -17,12 +19,15 @@ TYPES = {
17
19
  "incomplete_hypothesis",
18
20
  }
19
21
 
22
+ _LEGACY_DB_IMPORTED = False
23
+
24
+
25
+ def _legacy_db_path() -> Path:
26
+ return state_store.data_dir() / "unresolved_state.db"
27
+
20
28
 
21
29
  def _connect() -> sqlite3.Connection:
22
- path = state_store.data_dir() / "unresolved_state.db"
23
- path.parent.mkdir(parents=True, exist_ok=True)
24
- conn = sqlite3.connect(str(path))
25
- conn.row_factory = sqlite3.Row
30
+ conn = store.connect()
26
31
  conn.execute(
27
32
  """
28
33
  CREATE TABLE IF NOT EXISTS unresolved_state (
@@ -35,39 +40,95 @@ def _connect() -> sqlite3.Connection:
35
40
  )
36
41
  """
37
42
  )
43
+ conn.execute(
44
+ "CREATE INDEX IF NOT EXISTS idx_unresolved_state_resolved_reference_created ON unresolved_state(resolved, reference, created_at DESC)"
45
+ )
46
+ _import_legacy_db_if_needed(conn)
38
47
  return conn
39
48
 
40
49
 
50
+ def _import_legacy_db_if_needed(conn: sqlite3.Connection) -> None:
51
+ global _LEGACY_DB_IMPORTED
52
+ if _LEGACY_DB_IMPORTED:
53
+ return
54
+ legacy_path = _legacy_db_path()
55
+ if not legacy_path.exists():
56
+ _LEGACY_DB_IMPORTED = True
57
+ return
58
+ try:
59
+ existing = conn.execute("SELECT COUNT(*) FROM unresolved_state").fetchone()
60
+ existing_count = int(existing[0]) if existing else 0
61
+ legacy = sqlite3.connect(str(legacy_path))
62
+ legacy.row_factory = sqlite3.Row
63
+ try:
64
+ tables = {
65
+ str(row[0])
66
+ for row in legacy.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
67
+ }
68
+ if "unresolved_state" not in tables:
69
+ _LEGACY_DB_IMPORTED = True
70
+ return
71
+ rows = legacy.execute(
72
+ "SELECT state_type, reference, summary, created_at, resolved FROM unresolved_state ORDER BY state_id ASC"
73
+ ).fetchall()
74
+ finally:
75
+ legacy.close()
76
+ if rows and existing_count == 0:
77
+ conn.executemany(
78
+ "INSERT INTO unresolved_state (state_type, reference, summary, created_at, resolved) VALUES (?, ?, ?, ?, ?)",
79
+ [
80
+ (
81
+ str(row["state_type"] or "unresolved_question"),
82
+ row["reference"],
83
+ row["summary"],
84
+ row["created_at"] or time.strftime("%Y-%m-%d %H:%M:%S"),
85
+ int(row["resolved"] or 0),
86
+ )
87
+ for row in rows
88
+ ],
89
+ )
90
+ conn.commit()
91
+ emit_event(LOGFILE, "brain_unresolved_state_legacy_imported", status="ok", imported=len(rows))
92
+ finally:
93
+ _LEGACY_DB_IMPORTED = True
94
+
95
+
41
96
  def add_unresolved_state(state_type: str, reference: str, summary: str) -> int:
42
97
  if state_type not in TYPES:
43
98
  state_type = "unresolved_question"
44
99
  conn = _connect()
45
- conn.execute(
46
- "INSERT INTO unresolved_state (state_type, reference, summary, created_at, resolved) VALUES (?, ?, ?, ?, 0)",
47
- (state_type, reference, summary, time.strftime("%Y-%m-%d %H:%M:%S")),
48
- )
49
- conn.commit()
50
- row = conn.execute("SELECT last_insert_rowid()").fetchone()
51
- conn.close()
100
+ try:
101
+ conn.execute(
102
+ "INSERT INTO unresolved_state (state_type, reference, summary, created_at, resolved) VALUES (?, ?, ?, ?, 0)",
103
+ (state_type, reference, summary, time.strftime("%Y-%m-%d %H:%M:%S")),
104
+ )
105
+ conn.commit()
106
+ row = conn.execute("SELECT last_insert_rowid()").fetchone()
107
+ finally:
108
+ conn.close()
52
109
  emit_event(LOGFILE, "brain_unresolved_state_added", status="ok", state_type=state_type)
53
110
  return int(row[0]) if row else 0
54
111
 
55
112
 
56
113
  def list_unresolved_state(limit: int = 20) -> List[Dict[str, object]]:
57
114
  conn = _connect()
58
- rows = conn.execute(
59
- "SELECT state_id, state_type, reference, summary, created_at FROM unresolved_state WHERE resolved=0 ORDER BY created_at DESC LIMIT ?",
60
- (limit,),
61
- ).fetchall()
62
- conn.close()
115
+ try:
116
+ rows = conn.execute(
117
+ "SELECT state_id, state_type, reference, summary, created_at FROM unresolved_state WHERE resolved=0 ORDER BY created_at DESC LIMIT ?",
118
+ (limit,),
119
+ ).fetchall()
120
+ finally:
121
+ conn.close()
63
122
  return [dict(row) for row in rows]
64
123
 
65
124
 
66
125
  def resolve_unresolved_state(state_id: int) -> bool:
67
126
  conn = _connect()
68
- conn.execute("UPDATE unresolved_state SET resolved=1 WHERE state_id=?", (state_id,))
69
- conn.commit()
70
- conn.close()
127
+ try:
128
+ conn.execute("UPDATE unresolved_state SET resolved=1 WHERE state_id=?", (state_id,))
129
+ conn.commit()
130
+ finally:
131
+ conn.close()
71
132
  emit_event(LOGFILE, "brain_unresolved_state_resolved", status="ok", state_id=state_id)
72
133
  return True
73
134
 
@@ -78,16 +139,20 @@ def list_unresolved_state_for_references(references: List[str], limit: int = 20)
78
139
  return []
79
140
  placeholders = ",".join("?" for _ in refs)
80
141
  conn = _connect()
81
- rows = conn.execute(
82
- f"SELECT state_id, state_type, reference, summary, created_at FROM unresolved_state WHERE resolved=0 AND reference IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
83
- (*refs, limit),
84
- ).fetchall()
85
- conn.close()
142
+ try:
143
+ rows = conn.execute(
144
+ f"SELECT state_id, state_type, reference, summary, created_at FROM unresolved_state WHERE resolved=0 AND reference IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
145
+ (*refs, limit),
146
+ ).fetchall()
147
+ finally:
148
+ conn.close()
86
149
  return [dict(row) for row in rows]
87
150
 
88
151
 
89
152
  def count_unresolved_state() -> int:
90
153
  conn = _connect()
91
- row = conn.execute("SELECT COUNT(*) FROM unresolved_state WHERE resolved=0").fetchone()
92
- conn.close()
154
+ try:
155
+ row = conn.execute("SELECT COUNT(*) FROM unresolved_state WHERE resolved=0").fetchone()
156
+ finally:
157
+ conn.close()
93
158
  return int(row[0]) if row else 0
@@ -684,7 +684,13 @@ class GetRequest(BaseModel):
684
684
 
685
685
  class ContextRequest(BaseModel):
686
686
  reference: str
687
- radius: int = Field(default=10, ge=0, le=200)
687
+ radius: int = Field(default=200, ge=0, le=1000)
688
+
689
+
690
+ class TranscriptClaimSearchRequest(BaseModel):
691
+ query: str
692
+ radius: int = Field(default=40, ge=0, le=1000)
693
+ limit: int = Field(default=5, ge=1, le=20)
688
694
 
689
695
 
690
696
  class RecentRequest(BaseModel):
@@ -743,6 +749,11 @@ class ConversationHydrateRequest(BaseModel):
743
749
  predictive_brief_limit: int = Field(default=5, ge=1, le=12)
744
750
 
745
751
 
752
+ _LONG_SESSION_TURN_THRESHOLD = max(200, int(os.environ.get("OCMEMOG_LONG_SESSION_TURN_THRESHOLD", "800") or "800"))
753
+ _LONG_SESSION_HYDRATE_TURNS = max(6, int(os.environ.get("OCMEMOG_LONG_SESSION_HYDRATE_TURNS", "8") or "8"))
754
+ _LONG_SESSION_LINKED_MEMORY_LIMIT = max(1, int(os.environ.get("OCMEMOG_LONG_SESSION_LINKED_MEMORY_LIMIT", "2") or "2"))
755
+
756
+
746
757
  class ConversationCheckpointRequest(BaseModel):
747
758
  conversation_id: Optional[str] = None
748
759
  session_id: Optional[str] = None
@@ -939,7 +950,19 @@ def _build_predictive_brief(
939
950
  ) -> Dict[str, Any]:
940
951
  latest_user_ask = ((summary.get("latest_user_intent") or {}).get("effective_content") if isinstance(summary.get("latest_user_intent"), dict) else None) or ((summary.get("latest_user_ask") or {}).get("content") if isinstance(summary.get("latest_user_ask"), dict) else None) or ""
941
952
  summary_text = str(summary.get("summary_text") or "").strip()
942
- query = _compact_text(latest_user_ask or summary_text or "resume context", 260)
953
+
954
+ linked_query_hints: list[str] = []
955
+ for item in linked_memories[:3]:
956
+ if not isinstance(item, dict):
957
+ continue
958
+ hint = _compact_text(item.get("summary") or item.get("content") or "", 120)
959
+ lowered = hint.lower()
960
+ if any(token in lowered for token in ("codex", "openclaw", "tool", "runtime", "config", "setup", "session", "endpoint", "mechanism", "acp")):
961
+ linked_query_hints.append(hint)
962
+ query_seed = latest_user_ask or summary_text or "resume context"
963
+ if linked_query_hints:
964
+ query_seed = f"{query_seed} | prior linked precedent: {' | '.join(linked_query_hints[:2])}"
965
+ query = _compact_text(query_seed, 260)
943
966
  lane = retrieval.infer_lane(query)
944
967
  profiles = retrieval._load_lane_profiles()
945
968
  profile = profiles.get(lane or "") if lane else None
@@ -986,6 +1009,53 @@ def _build_predictive_brief(
986
1009
  "reference": item.get("reference"),
987
1010
  "summary": _compact_text(item.get("summary") or item.get("content") or item.get("reference") or "", 140),
988
1011
  })
1012
+
1013
+ lane_hints = []
1014
+ active_branch = summary.get("active_branch")
1015
+ active_branch_summary = ""
1016
+ if isinstance(active_branch, dict):
1017
+ active_branch_summary = str(active_branch.get("summary") or active_branch.get("branch_id") or "").strip()
1018
+ elif isinstance(active_branch, str):
1019
+ active_branch_summary = active_branch.strip()
1020
+ if active_branch_summary:
1021
+ lane_hints.append({"kind": "active_branch", "summary": _compact_text(active_branch_summary, 120)})
1022
+ if checkpoint and str(checkpoint.get("summary") or "").strip():
1023
+ lane_hints.append({
1024
+ "kind": "latest_checkpoint",
1025
+ "summary": _compact_text(checkpoint.get("summary") or "", 160),
1026
+ "reference": checkpoint.get("reference"),
1027
+ })
1028
+ if summary_text:
1029
+ lane_hints.append({"kind": "summary", "summary": _compact_text(summary_text, 180)})
1030
+
1031
+ next_steps = []
1032
+ pending_actions = summary.get("pending_actions") if isinstance(summary.get("pending_actions"), list) else []
1033
+ for item in pending_actions[:2]:
1034
+ if not isinstance(item, dict):
1035
+ continue
1036
+ summary_text_value = str(item.get("summary") or "").strip()
1037
+ if not summary_text_value:
1038
+ continue
1039
+ next_steps.append({
1040
+ "kind": item.get("kind") or "pending_action",
1041
+ "summary": _compact_text(summary_text_value, 140),
1042
+ "reference": item.get("source_reference") or item.get("reference"),
1043
+ })
1044
+
1045
+ avoid_drift = []
1046
+ for item in linked_memories[:4]:
1047
+ if not isinstance(item, dict):
1048
+ continue
1049
+ text = _compact_text(item.get("summary") or item.get("content") or "", 160)
1050
+ lowered = text.lower()
1051
+ if not text:
1052
+ continue
1053
+ if any(token in lowered for token in ("not ", "do not", "avoid", "instead of", "same-surface", "different mechanism", "don't assume")):
1054
+ avoid_drift.append({
1055
+ "reference": item.get("reference"),
1056
+ "summary": text,
1057
+ })
1058
+
989
1059
  return {
990
1060
  "lane": lane,
991
1061
  "query": query,
@@ -1005,6 +1075,9 @@ def _build_predictive_brief(
1005
1075
  ],
1006
1076
  "memories": items,
1007
1077
  "linked_memories": recent_linked,
1078
+ "lane_hints": lane_hints[:3],
1079
+ "next_steps": next_steps,
1080
+ "avoid_drift": avoid_drift[:2],
1008
1081
  "latest_user_ask": _compact_text(latest_user_ask, 180),
1009
1082
  "summary_text": _compact_text(summary_text, 220),
1010
1083
  "mode": "predictive-brief",
@@ -1156,7 +1229,29 @@ def _read_transcript_snippet(path: Path, line_start: Optional[int], line_end: Op
1156
1229
  }
1157
1230
 
1158
1231
 
1232
+ def _search_transcripts_for_claim(query: str, radius: int, limit: int) -> list[Dict[str, Any]]:
1233
+ needle = (query or "").strip().lower()
1234
+ if not needle:
1235
+ return []
1236
+ hits: list[Dict[str, Any]] = []
1237
+ for root in _allowed_transcript_roots():
1238
+ if not root.exists() or not root.is_dir():
1239
+ continue
1240
+ for path in sorted(root.glob('*.log')):
1241
+ try:
1242
+ with path.open('r', encoding='utf-8', errors='ignore') as handle:
1243
+ for idx, line in enumerate(handle, start=1):
1244
+ if needle in line.lower():
1245
+ hits.append(_read_transcript_snippet(path, idx, idx, radius))
1246
+ if len(hits) >= limit:
1247
+ return hits
1248
+ except OSError:
1249
+ continue
1250
+ return hits
1251
+
1252
+
1159
1253
  @app.get("/healthz")
1254
+ @app.get("/health")
1160
1255
  def healthz() -> dict[str, Any]:
1161
1256
  payload = _runtime_payload()
1162
1257
  payload["ok"] = True
@@ -1764,6 +1859,7 @@ def memory_get(request: GetRequest) -> dict[str, Any]:
1764
1859
  def memory_context(request: ContextRequest) -> dict[str, Any]:
1765
1860
  runtime = _runtime_payload()
1766
1861
  links = memory_links.get_memory_links(request.reference)
1862
+ prov = provenance.hydrate_reference(request.reference, depth=2)
1767
1863
  transcript = None
1768
1864
  for link in links:
1769
1865
  target = link.get("target_reference", "")
@@ -1772,12 +1868,34 @@ def memory_context(request: ContextRequest) -> dict[str, Any]:
1772
1868
  path, line_start, line_end = parsed
1773
1869
  transcript = _read_transcript_snippet(path, line_start, line_end, request.radius)
1774
1870
  break
1871
+ if transcript is None and isinstance(prov, dict):
1872
+ anchor = prov.get("transcript_anchor") or {}
1873
+ path_value = anchor.get("path")
1874
+ if path_value:
1875
+ transcript = _read_transcript_snippet(
1876
+ Path(path_value),
1877
+ anchor.get("start_line"),
1878
+ anchor.get("end_line"),
1879
+ request.radius,
1880
+ )
1775
1881
  return {
1776
1882
  "ok": True,
1777
1883
  "reference": request.reference,
1778
1884
  "links": links,
1779
1885
  "transcript": transcript,
1780
- "provenance": provenance.hydrate_reference(request.reference, depth=2),
1886
+ "provenance": prov,
1887
+ **runtime,
1888
+ }
1889
+
1890
+
1891
+ @app.post("/transcript/claim_search")
1892
+ def transcript_claim_search(request: TranscriptClaimSearchRequest) -> dict[str, Any]:
1893
+ runtime = _runtime_payload()
1894
+ hits = _search_transcripts_for_claim(request.query, request.radius, request.limit)
1895
+ return {
1896
+ "ok": True,
1897
+ "query": request.query,
1898
+ "results": hits,
1781
1899
  **runtime,
1782
1900
  }
1783
1901
 
@@ -1828,18 +1946,34 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1828
1946
  now_ms = time.time() * 1000.0
1829
1947
  if cached and (now_ms - cached[0]) <= cache_ttl_ms:
1830
1948
  return {**cached[1], **runtime}
1949
+ turn_counts = conversation_state.get_turn_counts(
1950
+ conversation_id=request.conversation_id,
1951
+ session_id=request.session_id,
1952
+ thread_id=request.thread_id,
1953
+ )
1954
+ _mark("get_turn_counts")
1955
+ requested_turns_limit = int(request.turns_limit)
1956
+ effective_turns_limit = requested_turns_limit
1957
+ long_session_mode = turn_counts.get("total", 0) >= _LONG_SESSION_TURN_THRESHOLD
1958
+ if long_session_mode:
1959
+ effective_turns_limit = min(effective_turns_limit, _LONG_SESSION_HYDRATE_TURNS)
1831
1960
  turns = conversation_state.get_recent_turns(
1832
1961
  conversation_id=request.conversation_id,
1833
1962
  session_id=request.session_id,
1834
1963
  thread_id=request.thread_id,
1835
- limit=request.turns_limit,
1964
+ limit=effective_turns_limit,
1836
1965
  )
1837
1966
  _mark("get_recent_turns")
1967
+ linked_memories_limit = request.memory_limit
1968
+ if _parse_bool_env("OCMEMOG_HYDRATE_FAST_MODE", default=True):
1969
+ linked_memories_limit = min(linked_memories_limit, 3)
1970
+ if long_session_mode:
1971
+ linked_memories_limit = min(linked_memories_limit, _LONG_SESSION_LINKED_MEMORY_LIMIT)
1838
1972
  linked_memories = conversation_state.get_linked_memories(
1839
1973
  conversation_id=request.conversation_id,
1840
1974
  session_id=request.session_id,
1841
1975
  thread_id=request.thread_id,
1842
- limit=request.memory_limit,
1976
+ limit=linked_memories_limit,
1843
1977
  )
1844
1978
  _mark("get_linked_memories")
1845
1979
  link_targets: List[Dict[str, Any]] = []
@@ -1849,11 +1983,6 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1849
1983
  link_targets.extend(memory_links.get_memory_links_for_session(request.session_id))
1850
1984
  if request.conversation_id:
1851
1985
  link_targets.extend(memory_links.get_memory_links_for_conversation(request.conversation_id))
1852
- conversation_state._self_heal_legacy_continuity_artifacts(
1853
- conversation_id=request.conversation_id,
1854
- session_id=request.session_id,
1855
- thread_id=request.thread_id,
1856
- )
1857
1986
  latest_checkpoint = conversation_state.get_latest_checkpoint(
1858
1987
  conversation_id=request.conversation_id,
1859
1988
  session_id=request.session_id,
@@ -1896,13 +2025,29 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1896
2025
  runtime["warnings"] = [*runtime["warnings"], "hydrate returned persisted state while state refresh was delayed"]
1897
2026
  elif state_status == "derived_not_persisted":
1898
2027
  runtime["warnings"] = [*runtime["warnings"], "hydrate returned derived state without inline state refresh"]
1899
- predictive_brief = _build_predictive_brief(
1900
- request=request,
1901
- turns=turns,
1902
- summary=summary,
1903
- linked_memories=linked_memories,
1904
- )
2028
+ predictive_brief: Dict[str, Any] = {
2029
+ "query": "",
2030
+ "items": [],
2031
+ "lane": None,
2032
+ "lane_hints": [],
2033
+ "next_steps": [],
2034
+ "avoid_drift": [],
2035
+ "linked_memories": [],
2036
+ }
2037
+ predictive_brief_enabled = _parse_bool_env("OCMEMOG_HYDRATE_PREDICTIVE_BRIEF", default=False)
2038
+ if predictive_brief_enabled:
2039
+ predictive_brief = _build_predictive_brief(
2040
+ request=request,
2041
+ turns=turns,
2042
+ summary=summary,
2043
+ linked_memories=linked_memories,
2044
+ )
1905
2045
  _mark("build_predictive_brief")
2046
+ if long_session_mode:
2047
+ runtime["warnings"] = [
2048
+ *runtime["warnings"],
2049
+ f"hydrate long-session guardrail active: total_turns={turn_counts.get('total', 0)} requested_turns_limit={requested_turns_limit} effective_turns_limit={effective_turns_limit}",
2050
+ ]
1906
2051
  response = {
1907
2052
  "ok": True,
1908
2053
  "conversation_id": request.conversation_id,
@@ -1911,11 +2056,15 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1911
2056
  "recent_turns": turns,
1912
2057
  "summary": summary,
1913
2058
  "predictive_brief": predictive_brief,
1914
- "turn_counts": conversation_state.get_turn_counts(
1915
- conversation_id=request.conversation_id,
1916
- session_id=request.session_id,
1917
- thread_id=request.thread_id,
1918
- ),
2059
+ "turn_counts": turn_counts,
2060
+ "hydrate_budget": {
2061
+ "requested_turns_limit": requested_turns_limit,
2062
+ "effective_turns_limit": effective_turns_limit,
2063
+ "requested_memory_limit": int(request.memory_limit),
2064
+ "effective_memory_limit": linked_memories_limit,
2065
+ "long_session_mode": long_session_mode,
2066
+ "long_session_threshold": _LONG_SESSION_TURN_THRESHOLD,
2067
+ },
1919
2068
  "linked_memories": linked_memories,
1920
2069
  "linked_references": link_targets,
1921
2070
  "checkpoint_graph": summary.get("checkpoint_graph"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simbimbo/memory-ocmemog",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Advanced OpenClaw memory plugin with durable recall, transcript-backed continuity, and sidecar APIs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -109,8 +109,22 @@ def run_scenario(scenario: Dict[str, Any]) -> Dict[str, Any]:
109
109
  expect = dict(scenario.get("expect") or {})
110
110
  latest_user_contains = str(expect.get("latest_user_contains") or "").strip()
111
111
  if latest_user_contains:
112
- value = str((hydrate.get("state") or {}).get("latest_user_ask") or "")
113
- recovered_value = str((recovered.get("state") or {}).get("latest_user_ask") or "")
112
+ hydrate_summary = hydrate.get("summary") or {}
113
+ recovered_summary = recovered.get("summary") or {}
114
+ hydrate_latest = (hydrate_summary.get("latest_user_intent") or {}) if isinstance(hydrate_summary.get("latest_user_intent"), dict) else {}
115
+ recovered_latest = (recovered_summary.get("latest_user_intent") or {}) if isinstance(recovered_summary.get("latest_user_intent"), dict) else {}
116
+ value = str(
117
+ hydrate_latest.get("literal", {}).get("content")
118
+ or hydrate_latest.get("literal", {}).get("effective_content")
119
+ or (hydrate.get("state") or {}).get("latest_user_ask")
120
+ or ""
121
+ )
122
+ recovered_value = str(
123
+ recovered_latest.get("literal", {}).get("content")
124
+ or recovered_latest.get("literal", {}).get("effective_content")
125
+ or (recovered.get("state") or {}).get("latest_user_ask")
126
+ or ""
127
+ )
114
128
  _run_check(checks, "hydrate.latest_user_contains", latest_user_contains in value, {"value": value, "expected": latest_user_contains})
115
129
  _run_check(checks, "restart.latest_user_contains", latest_user_contains in recovered_value, {"value": recovered_value, "expected": latest_user_contains})
116
130
 
@@ -121,10 +135,13 @@ def run_scenario(scenario: Dict[str, Any]) -> Dict[str, Any]:
121
135
  _run_check(checks, "hydrate.reply_chain_contains", all(item in reply_chain_ids for item in reply_chain_expected), {"value": reply_chain_ids, "expected": reply_chain_expected})
122
136
  _run_check(checks, "restart.reply_chain_contains", all(item in recovered_reply_chain_ids for item in reply_chain_expected), {"value": recovered_reply_chain_ids, "expected": reply_chain_expected})
123
137
 
138
+ branch_turn_ids = _message_id_list((hydrate.get("active_branch") or {}).get("turns") or [])
124
139
  excluded = list(expect.get("active_branch_turns_exclude") or [])
125
140
  if excluded:
126
- branch_turn_ids = _message_id_list((hydrate.get("active_branch") or {}).get("turns") or [])
127
141
  _run_check(checks, "hydrate.active_branch_excludes", all(item not in branch_turn_ids for item in excluded), {"value": branch_turn_ids, "excluded": excluded})
142
+ included = list(expect.get("active_branch_turns_include") or [])
143
+ if included:
144
+ _run_check(checks, "hydrate.active_branch_includes", all(item in branch_turn_ids for item in included), {"value": branch_turn_ids, "included": included})
128
145
 
129
146
  top_ranked_turn_message_id = str(expect.get("top_ranked_turn_message_id") or "").strip()
130
147
  if top_ranked_turn_message_id and checkpoint_expand: