@simbimbo/memory-ocmemog 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 — 2026-03-19
4
+
5
+ Memory quality, governance, and review release.
6
+
7
+ ### Highlights
8
+ - added near-duplicate collapse for transcript/session double-ingest candidate generation
9
+ - added conservative reflection reclassification and new durable buckets for `preferences` and `identity`
10
+ - wired new buckets through storage, retrieval, embeddings, health, integrity, and promotion/demotion paths
11
+ - hardened governance auto-promotion for duplicates and supersessions with stricter thresholds and guardrails
12
+ - added governance review endpoints plus dashboard review panel with filters and approve/reject actions
13
+ - fixed release-blocking distill fallback behavior in no-model environments and removed stale hard-coded bucket drift
14
+
3
15
  ## 0.1.8 — 2026-03-19
4
16
 
5
17
  Documentation and release follow-through after the llama.cpp migration and repo grooming pass.
@@ -9,6 +9,27 @@ from brain.runtime import inference
9
9
  from brain.runtime.instrumentation import emit_event
10
10
  from brain.runtime.security import redaction
11
11
 
12
+ _REVIEW_KIND_METADATA: Dict[str, Dict[str, str]] = {
13
+ "duplicate_candidate": {
14
+ "relationship": "duplicate_of",
15
+ "label": "Duplicate candidate",
16
+ "approve_label": "Approve duplicate merge",
17
+ "reject_label": "Reject duplicate merge",
18
+ },
19
+ "contradiction_candidate": {
20
+ "relationship": "contradicts",
21
+ "label": "Contradiction candidate",
22
+ "approve_label": "Mark as contradiction",
23
+ "reject_label": "Dismiss contradiction",
24
+ },
25
+ "supersession_recommendation": {
26
+ "relationship": "supersedes",
27
+ "label": "Supersession recommendation",
28
+ "approve_label": "Approve supersession",
29
+ "reject_label": "Dismiss supersession",
30
+ },
31
+ }
32
+
12
33
 
13
34
  def _sanitize(text: str) -> str:
14
35
  redacted, _ = redaction.redact_text(text)
@@ -72,8 +93,6 @@ def _recommend_supersession_from_contradictions(
72
93
 
73
94
  signal_threshold = float(os.environ.get("OCMEMOG_GOVERNANCE_SUPERSESSION_RECOMMEND_SIGNAL", "0.9") or 0.9)
74
95
  model_conf_threshold = float(os.environ.get("OCMEMOG_GOVERNANCE_SUPERSESSION_MODEL_CONFIDENCE", "0.9") or 0.9)
75
- auto_apply = os.environ.get("OCMEMOG_GOVERNANCE_AUTOPROMOTE_SUPERSESSION", "false").strip().lower() in {"1", "true", "yes"}
76
-
77
96
  ranked = sorted(contradiction_candidates, key=lambda item: float(item.get("signal") or 0.0), reverse=True)
78
97
  top = ranked[0]
79
98
  signal = float(top.get("signal") or 0.0)
@@ -105,28 +124,38 @@ def _recommend_supersession_from_contradictions(
105
124
  "model_hint": model_hint,
106
125
  })
107
126
 
108
- if auto_apply:
109
- merged = mark_memory_relationship(reference, relationship="supersedes", target_reference=target, status="active")
110
- recommendation["auto_applied"] = merged is not None
111
- recommendation["reason"] = "auto_applied_supersession" if merged is not None else "auto_apply_failed"
112
-
113
127
  return recommendation
114
128
 
115
129
 
