nexo-brain 7.31.4 → 7.31.7
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 +6 -2
- package/package.json +1 -1
- package/src/auto_update.py +50 -22
- package/src/cli.py +2 -0
- package/src/enforcement_engine.py +247 -0
- package/src/evidence_ledger.py +31 -0
- package/src/guardian_config.py +3 -1
- package/src/hooks/post_tool_use.py +228 -2
- package/src/hooks/stop.py +112 -0
- package/src/local_context/api.py +23 -18
- package/src/local_context/health.py +9 -4
- package/src/local_context/usage_events.py +85 -0
- package/src/mcp_write_queue.py +21 -1
- package/src/plugins/protocol.py +272 -1
- package/src/plugins/workflow.py +99 -2
- package/src/pre_answer_router.py +114 -3
- package/src/pre_answer_runtime.py +3 -0
- package/src/presets/guardian_default.json +7 -4
- package/src/provider_circuit_breaker.py +18 -0
- package/src/rules/core-rules.json +11 -3
- package/src/scripts/deep-sleep/collect.py +40 -0
- package/src/scripts/jargon_first_response.py +12 -9
- package/src/scripts/nexo-email-monitor.py +235 -56
- package/templates/CLAUDE.md.template +1 -0
- package/templates/CODEX.AGENTS.md.template +1 -0
- package/templates/core-prompts/r26-jargon-rewrite.md +1 -0
- package/templates/core-prompts/r34-capability-reality-check.md +1 -0
- package/templates/core-prompts/r35-execute-before-ask.md +1 -0
- package/templates/core-prompts/r36-production-change-log-required.md +1 -0
- package/templates/core-prompts/server-mcp-instructions.md +2 -1
- package/tool-enforcement-map.json +4 -2
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
22
|
import os
|
|
23
|
+
import re
|
|
23
24
|
import subprocess
|
|
24
25
|
import sys
|
|
25
26
|
import time
|
|
@@ -180,16 +181,240 @@ def _extract_tool_text(payload: dict) -> str:
|
|
|
180
181
|
return result
|
|
181
182
|
if isinstance(result, dict):
|
|
182
183
|
content = result.get("content") or result.get("output") or result.get("text") or ""
|
|
183
|
-
if isinstance(content, str):
|
|
184
|
+
if isinstance(content, str) and content.strip():
|
|
184
185
|
return content
|
|
185
186
|
if isinstance(content, list):
|
|
186
187
|
return "\n".join(
|
|
187
188
|
str(item.get("text", "")) if isinstance(item, dict) else str(item)
|
|
188
189
|
for item in content
|
|
189
190
|
)
|
|
191
|
+
fallback_parts = []
|
|
192
|
+
for key in ("tool_response", "output", "stdout", "stderr"):
|
|
193
|
+
value = payload.get(key)
|
|
194
|
+
if isinstance(value, str) and value.strip():
|
|
195
|
+
fallback_parts.append(value)
|
|
196
|
+
if fallback_parts:
|
|
197
|
+
return "\n".join(fallback_parts)
|
|
190
198
|
return ""
|
|
191
199
|
|
|
192
200
|
|
|
201
|
+
def _tool_name(payload: dict) -> str:
|
|
202
|
+
return str(payload.get("tool_name") or payload.get("name") or "").strip()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _tool_input(payload: dict) -> dict:
|
|
206
|
+
for key in ("tool_input", "input", "arguments"):
|
|
207
|
+
value = payload.get(key)
|
|
208
|
+
if isinstance(value, dict):
|
|
209
|
+
return value
|
|
210
|
+
return {}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _production_closeout_dir() -> Path:
|
|
214
|
+
try:
|
|
215
|
+
import paths # type: ignore
|
|
216
|
+
|
|
217
|
+
root = paths.operations_dir()
|
|
218
|
+
except Exception:
|
|
219
|
+
root = _NEXO_HOME / "runtime" / "operations"
|
|
220
|
+
path = root / "protocol-closeout"
|
|
221
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
return path
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _pending_change_log_path(sid: str) -> Path:
|
|
226
|
+
safe_sid = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in (sid or "unknown"))
|
|
227
|
+
return _production_closeout_dir() / f"change-log-required-{safe_sid}.json"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _extract_command(payload: dict) -> str:
|
|
231
|
+
tool_input = _tool_input(payload)
|
|
232
|
+
for key in ("command", "cmd", "script"):
|
|
233
|
+
value = tool_input.get(key)
|
|
234
|
+
if isinstance(value, str) and value.strip():
|
|
235
|
+
return value.strip()
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _is_production_mutation_command(cmd: str) -> bool:
|
|
240
|
+
patterns = (
|
|
241
|
+
r"\bgit\s+push\b(?!.*--dry-run)(?=.*\b(?:origin\s+)?(?:main|master|stable|release)\b)",
|
|
242
|
+
r"\bgcloud\s+builds\s+submit\b",
|
|
243
|
+
r"\bgcloud\s+builds\s+triggers\s+run\b",
|
|
244
|
+
r"\bgcloud\s+run\s+(?:deploy|services\s+update|jobs\s+deploy|jobs\s+update)\b",
|
|
245
|
+
r"\bgcloud\s+dns\s+record-sets\s+transaction\s+execute\b",
|
|
246
|
+
r"\bg(?:sutil|cloud\s+storage)\b.*\b(?:cp|rsync)\b.*\b(?:release|stable|cdn|bucket|buckets)\b",
|
|
247
|
+
r"\b(?:rsync|scp)\b(?!.*--dry-run).+\s+\S+:(?:/[^ \n\r;]*)(?:public_html|httpdocs|www|webroot)\b",
|
|
248
|
+
r"\bssh\b[^'\"]*['\"][^'\"]*(?:sed\s+-i|tee\s+|>\s*\S|>>\s*\S|rm\s+-|mv\s+|cp\s+)[^'\"]*(?:public_html|httpdocs|/var/www|/opt/)[^'\"]*['\"]",
|
|
249
|
+
r"\b(?:whmapi1|uapi|cpapi2)\b",
|
|
250
|
+
r"\b(?:cloudflare|cfcli)\b.*\b(?:dns|record)\b.*\b(?:create|delete|update|patch|put|post)\b",
|
|
251
|
+
r"\bcurl\b(?=.*api\.cloudflare\.com/client/v4/zones/.*/dns_records)(?=.*(?:-X|--request)\s*(?:POST|PUT|PATCH|DELETE)\b)",
|
|
252
|
+
r"\bcws-upload(?:\.sh)?\b.*\bpublish\b",
|
|
253
|
+
r"\bnpm\s+publish\b",
|
|
254
|
+
)
|
|
255
|
+
return any(re.search(pattern, cmd, re.IGNORECASE | re.DOTALL) for pattern in patterns)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_WEBROOT_BACKUP_RE = re.compile(
|
|
259
|
+
r"https?://[^\s'\"<>]+(?:\.php\.(?:bak|old|new)|\.(?:bak|old|new|sql|zip|tar|tgz|gz|env))(?:[?#][^\s'\"<>]*)?",
|
|
260
|
+
re.IGNORECASE,
|
|
261
|
+
)
|
|
262
|
+
_HTTP_200_RE = re.compile(r"\b(?:HTTP/\d(?:\.\d)?\s+200|200\s+OK|http_code\s*=\s*200|status\s*[:=]\s*200)\b", re.IGNORECASE)
|
|
263
|
+
_SECRET_MARKER_RE = re.compile(
|
|
264
|
+
r"\b(?:OPENAI_API_KEY|DB_PASSWORD|DB_USERNAME|MYSQL_PASSWORD|SHOPIFY_TOKEN|STRIPE_SECRET|"
|
|
265
|
+
r"api[_-]?key|secret|password|Bearer\s+[A-Za-z0-9._-]{12,}|sk_(?:live|test)_[A-Za-z0-9]+|sk-proj-[A-Za-z0-9_-]+)\b",
|
|
266
|
+
re.IGNORECASE,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _served_backup_secret_signal(payload: dict) -> dict | None:
|
|
271
|
+
text = "\n".join(part for part in (_extract_command(payload), _extract_tool_text(payload)) if part)
|
|
272
|
+
if not text:
|
|
273
|
+
return None
|
|
274
|
+
match = _WEBROOT_BACKUP_RE.search(text)
|
|
275
|
+
if not match:
|
|
276
|
+
return None
|
|
277
|
+
if not _HTTP_200_RE.search(text):
|
|
278
|
+
return None
|
|
279
|
+
if not _SECRET_MARKER_RE.search(text):
|
|
280
|
+
return None
|
|
281
|
+
return {"url": match.group(0)[:240]}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _security_followup_id(seed: str) -> str:
|
|
285
|
+
import hashlib
|
|
286
|
+
|
|
287
|
+
digest = hashlib.sha1(seed.encode("utf-8", errors="ignore"), usedforsecurity=False).hexdigest()[:8].upper()
|
|
288
|
+
return f"NF-SECURITY-WEBROOT-BACKUP-ROTATE-{digest}"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _ensure_webroot_backup_rotation_followup(payload: dict, sid: str) -> str | None:
|
|
292
|
+
signal = _served_backup_secret_signal(payload)
|
|
293
|
+
if not signal:
|
|
294
|
+
return None
|
|
295
|
+
followup_id = _security_followup_id(f"{sid}:{signal['url']}")
|
|
296
|
+
try:
|
|
297
|
+
from db import create_followup, get_followup # type: ignore
|
|
298
|
+
|
|
299
|
+
if not get_followup(followup_id):
|
|
300
|
+
create_followup(
|
|
301
|
+
followup_id,
|
|
302
|
+
description=(
|
|
303
|
+
"SEGURIDAD: canary HTTP detectó un backup o artefacto temporal servible desde webroot "
|
|
304
|
+
f"con marcador de secreto ({signal['url']}). Rotar/revocar los secretos expuestos, "
|
|
305
|
+
"mover el artefacto fuera del webroot y dejar bloqueo/canary verificado."
|
|
306
|
+
),
|
|
307
|
+
date=time.strftime("%Y-%m-%d"),
|
|
308
|
+
verification=(
|
|
309
|
+
"Cierre solo con HTTP público ya no servible (404/403), evidencia de revocación/rotación "
|
|
310
|
+
"de la credencial antigua y nueva ubicación segura registrada."
|
|
311
|
+
),
|
|
312
|
+
reasoning="post_tool_use webroot backup canary detected served secret",
|
|
313
|
+
priority="critical",
|
|
314
|
+
internal=1,
|
|
315
|
+
owner="agent",
|
|
316
|
+
)
|
|
317
|
+
except Exception:
|
|
318
|
+
return None
|
|
319
|
+
return followup_id
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _is_change_log_tool(tool_name: str) -> bool:
|
|
323
|
+
return tool_name in {"nexo_change_log", "mcp__nexo__nexo_change_log"}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _is_task_close_tool(tool_name: str) -> bool:
|
|
327
|
+
return tool_name in {"nexo_task_close", "mcp__nexo__nexo_task_close"}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _task_close_payload_has_change_trace(payload: dict) -> bool:
|
|
331
|
+
tool_input = _tool_input(payload)
|
|
332
|
+
files = str(tool_input.get("files_changed") or "").strip()
|
|
333
|
+
what = str(tool_input.get("change_summary") or tool_input.get("summary") or "").strip()
|
|
334
|
+
why = str(tool_input.get("change_why") or tool_input.get("triggered_by") or "").strip()
|
|
335
|
+
return bool(files and what and why)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _queue_change_log_from_task_close(payload: dict, sid: str, pending: dict) -> bool:
|
|
339
|
+
if not _task_close_payload_has_change_trace(payload):
|
|
340
|
+
return False
|
|
341
|
+
try:
|
|
342
|
+
from mcp_write_queue import enqueue_write # type: ignore
|
|
343
|
+
except Exception:
|
|
344
|
+
return False
|
|
345
|
+
tool_input = _tool_input(payload)
|
|
346
|
+
queued = enqueue_write(
|
|
347
|
+
"change_log",
|
|
348
|
+
{
|
|
349
|
+
"session_id": sid,
|
|
350
|
+
"files": str(tool_input.get("files_changed") or ""),
|
|
351
|
+
"what_changed": str(tool_input.get("change_summary") or tool_input.get("summary") or ""),
|
|
352
|
+
"why": str(tool_input.get("change_why") or tool_input.get("triggered_by") or pending.get("command") or ""),
|
|
353
|
+
"triggered_by": str(tool_input.get("triggered_by") or pending.get("triggered_by") or "post_tool_use production mutation"),
|
|
354
|
+
"affects": str(tool_input.get("change_summary") or ""),
|
|
355
|
+
"risks": str(tool_input.get("change_risks") or ""),
|
|
356
|
+
"verify": str(tool_input.get("change_verify") or tool_input.get("verification") or tool_input.get("evidence") or ""),
|
|
357
|
+
"commit_ref": str(tool_input.get("commit_ref") or ""),
|
|
358
|
+
},
|
|
359
|
+
priority="high",
|
|
360
|
+
)
|
|
361
|
+
return bool(queued.get("accepted"))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _read_json(path: Path) -> dict:
|
|
365
|
+
try:
|
|
366
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
367
|
+
except Exception:
|
|
368
|
+
return {}
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _write_json(path: Path, payload: dict) -> None:
|
|
372
|
+
tmp = path.with_suffix(path.suffix + f".tmp-{os.getpid()}")
|
|
373
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
|
|
374
|
+
tmp.replace(path)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def check_production_change_log_closeout(payload: dict, sid: str) -> str | None:
|
|
378
|
+
if not sid:
|
|
379
|
+
sid = "unknown"
|
|
380
|
+
tool_name = _tool_name(payload)
|
|
381
|
+
pending_path = _pending_change_log_path(sid)
|
|
382
|
+
rotation_followup_id = _ensure_webroot_backup_rotation_followup(payload, sid)
|
|
383
|
+
if _is_change_log_tool(tool_name):
|
|
384
|
+
pending_path.unlink(missing_ok=True)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
cmd = _extract_command(payload)
|
|
388
|
+
if cmd and _is_production_mutation_command(cmd):
|
|
389
|
+
_write_json(
|
|
390
|
+
pending_path,
|
|
391
|
+
{
|
|
392
|
+
"sid": sid,
|
|
393
|
+
"command": cmd[:500],
|
|
394
|
+
"tool_name": tool_name,
|
|
395
|
+
"created_at": time.time(),
|
|
396
|
+
"triggered_by": "PostToolUse production mutation detector",
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
pending = _read_json(pending_path)
|
|
401
|
+
if not pending:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
if _is_task_close_tool(tool_name) and _queue_change_log_from_task_close(payload, sid, pending):
|
|
405
|
+
pending_path.unlink(missing_ok=True)
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
message = (
|
|
409
|
+
"Cierre pendiente: se detectó una señal de despliegue/publicación de producción y todavía no consta "
|
|
410
|
+
"`nexo_change_log(...)` ni un `nexo_task_close(...)` con archivos, motivo y verificación suficiente. "
|
|
411
|
+
"Registra el cambio antes de declarar la tarea cerrada."
|
|
412
|
+
)
|
|
413
|
+
if rotation_followup_id:
|
|
414
|
+
message += f" Además, el canary webroot creó el followup de rotación {rotation_followup_id}."
|
|
415
|
+
return append_operator_language_contract(message)
|
|
416
|
+
|
|
417
|
+
|
|
193
418
|
def _run_auto_capture(payload: dict) -> int:
|
|
194
419
|
"""Pipe the tool result into auto_capture for post-output classification."""
|
|
195
420
|
text = _extract_tool_text(payload)
|
|
@@ -258,13 +483,14 @@ def main() -> int:
|
|
|
258
483
|
try:
|
|
259
484
|
sid = _resolve_sid_from_payload(payload)
|
|
260
485
|
reminder = check_inbox_and_emit_reminder(sid)
|
|
486
|
+
change_log_message = check_production_change_log_closeout(payload, sid)
|
|
261
487
|
g1_message: str | None = None
|
|
262
488
|
try:
|
|
263
489
|
from g1_enforcer import check_response_contract_gate # type: ignore
|
|
264
490
|
g1_message = check_response_contract_gate(sid)
|
|
265
491
|
except Exception:
|
|
266
492
|
g1_message = None
|
|
267
|
-
combined = _combine_system_messages(protocol_message, reminder, g1_message)
|
|
493
|
+
combined = _combine_system_messages(protocol_message, reminder, change_log_message, g1_message)
|
|
268
494
|
if combined:
|
|
269
495
|
print(json.dumps({"systemMessage": combined}))
|
|
270
496
|
except Exception:
|
package/src/hooks/stop.py
CHANGED
|
@@ -8,6 +8,8 @@ without rewriting ~200 lines of working bash.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
11
13
|
import subprocess
|
|
12
14
|
import sys
|
|
13
15
|
import time
|
|
@@ -16,6 +18,21 @@ from pathlib import Path
|
|
|
16
18
|
|
|
17
19
|
_DIR = Path(__file__).resolve().parent
|
|
18
20
|
|
|
21
|
+
FUTURE_COMMITMENT_MARKERS = (
|
|
22
|
+
"lo dejo como seguimiento",
|
|
23
|
+
"cuando quieras",
|
|
24
|
+
"pendiente",
|
|
25
|
+
"lo cojo aparte",
|
|
26
|
+
"después",
|
|
27
|
+
"despues",
|
|
28
|
+
"bloqueado por auth",
|
|
29
|
+
)
|
|
30
|
+
FOLLOWUP_CREATE_MARKERS = ("nexo_followup_create", "mcp__nexo__nexo_followup_create")
|
|
31
|
+
PARTIAL_TASK_CLOSE_RE = re.compile(
|
|
32
|
+
r"(nexo_task_close|mcp__nexo__nexo_task_close).{0,800}['\"]?outcome['\"]?\s*[:=]\s*['\"]?partial",
|
|
33
|
+
re.IGNORECASE | re.DOTALL,
|
|
34
|
+
)
|
|
35
|
+
|
|
19
36
|
|
|
20
37
|
def _record(duration_ms: int, exit_code: int) -> None:
|
|
21
38
|
try:
|
|
@@ -31,10 +48,105 @@ def _record(duration_ms: int, exit_code: int) -> None:
|
|
|
31
48
|
pass
|
|
32
49
|
|
|
33
50
|
|
|
51
|
+
def _candidate_transcript_paths() -> list[Path]:
|
|
52
|
+
try:
|
|
53
|
+
sys.path.insert(0, str(_DIR.parent))
|
|
54
|
+
import paths # type: ignore
|
|
55
|
+
|
|
56
|
+
candidates = [paths.brain_dir() / "session_buffer.jsonl"]
|
|
57
|
+
except Exception:
|
|
58
|
+
nexo_home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
59
|
+
candidates = [
|
|
60
|
+
nexo_home / "personal" / "brain" / "session_buffer.jsonl",
|
|
61
|
+
nexo_home / "brain" / "session_buffer.jsonl",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
for key in ("NEXO_TRANSCRIPT_PATH", "CLAUDE_TRANSCRIPT_PATH", "TRANSCRIPT_PATH"):
|
|
65
|
+
raw = os.environ.get(key, "").strip()
|
|
66
|
+
if raw:
|
|
67
|
+
candidates.append(Path(raw).expanduser())
|
|
68
|
+
return candidates
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_recent_lines(path: Path, max_lines: int = 800) -> list[str]:
|
|
72
|
+
try:
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
return []
|
|
75
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
76
|
+
return lines[-max(1, max_lines):]
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _line_text(line: str) -> str:
|
|
82
|
+
try:
|
|
83
|
+
payload = json.loads(line)
|
|
84
|
+
except Exception:
|
|
85
|
+
return line
|
|
86
|
+
if isinstance(payload, dict):
|
|
87
|
+
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
|
|
88
|
+
return str(payload)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_closeout_followup_gaps(lines: list[str]) -> dict:
|
|
92
|
+
findings: list[dict] = []
|
|
93
|
+
followup_creates = 0
|
|
94
|
+
for idx, raw_line in enumerate(lines):
|
|
95
|
+
text = _line_text(raw_line)
|
|
96
|
+
lower = text.lower()
|
|
97
|
+
if any(marker in lower for marker in FOLLOWUP_CREATE_MARKERS):
|
|
98
|
+
followup_creates += 1
|
|
99
|
+
for marker in FUTURE_COMMITMENT_MARKERS:
|
|
100
|
+
if marker in lower:
|
|
101
|
+
findings.append({"line": idx + 1, "kind": "future_commitment", "marker": marker})
|
|
102
|
+
break
|
|
103
|
+
if PARTIAL_TASK_CLOSE_RE.search(text):
|
|
104
|
+
findings.append({"line": idx + 1, "kind": "partial_task_close", "marker": "task_close partial"})
|
|
105
|
+
|
|
106
|
+
missing = max(0, len(findings) - followup_creates)
|
|
107
|
+
return {
|
|
108
|
+
"ok": missing == 0,
|
|
109
|
+
"findings": findings,
|
|
110
|
+
"followup_creates": followup_creates,
|
|
111
|
+
"missing_followups": missing,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _closeout_followup_message(result: dict) -> str:
|
|
116
|
+
examples = ", ".join(
|
|
117
|
+
f"{item.get('kind')}:{item.get('marker')}" for item in result.get("findings", [])[:5]
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
"Cierre bloqueado: hay compromisos futuros o cierres parciales sin seguimiento persistente. "
|
|
121
|
+
f"Detectados={len(result.get('findings', []))}; followups_creados={result.get('followup_creates', 0)}; "
|
|
122
|
+
f"faltan={result.get('missing_followups', 0)}. "
|
|
123
|
+
"Crea los `nexo_followup_create(...)` necesarios antes de cerrar. "
|
|
124
|
+
f"Ejemplos: {examples}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_closeout_followups() -> dict:
|
|
129
|
+
lines: list[str] = []
|
|
130
|
+
sources: list[str] = []
|
|
131
|
+
for path in _candidate_transcript_paths():
|
|
132
|
+
chunk = _read_recent_lines(path)
|
|
133
|
+
if chunk:
|
|
134
|
+
lines.extend(chunk)
|
|
135
|
+
sources.append(str(path))
|
|
136
|
+
result = scan_closeout_followup_gaps(lines)
|
|
137
|
+
result["sources"] = sources
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
34
141
|
def main() -> int:
|
|
35
142
|
started = time.time()
|
|
36
143
|
script = _DIR / "session-stop.sh"
|
|
37
144
|
exit_code = 0
|
|
145
|
+
closeout = check_closeout_followups()
|
|
146
|
+
if not closeout.get("ok", True):
|
|
147
|
+
print(json.dumps({"decision": "block", "systemMessage": _closeout_followup_message(closeout)}, ensure_ascii=False))
|
|
148
|
+
_record(int((time.time() - started) * 1000), 2)
|
|
149
|
+
return 0
|
|
38
150
|
if script.is_file():
|
|
39
151
|
try:
|
|
40
152
|
exit_code = subprocess.run(
|
package/src/local_context/api.py
CHANGED
|
@@ -6,7 +6,6 @@ import re
|
|
|
6
6
|
import shutil
|
|
7
7
|
import sqlite3
|
|
8
8
|
import stat
|
|
9
|
-
import hashlib
|
|
10
9
|
import subprocess
|
|
11
10
|
import sys
|
|
12
11
|
import time
|
|
@@ -16,7 +15,7 @@ from pathlib import Path
|
|
|
16
15
|
from typing import Any
|
|
17
16
|
|
|
18
17
|
import paths
|
|
19
|
-
from . import embeddings
|
|
18
|
+
from . import embeddings, usage_events
|
|
20
19
|
from .db import LOCAL_CONTEXT_TABLES, close_local_context_db, connect_local_context_db_readonly, ensure_local_context_db, get_local_context_db, local_context_db_path
|
|
21
20
|
from .extractors import canonical_entity_key, chunk_text, contains_secret, entities, entity_mentions, extract_text, normalize_entity_alias, summarize
|
|
22
21
|
from .logging import log_event, tail
|
|
@@ -4663,7 +4662,7 @@ def context_query(
|
|
|
4663
4662
|
include_relations: bool = True,
|
|
4664
4663
|
snippet_chars: int = 1200,
|
|
4665
4664
|
readonly: bool = True,
|
|
4666
|
-
record_query: bool =
|
|
4665
|
+
record_query: bool = True,
|
|
4667
4666
|
) -> dict:
|
|
4668
4667
|
conn = _read_conn() if readonly else _conn()
|
|
4669
4668
|
close_conn = bool(readonly)
|
|
@@ -4680,7 +4679,7 @@ def context_query(
|
|
|
4680
4679
|
include_entities=include_entities,
|
|
4681
4680
|
include_relations=include_relations,
|
|
4682
4681
|
snippet_chars=snippet_chars,
|
|
4683
|
-
record_query=bool(record_query
|
|
4682
|
+
record_query=bool(record_query),
|
|
4684
4683
|
)
|
|
4685
4684
|
finally:
|
|
4686
4685
|
if close_conn:
|
|
@@ -4791,21 +4790,27 @@ def _context_query_conn(
|
|
|
4791
4790
|
if assets:
|
|
4792
4791
|
summary = f"Found {len(assets)} local asset(s) related to '{clean_query}'."
|
|
4793
4792
|
if record_query:
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
""
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4793
|
+
recorded = usage_events.record_usage_event(
|
|
4794
|
+
query=clean_query,
|
|
4795
|
+
client="nexo",
|
|
4796
|
+
tool="nexo_local_context",
|
|
4797
|
+
source="local_context_query",
|
|
4798
|
+
route_stage="context_query",
|
|
4799
|
+
intent=intent,
|
|
4800
|
+
result_count=len(assets),
|
|
4801
|
+
should_inject=bool(evidence_refs),
|
|
4802
|
+
evidence_refs_count=len(evidence_refs),
|
|
4803
|
+
used_before_response=False,
|
|
4804
|
+
metadata={
|
|
4805
|
+
"legacy_table": "local_context_queries",
|
|
4806
|
+
"mode": normalized_mode,
|
|
4807
|
+
"warnings_count": len(warnings),
|
|
4808
|
+
},
|
|
4807
4809
|
)
|
|
4808
|
-
|
|
4810
|
+
if not recorded.get("ok"):
|
|
4811
|
+
warnings.append(
|
|
4812
|
+
f"Local-context usage telemetry not recorded: {recorded.get('error') or 'unknown_error'}"
|
|
4813
|
+
)
|
|
4809
4814
|
payload = {
|
|
4810
4815
|
"ok": True,
|
|
4811
4816
|
"query": clean_query,
|
|
@@ -7,7 +7,7 @@ import time
|
|
|
7
7
|
from typing import Any, Callable
|
|
8
8
|
|
|
9
9
|
from .db import connect_local_context_db_readonly, local_context_db_path
|
|
10
|
-
from .usage_events import DEFAULT_USAGE_WINDOW_SECONDS, summarize_usage, usage_snapshot
|
|
10
|
+
from .usage_events import DEFAULT_USAGE_WINDOW_SECONDS, summarize_query_events, summarize_usage, usage_snapshot
|
|
11
11
|
|
|
12
12
|
DEFAULT_HEALTH_DEADLINE_MS = 500
|
|
13
13
|
DEFAULT_DB_TIMEOUT_MS = 120
|
|
@@ -106,6 +106,7 @@ def _read_sidecar_snapshot(*, db_timeout_ms: int) -> dict[str, Any]:
|
|
|
106
106
|
latest_query = 0.0
|
|
107
107
|
if query_total:
|
|
108
108
|
latest_query = float(_scalar(conn, "SELECT MAX(created_at) FROM local_context_queries") or 0.0)
|
|
109
|
+
usage_query_counter = summarize_query_events()
|
|
109
110
|
|
|
110
111
|
jobs_pending = jobs_running = jobs_failed = jobs_done = 0
|
|
111
112
|
if _table_exists(conn, "local_index_jobs"):
|
|
@@ -144,9 +145,13 @@ def _read_sidecar_snapshot(*, db_timeout_ms: int) -> dict[str, Any]:
|
|
|
144
145
|
"phase": phase,
|
|
145
146
|
},
|
|
146
147
|
"sidecar_query_counter": {
|
|
147
|
-
"total":
|
|
148
|
-
"latest_at":
|
|
149
|
-
"
|
|
148
|
+
"total": int(usage_query_counter.get("total") or 0) if usage_query_counter.get("ok") else 0,
|
|
149
|
+
"latest_at": float(usage_query_counter.get("latest_at") or 0.0) if usage_query_counter.get("ok") else 0.0,
|
|
150
|
+
"store_path": usage_query_counter.get("store_path") or "",
|
|
151
|
+
"note": "usage_store_query_events",
|
|
152
|
+
"legacy_total": query_total,
|
|
153
|
+
"legacy_latest_at": latest_query,
|
|
154
|
+
"legacy_note": "legacy_counter_not_real_usage",
|
|
150
155
|
},
|
|
151
156
|
}
|
|
152
157
|
finally:
|
|
@@ -678,6 +678,66 @@ def summarize_usage(
|
|
|
678
678
|
}
|
|
679
679
|
|
|
680
680
|
|
|
681
|
+
def summarize_query_events(
|
|
682
|
+
*,
|
|
683
|
+
window_seconds: int = DEFAULT_USAGE_WINDOW_SECONDS,
|
|
684
|
+
db_path: str | os.PathLike[str] | None = None,
|
|
685
|
+
now_ts: float | None = None,
|
|
686
|
+
) -> dict[str, Any]:
|
|
687
|
+
path = Path(db_path).expanduser() if db_path else usage_db_path()
|
|
688
|
+
window = max(0, int(window_seconds))
|
|
689
|
+
current = float(now_ts if now_ts is not None else _now())
|
|
690
|
+
since = 0.0 if window == 0 else current - window
|
|
691
|
+
if not path.exists():
|
|
692
|
+
return {
|
|
693
|
+
"ok": True,
|
|
694
|
+
"store_path": str(path),
|
|
695
|
+
"window_seconds": window,
|
|
696
|
+
"since": since,
|
|
697
|
+
"total": 0,
|
|
698
|
+
"latest_at": 0.0,
|
|
699
|
+
"by_intent": {},
|
|
700
|
+
}
|
|
701
|
+
try:
|
|
702
|
+
conn = _connect_usage_db(create=False, db_path=path)
|
|
703
|
+
except sqlite3.OperationalError as exc:
|
|
704
|
+
return {"ok": False, "error": "usage_store_busy", "detail": str(exc), "store_path": str(path)}
|
|
705
|
+
except sqlite3.DatabaseError as exc:
|
|
706
|
+
return {"ok": False, "error": "usage_store_unreadable", "detail": str(exc), "store_path": str(path)}
|
|
707
|
+
try:
|
|
708
|
+
totals = conn.execute(
|
|
709
|
+
f"""
|
|
710
|
+
SELECT COUNT(*) AS total, MAX(created_at) AS latest_at
|
|
711
|
+
FROM {USAGE_TABLE}
|
|
712
|
+
WHERE created_at >= ?
|
|
713
|
+
AND (source = 'local_context_query' OR route_stage = 'context_query')
|
|
714
|
+
""",
|
|
715
|
+
(since,),
|
|
716
|
+
).fetchone()
|
|
717
|
+
intent_rows = conn.execute(
|
|
718
|
+
f"""
|
|
719
|
+
SELECT intent, COUNT(*) AS total
|
|
720
|
+
FROM {USAGE_TABLE}
|
|
721
|
+
WHERE created_at >= ?
|
|
722
|
+
AND (source = 'local_context_query' OR route_stage = 'context_query')
|
|
723
|
+
GROUP BY intent
|
|
724
|
+
ORDER BY total DESC, intent ASC
|
|
725
|
+
""",
|
|
726
|
+
(since,),
|
|
727
|
+
).fetchall()
|
|
728
|
+
finally:
|
|
729
|
+
conn.close()
|
|
730
|
+
return {
|
|
731
|
+
"ok": True,
|
|
732
|
+
"store_path": str(path),
|
|
733
|
+
"window_seconds": window,
|
|
734
|
+
"since": since,
|
|
735
|
+
"total": int(totals["total"] or 0),
|
|
736
|
+
"latest_at": float(totals["latest_at"] or 0.0),
|
|
737
|
+
"by_intent": {str(row["intent"]): int(row["total"] or 0) for row in intent_rows},
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
|
|
681
741
|
def usage_snapshot(
|
|
682
742
|
*,
|
|
683
743
|
indexed_files: int | None = None,
|
|
@@ -735,3 +795,28 @@ def list_recent_events(
|
|
|
735
795
|
finally:
|
|
736
796
|
conn.close()
|
|
737
797
|
return [dict(row) for row in rows]
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def list_recent_query_events(
|
|
801
|
+
*,
|
|
802
|
+
limit: int = 50,
|
|
803
|
+
db_path: str | os.PathLike[str] | None = None,
|
|
804
|
+
) -> list[dict[str, Any]]:
|
|
805
|
+
path = Path(db_path).expanduser() if db_path else usage_db_path()
|
|
806
|
+
if not path.exists():
|
|
807
|
+
return []
|
|
808
|
+
conn = _connect_usage_db(create=False, db_path=path)
|
|
809
|
+
try:
|
|
810
|
+
rows = conn.execute(
|
|
811
|
+
f"""
|
|
812
|
+
SELECT *
|
|
813
|
+
FROM {USAGE_TABLE}
|
|
814
|
+
WHERE source = 'local_context_query' OR route_stage = 'context_query'
|
|
815
|
+
ORDER BY created_at DESC
|
|
816
|
+
LIMIT ?
|
|
817
|
+
""",
|
|
818
|
+
(max(1, min(int(limit or 50), 500)),),
|
|
819
|
+
).fetchall()
|
|
820
|
+
finally:
|
|
821
|
+
conn.close()
|
|
822
|
+
return [dict(row) for row in rows]
|
package/src/mcp_write_queue.py
CHANGED
|
@@ -253,6 +253,27 @@ def _apply_write(record: dict[str, Any]) -> None:
|
|
|
253
253
|
|
|
254
254
|
capture_context_event(**payload)
|
|
255
255
|
return
|
|
256
|
+
if kind == "followup_create":
|
|
257
|
+
from tools_reminders_crud import handle_followup_create
|
|
258
|
+
|
|
259
|
+
result = handle_followup_create(**payload)
|
|
260
|
+
if str(result).startswith("ERROR:"):
|
|
261
|
+
raise ValueError(result)
|
|
262
|
+
return
|
|
263
|
+
if kind == "change_log":
|
|
264
|
+
from plugins.episodic_memory import handle_change_log
|
|
265
|
+
|
|
266
|
+
result = handle_change_log(**payload)
|
|
267
|
+
if str(result).startswith("ERROR:"):
|
|
268
|
+
raise ValueError(result)
|
|
269
|
+
return
|
|
270
|
+
if kind == "learning_add":
|
|
271
|
+
from tools_learnings import handle_learning_add
|
|
272
|
+
|
|
273
|
+
result = handle_learning_add(**payload)
|
|
274
|
+
if str(result).startswith("ERROR:"):
|
|
275
|
+
raise ValueError(result)
|
|
276
|
+
return
|
|
256
277
|
raise ValueError(f"unsupported write kind: {kind}")
|
|
257
278
|
|
|
258
279
|
|
|
@@ -351,4 +372,3 @@ def _worker_loop() -> None:
|
|
|
351
372
|
drain_write_queue(limit=50)
|
|
352
373
|
except Exception:
|
|
353
374
|
pass
|
|
354
|
-
|