nexo-brain 7.27.3 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/bin/windows-wsl-bridge.js +9 -0
- package/package.json +1 -1
- package/src/classifier_local.py +44 -0
- package/src/db/__init__.py +8 -0
- package/src/db/_commitments.py +344 -0
- package/src/db/_memory_v2.py +52 -2
- package/src/db/_schema.py +37 -0
- package/src/desktop_bridge.py +1 -1
- package/src/doctor/providers/runtime.py +9 -3
- package/src/enforcement_engine.py +128 -2
- package/src/hook_guardrails.py +104 -0
- package/src/local_context/api.py +54 -22
- package/src/plugins/protocol.py +96 -0
- package/src/pre_answer_router.py +298 -6
- package/src/r14_correction_learning.py +3 -3
- package/src/requirements.txt +5 -1
- package/src/runtime_versioning.py +11 -1
- package/src/saved_not_used_audit.py +44 -3
- package/src/scripts/nexo-followup-runner.py +194 -0
- package/src/semantic_reasoner.py +2 -2
- package/src/semantic_router.py +58 -11
- package/src/server.py +37 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.27.
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
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",
|
package/src/classifier_local.py
CHANGED
|
@@ -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
|
]
|
package/src/db/__init__.py
CHANGED
|
@@ -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
|
+
]
|
package/src/db/_memory_v2.py
CHANGED
|
@@ -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,
|
package/src/db/_schema.py
CHANGED
|
@@ -1139,6 +1139,42 @@ def _m69_provider_runtime_metadata(conn):
|
|
|
1139
1139
|
_migrate_add_index(conn, "idx_sessions_provider", "sessions", "session_provider")
|
|
1140
1140
|
|
|
1141
1141
|
|
|
1142
|
+
def _m70_commitments(conn):
|
|
1143
|
+
"""Durable promise/commitment index linked to existing action artifacts."""
|
|
1144
|
+
conn.execute(
|
|
1145
|
+
"""
|
|
1146
|
+
CREATE TABLE IF NOT EXISTS commitments (
|
|
1147
|
+
id TEXT PRIMARY KEY,
|
|
1148
|
+
created_at REAL NOT NULL,
|
|
1149
|
+
updated_at REAL NOT NULL,
|
|
1150
|
+
closed_at REAL DEFAULT NULL,
|
|
1151
|
+
source_type TEXT NOT NULL DEFAULT '',
|
|
1152
|
+
source_id TEXT DEFAULT '',
|
|
1153
|
+
memory_event_uid TEXT DEFAULT '',
|
|
1154
|
+
session_id TEXT DEFAULT '',
|
|
1155
|
+
conversation_id TEXT DEFAULT '',
|
|
1156
|
+
project_key TEXT DEFAULT '',
|
|
1157
|
+
statement TEXT NOT NULL,
|
|
1158
|
+
owner TEXT DEFAULT 'agent',
|
|
1159
|
+
deadline TEXT DEFAULT '',
|
|
1160
|
+
status TEXT DEFAULT 'active',
|
|
1161
|
+
confidence REAL DEFAULT 0.5,
|
|
1162
|
+
action_ref_type TEXT DEFAULT '',
|
|
1163
|
+
action_ref_id TEXT DEFAULT '',
|
|
1164
|
+
outcome_id INTEGER DEFAULT NULL,
|
|
1165
|
+
evidence_ref TEXT DEFAULT '',
|
|
1166
|
+
dedupe_key TEXT DEFAULT '',
|
|
1167
|
+
metadata_json TEXT DEFAULT '{}'
|
|
1168
|
+
)
|
|
1169
|
+
"""
|
|
1170
|
+
)
|
|
1171
|
+
_migrate_add_index(conn, "idx_commitments_status", "commitments", "status, deadline, updated_at")
|
|
1172
|
+
_migrate_add_index(conn, "idx_commitments_session", "commitments", "session_id, status, updated_at")
|
|
1173
|
+
_migrate_add_index(conn, "idx_commitments_source", "commitments", "source_type, source_id")
|
|
1174
|
+
_migrate_add_index(conn, "idx_commitments_action", "commitments", "action_ref_type, action_ref_id")
|
|
1175
|
+
_migrate_add_index(conn, "idx_commitments_dedupe", "commitments", "dedupe_key")
|
|
1176
|
+
|
|
1177
|
+
|
|
1142
1178
|
def _m42_v6_0_1_hotfix(conn):
|
|
1143
1179
|
"""v6.0.1 hotfix — last_heartbeat_ts on sessions + hook_inbox_reminders.
|
|
1144
1180
|
|
|
@@ -2270,6 +2306,7 @@ MIGRATIONS = [
|
|
|
2270
2306
|
(67, "diary_quality_backfill_repair", _m67_diary_quality_backfill_repair),
|
|
2271
2307
|
(68, "memory_fabric_index", _m68_memory_fabric_index),
|
|
2272
2308
|
(69, "provider_runtime_metadata", _m69_provider_runtime_metadata),
|
|
2309
|
+
(70, "commitments", _m70_commitments),
|
|
2273
2310
|
]
|
|
2274
2311
|
|
|
2275
2312
|
|
package/src/desktop_bridge.py
CHANGED
|
@@ -321,7 +321,7 @@ def _onboard_steps() -> list[dict]:
|
|
|
321
321
|
return [
|
|
322
322
|
{
|
|
323
323
|
"id": "name",
|
|
324
|
-
"prompt": {"es": "¿Cómo te
|
|
324
|
+
"prompt": {"es": "¿Cómo te llamas?", "en": "What's your name?"},
|
|
325
325
|
"hint": {
|
|
326
326
|
"es": "Tu nombre corto, el que usarás en el día a día.",
|
|
327
327
|
"en": "Your short name, the one we'll use day to day.",
|
|
@@ -3842,12 +3842,16 @@ def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
|
|
|
3842
3842
|
try:
|
|
3843
3843
|
from local_context import api as local_context_api
|
|
3844
3844
|
|
|
3845
|
-
|
|
3845
|
+
try:
|
|
3846
|
+
result = local_context_api.local_index_hygiene(fix=fix, quick=not fix)
|
|
3847
|
+
except TypeError:
|
|
3848
|
+
result = local_context_api.local_index_hygiene(fix=fix)
|
|
3846
3849
|
residue = result.get("residue") or {}
|
|
3847
3850
|
cleanup = result.get("cleanup") or {}
|
|
3848
3851
|
privacy = result.get("privacy") or {}
|
|
3849
3852
|
privacy_residue = privacy.get("residue") or {}
|
|
3850
3853
|
privacy_cleanup = privacy.get("cleanup") or {}
|
|
3854
|
+
privacy_truncated = bool(privacy.get("truncated") or privacy_residue.get("truncated"))
|
|
3851
3855
|
suspect_roots = [str(path) for path in result.get("removed_roots") or []]
|
|
3852
3856
|
residue_total = sum(int(residue.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
|
|
3853
3857
|
cleanup_total = sum(int(cleanup.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
|
|
@@ -3859,9 +3863,11 @@ def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
|
|
|
3859
3863
|
"cleanup=" + json.dumps(cleanup, sort_keys=True),
|
|
3860
3864
|
"privacy_residue=" + json.dumps(privacy_residue, sort_keys=True),
|
|
3861
3865
|
"privacy_cleanup=" + json.dumps(privacy_cleanup, sort_keys=True),
|
|
3866
|
+
"quick_scan=" + str(bool(result.get("quick") or privacy.get("quick"))),
|
|
3867
|
+
"privacy_truncated=" + str(privacy_truncated),
|
|
3862
3868
|
]
|
|
3863
3869
|
evidence.extend(f"root={path}" for path in suspect_roots[:5])
|
|
3864
|
-
if residue_total == 0 and privacy_residue_total == 0 and not suspect_roots:
|
|
3870
|
+
if residue_total == 0 and privacy_residue_total == 0 and not suspect_roots and not privacy_truncated:
|
|
3865
3871
|
return DoctorCheck(
|
|
3866
3872
|
id="runtime.local_index_hygiene",
|
|
3867
3873
|
tier="runtime",
|
|
@@ -3889,7 +3895,7 @@ def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
|
|
|
3889
3895
|
severity="warn",
|
|
3890
3896
|
summary="Local memory index has stale or private residue",
|
|
3891
3897
|
evidence=evidence,
|
|
3892
|
-
repair_plan=["Run `nexo doctor --tier runtime --fix` to purge stale local memory roots
|
|
3898
|
+
repair_plan=["Run `nexo doctor --tier runtime --fix` to purge stale local memory roots/private residue, or run a full local_index_hygiene scan outside release readiness"],
|
|
3893
3899
|
escalation_prompt="Local memory may contain stale or private index payloads that should be purged before indexing continues.",
|
|
3894
3900
|
)
|
|
3895
3901
|
except Exception as exc:
|