nexo-brain 7.23.13 → 7.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -11
- package/bin/nexo-brain.js +42 -235
- package/package.json +1 -1
- package/src/automation_supervisor.py +1 -1
- package/src/cli.py +255 -9
- package/src/cognitive_control_observatory.py +224 -0
- package/src/dashboard/app.py +26 -9
- package/src/db/__init__.py +2 -0
- package/src/db/_learnings.py +1 -1
- package/src/db/_memory_v2.py +107 -1
- package/src/db/_protocol.py +2 -2
- package/src/db/_reminders.py +132 -4
- package/src/db/_schema.py +2 -2
- package/src/events_bus.py +4 -5
- package/src/learning_resolver.py +419 -0
- package/src/lifecycle_events.py +9 -9
- package/src/local_context/api.py +67 -5
- package/src/local_context/usage_events.py +24 -0
- package/src/memory_observation_processor.py +28 -0
- package/src/memory_retrieval.py +5 -5
- package/src/operator_language.py +2 -0
- package/src/plugins/backup.py +1 -1
- package/src/plugins/cortex.py +21 -21
- package/src/plugins/episodic_memory.py +11 -11
- package/src/plugins/goal_engine.py +3 -3
- package/src/plugins/personal_scripts.py +75 -0
- package/src/plugins/protocol.py +10 -1
- package/src/pre_answer_router.py +116 -0
- package/src/r_catalog.py +4 -5
- package/src/saved_not_used_audit.py +31 -31
- package/src/script_registry.py +444 -1
- package/src/scripts/deep-sleep/apply_findings.py +79 -17
- package/src/scripts/nexo-daily-self-audit.py +46 -13
- package/src/scripts/nexo-email-migrate-config.py +2 -2
- package/src/scripts/nexo-email-monitor.py +19 -19
- package/src/scripts/nexo-followup-hygiene.py +40 -8
- package/src/scripts/nexo-followup-runner.py +31 -31
- package/src/scripts/nexo-inbox-hook.sh +1 -1
- package/src/scripts/nexo-learning-validator.py +24 -3
- package/src/server.py +73 -1
- package/src/system_catalog.py +31 -31
- package/src/tools_learnings.py +96 -65
- package/src/tools_memory_v2.py +2 -2
- package/src/tools_sessions.py +25 -7
- package/templates/core-prompts/postmortem-consolidator.md +3 -3
- package/templates/core-prompts/r17-promise-debt-injection.md +1 -1
- package/templates/core-prompts/server-mcp-instructions.md +6 -6
- package/tool-enforcement-map.json +143 -13
|
@@ -62,6 +62,7 @@ DB_PATH = data_dir() / "nexo.db"
|
|
|
62
62
|
|
|
63
63
|
from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
|
|
64
64
|
from core_prompts import render_core_prompt
|
|
65
|
+
from learning_resolver import resolve_learning_candidate
|
|
65
66
|
|
|
66
67
|
try:
|
|
67
68
|
from client_preferences import resolve_user_model as _resolve_user_model
|
|
@@ -79,12 +80,12 @@ def get_all_learnings(category: str | None = None) -> list[dict]:
|
|
|
79
80
|
conn.row_factory = sqlite3.Row
|
|
80
81
|
if category:
|
|
81
82
|
rows = conn.execute(
|
|
82
|
-
"SELECT id, category, title, content FROM learnings WHERE category = ?",
|
|
83
|
+
"SELECT id, category, title, content FROM learnings WHERE category = ? AND COALESCE(status, 'active') = 'active'",
|
|
83
84
|
(category,),
|
|
84
85
|
).fetchall()
|
|
85
86
|
else:
|
|
86
87
|
rows = conn.execute(
|
|
87
|
-
"SELECT id, category, title, content FROM learnings"
|
|
88
|
+
"SELECT id, category, title, content FROM learnings WHERE COALESCE(status, 'active') = 'active'"
|
|
88
89
|
).fetchall()
|
|
89
90
|
conn.close()
|
|
90
91
|
return [dict(r) for r in rows]
|
|
@@ -131,6 +132,12 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
131
132
|
"recommendation": str
|
|
132
133
|
}
|
|
133
134
|
"""
|
|
135
|
+
resolver = resolve_learning_candidate(
|
|
136
|
+
category=category or "process",
|
|
137
|
+
title=(finding or "Finding").strip()[:120] or "Finding",
|
|
138
|
+
content=finding or "",
|
|
139
|
+
source_authority="code_test_evidence" if any(token in (finding or "").lower() for token in ("test", "traceback", "stack", "verified", "evidence")) else "inference",
|
|
140
|
+
)
|
|
134
141
|
learnings = get_all_learnings(category)
|
|
135
142
|
|
|
136
143
|
if not learnings:
|
|
@@ -139,6 +146,7 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
139
146
|
"confidence": 0,
|
|
140
147
|
"matching_learnings": [],
|
|
141
148
|
"recommendation": "No learnings in DB — finding is new by default",
|
|
149
|
+
"resolver": resolver,
|
|
142
150
|
}
|
|
143
151
|
|
|
144
152
|
learnings_ref = [
|
|
@@ -168,13 +176,26 @@ def validate_finding(finding: str, category: str | None = None) -> dict:
|
|
|
168
176
|
)
|
|
169
177
|
parsed = _extract_json(result.stdout)
|
|
170
178
|
if result.returncode == 0 and parsed:
|
|
179
|
+
parsed["resolver"] = resolver
|
|
171
180
|
return parsed
|
|
172
181
|
except AutomationBackendUnavailableError:
|
|
173
182
|
pass
|
|
174
183
|
except Exception:
|
|
175
184
|
pass
|
|
176
185
|
|
|
177
|
-
|
|
186
|
+
result = _mechanical_validate(finding, learnings)
|
|
187
|
+
result["resolver"] = resolver
|
|
188
|
+
if resolver.get("action") in {"merge", "supersede", "conflict_review"}:
|
|
189
|
+
result["known"] = True
|
|
190
|
+
result["confidence"] = max(float(result.get("confidence") or 0), float(resolver.get("similarity") or 0.7))
|
|
191
|
+
result["matching_learnings"] = result.get("matching_learnings") or [{
|
|
192
|
+
"id": resolver.get("target_id"),
|
|
193
|
+
"category": category or "process",
|
|
194
|
+
"title": resolver.get("target_title"),
|
|
195
|
+
"similarity": resolver.get("similarity"),
|
|
196
|
+
}]
|
|
197
|
+
result["recommendation"] = f"Resolver action: {resolver.get('action')} ({resolver.get('reason')})"
|
|
198
|
+
return result
|
|
178
199
|
|
|
179
200
|
|
|
180
201
|
def _mechanical_validate(finding: str, learnings: list[dict]) -> dict:
|
package/src/server.py
CHANGED
|
@@ -79,7 +79,7 @@ from tools_reminders_crud import (
|
|
|
79
79
|
from tools_learnings import (
|
|
80
80
|
handle_learning_add, handle_learning_search,
|
|
81
81
|
handle_learning_update, handle_learning_delete, handle_learning_list,
|
|
82
|
-
handle_learning_quality,
|
|
82
|
+
handle_learning_quality, handle_learning_resolve_candidate,
|
|
83
83
|
)
|
|
84
84
|
from tools_credentials import (
|
|
85
85
|
handle_credential_get, handle_credential_create,
|
|
@@ -1089,6 +1089,19 @@ def nexo_context_router(query: str, intent: str = "answer", limit: int = 4, curr
|
|
|
1089
1089
|
return json.dumps(result, ensure_ascii=False)
|
|
1090
1090
|
|
|
1091
1091
|
|
|
1092
|
+
@mcp.tool
|
|
1093
|
+
def nexo_cognitive_control_observatory(window_seconds: int = 86400) -> str:
|
|
1094
|
+
"""Read-only metrics for Local Context, learnings, followups and intraday memory."""
|
|
1095
|
+
from cognitive_control_observatory import build_cognitive_control_observatory
|
|
1096
|
+
|
|
1097
|
+
return json.dumps(
|
|
1098
|
+
build_cognitive_control_observatory(window_seconds=window_seconds),
|
|
1099
|
+
ensure_ascii=False,
|
|
1100
|
+
indent=2,
|
|
1101
|
+
sort_keys=True,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
|
|
1092
1105
|
@mcp.tool
|
|
1093
1106
|
def nexo_local_asset_get(asset_id: str) -> str:
|
|
1094
1107
|
"""Return one indexed local asset by asset id."""
|
|
@@ -1386,6 +1399,23 @@ def nexo_memory_observation_process(limit: int = 25, backfill_limit: int = 100,
|
|
|
1386
1399
|
)
|
|
1387
1400
|
|
|
1388
1401
|
|
|
1402
|
+
@mcp.tool
|
|
1403
|
+
def nexo_intraday_memory_cycle(limit: int = 20, backfill_limit: int = 20, pending_sla_seconds: int = 3600) -> str:
|
|
1404
|
+
"""Run a low-limit daytime memory observation cycle that only publishes evidence-backed intraday facts."""
|
|
1405
|
+
from memory_observation_processor import process_intraday_cycle
|
|
1406
|
+
|
|
1407
|
+
return json.dumps(
|
|
1408
|
+
process_intraday_cycle(
|
|
1409
|
+
process_limit=limit,
|
|
1410
|
+
backfill_limit=backfill_limit,
|
|
1411
|
+
pending_sla_seconds=pending_sla_seconds,
|
|
1412
|
+
),
|
|
1413
|
+
ensure_ascii=False,
|
|
1414
|
+
indent=2,
|
|
1415
|
+
sort_keys=True,
|
|
1416
|
+
)
|
|
1417
|
+
|
|
1418
|
+
|
|
1389
1419
|
@mcp.tool
|
|
1390
1420
|
def nexo_memory_observation_list(
|
|
1391
1421
|
query: str = "",
|
|
@@ -1968,6 +1998,19 @@ def nexo_followup_get(id: str) -> str:
|
|
|
1968
1998
|
return handle_followup_get(id)
|
|
1969
1999
|
|
|
1970
2000
|
|
|
2001
|
+
@mcp.tool
|
|
2002
|
+
def nexo_followup_lifecycle(limit: int = 500) -> str:
|
|
2003
|
+
"""Return followups grouped by lifecycle lane for runner, dashboard and startup parity."""
|
|
2004
|
+
from db import followup_lifecycle_snapshot
|
|
2005
|
+
|
|
2006
|
+
return json.dumps(
|
|
2007
|
+
followup_lifecycle_snapshot(limit=limit),
|
|
2008
|
+
ensure_ascii=False,
|
|
2009
|
+
indent=2,
|
|
2010
|
+
sort_keys=True,
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
|
|
1971
2014
|
@mcp.tool
|
|
1972
2015
|
def nexo_followup_update(
|
|
1973
2016
|
id: str,
|
|
@@ -2066,6 +2109,7 @@ def nexo_learning_add(
|
|
|
2066
2109
|
review_days: int = 30,
|
|
2067
2110
|
priority: str = "medium",
|
|
2068
2111
|
supersedes_id: int = 0,
|
|
2112
|
+
source_authority: str = "explicit_instruction",
|
|
2069
2113
|
) -> str:
|
|
2070
2114
|
"""Add a new learning (resolved error, pattern, gotcha).
|
|
2071
2115
|
|
|
@@ -2079,11 +2123,39 @@ def nexo_learning_add(
|
|
|
2079
2123
|
review_days: Days until this learning should be reviewed again (default 30).
|
|
2080
2124
|
priority: critical, high, medium, low (default: medium). Critical/high never decay below floor.
|
|
2081
2125
|
supersedes_id: Existing learning ID this new canonical rule replaces (optional).
|
|
2126
|
+
source_authority: Authority tier for conflict resolution: francisco_correction, explicit_instruction, code_test_evidence, deep_sleep, inference.
|
|
2082
2127
|
"""
|
|
2083
2128
|
return handle_learning_add(
|
|
2084
2129
|
category, title, content, reasoning,
|
|
2085
2130
|
prevention=prevention, applies_to=applies_to,
|
|
2086
2131
|
review_days=review_days, priority=priority, supersedes_id=supersedes_id,
|
|
2132
|
+
source_authority=source_authority,
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
@mcp.tool
|
|
2137
|
+
def nexo_learning_resolve_candidate(
|
|
2138
|
+
category: str,
|
|
2139
|
+
title: str,
|
|
2140
|
+
content: str,
|
|
2141
|
+
reasoning: str = "",
|
|
2142
|
+
prevention: str = "",
|
|
2143
|
+
applies_to: str = "",
|
|
2144
|
+
priority: str = "medium",
|
|
2145
|
+
supersedes_id: int = 0,
|
|
2146
|
+
source_authority: str = "inference",
|
|
2147
|
+
) -> str:
|
|
2148
|
+
"""Dry-run the canonical learning resolver without creating or updating learnings."""
|
|
2149
|
+
return handle_learning_resolve_candidate(
|
|
2150
|
+
category=category,
|
|
2151
|
+
title=title,
|
|
2152
|
+
content=content,
|
|
2153
|
+
reasoning=reasoning,
|
|
2154
|
+
prevention=prevention,
|
|
2155
|
+
applies_to=applies_to,
|
|
2156
|
+
priority=priority,
|
|
2157
|
+
supersedes_id=supersedes_id,
|
|
2158
|
+
source_authority=source_authority,
|
|
2087
2159
|
)
|
|
2088
2160
|
|
|
2089
2161
|
|
package/src/system_catalog.py
CHANGED
|
@@ -262,110 +262,110 @@ def _guide_for_tool(name: str) -> dict[str, list]:
|
|
|
262
262
|
if name == "nexo_learning_add":
|
|
263
263
|
return {
|
|
264
264
|
"workflow": [
|
|
265
|
-
"
|
|
266
|
-
"
|
|
265
|
+
"Use `applies_to` if you want the pre-edit check to surface this learning before touching a concrete file, directory, or pattern.",
|
|
266
|
+
"Use `priority` (`critical`, `high`, `medium`, `low`) to mark operational severity.",
|
|
267
267
|
],
|
|
268
268
|
"examples": [
|
|
269
269
|
{
|
|
270
|
-
"title": "
|
|
271
|
-
"code": 'nexo_learning_add(category="shopify", title="
|
|
270
|
+
"title": "Minimal learning",
|
|
271
|
+
"code": 'nexo_learning_add(category="shopify", title="Pull before editing", content="Always sync before editing the live theme.")',
|
|
272
272
|
},
|
|
273
273
|
{
|
|
274
|
-
"title": "Learning
|
|
275
|
-
"code": 'nexo_learning_add(category="recambios-bmw", title="Pull
|
|
274
|
+
"title": "Learning linked to a file or pattern",
|
|
275
|
+
"code": 'nexo_learning_add(category="recambios-bmw", title="Pull before editing theme", content="The admin can touch live JSON files.", applies_to="/abs/path/templates/product.json,templates/*.json,sections/*.liquid", prevention="Run `shopify theme pull` before editing.", priority="high")',
|
|
276
276
|
},
|
|
277
277
|
],
|
|
278
278
|
"common_errors": [
|
|
279
|
-
"
|
|
280
|
-
"
|
|
281
|
-
"
|
|
279
|
+
"Using `severity` instead of `priority`.",
|
|
280
|
+
"Omitting `title`, which is required.",
|
|
281
|
+
"Omitting `applies_to` when you want the warning to appear before touching concrete files.",
|
|
282
282
|
],
|
|
283
283
|
}
|
|
284
284
|
if name == "nexo_learning_update":
|
|
285
285
|
return {
|
|
286
286
|
"workflow": [
|
|
287
|
-
"
|
|
287
|
+
"Use it to complete or harden an existing learning when you discover newly affected files, a better `prevention`, or a different priority.",
|
|
288
288
|
],
|
|
289
289
|
"examples": [
|
|
290
290
|
{
|
|
291
|
-
"title": "
|
|
292
|
-
"code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="
|
|
291
|
+
"title": "Add scope to an existing learning",
|
|
292
|
+
"code": 'nexo_learning_update(id=57, applies_to="/abs/path/file.py,src/plugins/*.py", prevention="Read the schema before first use", priority="high")',
|
|
293
293
|
},
|
|
294
294
|
],
|
|
295
295
|
"common_errors": [
|
|
296
|
-
"
|
|
296
|
+
"Recreating the learning from scratch when updating the existing one is enough.",
|
|
297
297
|
],
|
|
298
298
|
}
|
|
299
299
|
if name == "nexo_reminder_get":
|
|
300
300
|
return {
|
|
301
301
|
"workflow": [
|
|
302
|
-
"
|
|
302
|
+
"Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that reminder.",
|
|
303
303
|
],
|
|
304
304
|
"examples": [
|
|
305
305
|
{
|
|
306
|
-
"title": "
|
|
306
|
+
"title": "Read reminder and get token",
|
|
307
307
|
"code": 'nexo_reminder_get(id="R87")',
|
|
308
308
|
},
|
|
309
309
|
],
|
|
310
310
|
"common_errors": [
|
|
311
|
-
"
|
|
311
|
+
"Trying to edit or delete a reminder without calling `nexo_reminder_get` first.",
|
|
312
312
|
],
|
|
313
313
|
}
|
|
314
314
|
if name in {"nexo_reminder_update", "nexo_reminder_delete", "nexo_reminder_restore", "nexo_reminder_note"}:
|
|
315
315
|
return {
|
|
316
316
|
"workflow": [
|
|
317
|
-
"
|
|
318
|
-
f"
|
|
317
|
+
"First call `nexo_reminder_get(id=\"R87\")` to obtain `READ_TOKEN`.",
|
|
318
|
+
f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
|
|
319
319
|
],
|
|
320
320
|
"examples": [
|
|
321
321
|
{
|
|
322
|
-
"title": "1.
|
|
322
|
+
"title": "1. Get token",
|
|
323
323
|
"code": 'nexo_reminder_get(id="R87")',
|
|
324
324
|
},
|
|
325
325
|
{
|
|
326
|
-
"title": "2.
|
|
326
|
+
"title": "2. Reuse READ_TOKEN",
|
|
327
327
|
"code": f'{name}(id="R87", read_token="TOKEN")',
|
|
328
328
|
},
|
|
329
329
|
],
|
|
330
330
|
"common_errors": [
|
|
331
|
-
"
|
|
332
|
-
"
|
|
331
|
+
"Calling this tool without a valid `READ_TOKEN`.",
|
|
332
|
+
"Using a `READ_TOKEN` from another reminder or an older read.",
|
|
333
333
|
],
|
|
334
334
|
}
|
|
335
335
|
if name == "nexo_followup_get":
|
|
336
336
|
return {
|
|
337
337
|
"workflow": [
|
|
338
|
-
"
|
|
338
|
+
"Returns the `READ_TOKEN` required for `update`, `delete`, `restore`, and `note` on that followup.",
|
|
339
339
|
],
|
|
340
340
|
"examples": [
|
|
341
341
|
{
|
|
342
|
-
"title": "
|
|
342
|
+
"title": "Read followup and get token",
|
|
343
343
|
"code": 'nexo_followup_get(id="NF45")',
|
|
344
344
|
},
|
|
345
345
|
],
|
|
346
346
|
"common_errors": [
|
|
347
|
-
"
|
|
347
|
+
"Trying to edit or delete a followup without calling `nexo_followup_get` first.",
|
|
348
348
|
],
|
|
349
349
|
}
|
|
350
350
|
if name in {"nexo_followup_update", "nexo_followup_delete", "nexo_followup_restore", "nexo_followup_note"}:
|
|
351
351
|
return {
|
|
352
352
|
"workflow": [
|
|
353
|
-
"
|
|
354
|
-
f"
|
|
353
|
+
"First call `nexo_followup_get(id=\"NF45\")` to obtain `READ_TOKEN`.",
|
|
354
|
+
f"Then reuse that `READ_TOKEN` in `{name}(...)`.",
|
|
355
355
|
],
|
|
356
356
|
"examples": [
|
|
357
357
|
{
|
|
358
|
-
"title": "1.
|
|
358
|
+
"title": "1. Get token",
|
|
359
359
|
"code": 'nexo_followup_get(id="NF45")',
|
|
360
360
|
},
|
|
361
361
|
{
|
|
362
|
-
"title": "2.
|
|
362
|
+
"title": "2. Reuse READ_TOKEN",
|
|
363
363
|
"code": f'{name}(id="NF45", read_token="TOKEN")',
|
|
364
364
|
},
|
|
365
365
|
],
|
|
366
366
|
"common_errors": [
|
|
367
|
-
"
|
|
368
|
-
"
|
|
367
|
+
"Calling this tool without a valid `READ_TOKEN`.",
|
|
368
|
+
"Using a `READ_TOKEN` from another followup or an older read.",
|
|
369
369
|
],
|
|
370
370
|
}
|
|
371
371
|
return {}
|
package/src/tools_learnings.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""Learnings CRUD tools: add, search, update, delete, list."""
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import os
|
|
4
5
|
import re
|
|
6
|
+
import unicodedata
|
|
5
7
|
from datetime import datetime
|
|
6
8
|
|
|
7
9
|
from db import (create_learning, update_learning, delete_learning, search_learnings,
|
|
8
10
|
list_learnings, find_similar_learnings, get_db, now_epoch, supersede_learning, extract_keywords,
|
|
9
11
|
resolve_session_correction_requirements)
|
|
12
|
+
from learning_resolver import (
|
|
13
|
+
applies_overlap as _canonical_applies_overlap,
|
|
14
|
+
looks_contradictory as _canonical_looks_contradictory,
|
|
15
|
+
resolve_learning_candidate,
|
|
16
|
+
)
|
|
10
17
|
|
|
11
18
|
NEGATION_PATTERNS = (
|
|
12
19
|
"do not", "don't", "never", "avoid", "skip", "without", "forbid", "forbidden",
|
|
13
20
|
"disable", "disabled", "remove", "ban", "bypass",
|
|
21
|
+
" no ", " nunca ", " evita ", " evitar ", " sin ", " prohibe ", " prohibido ",
|
|
22
|
+
" desactiva ", " desactivar ", " elimina ", " eliminar ", " bloquea ", " bloquear ",
|
|
14
23
|
)
|
|
15
24
|
CONTRADICTION_PAIRS = (
|
|
16
25
|
("enable", "disable"),
|
|
@@ -23,6 +32,15 @@ CONTRADICTION_PAIRS = (
|
|
|
23
32
|
("validate", "skip"),
|
|
24
33
|
("validate", "bypass"),
|
|
25
34
|
("include", "exclude"),
|
|
35
|
+
("activar", "desactivar"),
|
|
36
|
+
("usar", "evitar"),
|
|
37
|
+
("usar", "no usar"),
|
|
38
|
+
("editar", "no editar"),
|
|
39
|
+
("tocar", "no tocar"),
|
|
40
|
+
("anadir", "eliminar"),
|
|
41
|
+
("permitir", "prohibir"),
|
|
42
|
+
("validar", "saltar"),
|
|
43
|
+
("incluir", "excluir"),
|
|
26
44
|
)
|
|
27
45
|
|
|
28
46
|
|
|
@@ -40,26 +58,13 @@ def _normalize_applies_token(value: str) -> str:
|
|
|
40
58
|
|
|
41
59
|
|
|
42
60
|
def _applies_overlap(left: str, right: str) -> bool:
|
|
43
|
-
|
|
44
|
-
right_tokens = {_normalize_applies_token(item) for item in _split_applies_to(right)}
|
|
45
|
-
left_tokens.discard("")
|
|
46
|
-
right_tokens.discard("")
|
|
47
|
-
if not left_tokens or not right_tokens:
|
|
48
|
-
return False
|
|
49
|
-
if left_tokens & right_tokens:
|
|
50
|
-
return True
|
|
51
|
-
for left_token in left_tokens:
|
|
52
|
-
for right_token in right_tokens:
|
|
53
|
-
if "/" in left_token or "/" in right_token:
|
|
54
|
-
if left_token.startswith(f"{right_token}/") or right_token.startswith(f"{left_token}/"):
|
|
55
|
-
return True
|
|
56
|
-
if left_token.endswith(f"/{right_token}") or right_token.endswith(f"/{left_token}"):
|
|
57
|
-
return True
|
|
58
|
-
return False
|
|
61
|
+
return _canonical_applies_overlap(left, right)
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def _normalize_text(text: str) -> str:
|
|
62
|
-
|
|
65
|
+
normalized = unicodedata.normalize("NFKD", str(text or ""))
|
|
66
|
+
ascii_text = "".join(ch for ch in normalized if not unicodedata.combining(ch))
|
|
67
|
+
return re.sub(r"\s+", " ", ascii_text.strip().lower())
|
|
63
68
|
|
|
64
69
|
|
|
65
70
|
def _tokenize(text: str) -> list[str]:
|
|
@@ -67,7 +72,7 @@ def _tokenize(text: str) -> list[str]:
|
|
|
67
72
|
|
|
68
73
|
|
|
69
74
|
def _contains_negation(text: str) -> bool:
|
|
70
|
-
lowered = _normalize_text(text)
|
|
75
|
+
lowered = f" {_normalize_text(text)} "
|
|
71
76
|
return any(token in lowered for token in NEGATION_PATTERNS)
|
|
72
77
|
|
|
73
78
|
|
|
@@ -75,37 +80,16 @@ def _negated_action_verbs(text: str) -> set[str]:
|
|
|
75
80
|
lowered = _normalize_text(text)
|
|
76
81
|
matches = set()
|
|
77
82
|
for pattern in (
|
|
78
|
-
r"(?:never|avoid|skip|disable|remove|forbid|bypass)\s+([a-z0-9_-]+)",
|
|
79
|
-
r"(?:do not|don't)\s+([a-z0-9_-]+)",
|
|
83
|
+
r"(?:never|avoid|skip|disable|remove|forbid|bypass|nunca|evita|evitar|desactiva|desactivar|elimina|eliminar|prohibe|prohibir|bloquea|bloquear)\s+([a-z0-9_-]+)",
|
|
84
|
+
r"(?:do not|don't|no)\s+([a-z0-9_-]+)",
|
|
85
|
+
r"(?:without|sin)\s+([a-z0-9_-]+)",
|
|
80
86
|
):
|
|
81
87
|
matches.update(re.findall(pattern, lowered))
|
|
82
88
|
return {match for match in matches if len(match) > 2}
|
|
83
89
|
|
|
84
90
|
|
|
85
91
|
def _looks_contradictory(existing_text: str, new_text: str) -> bool:
|
|
86
|
-
|
|
87
|
-
new_norm = _normalize_text(new_text)
|
|
88
|
-
if not existing_norm or not new_norm:
|
|
89
|
-
return False
|
|
90
|
-
existing_tokens = set(_tokenize(existing_norm))
|
|
91
|
-
new_tokens = set(_tokenize(new_norm))
|
|
92
|
-
if not (existing_tokens & new_tokens):
|
|
93
|
-
return False
|
|
94
|
-
existing_negated_verbs = _negated_action_verbs(existing_norm)
|
|
95
|
-
new_negated_verbs = _negated_action_verbs(new_norm)
|
|
96
|
-
if existing_negated_verbs & new_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
97
|
-
return True
|
|
98
|
-
if new_negated_verbs & existing_tokens and not existing_negated_verbs & new_negated_verbs:
|
|
99
|
-
return True
|
|
100
|
-
if _contains_negation(existing_norm) != _contains_negation(new_norm):
|
|
101
|
-
return True
|
|
102
|
-
for positive, negative in CONTRADICTION_PAIRS:
|
|
103
|
-
existing_has_pair = positive in existing_norm or negative in existing_norm
|
|
104
|
-
new_has_pair = positive in new_norm or negative in new_norm
|
|
105
|
-
if existing_has_pair and new_has_pair:
|
|
106
|
-
if (positive in existing_norm and negative in new_norm) or (negative in existing_norm and positive in new_norm):
|
|
107
|
-
return True
|
|
108
|
-
return False
|
|
92
|
+
return _canonical_looks_contradictory(existing_text, new_text)
|
|
109
93
|
|
|
110
94
|
|
|
111
95
|
def _find_conflicting_active_learning(conn, *, category: str, title: str, content: str,
|
|
@@ -286,7 +270,8 @@ def score_learning_quality(row: dict, conn=None) -> dict:
|
|
|
286
270
|
|
|
287
271
|
def handle_learning_add(category: str, title: str, content: str, reasoning: str = '',
|
|
288
272
|
prevention: str = '', applies_to: str = '', review_days: int = 30,
|
|
289
|
-
priority: str = 'medium', supersedes_id: int = 0
|
|
273
|
+
priority: str = 'medium', supersedes_id: int = 0,
|
|
274
|
+
source_authority: str = 'explicit_instruction') -> str:
|
|
290
275
|
"""Add a new learning entry to the specified category.
|
|
291
276
|
|
|
292
277
|
Args:
|
|
@@ -304,15 +289,55 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
304
289
|
category = category.lower().strip()
|
|
305
290
|
if not category:
|
|
306
291
|
return "ERROR: Category cannot be empty."
|
|
307
|
-
# Dedup guard: block exact title duplicates in same category
|
|
308
292
|
conn = get_db()
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
293
|
+
resolution = resolve_learning_candidate(
|
|
294
|
+
category=category,
|
|
295
|
+
title=title,
|
|
296
|
+
content=content,
|
|
297
|
+
reasoning=reasoning,
|
|
298
|
+
prevention=prevention,
|
|
299
|
+
applies_to=applies_to,
|
|
300
|
+
priority=priority,
|
|
301
|
+
supersedes_id=supersedes_id,
|
|
302
|
+
source_authority=source_authority,
|
|
303
|
+
conn=conn,
|
|
304
|
+
)
|
|
305
|
+
if resolution["action"] == "reject":
|
|
306
|
+
return f"ERROR: Learning candidate rejected: {resolution['reason']}."
|
|
307
|
+
if resolution["action"] == "merge":
|
|
308
|
+
existing_id = int(resolution.get("target_id") or 0)
|
|
309
|
+
existing = conn.execute("SELECT id, title, weight FROM learnings WHERE id = ?", (existing_id,)).fetchone()
|
|
310
|
+
if existing:
|
|
311
|
+
if resolution.get("reason") == "exact_title_duplicate":
|
|
312
|
+
_resolve_pending_correction_learning(existing_id)
|
|
313
|
+
return f"Learning #{existing['id']} already exists with same title in {category}: {existing['title']}. Use nexo_learning_update to modify it."
|
|
314
|
+
old_weight = float(existing["weight"] or 0.0)
|
|
315
|
+
new_weight = min(1.0, old_weight + 0.1)
|
|
316
|
+
conn.execute(
|
|
317
|
+
"UPDATE learnings SET weight = ?, updated_at = ? WHERE id = ?",
|
|
318
|
+
(new_weight, now_epoch(), existing_id),
|
|
319
|
+
)
|
|
320
|
+
conn.commit()
|
|
321
|
+
_resolve_pending_correction_learning(existing_id)
|
|
322
|
+
return (
|
|
323
|
+
f"Learning #{existing_id} resolved as merge ({resolution['reason']}, similarity "
|
|
324
|
+
f"{float(resolution.get('similarity') or 0):.2f}). No duplicate created. "
|
|
325
|
+
f"Weight bumped {old_weight:.2f} -> {new_weight:.2f}. Use nexo_learning_update(id={existing_id}) "
|
|
326
|
+
"to refine the canonical text."
|
|
327
|
+
)
|
|
328
|
+
if resolution["action"] == "conflict_review":
|
|
329
|
+
conflicting = {
|
|
330
|
+
"id": resolution.get("target_id"),
|
|
331
|
+
"title": resolution.get("target_title"),
|
|
332
|
+
"applies_to": applies_to,
|
|
333
|
+
}
|
|
334
|
+
return (
|
|
335
|
+
f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
|
|
336
|
+
f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
|
|
337
|
+
f"Supersede or update the existing canonical rule instead of creating two active file rules."
|
|
338
|
+
)
|
|
339
|
+
if resolution["action"] == "supersede":
|
|
340
|
+
supersedes_id = int(resolution.get("target_id") or supersedes_id or 0)
|
|
316
341
|
|
|
317
342
|
# ── R05 (Fase 2 Protocol Enforcer): auto-merge on high Jaccard similarity ──
|
|
318
343
|
# When a near-duplicate active learning exists (Jaccard >= R05 threshold),
|
|
@@ -356,19 +381,6 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
356
381
|
f"→ {new_weight:.2f}. Use nexo_learning_update(id={existing_id}) if you need to "
|
|
357
382
|
"refine the canonical text."
|
|
358
383
|
)
|
|
359
|
-
conflicting = _find_conflicting_active_learning(
|
|
360
|
-
conn,
|
|
361
|
-
category=category,
|
|
362
|
-
title=title,
|
|
363
|
-
content=content,
|
|
364
|
-
applies_to=applies_to,
|
|
365
|
-
)
|
|
366
|
-
if conflicting and int(supersedes_id or 0) != int(conflicting["id"]):
|
|
367
|
-
return (
|
|
368
|
-
f"ERROR: Contradictory active learning #{conflicting['id']} already exists for applies_to="
|
|
369
|
-
f"{conflicting.get('applies_to', '')}: {conflicting['title']}. "
|
|
370
|
-
f"Supersede or update the existing canonical rule instead of creating two active file rules."
|
|
371
|
-
)
|
|
372
384
|
result = create_learning(
|
|
373
385
|
category, title, content, reasoning=reasoning, supersedes_id=(int(supersedes_id) if supersedes_id else None)
|
|
374
386
|
)
|
|
@@ -508,6 +520,25 @@ def handle_learning_add(category: str, title: str, content: str, reasoning: str
|
|
|
508
520
|
return f"Learning #{result['id']} added in {category}: {title}{meta_str} ✓verified{repetition_msg}{retro_meta_msg}{correction_msg}"
|
|
509
521
|
|
|
510
522
|
|
|
523
|
+
def handle_learning_resolve_candidate(category: str, title: str, content: str, reasoning: str = '',
|
|
524
|
+
prevention: str = '', applies_to: str = '',
|
|
525
|
+
priority: str = 'medium', supersedes_id: int = 0,
|
|
526
|
+
source_authority: str = 'inference') -> str:
|
|
527
|
+
"""Dry-run the canonical learning resolver without mutating state."""
|
|
528
|
+
result = resolve_learning_candidate(
|
|
529
|
+
category=category,
|
|
530
|
+
title=title,
|
|
531
|
+
content=content,
|
|
532
|
+
reasoning=reasoning,
|
|
533
|
+
prevention=prevention,
|
|
534
|
+
applies_to=applies_to,
|
|
535
|
+
priority=priority,
|
|
536
|
+
supersedes_id=supersedes_id,
|
|
537
|
+
source_authority=source_authority,
|
|
538
|
+
)
|
|
539
|
+
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
540
|
+
|
|
541
|
+
|
|
511
542
|
def handle_learning_search(query: str, category: str = '') -> str:
|
|
512
543
|
"""Search learnings by query string, optionally filtered by category."""
|
|
513
544
|
results = search_learnings(query, category if category else None)
|
package/src/tools_memory_v2.py
CHANGED
|
@@ -173,8 +173,8 @@ def handle_memory_timeline(
|
|
|
173
173
|
result = memory_timeline(query, project_hint=project_hint, time_range=time_range, limit=limit)
|
|
174
174
|
candidates = result.get("candidates") or []
|
|
175
175
|
if not candidates:
|
|
176
|
-
return "
|
|
177
|
-
lines = [f"MEMORY TIMELINE ({len(candidates)}) — {query or time_range or '(
|
|
176
|
+
return "There are not enough events to build a timeline."
|
|
177
|
+
lines = [f"MEMORY TIMELINE ({len(candidates)}) — {query or time_range or '(no query)'}"]
|
|
178
178
|
for item in candidates:
|
|
179
179
|
refs = item.get("evidence_refs") or []
|
|
180
180
|
refs_note = f" refs={', '.join(refs[:3])}" if refs else ""
|
package/src/tools_sessions.py
CHANGED
|
@@ -1334,10 +1334,21 @@ def handle_context_packet(area: str, files: str = "") -> str:
|
|
|
1334
1334
|
parts.append("")
|
|
1335
1335
|
|
|
1336
1336
|
# 3. Active followups for this area
|
|
1337
|
-
|
|
1338
|
-
|
|
1337
|
+
from db import followup_lifecycle_lane, normalize_followup_status
|
|
1338
|
+
|
|
1339
|
+
followup_rows = conn.execute(
|
|
1340
|
+
"SELECT id, description, date, verification, status, owner FROM followups "
|
|
1341
|
+
"WHERE (description LIKE ? OR verification LIKE ?) ORDER BY date ASC LIMIT 50",
|
|
1339
1342
|
(f"%{area}%", f"%{area}%")
|
|
1340
1343
|
).fetchall()
|
|
1344
|
+
followups = []
|
|
1345
|
+
for row in followup_rows:
|
|
1346
|
+
item = dict(row)
|
|
1347
|
+
item["status"] = normalize_followup_status(item.get("status"))
|
|
1348
|
+
if followup_lifecycle_lane(item) == "active":
|
|
1349
|
+
followups.append(item)
|
|
1350
|
+
if len(followups) >= 10:
|
|
1351
|
+
break
|
|
1341
1352
|
if followups:
|
|
1342
1353
|
parts.append("## ACTIVE FOLLOWUPS")
|
|
1343
1354
|
for f in followups:
|
|
@@ -1479,11 +1490,18 @@ def handle_smart_startup_query() -> str:
|
|
|
1479
1490
|
sent_email_block = ""
|
|
1480
1491
|
|
|
1481
1492
|
# 1. Pending followups (what NEXO needs to do)
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1493
|
+
try:
|
|
1494
|
+
from db import followup_lifecycle_snapshot
|
|
1495
|
+
|
|
1496
|
+
active_followups = (followup_lifecycle_snapshot(limit=500).get("lanes") or {}).get("active", [])[:5]
|
|
1497
|
+
for f in active_followups:
|
|
1498
|
+
query_parts.append(str(f.get("description") or "")[:100])
|
|
1499
|
+
except Exception:
|
|
1500
|
+
followups = conn.execute(
|
|
1501
|
+
"SELECT description FROM followups WHERE status = 'PENDING' ORDER BY date ASC LIMIT 5"
|
|
1502
|
+
).fetchall()
|
|
1503
|
+
for f in followups:
|
|
1504
|
+
query_parts.append(f['description'][:100])
|
|
1487
1505
|
|
|
1488
1506
|
# 2. Due reminders (what the user needs to know)
|
|
1489
1507
|
reminders = conn.execute(
|