nexo-brain 7.23.13 → 7.24.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 +13 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +2 -2
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +116 -0
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- package/tool-enforcement-map.json +143 -13
package/src/plugins/backup.py
CHANGED
|
@@ -174,7 +174,7 @@ def handle_backup_restore(filename: str) -> str:
|
|
|
174
174
|
pass
|
|
175
175
|
db._shared_conn = None
|
|
176
176
|
|
|
177
|
-
return f"DB
|
|
177
|
+
return f"DB restored from {filename}. Safety backup: {safety.name}"
|
|
178
178
|
|
|
179
179
|
|
|
180
180
|
def _cleanup_old():
|
package/src/plugins/cortex.py
CHANGED
|
@@ -578,44 +578,44 @@ def _score_alternative(
|
|
|
578
578
|
|
|
579
579
|
if direct_hits:
|
|
580
580
|
impact += min(1.6, direct_hits * 0.4)
|
|
581
|
-
reasons.append("
|
|
581
|
+
reasons.append("points directly at the goal")
|
|
582
582
|
if safe_hits:
|
|
583
583
|
success += min(1.8, safe_hits * 0.45)
|
|
584
584
|
risk = max(1.0, risk - min(1.1, safe_hits * 0.35))
|
|
585
|
-
reasons.append("
|
|
585
|
+
reasons.append("includes verification or safe deployment")
|
|
586
586
|
if not safe_hits and task_type in {"edit", "execute"}:
|
|
587
587
|
risk += 1.2
|
|
588
|
-
reasons.append("
|
|
588
|
+
reasons.append("does not make verification explicit")
|
|
589
589
|
if risk_hits:
|
|
590
590
|
risk += min(2.8, risk_hits * 0.7)
|
|
591
|
-
reasons.append("
|
|
591
|
+
reasons.append("contains high-risk signals")
|
|
592
592
|
|
|
593
593
|
if focus == "impact" and direct_hits:
|
|
594
594
|
impact += 0.45
|
|
595
595
|
risk = max(1.0, risk - 0.35)
|
|
596
|
-
reasons.append("
|
|
596
|
+
reasons.append("the active profile prioritizes impact")
|
|
597
597
|
elif focus == "impact":
|
|
598
598
|
impact = max(1.0, impact - 0.35)
|
|
599
|
-
reasons.append("
|
|
599
|
+
reasons.append("the active profile penalizes low-momentum options")
|
|
600
600
|
elif focus == "success" and safe_hits:
|
|
601
601
|
success += 0.45
|
|
602
|
-
reasons.append("
|
|
602
|
+
reasons.append("the active profile prioritizes verifiable success")
|
|
603
603
|
elif focus == "risk":
|
|
604
604
|
if safe_hits:
|
|
605
605
|
risk = max(1.0, risk - 0.4)
|
|
606
606
|
if risk_hits:
|
|
607
607
|
risk += 0.8
|
|
608
|
-
reasons.append("
|
|
608
|
+
reasons.append("the active profile penalizes risk")
|
|
609
609
|
elif focus == "somatic":
|
|
610
|
-
reasons.append("
|
|
610
|
+
reasons.append("the active profile weights the somatic footprint")
|
|
611
611
|
|
|
612
612
|
history = _history_signal(lowered, area=area, goal=goal)
|
|
613
613
|
success += history["positive"]
|
|
614
614
|
risk += history["negative"]
|
|
615
615
|
if history["positive"]:
|
|
616
|
-
reasons.append("
|
|
616
|
+
reasons.append("similar history is favorable")
|
|
617
617
|
if history["negative"]:
|
|
618
|
-
reasons.append("
|
|
618
|
+
reasons.append("similar history is conflicting")
|
|
619
619
|
|
|
620
620
|
historical = _historical_outcome_signal(
|
|
621
621
|
alternative.get("name", ""),
|
|
@@ -628,15 +628,15 @@ def _score_alternative(
|
|
|
628
628
|
risk += historical["risk_adjustment"]
|
|
629
629
|
if historical["success_adjustment"] > 0:
|
|
630
630
|
reasons.append(
|
|
631
|
-
f"
|
|
631
|
+
f"resolved history is favorable ({historical['met']}/{historical['resolved_outcomes']} met)"
|
|
632
632
|
)
|
|
633
633
|
elif historical["success_adjustment"] < 0:
|
|
634
634
|
reasons.append(
|
|
635
|
-
f"
|
|
635
|
+
f"resolved history is weak ({historical['missed']}/{historical['resolved_outcomes']} missed)"
|
|
636
636
|
)
|
|
637
637
|
elif historical["resolved_outcomes"] > 0:
|
|
638
638
|
reasons.append(
|
|
639
|
-
f"
|
|
639
|
+
f"history is still insufficient ({historical['resolved_outcomes']}/{historical['threshold']} outcomes)"
|
|
640
640
|
)
|
|
641
641
|
|
|
642
642
|
pattern_learning = _pattern_learning_signal(
|
|
@@ -649,9 +649,9 @@ def _score_alternative(
|
|
|
649
649
|
success += pattern_learning["success_adjustment"]
|
|
650
650
|
risk += pattern_learning["risk_adjustment"]
|
|
651
651
|
if pattern_learning["mode"] == "prefer":
|
|
652
|
-
reasons.append("
|
|
652
|
+
reasons.append("captured structured rule favors this strategy")
|
|
653
653
|
elif pattern_learning["mode"] == "avoid":
|
|
654
|
-
reasons.append("
|
|
654
|
+
reasons.append("captured structured rule penalizes this strategy")
|
|
655
655
|
|
|
656
656
|
constraint_penalty, constraint_reasons = _constraint_penalty(lowered, constraints)
|
|
657
657
|
if constraint_penalty:
|
|
@@ -722,19 +722,19 @@ def _log_cortex_activation(goal: str, task_type: str, result: dict):
|
|
|
722
722
|
|
|
723
723
|
|
|
724
724
|
def _format_decision_summary(recommended: dict, alternatives_scored: list[dict]) -> str:
|
|
725
|
-
notes = ", ".join(recommended.get("notes") or []) or "
|
|
725
|
+
notes = ", ".join(recommended.get("notes") or []) or "strongest overall balance"
|
|
726
726
|
historical = recommended.get("historical_signal") or {}
|
|
727
727
|
second_gap = 0.0
|
|
728
728
|
if len(alternatives_scored) > 1:
|
|
729
729
|
second_gap = recommended["total_score"] - alternatives_scored[1]["total_score"]
|
|
730
730
|
if historical.get("active"):
|
|
731
731
|
notes = (
|
|
732
|
-
f"{notes};
|
|
733
|
-
f"{historical.get('resolved_outcomes', 0)} favorable
|
|
732
|
+
f"{notes}; resolved history {historical.get('met', 0)}/"
|
|
733
|
+
f"{historical.get('resolved_outcomes', 0)} favorable in comparable context"
|
|
734
734
|
)
|
|
735
735
|
if second_gap > 0.2:
|
|
736
|
-
return f"
|
|
737
|
-
return f"
|
|
736
|
+
return f"Recommended by a clear margin ({second_gap:.2f}) and because {notes}."
|
|
737
|
+
return f"Recommended for the best balance between impact, success, risk, and somatic footprint; {notes}."
|
|
738
738
|
|
|
739
739
|
|
|
740
740
|
def _parse_json_object_response(raw: str) -> dict:
|
|
@@ -256,7 +256,7 @@ def handle_session_diary_write(decisions: str = '', summary: str = '',
|
|
|
256
256
|
self_critique: str = '',
|
|
257
257
|
source: str = 'claude',
|
|
258
258
|
payload_json: str = '') -> str:
|
|
259
|
-
"""Write session diary entry at end of session.
|
|
259
|
+
"""Write a session diary entry at end of session. Mandatory before closing.
|
|
260
260
|
|
|
261
261
|
Args:
|
|
262
262
|
decisions: What was decided and why (JSON array or structured text)
|
|
@@ -344,22 +344,22 @@ def handle_session_diary_write(decisions: str = '', summary: str = '',
|
|
|
344
344
|
if repo_orphan_changes > 0:
|
|
345
345
|
if recent_repo_orphan_changes > 0 and recent_repo_orphan_changes != repo_orphan_changes:
|
|
346
346
|
warnings.append(
|
|
347
|
-
f"{_recent_change_phrase(recent_repo_orphan_changes)}
|
|
347
|
+
f"{_recent_change_phrase(recent_repo_orphan_changes)} repo changes without commit_ref "
|
|
348
348
|
f"({_format_change_count(repo_orphan_changes)} de repo total)"
|
|
349
349
|
)
|
|
350
350
|
elif recent_repo_orphan_changes > 0:
|
|
351
351
|
warnings.append(
|
|
352
|
-
f"{_recent_change_phrase(recent_repo_orphan_changes)}
|
|
352
|
+
f"{_recent_change_phrase(recent_repo_orphan_changes)} repo changes without commit_ref"
|
|
353
353
|
)
|
|
354
354
|
else:
|
|
355
355
|
warnings.append(
|
|
356
|
-
f"{_format_change_count(repo_orphan_changes)}
|
|
356
|
+
f"{_format_change_count(repo_orphan_changes)} historical repo changes without commit_ref"
|
|
357
357
|
)
|
|
358
358
|
orphan_decisions = conn.execute(
|
|
359
359
|
"SELECT COUNT(*) FROM decisions WHERE (outcome IS NULL OR outcome = '') AND created_at < datetime('now', '-7 days')"
|
|
360
360
|
).fetchone()[0]
|
|
361
361
|
if orphan_decisions > 0:
|
|
362
|
-
warnings.append(f"{orphan_decisions} decisions >7d
|
|
362
|
+
warnings.append(f"{orphan_decisions} decisions >7d without outcome")
|
|
363
363
|
if warnings:
|
|
364
364
|
msg += "\n⚠ EPISODIC GAPS: " + " | ".join(warnings) + " — resolve before closing session."
|
|
365
365
|
|
|
@@ -423,13 +423,13 @@ def _handle_session_diary_read_inner(session_id: str = '', last_n: int = 3, last
|
|
|
423
423
|
if d.get('decisions'):
|
|
424
424
|
lines.append(f" Decisions: {d['decisions'][:200]}")
|
|
425
425
|
if d.get('discarded'):
|
|
426
|
-
lines.append(f"
|
|
426
|
+
lines.append(f" Discarded: {d['discarded'][:150]}")
|
|
427
427
|
if d.get('pending'):
|
|
428
428
|
lines.append(f" Pending: {d['pending'][:150]}")
|
|
429
429
|
if d.get('context_next'):
|
|
430
430
|
lines.append(f" For next session: {d['context_next'][:200]}")
|
|
431
431
|
if d.get('mental_state'):
|
|
432
|
-
lines.append(f"
|
|
432
|
+
lines.append(f" Mental state: {d['mental_state'][:300]}")
|
|
433
433
|
if d.get('user_signals'):
|
|
434
434
|
lines.append(f" User signals: {d['user_signals'][:300]}")
|
|
435
435
|
return "\n".join(lines)
|
|
@@ -477,7 +477,7 @@ def handle_change_log(files: str, what_changed: str, why: str,
|
|
|
477
477
|
)
|
|
478
478
|
else:
|
|
479
479
|
msg += (
|
|
480
|
-
f"\n⚠ NO COMMIT
|
|
480
|
+
f"\n⚠ NO GIT COMMIT. If this was a local/server-side change, link a marker "
|
|
481
481
|
f"with nexo_change_commit({change_id}, 'server-direct') or "
|
|
482
482
|
f"'local-uncommitted'."
|
|
483
483
|
)
|
|
@@ -507,9 +507,9 @@ def handle_change_search(query: str = '', files: str = '', days: int = 30) -> st
|
|
|
507
507
|
if c.get('triggered_by'):
|
|
508
508
|
lines.append(f" Trigger: {c['triggered_by'][:80]}")
|
|
509
509
|
if c.get('affects'):
|
|
510
|
-
lines.append(f"
|
|
510
|
+
lines.append(f" Affects: {c['affects'][:80]}")
|
|
511
511
|
if c.get('risks'):
|
|
512
|
-
lines.append(f"
|
|
512
|
+
lines.append(f" Risks: {c['risks'][:80]}")
|
|
513
513
|
return "\n".join(lines)
|
|
514
514
|
|
|
515
515
|
|
|
@@ -644,7 +644,7 @@ def handle_diary_archive_search(
|
|
|
644
644
|
if r.get('decisions'):
|
|
645
645
|
lines.append(f" Decisions: {r['decisions'][:150]}")
|
|
646
646
|
if r.get('mental_state'):
|
|
647
|
-
lines.append(f"
|
|
647
|
+
lines.append(f" State: {r['mental_state'][:100]}")
|
|
648
648
|
return "\n".join(lines)
|
|
649
649
|
|
|
650
650
|
|
|
@@ -107,11 +107,11 @@ def handle_goal_engine_status() -> str:
|
|
|
107
107
|
}
|
|
108
108
|
next_gap = []
|
|
109
109
|
if not readiness["has_outcome_history"]:
|
|
110
|
-
next_gap.append("
|
|
110
|
+
next_gap.append("Needs real outcomes before optimizing from history.")
|
|
111
111
|
if not readiness["has_cortex_history"]:
|
|
112
|
-
next_gap.append("
|
|
112
|
+
next_gap.append("Needs real cortex evaluations before measuring Decision Cortex v2.")
|
|
113
113
|
if not readiness["has_linked_decisions"]:
|
|
114
|
-
next_gap.append("
|
|
114
|
+
next_gap.append("Needs impact decisions linked to outcomes to close the loop.")
|
|
115
115
|
|
|
116
116
|
return json.dumps(
|
|
117
117
|
{
|
|
@@ -6,13 +6,19 @@ from collections import Counter
|
|
|
6
6
|
from db import init_db, list_personal_scripts, list_personal_script_schedules
|
|
7
7
|
from plugins.schedule import handle_schedule_add
|
|
8
8
|
from script_registry import (
|
|
9
|
+
archive_agent,
|
|
9
10
|
classify_scripts_dir,
|
|
11
|
+
create_agent_script,
|
|
10
12
|
create_script,
|
|
11
13
|
ensure_personal_schedules,
|
|
14
|
+
get_agent_status,
|
|
12
15
|
get_automation_status,
|
|
16
|
+
list_agents,
|
|
13
17
|
list_operator_automations,
|
|
14
18
|
reconcile_personal_scripts,
|
|
15
19
|
remove_personal_script,
|
|
20
|
+
set_agent_enabled,
|
|
21
|
+
set_agent_schedule,
|
|
16
22
|
set_automation_enabled,
|
|
17
23
|
set_automation_instructions,
|
|
18
24
|
set_automation_schedule,
|
|
@@ -168,6 +174,61 @@ def handle_automations_list(include_all: bool = False) -> str:
|
|
|
168
174
|
return json.dumps({"ok": True, "automations": list_operator_automations(include_all=include_all)}, ensure_ascii=False)
|
|
169
175
|
|
|
170
176
|
|
|
177
|
+
def handle_agents_list(include_archived: bool = False) -> str:
|
|
178
|
+
init_db()
|
|
179
|
+
return json.dumps({"ok": True, "agents": list_agents(include_archived=include_archived)}, ensure_ascii=False)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def handle_agent_status(name: str) -> str:
|
|
183
|
+
init_db()
|
|
184
|
+
return json.dumps(get_agent_status(name), ensure_ascii=False)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def handle_agent_create(name: str, description: str = "", runtime: str = "python") -> str:
|
|
188
|
+
init_db()
|
|
189
|
+
try:
|
|
190
|
+
return json.dumps(create_agent_script(name, description=description, runtime=runtime), ensure_ascii=False)
|
|
191
|
+
except (FileExistsError, ValueError) as exc:
|
|
192
|
+
return json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def handle_agent_enable(name: str) -> str:
|
|
196
|
+
init_db()
|
|
197
|
+
return json.dumps(set_agent_enabled(name, True), ensure_ascii=False)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def handle_agent_disable(name: str) -> str:
|
|
201
|
+
init_db()
|
|
202
|
+
return json.dumps(set_agent_enabled(name, False), ensure_ascii=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def handle_agent_archive(name: str, restore: bool = False) -> str:
|
|
206
|
+
init_db()
|
|
207
|
+
return json.dumps(archive_agent(name, archived=not bool(restore)), ensure_ascii=False)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def handle_agent_schedule(
|
|
211
|
+
name: str,
|
|
212
|
+
every_seconds: int = 0,
|
|
213
|
+
daily_at: str = "",
|
|
214
|
+
clear: bool = False,
|
|
215
|
+
) -> str:
|
|
216
|
+
init_db()
|
|
217
|
+
try:
|
|
218
|
+
interval_seconds = int(every_seconds or 0) or None
|
|
219
|
+
except (TypeError, ValueError):
|
|
220
|
+
return json.dumps({"ok": False, "error": f"Invalid every_seconds: {every_seconds}"}, ensure_ascii=False)
|
|
221
|
+
return json.dumps(
|
|
222
|
+
set_agent_schedule(
|
|
223
|
+
name,
|
|
224
|
+
interval_seconds=interval_seconds,
|
|
225
|
+
daily_at=str(daily_at or "").strip() or None,
|
|
226
|
+
clear=bool(clear),
|
|
227
|
+
),
|
|
228
|
+
ensure_ascii=False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
171
232
|
def handle_automation_status(name: str) -> str:
|
|
172
233
|
init_db()
|
|
173
234
|
return json.dumps(get_automation_status(name), ensure_ascii=False)
|
|
@@ -273,6 +334,20 @@ TOOLS = [
|
|
|
273
334
|
"Set or clear operator-side extra instructions for one automation without editing the core prompt."),
|
|
274
335
|
(handle_automation_schedule, "nexo_automation_schedule",
|
|
275
336
|
"Set or clear the cadence override for one operator-facing automation."),
|
|
337
|
+
(handle_agents_list, "nexo_agents_list",
|
|
338
|
+
"List personal scripts marked as NEXO agents for the Desktop Home panel."),
|
|
339
|
+
(handle_agent_status, "nexo_agent_status",
|
|
340
|
+
"Read the composed runtime status for one personal-script-backed agent."),
|
|
341
|
+
(handle_agent_create, "nexo_agent_create",
|
|
342
|
+
"Create a personal script scaffold already marked as a NEXO agent."),
|
|
343
|
+
(handle_agent_enable, "nexo_agent_enable",
|
|
344
|
+
"Enable one personal-script-backed agent."),
|
|
345
|
+
(handle_agent_disable, "nexo_agent_disable",
|
|
346
|
+
"Disable one personal-script-backed agent without deleting its schedule."),
|
|
347
|
+
(handle_agent_archive, "nexo_agent_archive",
|
|
348
|
+
"Archive or restore one personal-script-backed agent without deleting its file."),
|
|
349
|
+
(handle_agent_schedule, "nexo_agent_schedule",
|
|
350
|
+
"Set or clear the cadence for one personal-script-backed agent."),
|
|
276
351
|
(handle_core_schedules_list, "nexo_core_schedules_list",
|
|
277
352
|
"List structural core crons whose cadence can be tuned without disabling them."),
|
|
278
353
|
(handle_core_schedule_status, "nexo_core_schedule_status",
|
package/src/plugins/protocol.py
CHANGED
|
@@ -325,6 +325,7 @@ HIGH_STAKES_OVERRIDE_FALSE = {
|
|
|
325
325
|
"dry-run",
|
|
326
326
|
"dryrun",
|
|
327
327
|
}
|
|
328
|
+
TASK_OPEN_LOCAL_CONTEXT_TRUE_VALUES = {"1", "true", "yes", "y", "on", "force"}
|
|
328
329
|
|
|
329
330
|
_INTERNAL_AUDIT_AREAS = {
|
|
330
331
|
"guardian",
|
|
@@ -450,6 +451,14 @@ def _parse_bool(value) -> bool:
|
|
|
450
451
|
return bool(value)
|
|
451
452
|
|
|
452
453
|
|
|
454
|
+
def _task_open_local_context_enabled() -> bool:
|
|
455
|
+
"""Keep heavy local-context routing off the task_open critical path by default."""
|
|
456
|
+
return (
|
|
457
|
+
os.environ.get("NEXO_TASK_OPEN_LOCAL_CONTEXT", "").strip().lower()
|
|
458
|
+
in TASK_OPEN_LOCAL_CONTEXT_TRUE_VALUES
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
453
462
|
def _parse_int_list(value) -> list[int]:
|
|
454
463
|
items = _parse_list(value)
|
|
455
464
|
parsed: list[int] = []
|
|
@@ -1326,7 +1335,7 @@ def handle_task_open(
|
|
|
1326
1335
|
response_contract["next_action"] = next_action
|
|
1327
1336
|
|
|
1328
1337
|
recent_excerpt = format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else ""
|
|
1329
|
-
if append_local_context_evidence is not None:
|
|
1338
|
+
if append_local_context_evidence is not None and _task_open_local_context_enabled():
|
|
1330
1339
|
recent_excerpt = append_local_context_evidence(
|
|
1331
1340
|
recent_excerpt,
|
|
1332
1341
|
" | ".join(part for part in [clean_goal, context_hint.strip()] if part),
|
package/src/pre_answer_router.py
CHANGED
|
@@ -440,6 +440,7 @@ _SOURCE_PLANS: dict[str, SourcePlan] = {
|
|
|
440
440
|
fallback=(
|
|
441
441
|
SourceStep("transcripts", phase="fallback", timeout_ms=700),
|
|
442
442
|
SourceStep("memory", phase="fallback", timeout_ms=400),
|
|
443
|
+
SourceStep("local_context", phase="fallback", timeout_ms=700, max_chars=700),
|
|
443
444
|
),
|
|
444
445
|
),
|
|
445
446
|
"file_location": SourcePlan(
|
|
@@ -1174,6 +1175,25 @@ def _source_project_atlas(request: SourceRequest) -> SourceResult:
|
|
|
1174
1175
|
def _source_local_context(request: SourceRequest) -> SourceResult:
|
|
1175
1176
|
from local_context import api as local_context_api
|
|
1176
1177
|
|
|
1178
|
+
mode = _local_context_pre_answer_mode()
|
|
1179
|
+
if mode == "off":
|
|
1180
|
+
_record_local_context_skip(request, mode=mode, reason="disabled")
|
|
1181
|
+
return SourceResult(
|
|
1182
|
+
source="local_context",
|
|
1183
|
+
ok=True,
|
|
1184
|
+
skipped=True,
|
|
1185
|
+
aborted_reason="disabled",
|
|
1186
|
+
)
|
|
1187
|
+
if not _local_context_query_worthwhile(request):
|
|
1188
|
+
_record_local_context_skip(request, mode=mode, reason="adaptive_skip")
|
|
1189
|
+
return SourceResult(
|
|
1190
|
+
source="local_context",
|
|
1191
|
+
ok=True,
|
|
1192
|
+
skipped=True,
|
|
1193
|
+
aborted_reason="adaptive_skip",
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
started = time.monotonic()
|
|
1177
1197
|
payload = local_context_api.context_router(
|
|
1178
1198
|
request.query,
|
|
1179
1199
|
intent=request.intent,
|
|
@@ -1181,6 +1201,20 @@ def _source_local_context(request: SourceRequest) -> SourceResult:
|
|
|
1181
1201
|
current_context=request.current_context,
|
|
1182
1202
|
max_chars=request.max_chars,
|
|
1183
1203
|
)
|
|
1204
|
+
elapsed_ms = (time.monotonic() - started) * 1000
|
|
1205
|
+
_record_local_context_pre_answer_usage(
|
|
1206
|
+
request,
|
|
1207
|
+
payload,
|
|
1208
|
+
mode=mode,
|
|
1209
|
+
elapsed_ms=elapsed_ms,
|
|
1210
|
+
)
|
|
1211
|
+
if mode == "shadow":
|
|
1212
|
+
return SourceResult(
|
|
1213
|
+
source="local_context",
|
|
1214
|
+
ok=True,
|
|
1215
|
+
skipped=True,
|
|
1216
|
+
aborted_reason="shadow_no_inject",
|
|
1217
|
+
)
|
|
1184
1218
|
if not payload.get("should_inject"):
|
|
1185
1219
|
return SourceResult(source="local_context", result_count=0)
|
|
1186
1220
|
return SourceResult(
|
|
@@ -1191,6 +1225,88 @@ def _source_local_context(request: SourceRequest) -> SourceResult:
|
|
|
1191
1225
|
)
|
|
1192
1226
|
|
|
1193
1227
|
|
|
1228
|
+
def _local_context_pre_answer_mode() -> str:
|
|
1229
|
+
value = (
|
|
1230
|
+
os.environ.get("NEXO_PRE_ANSWER_LOCAL_CONTEXT_MODE")
|
|
1231
|
+
or os.environ.get("NEXO_LOCAL_CONTEXT_PRE_ANSWER_MODE")
|
|
1232
|
+
or "inject"
|
|
1233
|
+
)
|
|
1234
|
+
clean = str(value or "").strip().lower()
|
|
1235
|
+
if clean in {"0", "false", "no", "off", "disabled"}:
|
|
1236
|
+
return "off"
|
|
1237
|
+
if clean in {"shadow", "observe", "observability", "audit"}:
|
|
1238
|
+
return "shadow"
|
|
1239
|
+
return "inject"
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def _local_context_query_worthwhile(request: SourceRequest) -> bool:
|
|
1243
|
+
if request.intent == "file_location":
|
|
1244
|
+
return True
|
|
1245
|
+
if request.files.strip() or request.area.strip():
|
|
1246
|
+
return True
|
|
1247
|
+
normalized = _normalize(f"{request.query}\n{request.current_context}")
|
|
1248
|
+
if _PATHISH_RE.search(normalized):
|
|
1249
|
+
return True
|
|
1250
|
+
tokens = _plain_tokens(normalized)
|
|
1251
|
+
concept_score = max(
|
|
1252
|
+
_feature_score(tokens, _FEATURE_LEXICON[field])
|
|
1253
|
+
for field in ("existing_ref", "location", "memory", "modify", "past_work")
|
|
1254
|
+
)
|
|
1255
|
+
return concept_score >= 0.18
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _record_local_context_skip(request: SourceRequest, *, mode: str, reason: str) -> None:
|
|
1259
|
+
try:
|
|
1260
|
+
from local_context import usage_events
|
|
1261
|
+
|
|
1262
|
+
usage_events.record_usage_event(
|
|
1263
|
+
query=request.query,
|
|
1264
|
+
client="pre_answer_router",
|
|
1265
|
+
tool="local_context",
|
|
1266
|
+
source="local_context",
|
|
1267
|
+
route_stage=f"pre_answer:{mode}",
|
|
1268
|
+
intent=request.intent,
|
|
1269
|
+
result_count=0,
|
|
1270
|
+
should_inject=False,
|
|
1271
|
+
aborted_reason=reason,
|
|
1272
|
+
used_before_response=True,
|
|
1273
|
+
metadata={
|
|
1274
|
+
"adaptive": reason == "adaptive_skip",
|
|
1275
|
+
"current_context_present": bool(request.current_context),
|
|
1276
|
+
},
|
|
1277
|
+
)
|
|
1278
|
+
except Exception:
|
|
1279
|
+
return
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _record_local_context_pre_answer_usage(
|
|
1283
|
+
request: SourceRequest,
|
|
1284
|
+
payload: dict[str, Any],
|
|
1285
|
+
*,
|
|
1286
|
+
mode: str,
|
|
1287
|
+
elapsed_ms: float,
|
|
1288
|
+
) -> None:
|
|
1289
|
+
try:
|
|
1290
|
+
from local_context import usage_events
|
|
1291
|
+
|
|
1292
|
+
usage_payload = dict(payload)
|
|
1293
|
+
usage_payload["intent"] = request.intent
|
|
1294
|
+
usage_payload["should_inject"] = bool(payload.get("should_inject")) and mode == "inject"
|
|
1295
|
+
usage_events.record_router_usage(
|
|
1296
|
+
request.query,
|
|
1297
|
+
usage_payload,
|
|
1298
|
+
client="pre_answer_router",
|
|
1299
|
+
tool="local_context",
|
|
1300
|
+
route_stage=f"pre_answer:{mode}",
|
|
1301
|
+
intent=request.intent,
|
|
1302
|
+
elapsed_ms=int(max(0.0, elapsed_ms)),
|
|
1303
|
+
deadline_ms=0,
|
|
1304
|
+
used_before_response=True,
|
|
1305
|
+
)
|
|
1306
|
+
except Exception:
|
|
1307
|
+
return
|
|
1308
|
+
|
|
1309
|
+
|
|
1194
1310
|
def _source_filesystem(request: SourceRequest) -> SourceResult:
|
|
1195
1311
|
root = Path.cwd()
|
|
1196
1312
|
try:
|
package/src/r_catalog.py
CHANGED
|
@@ -4,11 +4,10 @@ Pre-create discovery probe. Two trigger families:
|
|
|
4
4
|
|
|
5
5
|
(a) `nexo_*_create` / `_open` / `_add` — the original MCP-tool path.
|
|
6
6
|
(b) v7.7: `Edit` / `Write` writing into artefact-bearing paths
|
|
7
|
-
(skills/, plugins/, scripts/, personal scripts).
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
artefactos aunque no hayan pasado por un tool MCP de 'create'".
|
|
7
|
+
(skills/, plugins/, scripts/, personal scripts). This expands the
|
|
8
|
+
catalog pre-probe beyond `nexo_*_create/_open/_add` to also cover
|
|
9
|
+
writes that materialise skills, plugins, scripts, templates, or
|
|
10
|
+
artefacts without going through a dedicated MCP `create` tool.
|
|
12
11
|
|
|
13
12
|
Rationale: the Guardian should not re-teach what tools exist; the live
|
|
14
13
|
catalog does that. But if the agent materialises a new skill / plugin /
|