nexo-brain 7.23.6 → 7.23.8

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.6",
3
+ "version": "7.23.7",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.23.6",
3
+ "version": "7.23.8",
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",
@@ -31,7 +31,9 @@ if _PARENT not in sys.path:
31
31
 
32
32
  from agent_runner import AgentRunnerError, build_followup_terminal_shell_command
33
33
  from cognitive_paths import resolve_cognitive_db
34
+ from email_contract import email_contract_snapshot
34
35
  import paths
36
+ from tools_credentials import public_credential_records
35
37
 
36
38
  TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
37
39
  STATIC_DIR = Path(__file__).resolve().parent / "static"
@@ -1985,8 +1987,9 @@ async def api_artifacts():
1985
1987
  @app.get("/api/email")
1986
1988
  async def api_email_stats():
1987
1989
  conn = _email_db()
1990
+ contract = email_contract_snapshot()
1988
1991
  if not conn:
1989
- return {"error": "Email DB not found", "stats": {}}
1992
+ return {"error": "Email DB not found", "stats": {}, "contract": contract}
1990
1993
  total = conn.execute("SELECT COUNT(*) FROM emails").fetchone()[0]
1991
1994
  processed = conn.execute("SELECT COUNT(*) FROM emails WHERE status='processed'").fetchone()[0]
1992
1995
  pending = conn.execute("SELECT COUNT(*) FROM emails WHERE status='pending'").fetchone()[0]
@@ -1998,7 +2001,17 @@ async def api_email_stats():
1998
2001
  "FROM emails GROUP BY thread_id ORDER BY last_email DESC LIMIT 15"
1999
2002
  ).fetchall()]
2000
2003
  conn.close()
2001
- return {"stats": {"total": total, "processed": processed, "pending": pending}, "recent": recent, "threads": threads}
2004
+ return {
2005
+ "stats": {"total": total, "processed": processed, "pending": pending},
2006
+ "recent": recent,
2007
+ "threads": threads,
2008
+ "contract": contract,
2009
+ }
2010
+
2011
+
2012
+ @app.get("/api/email/contract")
2013
+ async def api_email_contract():
2014
+ return email_contract_snapshot()
2002
2015
 
2003
2016
 
2004
2017
  # ---------------------------------------------------------------------------
@@ -2007,16 +2020,21 @@ async def api_email_stats():
2007
2020
 
2008
2021
  @app.get("/api/credentials")
2009
2022
  async def api_credentials():
2010
- db = _db()
2011
- conn = db.get_db()
2012
- rows = conn.execute("SELECT service, key, notes, created_at, updated_at FROM credentials ORDER BY service, key").fetchall()
2013
- creds = [dict(r) for r in rows]
2023
+ creds = public_credential_records()
2014
2024
  services = {}
2015
2025
  for c in creds:
2016
- services.setdefault(c["service"], []).append({"key": c["key"], "notes": c.get("notes", "")})
2026
+ services.setdefault(c["service"], []).append(
2027
+ {"key": c["key"], "notes": c.get("notes", ""), "backend": c.get("backend", "db")}
2028
+ )
2017
2029
  return {"credentials": creds, "services": services, "total": len(creds)}
2018
2030
 
2019
2031
 
2032
+ @app.get("/api/change-log/retention")
2033
+ async def api_change_log_retention():
2034
+ db = _db()
2035
+ return db.change_log_retention_policy()
2036
+
2037
+
2020
2038
  # ---------------------------------------------------------------------------
2021
2039
  # Backups
2022
2040
  # ---------------------------------------------------------------------------
