nexo-brain 7.27.2 → 7.27.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.27.2",
3
+ "version": "7.27.6",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.27.2` is the current packaged-runtime line. Patch release over v7.27.1 - legacy Codex preferences now migrate to OpenAI correctly, and the F0.6 config-folder transition keeps reading existing `config/schedule.json` before it is moved.
21
+ Version `7.27.6` is the current packaged-runtime line. Patch release over v7.27.5 - operational memory continuity now persists promises as commitments, routes pre-answer questions through evidence-backed memory, and exposes observation-queue convergence in health checks.
22
+
23
+ Previously in `7.27.5`: patch release over v7.27.4 - Desktop onboarding asks for the user's name directly in Spanish with `¿Cómo te llamas?`, matching Desktop fallback copy.
22
24
 
23
25
  Previously in `7.27.1`: patch release over v7.27.0 - lifecycle stop calls skip external provider session UUIDs safely, and provider runtime selection keeps chat plus automation aligned to the same Anthropic/OpenAI account.
24
26
 
@@ -413,7 +415,7 @@ Version `5.0.0` closes the loop between memory, decisions, outcomes, and reusabl
413
415
  |------------|-------------|-------|----------------|
414
416
  | Shared brain / MCP runtime | Yes | Yes | Yes |
415
417
  | Managed bootstrap document | `~/.claude/CLAUDE.md` | `~/.codex/AGENTS.md` | Not applicable |
416
- | Global startup bootstrap sync | Native via hooks + bootstrap | Managed via bootstrap + Codex config `initial_messages` + `mcp_servers.nexo` | Managed MCP-only shared-brain metadata |
418
+ | Global startup bootstrap sync | Native via hooks + bootstrap | Managed via bootstrap + Codex config `mcp_servers.nexo` | Managed MCP-only shared-brain metadata |
417
419
  | `nexo chat` terminal client | Yes | Yes | No |
418
420
  | Background automation backend | Recommended | Supported | No |
419
421
  | Raw transcript source for Deep Sleep | Yes | Yes | No |
@@ -18,6 +18,15 @@ const PRESERVED_FLAG_ENV_KEYS = [
18
18
  "NEXO_SKIP_MODEL_WARMUP",
19
19
  "NEXO_NO_LAUNCHD",
20
20
  "NEXO_INSTALL_NO_LAUNCHD",
21
+ // v0.41.23 — forward the bundled-node hints so client_sync.py
22
+ // _bundled_npm_runtime() can resolve a real Linux node + npm-cli.js inside
23
+ // WSL and actually install claude/codex under NEXO_DESKTOP_MANAGED=1.
24
+ // Without these surviving the `env -i` reset below, the managed install
25
+ // hard-returns managed_install_failed and installs NOTHING → claude/codex
26
+ // absent → `claude --version` exit 127 → the Connect buttons open no
27
+ // browser on a fresh Windows install. (Learning #638.)
28
+ "NEXO_DESKTOP_NODE",
29
+ "NEXO_DESKTOP_NPM_CLI",
21
30
  ];
22
31
 
23
32
  function isWindowsHost(platform = process.platform) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.27.2",
3
+ "version": "7.27.6",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -625,10 +625,6 @@ def _codex_managed_initial_messages_enabled() -> bool:
625
625
  )
626
626
 
627
627
 
628
- def _codex_initial_messages_config(prompt_text: str) -> str:
629
- return f'initial_messages=[{{role="system",content={json.dumps(prompt_text, ensure_ascii=False)}}}]'
630
-
631
-
632
628
  def _codex_interactive_launch_flags() -> list[str]:
633
629
  return ["--sandbox", "danger-full-access", "--ask-for-approval", "never"]
634
630
 
@@ -752,9 +748,6 @@ def build_interactive_client_command(
752
748
  "Codex launcher not found in PATH. Install `codex` first or reconfigure NEXO."
753
749
  )
754
750
  cmd = [codex_bin, *_codex_interactive_launch_flags()]
755
- bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
756
- if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
757
- cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
758
751
  if resolved_model:
759
752
  cmd.extend(["-m", resolved_model])
760
753
  if resolved_effort:
@@ -915,9 +908,6 @@ def build_followup_terminal_shell_command(
915
908
  )
916
909
  target_cwd = str(Path(cwd).expanduser()) if cwd else str(Path.home())
917
910
  cmd = [codex_bin, *_codex_interactive_launch_flags()]
918
- bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
919
- if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
920
- cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
921
911
  if resolved_model:
922
912
  cmd.extend(["-m", resolved_model])
923
913
  if resolved_effort:
@@ -1451,9 +1441,6 @@ def run_automation_prompt(
1451
1441
  "-o",
1452
1442
  str(output_path),
1453
1443
  ]
1454
- bootstrap_prompt = _load_client_bootstrap_prompt(CLIENT_CODEX)
1455
- if bootstrap_prompt and not _codex_managed_initial_messages_enabled():
1456
- cmd.extend(["-c", _codex_initial_messages_config(bootstrap_prompt)])
1457
1444
  if resolved_model:
1458
1445
  cmd.extend(["-m", resolved_model])
1459
1446
  if resolved_effort:
@@ -138,6 +138,22 @@ def _extract_openai_text(response) -> str:
138
138
  return ""
139
139
 
140
140
 
141
+ def _openai_messages(prompt: str, system: str | None) -> list[dict]:
142
+ if system:
143
+ return [
144
+ {
145
+ "role": "user",
146
+ "content": (
147
+ "Instructions:\n"
148
+ f"{system}\n\n"
149
+ "Task:\n"
150
+ f"{prompt}"
151
+ ),
152
+ }
153
+ ]
154
+ return [{"role": "user", "content": prompt}]
155
+
156
+
141
157
  def _call_anthropic_raw(
142
158
  *,
143
159
  prompt: str,
@@ -214,10 +230,7 @@ def _call_openai_raw(
214
230
  raise ClassifierUnavailableError("openai: no OPENAI_API_KEY found")
215
231
 
216
232
  client = openai.OpenAI(api_key=api_key, timeout=timeout)
217
- messages: list[dict] = []
218
- if system:
219
- messages.append({"role": "system", "content": system})
220
- messages.append({"role": "user", "content": prompt})
233
+ messages = _openai_messages(prompt, system)
221
234
 
222
235
  try:
223
236
  response = client.chat.completions.create(
@@ -213,11 +213,55 @@ class LocalZeroShotClassifier:
213
213
  )
214
214
 
215
215
 
216
+ _SHARED_CLASSIFIER_LOCK = threading.Lock()
217
+ _SHARED_CLASSIFIERS: dict[tuple[str, str], LocalZeroShotClassifier] = {}
218
+
219
+
220
+ def get_shared_zero_shot_classifier(
221
+ *,
222
+ model_id: str = MODEL_ID,
223
+ revision: str = MODEL_REVISION,
224
+ confidence_floor: float = DEFAULT_CONFIDENCE_FLOOR,
225
+ ) -> LocalZeroShotClassifier:
226
+ """Return the process-wide classifier wrapper for this pinned model.
227
+
228
+ The underlying transformer pipeline is still lazy-loaded, but once it is
229
+ warm all semantic callers share it instead of creating fresh wrappers.
230
+ """
231
+ key = (model_id, revision)
232
+ with _SHARED_CLASSIFIER_LOCK:
233
+ classifier = _SHARED_CLASSIFIERS.get(key)
234
+ if classifier is None:
235
+ classifier = LocalZeroShotClassifier(
236
+ model_id=model_id,
237
+ revision=revision,
238
+ confidence_floor=confidence_floor,
239
+ )
240
+ _SHARED_CLASSIFIERS[key] = classifier
241
+ else:
242
+ classifier.confidence_floor = confidence_floor
243
+ return classifier
244
+
245
+
246
+ def is_local_classifier_warm(
247
+ *,
248
+ model_id: str = MODEL_ID,
249
+ revision: str = MODEL_REVISION,
250
+ ) -> bool:
251
+ """Return True only when this process already has a loaded pipeline."""
252
+ key = (model_id, revision)
253
+ with _SHARED_CLASSIFIER_LOCK:
254
+ classifier = _SHARED_CLASSIFIERS.get(key)
255
+ return bool(classifier is not None and classifier._pipe is not None)
256
+
257
+
216
258
  __all__ = [
217
259
  "LocalZeroShotClassifier",
218
260
  "ClassificationResult",
219
261
  "MODEL_ID",
220
262
  "MODEL_REVISION",
221
263
  "DEFAULT_CONFIDENCE_FLOOR",
264
+ "get_shared_zero_shot_classifier",
265
+ "is_local_classifier_warm",
222
266
  "is_local_classifier_available_with_install_state",
223
267
  ]
@@ -978,12 +978,10 @@ def _sync_codex_managed_config(
978
978
  else:
979
979
  payload["features"] = {"hooks": True}
980
980
 
981
- payload["initial_messages"] = [
982
- {
983
- "role": "system",
984
- "content": bootstrap_prompt,
985
- }
986
- ] if bootstrap_prompt else []
981
+ # Codex model APIs used by some accounts reject system-role bootstrap
982
+ # messages. Keep the durable bootstrap in AGENTS.md and remove any legacy
983
+ # initial_messages block written by older NEXO versions.
984
+ payload.pop("initial_messages", None)
987
985
 
988
986
  nexo_table = payload.setdefault("nexo", {})
989
987
  codex_table = nexo_table.setdefault("codex", {})
@@ -56,6 +56,7 @@ _outcomes = _load_submodule("db._outcomes")
56
56
  _goal_profiles = _load_submodule("db._goal_profiles")
57
57
  _continuity = _load_submodule("db._continuity")
58
58
  _memory_v2 = _load_submodule("db._memory_v2")
59
+ _commitments = _load_submodule("db._commitments")
59
60
 
60
61
  # Core: connection, constants, init, utils
61
62
  from db._core import (
@@ -110,6 +111,13 @@ from db._memory_v2 import (
110
111
  memory_observation_stats,
111
112
  )
112
113
 
114
+ from db._commitments import (
115
+ create_commitment,
116
+ list_commitments,
117
+ update_commitment_status,
118
+ resolve_matching_commitments,
119
+ )
120
+
113
121
  # PostToolUse inbox-reminder rate limit (v6.0.1)
114
122
  _hook_inbox_reminders = _load_submodule("db._hook_inbox_reminders")
115
123
  from db._hook_inbox_reminders import (
@@ -0,0 +1,344 @@
1
+ """Durable commitment ledger for future-action promises.
2
+
3
+ The ledger is an index over promises and their linked action artifacts. It is
4
+ not a scheduler: followups, workflows, outcomes, and protocol tasks remain the
5
+ systems that execute work.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import math
13
+ import re
14
+ from typing import Any
15
+
16
+ from db._core import get_db, now_epoch
17
+
18
+
19
+ ACTIVE_STATUSES = {"active", "in_progress", "pending"}
20
+ CLOSED_STATUSES = {"fulfilled", "missed", "cancelled", "superseded"}
21
+ VALID_OWNERS = {"agent", "user", "shared", "waiting"}
22
+ VALID_STATUSES = ACTIVE_STATUSES | CLOSED_STATUSES
23
+ _WORD_RE = re.compile(r"[a-z0-9_]+")
24
+
25
+
26
+ def _clean_text(value: Any, *, max_chars: int = 1200) -> str:
27
+ text = str(value or "").strip()
28
+ if len(text) > max_chars:
29
+ return text[: max(0, max_chars - 3)].rstrip() + "..."
30
+ return text
31
+
32
+
33
+ def _status(value: str) -> str:
34
+ clean = str(value or "active").strip().lower()
35
+ return clean if clean in VALID_STATUSES else "active"
36
+
37
+
38
+ def _owner(value: str) -> str:
39
+ clean = str(value or "agent").strip().lower()
40
+ return clean if clean in VALID_OWNERS else "agent"
41
+
42
+
43
+ def _json(value: dict[str, Any] | None) -> str:
44
+ try:
45
+ return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
46
+ except Exception:
47
+ return "{}"
48
+
49
+
50
+ def _tokens(value: str) -> set[str]:
51
+ return {item for item in _WORD_RE.findall(str(value or "").lower()) if len(item) >= 3}
52
+
53
+
54
+ def _dedupe_key(*, source_type: str, source_id: str, session_id: str, statement: str) -> str:
55
+ seed = "|".join(
56
+ [
57
+ _clean_text(source_type, max_chars=80).lower(),
58
+ _clean_text(source_id, max_chars=160).lower(),
59
+ _clean_text(session_id, max_chars=160).lower(),
60
+ _clean_text(statement, max_chars=600).lower(),
61
+ ]
62
+ )
63
+ return hashlib.sha1(seed.encode("utf-8", errors="ignore"), usedforsecurity=False).hexdigest()
64
+
65
+
66
+ def _commitment_id(dedupe_key: str) -> str:
67
+ return f"CM-{dedupe_key[:16].upper()}"
68
+
69
+
70
+ def _row_dict(row: Any) -> dict[str, Any]:
71
+ if not row:
72
+ return {}
73
+ data = dict(row)
74
+ try:
75
+ data["metadata"] = json.loads(data.get("metadata_json") or "{}")
76
+ except Exception:
77
+ data["metadata"] = {}
78
+ return data
79
+
80
+
81
+ def create_commitment(
82
+ *,
83
+ statement: str,
84
+ source_type: str = "",
85
+ source_id: str = "",
86
+ memory_event_uid: str = "",
87
+ session_id: str = "",
88
+ conversation_id: str = "",
89
+ project_key: str = "",
90
+ owner: str = "agent",
91
+ deadline: str = "",
92
+ status: str = "active",
93
+ confidence: float = 0.5,
94
+ action_ref_type: str = "",
95
+ action_ref_id: str = "",
96
+ outcome_id: int | None = None,
97
+ evidence_ref: str = "",
98
+ dedupe_key: str = "",
99
+ metadata: dict[str, Any] | None = None,
100
+ created_at: float | None = None,
101
+ ) -> dict[str, Any]:
102
+ clean_statement = _clean_text(statement)
103
+ if not clean_statement:
104
+ return {"ok": False, "error": "statement_required"}
105
+ stamp = float(created_at if created_at is not None else now_epoch())
106
+ clean_source_type = _clean_text(source_type, max_chars=80)
107
+ clean_source_id = _clean_text(source_id, max_chars=180)
108
+ clean_session_id = _clean_text(session_id, max_chars=180)
109
+ key = _clean_text(dedupe_key, max_chars=80) or _dedupe_key(
110
+ source_type=clean_source_type,
111
+ source_id=clean_source_id,
112
+ session_id=clean_session_id,
113
+ statement=clean_statement,
114
+ )
115
+ commitment_id = _commitment_id(key)
116
+ conn = get_db()
117
+ existing = conn.execute("SELECT * FROM commitments WHERE dedupe_key = ? LIMIT 1", (key,)).fetchone()
118
+ if existing:
119
+ result = _row_dict(existing)
120
+ result.update({"ok": True, "created": False})
121
+ return result
122
+ conn.execute(
123
+ """
124
+ INSERT INTO commitments (
125
+ id, created_at, updated_at, closed_at, source_type, source_id,
126
+ memory_event_uid, session_id, conversation_id, project_key,
127
+ statement, owner, deadline, status, confidence, action_ref_type,
128
+ action_ref_id, outcome_id, evidence_ref, dedupe_key, metadata_json
129
+ )
130
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131
+ """,
132
+ (
133
+ commitment_id,
134
+ stamp,
135
+ stamp,
136
+ clean_source_type,
137
+ clean_source_id,
138
+ _clean_text(memory_event_uid, max_chars=180),
139
+ clean_session_id,
140
+ _clean_text(conversation_id, max_chars=180),
141
+ _clean_text(project_key, max_chars=120),
142
+ clean_statement,
143
+ _owner(owner),
144
+ _clean_text(deadline, max_chars=80),
145
+ _status(status),
146
+ max(0.0, min(1.0, float(confidence or 0.5))),
147
+ _clean_text(action_ref_type, max_chars=80),
148
+ _clean_text(action_ref_id, max_chars=180),
149
+ int(outcome_id) if outcome_id is not None else None,
150
+ _clean_text(evidence_ref, max_chars=240),
151
+ key,
152
+ _json(metadata),
153
+ ),
154
+ )
155
+ conn.commit()
156
+ row = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id,)).fetchone()
157
+ result = _row_dict(row)
158
+ result.update({"ok": True, "created": True})
159
+ return result
160
+
161
+
162
+ def list_commitments(
163
+ *,
164
+ query: str = "",
165
+ status: str = "",
166
+ session_id: str = "",
167
+ project_key: str = "",
168
+ owner: str = "",
169
+ limit: int = 20,
170
+ ) -> list[dict[str, Any]]:
171
+ clauses = ["1=1"]
172
+ params: list[Any] = []
173
+ if status:
174
+ clean_status = status.strip().lower()
175
+ if clean_status in {"open", "active"}:
176
+ clauses.append("status IN ('active','in_progress','pending')")
177
+ elif clean_status in {"closed", "resolved"}:
178
+ clauses.append("status IN ('fulfilled','missed','cancelled','superseded')")
179
+ else:
180
+ clauses.append("status = ?")
181
+ params.append(_status(clean_status))
182
+ if session_id.strip():
183
+ clauses.append("session_id = ?")
184
+ params.append(session_id.strip())
185
+ if project_key.strip():
186
+ clauses.append("project_key = ?")
187
+ params.append(project_key.strip())
188
+ if owner.strip():
189
+ clauses.append("owner = ?")
190
+ params.append(_owner(owner))
191
+ max_items = max(1, min(int(limit or 20), 100))
192
+ terms = _tokens(query)
193
+ # Query filtering is semantic-ish and happens in Python over multiple
194
+ # fields. Fetch a larger bounded window first so older relevant open
195
+ # commitments do not disappear merely because the caller requested a
196
+ # small result limit.
197
+ query_window = 500 if terms else max_items * 3
198
+ rows = get_db().execute(
199
+ f"""
200
+ SELECT * FROM commitments
201
+ WHERE {' AND '.join(clauses)}
202
+ ORDER BY
203
+ CASE status WHEN 'active' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'pending' THEN 2 ELSE 3 END,
204
+ COALESCE(deadline, '') ASC,
205
+ updated_at DESC
206
+ LIMIT ?
207
+ """,
208
+ [*params, query_window],
209
+ ).fetchall()
210
+ items = [_row_dict(row) for row in rows]
211
+ if terms:
212
+ filtered = []
213
+ for item in items:
214
+ haystack = _tokens(
215
+ " ".join(
216
+ str(item.get(field) or "")
217
+ for field in (
218
+ "id",
219
+ "statement",
220
+ "source_type",
221
+ "source_id",
222
+ "session_id",
223
+ "project_key",
224
+ "action_ref_type",
225
+ "action_ref_id",
226
+ "evidence_ref",
227
+ )
228
+ )
229
+ )
230
+ if terms & haystack:
231
+ filtered.append(item)
232
+ items = filtered
233
+ return items[:max_items]
234
+
235
+
236
+ def update_commitment_status(
237
+ commitment_id: str,
238
+ *,
239
+ status: str,
240
+ evidence_ref: str = "",
241
+ action_ref_type: str = "",
242
+ action_ref_id: str = "",
243
+ outcome_id: int | None = None,
244
+ metadata: dict[str, Any] | None = None,
245
+ now: float | None = None,
246
+ ) -> dict[str, Any]:
247
+ clean_status = _status(status)
248
+ stamp = float(now if now is not None else now_epoch())
249
+ closed_at = stamp if clean_status in CLOSED_STATUSES else None
250
+ conn = get_db()
251
+ row = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id.strip(),)).fetchone()
252
+ if not row:
253
+ return {"ok": False, "error": f"commitment_not_found:{commitment_id}"}
254
+ merged_metadata = _row_dict(row).get("metadata") or {}
255
+ merged_metadata.update(metadata or {})
256
+ conn.execute(
257
+ """
258
+ UPDATE commitments
259
+ SET status = ?,
260
+ updated_at = ?,
261
+ closed_at = ?,
262
+ evidence_ref = COALESCE(NULLIF(?, ''), evidence_ref),
263
+ action_ref_type = COALESCE(NULLIF(?, ''), action_ref_type),
264
+ action_ref_id = COALESCE(NULLIF(?, ''), action_ref_id),
265
+ outcome_id = COALESCE(?, outcome_id),
266
+ metadata_json = ?
267
+ WHERE id = ?
268
+ """,
269
+ (
270
+ clean_status,
271
+ stamp,
272
+ closed_at,
273
+ _clean_text(evidence_ref, max_chars=240),
274
+ _clean_text(action_ref_type, max_chars=80),
275
+ _clean_text(action_ref_id, max_chars=180),
276
+ int(outcome_id) if outcome_id is not None else None,
277
+ _json(merged_metadata),
278
+ commitment_id.strip(),
279
+ ),
280
+ )
281
+ conn.commit()
282
+ updated = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id.strip(),)).fetchone()
283
+ result = _row_dict(updated)
284
+ result["ok"] = True
285
+ return result
286
+
287
+
288
+ def resolve_matching_commitments(
289
+ *,
290
+ session_id: str = "",
291
+ evidence_text: str = "",
292
+ action_ref_type: str = "",
293
+ action_ref_id: str = "",
294
+ evidence_ref: str = "",
295
+ status: str = "fulfilled",
296
+ limit: int = 5,
297
+ ) -> dict[str, Any]:
298
+ """Close active commitments when completion evidence overlaps enough."""
299
+ terms = _tokens(evidence_text)
300
+ if not terms and not (action_ref_type and action_ref_id):
301
+ return {"ok": True, "resolved": 0, "items": [], "reason": "no_matching_signal"}
302
+ candidates = list_commitments(session_id=session_id, status="open", limit=max(1, min(limit, 20)))
303
+ resolved: list[dict[str, Any]] = []
304
+ for item in candidates:
305
+ action_match = (
306
+ bool(action_ref_type and action_ref_id)
307
+ and item.get("action_ref_type") == action_ref_type
308
+ and item.get("action_ref_id") == action_ref_id
309
+ )
310
+ statement_terms = _tokens(str(item.get("statement") or ""))
311
+ matched_terms = terms & statement_terms
312
+ overlap = len(matched_terms) / max(1, len(statement_terms))
313
+ required_matches = max(4, math.ceil(len(statement_terms) * 0.55))
314
+ strong_text_match = bool(
315
+ len(matched_terms) >= required_matches
316
+ and overlap >= 0.65
317
+ )
318
+ if not action_match and not strong_text_match:
319
+ continue
320
+ resolved.append(
321
+ update_commitment_status(
322
+ str(item.get("id")),
323
+ status=status,
324
+ evidence_ref=evidence_ref,
325
+ action_ref_type=action_ref_type,
326
+ action_ref_id=action_ref_id,
327
+ metadata={
328
+ "resolved_by": "action_ref" if action_match else "strong_matching_evidence",
329
+ "overlap": round(overlap, 4),
330
+ "matched_terms": sorted(matched_terms)[:12],
331
+ },
332
+ )
333
+ )
334
+ if len(resolved) >= limit:
335
+ break
336
+ return {"ok": True, "resolved": len(resolved), "items": resolved}
337
+
338
+
339
+ __all__ = [
340
+ "create_commitment",
341
+ "list_commitments",
342
+ "update_commitment_status",
343
+ "resolve_matching_commitments",
344
+ ]
@@ -1028,7 +1028,7 @@ def backfill_memory_observations(
1028
1028
  return {"ok": True, "sources": sorted(requested), "seen": seen, "created_or_updated": created}
1029
1029
 
1030
1030
 
1031
- def memory_observation_health() -> dict:
1031
+ def memory_observation_health(*, pending_sla_seconds: int = 3600, now: float | None = None) -> dict:
1032
1032
  conn = _core().get_db()
1033
1033
  tables = {
1034
1034
  "memory_events": _table_exists(conn, "memory_events"),
@@ -1044,11 +1044,40 @@ def memory_observation_health() -> dict:
1044
1044
  if tables["memory_observations"]:
1045
1045
  counts["observations"] = int(conn.execute("SELECT COUNT(*) FROM memory_observations").fetchone()[0])
1046
1046
  latest["observation_created_at"] = conn.execute("SELECT MAX(created_at) FROM memory_observations").fetchone()[0]
1047
+ pending_sla = max(1, int(pending_sla_seconds or 3600))
1048
+ pending_older_than_sla = 0
1049
+ oldest_pending = None
1050
+ max_pending_age_seconds = 0.0
1047
1051
  if tables["memory_observation_queue"]:
1048
1052
  rows = conn.execute(
1049
1053
  "SELECT status, COUNT(*) AS cnt FROM memory_observation_queue GROUP BY status"
1050
1054
  ).fetchall()
1051
1055
  counts["queue"] = {row["status"]: int(row["cnt"]) for row in rows}
1056
+ stamp = float(now if now is not None else _core().now_epoch())
1057
+ stale_cutoff = stamp - pending_sla
1058
+ pending_older_than_sla = int(
1059
+ conn.execute(
1060
+ """
1061
+ SELECT COUNT(*)
1062
+ FROM memory_observation_queue
1063
+ WHERE status IN ('pending', 'failed')
1064
+ AND created_at <= ?
1065
+ """,
1066
+ (stale_cutoff,),
1067
+ ).fetchone()[0]
1068
+ )
1069
+ oldest = conn.execute(
1070
+ """
1071
+ SELECT event_uid, status, created_at, updated_at, last_error
1072
+ FROM memory_observation_queue
1073
+ WHERE status IN ('pending', 'failed')
1074
+ ORDER BY created_at ASC, id ASC
1075
+ LIMIT 1
1076
+ """
1077
+ ).fetchone()
1078
+ if oldest:
1079
+ oldest_pending = dict(oldest)
1080
+ max_pending_age_seconds = max(0.0, stamp - float(oldest["created_at"] or stamp))
1052
1081
 
1053
1082
  fts_enabled = _is_virtual_fts_table(conn, "memory_observations_fts")
1054
1083
  fts_queryable = False
@@ -1061,12 +1090,33 @@ def memory_observation_health() -> dict:
1061
1090
 
1062
1091
  missing_required = [name for name in ("memory_events", "memory_observations", "memory_observation_queue") if not tables[name]]
1063
1092
  failed_queue = int(counts["queue"].get("failed", 0))
1093
+ warnings = []
1094
+ if pending_older_than_sla:
1095
+ warnings.append(
1096
+ {
1097
+ "code": "pending_sla_breached",
1098
+ "pending_older_than_sla": pending_older_than_sla,
1099
+ "pending_sla_seconds": pending_sla,
1100
+ "max_pending_age_seconds": max_pending_age_seconds,
1101
+ "oldest_pending": oldest_pending,
1102
+ }
1103
+ )
1104
+ if failed_queue:
1105
+ warnings.append({"code": "queue_failed", "failed": failed_queue})
1064
1106
  return {
1065
- "ok": not missing_required and failed_queue == 0,
1107
+ "ok": not missing_required and failed_queue == 0 and pending_older_than_sla == 0,
1066
1108
  "tables": tables,
1067
1109
  "missing_required": missing_required,
1068
1110
  "counts": counts,
1069
1111
  "latest": latest,
1112
+ "queue_sla": {
1113
+ "pending_sla_seconds": pending_sla,
1114
+ "pending_sla_ok": pending_older_than_sla == 0,
1115
+ "pending_older_than_sla": pending_older_than_sla,
1116
+ "oldest_pending": oldest_pending,
1117
+ "max_pending_age_seconds": max_pending_age_seconds,
1118
+ },
1119
+ "warnings": warnings,
1070
1120
  "fts_enabled": fts_enabled,
1071
1121
  "fts_degraded": tables["memory_observations_fts"] and not fts_enabled,
1072
1122
  "fts_queryable": fts_queryable,