@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.
- package/CHANGELOG.md +19 -0
- package/README.md +17 -8
- package/index.ts +215 -14
- package/ocmemog/__init__.py +1 -1
- package/ocmemog/runtime/memory/conversation_state.py +138 -32
- package/ocmemog/runtime/memory/retrieval.py +135 -6
- package/ocmemog/sidecar/app.py +249 -13
- package/ocmemog/sidecar/transcript_watcher.py +191 -61
- package/package.json +1 -1
- package/scripts/ocmemog-hydrate-stress.py +628 -0
- package/scripts/ocmemog-release-check.sh +35 -1
- package/scripts/ocmemog-sidecar.sh +24 -2
- package/scripts/ocmemog-test-rig.py +15 -1
- package/scripts/ocmemog-transcript-append.py +17 -2
|
@@ -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
|
-
|
|
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(
|
|
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")
|
package/ocmemog/sidecar/app.py
CHANGED
|
@@ -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(
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
1374
|
-
|
|
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
|
-
|
|
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,
|