nexo-brain 7.20.9 → 7.20.11
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 +5 -1
- package/package.json +1 -1
- package/src/local_context/api.py +77 -14
- package/src/local_context/privacy.py +102 -9
- package/src/plugins/protocol.py +15 -2
- package/src/tools_hot_context.py +14 -2
- package/src/tools_sessions.py +10 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.11",
|
|
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,11 @@
|
|
|
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.20.
|
|
21
|
+
Version `7.20.11` is the current packaged-runtime line. Patch release over v7.20.10 — Local Context now starts from real system volume roots plus mounted/removable/network volumes, filters system/cache/app/product artifacts, and injects relevant local evidence automatically into heartbeat, task-open and pre-action context.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.10`: patch release over v7.20.9 — Local Context manual refreshes now reconcile automatic roots every time, so newly mounted disks and upgraded default roots are picked up immediately from Desktop's "comprobar cambios" path.
|
|
24
|
+
|
|
25
|
+
Previously in `7.20.9`: patch release over v7.20.8 — Local Context scans automatic roots at full operational depth, falls back to crontab when Linux/WSL systemd user timers fail, passes Windows AppData email roots into WSL, and blocks Google API keys before HTML cleaning.
|
|
22
26
|
|
|
23
27
|
Previously in `7.20.8`: patch release over v7.20.7 — Local Context recognises Windows Mail package roots and Outlook Mac profile roots as bounded local-email sources instead of rejecting them as generic AppData / Group Containers.
|
|
24
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.11",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
5
|
"description": "NEXO Brain — 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",
|
package/src/local_context/api.py
CHANGED
|
@@ -16,7 +16,7 @@ from db._schema import run_migrations
|
|
|
16
16
|
from . import embeddings
|
|
17
17
|
from .extractors import chunk_text, contains_secret, entities, extract_text, summarize
|
|
18
18
|
from .logging import log_event, tail
|
|
19
|
-
from .privacy import classify_path, is_queryable_path, should_extract, should_skip_file, should_skip_tree
|
|
19
|
+
from .privacy import classify_path, is_local_email_tree, is_queryable_path, should_extract, should_skip_file, should_skip_tree
|
|
20
20
|
from .util import content_hash, json_dumps, json_loads, norm_path, now, quick_fingerprint, redact_path, stable_id, system_label, tokenize
|
|
21
21
|
|
|
22
22
|
LOCAL_INDEX_SERVICE_LABEL = "com.nexo.local-index"
|
|
@@ -29,6 +29,7 @@ DEFAULT_LIVE_FILE_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_FILE_LIMIT",
|
|
|
29
29
|
DEFAULT_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_DEFAULT_DEPTH", "24") or "24")
|
|
30
30
|
DEFAULT_EMAIL_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_EMAIL_ROOT_DEPTH", "24") or "24")
|
|
31
31
|
DEFAULT_MOUNTED_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_MOUNTED_ROOT_DEPTH", "24") or "24")
|
|
32
|
+
DEFAULT_SYSTEM_ROOT_DEPTH = int(os.environ.get("NEXO_LOCAL_INDEX_SYSTEM_ROOT_DEPTH", "24") or "24")
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def ensure_ready() -> None:
|
|
@@ -44,7 +45,7 @@ def _conn():
|
|
|
44
45
|
def add_root(path: str, *, mode: str = "normal", depth: int | None = None) -> dict:
|
|
45
46
|
conn = _conn()
|
|
46
47
|
root_path = norm_path(path)
|
|
47
|
-
if should_skip_tree(root_path):
|
|
48
|
+
if should_skip_tree(root_path) and not _allow_explicit_blocked_root(root_path):
|
|
48
49
|
log_event("warn", "root_rejected_private", "Root rejected by local memory privacy rules", path=redact_path(root_path))
|
|
49
50
|
return {"ok": False, "error": "root_blocked_by_privacy", "root_path": root_path}
|
|
50
51
|
depth_value = 2 if depth is None else int(depth)
|
|
@@ -141,6 +142,18 @@ def _mounted_volume_roots() -> list[str]:
|
|
|
141
142
|
return roots
|
|
142
143
|
|
|
143
144
|
|
|
145
|
+
def _system_volume_roots() -> list[str]:
|
|
146
|
+
if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_SYSTEM_ROOTS", "").strip() in {"1", "true", "yes"}:
|
|
147
|
+
return []
|
|
148
|
+
if sys.platform == "darwin":
|
|
149
|
+
return ["/"]
|
|
150
|
+
if sys.platform.startswith("win"):
|
|
151
|
+
# Windows roots are discovered as mounted drive roots so mapped drives
|
|
152
|
+
# and removable disks share the same code path.
|
|
153
|
+
return []
|
|
154
|
+
return ["/"]
|
|
155
|
+
|
|
156
|
+
|
|
144
157
|
def _local_email_roots() -> list[str]:
|
|
145
158
|
home = Path.home()
|
|
146
159
|
roots: list[Path] = [home / ".nexo" / "runtime" / "nexo-email"]
|
|
@@ -180,14 +193,15 @@ def default_roots() -> list[str]:
|
|
|
180
193
|
def default_root_specs() -> list[tuple[str, int]]:
|
|
181
194
|
home = Path.home()
|
|
182
195
|
configured = os.environ.get("NEXO_LOCAL_INDEX_DEFAULT_ROOTS", "").strip()
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
196
|
+
system_specs = [(root, DEFAULT_SYSTEM_ROOT_DEPTH) for root in _system_volume_roots()]
|
|
197
|
+
mounted_specs = [(root, DEFAULT_MOUNTED_ROOT_DEPTH) for root in _mounted_volume_roots()]
|
|
198
|
+
configured_specs = [(item, DEFAULT_ROOT_DEPTH) for item in configured.split(os.pathsep) if item.strip()]
|
|
199
|
+
base_specs = system_specs + mounted_specs + configured_specs
|
|
200
|
+
if not base_specs:
|
|
201
|
+
base_specs = [(str(home), DEFAULT_ROOT_DEPTH)]
|
|
187
202
|
return _dedupe_root_specs(
|
|
188
|
-
|
|
203
|
+
base_specs
|
|
189
204
|
+ [(root, DEFAULT_EMAIL_ROOT_DEPTH) for root in _local_email_roots()]
|
|
190
|
-
+ [(root, DEFAULT_MOUNTED_ROOT_DEPTH) for root in _mounted_volume_roots()]
|
|
191
205
|
)
|
|
192
206
|
|
|
193
207
|
|
|
@@ -525,6 +539,15 @@ def _is_paused() -> bool:
|
|
|
525
539
|
return _get_state("paused", "0") == "1"
|
|
526
540
|
|
|
527
541
|
|
|
542
|
+
def _allow_explicit_blocked_root(path: str) -> bool:
|
|
543
|
+
# Test and controlled diagnostics may explicitly index a temporary fixture
|
|
544
|
+
# root while production root discovery still skips temp/system trees.
|
|
545
|
+
if os.environ.get("NEXO_LOCAL_INDEX_ALLOW_BLOCKED_ROOTS", "").strip().lower() not in {"1", "true", "yes"}:
|
|
546
|
+
return False
|
|
547
|
+
normalized = norm_path(path).replace("\\", "/").lower()
|
|
548
|
+
return any(marker in normalized for marker in ("/tmp/", "/var/folders/", "/private/var/folders/"))
|
|
549
|
+
|
|
550
|
+
|
|
528
551
|
def _is_excluded(path: str, exclusions: list[str]) -> bool:
|
|
529
552
|
value = norm_path(path)
|
|
530
553
|
return any(value == item or value.startswith(item + os.sep) for item in exclusions)
|
|
@@ -535,6 +558,47 @@ def _path_prefix(path: str) -> str:
|
|
|
535
558
|
return normalized + os.sep if normalized else os.sep
|
|
536
559
|
|
|
537
560
|
|
|
561
|
+
def _is_nested_path(path: str, parent: str) -> bool:
|
|
562
|
+
value = norm_path(path)
|
|
563
|
+
base = norm_path(parent)
|
|
564
|
+
if not value or not base or value == base:
|
|
565
|
+
return False
|
|
566
|
+
if base == os.sep:
|
|
567
|
+
return value.startswith(os.sep)
|
|
568
|
+
return value.startswith(_path_prefix(base))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _is_discovered_mount_path(path: str) -> bool:
|
|
572
|
+
value = norm_path(path).replace("\\", "/").lower()
|
|
573
|
+
if not value:
|
|
574
|
+
return False
|
|
575
|
+
return (
|
|
576
|
+
value.startswith("/volumes/")
|
|
577
|
+
or value.startswith("/mnt/")
|
|
578
|
+
or value.startswith("/media/")
|
|
579
|
+
or value.startswith("/run/media/")
|
|
580
|
+
or (len(value) == 3 and value[1:] == ":/")
|
|
581
|
+
or (len(value) == 3 and value[1:] == ":\\")
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _effective_scan_roots(roots: list[dict]) -> list[dict]:
|
|
586
|
+
active_roots = [root for root in roots if str(root.get("status") or "active") != "removed"]
|
|
587
|
+
parent_paths = [str(root.get("root_path") or "") for root in active_roots]
|
|
588
|
+
effective: list[dict] = []
|
|
589
|
+
for root in active_roots:
|
|
590
|
+
root_path = str(root.get("root_path") or "")
|
|
591
|
+
if _is_discovered_mount_path(root_path):
|
|
592
|
+
effective.append(root)
|
|
593
|
+
continue
|
|
594
|
+
if root_path and not is_local_email_tree(root_path) and any(
|
|
595
|
+
_is_nested_path(root_path, parent) for parent in parent_paths
|
|
596
|
+
):
|
|
597
|
+
continue
|
|
598
|
+
effective.append(root)
|
|
599
|
+
return effective
|
|
600
|
+
|
|
601
|
+
|
|
538
602
|
def _file_type(path: Path) -> str:
|
|
539
603
|
if path.is_dir():
|
|
540
604
|
return "folder"
|
|
@@ -1188,14 +1252,14 @@ def scan_once(*, limit: int | None = None) -> dict:
|
|
|
1188
1252
|
log_event("info", "scan_skipped_paused", "Local memory scan skipped because indexing is paused")
|
|
1189
1253
|
return {"ok": True, "paused": True, "roots": 0, "seen": 0, "changed": 0, "errors": 0, "partial": False}
|
|
1190
1254
|
started = now()
|
|
1191
|
-
roots = list_roots()
|
|
1255
|
+
roots = _effective_scan_roots(list_roots())
|
|
1192
1256
|
exclusions = [row["path"] for row in list_exclusions()]
|
|
1193
1257
|
totals = {"roots": len(roots), "seen": 0, "changed": 0, "errors": 0, "partial": False}
|
|
1194
1258
|
log_event("info", "scan_started", "Local memory scan started", roots=len(roots))
|
|
1195
1259
|
for root in roots:
|
|
1196
1260
|
root_path = Path(root["root_path"]).expanduser()
|
|
1197
1261
|
root_id = int(root["id"])
|
|
1198
|
-
if should_skip_tree(str(root_path)):
|
|
1262
|
+
if should_skip_tree(str(root_path)) and not _allow_explicit_blocked_root(str(root_path)):
|
|
1199
1263
|
conn.execute(
|
|
1200
1264
|
"UPDATE local_index_roots SET status='removed', last_scan_at=?, updated_at=? WHERE id=?",
|
|
1201
1265
|
(now(), now(), root_id),
|
|
@@ -1455,14 +1519,13 @@ def run_once(
|
|
|
1455
1519
|
if _get_state("privacy_hygiene_v2", "0") != "1":
|
|
1456
1520
|
local_index_privacy_hygiene(fix=True)
|
|
1457
1521
|
_set_state("privacy_hygiene_v2", "1")
|
|
1458
|
-
if
|
|
1459
|
-
add_root(root)
|
|
1460
|
-
elif (
|
|
1522
|
+
if (
|
|
1461
1523
|
os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
|
|
1462
1524
|
and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
|
|
1463
|
-
and not list_roots()
|
|
1464
1525
|
):
|
|
1465
1526
|
ensure_default_roots()
|
|
1527
|
+
if root:
|
|
1528
|
+
add_root(root)
|
|
1466
1529
|
live_result = reconcile_live_changes(
|
|
1467
1530
|
asset_limit=live_asset_limit,
|
|
1468
1531
|
dir_limit=live_dir_limit,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
SENSITIVE_FILE_NAMES = {
|
|
@@ -124,6 +125,15 @@ NOISY_PARTS = {
|
|
|
124
125
|
"target",
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
PRODUCT_ARTIFACT_PARTS = {
|
|
129
|
+
"brain-bundle",
|
|
130
|
+
"nexo desktop qa backups",
|
|
131
|
+
"nexo desktop qa.app",
|
|
132
|
+
"nexo desktop.app",
|
|
133
|
+
"nexo desktop beta.app",
|
|
134
|
+
"nexo desktop support.app",
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
TRANSIENT_PARTS = {"tmp", "temp"}
|
|
128
138
|
|
|
129
139
|
PRIVATE_PROFILE_PARTS = {
|
|
@@ -184,10 +194,47 @@ SYSTEM_PARTS = {
|
|
|
184
194
|
"windows",
|
|
185
195
|
"program files",
|
|
186
196
|
"program files (x86)",
|
|
187
|
-
|
|
188
|
-
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
SYSTEM_PATH_MARKERS = {
|
|
200
|
+
"/bin",
|
|
201
|
+
"/cores",
|
|
202
|
+
"/dev",
|
|
203
|
+
"/library/caches",
|
|
204
|
+
"/library/logs",
|
|
205
|
+
"/private/tmp",
|
|
206
|
+
"/private/var/db",
|
|
207
|
+
"/private/var/folders",
|
|
208
|
+
"/private/var/log",
|
|
209
|
+
"/private/var/vm",
|
|
189
210
|
"/proc",
|
|
211
|
+
"/run",
|
|
212
|
+
"/sbin",
|
|
190
213
|
"/sys",
|
|
214
|
+
"/system",
|
|
215
|
+
"/tmp",
|
|
216
|
+
"/usr",
|
|
217
|
+
"/var/folders",
|
|
218
|
+
"/var/tmp",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
TEMP_SYSTEM_PATH_MARKERS = {
|
|
222
|
+
"/private/var/folders",
|
|
223
|
+
"/tmp",
|
|
224
|
+
"/var/folders",
|
|
225
|
+
"/var/tmp",
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
HOME_PRIVATE_PROFILE_MARKERS = {
|
|
229
|
+
"library/application support",
|
|
230
|
+
"library/containers",
|
|
231
|
+
"library/group containers",
|
|
232
|
+
"library/keychains",
|
|
233
|
+
"library/logs",
|
|
234
|
+
"library/mail",
|
|
235
|
+
"library/messages",
|
|
236
|
+
"library/safari",
|
|
237
|
+
"library/saved application state",
|
|
191
238
|
}
|
|
192
239
|
|
|
193
240
|
|
|
@@ -210,6 +257,33 @@ def _is_under_marker(lowered: str, marker: str) -> bool:
|
|
|
210
257
|
return lowered.endswith("/" + marker) or f"/{marker}/" in lowered
|
|
211
258
|
|
|
212
259
|
|
|
260
|
+
def _has_absolute_marker(lowered: str, marker: str) -> bool:
|
|
261
|
+
marker = "/" + marker.strip("/").lower()
|
|
262
|
+
return lowered == marker or lowered.endswith(marker) or f"{marker}/" in lowered
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _is_system_path(path: str) -> bool:
|
|
266
|
+
lowered = _normalized(path)
|
|
267
|
+
parts = _parts(path)
|
|
268
|
+
if parts & SYSTEM_PARTS:
|
|
269
|
+
return True
|
|
270
|
+
matched_markers = {marker for marker in SYSTEM_PATH_MARKERS if _has_absolute_marker(lowered, marker)}
|
|
271
|
+
if not matched_markers:
|
|
272
|
+
return False
|
|
273
|
+
if (
|
|
274
|
+
matched_markers <= TEMP_SYSTEM_PATH_MARKERS
|
|
275
|
+
and os.environ.get("NEXO_LOCAL_INDEX_ALLOW_BLOCKED_ROOTS", "").strip().lower() in {"1", "true", "yes"}
|
|
276
|
+
and "pytest-" in lowered
|
|
277
|
+
):
|
|
278
|
+
return False
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_app_bundle_path(path: str) -> bool:
|
|
283
|
+
lowered = _normalized(path)
|
|
284
|
+
return lowered.endswith(".app") or ".app/" in lowered
|
|
285
|
+
|
|
286
|
+
|
|
213
287
|
def _is_inside_windows_mail_package(lowered: str) -> bool:
|
|
214
288
|
return "/appdata/local/packages/microsoft.windowscommunicationsapps" in lowered
|
|
215
289
|
|
|
@@ -323,9 +397,12 @@ def is_sensitive_path(path: str) -> bool:
|
|
|
323
397
|
def is_private_profile_path(path: str) -> bool:
|
|
324
398
|
lowered = _normalized(path)
|
|
325
399
|
parts = _parts(path)
|
|
326
|
-
|
|
400
|
+
global_parts = PRIVATE_PROFILE_PARTS - HOME_PRIVATE_PROFILE_MARKERS
|
|
401
|
+
if parts & global_parts:
|
|
402
|
+
return True
|
|
403
|
+
if _contains_path_marker(lowered, global_parts):
|
|
327
404
|
return True
|
|
328
|
-
if _contains_path_marker(lowered,
|
|
405
|
+
if (_is_home_hidden_path(path) or "/users/" in lowered) and _contains_path_marker(lowered, HOME_PRIVATE_PROFILE_MARKERS):
|
|
329
406
|
return True
|
|
330
407
|
name = Path(path).name.lower()
|
|
331
408
|
if name in PROFILE_HIDDEN_FILE_NAMES:
|
|
@@ -346,8 +423,12 @@ def classify_path(path: str) -> tuple[int, str, str]:
|
|
|
346
423
|
return 1, "sensitive_inventory_only", "sensitive_path"
|
|
347
424
|
if is_private_profile_path(path):
|
|
348
425
|
return 0, "private_profile_blocked", "private_profile_path"
|
|
349
|
-
if
|
|
426
|
+
if _is_system_path(path):
|
|
350
427
|
return 0, "system_blocked", "system_path"
|
|
428
|
+
if parts & PRODUCT_ARTIFACT_PARTS:
|
|
429
|
+
return 1, "inventory_only", "product_artifact"
|
|
430
|
+
if _is_app_bundle_path(path):
|
|
431
|
+
return 1, "inventory_only", "app_bundle"
|
|
351
432
|
if parts & NOISY_PARTS or _has_transient_project_part(path) or _has_hidden_dir_part(path):
|
|
352
433
|
return 1, "inventory_only", "noisy_tree"
|
|
353
434
|
return 2, "normal", "default"
|
|
@@ -358,11 +439,17 @@ def should_skip_tree(path: str) -> bool:
|
|
|
358
439
|
parts = _parts(path)
|
|
359
440
|
if is_local_email_tree(path):
|
|
360
441
|
return False
|
|
361
|
-
if
|
|
442
|
+
if _is_system_path(path):
|
|
362
443
|
return True
|
|
363
444
|
if is_sensitive_path(path) or is_private_profile_path(path):
|
|
364
445
|
return True
|
|
365
|
-
return bool(
|
|
446
|
+
return bool(
|
|
447
|
+
parts & PRODUCT_ARTIFACT_PARTS
|
|
448
|
+
or _is_app_bundle_path(path)
|
|
449
|
+
or parts & NOISY_PARTS
|
|
450
|
+
or _has_transient_project_part(path)
|
|
451
|
+
or _has_hidden_dir_part(path)
|
|
452
|
+
)
|
|
366
453
|
|
|
367
454
|
|
|
368
455
|
def should_skip_file(path: str) -> bool:
|
|
@@ -370,11 +457,17 @@ def should_skip_file(path: str) -> bool:
|
|
|
370
457
|
parts = _parts(path)
|
|
371
458
|
if is_local_email_tree(path):
|
|
372
459
|
return not is_allowed_local_email_file(path)
|
|
373
|
-
if
|
|
460
|
+
if _is_system_path(path):
|
|
374
461
|
return True
|
|
375
462
|
if is_sensitive_path(path) or is_private_profile_path(path):
|
|
376
463
|
return True
|
|
377
|
-
return bool(
|
|
464
|
+
return bool(
|
|
465
|
+
parts & PRODUCT_ARTIFACT_PARTS
|
|
466
|
+
or _is_app_bundle_path(path)
|
|
467
|
+
or parts & NOISY_PARTS
|
|
468
|
+
or _has_transient_project_part(path)
|
|
469
|
+
or _has_hidden_dir_part(path)
|
|
470
|
+
)
|
|
378
471
|
|
|
379
472
|
|
|
380
473
|
def is_queryable_path(path: str, privacy_class: str = "") -> bool:
|
package/src/plugins/protocol.py
CHANGED
|
@@ -42,6 +42,11 @@ from plugins.guard import handle_guard_check
|
|
|
42
42
|
from protocol_settings import get_protocol_strictness
|
|
43
43
|
from tools_sessions import handle_heartbeat
|
|
44
44
|
|
|
45
|
+
try:
|
|
46
|
+
from tools_hot_context import append_local_context_evidence
|
|
47
|
+
except Exception: # pragma: no cover - local context is optional in early boot
|
|
48
|
+
append_local_context_evidence = None
|
|
49
|
+
|
|
45
50
|
|
|
46
51
|
# ── R03 (Fase 2 Protocol Enforcer) evidence quality thresholds ────────
|
|
47
52
|
# "evidence" supplied to nexo_task_close must be substantive when an
|
|
@@ -1320,6 +1325,14 @@ def handle_task_open(
|
|
|
1320
1325
|
response_contract = dict(response_contract)
|
|
1321
1326
|
response_contract["next_action"] = next_action
|
|
1322
1327
|
|
|
1328
|
+
recent_excerpt = format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else ""
|
|
1329
|
+
if append_local_context_evidence is not None:
|
|
1330
|
+
recent_excerpt = append_local_context_evidence(
|
|
1331
|
+
recent_excerpt,
|
|
1332
|
+
" | ".join(part for part in [clean_goal, context_hint.strip()] if part),
|
|
1333
|
+
limit=4,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1323
1336
|
response = {
|
|
1324
1337
|
"ok": True,
|
|
1325
1338
|
"task_id": task["task_id"],
|
|
@@ -1351,8 +1364,8 @@ def handle_task_open(
|
|
|
1351
1364
|
"response_contract": response_contract,
|
|
1352
1365
|
"decision_support": decision_support,
|
|
1353
1366
|
"recent_context": {
|
|
1354
|
-
"has_matches": bool(recent_bundle.get("has_matches")),
|
|
1355
|
-
"excerpt":
|
|
1367
|
+
"has_matches": bool(recent_bundle.get("has_matches") or recent_excerpt.strip()),
|
|
1368
|
+
"excerpt": recent_excerpt,
|
|
1356
1369
|
},
|
|
1357
1370
|
"area_context": area_context if area_context.get("has_context") else None,
|
|
1358
1371
|
"contract": {
|
package/src/tools_hot_context.py
CHANGED
|
@@ -15,6 +15,11 @@ from db import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def append_local_context_evidence(rendered: str, query: str, *, limit: int = 4) -> str:
|
|
19
|
+
local_context = _format_local_context_evidence(query, limit=min(limit or 4, 4))
|
|
20
|
+
return f"{rendered}{local_context}" if local_context else rendered
|
|
21
|
+
|
|
22
|
+
|
|
18
23
|
def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
|
|
19
24
|
clean_query = (query or "").strip()
|
|
20
25
|
if not clean_query:
|
|
@@ -40,6 +45,14 @@ def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
|
|
|
40
45
|
summary = str(asset.get("summary") or "").strip()
|
|
41
46
|
suffix = f" — {summary[:180]}" if summary else ""
|
|
42
47
|
lines.append(f"- {display_path} ({asset.get('file_type', 'file')}, score={score}){suffix}")
|
|
48
|
+
chunks = result.get("chunks") or []
|
|
49
|
+
if chunks:
|
|
50
|
+
lines.append("Relevant excerpts:")
|
|
51
|
+
for chunk in chunks[:limit]:
|
|
52
|
+
text = " ".join(str(chunk.get("text") or "").split())
|
|
53
|
+
if not text:
|
|
54
|
+
continue
|
|
55
|
+
lines.append(f"- {text[:360]}")
|
|
43
56
|
refs = result.get("evidence_refs") or []
|
|
44
57
|
if refs:
|
|
45
58
|
lines.append(f"Evidence refs: {', '.join(str(ref) for ref in refs[:limit])}")
|
|
@@ -160,8 +173,7 @@ def handle_pre_action_context(
|
|
|
160
173
|
limit=limit,
|
|
161
174
|
)
|
|
162
175
|
rendered = format_pre_action_context_bundle(bundle)
|
|
163
|
-
|
|
164
|
-
return f"{rendered}{local_context}" if local_context else rendered
|
|
176
|
+
return append_local_context_evidence(rendered, " | ".join(query_bits), limit=limit)
|
|
165
177
|
|
|
166
178
|
|
|
167
179
|
def handle_recent_context_resolve(
|
package/src/tools_sessions.py
CHANGED
|
@@ -20,6 +20,11 @@ from db import (
|
|
|
20
20
|
capture_context_event, maintain_memory_observations,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
+
try:
|
|
24
|
+
from tools_hot_context import append_local_context_evidence
|
|
25
|
+
except Exception: # pragma: no cover - local context is optional during bootstrap
|
|
26
|
+
append_local_context_evidence = None
|
|
27
|
+
|
|
23
28
|
try:
|
|
24
29
|
from r14_correction_learning import detect_correction as _detect_correction_semantic
|
|
25
30
|
except Exception: # pragma: no cover - optional runtime dependency
|
|
@@ -707,6 +712,11 @@ def _handle_heartbeat_inner(sid: str, task: str, context_hint: str = '') -> str:
|
|
|
707
712
|
if bundle.get("has_matches"):
|
|
708
713
|
parts.append("")
|
|
709
714
|
parts.append(format_pre_action_context_bundle(bundle, compact=True))
|
|
715
|
+
if append_local_context_evidence is not None:
|
|
716
|
+
local_rendered = append_local_context_evidence("", recent_query, limit=4).strip()
|
|
717
|
+
if local_rendered:
|
|
718
|
+
parts.append("")
|
|
719
|
+
parts.append(local_rendered)
|
|
710
720
|
except Exception:
|
|
711
721
|
pass
|
|
712
722
|
|