@@ -154,7 +154,8 @@ from db._entities import (
154
154
 
155
155
  # Episodic memory
156
156
  from db._episodic import (
157
- cleanup_old_changes, log_change, search_changes, update_change_commit, auto_resolve_followups,
157
+ cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
158
+ log_change, search_changes, update_change_commit, auto_resolve_followups,
158
159
  cleanup_old_decisions, log_decision, update_decision_outcome,
159
160
  get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
160
161
  cleanup_old_diaries, write_session_diary,
@@ -1,22 +1,45 @@
1
1
  from __future__ import annotations
2
2
  """NEXO DB — Episodic module."""
3
- import datetime, time, json
3
+ import datetime, time, json, os
4
4
  from db._core import get_db, now_epoch, _multi_word_like
5
5
  from db._fts import fts_upsert, fts_search
6
6
 
7
7
  # ── Change Log ───────────────────────────────────────────────────
8
8
 
9
- def cleanup_old_changes(retention_days: int = 90) -> int:
9
+ DEFAULT_CHANGE_LOG_RETENTION_DAYS = 90
10
+
11
+
12
+ def change_log_retention_days() -> int:
13
+ """Return the configured change-log retention window in days."""
14
+ raw = os.environ.get("NEXO_CHANGE_LOG_RETENTION_DAYS", str(DEFAULT_CHANGE_LOG_RETENTION_DAYS))
15
+ try:
16
+ return max(1, int(raw))
17
+ except (TypeError, ValueError):
18
+ return DEFAULT_CHANGE_LOG_RETENTION_DAYS
19
+
20
+
21
+ def change_log_retention_policy() -> dict:
22
+ """Public contract for change-log cleanup and its FTS side effects."""
23
+ return {
24
+ "retention_days": change_log_retention_days(),
25
+ "env": "NEXO_CHANGE_LOG_RETENTION_DAYS",
26
+ "default_retention_days": DEFAULT_CHANGE_LOG_RETENTION_DAYS,
27
+ "applies_to": ["change_log", "unified_search:source=change"],
28
+ }
29
+
30
+
31
+ def cleanup_old_changes(retention_days: int | None = None) -> int:
10
32
  """Delete change_log entries older than retention_days. Returns count deleted."""
11
33
  conn = get_db()
34
+ days = change_log_retention_days() if retention_days is None else max(1, int(retention_days))
12
35
  # Get IDs before deleting so we can clean FTS
13
36
  ids = [str(r[0]) for r in conn.execute(
14
37
  "SELECT id FROM change_log WHERE created_at < datetime('now', ?)",
15
- (f"-{retention_days} days",)
38
+ (f"-{days} days",)
16
39
  ).fetchall()]
17
40
  cursor = conn.execute(
18
41
  "DELETE FROM change_log WHERE created_at < datetime('now', ?)",
19
- (f"-{retention_days} days",)
42
+ (f"-{days} days",)
20
43
  )
21
44
  for cid in ids:
22
45
  conn.execute("DELETE FROM unified_search WHERE source = 'change' AND source_id = ?", (cid,))
@@ -668,15 +691,30 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
668
691
  # Excludes: cron jobs, auto-closed crons (0 heartbeats or "Minimal diary").
669
692
  if include_automated:
670
693
  source_clause = ""
694
+ source_params = ()
671
695
  else:
696
+ automated_sources = (
697
+ "auto-close",
698
+ "cron",
699
+ "system",
700
+ "automation",
701
+ "deep-sleep",
702
+ "daily-self-audit",
703
+ "self-audit",
704
+ "watchdog",
705
+ "followup-runner",
706
+ "script",
707
+ )
708
+ placeholders = ",".join("?" for _ in automated_sources)
672
709
  source_clause = (
673
710
  " AND ("
674
- " (source = 'claude' AND summary NOT LIKE '[AUTO-%')"
711
+ f" (COALESCE(source, '') NOT IN ({placeholders}) AND summary NOT LIKE '[AUTO-%')"
675
712
  " OR (source = 'auto-close'"
676
713
  " AND mental_state NOT LIKE '%0 heartbeats%'"
677
714
  " AND mental_state NOT LIKE '%Minimal diary%')"
678
715
  ")"
679
716
  )
717
+ source_params = automated_sources
680
718
 
681
719
  if session_id:
682
720
  rows = conn.execute(
@@ -688,12 +726,12 @@ def read_session_diary(session_id: str = '', last_n: int = 3, last_day: bool = F
688
726
  f"SELECT * FROM session_diary "
689
727
  f"WHERE created_at >= datetime('now', '-36 hours'){domain_clause}{source_clause} "
690
728
  f"ORDER BY {_diary_order_sql()}",
691
- domain_params
729
+ domain_params + source_params
692
730
  ).fetchall()
693
731
  else:
694
732
  rows = conn.execute(
695
733
  f"SELECT * FROM session_diary WHERE 1=1{domain_clause}{source_clause} ORDER BY {_diary_order_sql()} LIMIT ?",
696
- domain_params + (last_n,)
734
+ domain_params + source_params + (last_n,)
697
735
  ).fetchall()
698
736
  return [dict(r) for r in rows]
699
737
 
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ """Email runtime contract: account config is separate from monitor events."""
4
+
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ import paths
9
+ from db import get_db
10
+
11
+
12
+ def _table_exists(conn, table: str) -> bool:
13
+ row = conn.execute(
14
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
15
+ (table,),
16
+ ).fetchone()
17
+ return row is not None
18
+
19
+
20
+ def _count(conn, sql: str, params: tuple = ()) -> int:
21
+ try:
22
+ return int(conn.execute(sql, params).fetchone()[0])
23
+ except Exception:
24
+ return 0
25
+
26
+
27
+ def _email_event_db_path() -> Path:
28
+ return paths.nexo_email_dir() / "nexo-email.db"
29
+
30
+
31
+ def _accounts_config_snapshot() -> dict:
32
+ conn = get_db()
33
+ exists = _table_exists(conn, "email_accounts")
34
+ out = {
35
+ "store": "nexo.db",
36
+ "table": "email_accounts",
37
+ "table_exists": exists,
38
+ "total": 0,
39
+ "enabled": 0,
40
+ "agent_accounts": 0,
41
+ "operator_accounts": 0,
42
+ "with_credentials": 0,
43
+ "missing_credentials": 0,
44
+ }
45
+ if not exists:
46
+ return out
47
+
48
+ rows = [dict(row) for row in conn.execute("SELECT * FROM email_accounts").fetchall()]
49
+ out["total"] = len(rows)
50
+ out["enabled"] = sum(1 for row in rows if int(row.get("enabled") or 0) == 1)
51
+ out["agent_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "agent")
52
+ out["operator_accounts"] = sum(1 for row in rows if (row.get("account_type") or "").lower() == "operator")
53
+ out["with_credentials"] = sum(
54
+ 1
55
+ for row in rows
56
+ if (row.get("credential_service") or "").strip() and (row.get("credential_key") or "").strip()
57
+ )
58
+ out["missing_credentials"] = out["total"] - out["with_credentials"]
59
+ return out
60
+
61
+
62
+ def _event_store_snapshot(email_db_path: str | Path | None = None) -> dict:
63
+ path = Path(email_db_path) if email_db_path else _email_event_db_path()
64
+ out = {
65
+ "store": str(path),
66
+ "db_exists": path.is_file(),
67
+ "emails_table": False,
68
+ "email_events_table": False,
69
+ "total_emails": 0,
70
+ "total_events": 0,
71
+ "pending": 0,
72
+ "processed": 0,
73
+ }
74
+ if not path.is_file():
75
+ return out
76
+
77
+ conn = sqlite3.connect(str(path))
78
+ conn.row_factory = sqlite3.Row
79
+ try:
80
+ out["emails_table"] = _table_exists(conn, "emails")
81
+ out["email_events_table"] = _table_exists(conn, "email_events")
82
+ if out["emails_table"]:
83
+ out["total_emails"] = _count(conn, "SELECT COUNT(*) FROM emails")
84
+ out["pending"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='pending'")
85
+ out["processed"] = _count(conn, "SELECT COUNT(*) FROM emails WHERE status='processed'")
86
+ if out["email_events_table"]:
87
+ out["total_events"] = _count(conn, "SELECT COUNT(*) FROM email_events")
88
+ finally:
89
+ conn.close()
90
+ return out
91
+
92
+
93
+ def email_contract_snapshot(email_db_path: str | Path | None = None) -> dict:
94
+ """Return the explicit split between email account config and monitor events."""
95
+ accounts = _accounts_config_snapshot()
96
+ events = _event_store_snapshot(email_db_path)
97
+ return {
98
+ "contract": {
99
+ "layers": ["accounts_config", "event_store"],
100
+ "conflated": False,
101
+ "accounts_config": "nexo.db/email_accounts",
102
+ "event_store": "runtime/nexo-email/nexo-email.db",
103
+ },
104
+ "accounts_config": accounts,
105
+ "event_store": events,
106
+ }
@@ -832,12 +832,15 @@ def resolve_restart_required(
832
832
  reason = ""
833
833
  client_action = ""
834
834
  marker_clients = dict(marker.get("clients") or {})
835
+ client_acknowledged = False
835
836
  fingerprint_usable = bool(installed_fp) and bool(process_fp) and process_fp != "unknown"
836
837
 
837
838
  if marker.get("required"):
838
- restart_required = True
839
- reason = "marker_required"
840
839
  client_action = str(marker_clients.get(client) or "")
840
+ client_acknowledged = bool(client and client_action == "ok")
841
+ if not client_acknowledged:
842
+ restart_required = True
843
+ reason = "marker_required"
841
844
  if marker.get("corrupt"):
842
845
  restart_required = True
843
846
  reason = "marker_corrupt"
@@ -847,7 +850,12 @@ def resolve_restart_required(
847
850
  # fingerprint change and therefore never reach this branch.
848
851
  restart_required = True
849
852
  reason = reason or "fingerprint_mismatch"
850
- elif marker.get("required") and marker_fp and (not process_fp or process_fp == "unknown"):
853
+ elif (
854
+ marker.get("required")
855
+ and not client_acknowledged
856
+ and marker_fp
857
+ and (not process_fp or process_fp == "unknown")
858
+ ):
851
859
  restart_required = True
852
860
  reason = reason or "process_fingerprint_missing"
853
861
  elif not fingerprint_usable and installed and process and installed != process:
@@ -856,7 +864,7 @@ def resolve_restart_required(
856
864
  # mismatch check so we never leave a stale process running unnoticed.
857
865
  restart_required = True
858
866
  reason = reason or "version_mismatch"
859
- elif client and client_action == "ok":
867
+ elif client_acknowledged:
860
868
  restart_required = False
861
869
  reason = ""
862
870
 
package/src/server.py CHANGED
@@ -105,7 +105,17 @@ from plugins.episodic_memory import handle_session_diary_read, handle_session_di
105
105
  from plugins.cards import handle_card_match
106
106
  from plugins.skills import handle_skill_match
107
107
  from plugins.workflow import (
108
+ handle_goal_get,
109
+ handle_goal_list,
110
+ handle_goal_open,
111
+ handle_goal_update,
112
+ handle_workflow_compensation,
113
+ handle_workflow_get,
114
+ handle_workflow_handoff,
115
+ handle_workflow_list,
108
116
  handle_workflow_open,
117
+ handle_workflow_replay,
118
+ handle_workflow_resume,
109
119
  handle_workflow_update,
110
120
  )
111
121
  from plugin_loader import load_all_plugins, load_plugin, remove_plugin, list_plugins
@@ -816,6 +826,115 @@ def nexo_workflow_update(
816
826
  )
817
827
 
818
828
 
829
+ @mcp.tool
830
+ def nexo_goal_open(
831
+ sid: str,
832
+ title: str,
833
+ objective: str = "",
834
+ parent_goal_id: str = "",
835
+ priority: str = "normal",
836
+ next_action: str = "",
837
+ success_signal: str = "",
838
+ owner: str = "",
839
+ shared_state: str = "{}",
840
+ ) -> str:
841
+ """Open a durable goal so objectives survive sessions."""
842
+ return handle_goal_open(
843
+ sid,
844
+ title,
845
+ objective,
846
+ parent_goal_id,
847
+ priority,
848
+ next_action,
849
+ success_signal,
850
+ owner,
851
+ shared_state,
852
+ )
853
+
854
+
855
+ @mcp.tool
856
+ def nexo_goal_update(
857
+ goal_id: str,
858
+ status: str = "",
859
+ title: str = "",
860
+ objective: str = "",
861
+ parent_goal_id: str = "",
862
+ next_action: str = "",
863
+ success_signal: str = "",
864
+ blocker_reason: str = "",
865
+ owner: str = "",
866
+ shared_state: str = "",
867
+ ) -> str:
868
+ """Update a durable goal with active/blocked/completed state."""
869
+ return handle_goal_update(
870
+ goal_id,
871
+ status,
872
+ title,
873
+ objective,
874
+ parent_goal_id,
875
+ next_action,
876
+ success_signal,
877
+ blocker_reason,
878
+ owner,
879
+ shared_state,
880
+ )
881
+
882
+
883
+ @mcp.tool
884
+ def nexo_goal_get(goal_id: str, include_runs: bool = False) -> str:
885
+ """Read one durable goal and optionally include linked workflow runs."""
886
+ return handle_goal_get(goal_id, include_runs)
887
+
888
+
889
+ @mcp.tool
890
+ def nexo_goal_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
891
+ """List durable goals."""
892
+ return handle_goal_list(status, include_closed, limit)
893
+
894
+
895
+ @mcp.tool
896
+ def nexo_workflow_get(run_id: str, include_steps: bool = True, checkpoint_limit: int = 8) -> str:
897
+ """Read the full durable workflow state."""
898
+ return handle_workflow_get(run_id, include_steps, checkpoint_limit)
899
+
900
+
901
+ @mcp.tool
902
+ def nexo_workflow_handoff(
903
+ run_id: str,
904
+ actor: str,
905
+ next_action: str = "",
906
+ handoff_note: str = "",
907
+ shared_state: str = "",
908
+ new_owner: str = "",
909
+ ) -> str:
910
+ """Record a durable workflow handoff."""
911
+ return handle_workflow_handoff(run_id, actor, next_action, handoff_note, shared_state, new_owner)
912
+
913
+
914
+ @mcp.tool
915
+ def nexo_workflow_compensation(run_id: str, checkpoint_limit: int = 10) -> str:
916
+ """Return the compensation plan for a partially completed workflow."""
917
+ return handle_workflow_compensation(run_id, checkpoint_limit)
918
+
919
+
920
+ @mcp.tool
921
+ def nexo_workflow_resume(run_id: str) -> str:
922
+ """Summarize the next actionable step for a workflow run."""
923
+ return handle_workflow_resume(run_id)
924
+
925
+
926
+ @mcp.tool
927
+ def nexo_workflow_replay(run_id: str, limit: int = 20) -> str:
928
+ """Replay recent checkpoints for a workflow run."""
929
+ return handle_workflow_replay(run_id, limit)
930
+
931
+
932
+ @mcp.tool
933
+ def nexo_workflow_list(status: str = "", include_closed: bool = False, limit: int = 20) -> str:
934
+ """List durable workflow runs."""
935
+ return handle_workflow_list(status, include_closed, limit)
936
+
937
+
819
938
  @mcp.tool
820
939
  def nexo_status(keyword: str = "") -> str:
821
940
  """List active sessions. Filter by keyword if provided."""
@@ -18,12 +18,73 @@ before persisting. The agent should never mint a BYOK entry on its own.
18
18
 
19
19
  import json
20
20
  import os
21
+ import re
21
22
  from pathlib import Path
22
23
 
23
24
  from db import create_credential, update_credential, delete_credential, get_credential, list_credentials, get_db
24
25
 
25
26
 
26
27
  BYOK_SERVICE = "byok"
28
+ REDACTED_SECRET_NOTE = "[redacted: secret-like note]"
29
+ SECRET_NOTE_PATTERNS = (
30
+ re.compile(r"\b(?:npm|ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{16,}\b"),
31
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
32
+ re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"),
33
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{20,}\b"),
34
+ re.compile(r"\b(?:api[_-]?key|secret|token|password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE),
35
+ re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{20,}\b", re.IGNORECASE),
36
+ re.compile(r"\b[A-Za-z0-9+/=_-]{40,}\b"),
37
+ )
38
+
39
+
40
+ def credential_note_has_secret(notes: str) -> bool:
41
+ """Detect notes that appear to contain a credential value."""
42
+ clean = (notes or "").strip()
43
+ if not clean:
44
+ return False
45
+ return any(pattern.search(clean) for pattern in SECRET_NOTE_PATTERNS)
46
+
47
+
48
+ def redact_credential_notes(notes: str) -> str:
49
+ """Return notes safe for list/dashboard surfaces."""
50
+ clean = notes or ""
51
+ if credential_note_has_secret(clean):
52
+ return REDACTED_SECRET_NOTE
53
+ return clean
54
+
55
+
56
+ def public_credential_records(service: str = "") -> list[dict]:
57
+ """List credential metadata from DB and BYOK without leaking values."""
58
+ requested = (service or "").strip()
59
+ records: list[dict] = []
60
+
61
+ if requested != BYOK_SERVICE:
62
+ for row in list_credentials(requested if requested else None):
63
+ records.append(
64
+ {
65
+ "service": row.get("service", ""),
66
+ "key": row.get("key", ""),
67
+ "notes": redact_credential_notes(row.get("notes") or ""),
68
+ "created_at": row.get("created_at", ""),
69
+ "updated_at": row.get("updated_at", ""),
70
+ "backend": "db",
71
+ }
72
+ )
73
+
74
+ if requested in ("", BYOK_SERVICE):
75
+ for row in _byok_get(""):
76
+ records.append(
77
+ {
78
+ "service": row.get("service", BYOK_SERVICE),
79
+ "key": row.get("key", ""),
80
+ "notes": redact_credential_notes(row.get("notes") or ""),
81
+ "created_at": "",
82
+ "updated_at": "",
83
+ "backend": "byok_local",
84
+ }
85
+ )
86
+
87
+ return records
27
88
 
28
89
 
29
90
  def _credential_exists(service: str, key: str) -> bool:
@@ -193,6 +254,11 @@ def handle_credential_create(service: str, key: str, value: str, notes: str = ''
193
254
  "connect the provider there (the UI validates the key with the "
194
255
  "provider before saving)."
195
256
  )
257
+ if credential_note_has_secret(notes):
258
+ return (
259
+ "ERROR: Credential notes look like they contain a secret. "
260
+ "Put the secret in value, and keep notes operational only."
261
+ )
196
262
  # ── R02 (Fase 2 Protocol Enforcer): reject exact (service, key) duplicates ──
197
263
  force_flag = str(force or "").strip().lower() in {"1", "true", "yes", "on"}
198
264
  if not force_flag and _credential_exists(service, key):
@@ -223,6 +289,11 @@ def handle_credential_update(service: str, key: str, value: str = '', notes: str
223
289
  "ERROR: BYOK credentials are not editable from the agent. "
224
290
  "Ask the user to update the connection in NEXO Desktop > Settings > Connections."
225
291
  )
292
+ if credential_note_has_secret(notes):
293
+ return (
294
+ "ERROR: Credential notes look like they contain a secret. "
295
+ "Put the secret in value, and keep notes operational only."
296
+ )
226
297
  result = update_credential(
227
298
  service,
228
299
  key,
@@ -264,17 +335,14 @@ def handle_credential_list(service: str = '') -> str:
264
335
  Listing without ``service`` only returns DB entries (the historical
265
336
  behaviour). Pass ``service='byok'`` to list the BYOK filesystem store.
266
337
  """
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"
338
+ results = public_credential_records(service)
339
+ label = service if service else "ALL"
273
340
  if not results:
274
341
  return f"CREDENTIALS {label.upper()}: No entries."
275
342
  lines = [f"CREDENTIALS {label.upper()} ({len(results)}):"]
276
343
  for r in results:
277
344
  notes = r.get("notes") or ""
278
345
  suffix = f" — {notes}" if notes else ""
279
- lines.append(f" {r['service']}/{r['key']}{suffix}")
346
+ backend = r.get("backend") or "db"
347
+ lines.append(f" {r['service']}/{r['key']} ({backend}){suffix}")
280
348
  return "\n".join(lines)