nexo-brain 7.32.0 → 7.34.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 +1 -1
- package/package.json +1 -1
- package/src/consolidation_prep.py +380 -0
- package/src/db/__init__.py +5 -1
- package/src/db/_episodic.py +32 -0
- package/src/db/_memory_v2.py +276 -0
- package/src/db/_protocol.py +35 -0
- package/src/db/_schema.py +207 -0
- package/src/hooks/auto_capture.py +60 -24
- package/src/learning_resolver.py +42 -0
- package/src/local_context/api.py +237 -33
- package/src/local_context/db.py +3 -2
- package/src/local_context/usage_events.py +2 -0
- package/src/memory_retrieval.py +96 -7
- package/src/message_batch_preview.py +290 -0
- package/src/plugins/protocol.py +218 -27
- package/src/ppr.py +473 -0
- package/src/pre_answer_router.py +316 -3
- package/src/pre_answer_runtime.py +156 -1
- package/src/resolution_cache.py +1119 -0
- package/src/scripts/deep-sleep/apply_findings.py +86 -9
- package/src/scripts/deep-sleep/rewrite.py +625 -0
- package/src/scripts/nexo-deep-sleep.sh +10 -0
- package/src/scripts/nexo-followup-runner.py +110 -8
- package/src/scripts/nexo-morning-agent.py +43 -2
- package/src/scripts/nexo-postmortem-consolidator.py +44 -1
- package/src/self_error_detector.py +414 -0
- package/src/semantic_layers.py +30 -3
- package/templates/core-prompts/morning-agent.md +3 -0
- package/templates/core-prompts/postmortem-consolidator.md +29 -2
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Build safe HTML previews before real WhatsApp/email batch sends.
|
|
2
|
+
|
|
3
|
+
This module is intentionally send-agnostic: it reads code/log/queue artifacts,
|
|
4
|
+
separates internal or test messages from deliverable candidates, renders a
|
|
5
|
+
sanitized HTML review document, and enforces a hard cap on real sends.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterable
|
|
16
|
+
|
|
17
|
+
from email_presentation import compose_html_document, text_to_html_fragment
|
|
18
|
+
from tools_email_guard import should_block_email_send
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_REAL_SEND_LIMIT = 10
|
|
22
|
+
INTERNAL_MARKERS = (
|
|
23
|
+
"[internal]",
|
|
24
|
+
"internal:",
|
|
25
|
+
"nexo_internal",
|
|
26
|
+
"solo interno",
|
|
27
|
+
"nota interna",
|
|
28
|
+
"mensaje interno",
|
|
29
|
+
"test:",
|
|
30
|
+
"[test]",
|
|
31
|
+
"dry-run",
|
|
32
|
+
"dry_run",
|
|
33
|
+
"prueba",
|
|
34
|
+
)
|
|
35
|
+
TEST_RECIPIENT_PATTERNS = (
|
|
36
|
+
re.compile(r"(^|@)(example|test|localhost)(\.|$)", re.I),
|
|
37
|
+
re.compile(r"\+test\b", re.I),
|
|
38
|
+
re.compile(r"^(?:0+|123456789|600000000)$"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class PreviewMessage:
|
|
44
|
+
source: str
|
|
45
|
+
channel: str
|
|
46
|
+
recipient: str
|
|
47
|
+
body: str
|
|
48
|
+
subject: str = ""
|
|
49
|
+
metadata: dict[str, Any] | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def fingerprint(self) -> str:
|
|
53
|
+
base = "\x1f".join([
|
|
54
|
+
self.channel.strip().lower(),
|
|
55
|
+
self.recipient.strip().lower(),
|
|
56
|
+
self.subject.strip(),
|
|
57
|
+
" ".join(self.body.split()),
|
|
58
|
+
])
|
|
59
|
+
return str(abs(hash(base)))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class PreviewResult:
|
|
64
|
+
deliverable: list[PreviewMessage]
|
|
65
|
+
internal_or_test: list[PreviewMessage]
|
|
66
|
+
blocked: list[dict[str, str]]
|
|
67
|
+
real_send_limit: int
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def capped_deliverable(self) -> list[PreviewMessage]:
|
|
71
|
+
return self.deliverable[: self.real_send_limit]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def over_limit_count(self) -> int:
|
|
75
|
+
return max(0, len(self.deliverable) - self.real_send_limit)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict[str, Any]:
|
|
78
|
+
return {
|
|
79
|
+
"deliverable_count": len(self.deliverable),
|
|
80
|
+
"capped_deliverable_count": len(self.capped_deliverable),
|
|
81
|
+
"internal_or_test_count": len(self.internal_or_test),
|
|
82
|
+
"blocked_count": len(self.blocked),
|
|
83
|
+
"real_send_limit": self.real_send_limit,
|
|
84
|
+
"over_limit_count": self.over_limit_count,
|
|
85
|
+
"deliverable": [_message_to_dict(m) for m in self.capped_deliverable],
|
|
86
|
+
"internal_or_test": [_message_to_dict(m) for m in self.internal_or_test],
|
|
87
|
+
"blocked": self.blocked,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _message_to_dict(message: PreviewMessage) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"source": message.source,
|
|
94
|
+
"channel": message.channel,
|
|
95
|
+
"recipient": message.recipient,
|
|
96
|
+
"subject": message.subject,
|
|
97
|
+
"body": message.body,
|
|
98
|
+
"metadata": message.metadata or {},
|
|
99
|
+
"fingerprint": message.fingerprint,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_messages(paths: Iterable[Path | str]) -> list[PreviewMessage]:
|
|
104
|
+
messages: list[PreviewMessage] = []
|
|
105
|
+
for raw_path in paths:
|
|
106
|
+
path = Path(raw_path)
|
|
107
|
+
if not path.exists() or not path.is_file():
|
|
108
|
+
raise FileNotFoundError(str(path))
|
|
109
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
110
|
+
messages.extend(_parse_artifact(path, text))
|
|
111
|
+
return messages
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_artifact(path: Path, text: str) -> list[PreviewMessage]:
|
|
115
|
+
stripped = text.strip()
|
|
116
|
+
if not stripped:
|
|
117
|
+
return []
|
|
118
|
+
if path.suffix.lower() == ".jsonl":
|
|
119
|
+
rows = [json.loads(line) for line in stripped.splitlines() if line.strip()]
|
|
120
|
+
return [_row_to_message(row, path, index) for index, row in enumerate(rows, start=1)]
|
|
121
|
+
if path.suffix.lower() == ".json":
|
|
122
|
+
payload = json.loads(stripped)
|
|
123
|
+
if isinstance(payload, list):
|
|
124
|
+
rows = payload
|
|
125
|
+
elif isinstance(payload, dict):
|
|
126
|
+
rows = payload.get("messages") or payload.get("items") or payload.get("queue") or [payload]
|
|
127
|
+
else:
|
|
128
|
+
rows = []
|
|
129
|
+
return [_row_to_message(row, path, index) for index, row in enumerate(rows, start=1) if isinstance(row, dict)]
|
|
130
|
+
return [PreviewMessage(source=str(path), channel="log", recipient="", body=stripped)]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _row_to_message(row: dict[str, Any], path: Path, index: int) -> PreviewMessage:
|
|
134
|
+
recipient = str(
|
|
135
|
+
row.get("recipient")
|
|
136
|
+
or row.get("to")
|
|
137
|
+
or row.get("phone")
|
|
138
|
+
or row.get("email")
|
|
139
|
+
or ""
|
|
140
|
+
).strip()
|
|
141
|
+
body = str(
|
|
142
|
+
row.get("body")
|
|
143
|
+
or row.get("message")
|
|
144
|
+
or row.get("text")
|
|
145
|
+
or row.get("html")
|
|
146
|
+
or ""
|
|
147
|
+
).strip()
|
|
148
|
+
channel = str(row.get("channel") or row.get("type") or _infer_channel(recipient)).strip().lower()
|
|
149
|
+
subject = str(row.get("subject") or "").strip()
|
|
150
|
+
return PreviewMessage(
|
|
151
|
+
source=f"{path}:{index}",
|
|
152
|
+
channel=channel or "unknown",
|
|
153
|
+
recipient=recipient,
|
|
154
|
+
subject=subject,
|
|
155
|
+
body=body,
|
|
156
|
+
metadata={k: v for k, v in row.items() if k not in {"body", "message", "text", "html"}},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _infer_channel(recipient: str) -> str:
|
|
161
|
+
if "@" in recipient:
|
|
162
|
+
return "email"
|
|
163
|
+
if recipient:
|
|
164
|
+
return "whatsapp"
|
|
165
|
+
return "unknown"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def is_internal_or_test(message: PreviewMessage) -> bool:
|
|
169
|
+
haystack = " ".join([
|
|
170
|
+
message.channel,
|
|
171
|
+
message.recipient,
|
|
172
|
+
message.subject,
|
|
173
|
+
message.body,
|
|
174
|
+
json.dumps(message.metadata or {}, ensure_ascii=False, sort_keys=True),
|
|
175
|
+
]).lower()
|
|
176
|
+
if any(marker in haystack for marker in INTERNAL_MARKERS):
|
|
177
|
+
return True
|
|
178
|
+
recipient = message.recipient.strip()
|
|
179
|
+
return any(pattern.search(recipient) for pattern in TEST_RECIPIENT_PATTERNS)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_preview(messages: Iterable[PreviewMessage], *, real_send_limit: int = DEFAULT_REAL_SEND_LIMIT) -> PreviewResult:
|
|
183
|
+
if real_send_limit < 1:
|
|
184
|
+
raise ValueError("real_send_limit must be >= 1")
|
|
185
|
+
deliverable: list[PreviewMessage] = []
|
|
186
|
+
internal_or_test: list[PreviewMessage] = []
|
|
187
|
+
blocked: list[dict[str, str]] = []
|
|
188
|
+
seen: set[str] = set()
|
|
189
|
+
|
|
190
|
+
for message in messages:
|
|
191
|
+
if is_internal_or_test(message):
|
|
192
|
+
internal_or_test.append(message)
|
|
193
|
+
continue
|
|
194
|
+
blocked_by_secret, reason = should_block_email_send(
|
|
195
|
+
"\n".join([message.subject, message.body, json.dumps(message.metadata or {}, ensure_ascii=False)])
|
|
196
|
+
)
|
|
197
|
+
if blocked_by_secret:
|
|
198
|
+
blocked.append({"source": message.source, "recipient": message.recipient, "reason": reason})
|
|
199
|
+
continue
|
|
200
|
+
if message.fingerprint in seen:
|
|
201
|
+
blocked.append({"source": message.source, "recipient": message.recipient, "reason": "duplicate message"})
|
|
202
|
+
continue
|
|
203
|
+
seen.add(message.fingerprint)
|
|
204
|
+
deliverable.append(message)
|
|
205
|
+
|
|
206
|
+
return PreviewResult(
|
|
207
|
+
deliverable=deliverable,
|
|
208
|
+
internal_or_test=internal_or_test,
|
|
209
|
+
blocked=blocked,
|
|
210
|
+
real_send_limit=real_send_limit,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_preview_html(result: PreviewResult) -> str:
|
|
215
|
+
parts = [
|
|
216
|
+
"<h1>Previsualización de lote</h1>",
|
|
217
|
+
"<table><tbody>",
|
|
218
|
+
f"<tr><th>Enviables</th><td>{len(result.deliverable)}</td></tr>",
|
|
219
|
+
f"<tr><th>Incluidos por límite</th><td>{len(result.capped_deliverable)}</td></tr>",
|
|
220
|
+
f"<tr><th>Internos/tests separados</th><td>{len(result.internal_or_test)}</td></tr>",
|
|
221
|
+
f"<tr><th>Bloqueados</th><td>{len(result.blocked)}</td></tr>",
|
|
222
|
+
f"<tr><th>Exceso de lote</th><td>{result.over_limit_count}</td></tr>",
|
|
223
|
+
"</tbody></table>",
|
|
224
|
+
"<h2>Candidatos a envío real</h2>",
|
|
225
|
+
_render_message_list(result.capped_deliverable),
|
|
226
|
+
"<h2>Separados: internos/tests</h2>",
|
|
227
|
+
_render_message_list(result.internal_or_test),
|
|
228
|
+
"<h2>Bloqueados</h2>",
|
|
229
|
+
_render_blocked(result.blocked),
|
|
230
|
+
]
|
|
231
|
+
return compose_html_document("".join(parts))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _render_message_list(messages: list[PreviewMessage]) -> str:
|
|
235
|
+
if not messages:
|
|
236
|
+
return "<p>Ninguno.</p>"
|
|
237
|
+
rows = []
|
|
238
|
+
for message in messages:
|
|
239
|
+
body = text_to_html_fragment(message.body[:1200])
|
|
240
|
+
rows.append(
|
|
241
|
+
"<tr>"
|
|
242
|
+
f"<td>{text_to_html_fragment(message.channel)}</td>"
|
|
243
|
+
f"<td>{text_to_html_fragment(message.recipient or '(sin destinatario)')}</td>"
|
|
244
|
+
f"<td>{text_to_html_fragment(message.subject or message.source)}</td>"
|
|
245
|
+
f"<td>{body}</td>"
|
|
246
|
+
"</tr>"
|
|
247
|
+
)
|
|
248
|
+
return "<table><thead><tr><th>Canal</th><th>Destino</th><th>Asunto/fuente</th><th>Mensaje</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _render_blocked(blocked: list[dict[str, str]]) -> str:
|
|
252
|
+
if not blocked:
|
|
253
|
+
return "<p>Ninguno.</p>"
|
|
254
|
+
rows = [
|
|
255
|
+
"<tr>"
|
|
256
|
+
f"<td>{text_to_html_fragment(item.get('source', ''))}</td>"
|
|
257
|
+
f"<td>{text_to_html_fragment(item.get('recipient', ''))}</td>"
|
|
258
|
+
f"<td>{text_to_html_fragment(item.get('reason', ''))}</td>"
|
|
259
|
+
"</tr>"
|
|
260
|
+
for item in blocked
|
|
261
|
+
]
|
|
262
|
+
return "<table><thead><tr><th>Fuente</th><th>Destino</th><th>Motivo</th></tr></thead><tbody>" + "".join(rows) + "</tbody></table>"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main(argv: list[str] | None = None) -> int:
|
|
266
|
+
parser = argparse.ArgumentParser(description="Generate a safe HTML preview for WhatsApp/email batch candidates.")
|
|
267
|
+
parser.add_argument("paths", nargs="+", help="JSON, JSONL, log, or text artifacts to inspect.")
|
|
268
|
+
parser.add_argument("--limit", type=int, default=DEFAULT_REAL_SEND_LIMIT, help="Maximum real sends allowed in one batch.")
|
|
269
|
+
parser.add_argument("--html-out", required=True, help="Destination HTML preview file.")
|
|
270
|
+
parser.add_argument("--json-out", default="", help="Optional JSON summary destination.")
|
|
271
|
+
args = parser.parse_args(argv)
|
|
272
|
+
|
|
273
|
+
result = build_preview(read_messages(args.paths), real_send_limit=args.limit)
|
|
274
|
+
Path(args.html_out).write_text(render_preview_html(result), encoding="utf-8")
|
|
275
|
+
if args.json_out:
|
|
276
|
+
Path(args.json_out).write_text(json.dumps(result.to_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
|
|
277
|
+
print(json.dumps({
|
|
278
|
+
"html_out": args.html_out,
|
|
279
|
+
"json_out": args.json_out,
|
|
280
|
+
"deliverable": len(result.deliverable),
|
|
281
|
+
"capped_deliverable": len(result.capped_deliverable),
|
|
282
|
+
"internal_or_test": len(result.internal_or_test),
|
|
283
|
+
"blocked": len(result.blocked),
|
|
284
|
+
"over_limit": result.over_limit_count,
|
|
285
|
+
}, ensure_ascii=False))
|
|
286
|
+
return 0
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
if __name__ == "__main__":
|
|
290
|
+
raise SystemExit(main())
|
package/src/plugins/protocol.py
CHANGED
|
@@ -157,7 +157,10 @@ def _requires_external_real_world_check(task: dict, *parts: str) -> bool:
|
|
|
157
157
|
if str(task.get("task_type") or "").strip() not in ACTION_TASKS:
|
|
158
158
|
return False
|
|
159
159
|
text = _external_real_world_text(task, *parts)
|
|
160
|
-
return any(
|
|
160
|
+
return any(
|
|
161
|
+
_contains_external_action_keyword(text, keyword)
|
|
162
|
+
for keyword in EXTERNAL_REAL_WORLD_ACTION_KEYWORDS
|
|
163
|
+
)
|
|
161
164
|
|
|
162
165
|
|
|
163
166
|
def _has_external_real_world_evidence(text: str) -> bool:
|
|
@@ -169,6 +172,18 @@ def _has_external_real_world_evidence(text: str) -> bool:
|
|
|
169
172
|
return has_verify_verb and has_artifact
|
|
170
173
|
|
|
171
174
|
|
|
175
|
+
def _contains_external_action_keyword(text: str, keyword: str) -> bool:
|
|
176
|
+
clean_text = str(text or "").lower()
|
|
177
|
+
clean_keyword = str(keyword or "").lower().strip()
|
|
178
|
+
if not clean_text or not clean_keyword:
|
|
179
|
+
return False
|
|
180
|
+
return re.search(
|
|
181
|
+
rf"(?<![a-z0-9]){re.escape(clean_keyword)}(?![a-z0-9])",
|
|
182
|
+
clean_text,
|
|
183
|
+
re.IGNORECASE,
|
|
184
|
+
) is not None
|
|
185
|
+
|
|
186
|
+
|
|
172
187
|
ACTION_TASKS = {"edit", "execute", "delegate"}
|
|
173
188
|
RESPONSE_TASKS = {"answer", "analyze"}
|
|
174
189
|
_GUARD_TOUCH_DEBT_TYPES = {
|
|
@@ -1146,13 +1161,16 @@ def _capture_learning(
|
|
|
1146
1161
|
content: str,
|
|
1147
1162
|
reasoning: str,
|
|
1148
1163
|
priority: str = "high",
|
|
1164
|
+
prevention: str = "",
|
|
1165
|
+
applies_to_override: str = "",
|
|
1166
|
+
source_authority: str = "explicit_instruction",
|
|
1149
1167
|
) -> dict:
|
|
1150
1168
|
from tools_learnings import find_conflicting_active_learning, handle_learning_add
|
|
1151
1169
|
|
|
1152
1170
|
clean_title = (title or "").strip()[:120]
|
|
1153
1171
|
clean_content = (content or "").strip()
|
|
1154
1172
|
clean_reasoning = (reasoning or f"Captured from protocol task {task_id}").strip()
|
|
1155
|
-
applies_to = ",".join(effective_files)
|
|
1173
|
+
applies_to = applies_to_override.strip() if applies_to_override.strip() else ",".join(effective_files)
|
|
1156
1174
|
if not clean_title or not clean_content:
|
|
1157
1175
|
return {"ok": False, "error": "insufficient context for learning capture"}
|
|
1158
1176
|
|
|
@@ -1168,9 +1186,11 @@ def _capture_learning(
|
|
|
1168
1186
|
title=clean_title,
|
|
1169
1187
|
content=clean_content,
|
|
1170
1188
|
reasoning=clean_reasoning,
|
|
1189
|
+
prevention=prevention,
|
|
1171
1190
|
applies_to=applies_to,
|
|
1172
1191
|
priority=priority,
|
|
1173
1192
|
supersedes_id=supersedes_id,
|
|
1193
|
+
source_authority=source_authority,
|
|
1174
1194
|
)
|
|
1175
1195
|
match = re.search(r"Learning #(\d+) added", response)
|
|
1176
1196
|
if match:
|
|
@@ -1180,6 +1200,20 @@ def _capture_learning(
|
|
|
1180
1200
|
"response": response,
|
|
1181
1201
|
"superseded_id": supersedes_id or None,
|
|
1182
1202
|
}
|
|
1203
|
+
# A near/exact duplicate is a SUCCESSFUL no-op merge — the learning already
|
|
1204
|
+
# exists and no duplicate row was created (handle_learning_add returns
|
|
1205
|
+
# "already exists" / "resolved as merge"). Treat it as success so idempotent
|
|
1206
|
+
# re-captures (e.g. the same self-detected error twice) do not report a
|
|
1207
|
+
# phantom learning_ok=False in the close-response telemetry.
|
|
1208
|
+
dedup = re.search(r"Learning #(\d+) (?:already exists|resolved as merge)", response)
|
|
1209
|
+
if dedup:
|
|
1210
|
+
return {
|
|
1211
|
+
"ok": True,
|
|
1212
|
+
"deduped": True,
|
|
1213
|
+
"id": int(dedup.group(1)),
|
|
1214
|
+
"response": response,
|
|
1215
|
+
"superseded_id": supersedes_id or None,
|
|
1216
|
+
}
|
|
1183
1217
|
return {
|
|
1184
1218
|
"ok": False,
|
|
1185
1219
|
"error": response,
|
|
@@ -1217,6 +1251,136 @@ def _auto_capture_learning(task: dict, task_id: str, effective_files: list[str],
|
|
|
1217
1251
|
)
|
|
1218
1252
|
|
|
1219
1253
|
|
|
1254
|
+
# ── Forgotten-step followup detector (objective omission markers) ──────
|
|
1255
|
+
_FORGOTTEN_STEP_FOLLOWUP_RE = re.compile(
|
|
1256
|
+
r"\b(?:forgot|forgotten|missed|omitted|never (?:created|added|set up|configured|deployed|ran)|"
|
|
1257
|
+
r"missing (?:the )?(?:cron|step|trigger|hook|migration|index|webhook|deploy)|"
|
|
1258
|
+
r"olvid[éeè]|me olvid[éeè]|falt[óoa]ba?|no se (?:cre[óo]|configur[óo]|despleg[óo]|registr[óo]))\b",
|
|
1259
|
+
re.IGNORECASE,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _followup_signals_forgotten_step(*descriptions: object) -> bool:
|
|
1264
|
+
"""True only when a followup description objectively states an omission.
|
|
1265
|
+
|
|
1266
|
+
A generic 'verify weekly' or 'monitor X' followup must NOT count — only an
|
|
1267
|
+
explicit 'forgot/missing/never created the cron' style description does.
|
|
1268
|
+
"""
|
|
1269
|
+
for desc in descriptions:
|
|
1270
|
+
text = str(desc or "").strip()
|
|
1271
|
+
if text and _FORGOTTEN_STEP_FOLLOWUP_RE.search(text):
|
|
1272
|
+
return True
|
|
1273
|
+
return False
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _detect_and_capture_self_error(
|
|
1277
|
+
task: dict,
|
|
1278
|
+
task_id: str,
|
|
1279
|
+
*,
|
|
1280
|
+
clean_outcome: str,
|
|
1281
|
+
closure_text: str,
|
|
1282
|
+
correction: bool,
|
|
1283
|
+
effective_files: list[str],
|
|
1284
|
+
forgotten_step_followup: bool,
|
|
1285
|
+
debts_created: list[dict],
|
|
1286
|
+
) -> dict | None:
|
|
1287
|
+
"""Ola 2 — auto-detect that a PRIOR own action was wrong and learn from it.
|
|
1288
|
+
|
|
1289
|
+
Runs AFTER the current task is closed. Compares it against recently
|
|
1290
|
+
closed-as-done tasks; on high-confidence objective evidence it creates a
|
|
1291
|
+
learning with a concrete prevention rule (source_authority=code_test_evidence,
|
|
1292
|
+
NOT a Francisco correction). On low confidence it records a low-confidence
|
|
1293
|
+
candidate as an INFO protocol_debt — never a learning. Best-effort: any
|
|
1294
|
+
failure returns None and never blocks the close.
|
|
1295
|
+
|
|
1296
|
+
Returns a small dict describing what happened (for the close response), or
|
|
1297
|
+
None when nothing was detected / on error.
|
|
1298
|
+
"""
|
|
1299
|
+
try:
|
|
1300
|
+
import self_error_detector as sed
|
|
1301
|
+
from db import list_recent_closed_tasks
|
|
1302
|
+
|
|
1303
|
+
# Only closes that actually claim progress can host / reveal a self-error.
|
|
1304
|
+
if clean_outcome not in {"done", "partial"}:
|
|
1305
|
+
return None
|
|
1306
|
+
|
|
1307
|
+
prior_tasks = list_recent_closed_tasks(
|
|
1308
|
+
outcome="done",
|
|
1309
|
+
exclude_task_id=task_id,
|
|
1310
|
+
within_days=sed.LOOKBACK_DAYS,
|
|
1311
|
+
limit=sed.MAX_PRIOR_TASKS,
|
|
1312
|
+
)
|
|
1313
|
+
if not prior_tasks:
|
|
1314
|
+
# Nothing previously declared done → cannot have a revealed self-error
|
|
1315
|
+
# from file overlap. A forgotten-step followup alone is candidate-only.
|
|
1316
|
+
if not forgotten_step_followup:
|
|
1317
|
+
return None
|
|
1318
|
+
|
|
1319
|
+
evaluation = sed.evaluate_self_error(
|
|
1320
|
+
current_task=task,
|
|
1321
|
+
prior_tasks=prior_tasks,
|
|
1322
|
+
closure_text=closure_text,
|
|
1323
|
+
correction_happened=correction,
|
|
1324
|
+
forgotten_step_followup=forgotten_step_followup,
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
decision = evaluation.get("decision")
|
|
1328
|
+
if decision == "none":
|
|
1329
|
+
return None
|
|
1330
|
+
|
|
1331
|
+
if decision == "candidate":
|
|
1332
|
+
# Low-confidence: record a quiet INFO candidate, NEVER a learning.
|
|
1333
|
+
# Reuses the existing open-debt dedup so the same candidate does not
|
|
1334
|
+
# pile up across repeated closes of the same task.
|
|
1335
|
+
debt = _ensure_open_debt(
|
|
1336
|
+
task.get("session_id", ""),
|
|
1337
|
+
task_id,
|
|
1338
|
+
"self_error_candidate",
|
|
1339
|
+
severity="info",
|
|
1340
|
+
evidence=(
|
|
1341
|
+
f"Low-confidence self-error candidate (confidence="
|
|
1342
|
+
f"{evaluation.get('confidence')}, signal={evaluation.get('signal')}). "
|
|
1343
|
+
f"{'; '.join(evaluation.get('reasons') or [])[:400]}"
|
|
1344
|
+
),
|
|
1345
|
+
debts=debts_created,
|
|
1346
|
+
)
|
|
1347
|
+
return {
|
|
1348
|
+
"decision": "candidate",
|
|
1349
|
+
"confidence": evaluation.get("confidence"),
|
|
1350
|
+
"signal": evaluation.get("signal"),
|
|
1351
|
+
"debt_id": debt.get("id"),
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
# decision == "fire": create the learning with a concrete prevention.
|
|
1355
|
+
payload = sed.build_self_error_learning(current_task=task, evaluation=evaluation)
|
|
1356
|
+
learning = _capture_learning(
|
|
1357
|
+
task,
|
|
1358
|
+
task_id,
|
|
1359
|
+
effective_files,
|
|
1360
|
+
category=payload["category"],
|
|
1361
|
+
title=payload["title"],
|
|
1362
|
+
content=payload["content"],
|
|
1363
|
+
reasoning=payload["reasoning"],
|
|
1364
|
+
priority="high",
|
|
1365
|
+
prevention=payload["prevention"],
|
|
1366
|
+
applies_to_override=payload["applies_to"],
|
|
1367
|
+
source_authority=payload["source_authority"],
|
|
1368
|
+
)
|
|
1369
|
+
return {
|
|
1370
|
+
"decision": "fire",
|
|
1371
|
+
"confidence": evaluation.get("confidence"),
|
|
1372
|
+
"signal": evaluation.get("signal"),
|
|
1373
|
+
"prior_task_id": evaluation.get("prior_task_id"),
|
|
1374
|
+
"overlap_files": evaluation.get("overlap_files"),
|
|
1375
|
+
"learning_ok": bool(learning.get("ok")),
|
|
1376
|
+
"learning_id": learning.get("id"),
|
|
1377
|
+
"learning_error": None if learning.get("ok") else learning.get("error"),
|
|
1378
|
+
}
|
|
1379
|
+
except Exception:
|
|
1380
|
+
# Self-error detection is strictly best-effort; never break a close.
|
|
1381
|
+
return None
|
|
1382
|
+
|
|
1383
|
+
|
|
1220
1384
|
def _append_debt_ref(debts: list[dict], debt: dict, *, debt_type: str, severity: str):
|
|
1221
1385
|
debt_id = debt.get("id")
|
|
1222
1386
|
if debt_id and any(item.get("id") == debt_id for item in debts):
|
|
@@ -2170,31 +2334,30 @@ def handle_task_close(
|
|
|
2170
2334
|
limit=3,
|
|
2171
2335
|
)
|
|
2172
2336
|
if pending_corrections:
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
)
|
|
2183
|
-
debts=debts_created,
|
|
2184
|
-
)
|
|
2185
|
-
return json.dumps(
|
|
2186
|
-
{
|
|
2187
|
-
"ok": False,
|
|
2188
|
-
"error": "Cannot close task while a detected user correction has no durable nexo_learning_add.",
|
|
2189
|
-
"hint": "Call nexo_learning_add with the reusable rule learned from the correction, then retry nexo_task_close.",
|
|
2190
|
-
"task_id": task_id,
|
|
2191
|
-
"blocked_by": "d5_correction_learning_required",
|
|
2192
|
-
"debt_id": debt.get("id"),
|
|
2193
|
-
"pending_corrections": len(pending_corrections),
|
|
2194
|
-
},
|
|
2195
|
-
ensure_ascii=False,
|
|
2196
|
-
indent=2,
|
|
2337
|
+
# SOFT enforcement (Ola 1): do NOT block the close. A detected user
|
|
2338
|
+
# correction without a durable nexo_learning_add opens/dedupes an
|
|
2339
|
+
# error-severity protocol_debt and the task still closes. The daily
|
|
2340
|
+
# self-audit + correction_requirement_summary surface the open debt, and
|
|
2341
|
+
# if THIS close supplies the learning, the `if correction:` block below
|
|
2342
|
+
# captures it and resolves both the requirement and the debt. A hard
|
|
2343
|
+
# block here interrupted the operator on every correction (friction);
|
|
2344
|
+
# the debt is the non-blocking signal instead.
|
|
2345
|
+
learning_in_this_close = bool(
|
|
2346
|
+
(learning_title or "").strip() and (learning_content or "").strip()
|
|
2197
2347
|
)
|
|
2348
|
+
if not learning_in_this_close:
|
|
2349
|
+
_ensure_open_debt(
|
|
2350
|
+
task["session_id"],
|
|
2351
|
+
task_id,
|
|
2352
|
+
"missing_learning_after_correction",
|
|
2353
|
+
severity="error",
|
|
2354
|
+
evidence=(
|
|
2355
|
+
"User correction detected for this session without a durable "
|
|
2356
|
+
"nexo_learning_add; debt opened (soft enforcement) — task closed "
|
|
2357
|
+
"but a follow-up learning is required."
|
|
2358
|
+
),
|
|
2359
|
+
debts=debts_created,
|
|
2360
|
+
)
|
|
2198
2361
|
|
|
2199
2362
|
# ── Evidence enforcement: reject 'done' without proof ──
|
|
2200
2363
|
# G1 hardening: "done" is no longer allowed to degrade into a debt-only
|
|
@@ -2643,6 +2806,25 @@ def handle_task_close(
|
|
|
2643
2806
|
followup_id=created_followup_id,
|
|
2644
2807
|
outcome_notes=outcome_notes,
|
|
2645
2808
|
)
|
|
2809
|
+
|
|
2810
|
+
# ── Ola 2: auto-detect a PRIOR own action that this close reveals as
|
|
2811
|
+
# wrong (e.g. code shipped earlier but the cron was never created). On
|
|
2812
|
+
# high-confidence objective evidence, capture an immediate learning +
|
|
2813
|
+
# prevention rule (source_authority=code_test_evidence, not a Francisco
|
|
2814
|
+
# correction); on low confidence, only a quiet INFO candidate. Strictly
|
|
2815
|
+
# best-effort — runs after the task is already persisted-closed.
|
|
2816
|
+
self_error = _detect_and_capture_self_error(
|
|
2817
|
+
task,
|
|
2818
|
+
task_id,
|
|
2819
|
+
clean_outcome=clean_outcome,
|
|
2820
|
+
closure_text=closure_text,
|
|
2821
|
+
correction=correction,
|
|
2822
|
+
effective_files=effective_files,
|
|
2823
|
+
forgotten_step_followup=_followup_signals_forgotten_step(
|
|
2824
|
+
followup_description, outcome_notes
|
|
2825
|
+
),
|
|
2826
|
+
debts_created=debts_created,
|
|
2827
|
+
)
|
|
2646
2828
|
capture_context_event(
|
|
2647
2829
|
event_type=f"protocol_task_{clean_outcome}",
|
|
2648
2830
|
title=(task.get("goal") or task_id)[:160],
|
|
@@ -2724,10 +2906,17 @@ def handle_task_close(
|
|
|
2724
2906
|
pass # Drive detection is best-effort
|
|
2725
2907
|
|
|
2726
2908
|
open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
|
|
2909
|
+
# The self-error CANDIDATE debt is an informational, non-actionable signal
|
|
2910
|
+
# (low confidence; recorded for audit/dedup, never a learning). It must not
|
|
2911
|
+
# flip an otherwise-clean close into "done_with_debts" — that would be the
|
|
2912
|
+
# exact kind of noise/debt Francisco rejects.
|
|
2913
|
+
status_debts = [
|
|
2914
|
+
debt for debt in open_debts if debt.get("debt_type") != "self_error_candidate"
|
|
2915
|
+
]
|
|
2727
2916
|
|
|
2728
2917
|
status = "clean"
|
|
2729
2918
|
next_action = "Task closed cleanly."
|
|
2730
|
-
if
|
|
2919
|
+
if status_debts:
|
|
2731
2920
|
if clean_outcome == "done":
|
|
2732
2921
|
status = "done_with_debts"
|
|
2733
2922
|
next_action = "Task closed as done, but resolve the open protocol debt next."
|
|
@@ -2779,6 +2968,8 @@ def handle_task_close(
|
|
|
2779
2968
|
"memory_event": memory_event,
|
|
2780
2969
|
"memory_event_ok": bool(memory_event and memory_event.get("ok")),
|
|
2781
2970
|
}
|
|
2971
|
+
if self_error:
|
|
2972
|
+
response["self_error"] = self_error
|
|
2782
2973
|
if durable_checkpoint:
|
|
2783
2974
|
response["durable_checkpoint"] = durable_checkpoint
|
|
2784
2975
|
return json.dumps(response, ensure_ascii=False, indent=2)
|