nexo-brain 5.8.2 → 5.9.0
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/agent_runner.py +270 -33
- package/src/cli.py +79 -0
- package/src/client_preferences.py +5 -0
- package/src/db/_schema.py +43 -0
- package/src/resonance_map.py +247 -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.0",
|
|
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 `5.
|
|
21
|
+
Version `5.9.0` is the current packaged-runtime line: 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.
|
|
22
|
+
|
|
23
|
+
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
24
|
|
|
23
25
|
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
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.0",
|
|
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,63 @@ def _clients_sync(args):
|
|
|
1099
1099
|
return 0 if result.get("ok") else 1
|
|
1100
1100
|
|
|
1101
1101
|
|
|
1102
|
+
def _preferences(args):
|
|
1103
|
+
"""Read or change user preferences stored in schedule.json.
|
|
1104
|
+
|
|
1105
|
+
Today this manages ``default_resonance``. Other knobs (default_client,
|
|
1106
|
+
autonomy level, etc.) can be added here instead of spreading across
|
|
1107
|
+
one-off flags.
|
|
1108
|
+
"""
|
|
1109
|
+
from client_preferences import (
|
|
1110
|
+
load_client_preferences,
|
|
1111
|
+
save_client_preferences,
|
|
1112
|
+
)
|
|
1113
|
+
from resonance_map import DEFAULT_RESONANCE, TIERS
|
|
1114
|
+
|
|
1115
|
+
prefs = load_client_preferences()
|
|
1116
|
+
if not isinstance(prefs, dict):
|
|
1117
|
+
prefs = {}
|
|
1118
|
+
|
|
1119
|
+
if args.resonance:
|
|
1120
|
+
tier = args.resonance.lower()
|
|
1121
|
+
if tier not in TIERS:
|
|
1122
|
+
print(
|
|
1123
|
+
f"[NEXO] Unknown resonance tier '{args.resonance}'. "
|
|
1124
|
+
f"Valid values: {', '.join(TIERS)}.",
|
|
1125
|
+
file=sys.stderr,
|
|
1126
|
+
)
|
|
1127
|
+
return 2
|
|
1128
|
+
save_client_preferences(default_resonance=tier)
|
|
1129
|
+
prefs = load_client_preferences()
|
|
1130
|
+
|
|
1131
|
+
current_resonance = str(
|
|
1132
|
+
(prefs.get("default_resonance") if isinstance(prefs, dict) else "")
|
|
1133
|
+
or DEFAULT_RESONANCE
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
if args.show or args.resonance:
|
|
1137
|
+
payload = {
|
|
1138
|
+
"default_resonance": current_resonance,
|
|
1139
|
+
"default_resonance_is_explicit": isinstance(prefs, dict)
|
|
1140
|
+
and bool(prefs.get("default_resonance")),
|
|
1141
|
+
"available_tiers": list(TIERS),
|
|
1142
|
+
}
|
|
1143
|
+
if args.json:
|
|
1144
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1145
|
+
else:
|
|
1146
|
+
print(f"default_resonance = {current_resonance}")
|
|
1147
|
+
if not payload["default_resonance_is_explicit"]:
|
|
1148
|
+
print(f" (inherited from DEFAULT_RESONANCE; run "
|
|
1149
|
+
f"`nexo preferences --resonance alto` to set explicitly)")
|
|
1150
|
+
return 0
|
|
1151
|
+
|
|
1152
|
+
# No flag: print usage
|
|
1153
|
+
print("Usage: nexo preferences [--resonance TIER] [--show] [--json]")
|
|
1154
|
+
print(f" resonance tiers: {', '.join(TIERS)}")
|
|
1155
|
+
print(f" current default: {current_resonance}")
|
|
1156
|
+
return 0
|
|
1157
|
+
|
|
1158
|
+
|
|
1102
1159
|
def _contributor_status(args):
|
|
1103
1160
|
public_contribution = _load_public_contribution_support()
|
|
1104
1161
|
config = public_contribution["refresh_public_contribution_state"](
|
|
@@ -2051,6 +2108,26 @@ def main():
|
|
|
2051
2108
|
clients_sync_p = clients_sub.add_parser("sync", help="Sync Claude Code, Claude Desktop, and Codex to the same NEXO brain")
|
|
2052
2109
|
clients_sync_p.add_argument("--json", action="store_true", help="JSON output")
|
|
2053
2110
|
|
|
2111
|
+
# -- preferences --
|
|
2112
|
+
preferences_parser = sub.add_parser(
|
|
2113
|
+
"preferences",
|
|
2114
|
+
help="Read or change NEXO user preferences (resonance, default client, ...)",
|
|
2115
|
+
)
|
|
2116
|
+
preferences_parser.add_argument(
|
|
2117
|
+
"--resonance",
|
|
2118
|
+
choices=["maximo", "alto", "medio", "bajo"],
|
|
2119
|
+
help="Set the default resonance tier for interactive sessions "
|
|
2120
|
+
"(nexo chat, Desktop new conversation, interactive nexo update). "
|
|
2121
|
+
"System-owned callers (deep-sleep, catchup, etc.) ignore this value "
|
|
2122
|
+
"and use the tier hard-coded in resonance_map.py. Default: alto.",
|
|
2123
|
+
)
|
|
2124
|
+
preferences_parser.add_argument(
|
|
2125
|
+
"--show",
|
|
2126
|
+
action="store_true",
|
|
2127
|
+
help="Print the current preferences as JSON and exit.",
|
|
2128
|
+
)
|
|
2129
|
+
preferences_parser.add_argument("--json", action="store_true", help="JSON output")
|
|
2130
|
+
|
|
2054
2131
|
# -- doctor --
|
|
2055
2132
|
doctor_parser = sub.add_parser("doctor", help="Unified diagnostics")
|
|
2056
2133
|
doctor_parser.add_argument("--tier", default="boot", choices=["boot", "runtime", "deep", "all"],
|
|
@@ -2245,6 +2322,8 @@ def main():
|
|
|
2245
2322
|
return _clients_sync(args)
|
|
2246
2323
|
clients_parser.print_help()
|
|
2247
2324
|
return 0
|
|
2325
|
+
elif args.command == "preferences":
|
|
2326
|
+
return _preferences(args)
|
|
2248
2327
|
elif args.command == "doctor":
|
|
2249
2328
|
return _doctor(args)
|
|
2250
2329
|
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
|
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Central resonance map — single source of truth for (backend, model, effort)
|
|
2
|
+
decisions across every automation caller.
|
|
3
|
+
|
|
4
|
+
Motivation
|
|
5
|
+
----------
|
|
6
|
+
Before v5.9.0 each caller that wanted to invoke Claude or Codex had to either
|
|
7
|
+
pass explicit model + reasoning_effort arguments or rely on the global defaults
|
|
8
|
+
in ``model_defaults.json``. Global defaults meant every background cron shared
|
|
9
|
+
the same max-effort configuration as the interactive ``nexo chat``, so batch
|
|
10
|
+
jobs (daily synthesis, postmortem consolidation, gbp posts) burned maximum
|
|
11
|
+
reasoning effort on tasks that didn't need it, while downgrading any single
|
|
12
|
+
default changed behaviour everywhere at once.
|
|
13
|
+
|
|
14
|
+
This module introduces four **resonance tiers** (``MAXIMO`` / ``ALTO`` /
|
|
15
|
+
``MEDIO`` / ``BAJO``) and maps each tier to a concrete ``(model, effort)`` pair
|
|
16
|
+
per backend. Every caller is labelled in one of two ways:
|
|
17
|
+
|
|
18
|
+
- **User-facing callers** (``nexo chat``, Desktop new session, interactive
|
|
19
|
+
``nexo update``) use the user's configured default resonance. When the
|
|
20
|
+
user changes the default via ``nexo preferences --resonance`` or through
|
|
21
|
+
the Desktop preferences pane, those three entry points adjust.
|
|
22
|
+
|
|
23
|
+
- **System-owned callers** (every cron, every background script, every
|
|
24
|
+
MCP-tool-triggered automation) use a fixed tier we pick per caller based
|
|
25
|
+
on what the task needs. A quarterly evolution pass that synthesizes
|
|
26
|
+
ten thousand lines into a new self-improvement plan is ``MAXIMO``. A
|
|
27
|
+
daily GBP post that needs to produce 200 characters of marketing copy
|
|
28
|
+
is ``BAJO``. That decision stays in this file and NEVER reads the user
|
|
29
|
+
default.
|
|
30
|
+
|
|
31
|
+
If a backend does not offer all four effort settings (e.g. a hypothetical
|
|
32
|
+
model with only ``max`` and ``low``), we collapse adjacent tiers — ``MAXIMO``
|
|
33
|
+
and ``ALTO`` both map to the backend's highest available effort, ``MEDIO``
|
|
34
|
+
and ``BAJO`` to the lowest. If a backend has no effort knob at all, the tier
|
|
35
|
+
still resolves to the same model with an empty effort string; the resonance
|
|
36
|
+
label is then informational only.
|
|
37
|
+
|
|
38
|
+
Contract
|
|
39
|
+
--------
|
|
40
|
+
Every call into ``run_automation_prompt`` and ``run_automation_interactive``
|
|
41
|
+
MUST pass a ``caller=`` string that is registered here. Callers not in the
|
|
42
|
+
registry raise ``UnregisteredCallerError`` — there is no silent default. This
|
|
43
|
+
forces the resonance decision to be explicit and auditable, and prevents
|
|
44
|
+
future scripts from silently inheriting the wrong tier.
|
|
45
|
+
"""
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from typing import Tuple
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Tier → (claude_model, claude_effort, codex_model, codex_effort)
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Keep this table in ONE place. When we promote a new Claude or Codex model,
|
|
55
|
+
# update only this dict — every caller rebalances automatically.
|
|
56
|
+
#
|
|
57
|
+
# If a future backend offers fewer tiers (e.g. only max + low), collapse
|
|
58
|
+
# adjacent tiers onto the closest available effort. MAXIMO + ALTO → highest,
|
|
59
|
+
# MEDIO + BAJO → lowest. If a backend has no effort setting at all, leave
|
|
60
|
+
# the effort string empty.
|
|
61
|
+
|
|
62
|
+
TIERS = ("maximo", "alto", "medio", "bajo")
|
|
63
|
+
|
|
64
|
+
_RESONANCE_TABLE: dict[str, dict[str, tuple[str, str]]] = {
|
|
65
|
+
"maximo": {
|
|
66
|
+
"claude_code": ("claude-opus-4-7[1m]", "max"),
|
|
67
|
+
"codex": ("gpt-5.4", "xhigh"),
|
|
68
|
+
},
|
|
69
|
+
"alto": {
|
|
70
|
+
"claude_code": ("claude-opus-4-7[1m]", "xhigh"),
|
|
71
|
+
"codex": ("gpt-5.4", "high"),
|
|
72
|
+
},
|
|
73
|
+
"medio": {
|
|
74
|
+
"claude_code": ("claude-opus-4-7[1m]", "high"),
|
|
75
|
+
"codex": ("gpt-5.4", "medium"),
|
|
76
|
+
},
|
|
77
|
+
"bajo": {
|
|
78
|
+
"claude_code": ("claude-opus-4-7[1m]", "medium"),
|
|
79
|
+
"codex": ("gpt-5.4", "low"),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
DEFAULT_RESONANCE = "alto"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# Caller registry
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Every script that calls the automation backend is registered here. Two
|
|
90
|
+
# categories, in two separate dicts so a reviewer can see at a glance which
|
|
91
|
+
# callers follow the user's preference and which are locked by us.
|
|
92
|
+
#
|
|
93
|
+
# USE_USER_DEFAULT → the caller reads the user's configured resonance at
|
|
94
|
+
# runtime. Only three callers should ever be in this list:
|
|
95
|
+
# the two interactive entry points (terminal chat, Desktop
|
|
96
|
+
# new conversation) and the interactive nexo update flow.
|
|
97
|
+
#
|
|
98
|
+
# SYSTEM_OWNED → the caller runs at whatever tier we deem appropriate
|
|
99
|
+
# for its workload, ignoring the user's default. Tier is
|
|
100
|
+
# picked for quality of output, not cost: batch jobs that
|
|
101
|
+
# synthesize across a lot of data lean ALTO/MAXIMO, jobs
|
|
102
|
+
# that apply a fixed transform or produce short copy lean
|
|
103
|
+
# MEDIO/BAJO.
|
|
104
|
+
|
|
105
|
+
USE_USER_DEFAULT_SENTINEL = "__USE_USER_DEFAULT__"
|
|
106
|
+
|
|
107
|
+
USER_FACING_CALLERS: dict[str, str] = {
|
|
108
|
+
"nexo_chat": USE_USER_DEFAULT_SENTINEL,
|
|
109
|
+
"desktop_new_session": USE_USER_DEFAULT_SENTINEL,
|
|
110
|
+
"nexo_update_interactive": USE_USER_DEFAULT_SENTINEL,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# System-owned callers. Grouped thematically for readability.
|
|
114
|
+
SYSTEM_OWNED_CALLERS: dict[str, str] = {
|
|
115
|
+
# ---- Evolution and introspection: highest-quality reasoning needed ----
|
|
116
|
+
"evolution/run": "maximo",
|
|
117
|
+
"reflection": "maximo",
|
|
118
|
+
|
|
119
|
+
# ---- Deep sleep: extraction and synthesis benefit from quality --------
|
|
120
|
+
"deep-sleep/extract": "alto",
|
|
121
|
+
"deep-sleep/synthesize": "maximo",
|
|
122
|
+
"deep-sleep/apply_findings": "alto",
|
|
123
|
+
"sleep/nightly": "alto",
|
|
124
|
+
"synthesis/daily": "alto",
|
|
125
|
+
|
|
126
|
+
# ---- User-facing outputs where quality is visible ---------------------
|
|
127
|
+
"catchup/morning": "alto",
|
|
128
|
+
"daily_self_audit": "alto",
|
|
129
|
+
"postmortem_consolidator": "alto",
|
|
130
|
+
"proactive_dashboard": "alto",
|
|
131
|
+
"followup_runner": "alto",
|
|
132
|
+
|
|
133
|
+
# ---- Defensive / consistency tasks ------------------------------------
|
|
134
|
+
"immune/scan": "medio",
|
|
135
|
+
"learning_validator": "medio",
|
|
136
|
+
"outcome_checker": "medio",
|
|
137
|
+
"check_context": "medio",
|
|
138
|
+
|
|
139
|
+
# ---- Agent orchestration ----------------------------------------------
|
|
140
|
+
"agent_run/generic": "alto",
|
|
141
|
+
|
|
142
|
+
# ---- Tooling helpers (short, structured outputs) ----------------------
|
|
143
|
+
"tools/drive_search": "medio",
|
|
144
|
+
|
|
145
|
+
# ---- Marketing automation ---------------------------------------------
|
|
146
|
+
# These produce short copy; we could run them at BAJO for speed, but the
|
|
147
|
+
# output is user-visible on a public surface, so we lean MEDIO for safety
|
|
148
|
+
# against embarrassing outputs.
|
|
149
|
+
"gbp/daily_post": "medio",
|
|
150
|
+
"gbp/post_wazion": "medio",
|
|
151
|
+
"gbp/post_psicologa": "medio",
|
|
152
|
+
"gbp/monthly_audit": "medio",
|
|
153
|
+
"gbp/reviews_watch": "medio",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
ALL_REGISTERED_CALLERS: frozenset[str] = frozenset(
|
|
157
|
+
list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class UnregisteredCallerError(ValueError):
|
|
162
|
+
"""Raised when a caller string is not in the resonance registry.
|
|
163
|
+
|
|
164
|
+
Every caller that dispatches an automation subprocess MUST register here.
|
|
165
|
+
We do not fall back to a default tier silently — that would re-introduce
|
|
166
|
+
the pre-v5.9.0 problem where the wrong script could inherit the wrong
|
|
167
|
+
reasoning budget without anyone noticing. The fix for this error is:
|
|
168
|
+
add an entry to SYSTEM_OWNED_CALLERS (or USER_FACING_CALLERS if it is a
|
|
169
|
+
genuine interactive entry point) and pick the tier deliberately.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Resolution
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
def resolve_tier_for_caller(caller: str, user_default: str | None = None) -> str:
|
|
178
|
+
"""Return the resonance tier that should apply to ``caller``.
|
|
179
|
+
|
|
180
|
+
- User-facing callers resolve to ``user_default`` (or ``DEFAULT_RESONANCE``
|
|
181
|
+
if the user has no preference recorded).
|
|
182
|
+
- System-owned callers resolve to their fixed tier.
|
|
183
|
+
- Unknown callers raise ``UnregisteredCallerError``.
|
|
184
|
+
"""
|
|
185
|
+
if not caller:
|
|
186
|
+
raise UnregisteredCallerError(
|
|
187
|
+
"caller= is required. Every automation subprocess must be registered "
|
|
188
|
+
"in src/resonance_map.py so its reasoning budget is deliberate."
|
|
189
|
+
)
|
|
190
|
+
if caller in USER_FACING_CALLERS:
|
|
191
|
+
tier = (user_default or DEFAULT_RESONANCE).strip().lower()
|
|
192
|
+
if tier not in TIERS:
|
|
193
|
+
tier = DEFAULT_RESONANCE
|
|
194
|
+
return tier
|
|
195
|
+
if caller in SYSTEM_OWNED_CALLERS:
|
|
196
|
+
return SYSTEM_OWNED_CALLERS[caller]
|
|
197
|
+
raise UnregisteredCallerError(
|
|
198
|
+
f"caller {caller!r} is not registered in resonance_map.py. "
|
|
199
|
+
"Add it to SYSTEM_OWNED_CALLERS (or USER_FACING_CALLERS if it is an "
|
|
200
|
+
"interactive entry point) with a deliberate tier."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def resolve_model_and_effort(
|
|
205
|
+
caller: str,
|
|
206
|
+
backend: str,
|
|
207
|
+
user_default: str | None = None,
|
|
208
|
+
) -> Tuple[str, str]:
|
|
209
|
+
"""Return ``(model, reasoning_effort)`` for ``caller`` on ``backend``.
|
|
210
|
+
|
|
211
|
+
The ``backend`` key must match the entries in ``_RESONANCE_TABLE`` tier
|
|
212
|
+
dicts (``claude_code`` or ``codex``). Unknown backends fall back to an
|
|
213
|
+
empty pair; the caller is expected to handle that by raising or by
|
|
214
|
+
passing its own explicit model/effort arguments.
|
|
215
|
+
"""
|
|
216
|
+
tier = resolve_tier_for_caller(caller, user_default=user_default)
|
|
217
|
+
backend_entry = _RESONANCE_TABLE.get(tier, {}).get(backend)
|
|
218
|
+
if backend_entry is None:
|
|
219
|
+
return "", ""
|
|
220
|
+
return backend_entry
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def register_system_caller(caller: str, tier: str) -> None:
|
|
224
|
+
"""Test/debug helper: register a caller at runtime.
|
|
225
|
+
|
|
226
|
+
Production code must add callers statically to ``SYSTEM_OWNED_CALLERS``
|
|
227
|
+
at module level so the registry is reviewable. This helper exists so
|
|
228
|
+
unit tests can exercise ``resolve_*`` against synthetic caller names
|
|
229
|
+
without mutating the shipped table.
|
|
230
|
+
"""
|
|
231
|
+
if tier not in TIERS:
|
|
232
|
+
raise ValueError(f"tier {tier!r} not in {TIERS}")
|
|
233
|
+
SYSTEM_OWNED_CALLERS[caller] = tier
|
|
234
|
+
# Rebuild the frozen view so the guard below sees the new caller.
|
|
235
|
+
global ALL_REGISTERED_CALLERS
|
|
236
|
+
ALL_REGISTERED_CALLERS = frozenset(
|
|
237
|
+
list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def unregister_system_caller(caller: str) -> None:
|
|
242
|
+
"""Mirror helper for tests that need to remove what they registered."""
|
|
243
|
+
SYSTEM_OWNED_CALLERS.pop(caller, None)
|
|
244
|
+
global ALL_REGISTERED_CALLERS
|
|
245
|
+
ALL_REGISTERED_CALLERS = frozenset(
|
|
246
|
+
list(USER_FACING_CALLERS.keys()) + list(SYSTEM_OWNED_CALLERS.keys())
|
|
247
|
+
)
|
|
@@ -193,7 +193,7 @@ def analyze_session(
|
|
|
193
193
|
|
|
194
194
|
result = run_automation_prompt(
|
|
195
195
|
prompt,
|
|
196
|
-
|
|
196
|
+
caller="deep-sleep/extract",
|
|
197
197
|
timeout=CLAUDE_TIMEOUT,
|
|
198
198
|
output_format="text",
|
|
199
199
|
append_system_prompt=JSON_SYSTEM_PROMPT,
|
|
@@ -224,7 +224,7 @@ def analyze_session(
|
|
|
224
224
|
)
|
|
225
225
|
convert_result = run_automation_prompt(
|
|
226
226
|
convert_prompt,
|
|
227
|
-
|
|
227
|
+
caller="deep-sleep/extract",
|
|
228
228
|
timeout=120,
|
|
229
229
|
output_format="text",
|
|
230
230
|
append_system_prompt=JSON_SYSTEM_PROMPT,
|
|
@@ -2050,7 +2050,7 @@ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
|
|
|
2050
2050
|
try:
|
|
2051
2051
|
result = run_automation_prompt(
|
|
2052
2052
|
prompt,
|
|
2053
|
-
|
|
2053
|
+
caller="daily_self_audit",
|
|
2054
2054
|
timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
2055
2055
|
output_format="text",
|
|
2056
2056
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -227,7 +227,7 @@ def call_claude_cli(prompt: str) -> str:
|
|
|
227
227
|
"""Call the configured automation backend for the managed evolution prompt."""
|
|
228
228
|
result = run_automation_prompt(
|
|
229
229
|
prompt,
|
|
230
|
-
|
|
230
|
+
caller="evolution/run",
|
|
231
231
|
timeout=CLI_TIMEOUT,
|
|
232
232
|
output_format="text",
|
|
233
233
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -241,9 +241,9 @@ def call_public_claude_cli(prompt: str, *, cwd: Path) -> str:
|
|
|
241
241
|
"""Run the configured automation backend in an isolated public repo checkout."""
|
|
242
242
|
result = run_automation_prompt(
|
|
243
243
|
prompt,
|
|
244
|
+
caller="evolution/run",
|
|
244
245
|
cwd=cwd,
|
|
245
246
|
env={"NEXO_PUBLIC_CONTRIBUTION": "1"},
|
|
246
|
-
model=_USER_MODEL,
|
|
247
247
|
timeout=CLI_TIMEOUT,
|
|
248
248
|
output_format="text",
|
|
249
249
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash",
|
|
@@ -916,7 +916,7 @@ Write the report. Be concise — max 40 lines."""
|
|
|
916
916
|
try:
|
|
917
917
|
result = run_automation_prompt(
|
|
918
918
|
prompt,
|
|
919
|
-
|
|
919
|
+
caller="immune/scan",
|
|
920
920
|
timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
921
921
|
output_format="text",
|
|
922
922
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -255,7 +255,7 @@ Execute without asking."""
|
|
|
255
255
|
try:
|
|
256
256
|
result = run_automation_prompt(
|
|
257
257
|
prompt,
|
|
258
|
-
|
|
258
|
+
caller="postmortem_consolidator",
|
|
259
259
|
timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
260
260
|
output_format="text",
|
|
261
261
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -446,7 +446,7 @@ Execute without asking."""
|
|
|
446
446
|
try:
|
|
447
447
|
result = run_automation_prompt(
|
|
448
448
|
prompt,
|
|
449
|
-
|
|
449
|
+
caller="sleep/nightly",
|
|
450
450
|
timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
451
451
|
output_format="text",
|
|
452
452
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
|
@@ -348,7 +348,7 @@ Execute without asking."""
|
|
|
348
348
|
try:
|
|
349
349
|
result = run_automation_prompt(
|
|
350
350
|
prompt,
|
|
351
|
-
|
|
351
|
+
caller="synthesis/daily",
|
|
352
352
|
timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
|
|
353
353
|
output_format="text",
|
|
354
354
|
allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
|
package/src/server.py
CHANGED
|
@@ -63,6 +63,10 @@ from tools_credentials import (
|
|
|
63
63
|
from tools_task_history import (
|
|
64
64
|
handle_task_log, handle_task_list, handle_task_frequency,
|
|
65
65
|
)
|
|
66
|
+
from tools_automation_sessions import (
|
|
67
|
+
handle_session_log_create,
|
|
68
|
+
handle_session_log_close,
|
|
69
|
+
)
|
|
66
70
|
from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
|
|
67
71
|
|
|
68
72
|
|
|
@@ -1375,6 +1379,104 @@ def nexo_drive_dismiss(signal_id: int, reason: str) -> str:
|
|
|
1375
1379
|
return handle_drive_dismiss(signal_id, reason)
|
|
1376
1380
|
|
|
1377
1381
|
|
|
1382
|
+
@mcp.tool
|
|
1383
|
+
def nexo_session_log_create(
|
|
1384
|
+
caller: str,
|
|
1385
|
+
backend: str,
|
|
1386
|
+
session_type: str = "interactive_desktop",
|
|
1387
|
+
model: str = "",
|
|
1388
|
+
reasoning_effort: str = "",
|
|
1389
|
+
resonance_tier: str = "",
|
|
1390
|
+
cwd: str = "",
|
|
1391
|
+
pid: str = "",
|
|
1392
|
+
context_excerpt: str = "",
|
|
1393
|
+
) -> str:
|
|
1394
|
+
"""Open an automation_runs row for an interactive Claude/Codex session.
|
|
1395
|
+
|
|
1396
|
+
Designed for clients that spawn Claude/Codex directly (notably NEXO
|
|
1397
|
+
Desktop, which runs a TypeScript process that shells out to the CLI
|
|
1398
|
+
without going through run_automation_prompt). Call this BEFORE
|
|
1399
|
+
spawning the child, store the returned session_id, then call
|
|
1400
|
+
nexo_session_log_close when the session ends.
|
|
1401
|
+
|
|
1402
|
+
Args:
|
|
1403
|
+
caller: Registered caller id (see src/resonance_map.py). For
|
|
1404
|
+
Desktop's "new conversation" button, use
|
|
1405
|
+
"desktop_new_session".
|
|
1406
|
+
backend: "claude_code" or "codex".
|
|
1407
|
+
session_type: "interactive_chat" | "interactive_desktop" — how
|
|
1408
|
+
the session is shaped. Default "interactive_desktop".
|
|
1409
|
+
model: Concrete model the client resolved, e.g. "claude-opus-4-7[1m]".
|
|
1410
|
+
reasoning_effort: Concrete effort string, e.g. "xhigh".
|
|
1411
|
+
resonance_tier: Tier label ("maximo"/"alto"/"medio"/"bajo"). If
|
|
1412
|
+
left empty the Brain resolves it from caller.
|
|
1413
|
+
cwd: Working directory the session is anchored to.
|
|
1414
|
+
pid: Child process PID if available.
|
|
1415
|
+
context_excerpt: Optional first-prompt preview (used to size
|
|
1416
|
+
prompt_chars in telemetry).
|
|
1417
|
+
"""
|
|
1418
|
+
import json as _json
|
|
1419
|
+
result = handle_session_log_create(
|
|
1420
|
+
caller=caller,
|
|
1421
|
+
backend=backend,
|
|
1422
|
+
session_type=session_type,
|
|
1423
|
+
model=model,
|
|
1424
|
+
reasoning_effort=reasoning_effort,
|
|
1425
|
+
resonance_tier=resonance_tier,
|
|
1426
|
+
cwd=cwd,
|
|
1427
|
+
pid=pid,
|
|
1428
|
+
context_excerpt=context_excerpt,
|
|
1429
|
+
)
|
|
1430
|
+
return _json.dumps(result, ensure_ascii=False)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
@mcp.tool
|
|
1434
|
+
def nexo_session_log_close(
|
|
1435
|
+
session_id: int,
|
|
1436
|
+
returncode: int = 0,
|
|
1437
|
+
duration_ms: int = 0,
|
|
1438
|
+
input_tokens: int = 0,
|
|
1439
|
+
cached_input_tokens: int = 0,
|
|
1440
|
+
output_tokens: int = 0,
|
|
1441
|
+
total_cost_usd: str = "",
|
|
1442
|
+
telemetry_source: str = "",
|
|
1443
|
+
cost_source: str = "",
|
|
1444
|
+
error: str = "",
|
|
1445
|
+
) -> str:
|
|
1446
|
+
"""Close an automation_runs row opened by nexo_session_log_create.
|
|
1447
|
+
|
|
1448
|
+
Args:
|
|
1449
|
+
session_id: id returned by the create call.
|
|
1450
|
+
returncode: child exit code (0 = ok).
|
|
1451
|
+
duration_ms: wall-clock duration in milliseconds.
|
|
1452
|
+
input_tokens / cached_input_tokens / output_tokens: client-side
|
|
1453
|
+
usage counters.
|
|
1454
|
+
total_cost_usd: cost in USD as a string (parsed to float).
|
|
1455
|
+
telemetry_source: short label identifying where the counts came
|
|
1456
|
+
from ("desktop_stream", "codex_json", ...).
|
|
1457
|
+
cost_source: short label for cost provenance.
|
|
1458
|
+
error: short error message if the session failed.
|
|
1459
|
+
"""
|
|
1460
|
+
import json as _json
|
|
1461
|
+
try:
|
|
1462
|
+
cost = float(total_cost_usd) if total_cost_usd else None
|
|
1463
|
+
except ValueError:
|
|
1464
|
+
cost = None
|
|
1465
|
+
result = handle_session_log_close(
|
|
1466
|
+
session_id=session_id,
|
|
1467
|
+
returncode=returncode,
|
|
1468
|
+
duration_ms=duration_ms,
|
|
1469
|
+
input_tokens=input_tokens,
|
|
1470
|
+
cached_input_tokens=cached_input_tokens,
|
|
1471
|
+
output_tokens=output_tokens,
|
|
1472
|
+
total_cost_usd=cost,
|
|
1473
|
+
telemetry_source=telemetry_source,
|
|
1474
|
+
cost_source=cost_source,
|
|
1475
|
+
error=error,
|
|
1476
|
+
)
|
|
1477
|
+
return _json.dumps(result, ensure_ascii=False)
|
|
1478
|
+
|
|
1479
|
+
|
|
1378
1480
|
if __name__ == "__main__":
|
|
1379
1481
|
_server_init()
|
|
1380
1482
|
mcp.run(**_run_kwargs_from_env())
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""MCP tools for automation session logging.
|
|
2
|
+
|
|
3
|
+
Gives NEXO Desktop (which launches ``claude`` directly, not via
|
|
4
|
+
``agent_runner.run_automation_prompt``) a way to record its interactive
|
|
5
|
+
sessions in the same ``automation_runs`` table as every other backend call.
|
|
6
|
+
|
|
7
|
+
Two tools:
|
|
8
|
+
|
|
9
|
+
nexo_session_log_create → INSERT a row with ended_at=NULL, return id
|
|
10
|
+
nexo_session_log_close → UPDATE the row with exit + duration + tokens
|
|
11
|
+
|
|
12
|
+
The tools are intentionally thin wrappers over the helpers in
|
|
13
|
+
``agent_runner``. They exist as MCP tools so clients that don't embed the
|
|
14
|
+
Python runtime (Desktop's TypeScript/Electron process, any future
|
|
15
|
+
third-party agent) can still participate in the unified log.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def handle_session_log_create(payload: dict | None = None, **kwargs) -> dict:
|
|
23
|
+
"""Open an automation session row.
|
|
24
|
+
|
|
25
|
+
Expected arguments (all optional except ``caller`` and ``backend``):
|
|
26
|
+
caller — e.g. "desktop_new_session" (registered in
|
|
27
|
+
resonance_map.py).
|
|
28
|
+
backend — "claude_code" or "codex".
|
|
29
|
+
session_type — "interactive_chat" | "interactive_desktop"
|
|
30
|
+
(default: "interactive_desktop").
|
|
31
|
+
model — concrete model string, if the client already
|
|
32
|
+
resolved it.
|
|
33
|
+
reasoning_effort — concrete effort string.
|
|
34
|
+
resonance_tier — tier label for traceability.
|
|
35
|
+
cwd — working directory the session is anchored to.
|
|
36
|
+
pid — the child PID if the client already has it.
|
|
37
|
+
context_excerpt — optional short preview of the first prompt
|
|
38
|
+
(truncated to 2048 chars for prompt_chars).
|
|
39
|
+
"""
|
|
40
|
+
args = payload or kwargs or {}
|
|
41
|
+
caller = str(args.get("caller") or "").strip()
|
|
42
|
+
backend = str(args.get("backend") or "").strip()
|
|
43
|
+
if not caller or not backend:
|
|
44
|
+
return {
|
|
45
|
+
"ok": False,
|
|
46
|
+
"error": "caller and backend are required",
|
|
47
|
+
"session_id": None,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
session_type = str(args.get("session_type") or "interactive_desktop").strip()
|
|
51
|
+
model = str(args.get("model") or "").strip()
|
|
52
|
+
reasoning_effort = str(args.get("reasoning_effort") or "").strip()
|
|
53
|
+
resonance_tier = str(args.get("resonance_tier") or "").strip()
|
|
54
|
+
cwd = Path(str(args.get("cwd") or ".")).expanduser()
|
|
55
|
+
pid_raw = args.get("pid")
|
|
56
|
+
try:
|
|
57
|
+
pid = int(pid_raw) if pid_raw is not None and pid_raw != "" else None
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
pid = None
|
|
60
|
+
|
|
61
|
+
context_excerpt = str(args.get("context_excerpt") or "")[:2048]
|
|
62
|
+
|
|
63
|
+
# Resolve resonance_tier if client did not pre-compute it.
|
|
64
|
+
if not resonance_tier and caller:
|
|
65
|
+
try:
|
|
66
|
+
from resonance_map import (
|
|
67
|
+
resolve_tier_for_caller,
|
|
68
|
+
UnregisteredCallerError,
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
from client_preferences import load_client_preferences
|
|
72
|
+
prefs = load_client_preferences()
|
|
73
|
+
except Exception:
|
|
74
|
+
prefs = {}
|
|
75
|
+
user_default = ""
|
|
76
|
+
if isinstance(prefs, dict):
|
|
77
|
+
user_default = str(prefs.get("default_resonance") or "").strip()
|
|
78
|
+
try:
|
|
79
|
+
resonance_tier = resolve_tier_for_caller(
|
|
80
|
+
caller, user_default=user_default or None
|
|
81
|
+
)
|
|
82
|
+
except UnregisteredCallerError:
|
|
83
|
+
resonance_tier = ""
|
|
84
|
+
except Exception:
|
|
85
|
+
resonance_tier = ""
|
|
86
|
+
|
|
87
|
+
from agent_runner import _record_automation_start
|
|
88
|
+
|
|
89
|
+
row_id, err = _record_automation_start(
|
|
90
|
+
caller=caller,
|
|
91
|
+
backend=backend,
|
|
92
|
+
session_type=session_type,
|
|
93
|
+
task_profile="",
|
|
94
|
+
model=model,
|
|
95
|
+
reasoning_effort=reasoning_effort,
|
|
96
|
+
resonance_tier=resonance_tier,
|
|
97
|
+
cwd=cwd,
|
|
98
|
+
output_format="interactive",
|
|
99
|
+
prompt=context_excerpt,
|
|
100
|
+
pid=pid,
|
|
101
|
+
)
|
|
102
|
+
if row_id is None:
|
|
103
|
+
return {"ok": False, "error": err or "session log insert failed", "session_id": None}
|
|
104
|
+
return {
|
|
105
|
+
"ok": True,
|
|
106
|
+
"session_id": int(row_id),
|
|
107
|
+
"resonance_tier": resonance_tier,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def handle_session_log_close(payload: dict | None = None, **kwargs) -> dict:
|
|
112
|
+
"""Close an automation session row opened by nexo_session_log_create.
|
|
113
|
+
|
|
114
|
+
Expected arguments:
|
|
115
|
+
session_id — int returned by the create call.
|
|
116
|
+
returncode — exit code (default 0).
|
|
117
|
+
duration_ms — total wall-clock duration in ms.
|
|
118
|
+
input_tokens, cached_input_tokens, output_tokens — counters from
|
|
119
|
+
the client's own telemetry, all optional.
|
|
120
|
+
total_cost_usd — float, optional.
|
|
121
|
+
telemetry_source — short label ("desktop_stream", "codex_json", ...).
|
|
122
|
+
error — short error string if the session failed.
|
|
123
|
+
"""
|
|
124
|
+
args = payload or kwargs or {}
|
|
125
|
+
sid_raw = args.get("session_id")
|
|
126
|
+
try:
|
|
127
|
+
session_id = int(sid_raw) if sid_raw is not None else None
|
|
128
|
+
except (TypeError, ValueError):
|
|
129
|
+
session_id = None
|
|
130
|
+
if session_id is None:
|
|
131
|
+
return {"ok": False, "error": "session_id is required"}
|
|
132
|
+
|
|
133
|
+
returncode = int(args.get("returncode") or 0)
|
|
134
|
+
duration_ms = int(args.get("duration_ms") or 0)
|
|
135
|
+
telemetry = {
|
|
136
|
+
"usage": {
|
|
137
|
+
"input_tokens": int(args.get("input_tokens") or 0),
|
|
138
|
+
"cached_input_tokens": int(args.get("cached_input_tokens") or 0),
|
|
139
|
+
"output_tokens": int(args.get("output_tokens") or 0),
|
|
140
|
+
},
|
|
141
|
+
"total_cost_usd": args.get("total_cost_usd"),
|
|
142
|
+
"telemetry_source": str(args.get("telemetry_source") or "").strip(),
|
|
143
|
+
"cost_source": str(args.get("cost_source") or "").strip(),
|
|
144
|
+
"warnings": [],
|
|
145
|
+
"raw": {},
|
|
146
|
+
}
|
|
147
|
+
err_message = str(args.get("error") or "").strip()
|
|
148
|
+
if err_message:
|
|
149
|
+
telemetry["warnings"].append(err_message)
|
|
150
|
+
|
|
151
|
+
from agent_runner import _record_automation_end
|
|
152
|
+
|
|
153
|
+
ok, err = _record_automation_end(
|
|
154
|
+
row_id=session_id,
|
|
155
|
+
returncode=returncode,
|
|
156
|
+
duration_ms=duration_ms,
|
|
157
|
+
telemetry=telemetry,
|
|
158
|
+
)
|
|
159
|
+
return {"ok": bool(ok), "error": err or ""}
|
package/src/tools_drive.py
CHANGED