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.
@@ -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(
@@ -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 = False,
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 and not readonly),
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
- conn.execute(
4795
- """
4796
- INSERT INTO local_context_queries(query_hash, intent, result_count, confidence, warnings_json, created_at)
4797
- VALUES (?, ?, ?, ?, ?, ?)
4798
- """,
4799
- (
4800
- hashlib.sha256(clean_query.encode("utf-8", errors="ignore")).hexdigest(),
4801
- intent,
4802
- len(assets),
4803
- 0.75 if evidence_refs else 0.0,
4804
- json_dumps(warnings),
4805
- now(),
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
- conn.commit()
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": query_total,
148
- "latest_at": latest_query,
149
- "note": "legacy_counter_not_real_usage",
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]
@@ -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
-