@simbimbo/memory-ocmemog 0.1.13 → 0.1.15

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.
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from datetime import datetime, timezone
4
- from typing import Dict, List, Any, Iterable, Tuple
4
+ from typing import Dict, List, Any, Iterable, Tuple, Optional
5
5
 
6
6
  import json
7
+ import os
7
8
 
8
9
  from ocmemog.runtime import state_store
9
10
  from ocmemog.runtime.instrumentation import emit_event
@@ -87,12 +88,126 @@ def _governance_state(metadata: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
87
88
  return str(state["memory_status"] or "active"), state
88
89
 
89
90
 
91
+ def _flatten_strings(value: Any) -> List[str]:
92
+ items: List[str] = []
93
+ if isinstance(value, str):
94
+ stripped = value.strip()
95
+ if stripped:
96
+ items.append(stripped)
97
+ elif isinstance(value, dict):
98
+ for child in value.values():
99
+ items.extend(_flatten_strings(child))
100
+ elif isinstance(value, (list, tuple, set)):
101
+ for child in value:
102
+ items.extend(_flatten_strings(child))
103
+ return items
104
+
105
+
106
+ def _metadata_lookup(metadata: Dict[str, Any], dotted_key: str) -> Any:
107
+ current: Any = metadata
108
+ for part in (dotted_key or "").split("."):
109
+ if not isinstance(current, dict):
110
+ return None
111
+ current = current.get(part)
112
+ return current
113
+
114
+
115
+ def _metadata_matches(metadata: Dict[str, Any], filters: Optional[Dict[str, Any]]) -> bool:
116
+ if not filters:
117
+ return True
118
+ for key, expected in filters.items():
119
+ actual = _metadata_lookup(metadata, key)
120
+ actual_values = {item.lower() for item in _flatten_strings(actual)}
121
+ expected_values = {item.lower() for item in _flatten_strings(expected)}
122
+ if expected_values:
123
+ if not actual_values.intersection(expected_values):
124
+ return False
125
+ else:
126
+ if actual not in (None, "", [], {}):
127
+ return False
128
+ return True
129
+
130
+
131
+ def _load_lane_profiles() -> Dict[str, Dict[str, Any]]:
132
+ raw = os.getenv("OCMEMOG_MEMORY_LANES_JSON", "").strip()
133
+ if not raw:
134
+ return {}
135
+ try:
136
+ payload = json.loads(raw)
137
+ except Exception:
138
+ return {}
139
+ if not isinstance(payload, dict):
140
+ return {}
141
+ profiles: Dict[str, Dict[str, Any]] = {}
142
+ for lane_name, config in payload.items():
143
+ if not isinstance(config, dict):
144
+ continue
145
+ normalized_name = str(lane_name or "").strip().lower()
146
+ if not normalized_name:
147
+ continue
148
+ profiles[normalized_name] = {
149
+ "keywords": [item.lower() for item in _flatten_strings(config.get("keywords"))],
150
+ "metadata_filters": config.get("metadata_filters") if isinstance(config.get("metadata_filters"), dict) else {},
151
+ }
152
+ return profiles
153
+
154
+
155
+ def infer_lane(prompt: str, explicit_lane: Optional[str] = None) -> Optional[str]:
156
+ lane = str(explicit_lane or "").strip().lower()
157
+ if lane:
158
+ return lane
159
+ profiles = _load_lane_profiles()
160
+ if not profiles:
161
+ return None
162
+ prompt_l = str(prompt or "").lower()
163
+ tokens = set(_tokenize(prompt))
164
+ best_lane: Optional[str] = None
165
+ best_score = 0
166
+ for lane_name, config in profiles.items():
167
+ keywords = {item for item in config.get("keywords", []) if item}
168
+ if not keywords:
169
+ continue
170
+ score = 0
171
+ for keyword in keywords:
172
+ keyword_tokens = set(_tokenize(keyword))
173
+ if not keyword_tokens:
174
+ continue
175
+ if len(keyword_tokens) == 1:
176
+ if next(iter(keyword_tokens)) in tokens:
177
+ score += 1
178
+ elif keyword.lower() in prompt_l:
179
+ score += len(keyword_tokens)
180
+ if score > best_score:
181
+ best_score = score
182
+ best_lane = lane_name
183
+ return best_lane if best_score > 0 else None
184
+
185
+
186
+ def _lane_bonus(metadata: Dict[str, Any], lane: Optional[str]) -> float:
187
+ lane_value = str(lane or "").strip().lower()
188
+ if not lane_value:
189
+ return 0.0
190
+ domain = str(_metadata_lookup(metadata, "domain") or "").strip().lower()
191
+ if domain == lane_value:
192
+ return 0.2
193
+ profile = _load_lane_profiles().get(lane_value) or {}
194
+ filters = profile.get("metadata_filters") if isinstance(profile.get("metadata_filters"), dict) else {}
195
+ if filters and _metadata_matches(metadata, filters):
196
+ return 0.16
197
+ source_labels = {item.lower() for item in _flatten_strings(_metadata_lookup(metadata, "source_labels"))}
198
+ if lane_value in source_labels:
199
+ return 0.08
200
+ return 0.0
201
+
202
+
90
203
  def retrieve(
91
204
  prompt: str,
92
205
  limit: int = 5,
93
206
  categories: Iterable[str] | None = None,
94
207
  *,
95
208
  skip_vector_provider: bool = False,
209
+ metadata_filters: Optional[Dict[str, Any]] = None,
210
+ lane: Optional[str] = None,
96
211
  ) -> Dict[str, List[Dict[str, Any]]]:
97
212
  emit_event(state_store.report_log_path(), "brain_memory_retrieval_start", status="ok")
98
213
  emit_event(state_store.report_log_path(), "brain_memory_retrieval_rank_start", status="ok")
@@ -100,6 +215,7 @@ def retrieve(
100
215
  conn = store.connect()
101
216
  results = _empty_results()
102
217
  selected_categories = tuple(dict.fromkeys(category for category in (categories or MEMORY_BUCKETS) if category in MEMORY_BUCKETS))
218
+ active_lane = infer_lane(prompt, explicit_lane=lane)
103
219
 
104
220
  reinf_rows = conn.execute("SELECT memory_reference, reward_score, confidence FROM experiences").fetchall()
105
221
  reinforcement: Dict[str, Dict[str, float]] = {}
@@ -129,20 +245,22 @@ def retrieve(
129
245
  if source_type in selected_categories and source_id:
130
246
  semantic_scores[f"{source_type}:{source_id}"] = float(item.get("score") or 0.0)
131
247
 
132
- def score_record(*, content: str, memory_ref: str, promo_conf: float, timestamp: str | None) -> tuple[float, Dict[str, float]]:
248
+ def score_record(*, content: str, memory_ref: str, promo_conf: float, timestamp: str | None, metadata_payload: Dict[str, Any]) -> tuple[float, Dict[str, float]]:
133
249
  keyword = _match_score(content, prompt)
134
250
  semantic = float(semantic_scores.get(memory_ref, 0.0))
135
251
  reinf = reinforcement.get(memory_ref, {})
136
252
  reinf_score = float(reinf.get("reward_score", 0.0)) * 0.35
137
253
  promo_score = float(promo_conf) * 0.2
138
254
  recency = _recency_score(timestamp)
139
- score = round((keyword * 0.45) + (semantic * 0.35) + reinf_score + promo_score + recency, 3)
255
+ lane_bonus = _lane_bonus(metadata_payload, active_lane)
256
+ score = round((keyword * 0.45) + (semantic * 0.35) + reinf_score + promo_score + recency + lane_bonus, 3)
140
257
  return score, {
141
258
  "keyword": round(keyword, 3),
142
259
  "semantic": round(semantic, 3),
143
260
  "reinforcement": round(reinf_score, 3),
144
261
  "promotion": round(promo_score, 3),
145
262
  "recency": round(recency, 3),
263
+ "lane_bonus": round(lane_bonus, 3),
146
264
  }
147
265
 
148
266
  for table in selected_categories:
@@ -165,11 +283,13 @@ def retrieve(
165
283
  timestamp = row["timestamp"] if isinstance(row, dict) else row[1]
166
284
  raw_metadata = row["metadata_json"] if isinstance(row, dict) else row[4]
167
285
  metadata_payload = _parse_metadata(raw_metadata)
286
+ if not _metadata_matches(metadata_payload, metadata_filters):
287
+ continue
168
288
  memory_status, governance = _governance_state(metadata_payload)
169
289
  if memory_status in {"superseded", "duplicate"}:
170
290
  continue
171
291
  metadata = provenance.fetch_reference(mem_ref)
172
- score, signals = score_record(content=content, memory_ref=mem_ref, promo_conf=promo_conf, timestamp=timestamp)
292
+ score, signals = score_record(content=content, memory_ref=mem_ref, promo_conf=promo_conf, timestamp=timestamp, metadata_payload=metadata_payload)
173
293
  if memory_status == "contested":
174
294
  score = round(max(0.0, score - 0.15), 3)
175
295
  signals["contradiction_penalty"] = 0.15
@@ -200,6 +320,8 @@ def retrieve_for_queries(
200
320
  limit: int = 5,
201
321
  categories: Iterable[str] | None = None,
202
322
  skip_vector_provider: bool = False,
323
+ metadata_filters: Optional[Dict[str, Any]] = None,
324
+ lane: Optional[str] = None,
203
325
  ) -> Dict[str, List[Dict[str, Any]]]:
204
326
  merged = _empty_results()
205
327
  seen_refs = {bucket: set() for bucket in MEMORY_BUCKETS}
@@ -207,10 +329,17 @@ def retrieve_for_queries(
207
329
  normalized_queries = [query.strip() for query in queries if isinstance(query, str) and query.strip()]
208
330
 
209
331
  if not normalized_queries:
210
- return retrieve("", limit=limit, categories=selected_categories)
332
+ return retrieve("", limit=limit, categories=selected_categories, metadata_filters=metadata_filters, lane=lane)
211
333
 
212
334
  for query in normalized_queries:
213
- partial = retrieve(query, limit=limit, categories=selected_categories, skip_vector_provider=skip_vector_provider)
335
+ partial = retrieve(
336
+ query,
337
+ limit=limit,
338
+ categories=selected_categories,
339
+ skip_vector_provider=skip_vector_provider,
340
+ metadata_filters=metadata_filters,
341
+ lane=lane,
342
+ )
214
343
  for bucket in selected_categories:
215
344
  for item in partial.get(bucket, []):
216
345
  ref = item.get("memory_reference")
@@ -46,6 +46,28 @@ _BOOL_TRUE_VALUES = {"1", "true", "yes", "on", "y", "t"}
46
46
  _BOOL_FALSE_VALUES = {"0", "false", "no", "off", "n", "f"}
47
47
 
48
48
 
49
+ def _default_openclaw_home() -> Path:
50
+ explicit = os.environ.get("OPENCLAW_HOME", "").strip() or os.environ.get("OCMEMOG_OPENCLAW_HOME", "").strip()
51
+ if explicit:
52
+ return Path(explicit).expanduser().resolve()
53
+ xdg = os.environ.get("XDG_DATA_HOME", "").strip()
54
+ if xdg:
55
+ return (Path(xdg).expanduser() / "openclaw").resolve()
56
+ if os.name == "nt":
57
+ appdata = os.environ.get("APPDATA", "").strip() or os.environ.get("LOCALAPPDATA", "").strip()
58
+ if appdata:
59
+ return (Path(appdata).expanduser() / "OpenClaw").resolve()
60
+ return (Path.home() / ".openclaw").resolve()
61
+
62
+
63
+ def _default_transcript_root() -> Path:
64
+ home = _default_openclaw_home()
65
+ legacy = (Path.home() / ".openclaw" / "workspace" / "memory").resolve()
66
+ if home == legacy.parent.parent:
67
+ return legacy
68
+ return home / "workspace" / "memory"
69
+
70
+
49
71
  def _parse_bool_env_value(raw: Any | None, default: bool = False) -> tuple[bool, bool]:
50
72
  """Return ``(value, valid)``, where ``valid`` indicates parser confidence."""
51
73
  if raw is None:
@@ -138,6 +160,8 @@ _INGEST_WORKER_LOCK = threading.Lock()
138
160
  _WATCHER_STOP = threading.Event()
139
161
  _WATCHER_THREAD: threading.Thread | None = None
140
162
  _WATCHER_LOCK = threading.Lock()
163
+ _HYDRATE_CACHE_LOCK = threading.Lock()
164
+ _HYDRATE_CACHE: dict[tuple[str, str, str, int, int], tuple[float, dict[str, Any]]] = {}
141
165
  QUEUE_LOCK = threading.Lock()
142
166
  QUEUE_PROCESS_LOCK = threading.Lock()
143
167
  QUEUE_STATS = {
@@ -221,6 +245,17 @@ async def _auth_middleware(request: Request, call_next):
221
245
  return await call_next(request)
222
246
 
223
247
 
248
+ def _watcher_direct_turn_ingest(payload: dict) -> bool:
249
+ try:
250
+ request = ConversationTurnRequest(**payload)
251
+ response = _ingest_conversation_turn(request)
252
+ return bool(response.get("ok"))
253
+ except Exception as exc:
254
+ print(f"[ocmemog][watcher] direct_turn_ingest_failed error={exc!r}", file=sys.stderr)
255
+ return False
256
+
257
+
258
+
224
259
  def _start_transcript_watcher() -> None:
225
260
  global _WATCHER_THREAD
226
261
  _load_queue_stats()
@@ -233,7 +268,7 @@ def _start_transcript_watcher() -> None:
233
268
  _WATCHER_STOP.clear()
234
269
  _WATCHER_THREAD = threading.Thread(
235
270
  target=watch_forever,
236
- args=(_WATCHER_STOP,),
271
+ args=(_WATCHER_STOP, _watcher_direct_turn_ingest),
237
272
  daemon=True,
238
273
  name="ocmemog-transcript-watcher",
239
274
  )
@@ -325,15 +360,28 @@ def _enqueue_postprocess(reference: str, *, skip_embedding_provider: bool = True
325
360
 
326
361
 
327
362
  def _run_postprocess_payload(payload: Dict[str, Any]) -> None:
363
+ started = time.perf_counter()
328
364
  reference = str(payload.get("reference") or "").strip()
329
365
  if not reference:
330
366
  raise ValueError("missing_reference")
331
367
  skip_embedding_provider = bool(payload.get("skip_embedding_provider", True))
332
368
  result = api.postprocess_stored_memory(reference, skip_embedding_provider=skip_embedding_provider)
369
+ elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
370
+ trace = _parse_bool_env("OCMEMOG_TRACE_INGEST_PIPELINE", default=False)
371
+ warn_ms = _parse_float_env("OCMEMOG_TRACE_INGEST_PIPELINE_WARN_MS", default=20.0, minimum=0.0)
372
+ if trace or elapsed_ms >= warn_ms:
373
+ print(f"[ocmemog][ingest] postprocess elapsed_ms={elapsed_ms:.3f} reference={reference}", file=sys.stderr)
333
374
  if not result.get("ok"):
334
375
  raise RuntimeError(str(result.get("error") or "postprocess_failed"))
335
376
 
336
377
 
378
+ def _should_link_ingest_memory_to_turn(request: IngestRequest) -> bool:
379
+ source = str(request.source or "").strip().lower()
380
+ if source in {"session", "transcript"}:
381
+ return False
382
+ return True
383
+
384
+
337
385
 
338
386
  def _process_queue(limit: Optional[int] = None) -> Dict[str, Any]:
339
387
  processed = 0
@@ -530,6 +578,8 @@ class SearchRequest(BaseModel):
530
578
  query: str = Field(default="")
531
579
  limit: int = Field(default=5, ge=1, le=50)
532
580
  categories: Optional[List[str]] = None
581
+ metadata_filters: Optional[Dict[str, Any]] = None
582
+ lane: Optional[str] = Field(default=None, description="Optional retrieval lane/domain hint, e.g. 'tbc'")
533
583
 
534
584
 
535
585
  class DuplicateCandidatesRequest(BaseModel):
@@ -638,6 +688,7 @@ class IngestRequest(BaseModel):
638
688
  transcript_offset: Optional[int] = None
639
689
  transcript_end_offset: Optional[int] = None
640
690
  timestamp: Optional[str] = None
691
+ metadata: Optional[Dict[str, Any]] = None
641
692
 
642
693
 
643
694
  class ConversationTurnRequest(BaseModel):
@@ -661,6 +712,7 @@ class ConversationHydrateRequest(BaseModel):
661
712
  thread_id: Optional[str] = None
662
713
  turns_limit: int = Field(default=12, ge=1, le=100)
663
714
  memory_limit: int = Field(default=8, ge=1, le=50)
715
+ predictive_brief_limit: int = Field(default=5, ge=1, le=12)
664
716
 
665
717
 
666
718
  class ConversationCheckpointRequest(BaseModel):
@@ -770,16 +822,28 @@ def _retune_reflection_memory_type(content: str, memory_type: str) -> str:
770
822
  return memory_type
771
823
 
772
824
 
773
- def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict[str, Any]]:
825
+ def _fallback_search(
826
+ query: str,
827
+ limit: int,
828
+ categories: List[str],
829
+ *,
830
+ metadata_filters: Optional[Dict[str, Any]] = None,
831
+ lane: Optional[str] = None,
832
+ ) -> List[Dict[str, Any]]:
774
833
  conn = store.connect()
834
+ active_lane = retrieval.infer_lane(query, explicit_lane=lane)
775
835
  try:
776
836
  results: List[Dict[str, Any]] = []
777
837
  for table in categories:
778
838
  rows = conn.execute(
779
- f"SELECT id, content, confidence FROM {table} WHERE content LIKE ? ORDER BY id DESC LIMIT ?",
780
- (f"%{query}%", limit),
839
+ f"SELECT id, content, confidence, metadata_json FROM {table} WHERE content LIKE ? ORDER BY id DESC LIMIT ?",
840
+ (f"%{query}%", limit * 5),
781
841
  ).fetchall()
782
842
  for row in rows:
843
+ meta = json.loads(row["metadata_json"] or "{}") if row["metadata_json"] else {}
844
+ if not retrieval._metadata_matches(meta, metadata_filters):
845
+ continue
846
+ lane_bonus = retrieval._lane_bonus(meta, active_lane)
783
847
  results.append(
784
848
  {
785
849
  "bucket": table,
@@ -787,8 +851,9 @@ def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict
787
851
  "table": table,
788
852
  "id": str(row["id"]),
789
853
  "content": str(row["content"] or ""),
790
- "score": float(row["confidence"] or 0.0),
854
+ "score": float(row["confidence"] or 0.0) + lane_bonus,
791
855
  "links": [],
856
+ "metadata": meta,
792
857
  }
793
858
  )
794
859
  results.sort(key=lambda item: item["score"], reverse=True)
@@ -797,6 +862,94 @@ def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict
797
862
  conn.close()
798
863
 
799
864
 
865
+ def _compact_text(value: Any, max_len: int = 180) -> str:
866
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
867
+ if len(text) > max_len:
868
+ return f"{text[: max_len - 1].rstrip()}…"
869
+ return text
870
+
871
+
872
+ def _build_predictive_brief(
873
+ *,
874
+ request: ConversationHydrateRequest,
875
+ turns: Sequence[Dict[str, Any]],
876
+ summary: Dict[str, Any],
877
+ linked_memories: Sequence[Dict[str, Any]],
878
+ ) -> Dict[str, Any]:
879
+ 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 ""
880
+ summary_text = str(summary.get("summary_text") or "").strip()
881
+ query = _compact_text(latest_user_ask or summary_text or "resume context", 260)
882
+ lane = retrieval.infer_lane(query)
883
+ profiles = retrieval._load_lane_profiles()
884
+ profile = profiles.get(lane or "") if lane else None
885
+ metadata_filters = profile.get("metadata_filters") if isinstance(profile, dict) else None
886
+ categories = ["knowledge", "runbooks", "tasks", "reflections", "directives"]
887
+ retrieved = retrieval.retrieve_for_queries(
888
+ [query],
889
+ limit=max(1, request.predictive_brief_limit),
890
+ categories=categories,
891
+ metadata_filters=metadata_filters,
892
+ lane=lane,
893
+ skip_vector_provider=True,
894
+ )
895
+ items: List[Dict[str, Any]] = []
896
+ seen: set[str] = set()
897
+ for bucket in categories:
898
+ for item in retrieved.get(bucket, []) or []:
899
+ ref = str(item.get("reference") or "")
900
+ if not ref or ref in seen:
901
+ continue
902
+ seen.add(ref)
903
+ items.append(
904
+ {
905
+ "reference": ref,
906
+ "category": bucket,
907
+ "content": _compact_text(item.get("content") or "", 180),
908
+ "selected_because": item.get("selected_because") or item.get("retrieval_signals") or "retrieval",
909
+ "score": item.get("score"),
910
+ "metadata": item.get("metadata") or {},
911
+ }
912
+ )
913
+ if len(items) >= request.predictive_brief_limit:
914
+ break
915
+ if len(items) >= request.predictive_brief_limit:
916
+ break
917
+
918
+ checkpoint = summary.get("latest_checkpoint") if isinstance(summary.get("latest_checkpoint"), dict) else None
919
+ open_loops = summary.get("open_loops") if isinstance(summary.get("open_loops"), list) else []
920
+ recent_linked = []
921
+ for item in linked_memories[:2]:
922
+ if not isinstance(item, dict):
923
+ continue
924
+ recent_linked.append({
925
+ "reference": item.get("reference"),
926
+ "summary": _compact_text(item.get("summary") or item.get("content") or item.get("reference") or "", 140),
927
+ })
928
+ return {
929
+ "lane": lane,
930
+ "query": query,
931
+ "metadata_filters": metadata_filters or {},
932
+ "checkpoint": {
933
+ "reference": checkpoint.get("reference") if checkpoint else None,
934
+ "summary": _compact_text(checkpoint.get("summary") if checkpoint else "", 180),
935
+ } if checkpoint else None,
936
+ "open_loops": [
937
+ {
938
+ "kind": item.get("kind"),
939
+ "summary": _compact_text(item.get("summary") or "", 120),
940
+ "reference": item.get("source_reference") or item.get("reference"),
941
+ }
942
+ for item in open_loops[:2]
943
+ if isinstance(item, dict) and str(item.get("summary") or "").strip()
944
+ ],
945
+ "memories": items,
946
+ "linked_memories": recent_linked,
947
+ "latest_user_ask": _compact_text(latest_user_ask, 180),
948
+ "summary_text": _compact_text(summary_text, 220),
949
+ "mode": "predictive-brief",
950
+ }
951
+
952
+
800
953
  _ALLOWED_MEMORY_REFERENCE_TYPES = {
801
954
  *store.MEMORY_TABLES,
802
955
  "conversation_turns",
@@ -895,7 +1048,7 @@ def _allowed_transcript_roots() -> list[Path]:
895
1048
  if raw:
896
1049
  roots = [Path(item).expanduser().resolve() for item in raw.split(",") if item.strip()]
897
1050
  else:
898
- roots = [Path.home() / ".openclaw" / "workspace" / "memory"]
1051
+ roots = [_default_transcript_root()]
899
1052
  return roots
900
1053
 
901
1054
 
@@ -963,13 +1116,15 @@ def memory_search(request: SearchRequest) -> dict[str, Any]:
963
1116
  limit=request.limit,
964
1117
  categories=categories,
965
1118
  skip_vector_provider=skip_vector_provider,
1119
+ metadata_filters=request.metadata_filters,
1120
+ lane=request.lane,
966
1121
  )
967
1122
  flattened = flatten_results(results)
968
1123
  if len(flattened) > request.limit:
969
1124
  flattened = flattened[: request.limit]
970
1125
  used_fallback = False
971
1126
  except Exception as exc:
972
- flattened = _fallback_search(request.query, request.limit, categories)
1127
+ flattened = _fallback_search(request.query, request.limit, categories, metadata_filters=request.metadata_filters, lane=request.lane)
973
1128
  used_fallback = True
974
1129
  runtime["warnings"] = [*runtime["warnings"], f"search fallback enabled: {exc}"]
975
1130
  elapsed_ms = round((time.perf_counter() - started) * 1000, 3)
@@ -1320,18 +1475,40 @@ def conversation_ingest_turn(request: ConversationTurnRequest) -> dict[str, Any]
1320
1475
  @app.post("/conversation/hydrate")
1321
1476
  def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1322
1477
  runtime = _runtime_payload()
1478
+ route_started = time.perf_counter()
1479
+ stage_marks: list[tuple[str, float]] = []
1480
+
1481
+ def _mark(stage: str) -> None:
1482
+ stage_marks.append((stage, time.perf_counter()))
1483
+
1484
+ cache_ttl_ms = _parse_float_env("OCMEMOG_HYDRATE_CACHE_TTL_MS", default=350.0, minimum=0.0)
1485
+ cache_key = (
1486
+ str(request.conversation_id or ""),
1487
+ str(request.session_id or ""),
1488
+ str(request.thread_id or ""),
1489
+ int(request.turns_limit),
1490
+ int(request.memory_limit),
1491
+ )
1492
+ if cache_ttl_ms > 0:
1493
+ with _HYDRATE_CACHE_LOCK:
1494
+ cached = _HYDRATE_CACHE.get(cache_key)
1495
+ now_ms = time.time() * 1000.0
1496
+ if cached and (now_ms - cached[0]) <= cache_ttl_ms:
1497
+ return {**cached[1], **runtime}
1323
1498
  turns = conversation_state.get_recent_turns(
1324
1499
  conversation_id=request.conversation_id,
1325
1500
  session_id=request.session_id,
1326
1501
  thread_id=request.thread_id,
1327
1502
  limit=request.turns_limit,
1328
1503
  )
1504
+ _mark("get_recent_turns")
1329
1505
  linked_memories = conversation_state.get_linked_memories(
1330
1506
  conversation_id=request.conversation_id,
1331
1507
  session_id=request.session_id,
1332
1508
  thread_id=request.thread_id,
1333
1509
  limit=request.memory_limit,
1334
1510
  )
1511
+ _mark("get_linked_memories")
1335
1512
  link_targets: List[Dict[str, Any]] = []
1336
1513
  if request.thread_id:
1337
1514
  link_targets.extend(memory_links.get_memory_links_for_thread(request.thread_id))
@@ -1339,6 +1516,11 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1339
1516
  link_targets.extend(memory_links.get_memory_links_for_session(request.session_id))
1340
1517
  if request.conversation_id:
1341
1518
  link_targets.extend(memory_links.get_memory_links_for_conversation(request.conversation_id))
1519
+ conversation_state._self_heal_legacy_continuity_artifacts(
1520
+ conversation_id=request.conversation_id,
1521
+ session_id=request.session_id,
1522
+ thread_id=request.thread_id,
1523
+ )
1342
1524
  latest_checkpoint = conversation_state.get_latest_checkpoint(
1343
1525
  conversation_id=request.conversation_id,
1344
1526
  session_id=request.session_id,
@@ -1350,6 +1532,7 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1350
1532
  thread_id=request.thread_id,
1351
1533
  limit=10,
1352
1534
  )
1535
+ _mark("list_relevant_unresolved_state")
1353
1536
  summary = conversation_state.infer_hydration_payload(
1354
1537
  turns,
1355
1538
  conversation_id=request.conversation_id,
@@ -1359,25 +1542,42 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1359
1542
  latest_checkpoint=latest_checkpoint,
1360
1543
  linked_memories=linked_memories,
1361
1544
  )
1362
- state_payload = conversation_state.refresh_state(
1545
+ _mark("infer_hydration_payload")
1546
+ state_payload = conversation_state.get_state(
1363
1547
  conversation_id=request.conversation_id,
1364
1548
  session_id=request.session_id,
1365
1549
  thread_id=request.thread_id,
1366
- tolerate_write_failure=True,
1367
1550
  )
1551
+ _mark("get_state")
1552
+ if not state_payload:
1553
+ state_payload = conversation_state._state_from_payload(
1554
+ summary,
1555
+ conversation_id=request.conversation_id,
1556
+ session_id=request.session_id,
1557
+ thread_id=request.thread_id,
1558
+ )
1368
1559
  state_meta = (state_payload or {}).get("metadata") if isinstance((state_payload or {}).get("metadata"), dict) else {}
1369
1560
  state_status = str(state_meta.get("state_status") or "")
1561
+ runtime["warnings"] = [*runtime["warnings"], "hydrate returned state without inline state refresh"]
1370
1562
  if state_status == "stale_persisted":
1371
1563
  runtime["warnings"] = [*runtime["warnings"], "hydrate returned persisted state while state refresh was delayed"]
1372
1564
  elif state_status == "derived_not_persisted":
1373
- runtime["warnings"] = [*runtime["warnings"], "hydrate returned derived state while state refresh was delayed"]
1374
- return {
1565
+ runtime["warnings"] = [*runtime["warnings"], "hydrate returned derived state without inline state refresh"]
1566
+ predictive_brief = _build_predictive_brief(
1567
+ request=request,
1568
+ turns=turns,
1569
+ summary=summary,
1570
+ linked_memories=linked_memories,
1571
+ )
1572
+ _mark("build_predictive_brief")
1573
+ response = {
1375
1574
  "ok": True,
1376
1575
  "conversation_id": request.conversation_id,
1377
1576
  "session_id": request.session_id,
1378
1577
  "thread_id": request.thread_id,
1379
1578
  "recent_turns": turns,
1380
1579
  "summary": summary,
1580
+ "predictive_brief": predictive_brief,
1381
1581
  "turn_counts": conversation_state.get_turn_counts(
1382
1582
  conversation_id=request.conversation_id,
1383
1583
  session_id=request.session_id,
@@ -1390,6 +1590,33 @@ def conversation_hydrate(request: ConversationHydrateRequest) -> dict[str, Any]:
1390
1590
  "state": state_payload,
1391
1591
  **runtime,
1392
1592
  }
1593
+ elapsed_ms = round((time.perf_counter() - route_started) * 1000, 3)
1594
+ hydrate_trace_enabled = _parse_bool_env("OCMEMOG_TRACE_HYDRATE", default=False)
1595
+ hydrate_warn_ms_raw = os.environ.get("OCMEMOG_TRACE_HYDRATE_WARN_MS", "25").strip()
1596
+ try:
1597
+ hydrate_warn_ms = max(0.0, float(hydrate_warn_ms_raw))
1598
+ except Exception:
1599
+ hydrate_warn_ms = 25.0
1600
+ if cache_ttl_ms > 0:
1601
+ with _HYDRATE_CACHE_LOCK:
1602
+ _HYDRATE_CACHE[cache_key] = (time.time() * 1000.0, dict(response))
1603
+ if len(_HYDRATE_CACHE) > 256:
1604
+ oldest_key = min(_HYDRATE_CACHE.items(), key=lambda item: item[1][0])[0]
1605
+ _HYDRATE_CACHE.pop(oldest_key, None)
1606
+ if hydrate_trace_enabled or elapsed_ms >= hydrate_warn_ms:
1607
+ stage_details: list[str] = []
1608
+ previous = route_started
1609
+ for name, mark in stage_marks:
1610
+ stage_details.append(f"{name}={(mark - previous) * 1000.0:.3f}ms")
1611
+ previous = mark
1612
+ print(
1613
+ "[ocmemog][route] conversation_hydrate "
1614
+ f"elapsed_ms={elapsed_ms:.3f} turns={len(turns)} linked_memories={len(linked_memories)} "
1615
+ f"unresolved_items={len(unresolved_items)} state_status={state_status or 'fresh'} "
1616
+ f"stages={'|'.join(stage_details) or 'none'}",
1617
+ file=sys.stderr,
1618
+ )
1619
+ return response
1393
1620
 
1394
1621
 
1395
1622
  @app.post("/conversation/checkpoint")
@@ -1495,6 +1722,7 @@ def memory_ponder_latest(limit: int = 5) -> dict[str, Any]:
1495
1722
 
1496
1723
 
1497
1724
  def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1725
+ ingest_started = time.perf_counter()
1498
1726
  runtime = _runtime_payload()
1499
1727
  content = request.content.strip() if isinstance(request.content, str) else ""
1500
1728
  if not content:
@@ -1508,6 +1736,7 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1508
1736
  memory_type = "knowledge"
1509
1737
  memory_type = _retune_reflection_memory_type(content, memory_type)
1510
1738
  metadata = {
1739
+ **(request.metadata or {}),
1511
1740
  "conversation_id": request.conversation_id,
1512
1741
  "session_id": request.session_id,
1513
1742
  "thread_id": request.thread_id,
@@ -1548,7 +1777,7 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1548
1777
  else:
1549
1778
  suffix = ""
1550
1779
  memory_links.add_memory_link(reference, "transcript", f"transcript:{request.transcript_path}{suffix}")
1551
- if request.role:
1780
+ if request.role and _should_link_ingest_memory_to_turn(request):
1552
1781
  turn_response = _ingest_conversation_turn(
1553
1782
  ConversationTurnRequest(
1554
1783
  role=request.role,
@@ -1578,11 +1807,18 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1578
1807
  ]
1579
1808
  },
1580
1809
  )
1581
- return {"ok": True, "kind": "memory", "memory_type": memory_type, "reference": reference, "turn": turn_response, **runtime}
1810
+ response = {"ok": True, "kind": "memory", "memory_type": memory_type, "reference": reference, "turn": turn_response, **runtime}
1811
+ elapsed_ms = round((time.perf_counter() - ingest_started) * 1000, 3)
1812
+ trace = _parse_bool_env("OCMEMOG_TRACE_INGEST_PIPELINE", default=False)
1813
+ warn_ms = _parse_float_env("OCMEMOG_TRACE_INGEST_PIPELINE_WARN_MS", default=20.0, minimum=0.0)
1814
+ if trace or elapsed_ms >= warn_ms:
1815
+ print(f"[ocmemog][ingest] ingest_request elapsed_ms={elapsed_ms:.3f} kind=memory source={request.source or ''} reference={reference}", file=sys.stderr)
1816
+ return response
1582
1817
 
1583
1818
  # experience ingest
1584
1819
  experience_metadata = provenance.normalize_metadata(
1585
1820
  {
1821
+ **(request.metadata or {}),
1586
1822
  "conversation_id": request.conversation_id,
1587
1823
  "session_id": request.session_id,
1588
1824
  "thread_id": request.thread_id,