nexo-brain 5.8.2 → 5.9.1
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 +5 -1
- package/package.json +1 -1
- package/src/agent_runner.py +270 -33
- package/src/cli.py +122 -0
- package/src/client_preferences.py +5 -0
- package/src/db/_schema.py +43 -0
- package/src/desktop_bridge.py +28 -0
- package/src/resonance_map.py +295 -0
- package/src/scripts/check-context.py +1 -1
- package/src/scripts/deep-sleep/extract.py +2 -2
- package/src/scripts/deep-sleep/synthesize.py +1 -1
- package/src/scripts/nexo-agent-run.py +1 -0
- package/src/scripts/nexo-catchup.py +1 -1
- package/src/scripts/nexo-daily-self-audit.py +1 -1
- package/src/scripts/nexo-evolution-run.py +2 -2
- package/src/scripts/nexo-immune.py +1 -1
- package/src/scripts/nexo-learning-validator.py +1 -1
- package/src/scripts/nexo-postmortem-consolidator.py +1 -1
- package/src/scripts/nexo-sleep.py +1 -1
- package/src/scripts/nexo-synthesis.py +1 -1
- package/src/server.py +102 -0
- package/src/tools_automation_sessions.py +159 -0
- package/src/tools_drive.py +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.1",
|
|
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,11 @@
|
|
|
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 `5.
|
|
21
|
+
Version `5.9.1` is the current packaged-runtime line: adds `default_resonance` to `brain/calibration.json` via the Desktop-facing schema (`nexo schema --json`), so NEXO Desktop's Preferences dialog renders a select with `Máximo` / `Alto (recomendado)` / `Medio` / `Bajo` automatically — no Desktop release needed. `resolve_tier_for_caller` reads calibration first and falls back to the legacy `schedule.json` location. `nexo preferences --resonance` writes both. The UI control only affects interactive sessions (`nexo chat`, Desktop new conversation, interactive `nexo update`); crons and background processes stay pinned per caller in `resonance_map.py`.
|
|
22
|
+
|
|
23
|
+
Previously in `5.9.0`: every Claude/Codex invocation now flows through a central **resonance map** and a **unified session log**. Four tiers (`MAXIMO` / `ALTO` / `MEDIO` / `BAJO`) each resolve to a concrete `(model, reasoning_effort)` pair per backend. User-facing callers (`nexo chat`, Desktop new conversation, interactive `nexo update`) honour the user's `default_resonance` preference; system-owned callers (deep-sleep, evolution, catchup, GBP posts, …) run at a fixed tier chosen per caller in `src/resonance_map.py` — the user's preference never downgrades a cron we decided needs `MAXIMO`. Unknown callers raise `UnregisteredCallerError`. Migration #41 adds `caller`, `session_type`, `started_at`, `ended_at`, `pid`, `resonance_tier` to `automation_runs`; interactive sessions record a row at spawn (with `ended_at=NULL`) and update it on close, so the Brain now has a single source of truth for every Claude/Codex call regardless of origin. New `nexo preferences --resonance` CLI. New MCP tools `nexo_session_log_create` / `nexo_session_log_close` let NEXO Desktop (which spawns `claude` directly from its TypeScript process) feed the same log.
|
|
24
|
+
|
|
25
|
+
Previously in `5.8.2`: the Brain core no longer auto-classifies `followups` and `reminders` on behalf of agents. v5.8.0's `classify_task()` heuristic (NEXO-specific ID prefixes `NF-PROTOCOL-*` / `NF-DS-*` / `NF-AUDIT-*`, Spanish user-verbs `debes` / `revisar` / `firmar`, agent keywords `monitor` / `auditoría diaria` / `checkpoint`) was fine for NEXO's own DB but bled convention into every third-party agent plugged into the shared Brain. The core now persists `internal=0` and `owner=NULL` when the caller omits them, and clients that want automatic classification (NEXO Desktop does, via its `_legacyClassifyOwner` helpers) compute it themselves and pass the result. Migration #40 keeps the columns + indexes; rows already backfilled by v5.8.0 keep their values. `normalise_owner` still explicitly rejects the string `"nexo"` so legacy hardcoding cannot sneak back in.
|
|
22
26
|
|
|
23
27
|
Previously in `5.8.1`: closes a self-reinforcing `launchctl kickstart -k` loop in the watchdog that wedged deep-sleep Phase 2 between 2026-04-14 and 2026-04-17. The cron wrapper now INSERTs an in-flight row (`ended_at=NULL`) at start and traps SIGTERM/INT/HUP to close it with `exit_code=143` instead of vanishing from `cron_runs`. The watchdog interprets in-flight rows as "currently running" and only re-executes after verifying the worker process is dead. `extract.py` classifies CLI failures into transient (`overloaded_error`, rate-limit, timeout, signal — retried next run) and deterministic (skipped after `MAX_POISON_ATTEMPTS`), and passes a slim shared-context (200 head lines + metadata) instead of the full 400+ KB dump. A new `auto_update._heal_deep_sleep_runtime()` repairs existing installs silently on the next `nexo update`: poisoned checkpoints, stale locks, dangling `cron_runs` rows, and bloated `.watchdog-fails` counters.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.1",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain \u2014 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/agent_runner.py
CHANGED
|
@@ -180,68 +180,188 @@ def _append_stderr(stderr: str, message: str) -> str:
|
|
|
180
180
|
return "\n".join(bits) + "\n"
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def
|
|
183
|
+
def _record_automation_start(
|
|
184
184
|
*,
|
|
185
|
+
caller: str,
|
|
185
186
|
backend: str,
|
|
187
|
+
session_type: str,
|
|
186
188
|
task_profile: str,
|
|
187
189
|
model: str,
|
|
188
190
|
reasoning_effort: str,
|
|
191
|
+
resonance_tier: str,
|
|
189
192
|
cwd: Path,
|
|
190
193
|
output_format: str,
|
|
191
194
|
prompt: str,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
pid: int | None = None,
|
|
196
|
+
) -> tuple[int | None, str]:
|
|
197
|
+
"""Insert an automation_runs row at START time with ended_at=NULL.
|
|
198
|
+
|
|
199
|
+
Returns ``(row_id, error_message)``. Row_id is ``None`` if the insert
|
|
200
|
+
fails for any reason — callers should degrade gracefully (telemetry is
|
|
201
|
+
best-effort, never blocking the actual automation call).
|
|
202
|
+
"""
|
|
196
203
|
try:
|
|
197
204
|
from db._core import get_db
|
|
198
205
|
except Exception as exc:
|
|
199
|
-
return
|
|
206
|
+
return None, f"automation telemetry unavailable: {exc}"
|
|
200
207
|
|
|
201
208
|
try:
|
|
202
209
|
conn = get_db()
|
|
203
|
-
|
|
204
|
-
conn.execute(
|
|
210
|
+
cur = conn.execute(
|
|
205
211
|
"""
|
|
206
212
|
INSERT INTO automation_runs (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
213
|
+
caller, backend, session_type, task_profile, model,
|
|
214
|
+
reasoning_effort, resonance_tier, cwd, output_format,
|
|
215
|
+
prompt_chars, status, started_at, pid
|
|
216
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', datetime('now'), ?)
|
|
212
217
|
""",
|
|
213
218
|
(
|
|
219
|
+
caller or "",
|
|
214
220
|
backend,
|
|
221
|
+
session_type or "headless",
|
|
215
222
|
task_profile or "default",
|
|
216
|
-
model,
|
|
217
|
-
reasoning_effort,
|
|
223
|
+
model or "",
|
|
224
|
+
reasoning_effort or "",
|
|
225
|
+
resonance_tier or "",
|
|
218
226
|
str(cwd),
|
|
219
227
|
output_format or "text",
|
|
220
228
|
len(prompt or ""),
|
|
221
|
-
int(
|
|
222
|
-
int(duration_ms),
|
|
223
|
-
int(usage.get("input_tokens") or 0),
|
|
224
|
-
int(usage.get("cached_input_tokens") or 0),
|
|
225
|
-
int(usage.get("output_tokens") or 0),
|
|
226
|
-
telemetry.get("total_cost_usd"),
|
|
227
|
-
telemetry.get("telemetry_source", ""),
|
|
228
|
-
telemetry.get("cost_source", ""),
|
|
229
|
-
"ok" if int(returncode) == 0 else "failed",
|
|
230
|
-
json.dumps(
|
|
231
|
-
{
|
|
232
|
-
"warnings": telemetry.get("warnings") or [],
|
|
233
|
-
"raw": telemetry.get("raw") or {},
|
|
234
|
-
},
|
|
235
|
-
ensure_ascii=False,
|
|
236
|
-
),
|
|
229
|
+
int(pid) if pid is not None else None,
|
|
237
230
|
),
|
|
238
231
|
)
|
|
239
232
|
conn.commit()
|
|
233
|
+
return int(cur.lastrowid), ""
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
return None, f"automation telemetry unavailable: {exc}"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _record_automation_end(
|
|
239
|
+
*,
|
|
240
|
+
row_id: int | None,
|
|
241
|
+
returncode: int,
|
|
242
|
+
duration_ms: int,
|
|
243
|
+
telemetry: dict,
|
|
244
|
+
) -> tuple[bool, str]:
|
|
245
|
+
"""Close an automation_runs row opened by _record_automation_start.
|
|
246
|
+
|
|
247
|
+
Falls back to a fresh INSERT when ``row_id`` is None so callers that
|
|
248
|
+
never got a start row (start failed, or pre-v5.9.0 code paths) still
|
|
249
|
+
leave a completion trace.
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
from db._core import get_db
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
return False, f"automation telemetry unavailable: {exc}"
|
|
255
|
+
|
|
256
|
+
usage = (telemetry or {}).get("usage") or {}
|
|
257
|
+
metadata = json.dumps(
|
|
258
|
+
{
|
|
259
|
+
"warnings": (telemetry or {}).get("warnings") or [],
|
|
260
|
+
"raw": (telemetry or {}).get("raw") or {},
|
|
261
|
+
},
|
|
262
|
+
ensure_ascii=False,
|
|
263
|
+
)
|
|
264
|
+
status = "ok" if int(returncode) == 0 else "failed"
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
conn = get_db()
|
|
268
|
+
if row_id is not None:
|
|
269
|
+
conn.execute(
|
|
270
|
+
"""
|
|
271
|
+
UPDATE automation_runs
|
|
272
|
+
SET returncode=?, duration_ms=?,
|
|
273
|
+
input_tokens=?, cached_input_tokens=?, output_tokens=?,
|
|
274
|
+
total_cost_usd=?, telemetry_source=?, cost_source=?,
|
|
275
|
+
status=?, metadata=?, ended_at=datetime('now')
|
|
276
|
+
WHERE id=?
|
|
277
|
+
""",
|
|
278
|
+
(
|
|
279
|
+
int(returncode),
|
|
280
|
+
int(duration_ms),
|
|
281
|
+
int(usage.get("input_tokens") or 0),
|
|
282
|
+
int(usage.get("cached_input_tokens") or 0),
|
|
283
|
+
int(usage.get("output_tokens") or 0),
|
|
284
|
+
(telemetry or {}).get("total_cost_usd"),
|
|
285
|
+
(telemetry or {}).get("telemetry_source", ""),
|
|
286
|
+
(telemetry or {}).get("cost_source", ""),
|
|
287
|
+
status,
|
|
288
|
+
metadata,
|
|
289
|
+
int(row_id),
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
conn.execute(
|
|
294
|
+
"""
|
|
295
|
+
INSERT INTO automation_runs (
|
|
296
|
+
backend, task_profile, model, reasoning_effort, cwd,
|
|
297
|
+
output_format, prompt_chars, returncode, duration_ms,
|
|
298
|
+
input_tokens, cached_input_tokens, output_tokens,
|
|
299
|
+
total_cost_usd, telemetry_source, cost_source, status,
|
|
300
|
+
metadata, ended_at
|
|
301
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
302
|
+
""",
|
|
303
|
+
(
|
|
304
|
+
"", "default", "", "", "", "text", 0,
|
|
305
|
+
int(returncode),
|
|
306
|
+
int(duration_ms),
|
|
307
|
+
int(usage.get("input_tokens") or 0),
|
|
308
|
+
int(usage.get("cached_input_tokens") or 0),
|
|
309
|
+
int(usage.get("output_tokens") or 0),
|
|
310
|
+
(telemetry or {}).get("total_cost_usd"),
|
|
311
|
+
(telemetry or {}).get("telemetry_source", ""),
|
|
312
|
+
(telemetry or {}).get("cost_source", ""),
|
|
313
|
+
status,
|
|
314
|
+
metadata,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
conn.commit()
|
|
240
318
|
return True, ""
|
|
241
319
|
except Exception as exc:
|
|
242
320
|
return False, f"automation telemetry unavailable: {exc}"
|
|
243
321
|
|
|
244
322
|
|
|
323
|
+
def _record_automation_run(
|
|
324
|
+
*,
|
|
325
|
+
backend: str,
|
|
326
|
+
task_profile: str,
|
|
327
|
+
model: str,
|
|
328
|
+
reasoning_effort: str,
|
|
329
|
+
cwd: Path,
|
|
330
|
+
output_format: str,
|
|
331
|
+
prompt: str,
|
|
332
|
+
returncode: int,
|
|
333
|
+
duration_ms: int,
|
|
334
|
+
telemetry: dict,
|
|
335
|
+
caller: str = "",
|
|
336
|
+
session_type: str = "headless",
|
|
337
|
+
resonance_tier: str = "",
|
|
338
|
+
) -> tuple[bool, str]:
|
|
339
|
+
"""Backwards-compatible facade for code paths that record in a single
|
|
340
|
+
shot (no separate start row). Prefer the split start/end pair going
|
|
341
|
+
forward so interactive sessions and long-running jobs can be seen
|
|
342
|
+
while they are still in flight."""
|
|
343
|
+
row_id, err = _record_automation_start(
|
|
344
|
+
caller=caller,
|
|
345
|
+
backend=backend,
|
|
346
|
+
session_type=session_type,
|
|
347
|
+
task_profile=task_profile,
|
|
348
|
+
model=model,
|
|
349
|
+
reasoning_effort=reasoning_effort,
|
|
350
|
+
resonance_tier=resonance_tier,
|
|
351
|
+
cwd=cwd,
|
|
352
|
+
output_format=output_format,
|
|
353
|
+
prompt=prompt,
|
|
354
|
+
)
|
|
355
|
+
if err and row_id is None:
|
|
356
|
+
pass # fall through, end will insert a single row
|
|
357
|
+
return _record_automation_end(
|
|
358
|
+
row_id=row_id,
|
|
359
|
+
returncode=returncode,
|
|
360
|
+
duration_ms=duration_ms,
|
|
361
|
+
telemetry=telemetry,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
245
365
|
def _resolve_claude_cli() -> str:
|
|
246
366
|
saved = NEXO_HOME / "config" / "claude-cli-path"
|
|
247
367
|
if saved.exists():
|
|
@@ -394,11 +514,94 @@ def launch_interactive_client(
|
|
|
394
514
|
env: dict | None = None,
|
|
395
515
|
preferences: dict | None = None,
|
|
396
516
|
) -> subprocess.CompletedProcess:
|
|
397
|
-
|
|
517
|
+
return run_automation_interactive(
|
|
518
|
+
caller="nexo_chat",
|
|
519
|
+
target=target,
|
|
520
|
+
client=client,
|
|
521
|
+
env=env,
|
|
522
|
+
preferences=preferences,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def run_automation_interactive(
|
|
527
|
+
*,
|
|
528
|
+
caller: str,
|
|
529
|
+
target: str | os.PathLike[str],
|
|
530
|
+
client: str | None = None,
|
|
531
|
+
env: dict | None = None,
|
|
532
|
+
preferences: dict | None = None,
|
|
533
|
+
session_type: str = "interactive_chat",
|
|
534
|
+
) -> subprocess.CompletedProcess:
|
|
535
|
+
"""Launch an interactive Claude/Codex session with automation_runs logging.
|
|
536
|
+
|
|
537
|
+
Unlike ``run_automation_prompt`` the child inherits stdin/stdout/stderr
|
|
538
|
+
from the current terminal so the user can carry a normal conversation.
|
|
539
|
+
We still record the run in the ``automation_runs`` table: a row is
|
|
540
|
+
INSERTed at spawn time with ``ended_at IS NULL`` and UPDATEd when the
|
|
541
|
+
session exits. Rows that die before the UPDATE (crash, kill -9) can be
|
|
542
|
+
reconciled later by the watchdog via the ``pid`` column.
|
|
543
|
+
|
|
544
|
+
``caller`` must be registered in ``src/resonance_map.py`` (typically
|
|
545
|
+
``nexo_chat``, ``desktop_new_session``, or ``nexo_update_interactive``).
|
|
546
|
+
The resonance tier resolves through the user's default preference so
|
|
547
|
+
the interactive surface honours whatever the user selected.
|
|
548
|
+
"""
|
|
549
|
+
prefs = preferences or load_client_preferences()
|
|
550
|
+
resolved_client, cmd = build_interactive_client_command(
|
|
551
|
+
target=target, client=client, preferences=prefs
|
|
552
|
+
)
|
|
398
553
|
launch_env = os.environ.copy()
|
|
399
554
|
if env:
|
|
400
555
|
launch_env.update(env)
|
|
401
|
-
|
|
556
|
+
cwd_path = Path(_interactive_target_cwd(target))
|
|
557
|
+
|
|
558
|
+
# Best-effort resonance lookup — interactive sessions do not swap the
|
|
559
|
+
# command (the user chose Claude or Codex explicitly), but we still
|
|
560
|
+
# record which tier they are running at so telemetry is honest.
|
|
561
|
+
resonance_tier = ""
|
|
562
|
+
try:
|
|
563
|
+
from resonance_map import resolve_tier_for_caller
|
|
564
|
+
user_default = ""
|
|
565
|
+
if isinstance(prefs, dict):
|
|
566
|
+
user_default = str(prefs.get("default_resonance") or "").strip()
|
|
567
|
+
resonance_tier = resolve_tier_for_caller(
|
|
568
|
+
caller, user_default=user_default or None
|
|
569
|
+
)
|
|
570
|
+
except Exception:
|
|
571
|
+
resonance_tier = ""
|
|
572
|
+
|
|
573
|
+
# model / effort come from the pre-built command; we don't replay them
|
|
574
|
+
# here (build_interactive_client_command already embedded them).
|
|
575
|
+
row_id, _record_err = _record_automation_start(
|
|
576
|
+
caller=caller,
|
|
577
|
+
backend=resolved_client,
|
|
578
|
+
session_type=session_type,
|
|
579
|
+
task_profile="",
|
|
580
|
+
model="",
|
|
581
|
+
reasoning_effort="",
|
|
582
|
+
resonance_tier=resonance_tier,
|
|
583
|
+
cwd=cwd_path,
|
|
584
|
+
output_format="interactive",
|
|
585
|
+
prompt="",
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
started_wall = time.perf_counter()
|
|
589
|
+
try:
|
|
590
|
+
result = subprocess.run(cmd, env=launch_env, cwd=str(cwd_path))
|
|
591
|
+
finally:
|
|
592
|
+
duration_ms = int((time.perf_counter() - started_wall) * 1000)
|
|
593
|
+
try:
|
|
594
|
+
_record_automation_end(
|
|
595
|
+
row_id=row_id,
|
|
596
|
+
returncode=getattr(result, "returncode", -1)
|
|
597
|
+
if "result" in locals()
|
|
598
|
+
else -1,
|
|
599
|
+
duration_ms=duration_ms,
|
|
600
|
+
telemetry={},
|
|
601
|
+
)
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
return result
|
|
402
605
|
|
|
403
606
|
|
|
404
607
|
def build_followup_terminal_shell_command(
|
|
@@ -592,6 +795,7 @@ def _build_enforcement_system_prompt() -> str:
|
|
|
592
795
|
def run_automation_prompt(
|
|
593
796
|
prompt: str,
|
|
594
797
|
*,
|
|
798
|
+
caller: str = "",
|
|
595
799
|
backend: str | None = None,
|
|
596
800
|
task_profile: str = "",
|
|
597
801
|
cwd: str | os.PathLike[str] | None = None,
|
|
@@ -618,6 +822,36 @@ def run_automation_prompt(
|
|
|
618
822
|
reasoning_effort = profile["reasoning_effort"]
|
|
619
823
|
selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
|
|
620
824
|
|
|
825
|
+
# Resonance map takes over model+effort decisions when the caller is
|
|
826
|
+
# registered. Explicit model/effort arguments still win (required for
|
|
827
|
+
# edge cases like the fallback JSON-conversion call inside extract.py
|
|
828
|
+
# that asks a shorter/cheaper follow-up).
|
|
829
|
+
resonance_tier = ""
|
|
830
|
+
if caller and not model and not reasoning_effort:
|
|
831
|
+
try:
|
|
832
|
+
from resonance_map import (
|
|
833
|
+
resolve_model_and_effort,
|
|
834
|
+
resolve_tier_for_caller,
|
|
835
|
+
UnregisteredCallerError,
|
|
836
|
+
)
|
|
837
|
+
user_default = ""
|
|
838
|
+
if isinstance(prefs, dict):
|
|
839
|
+
user_default = str(prefs.get("default_resonance") or "").strip()
|
|
840
|
+
resonance_tier = resolve_tier_for_caller(
|
|
841
|
+
caller, user_default=user_default or None
|
|
842
|
+
)
|
|
843
|
+
mapped_model, mapped_effort = resolve_model_and_effort(
|
|
844
|
+
caller, selected_backend, user_default=user_default or None
|
|
845
|
+
)
|
|
846
|
+
if mapped_model:
|
|
847
|
+
model = mapped_model
|
|
848
|
+
if mapped_effort:
|
|
849
|
+
reasoning_effort = mapped_effort
|
|
850
|
+
except (ImportError, UnregisteredCallerError):
|
|
851
|
+
# Unknown caller during a transitional release: fall back to
|
|
852
|
+
# the legacy task_profile / model_defaults resolution below.
|
|
853
|
+
pass
|
|
854
|
+
|
|
621
855
|
enforcement_fragment = _build_enforcement_system_prompt()
|
|
622
856
|
if enforcement_fragment:
|
|
623
857
|
if append_system_prompt:
|
|
@@ -698,6 +932,9 @@ def run_automation_prompt(
|
|
|
698
932
|
returncode=result.returncode,
|
|
699
933
|
duration_ms=int((time.perf_counter() - started_at) * 1000),
|
|
700
934
|
telemetry=telemetry,
|
|
935
|
+
caller=caller,
|
|
936
|
+
session_type="headless",
|
|
937
|
+
resonance_tier=resonance_tier,
|
|
701
938
|
)
|
|
702
939
|
stderr = result.stderr or ""
|
|
703
940
|
if not recorded:
|
package/src/cli.py
CHANGED
|
@@ -1099,6 +1099,106 @@ def _clients_sync(args):
|
|
|
1099
1099
|
return 0 if result.get("ok") else 1
|
|
1100
1100
|
|
|
1101
1101
|
|
|
1102
|
+
def _write_calibration_default_resonance(tier: str) -> None:
|
|
1103
|
+
"""Persist ``preferences.default_resonance`` in ``brain/calibration.json``.
|
|
1104
|
+
|
|
1105
|
+
NEXO Desktop's preferences UI reads from calibration.json (matches the
|
|
1106
|
+
rest of the user-facing knobs — autonomy, communication, assistant_name,
|
|
1107
|
+
…). This helper keeps the CLI path writing to both calibration.json
|
|
1108
|
+
AND schedule.json so the two surfaces never disagree.
|
|
1109
|
+
"""
|
|
1110
|
+
cal_path = NEXO_HOME / "brain" / "calibration.json"
|
|
1111
|
+
try:
|
|
1112
|
+
cal_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1113
|
+
if cal_path.exists():
|
|
1114
|
+
data = json.loads(cal_path.read_text())
|
|
1115
|
+
if not isinstance(data, dict):
|
|
1116
|
+
data = {}
|
|
1117
|
+
else:
|
|
1118
|
+
data = {}
|
|
1119
|
+
prefs = data.get("preferences")
|
|
1120
|
+
if not isinstance(prefs, dict):
|
|
1121
|
+
prefs = {}
|
|
1122
|
+
prefs["default_resonance"] = tier
|
|
1123
|
+
data["preferences"] = prefs
|
|
1124
|
+
cal_path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
1125
|
+
except Exception as exc: # best-effort; schedule.json still has the value
|
|
1126
|
+
print(f"[NEXO] Warning: could not update calibration.json: {exc}",
|
|
1127
|
+
file=sys.stderr)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def _preferences(args):
|
|
1131
|
+
"""Read or change user preferences stored in schedule.json.
|
|
1132
|
+
|
|
1133
|
+
Today this manages ``default_resonance``. Other knobs (default_client,
|
|
1134
|
+
autonomy level, etc.) can be added here instead of spreading across
|
|
1135
|
+
one-off flags.
|
|
1136
|
+
"""
|
|
1137
|
+
from client_preferences import (
|
|
1138
|
+
load_client_preferences,
|
|
1139
|
+
save_client_preferences,
|
|
1140
|
+
)
|
|
1141
|
+
from resonance_map import (
|
|
1142
|
+
DEFAULT_RESONANCE,
|
|
1143
|
+
TIERS,
|
|
1144
|
+
_load_user_default_resonance,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
prefs = load_client_preferences()
|
|
1148
|
+
if not isinstance(prefs, dict):
|
|
1149
|
+
prefs = {}
|
|
1150
|
+
|
|
1151
|
+
if args.resonance:
|
|
1152
|
+
tier = args.resonance.lower()
|
|
1153
|
+
if tier not in TIERS:
|
|
1154
|
+
print(
|
|
1155
|
+
f"[NEXO] Unknown resonance tier '{args.resonance}'. "
|
|
1156
|
+
f"Valid values: {', '.join(TIERS)}.",
|
|
1157
|
+
file=sys.stderr,
|
|
1158
|
+
)
|
|
1159
|
+
return 2
|
|
1160
|
+
# Write to schedule.json (legacy CLI location)…
|
|
1161
|
+
save_client_preferences(default_resonance=tier)
|
|
1162
|
+
# …and to calibration.json (where NEXO Desktop's preferences UI
|
|
1163
|
+
# reads/writes). Keeping both in sync means the two surfaces agree.
|
|
1164
|
+
_write_calibration_default_resonance(tier)
|
|
1165
|
+
prefs = load_client_preferences()
|
|
1166
|
+
|
|
1167
|
+
calibration_value = _load_user_default_resonance()
|
|
1168
|
+
schedule_value = str(
|
|
1169
|
+
(prefs.get("default_resonance") if isinstance(prefs, dict) else "")
|
|
1170
|
+
or ""
|
|
1171
|
+
).strip().lower()
|
|
1172
|
+
current_resonance = calibration_value or schedule_value or DEFAULT_RESONANCE
|
|
1173
|
+
|
|
1174
|
+
if args.show or args.resonance:
|
|
1175
|
+
is_explicit = bool(calibration_value or schedule_value)
|
|
1176
|
+
payload = {
|
|
1177
|
+
"default_resonance": current_resonance,
|
|
1178
|
+
"default_resonance_is_explicit": is_explicit,
|
|
1179
|
+
"default_resonance_source": (
|
|
1180
|
+
"calibration.json" if calibration_value
|
|
1181
|
+
else ("schedule.json" if schedule_value else "default")
|
|
1182
|
+
),
|
|
1183
|
+
"available_tiers": list(TIERS),
|
|
1184
|
+
}
|
|
1185
|
+
if args.json:
|
|
1186
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1187
|
+
else:
|
|
1188
|
+
print(f"default_resonance = {current_resonance}")
|
|
1189
|
+
print(f" source: {payload['default_resonance_source']}")
|
|
1190
|
+
if not is_explicit:
|
|
1191
|
+
print(f" (inherited from DEFAULT_RESONANCE; run "
|
|
1192
|
+
f"`nexo preferences --resonance alto` to set explicitly)")
|
|
1193
|
+
return 0
|
|
1194
|
+
|
|
1195
|
+
# No flag: print usage
|
|
1196
|
+
print("Usage: nexo preferences [--resonance TIER] [--show] [--json]")
|
|
1197
|
+
print(f" resonance tiers: {', '.join(TIERS)}")
|
|
1198
|
+
print(f" current default: {current_resonance}")
|
|
1199
|
+
return 0
|
|
1200
|
+
|
|
1201
|
+
|
|
1102
1202
|
def _contributor_status(args):
|
|
1103
1203
|
public_contribution = _load_public_contribution_support()
|
|
1104
1204
|
config = public_contribution["refresh_public_contribution_state"](
|
|
@@ -2051,6 +2151,26 @@ def main():
|
|
|
2051
2151
|
clients_sync_p = clients_sub.add_parser("sync", help="Sync Claude Code, Claude Desktop, and Codex to the same NEXO brain")
|
|
2052
2152
|
clients_sync_p.add_argument("--json", action="store_true", help="JSON output")
|
|
2053
2153
|
|
|
2154
|
+
# -- preferences --
|
|
2155
|
+
preferences_parser = sub.add_parser(
|
|
2156
|
+
"preferences",
|
|
2157
|
+
help="Read or change NEXO user preferences (resonance, default client, ...)",
|
|
2158
|
+
)
|
|
2159
|
+
preferences_parser.add_argument(
|
|
2160
|
+
"--resonance",
|
|
2161
|
+
choices=["maximo", "alto", "medio", "bajo"],
|
|
2162
|
+
help="Set the default resonance tier for interactive sessions "
|
|
2163
|
+
"(nexo chat, Desktop new conversation, interactive nexo update). "
|
|
2164
|
+
"System-owned callers (deep-sleep, catchup, etc.) ignore this value "
|
|
2165
|
+
"and use the tier hard-coded in resonance_map.py. Default: alto.",
|
|
2166
|
+
)
|
|
2167
|
+
preferences_parser.add_argument(
|
|
2168
|
+
"--show",
|
|
2169
|
+
action="store_true",
|
|
2170
|
+
help="Print the current preferences as JSON and exit.",
|
|
2171
|
+
)
|
|
2172
|
+
preferences_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2173
|
+
|
|
2054
2174
|
# -- doctor --
|
|
2055
2175
|
doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
|
|
2056
2176
|
doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
|
|
@@ -2245,6 +2365,8 @@ def main():
|
|
|
2245
2365
|
return _clients_sync(args)
|
|
2246
2366
|
clients_parser.print_help()
|
|
2247
2367
|
return 0
|
|
2368
|
+
elif args.command == "preferences":
|
|
2369
|
+
return _preferences(args)
|
|
2248
2370
|
elif args.command == "doctor":
|
|
2249
2371
|
return _doctor(args)
|
|
2250
2372
|
elif args.command == "contributor":
|
|
@@ -494,6 +494,7 @@ def save_client_preferences(
|
|
|
494
494
|
automation_task_profiles: dict | None = None,
|
|
495
495
|
client_install_preferences: dict | None = None,
|
|
496
496
|
acknowledged_model_recommendations: dict | None = None,
|
|
497
|
+
default_resonance: str | None = None,
|
|
497
498
|
) -> Path:
|
|
498
499
|
schedule = apply_client_preferences(
|
|
499
500
|
load_schedule_config(),
|
|
@@ -507,6 +508,10 @@ def save_client_preferences(
|
|
|
507
508
|
client_install_preferences=client_install_preferences,
|
|
508
509
|
acknowledged_model_recommendations=acknowledged_model_recommendations,
|
|
509
510
|
)
|
|
511
|
+
if default_resonance is not None:
|
|
512
|
+
tier = str(default_resonance).strip().lower()
|
|
513
|
+
if tier:
|
|
514
|
+
schedule["default_resonance"] = tier
|
|
510
515
|
return save_schedule_config(schedule)
|
|
511
516
|
|
|
512
517
|
|
package/src/db/_schema.py
CHANGED
|
@@ -968,6 +968,48 @@ def _m40_classification_columns(conn):
|
|
|
968
968
|
_migrate_add_index(conn, "idx_reminders_owner", "reminders", "owner")
|
|
969
969
|
|
|
970
970
|
|
|
971
|
+
def _m41_automation_sessions_columns(conn):
|
|
972
|
+
"""Extend automation_runs with session-level tracking.
|
|
973
|
+
|
|
974
|
+
v5.9.0 introduces two changes to how we record Claude/Codex invocations:
|
|
975
|
+
|
|
976
|
+
1. Every caller is now required to pass a ``caller=`` string registered in
|
|
977
|
+
``src/resonance_map.py``. Stored in a new ``caller`` column so every
|
|
978
|
+
row is traceable to the subsystem that started it (deep-sleep/extract,
|
|
979
|
+
evolution/run, nexo_chat, desktop_new_session, …).
|
|
980
|
+
|
|
981
|
+
2. Interactive sessions (``nexo chat`` and Desktop new conversation) no
|
|
982
|
+
longer bypass the logging path. They record a row at spawn time with
|
|
983
|
+
``ended_at IS NULL`` and update it on close. The ``session_type``
|
|
984
|
+
column distinguishes ``headless`` from ``interactive_chat`` and
|
|
985
|
+
``interactive_desktop`` so dashboards can slice the data by invocation
|
|
986
|
+
shape.
|
|
987
|
+
|
|
988
|
+
Migration is idempotent: ``_migrate_add_column`` is a no-op when the
|
|
989
|
+
column already exists; existing rows get empty / NULL values which is
|
|
990
|
+
compatible with callers that have not been updated yet.
|
|
991
|
+
"""
|
|
992
|
+
_migrate_add_column(conn, "automation_runs", "caller", "TEXT DEFAULT ''")
|
|
993
|
+
_migrate_add_column(
|
|
994
|
+
conn, "automation_runs", "session_type", "TEXT DEFAULT 'headless'"
|
|
995
|
+
)
|
|
996
|
+
_migrate_add_column(conn, "automation_runs", "started_at", "TEXT")
|
|
997
|
+
_migrate_add_column(conn, "automation_runs", "ended_at", "TEXT")
|
|
998
|
+
_migrate_add_column(conn, "automation_runs", "pid", "INTEGER")
|
|
999
|
+
_migrate_add_column(
|
|
1000
|
+
conn, "automation_runs", "resonance_tier", "TEXT DEFAULT ''"
|
|
1001
|
+
)
|
|
1002
|
+
_migrate_add_index(
|
|
1003
|
+
conn, "idx_automation_runs_caller", "automation_runs", "caller"
|
|
1004
|
+
)
|
|
1005
|
+
_migrate_add_index(
|
|
1006
|
+
conn, "idx_automation_runs_session_type", "automation_runs", "session_type"
|
|
1007
|
+
)
|
|
1008
|
+
_migrate_add_index(
|
|
1009
|
+
conn, "idx_automation_runs_started_at", "automation_runs", "started_at"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
971
1013
|
MIGRATIONS = [
|
|
972
1014
|
(1, "learnings_columns", _m1_learnings_columns),
|
|
973
1015
|
(2, "followups_reasoning", _m2_followups_reasoning),
|
|
@@ -1009,6 +1051,7 @@ MIGRATIONS = [
|
|
|
1009
1051
|
(38, "evolution_log_proposal_payload", _m38_evolution_log_proposal_payload),
|
|
1010
1052
|
(39, "hook_runs", _m39_hook_runs),
|
|
1011
1053
|
(40, "classification_columns", _m40_classification_columns),
|
|
1054
|
+
(41, "automation_sessions_columns", _m41_automation_sessions_columns),
|
|
1012
1055
|
]
|
|
1013
1056
|
|
|
1014
1057
|
|
package/src/desktop_bridge.py
CHANGED
|
@@ -191,6 +191,34 @@ def _schema_fields() -> list[dict]:
|
|
|
191
191
|
{"value": "verbose", "label": {"es": "Detallado", "en": "Verbose"}},
|
|
192
192
|
],
|
|
193
193
|
},
|
|
194
|
+
{
|
|
195
|
+
"path": "preferences.default_resonance",
|
|
196
|
+
"file": "calibration.json",
|
|
197
|
+
"label": {"es": "Resonancia por defecto", "en": "Default resonance"},
|
|
198
|
+
"type": "select",
|
|
199
|
+
"group": "preferences",
|
|
200
|
+
"default": "alto",
|
|
201
|
+
"hint": {
|
|
202
|
+
"es": (
|
|
203
|
+
"Potencia del modelo para sesiones interactivas (nexo chat y "
|
|
204
|
+
"nueva conversación en Desktop). Los crons y procesos de fondo "
|
|
205
|
+
"(deep sleep, evolution, etc.) ignoran esta preferencia — los "
|
|
206
|
+
"definimos nosotros en resonance_map.py por calidad."
|
|
207
|
+
),
|
|
208
|
+
"en": (
|
|
209
|
+
"Model power for interactive sessions (nexo chat and Desktop "
|
|
210
|
+
"new conversation). Crons and background processes (deep sleep, "
|
|
211
|
+
"evolution, etc.) ignore this preference — we pin them per "
|
|
212
|
+
"caller in resonance_map.py based on quality needs."
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
"options": [
|
|
216
|
+
{"value": "maximo", "label": {"es": "Máximo", "en": "Maximum"}},
|
|
217
|
+
{"value": "alto", "label": {"es": "Alto (recomendado)", "en": "High (recommended)"}},
|
|
218
|
+
{"value": "medio", "label": {"es": "Medio", "en": "Medium"}},
|
|
219
|
+
{"value": "bajo", "label": {"es": "Bajo", "en": "Low"}},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
194
222
|
{
|
|
195
223
|
"path": "meta.role",
|
|
196
224
|
"file": "calibration.json",
|