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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.8.2",
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.8.2` is the current packaged-runtime line: 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.
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.8.2",
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",
@@ -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 _record_automation_run(
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
- returncode: int,
193
- duration_ms: int,
194
- telemetry: dict,
195
- ) -> tuple[bool, str]:
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 False, f"automation telemetry unavailable: {exc}"
206
+ return None, f"automation telemetry unavailable: {exc}"
200
207
 
201
208
  try:
202
209
  conn = get_db()
203
- usage = telemetry.get("usage") or {}
204
- conn.execute(
210
+ cur = conn.execute(
205
211
  """
206
212
  INSERT INTO automation_runs (
207
- backend, task_profile, model, reasoning_effort, cwd, output_format,
208
- prompt_chars, returncode, duration_ms,
209
- input_tokens, cached_input_tokens, output_tokens,
210
- total_cost_usd, telemetry_source, cost_source, status, metadata
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(returncode),
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
- _, cmd = build_interactive_client_command(target=target, client=client, preferences=preferences)
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
- return subprocess.run(cmd, env=launch_env, cwd=_interactive_target_cwd(target))
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
+ )
@@ -198,7 +198,7 @@ Rules:
198
198
  try:
199
199
  result = run_automation_prompt(
200
200
  prompt,
201
- model=_USER_MODEL,
201
+ caller="check_context",
202
202
  timeout=300,
203
203
  output_format="text",
204
204
  append_system_prompt="Return exactly one valid JSON object.",
@@ -193,7 +193,7 @@ def analyze_session(
193
193
 
194
194
  result = run_automation_prompt(
195
195
  prompt,
196
- model=_USER_MODEL,
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
- model=_USER_MODEL,
227
+ caller="deep-sleep/extract",
228
228
  timeout=120,
229
229
  output_format="text",
230
230
  append_system_prompt=JSON_SYSTEM_PROMPT,
@@ -241,7 +241,7 @@ def main():
241
241
  try:
242
242
  result = run_automation_prompt(
243
243
  prompt,
244
- model=_USER_MODEL,
244
+ caller="deep-sleep/synthesize",
245
245
  timeout=CLAUDE_TIMEOUT,
246
246
  output_format="text",
247
247
  allowed_tools="Read,Grep,Bash",
@@ -52,6 +52,7 @@ def main(argv: list[str] | None = None) -> int:
52
52
  try:
53
53
  result = run_automation_prompt(
54
54
  prompt,
55
+ caller=getattr(args, "caller", "") or "agent_run/generic",
55
56
  cwd=args.cwd or None,
56
57
  task_profile=args.task_profile,
57
58
  model=args.model,
@@ -280,7 +280,7 @@ Format:
280
280
  try:
281
281
  result = run_automation_prompt(
282
282
  prompt,
283
- model=_USER_MODEL,
283
+ caller="catchup/morning",
284
284
  timeout=AUTOMATION_SUBPROCESS_TIMEOUT,
285
285
  output_format="text",
286
286
  allowed_tools="Read,Write,Edit,Glob,Grep,Bash,mcp__nexo__*",
@@ -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
- model=_USER_MODEL,
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
- model=_USER_MODEL,
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
- model=_USER_MODEL,
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__*",
@@ -158,7 +158,7 @@ Rules:
158
158
  try:
159
159
  result = run_automation_prompt(
160
160
  prompt,
161
- model=_USER_MODEL,
161
+ caller="learning_validator",
162
162
  timeout=60,
163
163
  output_format="text",
164
164
  append_system_prompt=JSON_ONLY_SYSTEM_PROMPT,
@@ -255,7 +255,7 @@ Execute without asking."""
255
255
  try:
256
256
  result = run_automation_prompt(
257
257
  prompt,
258
- model=_USER_MODEL,
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
- model=_USER_MODEL,
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
- model=_USER_MODEL,
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 ""}
@@ -283,6 +283,7 @@ def _llm_classify_signal(text: str) -> dict:
283
283
  try:
284
284
  result = run_automation_prompt(
285
285
  prompt,
286
+ caller="tools/drive_search",
286
287
  task_profile="fast",
287
288
  timeout=_LLM_TIMEOUT_SECONDS,
288
289
  output_format="text",