nexo-brain 7.20.11 → 7.20.13

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.
@@ -41,7 +41,8 @@ from db_guard import (
41
41
  db_row_counts,
42
42
  diff_row_counts,
43
43
  find_latest_hourly_backup,
44
- kill_nexo_mcp_servers,
44
+ quiesce_nexo_db_writers,
45
+ resume_nexo_launchagents,
45
46
  safe_sqlite_backup,
46
47
  validate_backup_matches_source,
47
48
  )
@@ -257,15 +258,20 @@ def recover(
257
258
  result["steps"].append("dry-run: stopping before any write")
258
259
  return result
259
260
 
260
- # Step 3: kill live MCP servers
261
+ stopped_launchagents: list[str] = []
262
+
263
+ # Step 3: quiesce live DB writers
261
264
  if not skip_kill:
262
- kill_report = kill_nexo_mcp_servers(dry_run=False)
263
- result["steps"].append(f"kill_mcp: terminated={kill_report['terminated']} scanned={kill_report['scanned']}")
264
- if kill_report.get("errors"):
265
- result["warnings"].extend(kill_report["errors"])
266
- # Tiny settle so the ex-server releases file locks.
267
- if kill_report["terminated"]:
268
- time.sleep(0.5)
265
+ quiesce_report = quiesce_nexo_db_writers(dry_run=False)
266
+ result["quiesce"] = quiesce_report
267
+ stopped_launchagents = list((quiesce_report.get("launchagents") or {}).get("stopped") or [])
268
+ result["steps"].append(
269
+ "quiesce_db_writers: "
270
+ f"terminated={quiesce_report.get('terminated', 0)} "
271
+ f"launchagents={len(stopped_launchagents)}"
272
+ )
273
+ if quiesce_report.get("errors"):
274
+ result["warnings"].extend(quiesce_report["errors"])
269
275
 
270
276
  # Step 4: snapshot current state to pre-recover/
271
277
  pre_recover_dir = _backup_base() / f"pre-recover-{time.strftime('%Y-%m-%d-%H%M%S')}"
@@ -297,17 +303,24 @@ def recover(
297
303
  ok, copy_err = safe_sqlite_backup(chosen, target_path)
298
304
  if not ok:
299
305
  result["errors"].append(f"restore copy failed: {copy_err}")
306
+ if stopped_launchagents:
307
+ result["resume"] = resume_nexo_launchagents(stopped_launchagents)
300
308
  return result
301
309
  result["steps"].append(f"restored {chosen.name} -> {target_path}")
302
310
 
303
311
  valid, valid_err = validate_backup_matches_source(chosen, target_path)
304
312
  if not valid:
305
313
  result["errors"].append(f"post-restore validation failed: {valid_err}")
314
+ if stopped_launchagents:
315
+ result["resume"] = resume_nexo_launchagents(stopped_launchagents)
306
316
  return result
307
317
  result["steps"].append("validated post-restore row counts")
308
318
 
309
319
  final_counts = db_row_counts(target_path)
310
320
  result["final_row_counts"] = {k: v for k, v in final_counts.items() if v is not None}
321
+ if stopped_launchagents:
322
+ result["resume"] = resume_nexo_launchagents(stopped_launchagents)
323
+ result["steps"].append(f"resumed {len((result['resume'] or {}).get('started') or [])} launchagent(s)")
311
324
  result["ok"] = True
312
325
  return result
313
326
 
package/src/server.py CHANGED
@@ -104,6 +104,7 @@ from plugins.workflow import (
104
104
  )
105
105
  from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
106
106
  from tools_guardian import handle_guardian_rule_override
107
+ from tools_api_call import handle_api_call, handle_create_app_token
107
108
  from runtime_versioning import (
108
109
  RestartRequiredMiddleware,
109
110
  build_mcp_status,
@@ -682,14 +683,45 @@ def nexo_local_index_exclusions(action: str = "list", path: str = "", reason: st
682
683
 
683
684
 
684
685
  @mcp.tool
685
- def nexo_local_context(query: str, intent: str = "answer", limit: int = 12, evidence_required: bool = True, current_context: str = "") -> str:
686
- """Retrieve local evidence before answering or acting."""
686
+ def nexo_local_context(
687
+ query: str,
688
+ intent: str = "answer",
689
+ limit: int = 8,
690
+ evidence_required: bool = True,
691
+ current_context: str = "",
692
+ mode: str = "compact",
693
+ max_chars: int = 20000,
694
+ include_entities: bool = False,
695
+ include_relations: bool = False,
696
+ ) -> str:
697
+ """Retrieve local evidence before answering or acting.
698
+
699
+ Use mode='compact' for normal answers. Use mode='full' only for deep
700
+ debugging, ideally with a higher max_chars and a specific query.
701
+ """
687
702
  result = local_context_api.context_query(
688
703
  query,
689
704
  intent=intent,
690
705
  limit=limit,
691
706
  evidence_required=evidence_required,
692
707
  current_context=current_context,
708
+ mode=mode,
709
+ max_chars=max_chars,
710
+ include_entities=include_entities,
711
+ include_relations=include_relations,
712
+ )
713
+ return json.dumps(result, ensure_ascii=False)
714
+
715
+
716
+ @mcp.tool
717
+ def nexo_context_router(query: str, intent: str = "answer", limit: int = 4, current_context: str = "", max_chars: int = 6000) -> str:
718
+ """Return compact local context evidence suitable for injection before a reply."""
719
+ result = local_context_api.context_router(
720
+ query,
721
+ intent=intent,
722
+ limit=limit,
723
+ current_context=current_context,
724
+ max_chars=max_chars,
693
725
  )
694
726
  return json.dumps(result, ensure_ascii=False)
695
727
 
@@ -2058,6 +2090,62 @@ def nexo_session_log_close(
2058
2090
  return _json.dumps(result, ensure_ascii=False)
2059
2091
 
2060
2092
 
2093
+ @mcp.tool
2094
+ def nexo_api_call(
2095
+ method: str,
2096
+ path: str,
2097
+ body_json: str = "",
2098
+ idempotency_key: str = "",
2099
+ headers_json: str = "",
2100
+ base_url: str = "",
2101
+ ) -> str:
2102
+ """Make an authenticated HTTP request to the NEXO Desktop backend (nexo-desktop.com).
2103
+
2104
+ The session bearer is auto-loaded from the OS keychain — the agent never
2105
+ sees or handles tokens. Use this for any /api/* endpoint the user has
2106
+ permission for: provider-proxy/*, credits/*, cards/*, auth/app-tokens, etc.
2107
+
2108
+ Args:
2109
+ method: HTTP method (GET / POST / PUT / DELETE / PATCH).
2110
+ path: path starting with '/' (e.g. '/api/provider-proxy/call').
2111
+ body_json: JSON string of the request body. Empty for GET.
2112
+ idempotency_key: UUID v4 to dedupe POST/PUT retries (avoids double-charge).
2113
+ headers_json: optional extra headers as a JSON object. Authorization is ignored.
2114
+ base_url: override default base (default: https://nexo-desktop.com).
2115
+
2116
+ Returns formatted text with HTTP status + parsed JSON body. Bearer is never echoed.
2117
+ """
2118
+ return handle_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
2119
+
2120
+
2121
+ @mcp.tool
2122
+ def nexo_create_app_token(
2123
+ name: str,
2124
+ abilities: str = "",
2125
+ allowed_platforms: str = "",
2126
+ expires_at: str = "",
2127
+ ) -> str:
2128
+ """Create a persistent AppToken for the current user via POST /api/auth/app-tokens.
2129
+
2130
+ Use this when a card needs to mint a token that will live inside a snippet
2131
+ the user pastes on their own website (chatbot widget, embed, public API
2132
+ autoresponder). The plain-text token is returned ONCE — embed it in the
2133
+ generated snippet and never store it elsewhere.
2134
+
2135
+ Args:
2136
+ name: human label for the token (e.g. 'chatbot-mitienda-com').
2137
+ abilities: comma-separated abilities. Allowed:
2138
+ provider-proxy:call, provider-proxy:estimate, credits:read.
2139
+ Defaults to 'provider-proxy:call' if empty.
2140
+ allowed_platforms: comma-separated platform keys (openai, anthropic, gemini, ...).
2141
+ Empty = all platforms the user has access to.
2142
+ expires_at: ISO 8601 future date, empty for non-expiring token.
2143
+
2144
+ Returns the created token summary + plain_text_token (one-time disclosure).
2145
+ """
2146
+ return handle_create_app_token(name, abilities, allowed_platforms, expires_at)
2147
+
2148
+
2061
2149
  if __name__ == "__main__":
2062
2150
  _server_init()
2063
2151
  mcp.run(**_run_kwargs_from_env())
@@ -0,0 +1,196 @@
1
+ """HTTP API calls to the nexo-desktop-web backend using the user's session bearer.
2
+
3
+ Exposes two MCP tools registered in server.py:
4
+ - nexo_api_call(method, path, body_json, idempotency_key, headers_json, base_url)
5
+ - nexo_create_app_token(name, abilities, allowed_platforms, expires_at)
6
+
7
+ The session bearer (Sanctum personal access token) is stored by NEXO Desktop in
8
+ the OS keychain at:
9
+ service = "com.nexo.shared-auth"
10
+ account = "sanctum-token"
11
+
12
+ Cross-platform via the `keyring` library (macOS Keychain, Windows Credential
13
+ Manager, secret-service on Linux). The bearer is never echoed back to the
14
+ agent — it only flows in the Authorization header to the backend.
15
+
16
+ These tools let the agent (Nero) call any /api/* endpoint on the user's behalf
17
+ without the agent having to manage tokens. Use them from fichas that need
18
+ backend-driven NEXO Credits flows, or to mint persistent AppTokens for
19
+ embeddable resources (chatbot snippets, widgets) that the user pastes on
20
+ their own website.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ from typing import Any
27
+
28
+ import keyring
29
+ import requests
30
+
31
+ KEYCHAIN_SERVICE = "com.nexo.shared-auth"
32
+ KEYCHAIN_ACCOUNT = "sanctum-token"
33
+ DEFAULT_BASE_URL = "https://nexo-desktop.com"
34
+ ALLOWED_METHODS = {"GET", "POST", "PUT", "DELETE", "PATCH"}
35
+ REQUEST_TIMEOUT_SECONDS = 60
36
+ MAX_BODY_PREVIEW_CHARS = 6000
37
+
38
+ # Allowed abilities for AppToken creation. Keep in sync with
39
+ # AppTokenService::ABILITY_* constants in the Laravel backend.
40
+ ALLOWED_ABILITIES = {
41
+ "provider-proxy:call",
42
+ "provider-proxy:estimate",
43
+ "credits:read",
44
+ }
45
+
46
+
47
+ def _read_session_bearer() -> str | None:
48
+ try:
49
+ return keyring.get_password(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
50
+ except Exception:
51
+ return None
52
+
53
+
54
+ def _parse_json_arg(raw: str, label: str) -> tuple[Any, str | None]:
55
+ text = (raw or "").strip()
56
+ if not text:
57
+ return None, None
58
+ try:
59
+ return json.loads(text), None
60
+ except json.JSONDecodeError as exc:
61
+ return None, f"ERROR: {label} is not valid JSON: {exc}"
62
+
63
+
64
+ def _format_response(method: str, path: str, status: int, body_text: str) -> str:
65
+ return f"HTTP {status} {method} {path}\n{body_text}"
66
+
67
+
68
+ def handle_api_call(
69
+ method: str,
70
+ path: str,
71
+ body_json: str = "",
72
+ idempotency_key: str = "",
73
+ headers_json: str = "",
74
+ base_url: str = "",
75
+ ) -> str:
76
+ """Make an authenticated request to the NEXO Desktop backend.
77
+
78
+ The session bearer is auto-loaded from the OS keychain. It is never
79
+ returned to the caller. Use this for any /api/* endpoint the user has
80
+ permission for (provider-proxy/*, credits/*, cards/*, etc).
81
+ """
82
+ bearer = _read_session_bearer()
83
+ if not bearer:
84
+ return (
85
+ "ERROR: no NEXO Desktop session token found in the system keychain. "
86
+ "The user must be logged in to NEXO Desktop before this tool can be used."
87
+ )
88
+
89
+ method_upper = (method or "").strip().upper()
90
+ if method_upper not in ALLOWED_METHODS:
91
+ return f"ERROR: method '{method}' is not allowed. Use one of {sorted(ALLOWED_METHODS)}."
92
+
93
+ cleaned_path = (path or "").strip()
94
+ if not cleaned_path.startswith("/"):
95
+ return "ERROR: path must start with '/' (e.g. /api/provider-proxy/call)."
96
+
97
+ base = (base_url or "").strip() or DEFAULT_BASE_URL
98
+ url = base.rstrip("/") + cleaned_path
99
+
100
+ headers = {
101
+ "Authorization": f"Bearer {bearer}",
102
+ "Accept": "application/json",
103
+ }
104
+
105
+ extra_headers, err = _parse_json_arg(headers_json, "headers_json")
106
+ if err:
107
+ return err
108
+ if extra_headers is not None:
109
+ if not isinstance(extra_headers, dict):
110
+ return "ERROR: headers_json must be a JSON object."
111
+ for k, v in extra_headers.items():
112
+ # Never let caller override Authorization.
113
+ if str(k).lower() == "authorization":
114
+ continue
115
+ headers[str(k)] = str(v)
116
+
117
+ if idempotency_key.strip():
118
+ headers["Idempotency-Key"] = idempotency_key.strip()
119
+
120
+ body, err = _parse_json_arg(body_json, "body_json")
121
+ if err:
122
+ return err
123
+ if body is not None:
124
+ headers["Content-Type"] = "application/json"
125
+
126
+ try:
127
+ resp = requests.request(
128
+ method_upper,
129
+ url,
130
+ headers=headers,
131
+ json=body,
132
+ timeout=REQUEST_TIMEOUT_SECONDS,
133
+ )
134
+ except requests.exceptions.Timeout:
135
+ return f"ERROR: request to {cleaned_path} timed out after {REQUEST_TIMEOUT_SECONDS}s."
136
+ except requests.exceptions.RequestException as exc:
137
+ return f"ERROR: network error calling {cleaned_path}: {exc}"
138
+
139
+ try:
140
+ parsed = resp.json()
141
+ body_text = json.dumps(parsed, indent=2, ensure_ascii=False)
142
+ except ValueError:
143
+ body_text = (resp.text or "")[:MAX_BODY_PREVIEW_CHARS]
144
+
145
+ if len(body_text) > MAX_BODY_PREVIEW_CHARS:
146
+ body_text = body_text[:MAX_BODY_PREVIEW_CHARS] + "\n... [truncated]"
147
+
148
+ return _format_response(method_upper, cleaned_path, resp.status_code, body_text)
149
+
150
+
151
+ def handle_create_app_token(
152
+ name: str,
153
+ abilities: str = "",
154
+ allowed_platforms: str = "",
155
+ expires_at: str = "",
156
+ ) -> str:
157
+ """Create a persistent AppToken for the current user via POST /api/auth/app-tokens.
158
+
159
+ Use this when a card needs to mint a token that will live inside a snippet
160
+ the user pastes on their own website (chatbot widget, embed, public API
161
+ autoresponder). The plain-text token is returned ONCE — the agent must
162
+ embed it in the snippet immediately and never store it elsewhere.
163
+ """
164
+ label = (name or "").strip()
165
+ if not label:
166
+ return "ERROR: name is required (human label for the token, e.g. 'chatbot-mitienda-com')."
167
+
168
+ requested_abilities = [a.strip() for a in (abilities or "").split(",") if a.strip()]
169
+ if not requested_abilities:
170
+ requested_abilities = ["provider-proxy:call"]
171
+
172
+ invalid = [a for a in requested_abilities if a not in ALLOWED_ABILITIES]
173
+ if invalid:
174
+ return (
175
+ f"ERROR: invalid abilities {invalid}. "
176
+ f"Allowed: {sorted(ALLOWED_ABILITIES)}."
177
+ )
178
+
179
+ payload: dict[str, Any] = {
180
+ "name": label,
181
+ "abilities": requested_abilities,
182
+ }
183
+
184
+ platforms = [p.strip() for p in (allowed_platforms or "").split(",") if p.strip()]
185
+ if platforms:
186
+ payload["allowed_platforms"] = platforms
187
+
188
+ expires_clean = (expires_at or "").strip()
189
+ if expires_clean:
190
+ payload["expires_at"] = expires_clean
191
+
192
+ return handle_api_call(
193
+ method="POST",
194
+ path="/api/auth/app-tokens",
195
+ body_json=json.dumps(payload),
196
+ )
@@ -1,8 +1,31 @@
1
- """Credentials CRUD tools: get, create, update, delete, list."""
1
+ """Credentials CRUD tools: get, create, update, delete, list.
2
+
3
+ Two storage backends live behind these handlers:
4
+
5
+ 1) The default SQLite credentials table (DB). Used by the agent for any
6
+ internally-managed secret (Anthropic platform key, Stripe keys, etc.).
7
+
8
+ 2) The BYOK filesystem store at ``~/.nexo/credentials/byok/{slug}.json``.
9
+ Used for the user's own API keys connected from NEXO Desktop's
10
+ "Connections" settings tab. Keys NEVER cross to the NEXO backend and
11
+ never go through the DB — they live only on the user's machine.
12
+
13
+ When ``service='byok'`` the handlers transparently route to the filesystem
14
+ backend (get / list / delete). Create is intentionally NOT routed: BYOK
15
+ keys are written through Desktop's UI, which performs remote validation
16
+ before persisting. The agent should never mint a BYOK entry on its own.
17
+ """
18
+
19
+ import json
20
+ import os
21
+ from pathlib import Path
2
22
 
3
23
  from db import create_credential, update_credential, delete_credential, get_credential, list_credentials, get_db
4
24
 
5
25
 
26
+ BYOK_SERVICE = "byok"
27
+
28
+
6
29
  def _credential_exists(service: str, key: str) -> bool:
7
30
  """Fase 2 R02 helper — exact (service, key) match against active credentials.
8
31
 
@@ -20,9 +43,116 @@ def _credential_exists(service: str, key: str) -> bool:
20
43
  return row is not None
21
44
 
22
45
 
46
+ def _byok_base_dir() -> Path:
47
+ home = os.environ.get("NEXO_HOME") or str(Path.home() / ".nexo")
48
+ return Path(home) / "credentials" / "byok"
49
+
50
+
51
+ def _safe_byok_slug(raw: str) -> str:
52
+ cleaned = (raw or "").strip().lower()
53
+ return "".join(ch for ch in cleaned if ch.isalnum() or ch in "-_")
54
+
55
+
56
+ def _byok_file_for(key: str) -> Path | None:
57
+ safe = _safe_byok_slug(key)
58
+ if not safe:
59
+ return None
60
+ return _byok_base_dir() / f"{safe}.json"
61
+
62
+
63
+ def _byok_read_file(path: Path) -> dict | None:
64
+ try:
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+ except Exception:
67
+ return None
68
+
69
+
70
+ def _byok_entry_from_file(slug: str, data: dict) -> dict:
71
+ """Shape a BYOK file into the {service, key, value, notes} schema the
72
+ handlers expect — same as DB rows. ``value`` carries the actual API key.
73
+ """
74
+ provider = data.get("provider") or slug
75
+ label = data.get("label") or ""
76
+ validation = data.get("validation_status") or "unknown"
77
+ connected_at = data.get("connected_at") or ""
78
+ last_validated = data.get("last_validated_at") or ""
79
+ note_parts = [
80
+ f"provider={provider}",
81
+ f"label={label}" if label else "",
82
+ f"validation={validation}",
83
+ f"connected_at={connected_at}" if connected_at else "",
84
+ f"last_validated_at={last_validated}" if last_validated else "",
85
+ ]
86
+ notes = " | ".join(p for p in note_parts if p)
87
+ return {
88
+ "service": BYOK_SERVICE,
89
+ "key": slug,
90
+ "value": str(data.get("api_key") or ""),
91
+ "notes": notes,
92
+ }
93
+
94
+
95
+ def _byok_get(key: str = "") -> list[dict]:
96
+ """Filesystem-backed lookup for BYOK keys. Returns list of dicts in the
97
+ same shape as get_credential() so the handler can render them uniformly.
98
+ """
99
+ base = _byok_base_dir()
100
+ if not base.is_dir():
101
+ return []
102
+
103
+ if key:
104
+ path = _byok_file_for(key)
105
+ if not path or not path.is_file():
106
+ return []
107
+ data = _byok_read_file(path)
108
+ if data is None:
109
+ return []
110
+ return [_byok_entry_from_file(_safe_byok_slug(key), data)]
111
+
112
+ out: list[dict] = []
113
+ for path in sorted(base.glob("*.json")):
114
+ data = _byok_read_file(path)
115
+ if data is None:
116
+ continue
117
+ out.append(_byok_entry_from_file(path.stem, data))
118
+ return out
119
+
120
+
121
+ def _byok_delete(key: str = "") -> int:
122
+ """Filesystem-backed delete. Returns the number of files removed."""
123
+ base = _byok_base_dir()
124
+ if not base.is_dir():
125
+ return 0
126
+ if key:
127
+ path = _byok_file_for(key)
128
+ if not path or not path.is_file():
129
+ return 0
130
+ try:
131
+ path.unlink()
132
+ return 1
133
+ except Exception:
134
+ return 0
135
+ removed = 0
136
+ for path in base.glob("*.json"):
137
+ try:
138
+ path.unlink()
139
+ removed += 1
140
+ except Exception:
141
+ continue
142
+ return removed
143
+
144
+
23
145
  def handle_credential_get(service: str, key: str = '') -> str:
24
- """Retrieve credential(s) including their values. Use for reading secrets."""
25
- results = get_credential(service, key if key else None)
146
+ """Retrieve credential(s) including their values. Use for reading secrets.
147
+
148
+ When ``service='byok'`` the values are read from the local filesystem
149
+ store written by NEXO Desktop's Settings > Connections UI (the user's
150
+ own provider API keys, e.g. OpenAI/Anthropic/Gemini/ElevenLabs).
151
+ """
152
+ if service == BYOK_SERVICE:
153
+ results = _byok_get(key)
154
+ else:
155
+ results = get_credential(service, key if key else None)
26
156
  if not results:
27
157
  target = f"{service}/{key}" if key else service
28
158
  return f"ERROR: No credentials found for '{target}'."
@@ -50,7 +180,19 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
50
180
  force: Set to '1'/'true' to OVERWRITE an existing (service, key) pair.
51
181
  Without force, Fase 2 R02 rejects duplicates and points at
52
182
  nexo_credential_update as the canonical edit path.
183
+
184
+ BYOK keys (service='byok') are intentionally NOT mintable through this
185
+ tool: they must be added via NEXO Desktop's Settings > Connections, which
186
+ validates the key against the provider before saving. The agent should
187
+ not silently inject BYOK credentials.
53
188
  """
189
+ if service == BYOK_SERVICE:
190
+ return (
191
+ "ERROR: BYOK credentials cannot be created through this tool. "
192
+ "Ask the user to open NEXO Desktop > Settings > Connections and "
193
+ "connect the provider there (the UI validates the key with the "
194
+ "provider before saving)."
195
+ )
54
196
  # ── R02 (Fase 2 Protocol Enforcer): reject exact (service, key) duplicates ──
55
197
  force_flag = str(force or "").strip().lower() in {"1", "true", "yes", "on"}
56
198
  if not force_flag and _credential_exists(service, key):
@@ -71,7 +213,16 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
71
213
 
72
214
 
73
215
  def handle_credential_update(service: str, key: str, value: str = '', notes: str = '') -> str:
74
- """Update the value and/or notes of an existing credential."""
216
+ """Update the value and/or notes of an existing credential.
217
+
218
+ BYOK entries are not editable from the agent side; users update them by
219
+ re-connecting the provider from Settings > Connections in Desktop.
220
+ """
221
+ if service == BYOK_SERVICE:
222
+ return (
223
+ "ERROR: BYOK credentials are not editable from the agent. "
224
+ "Ask the user to update the connection in NEXO Desktop > Settings > Connections."
225
+ )
75
226
  result = update_credential(
76
227
  service,
77
228
  key,
@@ -84,7 +235,20 @@ def handle_credential_update(service: str, key: str, value: str = '', notes: str
84
235
 
85
236
 
86
237
  def handle_credential_delete(service: str, key: str = '') -> str:
87
- """Delete a credential or all credentials for a service."""
238
+ """Delete a credential or all credentials for a service.
239
+
240
+ For ``service='byok'`` the delete reaches the filesystem store; the file
241
+ is removed but the provider account on the user's side is untouched.
242
+ """
243
+ if service == BYOK_SERVICE:
244
+ removed = _byok_delete(key if key else None)
245
+ if removed == 0:
246
+ target = f"{service}/{key}" if key else service
247
+ return f"ERROR: No credentials found for '{target}'."
248
+ if key:
249
+ return f"Credential deleted."
250
+ return f"All BYOK credentials deleted ({removed} files)."
251
+
88
252
  deleted = delete_credential(service, key if key else None)
89
253
  if not deleted:
90
254
  target = f"{service}/{key}" if key else service
@@ -95,9 +259,17 @@ def handle_credential_delete(service: str, key: str = '') -> str:
95
259
 
96
260
 
97
261
  def handle_credential_list(service: str = '') -> str:
98
- """List credential service/key names and notes — values are never shown."""
99
- results = list_credentials(service if service else None)
100
- label = service if service else "ALL"
262
+ """List credential service/key names and notes — values are never shown.
263
+
264
+ Listing without ``service`` only returns DB entries (the historical
265
+ behaviour). Pass ``service='byok'`` to list the BYOK filesystem store.
266
+ """
267
+ if service == BYOK_SERVICE:
268
+ results = _byok_get("")
269
+ label = "BYOK"
270
+ else:
271
+ results = list_credentials(service if service else None)
272
+ label = service if service else "ALL"
101
273
  if not results:
102
274
  return f"CREDENTIALS {label.upper()}: No entries."
103
275
  lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
@@ -27,45 +27,17 @@ def _format_local_context_evidence(query: str, *, limit: int = 4) -> str:
27
27
  try:
28
28
  from local_context import api as local_context_api
29
29
 
30
- result = local_context_api.context_query(
30
+ routed = local_context_api.context_router(
31
31
  clean_query,
32
32
  intent="pre_action",
33
33
  limit=max(1, min(int(limit or 4), 8)),
34
- evidence_required=False,
34
+ max_chars=6000,
35
35
  )
36
36
  except Exception:
37
37
  return ""
38
- assets = result.get("assets") or []
39
- if not assets:
38
+ if not routed.get("should_inject"):
40
39
  return ""
41
- lines = ["", "LOCAL CONTEXT EVIDENCE:"]
42
- for asset in assets[:limit]:
43
- display_path = str(asset.get("display_path") or asset.get("path") or "")
44
- score = asset.get("score")
45
- summary = str(asset.get("summary") or "").strip()
46
- suffix = f" — {summary[:180]}" if summary else ""
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]}")
56
- refs = result.get("evidence_refs") or []
57
- if refs:
58
- lines.append(f"Evidence refs: {', '.join(str(ref) for ref in refs[:limit])}")
59
- relations = result.get("relations") or []
60
- if relations:
61
- lines.append("Local relations:")
62
- for relation in relations[:limit]:
63
- relation_type = str(relation.get("relation_type") or "related")
64
- target = str(relation.get("target_ref") or relation.get("target_asset_id") or "").strip()
65
- evidence = str(relation.get("evidence") or "").strip()
66
- suffix = f" — {evidence[:120]}" if evidence else ""
67
- lines.append(f"- {relation_type}: {target}{suffix}")
68
- return "\n".join(lines)
40
+ return str(routed.get("rendered") or "")
69
41
 
70
42
 
71
43
  def _parse_metadata(metadata: str = "") -> dict: