nexo-brain 7.1.7 → 7.1.10
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/package.json +1 -1
- package/src/auto_update.py +13 -7
- package/src/autonomy_mandate.py +62 -0
- package/src/checkpoint_policy.py +302 -0
- package/src/classifier_local.py +6 -1
- package/src/cli.py +27 -1
- package/src/db/_email_accounts.py +20 -1
- package/src/db/_schema.py +71 -0
- package/src/email_config.py +9 -3
- package/src/hook_guardrails.py +264 -0
- package/src/hooks/post-compact.sh +34 -0
- package/src/hooks/post_tool_use.py +23 -3
- package/src/hooks/pre-compact.sh +14 -0
- package/src/hooks/session-start.sh +58 -0
- package/src/plugins/protocol.py +65 -0
- package/src/plugins/workflow.py +65 -0
- package/src/scripts/backfill_task_owner.py +86 -1
- package/src/scripts/deep-sleep/phase_protocol_debt_drain.py +259 -0
- package/src/scripts/nexo_personal_automation.py +85 -0
- package/src/scripts/runner-health-check.py +314 -0
- package/src/server.py +232 -0
- package/src/skills/run-release-final-audit/guide.md +3 -1
- package/src/skills/run-release-final-audit/script.py +2 -0
- package/src/tools_sessions.py +31 -0
- package/templates/core-prompts/hook-protocol-warning-task-close-evidence.md +1 -1
- package/templates/core-prompts/r14-correction-learning-injection.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.10",
|
|
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.1.
|
|
21
|
+
Version `7.1.10` is the current packaged-runtime line. It is a follow-up over v7.1.8 that ships two rescue batches of WIP that were stashed aside during the v7.1.8 release window. First rescue: `src/autonomy_mandate.py` expands the mandate-detection vocabulary (hazlo todo / no pares / estás al mando / te dejo al mando / sigue sin parar / haz el plan completo), adds three honest flags on `MandateState` (`execute_until_blocker`, `suppress_mid_task_menus`, `revalidate_after_compaction`) with session filtering, wires post/pre-compact hooks that read those flags, surfaces them through protocol/workflow handlers and session payload, and introduces the new `src/checkpoint_policy.py` module with tests. Second rescue: `scripts/verify_release_readiness.py` gains a smoke-artifact contract pass that validates `release-contracts/smoke/v<version>.json` before any tag push, the release-final audit skill references the new contract, `src/hook_guardrails.py` + `src/hooks/post_tool_use.py` refine the post-tool protocol reminder path with a new contract test, and a couple of core prompts (task-close evidence, r14 correction learning) get wording polish. Both rescues are orthogonal to v7.1.8 and fully covered by tests.
|
|
22
|
+
|
|
23
|
+
Previously in `7.1.8`: batch release over v7.1.7 consolidating the Block K Guardian/Enforcer roadmap (auto-drain of stale `protocol_debt` rows, destructive-command pre-tool gate, `guard_check`-required gate, inline guard ack on `nexo_task_open`, Guardian Health in the morning briefing) with Block D hardcode cleanup (classifier-backed `backfill_task_owner`, migration v50 supersedes the duplicate NEXO-product learning pair, new semantic-hardcodes audit) and Block E product guards (LaunchAgent plist protection, agent-name fallbacks no longer leak the product identity, `francisco_emails` removed from the email-config dict export, `runner-health-check.py` + `nexo_personal_automation.py` promoted from personal to core).
|
|
22
24
|
|
|
23
25
|
Previously in `7.0.1`: hotfix over v7.0.0 (db._core.DB_PATH was only caller still hardcoded to legacy ~/.nexo/data/nexo.db; every shared-DB command silently returned empty results post-migration). Previously in `7.0.0`: **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.10",
|
|
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/auto_update.py
CHANGED
|
@@ -3007,10 +3007,11 @@ def _check_npm_version() -> str | None:
|
|
|
3007
3007
|
return None
|
|
3008
3008
|
if latest != current and not current.endswith(latest):
|
|
3009
3009
|
try:
|
|
3010
|
-
from user_context import get_context
|
|
3011
|
-
_name = get_context().assistant_name
|
|
3010
|
+
from user_context import get_context, DEFAULT_ASSISTANT_NAME
|
|
3011
|
+
_name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
|
|
3012
3012
|
except Exception:
|
|
3013
|
-
|
|
3013
|
+
from user_context import DEFAULT_ASSISTANT_NAME
|
|
3014
|
+
_name = DEFAULT_ASSISTANT_NAME
|
|
3014
3015
|
return f"{_name} update available: {current} -> {latest}. Run: npm update -g {pkg_name}"
|
|
3015
3016
|
except Exception:
|
|
3016
3017
|
pass
|
|
@@ -3385,12 +3386,17 @@ def _find_user_claude_md() -> Path | None:
|
|
|
3385
3386
|
|
|
3386
3387
|
def _resolve_placeholders(template_text: str) -> str:
|
|
3387
3388
|
"""Fill {{NAME}} and {{NEXO_HOME}} from the user's existing CLAUDE.md or config."""
|
|
3388
|
-
# Read operator name from calibration/version
|
|
3389
|
+
# Read operator name from calibration/version. The fallback must never be a
|
|
3390
|
+
# reserved product identity ("NEXO", "NEXO Brain", "NEXO Desktop"); those
|
|
3391
|
+
# are rejected by desktop_bridge.RESERVED_ASSISTANT_NAME_VALUES and leaking
|
|
3392
|
+
# them into the managed CLAUDE.md would conflate the agent (Nova/Nero/etc.)
|
|
3393
|
+
# with the product itself.
|
|
3389
3394
|
try:
|
|
3390
|
-
from user_context import get_context
|
|
3391
|
-
name = get_context().assistant_name
|
|
3395
|
+
from user_context import get_context, DEFAULT_ASSISTANT_NAME
|
|
3396
|
+
name = get_context().assistant_name or DEFAULT_ASSISTANT_NAME
|
|
3392
3397
|
except Exception:
|
|
3393
|
-
|
|
3398
|
+
from user_context import DEFAULT_ASSISTANT_NAME
|
|
3399
|
+
name = DEFAULT_ASSISTANT_NAME
|
|
3394
3400
|
|
|
3395
3401
|
return (
|
|
3396
3402
|
template_text
|
package/src/autonomy_mandate.py
CHANGED
|
@@ -45,8 +45,15 @@ MARKERS = (
|
|
|
45
45
|
"autonomía total",
|
|
46
46
|
"autonomia total",
|
|
47
47
|
"sin esperas",
|
|
48
|
+
"hazlo todo",
|
|
48
49
|
"todo ya",
|
|
50
|
+
"no pares",
|
|
51
|
+
"estás al mando",
|
|
52
|
+
"estas al mando",
|
|
53
|
+
"te dejo al mando",
|
|
49
54
|
"no esperes",
|
|
55
|
+
"sigue sin parar",
|
|
56
|
+
"haz el plan completo",
|
|
50
57
|
"no te escondas",
|
|
51
58
|
"llevo 3 veces",
|
|
52
59
|
"lo quiero ya",
|
|
@@ -81,6 +88,9 @@ class MandateState:
|
|
|
81
88
|
expires_at: float
|
|
82
89
|
marker: str
|
|
83
90
|
source: str
|
|
91
|
+
execute_until_blocker: bool = True
|
|
92
|
+
suppress_mid_task_menus: bool = True
|
|
93
|
+
revalidate_after_compaction: bool = True
|
|
84
94
|
|
|
85
95
|
def remaining_seconds(self, now: Optional[float] = None) -> float:
|
|
86
96
|
now = time.time() if now is None else now
|
|
@@ -94,6 +104,9 @@ class MandateState:
|
|
|
94
104
|
"expires_at": self.expires_at,
|
|
95
105
|
"marker": self.marker,
|
|
96
106
|
"source": self.source,
|
|
107
|
+
"execute_until_blocker": self.execute_until_blocker,
|
|
108
|
+
"suppress_mid_task_menus": self.suppress_mid_task_menus,
|
|
109
|
+
"revalidate_after_compaction": self.revalidate_after_compaction,
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
|
|
@@ -137,6 +150,9 @@ def load_state() -> Optional[MandateState]:
|
|
|
137
150
|
expires_at=float(raw.get("expires_at", 0.0)),
|
|
138
151
|
marker=str(raw.get("marker", "")),
|
|
139
152
|
source=str(raw.get("source", "")),
|
|
153
|
+
execute_until_blocker=bool(raw.get("execute_until_blocker", True)),
|
|
154
|
+
suppress_mid_task_menus=bool(raw.get("suppress_mid_task_menus", True)),
|
|
155
|
+
revalidate_after_compaction=bool(raw.get("revalidate_after_compaction", True)),
|
|
140
156
|
)
|
|
141
157
|
except (TypeError, ValueError):
|
|
142
158
|
return None
|
|
@@ -150,6 +166,9 @@ def set_mandate(
|
|
|
150
166
|
marker: str,
|
|
151
167
|
source: str = "manual",
|
|
152
168
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
169
|
+
execute_until_blocker: bool = True,
|
|
170
|
+
suppress_mid_task_menus: bool = True,
|
|
171
|
+
revalidate_after_compaction: bool = True,
|
|
153
172
|
) -> MandateState:
|
|
154
173
|
_ensure_dir()
|
|
155
174
|
now = time.time()
|
|
@@ -160,6 +179,9 @@ def set_mandate(
|
|
|
160
179
|
expires_at=now + max(60, int(ttl_seconds)),
|
|
161
180
|
marker=marker,
|
|
162
181
|
source=source,
|
|
182
|
+
execute_until_blocker=bool(execute_until_blocker),
|
|
183
|
+
suppress_mid_task_menus=bool(suppress_mid_task_menus),
|
|
184
|
+
revalidate_after_compaction=bool(revalidate_after_compaction),
|
|
163
185
|
)
|
|
164
186
|
STATE_PATH.write_text(json.dumps(st.to_dict(), ensure_ascii=False, indent=2))
|
|
165
187
|
return st
|
|
@@ -196,6 +218,46 @@ def maybe_ingest_from_text(
|
|
|
196
218
|
)
|
|
197
219
|
|
|
198
220
|
|
|
221
|
+
def _state_applies_to_session(state: MandateState, session_id: str = "") -> bool:
|
|
222
|
+
clean_sid = str(session_id or "").strip()
|
|
223
|
+
if not clean_sid:
|
|
224
|
+
return True
|
|
225
|
+
return not state.session_id or state.session_id == clean_sid
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_execute_until_blocker_active(
|
|
229
|
+
session_id: str = "",
|
|
230
|
+
state: Optional[MandateState] = None,
|
|
231
|
+
) -> bool:
|
|
232
|
+
st = state if state is not None else load_state()
|
|
233
|
+
if st is None or not _state_applies_to_session(st, session_id):
|
|
234
|
+
return False
|
|
235
|
+
return bool(st.active and st.execute_until_blocker)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def format_execution_latch_notice(
|
|
239
|
+
session_id: str = "",
|
|
240
|
+
state: Optional[MandateState] = None,
|
|
241
|
+
) -> str:
|
|
242
|
+
st = state if state is not None else load_state()
|
|
243
|
+
if st is None or not _state_applies_to_session(st, session_id):
|
|
244
|
+
return ""
|
|
245
|
+
if not st.active or not st.execute_until_blocker:
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
lines = [
|
|
249
|
+
(
|
|
250
|
+
"EXECUTION MODE: execute-until-blocker active "
|
|
251
|
+
f"(marker='{st.marker}', source={st.source})."
|
|
252
|
+
),
|
|
253
|
+
"Do not stop for option menus, reprioritization, summaries, or audits.",
|
|
254
|
+
"Pause only for a real blocker, an explicit approval gate, or a requested report.",
|
|
255
|
+
]
|
|
256
|
+
if st.revalidate_after_compaction:
|
|
257
|
+
lines.append("Re-validate this latch after compaction and continue from the stored next step.")
|
|
258
|
+
return "\n".join(lines)
|
|
259
|
+
|
|
260
|
+
|
|
199
261
|
def _description_has_exception(description: str, exception: str) -> bool:
|
|
200
262
|
haystack = f"{description or ''}\n{exception or ''}".lower()
|
|
201
263
|
return any(kw in haystack for kw in EXCEPTION_KEYWORDS)
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Durable checkpoint policy for long-running multi-step work.
|
|
2
|
+
|
|
3
|
+
This module turns task/workflow milestones into a small persistent state file
|
|
4
|
+
and periodically flushes that state into ``session_checkpoints`` so compaction
|
|
5
|
+
and phase switches recover a richer next-step snapshot than the heartbeat-only
|
|
6
|
+
goal stub.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
19
|
+
STATE_PATH = NEXO_HOME / "runtime" / "data" / "durable_checkpoint_state.json"
|
|
20
|
+
DEFAULT_MILESTONE_INTERVAL = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _now_iso() -> str:
|
|
24
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_dir() -> None:
|
|
28
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_all() -> dict[str, dict[str, Any]]:
|
|
32
|
+
try:
|
|
33
|
+
raw = json.loads(STATE_PATH.read_text())
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return {}
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return {}
|
|
38
|
+
return raw if isinstance(raw, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save_all(payload: dict[str, dict[str, Any]]) -> None:
|
|
42
|
+
_ensure_dir()
|
|
43
|
+
STATE_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _blank_session_state(session_id: str) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"session_id": session_id,
|
|
49
|
+
"milestone_count": 0,
|
|
50
|
+
"updated_at": _now_iso(),
|
|
51
|
+
"last_reason": "",
|
|
52
|
+
"task": "",
|
|
53
|
+
"task_status": "active",
|
|
54
|
+
"active_files": [],
|
|
55
|
+
"current_goal": "",
|
|
56
|
+
"decisions_summary": "",
|
|
57
|
+
"blockers": "",
|
|
58
|
+
"reasoning_thread": "",
|
|
59
|
+
"next_step": "",
|
|
60
|
+
"last_flushed_at": "",
|
|
61
|
+
"last_flush_reason": "",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _coalesce_text(new_value: str, old_value: str = "") -> str:
|
|
66
|
+
clean = str(new_value or "").strip()
|
|
67
|
+
return clean or str(old_value or "").strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_active_files(active_files: Any) -> list[str]:
|
|
71
|
+
if active_files is None:
|
|
72
|
+
return []
|
|
73
|
+
if isinstance(active_files, str):
|
|
74
|
+
stripped = active_files.strip()
|
|
75
|
+
if not stripped:
|
|
76
|
+
return []
|
|
77
|
+
if stripped.startswith("["):
|
|
78
|
+
try:
|
|
79
|
+
parsed = json.loads(stripped)
|
|
80
|
+
except json.JSONDecodeError:
|
|
81
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
82
|
+
else:
|
|
83
|
+
parsed = [part.strip() for part in stripped.split(",") if part.strip()]
|
|
84
|
+
elif isinstance(active_files, (list, tuple, set)):
|
|
85
|
+
parsed = list(active_files)
|
|
86
|
+
else:
|
|
87
|
+
parsed = [str(active_files).strip()]
|
|
88
|
+
|
|
89
|
+
seen: list[str] = []
|
|
90
|
+
for item in parsed:
|
|
91
|
+
clean = str(item or "").strip()
|
|
92
|
+
if clean and clean not in seen:
|
|
93
|
+
seen.append(clean)
|
|
94
|
+
return seen
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _session_state(all_state: dict[str, dict[str, Any]], session_id: str) -> dict[str, Any]:
|
|
98
|
+
existing = all_state.get(session_id)
|
|
99
|
+
if not isinstance(existing, dict):
|
|
100
|
+
return _blank_session_state(session_id)
|
|
101
|
+
base = _blank_session_state(session_id)
|
|
102
|
+
base.update(existing)
|
|
103
|
+
base["session_id"] = session_id
|
|
104
|
+
base["active_files"] = _normalize_active_files(base.get("active_files"))
|
|
105
|
+
try:
|
|
106
|
+
base["milestone_count"] = max(0, int(base.get("milestone_count", 0)))
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
base["milestone_count"] = 0
|
|
109
|
+
return base
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_active_files_from_payload(payload: dict[str, Any] | None) -> list[str]:
|
|
113
|
+
if not isinstance(payload, dict):
|
|
114
|
+
return []
|
|
115
|
+
for key in ("active_files", "files", "tracked_files"):
|
|
116
|
+
files = _normalize_active_files(payload.get(key))
|
|
117
|
+
if files:
|
|
118
|
+
return files
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _flush_state(
|
|
123
|
+
all_state: dict[str, dict[str, Any]],
|
|
124
|
+
session_id: str,
|
|
125
|
+
state: dict[str, Any],
|
|
126
|
+
*,
|
|
127
|
+
reason: str,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
from db import save_checkpoint
|
|
130
|
+
|
|
131
|
+
decisions_summary = _coalesce_text(state.get("decisions_summary", ""))
|
|
132
|
+
if reason:
|
|
133
|
+
reason_note = f"checkpoint_reason={reason}"
|
|
134
|
+
decisions_summary = f"{decisions_summary} | {reason_note}".strip(" |")
|
|
135
|
+
|
|
136
|
+
active_files = _normalize_active_files(state.get("active_files"))
|
|
137
|
+
save_result = save_checkpoint(
|
|
138
|
+
sid=session_id,
|
|
139
|
+
task=_coalesce_text(state.get("task", ""), state.get("current_goal", "")),
|
|
140
|
+
task_status=_coalesce_text(state.get("task_status", ""), "active"),
|
|
141
|
+
active_files=json.dumps(active_files, ensure_ascii=False),
|
|
142
|
+
current_goal=_coalesce_text(state.get("current_goal", ""), state.get("task", "")),
|
|
143
|
+
decisions_summary=decisions_summary,
|
|
144
|
+
errors_found=_coalesce_text(state.get("blockers", "")),
|
|
145
|
+
reasoning_thread=_coalesce_text(state.get("reasoning_thread", "")),
|
|
146
|
+
next_step=_coalesce_text(state.get("next_step", "")),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
state["active_files"] = active_files
|
|
150
|
+
state["milestone_count"] = 0
|
|
151
|
+
state["last_flushed_at"] = _now_iso()
|
|
152
|
+
state["last_flush_reason"] = reason
|
|
153
|
+
state["updated_at"] = _now_iso()
|
|
154
|
+
all_state[session_id] = state
|
|
155
|
+
_save_all(all_state)
|
|
156
|
+
return {
|
|
157
|
+
"ok": True,
|
|
158
|
+
"checkpoint_written": True,
|
|
159
|
+
"session_id": session_id,
|
|
160
|
+
"milestone_count": 0,
|
|
161
|
+
"last_flush_reason": reason,
|
|
162
|
+
"compaction_count": save_result.get("compaction_count", 0),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def record_milestone(
|
|
167
|
+
session_id: str,
|
|
168
|
+
*,
|
|
169
|
+
reason: str,
|
|
170
|
+
task: str = "",
|
|
171
|
+
task_status: str = "active",
|
|
172
|
+
active_files: Any = None,
|
|
173
|
+
current_goal: str = "",
|
|
174
|
+
decisions_summary: str = "",
|
|
175
|
+
blockers: str = "",
|
|
176
|
+
reasoning_thread: str = "",
|
|
177
|
+
next_step: str = "",
|
|
178
|
+
interval: int = DEFAULT_MILESTONE_INTERVAL,
|
|
179
|
+
force_flush: bool = False,
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
clean_sid = str(session_id or "").strip()
|
|
182
|
+
if not clean_sid:
|
|
183
|
+
return {"ok": False, "error": "session_id is required"}
|
|
184
|
+
|
|
185
|
+
all_state = _load_all()
|
|
186
|
+
state = _session_state(all_state, clean_sid)
|
|
187
|
+
|
|
188
|
+
state["last_reason"] = str(reason or "").strip()
|
|
189
|
+
state["updated_at"] = _now_iso()
|
|
190
|
+
state["task"] = _coalesce_text(task, state.get("task", ""))
|
|
191
|
+
state["task_status"] = _coalesce_text(task_status, state.get("task_status", "active"))
|
|
192
|
+
state["current_goal"] = _coalesce_text(current_goal, state.get("current_goal", ""))
|
|
193
|
+
state["decisions_summary"] = _coalesce_text(decisions_summary, state.get("decisions_summary", ""))
|
|
194
|
+
state["blockers"] = _coalesce_text(blockers, state.get("blockers", ""))
|
|
195
|
+
state["reasoning_thread"] = _coalesce_text(reasoning_thread, state.get("reasoning_thread", ""))
|
|
196
|
+
state["next_step"] = _coalesce_text(next_step, state.get("next_step", ""))
|
|
197
|
+
|
|
198
|
+
files = _normalize_active_files(active_files)
|
|
199
|
+
if files:
|
|
200
|
+
state["active_files"] = files
|
|
201
|
+
|
|
202
|
+
state["milestone_count"] = max(0, int(state.get("milestone_count", 0))) + 1
|
|
203
|
+
all_state[clean_sid] = state
|
|
204
|
+
|
|
205
|
+
flush_every = max(1, int(interval or DEFAULT_MILESTONE_INTERVAL))
|
|
206
|
+
if force_flush or state["milestone_count"] >= flush_every:
|
|
207
|
+
return _flush_state(all_state, clean_sid, state, reason=str(reason or "").strip())
|
|
208
|
+
|
|
209
|
+
_save_all(all_state)
|
|
210
|
+
return {
|
|
211
|
+
"ok": True,
|
|
212
|
+
"checkpoint_written": False,
|
|
213
|
+
"session_id": clean_sid,
|
|
214
|
+
"milestone_count": state["milestone_count"],
|
|
215
|
+
"flush_interval": flush_every,
|
|
216
|
+
"pending_reason": state["last_reason"],
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def force_runtime_checkpoint(session_id: str, *, reason: str = "pre-compact") -> dict[str, Any]:
|
|
221
|
+
clean_sid = str(session_id or "").strip()
|
|
222
|
+
if not clean_sid:
|
|
223
|
+
return {"ok": False, "error": "session_id is required"}
|
|
224
|
+
|
|
225
|
+
from db import get_db, read_checkpoint
|
|
226
|
+
|
|
227
|
+
all_state = _load_all()
|
|
228
|
+
state = _session_state(all_state, clean_sid)
|
|
229
|
+
conn = get_db()
|
|
230
|
+
|
|
231
|
+
session_row = conn.execute(
|
|
232
|
+
"SELECT task FROM sessions WHERE sid = ? LIMIT 1",
|
|
233
|
+
(clean_sid,),
|
|
234
|
+
).fetchone()
|
|
235
|
+
existing_checkpoint = read_checkpoint(clean_sid) or {}
|
|
236
|
+
workflow_row = conn.execute(
|
|
237
|
+
"""SELECT goal, status, current_step_key, next_action, shared_state
|
|
238
|
+
FROM workflow_runs
|
|
239
|
+
WHERE session_id = ?
|
|
240
|
+
ORDER BY updated_at DESC LIMIT 1""",
|
|
241
|
+
(clean_sid,),
|
|
242
|
+
).fetchone()
|
|
243
|
+
|
|
244
|
+
workflow_state: dict[str, Any] = {}
|
|
245
|
+
if workflow_row and workflow_row["shared_state"]:
|
|
246
|
+
try:
|
|
247
|
+
parsed = json.loads(workflow_row["shared_state"])
|
|
248
|
+
if isinstance(parsed, dict):
|
|
249
|
+
workflow_state = parsed
|
|
250
|
+
except json.JSONDecodeError:
|
|
251
|
+
workflow_state = {}
|
|
252
|
+
|
|
253
|
+
workflow_blocker = ""
|
|
254
|
+
if workflow_row and workflow_row["status"] in {"blocked", "waiting_approval"}:
|
|
255
|
+
workflow_blocker = (
|
|
256
|
+
f"Workflow {workflow_row['status']} at "
|
|
257
|
+
f"{workflow_row['current_step_key'] or 'current-step'}"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
merged_files = (
|
|
261
|
+
_normalize_active_files(state.get("active_files"))
|
|
262
|
+
or _extract_active_files_from_payload(workflow_state)
|
|
263
|
+
or _normalize_active_files(existing_checkpoint.get("active_files"))
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
state["task"] = _coalesce_text(
|
|
267
|
+
state.get("task", ""),
|
|
268
|
+
(session_row["task"] if session_row else "") or existing_checkpoint.get("task", ""),
|
|
269
|
+
)
|
|
270
|
+
state["current_goal"] = _coalesce_text(
|
|
271
|
+
state.get("current_goal", ""),
|
|
272
|
+
(workflow_row["goal"] if workflow_row else "") or existing_checkpoint.get("current_goal", "") or state.get("task", ""),
|
|
273
|
+
)
|
|
274
|
+
state["decisions_summary"] = _coalesce_text(
|
|
275
|
+
state.get("decisions_summary", ""),
|
|
276
|
+
existing_checkpoint.get("decisions_summary", "") or f"Forced durable checkpoint before {reason}.",
|
|
277
|
+
)
|
|
278
|
+
current_blockers = _coalesce_text(
|
|
279
|
+
state.get("blockers", ""),
|
|
280
|
+
existing_checkpoint.get("errors_found", ""),
|
|
281
|
+
)
|
|
282
|
+
if workflow_blocker:
|
|
283
|
+
if workflow_blocker not in current_blockers:
|
|
284
|
+
current_blockers = f"{workflow_blocker} | {current_blockers}".strip(" |")
|
|
285
|
+
state["blockers"] = current_blockers
|
|
286
|
+
state["reasoning_thread"] = _coalesce_text(
|
|
287
|
+
state.get("reasoning_thread", ""),
|
|
288
|
+
existing_checkpoint.get("reasoning_thread", "") or f"Auto-flushed by checkpoint_policy ({reason}).",
|
|
289
|
+
)
|
|
290
|
+
state["next_step"] = _coalesce_text(
|
|
291
|
+
state.get("next_step", ""),
|
|
292
|
+
(workflow_row["next_action"] if workflow_row else "") or existing_checkpoint.get("next_step", ""),
|
|
293
|
+
)
|
|
294
|
+
state["task_status"] = _coalesce_text(
|
|
295
|
+
state.get("task_status", ""),
|
|
296
|
+
(workflow_row["status"] if workflow_row else "") or existing_checkpoint.get("task_status", "") or "active",
|
|
297
|
+
)
|
|
298
|
+
state["active_files"] = merged_files
|
|
299
|
+
state["updated_at"] = _now_iso()
|
|
300
|
+
state["last_reason"] = reason
|
|
301
|
+
all_state[clean_sid] = state
|
|
302
|
+
return _flush_state(all_state, clean_sid, state, reason=reason)
|
package/src/classifier_local.py
CHANGED
|
@@ -11,7 +11,12 @@ Contract:
|
|
|
11
11
|
"lo hemos dejado, ya estaría",
|
|
12
12
|
labels=("done_claim", "status_update", "question", "noise"),
|
|
13
13
|
)
|
|
14
|
-
result
|
|
14
|
+
# result is a ``ClassificationResult`` dataclass (see below):
|
|
15
|
+
# result.label == "done_claim"
|
|
16
|
+
# result.confidence == 0.87
|
|
17
|
+
# result.scores == {"done_claim": 0.87, ...}
|
|
18
|
+
# The dataclass keeps attribute access + ``asdict()`` compatibility
|
|
19
|
+
# for callers that previously consumed it as a dict.
|
|
15
20
|
|
|
16
21
|
When transformers is not installed or the download fails (offline),
|
|
17
22
|
`classify` returns `None` and `classify_fail_closed` returns a
|
package/src/cli.py
CHANGED
|
@@ -1755,6 +1755,21 @@ def _terminal_client_label(client: str) -> str:
|
|
|
1755
1755
|
|
|
1756
1756
|
|
|
1757
1757
|
def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> list[str]:
|
|
1758
|
+
"""Return the terminal clients we should offer the operator, in priority order.
|
|
1759
|
+
|
|
1760
|
+
Policy:
|
|
1761
|
+
1. Clients that are BOTH detected installed AND enabled in preferences
|
|
1762
|
+
keep their priority (``last_used`` → ``default`` → TERMINAL_CLIENT_ORDER).
|
|
1763
|
+
2. If that primary list ends up with ≤1 choice, we also surface every
|
|
1764
|
+
other DETECTED-INSTALLED client even when it is not yet marked
|
|
1765
|
+
``enabled`` in preferences. Rationale (bug Francisco 2026-04-22):
|
|
1766
|
+
operators with both Claude Code and Codex installed were being
|
|
1767
|
+
dropped straight into whichever one was last used (or the only one
|
|
1768
|
+
ever enabled) with no chance to pick. ``nexo chat`` must offer the
|
|
1769
|
+
picker whenever more than one interactive client is available on
|
|
1770
|
+
the machine; the preferences flag stays as a *priority* signal, not
|
|
1771
|
+
as a hard filter that hides a legitimately installed CLI.
|
|
1772
|
+
"""
|
|
1758
1773
|
enabled = preferences.get("interactive_clients", {})
|
|
1759
1774
|
last_used = str(preferences.get("last_terminal_client", "")).strip()
|
|
1760
1775
|
preferred = str(preferences.get("default_terminal_client", "")).strip()
|
|
@@ -1764,11 +1779,22 @@ def _ordered_available_terminal_clients(preferences: dict, detected: dict) -> li
|
|
|
1764
1779
|
if client in TERMINAL_CLIENT_ORDER and client not in ordered:
|
|
1765
1780
|
ordered.append(client)
|
|
1766
1781
|
|
|
1767
|
-
|
|
1782
|
+
primary = [
|
|
1768
1783
|
client
|
|
1769
1784
|
for client in ordered
|
|
1770
1785
|
if enabled.get(client, False) and detected.get(client, {}).get("installed", False)
|
|
1771
1786
|
]
|
|
1787
|
+
if len(primary) <= 1:
|
|
1788
|
+
# Surface additional installed clients so the operator gets to pick
|
|
1789
|
+
# whenever there is a genuine competing install on disk. We preserve
|
|
1790
|
+
# the priority order built above so the picker still highlights the
|
|
1791
|
+
# last-used/default client first.
|
|
1792
|
+
for client in ordered:
|
|
1793
|
+
if client in primary:
|
|
1794
|
+
continue
|
|
1795
|
+
if detected.get(client, {}).get("installed", False):
|
|
1796
|
+
primary.append(client)
|
|
1797
|
+
return primary
|
|
1772
1798
|
|
|
1773
1799
|
|
|
1774
1800
|
def _preferred_terminal_client_label(preferences: dict, clients: list[str]) -> str:
|
|
@@ -78,6 +78,22 @@ def add_email_account(
|
|
|
78
78
|
raise ValueError("label and email are required")
|
|
79
79
|
if role not in VALID_ROLES:
|
|
80
80
|
raise ValueError(f"role must be one of {VALID_ROLES}, got {role!r}")
|
|
81
|
+
# AUDITOR-3RDPASS §Risk 3: the SELECT (``get_email_account``) and the
|
|
82
|
+
# follow-up INSERT ... ON CONFLICT DO UPDATE below used to run as two
|
|
83
|
+
# independent statements on the shared connection. A concurrent writer
|
|
84
|
+
# (Desktop + cron overlap) could slip a metadata mutation between them
|
|
85
|
+
# and get silently overwritten by whatever ``existing.get("metadata")``
|
|
86
|
+
# we captured here. Wrap the read-modify-write in ``BEGIN IMMEDIATE``
|
|
87
|
+
# so the row is pinned against concurrent writers for the duration of
|
|
88
|
+
# the upsert. WAL mode still lets readers through, so no lock storm.
|
|
89
|
+
conn = _get_db()
|
|
90
|
+
try:
|
|
91
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
92
|
+
_owns_txn = True
|
|
93
|
+
except Exception:
|
|
94
|
+
# Already inside a transaction (e.g. caller wrapped the whole flow);
|
|
95
|
+
# trust the caller's boundary instead of nesting an IMMEDIATE.
|
|
96
|
+
_owns_txn = False
|
|
81
97
|
existing = get_email_account(label) or {}
|
|
82
98
|
clean_account_type = str(account_type or existing.get("account_type") or "agent").strip().lower()
|
|
83
99
|
if clean_account_type not in VALID_ACCOUNT_TYPES:
|
|
@@ -166,7 +182,10 @@ def add_email_account(
|
|
|
166
182
|
now,
|
|
167
183
|
),
|
|
168
184
|
)
|
|
169
|
-
|
|
185
|
+
# Only commit/close the transaction we opened ourselves. Callers that
|
|
186
|
+
# already wrapped the flow in their own transaction must keep control.
|
|
187
|
+
if _owns_txn:
|
|
188
|
+
conn.commit()
|
|
170
189
|
return get_email_account(label) or {}
|
|
171
190
|
|
|
172
191
|
|
package/src/db/_schema.py
CHANGED
|
@@ -1222,6 +1222,75 @@ def _m45_personal_scripts_origin(conn):
|
|
|
1222
1222
|
_migrate_add_index(conn, "idx_personal_scripts_origin", "personal_scripts", "origin")
|
|
1223
1223
|
|
|
1224
1224
|
|
|
1225
|
+
def _m49_protocol_guard_ack_backfill(conn):
|
|
1226
|
+
"""Backfill protocol guard-ack columns for installs that already marked
|
|
1227
|
+
migration v22 as applied before those columns were added to the migration
|
|
1228
|
+
body.
|
|
1229
|
+
|
|
1230
|
+
This must remain a standalone migration instead of reusing v22 because
|
|
1231
|
+
real runtimes can legitimately sit at schema version 48 with an older
|
|
1232
|
+
``protocol_tasks`` shape. Re-running ``init_db()`` skips v22 once it is
|
|
1233
|
+
recorded in ``schema_migrations``, so the missing columns never land
|
|
1234
|
+
without a new version.
|
|
1235
|
+
"""
|
|
1236
|
+
_migrate_add_column(conn, "protocol_tasks", "guard_acknowledged", "INTEGER NOT NULL DEFAULT 0")
|
|
1237
|
+
_migrate_add_column(conn, "protocol_tasks", "guard_acknowledged_at", "TEXT DEFAULT NULL")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _m50_dedupe_nexo_product_learning_pair(conn):
|
|
1241
|
+
"""Block D.2 / G7-adjacent: dedupe the two learnings that encode the
|
|
1242
|
+
"NEXO Brain producto público vs instancia personal de Francisco"
|
|
1243
|
+
invariant as a physically separate pair.
|
|
1244
|
+
|
|
1245
|
+
Francisco's runtime has this concept stored twice (historical IDs 212
|
|
1246
|
+
and 224). Guard dedup already collapses them at display time, but
|
|
1247
|
+
the underlying rows stayed split, so list/search/update flows still
|
|
1248
|
+
saw two rows. Physically supersede the older one by pointing its
|
|
1249
|
+
``supersedes_id`` at the newer duplicate and flipping its status to
|
|
1250
|
+
``superseded``. Anything newer than both is left untouched.
|
|
1251
|
+
|
|
1252
|
+
Idempotent. Fresh installs that never created either row silently
|
|
1253
|
+
do nothing; installs where an operator has already set the relation
|
|
1254
|
+
manually do nothing. The migration matches on a text-normalised form
|
|
1255
|
+
of the title so synonymous wording on both rows is enough — we don't
|
|
1256
|
+
need identical strings, and we don't need the IDs to literally be
|
|
1257
|
+
212 and 224.
|
|
1258
|
+
"""
|
|
1259
|
+
try:
|
|
1260
|
+
rows = conn.execute(
|
|
1261
|
+
"SELECT id, title, content, status, supersedes_id FROM learnings "
|
|
1262
|
+
"WHERE status = 'active'"
|
|
1263
|
+
).fetchall()
|
|
1264
|
+
except Exception:
|
|
1265
|
+
return
|
|
1266
|
+
|
|
1267
|
+
def _norm(text: str) -> str:
|
|
1268
|
+
# Collapse whitespace and strip punctuation/case so "NEXO Brain
|
|
1269
|
+
# producto público vs instancia personal" matches its twin no
|
|
1270
|
+
# matter how the operator rephrased it.
|
|
1271
|
+
import re as _re
|
|
1272
|
+
stripped = _re.sub(r"[\W_]+", " ", str(text or "")).strip().lower()
|
|
1273
|
+
return _re.sub(r"\s+", " ", stripped)
|
|
1274
|
+
|
|
1275
|
+
marker = "nexo brain producto"
|
|
1276
|
+
candidates = [r for r in rows if marker in _norm(r[1] or "")]
|
|
1277
|
+
# Need at least two rows for this migration to do anything.
|
|
1278
|
+
if len(candidates) < 2:
|
|
1279
|
+
return
|
|
1280
|
+
# Sort by id ascending; the highest id is the canonical survivor.
|
|
1281
|
+
candidates.sort(key=lambda r: int(r[0] or 0))
|
|
1282
|
+
survivor = candidates[-1]
|
|
1283
|
+
for older in candidates[:-1]:
|
|
1284
|
+
older_id = int(older[0] or 0)
|
|
1285
|
+
if int(older[4] or 0) == int(survivor[0] or 0):
|
|
1286
|
+
continue # already linked
|
|
1287
|
+
conn.execute(
|
|
1288
|
+
"UPDATE learnings SET supersedes_id = ?, status = 'superseded', "
|
|
1289
|
+
"updated_at = strftime('%s','now') WHERE id = ? AND status = 'active'",
|
|
1290
|
+
(int(survivor[0] or 0), older_id),
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1225
1294
|
def _m44_entities_extended_schema(conn):
|
|
1226
1295
|
"""Plan Consolidado 0.3 — extend entities with aliases/metadata/source/confidence/access_mode.
|
|
1227
1296
|
|
|
@@ -1291,6 +1360,8 @@ MIGRATIONS = [
|
|
|
1291
1360
|
(46, "email_accounts", _m46_email_accounts),
|
|
1292
1361
|
(47, "email_operator_accounts", _m47_email_operator_accounts),
|
|
1293
1362
|
(48, "email_agent_contract_backfill", _m48_email_agent_contract_backfill),
|
|
1363
|
+
(49, "protocol_guard_ack_backfill", _m49_protocol_guard_ack_backfill),
|
|
1364
|
+
(50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
|
|
1294
1365
|
]
|
|
1295
1366
|
|
|
1296
1367
|
|