116
- def _auto_promote_governance_candidates(
130
+ def _canonicalize_duplicate_target(reference: str) -> str:
131
+ payload = provenance.fetch_reference(reference) or {}
132
+ metadata = payload.get("metadata") or {}
133
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
134
+ canonical = str(prov.get("canonical_reference") or prov.get("duplicate_of") or reference).strip()
135
+ return canonical or reference
136
+
137
+
138
+ def _token_signature(text: str) -> frozenset[str]:
139
+ return frozenset(_tokenize(text))
140
+
141
+
142
+ def _auto_promote_duplicate_candidate(
117
143
  reference: str,
118
144
  *,
119
145
  duplicate_candidates: List[Dict[str, Any]],
120
146
  contradiction_candidates: List[Dict[str, Any]],
121
147
  ) -> Dict[str, Any]:
122
148
  auto_promote_enabled = os.environ.get("OCMEMOG_GOVERNANCE_AUTOPROMOTE", "true").strip().lower() in {"1", "true", "yes"}
123
- duplicate_threshold = float(os.environ.get("OCMEMOG_GOVERNANCE_DUPLICATE_AUTOPROMOTE_SIMILARITY", "0.92") or 0.92)
149
+ allow_with_contradictions = os.environ.get("OCMEMOG_GOVERNANCE_AUTOPROMOTE_ALLOW_CONTRADICTIONS", "false").strip().lower() in {"1", "true", "yes"}
150
+ duplicate_threshold = float(os.environ.get("OCMEMOG_GOVERNANCE_DUPLICATE_AUTOPROMOTE_SIMILARITY", "0.98") or 0.98)
151
+ duplicate_margin = float(os.environ.get("OCMEMOG_GOVERNANCE_DUPLICATE_AUTOPROMOTE_MARGIN", "0.02") or 0.02)
152
+ require_exact_tokens = os.environ.get("OCMEMOG_GOVERNANCE_DUPLICATE_AUTOPROMOTE_REQUIRE_EXACT_TOKENS", "true").strip().lower() in {"1", "true", "yes"}
124
153
  promoted: Dict[str, Any] = {"duplicate_of": None, "promoted": False, "reason": "disabled" if not auto_promote_enabled else "none"}
125
154
 
126
155
  if not auto_promote_enabled:
127
156
  return promoted
128
157
 
129
- if contradiction_candidates:
158
+ if contradiction_candidates and not allow_with_contradictions:
130
159
  promoted["reason"] = "blocked_by_contradiction_candidates"
131
160
  return promoted
132
161
 
@@ -134,13 +163,29 @@ def _auto_promote_governance_candidates(
134
163
  promoted["reason"] = "no_duplicate_candidates"
135
164
  return promoted
136
165
 
137
- top = sorted(duplicate_candidates, key=lambda item: float(item.get("similarity") or 0.0), reverse=True)[0]
166
+ payload = provenance.fetch_reference(reference) or {}
167
+ reference_content = str(payload.get("content") or "")
168
+ reference_signature = _token_signature(reference_content)
169
+ ranked = sorted(duplicate_candidates, key=lambda item: float(item.get("similarity") or 0.0), reverse=True)
170
+ top = ranked[0]
138
171
  similarity = float(top.get("similarity") or 0.0)
139
- target = str(top.get("reference") or "")
140
- if not target or similarity < duplicate_threshold:
172
+ target = _canonicalize_duplicate_target(str(top.get("reference") or ""))
173
+ if not target or target == reference or similarity < duplicate_threshold:
141
174
  promoted["reason"] = "similarity_below_threshold"
142
175
  return promoted
143
176
 
177
+ if len(ranked) > 1:
178
+ runner_up = float(ranked[1].get("similarity") or 0.0)
179
+ if similarity - runner_up < duplicate_margin:
180
+ promoted["reason"] = "ambiguous_duplicate_candidates"
181
+ return promoted
182
+
183
+ target_payload = provenance.fetch_reference(target) or {}
184
+ target_content = str(target_payload.get("content") or "")
185
+ if require_exact_tokens and _token_signature(target_content) != reference_signature:
186
+ promoted["reason"] = "token_signature_mismatch"
187
+ return promoted
188
+
144
189
  merged = mark_memory_relationship(reference, relationship="duplicate_of", target_reference=target, status="duplicate")
145
190
  promoted.update({
146
191
  "duplicate_of": target,
@@ -151,17 +196,70 @@ def _auto_promote_governance_candidates(
151
196
  return promoted
152
197
 
153
198
 
199
+ def _auto_apply_supersession_recommendation(
200
+ reference: str,
201
+ *,
202
+ contradiction_candidates: List[Dict[str, Any]],
203
+ supersession_recommendation: Dict[str, Any],
204
+ ) -> Dict[str, Any]:
205
+ recommendation = dict(supersession_recommendation or {})
206
+ if not recommendation:
207
+ return {"recommended": False, "auto_applied": False, "reason": "missing_recommendation", "target_reference": None, "signal": 0.0}
208
+
209
+ auto_apply = os.environ.get("OCMEMOG_GOVERNANCE_AUTOPROMOTE_SUPERSESSION", "false").strip().lower() in {"1", "true", "yes"}
210
+ allow_with_contradictions = os.environ.get("OCMEMOG_GOVERNANCE_AUTOPROMOTE_ALLOW_CONTRADICTIONS", "false").strip().lower() in {"1", "true", "yes"}
211
+ auto_apply_signal = float(os.environ.get("OCMEMOG_GOVERNANCE_SUPERSESSION_AUTOPROMOTE_SIGNAL", "0.97") or 0.97)
212
+ model_conf_threshold = float(os.environ.get("OCMEMOG_GOVERNANCE_SUPERSESSION_AUTOPROMOTE_MODEL_CONFIDENCE", "0.97") or 0.97)
213
+
214
+ recommendation.setdefault("auto_applied", False)
215
+ if not recommendation.get("recommended"):
216
+ recommendation["reason"] = recommendation.get("reason") or "not_recommended"
217
+ return recommendation
218
+
219
+ if not auto_apply:
220
+ return recommendation
221
+
222
+ if contradiction_candidates and not allow_with_contradictions:
223
+ recommendation["reason"] = "blocked_by_contradiction_candidates"
224
+ return recommendation
225
+
226
+ signal = float(recommendation.get("signal") or 0.0)
227
+ if signal < auto_apply_signal:
228
+ recommendation["reason"] = "signal_below_autopromote_threshold"
229
+ return recommendation
230
+
231
+ model_hint = recommendation.get("model_hint") if isinstance(recommendation.get("model_hint"), dict) else {}
232
+ if not model_hint or not model_hint.get("contradiction") or float(model_hint.get("confidence") or 0.0) < model_conf_threshold:
233
+ recommendation["reason"] = "model_hint_below_autopromote_threshold"
234
+ return recommendation
235
+
236
+ target = str(recommendation.get("target_reference") or "").strip()
237
+ if not target or target == reference:
238
+ recommendation["reason"] = "missing_target"
239
+ return recommendation
240
+
241
+ merged = mark_memory_relationship(reference, relationship="supersedes", target_reference=target, status="active")
242
+ recommendation["auto_applied"] = merged is not None
243
+ recommendation["reason"] = "auto_applied_supersession" if merged is not None else "auto_apply_failed"
244
+ return recommendation
245
+
246
+
154
247
  def _auto_attach_governance_candidates(reference: str) -> Dict[str, Any]:
155
248
  duplicate_candidates = find_duplicate_candidates(reference, limit=5, min_similarity=0.72)
156
249
  contradiction_candidates = find_contradiction_candidates(reference, limit=5, min_signal=0.55, use_model=True)
157
- auto_promotion = _auto_promote_governance_candidates(
250
+ supersession_recommendation = _recommend_supersession_from_contradictions(
251
+ reference,
252
+ contradiction_candidates=contradiction_candidates,
253
+ )
254
+ auto_promotion = _auto_promote_duplicate_candidate(
158
255
  reference,
159
256
  duplicate_candidates=duplicate_candidates,
160
257
  contradiction_candidates=contradiction_candidates,
161
258
  )
162
- supersession_recommendation = _recommend_supersession_from_contradictions(
259
+ supersession_recommendation = _auto_apply_supersession_recommendation(
163
260
  reference,
164
261
  contradiction_candidates=contradiction_candidates,
262
+ supersession_recommendation=supersession_recommendation,
165
263
  )
166
264
  payload = {
167
265
  "duplicate_candidates": [item.get("reference") for item in duplicate_candidates if item.get("reference")],
@@ -196,7 +294,7 @@ def store_memory(
196
294
  ) -> int:
197
295
  content = _sanitize(content)
198
296
  table = memory_type.strip().lower() if memory_type else "knowledge"
199
- allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
297
+ allowed = set(store.MEMORY_TABLES)
200
298
  if table not in allowed:
201
299
  table = "knowledge"
202
300
  normalized_metadata = provenance.normalize_metadata(metadata, source=source)
@@ -344,7 +442,7 @@ def find_duplicate_candidates(
344
442
  payload = provenance.fetch_reference(reference) or {}
345
443
  table = str(payload.get("table") or payload.get("type") or "")
346
444
  content = str(payload.get("content") or "")
347
- if table not in {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}:
445
+ if table not in set(store.MEMORY_TABLES):
348
446
  return []
349
447
  row_id = payload.get("id")
350
448
  conn = store.connect()
@@ -395,7 +493,7 @@ def find_contradiction_candidates(
395
493
  payload = provenance.fetch_reference(reference) or {}
396
494
  table = str(payload.get("table") or payload.get("type") or "")
397
495
  content = str(payload.get("content") or "")
398
- if table not in {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}:
496
+ if table not in set(store.MEMORY_TABLES):
399
497
  return []
400
498
  row_id = payload.get("id")
401
499
  conn = store.connect()
@@ -494,7 +592,7 @@ def list_governance_candidates(
494
592
  categories: Optional[List[str]] = None,
495
593
  limit: int = 50,
496
594
  ) -> List[Dict[str, Any]]:
497
- allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
595
+ allowed = set(store.MEMORY_TABLES)
498
596
  tables = [table for table in (categories or list(allowed)) if table in allowed]
499
597
  conn = store.connect()
500
598
  try:
@@ -532,6 +630,95 @@ def _remove_from_list(values: Any, target: str) -> List[str]:
532
630
  return [str(item) for item in (values or []) if str(item) and str(item) != target]
533
631
 
534
632
 
633
+ def _review_item_context(reference: str, *, depth: int = 1) -> Dict[str, Any]:
634
+ payload = provenance.hydrate_reference(reference, depth=depth) or {"reference": reference}
635
+ metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
636
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
637
+ return {
638
+ "reference": reference,
639
+ "bucket": payload.get("table"),
640
+ "id": payload.get("id"),
641
+ "timestamp": payload.get("timestamp"),
642
+ "content": payload.get("content"),
643
+ "memory_status": prov.get("memory_status") or metadata.get("memory_status") or "active",
644
+ "provenance_preview": payload.get("provenance_preview") or provenance.preview_from_metadata(metadata),
645
+ "metadata": metadata,
646
+ "links": payload.get("links") or [],
647
+ "backlinks": payload.get("backlinks") or [],
648
+ }
649
+
650
+
651
+ def _review_item_summary(kind: str, reference: str, target_reference: str) -> str:
652
+ if kind == "duplicate_candidate":
653
+ return f"{reference} may duplicate {target_reference}"
654
+ if kind == "contradiction_candidate":
655
+ return f"{reference} may contradict {target_reference}"
656
+ if kind == "supersession_recommendation":
657
+ return f"{reference} may supersede {target_reference}"
658
+ return f"{reference} requires review against {target_reference}"
659
+
660
+
661
+ def _review_actions(kind: str, relationship: str) -> List[Dict[str, Any]]:
662
+ meta = _REVIEW_KIND_METADATA.get(kind, {})
663
+ return [
664
+ {
665
+ "decision": "approve",
666
+ "approved": True,
667
+ "relationship": relationship,
668
+ "label": meta.get("approve_label") or "Approve",
669
+ },
670
+ {
671
+ "decision": "reject",
672
+ "approved": False,
673
+ "relationship": relationship,
674
+ "label": meta.get("reject_label") or "Reject",
675
+ },
676
+ ]
677
+
678
+
679
+ def _relationship_for_review(kind: str | None = None, relationship: str | None = None) -> str:
680
+ resolved = (relationship or "").strip().lower()
681
+ if resolved:
682
+ return resolved
683
+ kind_key = (kind or "").strip().lower()
684
+ return _REVIEW_KIND_METADATA.get(kind_key, {}).get("relationship", "")
685
+
686
+
687
+ def list_governance_review_items(
688
+ *,
689
+ categories: Optional[List[str]] = None,
690
+ limit: int = 100,
691
+ context_depth: int = 1,
692
+ ) -> List[Dict[str, Any]]:
693
+ items = governance_queue(categories=categories, limit=limit)
694
+ review_items: List[Dict[str, Any]] = []
695
+ for item in items:
696
+ kind = str(item.get("kind") or "")
697
+ relationship = _relationship_for_review(kind=kind)
698
+ reference = str(item.get("reference") or "")
699
+ target_reference = str(item.get("target_reference") or "")
700
+ if not reference or not target_reference or not relationship:
701
+ continue
702
+ review_items.append({
703
+ "review_id": f"{kind}:{reference}->{target_reference}",
704
+ "kind": kind,
705
+ "kind_label": _REVIEW_KIND_METADATA.get(kind, {}).get("label") or kind.replace("_", " "),
706
+ "relationship": relationship,
707
+ "priority": int(item.get("priority") or 0),
708
+ "timestamp": item.get("timestamp"),
709
+ "bucket": item.get("bucket"),
710
+ "signal": float(item.get("signal") or 0.0),
711
+ "reason": item.get("reason"),
712
+ "reference": reference,
713
+ "target_reference": target_reference,
714
+ "summary": _review_item_summary(kind, reference, target_reference),
715
+ "actions": _review_actions(kind, relationship),
716
+ "source": _review_item_context(reference, depth=context_depth),
717
+ "target": _review_item_context(target_reference, depth=context_depth),
718
+ })
719
+ return review_items
720
+
721
+
535
722
  def apply_governance_decision(
536
723
  reference: str,
537
724
  *,
@@ -541,7 +728,26 @@ def apply_governance_decision(
541
728
  ) -> Dict[str, Any] | None:
542
729
  relationship = (relationship or "").strip().lower()
543
730
  if approved:
544
- return mark_memory_relationship(reference, relationship=relationship, target_reference=target_reference)
731
+ merged = mark_memory_relationship(reference, relationship=relationship, target_reference=target_reference)
732
+ if merged is None:
733
+ return None
734
+ updates: Dict[str, Any] = {}
735
+ if relationship == "duplicate_of":
736
+ current = provenance.fetch_reference(reference) or {}
737
+ metadata = current.get("metadata") or {}
738
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
739
+ updates["duplicate_candidates"] = _remove_from_list(prov.get("duplicate_candidates"), target_reference)
740
+ elif relationship == "contradicts":
741
+ current = provenance.fetch_reference(reference) or {}
742
+ metadata = current.get("metadata") or {}
743
+ prov = metadata.get("provenance") if isinstance(metadata.get("provenance"), dict) else {}
744
+ updates["contradiction_candidates"] = _remove_from_list(prov.get("contradiction_candidates"), target_reference)
745
+ elif relationship == "supersedes":
746
+ updates["supersession_recommendation"] = None
747
+ if updates:
748
+ merged = provenance.force_update_memory_metadata(reference, updates) or merged
749
+ _emit(f"apply_governance_decision_{relationship}_approved")
750
+ return merged
545
751
 
546
752
  current = provenance.fetch_reference(reference) or {}
547
753
  metadata = current.get("metadata") or {}
@@ -552,14 +758,55 @@ def apply_governance_decision(
552
758
  elif relationship == "contradicts":
553
759
  updates["contradiction_candidates"] = _remove_from_list(prov.get("contradiction_candidates"), target_reference)
554
760
  elif relationship == "supersedes":
761
+ recommendation = prov.get("supersession_recommendation") if isinstance(prov.get("supersession_recommendation"), dict) else {}
762
+ if not recommendation or str(recommendation.get("target_reference") or "") == target_reference:
763
+ updates["supersession_recommendation"] = None
555
764
  updates["supersedes"] = None
556
765
  else:
557
766
  return None
558
- merged = provenance.update_memory_metadata(reference, updates)
767
+ merged = provenance.force_update_memory_metadata(reference, updates)
559
768
  _emit(f"apply_governance_decision_{relationship}_{'approved' if approved else 'rejected'}")
560
769
  return merged
561
770
 
562
771
 
772
+ def apply_governance_review_decision(
773
+ reference: str,
774
+ *,
775
+ target_reference: str,
776
+ approved: bool = True,
777
+ kind: str | None = None,
778
+ relationship: str | None = None,
779
+ context_depth: int = 1,
780
+ ) -> Dict[str, Any] | None:
781
+ resolved_relationship = _relationship_for_review(kind=kind, relationship=relationship)
782
+ if not resolved_relationship:
783
+ return None
784
+ result = apply_governance_decision(
785
+ reference,
786
+ relationship=resolved_relationship,
787
+ target_reference=target_reference,
788
+ approved=approved,
789
+ )
790
+ if result is None:
791
+ return None
792
+ resolved_kind = (kind or "").strip().lower()
793
+ if not resolved_kind:
794
+ for candidate_kind, meta in _REVIEW_KIND_METADATA.items():
795
+ if meta.get("relationship") == resolved_relationship:
796
+ resolved_kind = candidate_kind
797
+ break
798
+ return {
799
+ "reference": reference,
800
+ "target_reference": target_reference,
801
+ "approved": bool(approved),
802
+ "kind": resolved_kind or None,
803
+ "relationship": resolved_relationship,
804
+ "result": result,
805
+ "source": _review_item_context(reference, depth=context_depth),
806
+ "target": _review_item_context(target_reference, depth=context_depth),
807
+ }
808
+
809
+
563
810
  def rollback_governance_decision(
564
811
  reference: str,
565
812
  *,
@@ -617,7 +864,7 @@ def rollback_governance_decision(
617
864
 
618
865
 
619
866
  def governance_queue(*, categories: Optional[List[str]] = None, limit: int = 100) -> List[Dict[str, Any]]:
620
- allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
867
+ allowed = set(store.MEMORY_TABLES)
621
868
  tables = [table for table in (categories or list(allowed)) if table in allowed]
622
869
  conn = store.connect()
623
870
  try:
@@ -883,7 +1130,7 @@ def governance_audit(*, limit: int = 100, kinds: Optional[List[str]] = None) ->
883
1130
 
884
1131
 
885
1132
  def governance_summary(*, categories: Optional[List[str]] = None) -> Dict[str, Any]:
886
- allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
1133
+ allowed = set(store.MEMORY_TABLES)
887
1134
  tables = [table for table in (categories or list(allowed)) if table in allowed]
888
1135
  conn = store.connect()
889
1136
  try:
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import uuid
4
4
  import json
5
+ import re
6
+ from difflib import SequenceMatcher
5
7
  from typing import Dict, Any
6
8
 
7
9
  from brain.runtime.instrumentation import emit_event
@@ -11,6 +13,96 @@ from brain.runtime.security import redaction
11
13
 
12
14
 
13
15
  LOGFILE = state_store.reports_dir() / "brain_memory.log.jsonl"
16
+ _NEAR_DUPLICATE_SIMILARITY = 0.85
17
+
18
+
19
+ def _normalize_summary(text: str) -> str:
20
+ return re.sub(r"\s+", " ", str(text or "").strip().lower())
21
+
22
+
23
+ def _tokenize(text: str) -> set[str]:
24
+ return {token for token in re.findall(r"[a-z0-9]+", _normalize_summary(text))}
25
+
26
+
27
+ def _summary_similarity(left: str, right: str) -> float:
28
+ left_tokens = _tokenize(left)
29
+ right_tokens = _tokenize(right)
30
+ token_similarity = 0.0
31
+ if left_tokens and right_tokens:
32
+ overlap = len(left_tokens & right_tokens)
33
+ union = len(left_tokens | right_tokens)
34
+ token_similarity = overlap / max(1, union)
35
+ sequence_similarity = SequenceMatcher(None, _normalize_summary(left), _normalize_summary(right)).ratio()
36
+ return max(token_similarity, sequence_similarity)
37
+
38
+
39
+ def _ranges_overlap(left: Dict[str, Any], right: Dict[str, Any]) -> bool:
40
+ if str(left.get("path") or "") != str(right.get("path") or ""):
41
+ return False
42
+
43
+ def _as_int(value: Any) -> int | None:
44
+ try:
45
+ return int(value) if value is not None else None
46
+ except Exception:
47
+ return None
48
+
49
+ left_start = _as_int(left.get("start_line"))
50
+ left_end = _as_int(left.get("end_line")) or left_start
51
+ right_start = _as_int(right.get("start_line"))
52
+ right_end = _as_int(right.get("end_line")) or right_start
53
+
54
+ if left_start is None and right_start is None:
55
+ return True
56
+ if left_start is None or right_start is None:
57
+ return False
58
+ return max(left_start, right_start) <= min(left_end or left_start, right_end or right_start)
59
+
60
+
61
+ def _shares_provenance_anchor(left: Dict[str, Any], right: Dict[str, Any]) -> bool:
62
+ left_meta = provenance.normalize_metadata(left)
63
+ right_meta = provenance.normalize_metadata(right)
64
+ left_prov = left_meta.get("provenance") if isinstance(left_meta.get("provenance"), dict) else {}
65
+ right_prov = right_meta.get("provenance") if isinstance(right_meta.get("provenance"), dict) else {}
66
+
67
+ left_conv = left_prov.get("conversation") if isinstance(left_prov.get("conversation"), dict) else {}
68
+ right_conv = right_prov.get("conversation") if isinstance(right_prov.get("conversation"), dict) else {}
69
+ if left_conv.get("message_id") and left_conv.get("message_id") == right_conv.get("message_id"):
70
+ return True
71
+
72
+ left_transcript = left_prov.get("transcript_anchor") if isinstance(left_prov.get("transcript_anchor"), dict) else {}
73
+ right_transcript = right_prov.get("transcript_anchor") if isinstance(right_prov.get("transcript_anchor"), dict) else {}
74
+ if left_transcript.get("path") and right_transcript.get("path") and _ranges_overlap(left_transcript, right_transcript):
75
+ return True
76
+
77
+ left_refs = {str(item) for item in left_prov.get("source_references") or [] if str(item).strip()}
78
+ right_refs = {str(item) for item in right_prov.get("source_references") or [] if str(item).strip()}
79
+ return bool(left_refs & right_refs)
80
+
81
+
82
+ def _find_near_duplicate_candidate(conn, source_event_id: int, summary: str, metadata: Dict[str, Any]) -> str | None:
83
+ rows = conn.execute(
84
+ """
85
+ SELECT candidate_id, distilled_summary, metadata_json
86
+ FROM candidates
87
+ WHERE source_event_id != ?
88
+ ORDER BY created_at DESC, candidate_id DESC
89
+ LIMIT 250
90
+ """,
91
+ (source_event_id,),
92
+ ).fetchall()
93
+ normalized_summary = _normalize_summary(summary)
94
+ for row in rows:
95
+ existing_summary = str(row["distilled_summary"] if isinstance(row, dict) else row[1] or "")
96
+ similarity = _summary_similarity(normalized_summary, existing_summary)
97
+ if similarity < _NEAR_DUPLICATE_SIMILARITY:
98
+ continue
99
+ try:
100
+ existing_metadata = json.loads(row["metadata_json"] if isinstance(row, dict) else row[2] or "{}")
101
+ except Exception:
102
+ existing_metadata = {}
103
+ if _shares_provenance_anchor(metadata, existing_metadata):
104
+ return str(row["candidate_id"] if isinstance(row, dict) else row[0])
105
+ return None
14
106
 
15
107
 
16
108
  def create_candidate(
@@ -29,14 +121,20 @@ def create_candidate(
29
121
  normalized_metadata = provenance.normalize_metadata(metadata, source="candidate")
30
122
 
31
123
  conn = store.connect()
32
- row = conn.execute(
124
+ exact_row = conn.execute(
33
125
  "SELECT candidate_id FROM candidates WHERE source_event_id=? AND distilled_summary=?",
34
126
  (source_event_id, summary),
35
127
  ).fetchone()
36
- if row:
128
+ if exact_row:
37
129
  conn.close()
38
130
  emit_event(LOGFILE, "brain_memory_candidate_duplicate", status="ok", source_event_id=source_event_id)
39
- return {"candidate_id": row[0], "duplicate": True}
131
+ return {"candidate_id": exact_row[0], "duplicate": True}
132
+
133
+ near_duplicate_id = _find_near_duplicate_candidate(conn, source_event_id, summary, normalized_metadata)
134
+ if near_duplicate_id:
135
+ conn.close()
136
+ emit_event(LOGFILE, "brain_memory_candidate_duplicate", status="ok", source_event_id=source_event_id, duplicate_kind="near")
137
+ return {"candidate_id": near_duplicate_id, "duplicate": True}
40
138
 
41
139
  candidate_id = str(uuid.uuid4())
42
140
  verification_status = "verified" if verification_lines else "unverified"
@@ -9,7 +9,7 @@ from brain.runtime import state_store
9
9
  from brain.runtime.instrumentation import emit_event
10
10
  from brain.runtime.memory import memory_links, memory_salience, provenance, store, unresolved_state
11
11
 
12
- _ALLOWED_MEMORY_TABLES = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons", "candidates", "promotions"}
12
+ _ALLOWED_MEMORY_TABLES = {*store.MEMORY_TABLES, "candidates", "promotions"}
13
13
  LOGFILE = state_store.reports_dir() / "brain_memory.log.jsonl"
14
14
  _COMMITMENT_RE = re.compile(
15
15
  r"\b(i(?:'m| am)? going to|i will|i'll|let me|i can(?:\s+now)?|next,? i(?:'ll| will)|i should be able to)\b",
@@ -97,7 +97,12 @@ def _reject_distilled_summary(summary: str, source: str) -> bool:
97
97
  if lowered.startswith(("good job", "be proactive", "be thorough", "always check", "always remember")):
98
98
  return True
99
99
  if source and lowered == _normalize(source):
100
- return True
100
+ # In no-model environments the best available summary can be the
101
+ # original one-line experience. Keep rejecting verbose/source-equal
102
+ # fallbacks, but allow concise operational statements through.
103
+ compact_source = re.sub(r"\s+", " ", str(source or "")).strip()
104
+ if "\n" in compact_source or len(compact_source) > 120:
105
+ return True
101
106
  return False
102
107
 
103
108
 
@@ -5,13 +5,13 @@ from typing import Dict, Any
5
5
  from brain.runtime.memory import store, integrity
6
6
 
7
7
 
8
- EMBED_TABLES = ("knowledge", "runbooks", "lessons", "directives", "reflections", "tasks")
8
+ EMBED_TABLES = tuple(store.MEMORY_TABLES)
9
9
 
10
10
 
11
11
  def get_memory_health() -> Dict[str, Any]:
12
12
  conn = store.connect()
13
13
  counts: Dict[str, int] = {}
14
- for table in ["experiences", "candidates", "promotions", "memory_index", "knowledge", "runbooks", "lessons", "directives", "reflections", "tasks", "vector_embeddings"]:
14
+ for table in ["experiences", "candidates", "promotions", "memory_index", *store.MEMORY_TABLES, "vector_embeddings"]:
15
15
  try:
16
16
  counts[table] = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
17
17
  except Exception:
@@ -20,7 +20,7 @@ def get_memory_health() -> Dict[str, Any]:
20
20
  vector_count = 0
21
21
  try:
22
22
  vector_count = conn.execute(
23
- "SELECT COUNT(*) FROM vector_embeddings WHERE source_type IN ('knowledge','runbooks','lessons','directives','reflections','tasks')"
23
+ "SELECT COUNT(*) FROM vector_embeddings WHERE source_type IN ('knowledge','preferences','identity','reflections','directives','tasks','runbooks','lessons')"
24
24
  ).fetchone()[0]
25
25
  except Exception:
26
26
  vector_count = 0
@@ -7,7 +7,7 @@ from brain.runtime import state_store
7
7
  from brain.runtime.memory import store
8
8
 
9
9
 
10
- EMBED_TABLES = ("knowledge", "runbooks", "lessons", "directives", "reflections", "tasks")
10
+ EMBED_TABLES = tuple(store.MEMORY_TABLES)
11
11
 
12
12
 
13
13
  def run_integrity_check() -> Dict[str, Any]:
@@ -21,6 +21,8 @@ def run_integrity_check() -> Dict[str, Any]:
21
21
  required = {
22
22
  "experiences",
23
23
  "knowledge",
24
+ "preferences",
25
+ "identity",
24
26
  "reflections",
25
27
  "tasks",
26
28
  "directives",
@@ -12,7 +12,7 @@ from brain.runtime.instrumentation import emit_event
12
12
  from brain.runtime.memory import api, integrity, memory_consolidation, memory_links, provenance, store, unresolved_state, vector_index
13
13
 
14
14
  LOGFILE = state_store.reports_dir() / "brain_memory.log.jsonl"
15
- _WRITABLE_MEMORY_TABLES = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
15
+ _WRITABLE_MEMORY_TABLES = set(store.MEMORY_TABLES)
16
16
  _SUMMARY_PREFIX_RE = re.compile(r"^(?:insight|recommendation|lesson)\s*:\s*", re.IGNORECASE)
17
17
 
18
18
 
@@ -11,6 +11,38 @@ from brain.runtime import config
11
11
 
12
12
  LOGFILE = state_store.reports_dir() / "brain_memory.log.jsonl"
13
13
 
14
+ _PREFERENCE_PATTERNS = (
15
+ "prefer",
16
+ "preference",
17
+ "favorite",
18
+ "favourite",
19
+ "likes",
20
+ "like ",
21
+ "loves",
22
+ "enjoys",
23
+ "dislikes",
24
+ "hate ",
25
+ "avoids",
26
+ )
27
+ _IDENTITY_PATTERNS = (
28
+ "my name is",
29
+ "i am ",
30
+ "i'm ",
31
+ "my pronouns are",
32
+ "i live in",
33
+ "we live in",
34
+ "i work at",
35
+ "we work at",
36
+ "i study at",
37
+ "we study at",
38
+ "my timezone is",
39
+ "my time zone is",
40
+ "my email is",
41
+ "my phone number is",
42
+ "my birthday is",
43
+ "allergic to",
44
+ )
45
+
14
46
 
15
47
  def _should_promote(confidence: float, threshold: float | None = None) -> bool:
16
48
  threshold = config.OCMEMOG_PROMOTION_THRESHOLD if threshold is None else threshold
@@ -23,6 +55,10 @@ def _destination_table(summary: str) -> str:
23
55
  return "runbooks"
24
56
  if "lesson" in lowered or "postmortem" in lowered or "learned" in lowered:
25
57
  return "lessons"
58
+ if any(pattern in lowered for pattern in _PREFERENCE_PATTERNS):
59
+ return "preferences"
60
+ if any(pattern in lowered for pattern in _IDENTITY_PATTERNS):
61
+ return "identity"
26
62
  return "knowledge"
27
63
 
28
64
 
@@ -139,7 +175,7 @@ def promote_candidate(candidate: Dict[str, Any]) -> Dict[str, Any]:
139
175
  )
140
176
  emit_event(LOGFILE, "brain_memory_reinforcement_created", status="ok")
141
177
  if memory_id:
142
- vector_index.insert_memory(memory_id, candidate.get("distilled_summary", ""), confidence)
178
+ vector_index.insert_memory(memory_id, candidate.get("distilled_summary", ""), confidence, source_type=destination)
143
179
  try:
144
180
  from brain.runtime.memory import api as memory_api
145
181
 
@@ -176,7 +212,7 @@ def demote_memory(reference: str, reason: str = "low_confidence", new_confidence
176
212
  table, sep, raw_id = reference.partition(":")
177
213
  if not sep or not raw_id.isdigit():
178
214
  return {"ok": False, "error": "invalid_reference"}
179
- allowed = {"knowledge", "runbooks", "lessons", "directives", "reflections", "tasks"}
215
+ allowed = set(store.MEMORY_TABLES)
180
216
  if table not in allowed:
181
217
  return {"ok": False, "error": "unsupported_table"}
182
218
  conn = store.connect()
@@ -212,7 +248,7 @@ def demote_memory(reference: str, reason: str = "low_confidence", new_confidence
212
248
 
213
249
  def demote_by_confidence(limit: int = 20, threshold: float | None = None, force: bool = False) -> Dict[str, Any]:
214
250
  threshold = config.OCMEMOG_DEMOTION_THRESHOLD if threshold is None else threshold
215
- tables = ("knowledge", "runbooks", "lessons", "directives", "reflections", "tasks")
251
+ tables = tuple(store.MEMORY_TABLES)
216
252
  conn = store.connect()
217
253
  rows = []
218
254
  for table in tables:
@@ -6,7 +6,7 @@ from typing import Any, Dict, Iterable, List, Optional, Set
6
6
 
7
7
  from brain.runtime.memory import memory_links, store
8
8
 
9
- _MEMORY_TABLES = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
9
+ _MEMORY_TABLES = set(store.MEMORY_TABLES)
10
10
  _FETCHABLE_TABLES = _MEMORY_TABLES | {"promotions", "experiences", "conversation_turns", "conversation_checkpoints"}
11
11
  _SYNTHETIC_PREFIXES = {"conversation", "session", "thread", "message", "label", "transcript"}
12
12
 
@@ -55,14 +55,7 @@ def _recency_score(timestamp: str | None) -> float:
55
55
  return 0.0
56
56
 
57
57
 
58
- MEMORY_BUCKETS: Tuple[str, ...] = (
59
- "knowledge",
60
- "reflections",
61
- "directives",
62
- "tasks",
63
- "runbooks",
64
- "lessons",
65
- )
58
+ MEMORY_BUCKETS: Tuple[str, ...] = tuple(store.MEMORY_TABLES)
66
59
 
67
60
 
68
61
  def _empty_results() -> Dict[str, List[Dict[str, Any]]]:
@@ -8,6 +8,17 @@ from brain.runtime import state_store
8
8
 
9
9
  SCHEMA_VERSION = "v1"
10
10
 
11
+ MEMORY_TABLES = (
12
+ "knowledge",
13
+ "preferences",
14
+ "identity",
15
+ "reflections",
16
+ "directives",
17
+ "tasks",
18
+ "runbooks",
19
+ "lessons",
20
+ )
21
+
11
22
  SCHEMA_SQL = """
12
23
  CREATE TABLE IF NOT EXISTS memory_events (
13
24
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -110,6 +121,26 @@ CREATE TABLE IF NOT EXISTS knowledge (
110
121
  schema_version TEXT NOT NULL
111
122
  );
112
123
 
124
+ CREATE TABLE IF NOT EXISTS preferences (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
127
+ source TEXT,
128
+ confidence REAL NOT NULL DEFAULT 1.0,
129
+ metadata_json TEXT DEFAULT '{}',
130
+ content TEXT NOT NULL,
131
+ schema_version TEXT NOT NULL
132
+ );
133
+
134
+ CREATE TABLE IF NOT EXISTS identity (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ timestamp TEXT NOT NULL DEFAULT (datetime('now')),
137
+ source TEXT,
138
+ confidence REAL NOT NULL DEFAULT 1.0,
139
+ metadata_json TEXT DEFAULT '{}',
140
+ content TEXT NOT NULL,
141
+ schema_version TEXT NOT NULL
142
+ );
143
+
113
144
  CREATE TABLE IF NOT EXISTS runbooks (
114
145
  id INTEGER PRIMARY KEY AUTOINCREMENT,
115
146
  timestamp TEXT NOT NULL DEFAULT (datetime('now')),
@@ -14,14 +14,7 @@ from brain.runtime.security import redaction
14
14
 
15
15
  LOGFILE = state_store.reports_dir() / "brain_memory.log.jsonl"
16
16
 
17
- EMBEDDING_TABLES: tuple[str, ...] = (
18
- "knowledge",
19
- "runbooks",
20
- "lessons",
21
- "directives",
22
- "reflections",
23
- "tasks",
24
- )
17
+ EMBEDDING_TABLES: tuple[str, ...] = tuple(store.MEMORY_TABLES)
25
18
  _REBUILD_LOCK = threading.Lock()
26
19
  _WRITE_CHUNK_SIZE = 64
27
20
  _EMBEDDING_TEXT_LIMIT = 8000
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import threading
6
7
  import time
7
8
  from pathlib import Path
@@ -17,9 +18,9 @@ from brain.runtime.memory import api, conversation_state, distill, health, memor
17
18
  from ocmemog.sidecar.compat import flatten_results, probe_runtime
18
19
  from ocmemog.sidecar.transcript_watcher import watch_forever
19
20
 
20
- DEFAULT_CATEGORIES = ("knowledge", "reflections", "directives", "tasks", "runbooks", "lessons")
21
+ DEFAULT_CATEGORIES = tuple(store.MEMORY_TABLES)
21
22
 
22
- app = FastAPI(title="ocmemog sidecar", version="0.1.8")
23
+ app = FastAPI(title="ocmemog sidecar", version="0.1.9")
23
24
 
24
25
  API_TOKEN = os.environ.get("OCMEMOG_API_TOKEN")
25
26
 
@@ -34,6 +35,35 @@ QUEUE_STATS = {
34
35
  }
35
36
 
36
37
 
38
+ _REFLECTION_RECLASSIFY_PREFERENCE_PATTERNS = (
39
+ re.compile(r"\b(?:i|we)\s+(?:prefer|like|love|enjoy)\b", re.IGNORECASE),
40
+ re.compile(r"\b(?:i|we)\s+(?:dislike|hate|avoid)\b", re.IGNORECASE),
41
+ re.compile(r"\bmy favorite\b", re.IGNORECASE),
42
+ re.compile(r"\b(?:the )?user\s+(?:prefers|likes|loves|enjoys|dislikes|hates|avoids)\b", re.IGNORECASE),
43
+ )
44
+ _REFLECTION_RECLASSIFY_IDENTITY_PATTERNS = (
45
+ re.compile(r"\b(?:i am|i'm)\s+(?!thinking\b|trying\b|working on\b|going to\b|not\b)", re.IGNORECASE),
46
+ re.compile(r"\bmy name is\b", re.IGNORECASE),
47
+ re.compile(r"\b(?:i|we)\s+(?:live|work)\s+in\b", re.IGNORECASE),
48
+ re.compile(r"\b(?:i|we)\s+(?:work|study)\s+at\b", re.IGNORECASE),
49
+ re.compile(r"\bmy pronouns are\b", re.IGNORECASE),
50
+ re.compile(r"\bmy (?:time zone|timezone) is\b", re.IGNORECASE),
51
+ re.compile(r"\b(?:the )?user\s+(?:is|works|lives)\b", re.IGNORECASE),
52
+ )
53
+ _REFLECTION_RECLASSIFY_FACT_PATTERNS = (
54
+ re.compile(r"\b(?:i|we)\s+use\b", re.IGNORECASE),
55
+ re.compile(r"\b(?:i|we)\s+have\b", re.IGNORECASE),
56
+ re.compile(r"\b(?:i am|i'm)\s+allergic to\b", re.IGNORECASE),
57
+ re.compile(r"\bmy (?:birthday|email|phone number) is\b", re.IGNORECASE),
58
+ re.compile(r"\b(?:the )?user\s+has\b", re.IGNORECASE),
59
+ )
60
+ _REFLECTION_RECLASSIFY_BLOCKLIST_PATTERNS = (
61
+ re.compile(r"\b(?:reflect|reflection|reflections)\b", re.IGNORECASE),
62
+ re.compile(r"\b(?:i think|i wonder|maybe|perhaps)\b", re.IGNORECASE),
63
+ re.compile(r"\?$"),
64
+ )
65
+
66
+
37
67
  def _queue_stats_path() -> Path:
38
68
  path = state_store.data_dir() / "queue_stats.json"
39
69
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -255,6 +285,12 @@ class GovernanceCandidatesRequest(BaseModel):
255
285
  limit: int = Field(default=50, ge=1, le=200)
256
286
 
257
287
 
288
+ class GovernanceReviewRequest(BaseModel):
289
+ categories: Optional[List[str]] = None
290
+ limit: int = Field(default=100, ge=1, le=500)
291
+ context_depth: int = Field(default=1, ge=0, le=2)
292
+
293
+
258
294
  class GovernanceDecisionRequest(BaseModel):
259
295
  reference: str
260
296
  relationship: str
@@ -262,6 +298,15 @@ class GovernanceDecisionRequest(BaseModel):
262
298
  approved: bool = True
263
299
 
264
300
 
301
+ class GovernanceReviewDecisionRequest(BaseModel):
302
+ reference: str
303
+ target_reference: str
304
+ approved: bool = True
305
+ kind: Optional[str] = None
306
+ relationship: Optional[str] = None
307
+ context_depth: int = Field(default=1, ge=0, le=2)
308
+
309
+
265
310
  class GovernanceSummaryRequest(BaseModel):
266
311
  categories: Optional[List[str]] = None
267
312
 
@@ -311,7 +356,7 @@ class PonderRequest(BaseModel):
311
356
  class IngestRequest(BaseModel):
312
357
  content: str
313
358
  kind: str = Field(default="experience", description="experience or memory")
314
- memory_type: Optional[str] = Field(default=None, description="knowledge|reflections|directives|tasks|runbooks|lessons")
359
+ memory_type: Optional[str] = Field(default=None, description="knowledge|preferences|identity|reflections|directives|tasks|runbooks|lessons")
315
360
  source: Optional[str] = None
316
361
  task_id: Optional[str] = None
317
362
  conversation_id: Optional[str] = None
@@ -440,6 +485,23 @@ def _runtime_payload() -> Dict[str, Any]:
440
485
  }
441
486
 
442
487
 
488
+ def _retune_reflection_memory_type(content: str, memory_type: str) -> str:
489
+ if memory_type != "reflections":
490
+ return memory_type
491
+ text = re.sub(r"\s+", " ", str(content or "")).strip()
492
+ if not text or len(text) > 280:
493
+ return memory_type
494
+ if any(pattern.search(text) for pattern in _REFLECTION_RECLASSIFY_BLOCKLIST_PATTERNS):
495
+ return memory_type
496
+ if any(pattern.search(text) for pattern in _REFLECTION_RECLASSIFY_PREFERENCE_PATTERNS):
497
+ return "preferences"
498
+ if any(pattern.search(text) for pattern in _REFLECTION_RECLASSIFY_IDENTITY_PATTERNS):
499
+ return "identity"
500
+ if any(pattern.search(text) for pattern in _REFLECTION_RECLASSIFY_FACT_PATTERNS):
501
+ return "identity"
502
+ return memory_type
503
+
504
+
443
505
  def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict[str, Any]]:
444
506
  conn = store.connect()
445
507
  try:
@@ -468,12 +530,7 @@ def _fallback_search(query: str, limit: int, categories: List[str]) -> List[Dict
468
530
 
469
531
 
470
532
  _ALLOWED_MEMORY_REFERENCE_TYPES = {
471
- "knowledge",
472
- "reflections",
473
- "directives",
474
- "tasks",
475
- "runbooks",
476
- "lessons",
533
+ *store.MEMORY_TABLES,
477
534
  "conversation_turns",
478
535
  "conversation_checkpoints",
479
536
  }
@@ -497,7 +554,7 @@ def _get_row(reference: str) -> Optional[Dict[str, Any]]:
497
554
  prefix, identifier = parsed
498
555
  if prefix not in _ALLOWED_MEMORY_REFERENCE_TYPES:
499
556
  return None
500
- if prefix in {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons", "conversation_turns", "conversation_checkpoints"} and not identifier.isdigit():
557
+ if prefix in {*store.MEMORY_TABLES, "conversation_turns", "conversation_checkpoints"} and not identifier.isdigit():
501
558
  return None
502
559
  return provenance.hydrate_reference(reference, depth=2)
503
560
 
@@ -699,6 +756,24 @@ def memory_governance_candidates(request: GovernanceCandidatesRequest) -> dict[s
699
756
  }
700
757
 
701
758
 
759
+ @app.post("/memory/governance/review")
760
+ def memory_governance_review(request: GovernanceReviewRequest) -> dict[str, Any]:
761
+ runtime = _runtime_payload()
762
+ items = api.list_governance_review_items(
763
+ categories=request.categories,
764
+ limit=request.limit,
765
+ context_depth=request.context_depth,
766
+ )
767
+ return {
768
+ "ok": True,
769
+ "categories": request.categories,
770
+ "limit": request.limit,
771
+ "context_depth": request.context_depth,
772
+ "items": items,
773
+ **runtime,
774
+ }
775
+
776
+
702
777
  @app.post("/memory/governance/decision")
703
778
  def memory_governance_decision(request: GovernanceDecisionRequest) -> dict[str, Any]:
704
779
  runtime = _runtime_payload()
@@ -719,6 +794,30 @@ def memory_governance_decision(request: GovernanceDecisionRequest) -> dict[str,
719
794
  }
720
795
 
721
796
 
797
+ @app.post("/memory/governance/review/decision")
798
+ def memory_governance_review_decision(request: GovernanceReviewDecisionRequest) -> dict[str, Any]:
799
+ runtime = _runtime_payload()
800
+ result = api.apply_governance_review_decision(
801
+ request.reference,
802
+ target_reference=request.target_reference,
803
+ approved=request.approved,
804
+ kind=request.kind,
805
+ relationship=request.relationship,
806
+ context_depth=request.context_depth,
807
+ )
808
+ return {
809
+ "ok": result is not None,
810
+ "reference": request.reference,
811
+ "target_reference": request.target_reference,
812
+ "approved": request.approved,
813
+ "kind": request.kind,
814
+ "relationship": request.relationship,
815
+ "context_depth": request.context_depth,
816
+ "result": result,
817
+ **runtime,
818
+ }
819
+
820
+
722
821
  @app.post("/memory/governance/summary")
723
822
  def memory_governance_summary(request: GovernanceSummaryRequest) -> dict[str, Any]:
724
823
  runtime = _runtime_payload()
@@ -815,7 +914,7 @@ def memory_get(request: GetRequest) -> dict[str, Any]:
815
914
  "reference": request.reference,
816
915
  **runtime,
817
916
  }
818
- if prefix in {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons", "conversation_turns", "conversation_checkpoints"} and not identifier.isdigit():
917
+ if prefix in {*store.MEMORY_TABLES, "conversation_turns", "conversation_checkpoints"} and not identifier.isdigit():
819
918
  return {
820
919
  "ok": False,
821
920
  "error": "invalid_reference_id",
@@ -1072,9 +1171,10 @@ def _ingest_request(request: IngestRequest) -> dict[str, Any]:
1072
1171
  kind = (request.kind or "experience").lower()
1073
1172
  if kind == "memory":
1074
1173
  memory_type = (request.memory_type or "knowledge").lower()
1075
- allowed = {"knowledge", "reflections", "directives", "tasks", "runbooks", "lessons"}
1174
+ allowed = set(store.MEMORY_TABLES)
1076
1175
  if memory_type not in allowed:
1077
1176
  memory_type = "knowledge"
1177
+ memory_type = _retune_reflection_memory_type(content, memory_type)
1078
1178
  metadata = {
1079
1179
  "conversation_id": request.conversation_id,
1080
1180
  "session_id": request.session_id,
@@ -1249,7 +1349,7 @@ def metrics() -> dict[str, Any]:
1249
1349
  counts["queue_processed"] = QUEUE_STATS.get("processed", 0)
1250
1350
  counts["queue_errors"] = QUEUE_STATS.get("errors", 0)
1251
1351
  payload["counts"] = counts
1252
- coverage_tables = ["knowledge", "runbooks", "lessons", "directives", "reflections", "tasks"]
1352
+ coverage_tables = list(store.MEMORY_TABLES)
1253
1353
  conn = store.connect()
1254
1354
  try:
1255
1355
  payload["coverage"] = [
@@ -1302,7 +1402,7 @@ def _tail_events(limit: int = 50) -> str:
1302
1402
  def dashboard() -> HTMLResponse:
1303
1403
  metrics_payload = health.get_memory_health()
1304
1404
  counts = metrics_payload.get("counts", {})
1305
- coverage_tables = ["knowledge", "runbooks", "lessons", "directives", "reflections", "tasks"]
1405
+ coverage_tables = list(store.MEMORY_TABLES)
1306
1406
  conn = store.connect()
1307
1407
  try:
1308
1408
  coverage_rows = []
@@ -1370,6 +1470,15 @@ def dashboard() -> HTMLResponse:
1370
1470
  body {{ font-family: system-ui, sans-serif; padding: 20px; }}
1371
1471
  .metrics {{ display: flex; gap: 12px; flex-wrap: wrap; }}
1372
1472
  .card {{ border: 1px solid #ddd; padding: 10px 14px; border-radius: 8px; min-width: 140px; }}
1473
+ .panel {{ margin-top: 24px; }}
1474
+ .controls {{ display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 8px 0; }}
1475
+ .controls label {{ display: flex; gap: 6px; align-items: center; }}
1476
+ table {{ width: 100%; border-collapse: collapse; margin-top: 8px; }}
1477
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; vertical-align: top; }}
1478
+ th {{ background: #f7f7f7; }}
1479
+ button {{ padding: 6px 10px; }}
1480
+ .muted {{ color: #666; }}
1481
+ .error {{ color: #a40000; }}
1373
1482
  pre {{ background: #f7f7f7; padding: 10px; height: 320px; overflow: auto; }}
1374
1483
  </style>
1375
1484
  </head>
@@ -1380,6 +1489,45 @@ def dashboard() -> HTMLResponse:
1380
1489
  <div class="metrics" id="local-cognition">{local_html}</div>
1381
1490
  <h3>Vector coverage</h3>
1382
1491
  <div class="metrics" id="coverage">{coverage_html}</div>
1492
+ <div class="panel">
1493
+ <h3>Governance review</h3>
1494
+ <div class="controls">
1495
+ <label>Kind
1496
+ <select id="review-kind-filter">
1497
+ <option value="">All</option>
1498
+ <option value="duplicate_candidate">Duplicate</option>
1499
+ <option value="contradiction_candidate">Contradiction</option>
1500
+ <option value="supersession_recommendation">Supersession</option>
1501
+ </select>
1502
+ </label>
1503
+ <label>Priority
1504
+ <select id="review-priority-filter">
1505
+ <option value="">All</option>
1506
+ <option value="90">90</option>
1507
+ <option value="70">70</option>
1508
+ <option value="40">40</option>
1509
+ </select>
1510
+ </label>
1511
+ <button id="review-refresh" type="button">Refresh</button>
1512
+ </div>
1513
+ <div id="review-note" class="muted">Loading review items...</div>
1514
+ <div id="review-error" class="error"></div>
1515
+ <table>
1516
+ <thead>
1517
+ <tr>
1518
+ <th>Priority</th>
1519
+ <th>Kind</th>
1520
+ <th>Source</th>
1521
+ <th>Target</th>
1522
+ <th>Summary</th>
1523
+ <th>Actions</th>
1524
+ </tr>
1525
+ </thead>
1526
+ <tbody id="review-table-body">
1527
+ <tr><td colspan="6" class="muted">Loading...</td></tr>
1528
+ </tbody>
1529
+ </table>
1530
+ </div>
1383
1531
  <h3>Ponder recommendations</h3>
1384
1532
  <div id="ponder-meta" style="margin-bottom:8px; color:#666;"></div>
1385
1533
  <div id="ponder"></div>
@@ -1388,9 +1536,109 @@ def dashboard() -> HTMLResponse:
1388
1536
  <script>
1389
1537
  const metricsEl = document.getElementById('metrics');
1390
1538
  const coverageEl = document.getElementById('coverage');
1539
+ const reviewKindFilterEl = document.getElementById('review-kind-filter');
1540
+ const reviewPriorityFilterEl = document.getElementById('review-priority-filter');
1541
+ const reviewRefreshEl = document.getElementById('review-refresh');
1542
+ const reviewNoteEl = document.getElementById('review-note');
1543
+ const reviewErrorEl = document.getElementById('review-error');
1544
+ const reviewTableBodyEl = document.getElementById('review-table-body');
1391
1545
  const ponderEl = document.getElementById('ponder');
1392
1546
  const ponderMetaEl = document.getElementById('ponder-meta');
1393
1547
  const eventsEl = document.getElementById('events');
1548
+ let reviewItems = [];
1549
+ let reviewLastRefresh = null;
1550
+ const pendingReviewIds = new Set();
1551
+
1552
+ /**
1553
+ * @typedef {{Object}} GovernanceReviewContext
1554
+ * @property {{string}} reference
1555
+ * @property {{string | undefined}} bucket
1556
+ * @property {{number | undefined}} id
1557
+ * @property {{string | undefined}} timestamp
1558
+ * @property {{string | undefined}} content
1559
+ * @property {{string | undefined}} memory_status
1560
+ *
1561
+ * @typedef {{Object}} GovernanceReviewItem
1562
+ * @property {{string}} review_id
1563
+ * @property {{string}} kind
1564
+ * @property {{string | undefined}} kind_label
1565
+ * @property {{string | undefined}} relationship
1566
+ * @property {{number}} priority
1567
+ * @property {{string | undefined}} timestamp
1568
+ * @property {{number | undefined}} signal
1569
+ * @property {{string | undefined}} summary
1570
+ * @property {{string}} reference
1571
+ * @property {{string}} target_reference
1572
+ * @property {{GovernanceReviewContext | undefined}} source
1573
+ * @property {{GovernanceReviewContext | undefined}} target
1574
+ */
1575
+
1576
+ function escapeHtml(value) {{
1577
+ return String(value ?? '')
1578
+ .replaceAll('&', '&amp;')
1579
+ .replaceAll('<', '&lt;')
1580
+ .replaceAll('>', '&gt;')
1581
+ .replaceAll('"', '&quot;')
1582
+ .replaceAll("'", '&#39;');
1583
+ }}
1584
+
1585
+ function formatTimestamp(value) {{
1586
+ if (!value) {{
1587
+ return 'n/a';
1588
+ }}
1589
+ const parsed = new Date(value);
1590
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString();
1591
+ }}
1592
+
1593
+ function renderReviewTable() {{
1594
+ const kindFilter = reviewKindFilterEl.value;
1595
+ const priorityFilter = reviewPriorityFilterEl.value;
1596
+ /** @type {{GovernanceReviewItem[]}} */
1597
+ const filtered = reviewItems.filter((item) => {{
1598
+ if (kindFilter && item.kind !== kindFilter) {{
1599
+ return false;
1600
+ }}
1601
+ if (priorityFilter && String(item.priority) !== priorityFilter) {{
1602
+ return false;
1603
+ }}
1604
+ return true;
1605
+ }});
1606
+
1607
+ reviewNoteEl.textContent = `${{filtered.length}} items shown${{reviewItems.length !== filtered.length ? ` of ${{reviewItems.length}}` : ''}} • Last refresh: ${{reviewLastRefresh ? formatTimestamp(reviewLastRefresh) : 'n/a'}}`;
1608
+
1609
+ if (!filtered.length) {{
1610
+ reviewTableBodyEl.innerHTML = '<tr><td colspan="6" class="muted">No review items match the current filters.</td></tr>';
1611
+ return;
1612
+ }}
1613
+
1614
+ reviewTableBodyEl.innerHTML = filtered.map((item) => {{
1615
+ const disabled = pendingReviewIds.has(item.review_id) ? 'disabled' : '';
1616
+ const sourceContent = item.source?.content || item.reference;
1617
+ const targetContent = item.target?.content || item.target_reference;
1618
+ return `
1619
+ <tr>
1620
+ <td>${{escapeHtml(item.priority)}}</td>
1621
+ <td>${{escapeHtml(item.kind_label || item.kind)}}</td>
1622
+ <td>
1623
+ <strong>${{escapeHtml(item.reference)}}</strong><br/>
1624
+ <span class="muted">${{escapeHtml(sourceContent)}}</span>
1625
+ </td>
1626
+ <td>
1627
+ <strong>${{escapeHtml(item.target_reference)}}</strong><br/>
1628
+ <span class="muted">${{escapeHtml(targetContent)}}</span>
1629
+ </td>
1630
+ <td>
1631
+ <strong>${{escapeHtml(item.summary || '')}}</strong><br/>
1632
+ <span class="muted">${{escapeHtml(item.relationship || '')}}${{item.signal ? ` • signal ${{item.signal}}` : ''}}</span>
1633
+ </td>
1634
+ <td>
1635
+ <button type="button" data-review-id="${{escapeHtml(item.review_id)}}" data-approved="true" ${{disabled}}>Approve</button>
1636
+ <button type="button" data-review-id="${{escapeHtml(item.review_id)}}" data-approved="false" ${{disabled}}>Reject</button>
1637
+ </td>
1638
+ </tr>
1639
+ `;
1640
+ }}).join('');
1641
+ }}
1394
1642
 
1395
1643
  async function refreshMetrics() {{
1396
1644
  const res = await fetch('/metrics');
@@ -1423,9 +1671,81 @@ def dashboard() -> HTMLResponse:
1423
1671
  ).join('');
1424
1672
  }}
1425
1673
 
1674
+ async function refreshGovernanceReview() {{
1675
+ reviewErrorEl.textContent = '';
1676
+ try {{
1677
+ const res = await fetch('/memory/governance/review', {{
1678
+ method: 'POST',
1679
+ headers: {{ 'Content-Type': 'application/json' }},
1680
+ body: JSON.stringify({{ limit: 100, context_depth: 1 }}),
1681
+ }});
1682
+ const data = await res.json();
1683
+ if (!res.ok || !data.ok) {{
1684
+ throw new Error(data.error || `review request failed: ${{res.status}}`);
1685
+ }}
1686
+ reviewItems = Array.isArray(data.items) ? data.items : [];
1687
+ reviewLastRefresh = new Date().toISOString();
1688
+ renderReviewTable();
1689
+ }} catch (error) {{
1690
+ reviewErrorEl.textContent = error instanceof Error ? error.message : String(error);
1691
+ reviewTableBodyEl.innerHTML = '<tr><td colspan="6" class="muted">Unable to load review items.</td></tr>';
1692
+ }}
1693
+ }}
1694
+
1695
+ async function applyGovernanceReviewDecision(reviewId, approved) {{
1696
+ const item = reviewItems.find((entry) => entry.review_id === reviewId);
1697
+ if (!item) {{
1698
+ return;
1699
+ }}
1700
+ pendingReviewIds.add(reviewId);
1701
+ renderReviewTable();
1702
+ reviewErrorEl.textContent = '';
1703
+ try {{
1704
+ const res = await fetch('/memory/governance/review/decision', {{
1705
+ method: 'POST',
1706
+ headers: {{ 'Content-Type': 'application/json' }},
1707
+ body: JSON.stringify({{
1708
+ reference: item.reference,
1709
+ target_reference: item.target_reference,
1710
+ approved,
1711
+ kind: item.kind,
1712
+ relationship: item.relationship,
1713
+ context_depth: 1,
1714
+ }}),
1715
+ }});
1716
+ const data = await res.json();
1717
+ if (!res.ok || !data.ok) {{
1718
+ throw new Error(data.error || `decision request failed: ${{res.status}}`);
1719
+ }}
1720
+ await refreshGovernanceReview();
1721
+ }} catch (error) {{
1722
+ reviewErrorEl.textContent = error instanceof Error ? error.message : String(error);
1723
+ }} finally {{
1724
+ pendingReviewIds.delete(reviewId);
1725
+ renderReviewTable();
1726
+ }}
1727
+ }}
1728
+
1729
+ reviewKindFilterEl.addEventListener('change', renderReviewTable);
1730
+ reviewPriorityFilterEl.addEventListener('change', renderReviewTable);
1731
+ reviewRefreshEl.addEventListener('click', refreshGovernanceReview);
1732
+ reviewTableBodyEl.addEventListener('click', (event) => {{
1733
+ const target = event.target;
1734
+ if (!(target instanceof HTMLButtonElement)) {{
1735
+ return;
1736
+ }}
1737
+ const reviewId = target.dataset.reviewId;
1738
+ if (!reviewId) {{
1739
+ return;
1740
+ }}
1741
+ applyGovernanceReviewDecision(reviewId, target.dataset.approved === 'true');
1742
+ }});
1743
+
1426
1744
  refreshMetrics();
1745
+ refreshGovernanceReview();
1427
1746
  refreshPonder();
1428
1747
  setInterval(refreshMetrics, 5000);
1748
+ setInterval(refreshGovernanceReview, 15000);
1429
1749
  setInterval(refreshPonder, 10000);
1430
1750
 
1431
1751
  const es = new EventSource('/events');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simbimbo/memory-ocmemog",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Advanced OpenClaw memory plugin with durable recall, transcript-backed continuity, and sidecar APIs",
5
5
  "license": "MIT",
6
6
  "repository": {