nexo-brain 7.12.14 → 7.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/auto_close_sessions.py +37 -1
- package/src/doctor/providers/runtime.py +6 -4
- package/src/email_sent_events.py +222 -0
- package/src/hook_guardrails.py +57 -1
- package/src/hooks/post_edit_change_log.py +136 -0
- package/src/hooks/post_tool_use.py +16 -0
- package/src/plugins/update.py +62 -18
- package/src/runtime_versioning.py +63 -0
- package/src/script_registry.py +244 -1
- package/src/scripts/check-context.py +4 -0
- package/src/scripts/nexo-email-monitor.py +1 -1
- package/src/scripts/nexo-followup-runner.py +4 -3
- package/src/scripts/nexo-morning-agent.py +32 -0
- package/src/scripts/nexo-send-reply.py +22 -2
- package/src/scripts/nexo-sleep.py +42 -6
- package/src/tools_sessions.py +15 -0
- package/src/tree_hygiene.py +8 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.3",
|
|
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 `7.
|
|
21
|
+
Version `7.13.3` is the current packaged-runtime line. Unified release — doctor now repairs orphan personal script metadata and ignores historical `versions/**` snapshots, `nexo update` prunes runtime snapshots older than two back, protocol compliance self-heals missing task-open/change-log/stale-session gaps, headless automation uses bounded timeouts, Guardian false positives are tightened, and Codex CLI parity is release-gated. Result: coordinated Desktop bundles can ship the new Brain without changing the Mac/Windows installation contract.
|
|
22
|
+
|
|
23
|
+
Previously in `7.12.15`: patch release — same-version packaged updates now still run the safe maintenance path, Deep Sleep clears process locks on shutdown, sent replies are recorded in durable continuity, and personal script schedule-marker drift is surfaced during reconcile. Result: coordinated Desktop bundles can refresh Brain safely without breaking install/update parity on macOS, Windows via WSL, or Linux.
|
|
22
24
|
|
|
23
25
|
Previously in `7.12.0`: minor release — adds `nexo support-snapshot` for generic local runtime diagnostics and completes the silent-reminder hardening on the live Protocol Enforcer path. The support collector emits one JSON bundle with version/platform metadata, runtime path presence, health-check output, and recent event/operation tails, while map-driven reminders (`nexo_startup`, `nexo_smart_startup`, `nexo_heartbeat`, `nexo_reminders`, `nexo_session_diary_*`, `nexo_stop`, `nexo_task_close`, compaction checkpoint prompts) now say explicitly that silence owns the entire reminder turn.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.13.3",
|
|
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",
|
|
@@ -152,6 +152,36 @@ def promote_draft_to_diary(sid: str, draft: dict, task: str = ""):
|
|
|
152
152
|
delete_diary_draft(sid)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
|
|
156
|
+
"""Close stale open protocol tasks as partial when their session is reaped."""
|
|
157
|
+
rows = conn.execute(
|
|
158
|
+
"""SELECT task_id, goal
|
|
159
|
+
FROM protocol_tasks
|
|
160
|
+
WHERE session_id = ? AND status = 'open'
|
|
161
|
+
ORDER BY opened_at ASC""",
|
|
162
|
+
(sid,),
|
|
163
|
+
).fetchall()
|
|
164
|
+
closed: list[str] = []
|
|
165
|
+
for row in rows:
|
|
166
|
+
task_id = row["task_id"]
|
|
167
|
+
goal = str(row["goal"] or "")
|
|
168
|
+
evidence = (
|
|
169
|
+
f"Auto-closed as partial because session {sid} became stale before an explicit nexo_task_close. "
|
|
170
|
+
f"Session task: {task or 'unknown'}. Open goal: {goal[:240]}"
|
|
171
|
+
)
|
|
172
|
+
conn.execute(
|
|
173
|
+
"""UPDATE protocol_tasks
|
|
174
|
+
SET status = 'partial',
|
|
175
|
+
close_evidence = ?,
|
|
176
|
+
outcome_notes = 'auto-close: stale session ended without explicit task_close',
|
|
177
|
+
closed_at = datetime('now')
|
|
178
|
+
WHERE task_id = ? AND status = 'open'""",
|
|
179
|
+
(evidence[:4000], task_id),
|
|
180
|
+
)
|
|
181
|
+
closed.append(task_id)
|
|
182
|
+
return closed
|
|
183
|
+
|
|
184
|
+
|
|
155
185
|
def main():
|
|
156
186
|
init_db()
|
|
157
187
|
conn = get_db()
|
|
@@ -161,9 +191,12 @@ def main():
|
|
|
161
191
|
print(f"[{datetime.datetime.now().isoformat(timespec='seconds')}] No stale sessions")
|
|
162
192
|
return
|
|
163
193
|
|
|
194
|
+
closed_task_ids: list[str] = []
|
|
164
195
|
for session in orphans:
|
|
165
196
|
sid = session["sid"]
|
|
166
197
|
draft = get_diary_draft(sid)
|
|
198
|
+
closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
|
|
199
|
+
closed_task_ids.extend(closed_tasks)
|
|
167
200
|
|
|
168
201
|
if draft:
|
|
169
202
|
promote_draft_to_diary(sid, draft, task=session.get("task", ""))
|
|
@@ -196,7 +229,10 @@ def main():
|
|
|
196
229
|
os.makedirs(os.path.dirname(AUTO_CLOSE_LOG), exist_ok=True)
|
|
197
230
|
with open(AUTO_CLOSE_LOG, "a") as f:
|
|
198
231
|
ts = datetime.datetime.now().isoformat(timespec="seconds")
|
|
199
|
-
f.write(
|
|
232
|
+
f.write(
|
|
233
|
+
f"{ts} — auto-closed {len(orphans)} session(s): {[s['sid'] for s in orphans]} "
|
|
234
|
+
f"and {len(closed_task_ids)} protocol task(s): {closed_task_ids}\n"
|
|
235
|
+
)
|
|
200
236
|
|
|
201
237
|
|
|
202
238
|
if __name__ == "__main__":
|
|
@@ -704,10 +704,6 @@ def _client_assumption_regressions() -> list[str]:
|
|
|
704
704
|
}
|
|
705
705
|
offenders: list[str] = []
|
|
706
706
|
for path in src_root.rglob("*.py"):
|
|
707
|
-
try:
|
|
708
|
-
text = path.read_text()
|
|
709
|
-
except Exception:
|
|
710
|
-
continue
|
|
711
707
|
resolved = path.resolve()
|
|
712
708
|
try:
|
|
713
709
|
if resolved.is_relative_to(backup_root):
|
|
@@ -723,6 +719,12 @@ def _client_assumption_regressions() -> list[str]:
|
|
|
723
719
|
relative_path = resolved.relative_to(src_root)
|
|
724
720
|
except Exception:
|
|
725
721
|
relative_path = path.relative_to(src_root)
|
|
722
|
+
if "versions" in relative_path.parts:
|
|
723
|
+
continue
|
|
724
|
+
try:
|
|
725
|
+
text = path.read_text()
|
|
726
|
+
except Exception:
|
|
727
|
+
continue
|
|
726
728
|
if ".claude/projects" in text and relative_path not in allowed_relative_paths:
|
|
727
729
|
offenders.append(f"{relative_path} hardcodes ~/.claude/projects")
|
|
728
730
|
collect_path = src_root / "scripts" / "deep-sleep" / "collect.py"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Durable sent-email continuity for NEXO automations.
|
|
2
|
+
|
|
3
|
+
The email monitor tracks inbound lifecycle rows. This module tracks outbound
|
|
4
|
+
messages so startup, duplicate checks, and briefings can see what NEXO already
|
|
5
|
+
sent even when the send path did not originate from an inbound email row.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import paths
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
EMAIL_SENT_TABLE_SQL = """
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sent_email_events (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
message_id TEXT,
|
|
23
|
+
sender TEXT,
|
|
24
|
+
to_addrs TEXT NOT NULL DEFAULT '',
|
|
25
|
+
cc_addrs TEXT NOT NULL DEFAULT '',
|
|
26
|
+
subject TEXT NOT NULL DEFAULT '',
|
|
27
|
+
in_reply_to TEXT NOT NULL DEFAULT '',
|
|
28
|
+
references_header TEXT NOT NULL DEFAULT '',
|
|
29
|
+
source TEXT NOT NULL DEFAULT '',
|
|
30
|
+
status TEXT NOT NULL DEFAULT 'sent',
|
|
31
|
+
sent_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
|
32
|
+
meta TEXT NOT NULL DEFAULT '{}'
|
|
33
|
+
);
|
|
34
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_sent_email_message_id
|
|
35
|
+
ON sent_email_events(message_id)
|
|
36
|
+
WHERE message_id IS NOT NULL AND message_id != '';
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_sent_email_sent_at ON sent_email_events(sent_at);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS idx_sent_email_subject ON sent_email_events(subject);
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
RECENT_SENT_EMAILS_TITLE = "EMAILS ENVIADOS ULTIMAS 24H POR LA OPERATIVA"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sent_email_db_path() -> Path:
|
|
45
|
+
return paths.nexo_email_dir() / "nexo-email.db"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def ensure_sent_email_table(conn: sqlite3.Connection) -> None:
|
|
49
|
+
conn.executescript(EMAIL_SENT_TABLE_SQL)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _connect(db_path: str | Path | None = None) -> sqlite3.Connection:
|
|
53
|
+
path = Path(db_path) if db_path else sent_email_db_path()
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
conn = sqlite3.connect(path)
|
|
56
|
+
conn.row_factory = sqlite3.Row
|
|
57
|
+
ensure_sent_email_table(conn)
|
|
58
|
+
return conn
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _clean(value: object, limit: int = 500) -> str:
|
|
62
|
+
text = " ".join(str(value or "").split())
|
|
63
|
+
if len(text) <= limit:
|
|
64
|
+
return text
|
|
65
|
+
return text[: limit - 3].rstrip() + "..."
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _safe_meta(meta: dict[str, Any] | None) -> str:
|
|
69
|
+
try:
|
|
70
|
+
return json.dumps(meta or {}, ensure_ascii=True, sort_keys=True)
|
|
71
|
+
except Exception:
|
|
72
|
+
return "{}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _record_cognitive_memory(event: dict[str, str]) -> None:
|
|
76
|
+
try:
|
|
77
|
+
import cognitive
|
|
78
|
+
|
|
79
|
+
to_value = event.get("to_addrs", "")
|
|
80
|
+
subject = event.get("subject", "")
|
|
81
|
+
message_id = event.get("message_id", "")
|
|
82
|
+
content = (
|
|
83
|
+
"Sent email recorded by NEXO. "
|
|
84
|
+
f"To: {to_value}. Subject: {subject}. Message-ID: {message_id}."
|
|
85
|
+
)
|
|
86
|
+
cognitive.ingest_to_ltm(
|
|
87
|
+
content,
|
|
88
|
+
source_type="email_sent",
|
|
89
|
+
source_id=message_id or f"{to_value}:{subject}",
|
|
90
|
+
source_title=subject,
|
|
91
|
+
domain="email",
|
|
92
|
+
tags="email,sent,continuity",
|
|
93
|
+
bypass_gate=True,
|
|
94
|
+
)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def record_sent_email(
|
|
100
|
+
*,
|
|
101
|
+
message_id: str = "",
|
|
102
|
+
sender: str = "",
|
|
103
|
+
to_addrs: str = "",
|
|
104
|
+
cc_addrs: str = "",
|
|
105
|
+
subject: str = "",
|
|
106
|
+
in_reply_to: str = "",
|
|
107
|
+
references_header: str = "",
|
|
108
|
+
source: str = "",
|
|
109
|
+
status: str = "sent",
|
|
110
|
+
meta: dict[str, Any] | None = None,
|
|
111
|
+
db_path: str | Path | None = None,
|
|
112
|
+
record_memory: bool = True,
|
|
113
|
+
) -> dict[str, str]:
|
|
114
|
+
event = {
|
|
115
|
+
"message_id": _clean(message_id, 300),
|
|
116
|
+
"sender": _clean(sender, 300),
|
|
117
|
+
"to_addrs": _clean(to_addrs, 800),
|
|
118
|
+
"cc_addrs": _clean(cc_addrs, 800),
|
|
119
|
+
"subject": _clean(subject, 500),
|
|
120
|
+
"in_reply_to": _clean(in_reply_to, 300),
|
|
121
|
+
"references_header": _clean(references_header, 1000),
|
|
122
|
+
"source": _clean(source or "unknown", 120),
|
|
123
|
+
"status": _clean(status or "sent", 80),
|
|
124
|
+
"meta": _safe_meta(meta),
|
|
125
|
+
}
|
|
126
|
+
conn = _connect(db_path)
|
|
127
|
+
try:
|
|
128
|
+
conn.execute(
|
|
129
|
+
"""
|
|
130
|
+
INSERT OR REPLACE INTO sent_email_events (
|
|
131
|
+
message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
|
|
132
|
+
references_header, source, status, meta
|
|
133
|
+
)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
135
|
+
""",
|
|
136
|
+
(
|
|
137
|
+
event["message_id"],
|
|
138
|
+
event["sender"],
|
|
139
|
+
event["to_addrs"],
|
|
140
|
+
event["cc_addrs"],
|
|
141
|
+
event["subject"],
|
|
142
|
+
event["in_reply_to"],
|
|
143
|
+
event["references_header"],
|
|
144
|
+
event["source"],
|
|
145
|
+
event["status"],
|
|
146
|
+
event["meta"],
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
conn.commit()
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
if record_memory:
|
|
154
|
+
_record_cognitive_memory(event)
|
|
155
|
+
return event
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def recent_sent_emails(
|
|
159
|
+
*,
|
|
160
|
+
hours: int = 24,
|
|
161
|
+
limit: int = 10,
|
|
162
|
+
db_path: str | Path | None = None,
|
|
163
|
+
) -> list[dict[str, str]]:
|
|
164
|
+
cutoff = (datetime.now() - timedelta(hours=max(1, int(hours)))).strftime("%Y-%m-%d %H:%M:%S")
|
|
165
|
+
conn = _connect(db_path)
|
|
166
|
+
try:
|
|
167
|
+
rows = conn.execute(
|
|
168
|
+
"""
|
|
169
|
+
SELECT message_id, sender, to_addrs, cc_addrs, subject, in_reply_to,
|
|
170
|
+
references_header, source, status, sent_at, meta
|
|
171
|
+
FROM sent_email_events
|
|
172
|
+
WHERE sent_at >= ?
|
|
173
|
+
ORDER BY sent_at DESC
|
|
174
|
+
LIMIT ?
|
|
175
|
+
""",
|
|
176
|
+
(cutoff, max(1, int(limit))),
|
|
177
|
+
).fetchall()
|
|
178
|
+
return [dict(row) for row in rows]
|
|
179
|
+
finally:
|
|
180
|
+
conn.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def find_sent_email(
|
|
184
|
+
*,
|
|
185
|
+
to_addr: str = "",
|
|
186
|
+
subject: str = "",
|
|
187
|
+
since_hours: int = 72,
|
|
188
|
+
db_path: str | Path | None = None,
|
|
189
|
+
) -> dict[str, str] | None:
|
|
190
|
+
target_to = _clean(to_addr).lower()
|
|
191
|
+
target_subject = _clean(subject).lower()
|
|
192
|
+
if not target_to or not target_subject:
|
|
193
|
+
return None
|
|
194
|
+
for event in recent_sent_emails(hours=since_hours, limit=100, db_path=db_path):
|
|
195
|
+
if target_to in str(event.get("to_addrs") or "").lower() and target_subject in str(event.get("subject") or "").lower():
|
|
196
|
+
return event
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def format_recent_sent_email_block(*, hours: int = 24, limit: int = 8) -> str:
|
|
201
|
+
rows = recent_sent_emails(hours=hours, limit=limit)
|
|
202
|
+
if not rows:
|
|
203
|
+
return ""
|
|
204
|
+
lines = [f"== {RECENT_SENT_EMAILS_TITLE} =="]
|
|
205
|
+
for row in rows:
|
|
206
|
+
sent_at = str(row.get("sent_at") or "")
|
|
207
|
+
to_value = _clean(row.get("to_addrs"), 120)
|
|
208
|
+
subject = _clean(row.get("subject"), 160)
|
|
209
|
+
source = _clean(row.get("source"), 80)
|
|
210
|
+
lines.append(f"- {sent_at} | to: {to_value} | subject: {subject} | source: {source}")
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"RECENT_SENT_EMAILS_TITLE",
|
|
216
|
+
"ensure_sent_email_table",
|
|
217
|
+
"find_sent_email",
|
|
218
|
+
"format_recent_sent_email_block",
|
|
219
|
+
"recent_sent_emails",
|
|
220
|
+
"record_sent_email",
|
|
221
|
+
"sent_email_db_path",
|
|
222
|
+
]
|
package/src/hook_guardrails.py
CHANGED
|
@@ -13,7 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
import paths
|
|
14
14
|
|
|
15
15
|
from core_prompts import render_core_prompt
|
|
16
|
-
from db import create_protocol_debt, get_db, get_last_heartbeat_ts
|
|
16
|
+
from db import create_protocol_debt, create_protocol_task, get_db, get_last_heartbeat_ts
|
|
17
17
|
from operator_language import append_operator_language_contract
|
|
18
18
|
from plugins.guard import _load_conditioned_learnings, _normalize_path_token
|
|
19
19
|
from protocol_settings import get_protocol_strictness
|
|
@@ -429,6 +429,42 @@ def _strict_write_without_task_severity(session_id: str) -> str:
|
|
|
429
429
|
return "error"
|
|
430
430
|
|
|
431
431
|
|
|
432
|
+
def _auto_task_open_enabled() -> bool:
|
|
433
|
+
return os.environ.get("NEXO_AUTO_TASK_OPEN", "1").strip().lower() not in {"0", "false", "no", "off"}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _auto_open_protocol_task_for_write(*, sid: str, tool_name: str, operation: str, files: list[str]) -> dict | None:
|
|
437
|
+
if not sid or not _auto_task_open_enabled():
|
|
438
|
+
return None
|
|
439
|
+
task_type = "edit" if operation == "write" else "execute"
|
|
440
|
+
clean_files = [item for item in files if str(item or "").strip()]
|
|
441
|
+
target = ", ".join(clean_files[:3]) if clean_files else "unknown target"
|
|
442
|
+
if len(clean_files) > 3:
|
|
443
|
+
target += f", +{len(clean_files) - 3} more"
|
|
444
|
+
try:
|
|
445
|
+
return create_protocol_task(
|
|
446
|
+
sid,
|
|
447
|
+
f"Auto-opened {task_type} task for {tool_name} on {target}",
|
|
448
|
+
task_type=task_type,
|
|
449
|
+
area="auto",
|
|
450
|
+
context_hint="PreToolUse auto-task_open: write/delete attempted without a matching open task.",
|
|
451
|
+
files=clean_files,
|
|
452
|
+
plan=[
|
|
453
|
+
"Auto-opened because the agent attempted a write/delete before explicit task_open.",
|
|
454
|
+
"Verify the edit and close with evidence.",
|
|
455
|
+
],
|
|
456
|
+
constraints=[
|
|
457
|
+
"Do not treat this auto-open as success evidence.",
|
|
458
|
+
"Close as done only after verification; otherwise close partial/failed.",
|
|
459
|
+
],
|
|
460
|
+
verification_step="Run the relevant test or inspection and close with evidence.",
|
|
461
|
+
must_verify=True,
|
|
462
|
+
must_change_log=True,
|
|
463
|
+
)
|
|
464
|
+
except Exception:
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
|
|
432
468
|
def _resolve_runtime_path(path: str) -> Path:
|
|
433
469
|
candidate = Path(str(path or "")).expanduser()
|
|
434
470
|
if not candidate.is_absolute():
|
|
@@ -1599,6 +1635,24 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1599
1635
|
"status": "blocked",
|
|
1600
1636
|
}
|
|
1601
1637
|
|
|
1638
|
+
auto_opened_task = None
|
|
1639
|
+
if files:
|
|
1640
|
+
missing_task_files = [filepath for filepath in files if not _find_open_task_for_file(conn, sid, filepath)]
|
|
1641
|
+
if missing_task_files:
|
|
1642
|
+
auto_opened_task = _auto_open_protocol_task_for_write(
|
|
1643
|
+
sid=sid,
|
|
1644
|
+
tool_name=tool_name,
|
|
1645
|
+
operation=op,
|
|
1646
|
+
files=missing_task_files,
|
|
1647
|
+
)
|
|
1648
|
+
elif not _find_any_open_task(conn, sid):
|
|
1649
|
+
auto_opened_task = _auto_open_protocol_task_for_write(
|
|
1650
|
+
sid=sid,
|
|
1651
|
+
tool_name=tool_name,
|
|
1652
|
+
operation=op,
|
|
1653
|
+
files=[],
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1602
1656
|
if not files:
|
|
1603
1657
|
task = _find_any_open_task(conn, sid)
|
|
1604
1658
|
if not task:
|
|
@@ -1628,6 +1682,7 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1628
1682
|
"operation": op,
|
|
1629
1683
|
"strictness": strictness,
|
|
1630
1684
|
"blocks": blocks,
|
|
1685
|
+
"auto_opened_task": auto_opened_task,
|
|
1631
1686
|
"status": "blocked" if blocks else "clean",
|
|
1632
1687
|
}
|
|
1633
1688
|
|
|
@@ -1711,6 +1766,7 @@ def process_pre_tool_event(payload: dict) -> dict:
|
|
|
1711
1766
|
"operation": op,
|
|
1712
1767
|
"strictness": strictness,
|
|
1713
1768
|
"blocks": blocks,
|
|
1769
|
+
"auto_opened_task": auto_opened_task,
|
|
1714
1770
|
"status": "blocked" if blocks else "clean",
|
|
1715
1771
|
}
|
|
1716
1772
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
"""Best-effort PostToolUse change_log recorder for write tools.
|
|
5
|
+
|
|
6
|
+
This hook records file edit visibility directly in the DB layer. It never
|
|
7
|
+
calls MCP tools and never blocks the client pipeline.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_DIR = Path(__file__).resolve().parent
|
|
17
|
+
_SRC = _DIR.parent
|
|
18
|
+
if str(_SRC) not in sys.path:
|
|
19
|
+
sys.path.insert(0, str(_SRC))
|
|
20
|
+
|
|
21
|
+
WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _short_tool_name(tool_name: str) -> str:
|
|
25
|
+
clean = str(tool_name or "").strip()
|
|
26
|
+
return clean.rsplit("__", 1)[-1] if "__" in clean else clean
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_stdin_json() -> dict:
|
|
30
|
+
if sys.stdin.isatty():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
raw = sys.stdin.read()
|
|
34
|
+
if not raw.strip():
|
|
35
|
+
return {}
|
|
36
|
+
payload = json.loads(raw)
|
|
37
|
+
return payload if isinstance(payload, dict) else {}
|
|
38
|
+
except Exception:
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tool_input(payload: dict) -> dict:
|
|
43
|
+
value = payload.get("tool_input") or payload.get("toolInput") or payload.get("input") or {}
|
|
44
|
+
return value if isinstance(value, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_file_paths(payload: dict) -> list[str]:
|
|
48
|
+
tool_input = _tool_input(payload)
|
|
49
|
+
candidates: list[str] = []
|
|
50
|
+
for key in ("file_path", "filePath", "path", "notebook_path", "notebookPath"):
|
|
51
|
+
value = tool_input.get(key)
|
|
52
|
+
if isinstance(value, str) and value.strip():
|
|
53
|
+
candidates.append(value.strip())
|
|
54
|
+
for key in ("file_paths", "filePaths", "paths"):
|
|
55
|
+
value = tool_input.get(key)
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
candidates.extend(str(item).strip() for item in value if str(item).strip())
|
|
58
|
+
seen: set[str] = set()
|
|
59
|
+
paths: list[str] = []
|
|
60
|
+
for item in candidates:
|
|
61
|
+
if item in seen:
|
|
62
|
+
continue
|
|
63
|
+
seen.add(item)
|
|
64
|
+
paths.append(item)
|
|
65
|
+
return paths
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_sid_from_payload(payload: dict) -> str:
|
|
69
|
+
candidates: list[str] = []
|
|
70
|
+
for key in ("nexo_sid", "sid", "session_id", "sessionId"):
|
|
71
|
+
value = payload.get(key)
|
|
72
|
+
if isinstance(value, str) and value.strip():
|
|
73
|
+
candidates.append(value.strip())
|
|
74
|
+
for key in ("NEXO_SID", "CLAUDE_SESSION_ID"):
|
|
75
|
+
value = os.environ.get(key, "").strip()
|
|
76
|
+
if value:
|
|
77
|
+
candidates.append(value)
|
|
78
|
+
try:
|
|
79
|
+
from db import resolve_sid_from_external
|
|
80
|
+
except Exception:
|
|
81
|
+
return ""
|
|
82
|
+
for candidate in candidates:
|
|
83
|
+
if candidate.startswith("nexo-"):
|
|
84
|
+
return candidate
|
|
85
|
+
resolved = resolve_sid_from_external(candidate)
|
|
86
|
+
if resolved:
|
|
87
|
+
return resolved
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def record_post_edit_change(payload: dict) -> dict:
|
|
92
|
+
"""Record a minimal change_log row for write-like tool payloads."""
|
|
93
|
+
tool_name = _short_tool_name(str(payload.get("tool_name") or payload.get("toolName") or ""))
|
|
94
|
+
if tool_name not in WRITE_TOOLS:
|
|
95
|
+
return {"ok": True, "skipped": True, "reason": "tool_not_write"}
|
|
96
|
+
if os.environ.get("NEXO_AUTO_CHANGE_LOG", "1").strip().lower() in {"0", "false", "no", "off"}:
|
|
97
|
+
return {"ok": True, "skipped": True, "reason": "disabled"}
|
|
98
|
+
|
|
99
|
+
paths = _extract_file_paths(payload)
|
|
100
|
+
if not paths:
|
|
101
|
+
return {"ok": True, "skipped": True, "reason": "missing_file_path"}
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
from db import init_db, log_change
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
return {"ok": False, "error": f"db_import_failed: {exc}"}
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
init_db()
|
|
110
|
+
sid = _resolve_sid_from_payload(payload) or "unknown"
|
|
111
|
+
files = ", ".join(paths)
|
|
112
|
+
result = log_change(
|
|
113
|
+
sid,
|
|
114
|
+
files,
|
|
115
|
+
f"Auto-recorded PostToolUse {tool_name} file edit",
|
|
116
|
+
"PostToolUse observed a file write; recording traceability even if the agent forgets nexo_change_log.",
|
|
117
|
+
triggered_by="post_edit_change_log.py",
|
|
118
|
+
affects=files,
|
|
119
|
+
risks="Automatic trace only; verify the actual diff and tests separately.",
|
|
120
|
+
verify="Inspect git diff and run the relevant tests for the edited file.",
|
|
121
|
+
commit_ref="",
|
|
122
|
+
)
|
|
123
|
+
if "error" in result:
|
|
124
|
+
return {"ok": False, "error": result["error"]}
|
|
125
|
+
return {"ok": True, "change_log_id": result.get("id"), "files": paths}
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
return {"ok": False, "error": str(exc)}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main() -> int:
|
|
131
|
+
record_post_edit_change(_read_stdin_json())
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|
|
@@ -208,6 +208,21 @@ def _run_auto_capture(payload: dict) -> int:
|
|
|
208
208
|
return 1
|
|
209
209
|
|
|
210
210
|
|
|
211
|
+
def _run_post_edit_change_log(payload: dict) -> int:
|
|
212
|
+
"""Record write-tool visibility without calling MCP from the hook."""
|
|
213
|
+
try:
|
|
214
|
+
proc = subprocess.run(
|
|
215
|
+
["python3", str(_DIR / "post_edit_change_log.py")],
|
|
216
|
+
input=json.dumps(payload),
|
|
217
|
+
capture_output=True,
|
|
218
|
+
text=True,
|
|
219
|
+
timeout=5,
|
|
220
|
+
)
|
|
221
|
+
return proc.returncode
|
|
222
|
+
except Exception:
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
|
|
211
226
|
def main() -> int:
|
|
212
227
|
started = time.time()
|
|
213
228
|
payload = _read_stdin_json()
|
|
@@ -231,6 +246,7 @@ def main() -> int:
|
|
|
231
246
|
protocol_message = stdout
|
|
232
247
|
|
|
233
248
|
exits.append(_run_auto_capture(payload))
|
|
249
|
+
exits.append(_run_post_edit_change_log(payload))
|
|
234
250
|
|
|
235
251
|
# v6.0.1 — inbox autodetect runs LAST so it sees the latest DB state
|
|
236
252
|
# (including any writes the previous steps may have done). Emits a
|