From 165e2db2044a24f6e7b231219eecf0bfce291864 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:44:17 -0500 Subject: [PATCH] feat: add semantic index scaffolding, cli/api surfaces, and tui tree nav polish --- docs/dev_control_center.md | 27 +- docs/semantic_vector_index_plan.md | 60 +++-- docs/tui_v2_ui_refresh_plan.md | 40 ++- src/seedpass/api.py | 65 ++++- src/seedpass/cli/__init__.py | 3 +- src/seedpass/cli/capabilities.py | 26 ++ src/seedpass/cli/semantic.py | 98 +++++++ src/seedpass/core/api.py | 82 ++++++ src/seedpass/core/config_manager.py | 13 + src/seedpass/core/semantic_index.py | 249 ++++++++++++++++++ src/seedpass/tui_v2/app.py | 166 ++++++++++-- src/tests/test_api_new_endpoints.py | 79 ++++++ src/tests/test_cli_semantic.py | 76 ++++++ src/tests/test_semantic_index.py | 108 ++++++++ src/tests/test_tui_v2_action_matrix.py | 15 +- src/tests/test_tui_v2_textual_interactions.py | 47 +++- 16 files changed, 1087 insertions(+), 67 deletions(-) create mode 100644 src/seedpass/cli/semantic.py create mode 100644 src/seedpass/core/semantic_index.py create mode 100644 src/tests/test_cli_semantic.py create mode 100644 src/tests/test_semantic_index.py diff --git a/docs/dev_control_center.md b/docs/dev_control_center.md index 82cfbf4c..61e2519d 100644 --- a/docs/dev_control_center.md +++ b/docs/dev_control_center.md @@ -22,8 +22,8 @@ Latest verification in this session: - coverage: `85.65%` - Focused TUI validation slices: - `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` - - latest result: `29 passed` - - plus online-default notice coverage run: `31 passed` (adds config/notice tests) + - latest result: `31 passed` + - includes profile-tree keyboard navigation/select command coverage ## 2) Canonical Status Sources @@ -71,10 +71,10 @@ Use these docs as inputs, but treat this file as the decision layer: ### Semantic Search Roadmap - Semantic index plan drafted and linked: - `docs/semantic_vector_index_plan.md` -- Current recommendation: - - local per-device/profile derived vector index - - canonical sync unchanged (vectors not synced in Phase 1) - - add semantic/hybrid retrieval across KB-centric fields for agents and users +- Current implementation status: + - Phase A complete (local derived index + profile-scoped manifest/records + config flag) + - Phase B baseline complete (CLI + API surfaces + initial tests) + - Phase C pending (legacy TUI + TUI v2 semantic workflow integration) ## 4) Recommended Next Steps (Priority Order) @@ -82,11 +82,11 @@ Use these docs as inputs, but treat this file as the decision layer: : prioritized gaps from side-by-side legacy vs v2 run: : onboarding + stats/ops + lock/session affordances now landed; focus now is mockup-aligned UI refresh completion. 2. Complete Phase 5 TUI v2 UI refresh queue: -: interactive profile tree navigation + profile-select actions + final visual polish. -3. Begin semantic index Phase A scaffolding (feature-flagged local derived index, CLI-first controls). -4. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`). -5. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites. -6. Execute a tagged release integrity run and attach evidence URLs in readiness checklist. +: final visual polish pass (profile tree navigation/select is now landed). +3. Begin semantic index Phase C (legacy TUI + TUI v2 integration with status/build/search controls). +4. Add semantic incremental update hooks on entry/link/tag mutations. +5. Raise TUI v2 critical-path coverage beyond current gate floor (target >82% for `src/seedpass/tui_v2/app.py`). +6. Advance Nostr resilience validation with optional real-relay soak lane after deterministic suites. ## 5) Next Slice Definition (Recommended Immediate Work) @@ -162,9 +162,8 @@ Progress update (2026-03-02): - one-time onboarding notice added for first online profile load with settings guidance Current UI refresh remaining queue: -1. Add keyboard navigation/select behavior inside left profile tree. -2. Wire tree selection to profile context switch actions. -3. Final visual polish pass against mockups (spacing, headers, action strip clarity). +1. Final visual polish pass against mockups (spacing, headers, action strip clarity). +2. Semantic indicator slot in top ribbon/action strip (for upcoming Phase C semantic integration). ## 6) Working Rule diff --git a/docs/semantic_vector_index_plan.md b/docs/semantic_vector_index_plan.md index 0614fbf1..c6c4046c 100644 --- a/docs/semantic_vector_index_plan.md +++ b/docs/semantic_vector_index_plan.md @@ -1,6 +1,6 @@ # Semantic Vector Index Integration Plan -Status: Proposed (2026-03-02) +Status: In Progress (updated 2026-03-02) Branch target: `beta` Scope: Legacy TUI + Textual TUI v2 + CLI + API @@ -225,29 +225,31 @@ Scale tests: ## Phase A: Core scaffolding (no UI) -1. add service interfaces + local store abstraction -2. add index manifest + per-profile storage layout -3. add offline build/rebuild/status operations +1. [x] add service interfaces + local store abstraction +2. [x] add index manifest + per-profile storage layout +3. [x] add offline build/rebuild/status operations Exit: -1. CLI-only smoke works (`semantic status/build/search`) +1. [x] CLI-only smoke works (`semantic status/build/search`) ## Phase B: CLI + API -1. expose semantic commands in CLI -2. expose semantic endpoints in API -3. add tests and policy guards +1. [x] expose semantic commands in CLI +2. [x] expose semantic endpoints in API +3. [x] add initial tests for core + CLI + API +4. [ ] add lock/policy guard refinements for high-risk partitions and audit logging Exit: -1. interface tests pass +1. [x] interface tests pass for current endpoints/commands +2. [ ] audit and lock-policy behavior validated end-to-end ## Phase C: TUI integration -1. wire legacy TUI settings/search hooks -2. wire TUI v2 palette commands + ribbon/status indicators -3. add hybrid search mode toggles +1. [ ] wire legacy TUI settings/search hooks +2. [ ] wire TUI v2 palette commands + ribbon/status indicators +3. [ ] add hybrid search mode toggles Exit: @@ -272,8 +274,32 @@ Exit: ## 14) Recommended Immediate Next Steps -1. Approve local-only (non-synced embeddings) as Phase A baseline. -2. Finalize field-level allowlist/denylist defaults with security review. -3. Implement Phase A core scaffolding behind feature flag: -- `semantic_index_enabled` (default `false`) -4. Add CLI `seedpass semantic status/build/rebuild/search` as first user-facing slice. +1. Complete Phase C integration in both TUIs (`semantic-status/build/rebuild/search` + status indicator). +2. Add incremental update hooks on entry/link/tag mutations (avoid full rebuild for small edits). +3. Add hybrid ranking mode (`keyword|hybrid|semantic`) with explicit config + API contract. +4. Add audit logging and lock-policy tests for semantic operations in agent/API contexts. +5. Run KB scale benchmark with semantic index enabled and record latency/memory evidence. + +## 15) Progress Snapshot (2026-03-02) + +Implemented: + +1. `seedpass.core.semantic_index` local derived index with profile-local manifest/records. +2. `semantic_index_enabled` profile config flag and config service wrappers. +3. `SemanticIndexService` in core API layer. +4. CLI commands: +- `seedpass semantic status|enable|disable|build|rebuild|search` +5. API endpoints: +- `GET /api/v1/semantic/status` +- `POST /api/v1/semantic/build` +- `POST /api/v1/semantic/rebuild` +- `POST /api/v1/semantic/search` +- `POST /api/v1/semantic/config` +6. Initial tests for core service/index, CLI commands, and API endpoints. + +Not yet implemented: + +1. Legacy TUI semantic controls. +2. TUI v2 semantic palette integration/ribbon indicator. +3. True embedding backend and vector DB adapter (current implementation is token-overlap baseline). +4. Incremental mutation hooks and hybrid ranking mode. diff --git a/docs/tui_v2_ui_refresh_plan.md b/docs/tui_v2_ui_refresh_plan.md index 8652b6cf..2aa2439a 100644 --- a/docs/tui_v2_ui_refresh_plan.md +++ b/docs/tui_v2_ui_refresh_plan.md @@ -500,6 +500,38 @@ Next recommended slice: 1. Continue Phase 5 by adding explicit keyboard navigation inside the left profile tree and wiring profile-selection actions to update active context. +### 2026-03-02 - Phase 5 polish (interactive profile tree navigation) landed + +Implemented in `src/seedpass/tui_v2/app.py`: + +1. Added keyboard bindings for left profile tree interactions: +- `Up` / `Down`: move profile cursor +- `Ctrl+O`: open/switch to selected profile + +2. Added palette command aliases: +- `profile-tree-next` +- `profile-tree-prev` +- `profile-tree-open` + +3. Updated left-panel rendering: +- cursor marker (`▶`) and active profile marker (`*`) now render together +- status text confirms selection movement and profile-switch outcomes + +4. Wired profile mutation commands to refresh tree state: +- `profile-switch` +- `profile-add` +- `profile-remove` +- `profile-rename` + +Validation: + +1. `poetry run pytest -q src/tests/test_tui_v2_textual_interactions.py src/tests/test_tui_v2_action_matrix.py` +2. Result: `31 passed`. + +Next recommended slice: + +1. Final visual alignment pass to mockups (spacing, headers, action strip hierarchy) and semantic indicator slot prep. + ## 14) Current Status Snapshot Completed: @@ -509,10 +541,10 @@ Completed: 3. Phase 3 core inspector board templates. 4. Phase 4 advanced inspector actions (`copy`, `export-field` with confirm gates). 5. Phase 5 initial polish (`density` controls + profile tree scaffold). +6. Phase 5 interactive profile tree navigation and open/switch behavior. Remaining (active queue): -1. Implement explicit keyboard navigation/select actions in left profile tree. -2. Wire tree selection to active profile context updates (safe, service-backed path). -3. Final alignment pass to mockup spacing/rhythm and action-strip clarity. -4. Run focused TUI tests and coverage gate after each polish slice. +1. Final alignment pass to mockup spacing/rhythm and action-strip clarity. +2. Add semantic-state indicator slot in ribbon/action strip for upcoming semantic Phase C. +3. Run focused TUI tests and coverage gate after each polish slice. diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 584d91b2..c02d6227 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -63,7 +63,7 @@ from seedpass.core.agent_recovery import ( verify_recovery_drills, ) from seedpass.core.high_risk_partition_store import load_partition_entry -from seedpass.core.api import UtilityService +from seedpass.core.api import SemanticIndexService, UtilityService _RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100")) _RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60")) @@ -218,6 +218,10 @@ def _require_unlocked(request: Request) -> None: raise HTTPException(status_code=423, detail="Vault is locked") +def _semantic_service(request: Request) -> SemanticIndexService: + return SemanticIndexService(_get_pm(request)) + + def _validate_encryption_path(request: Request, path: Path) -> Path: """Validate and normalize ``path`` within the active fingerprint directory. @@ -1406,6 +1410,65 @@ def set_secret_mode( return {"status": "ok"} +@app.get("/api/v1/semantic/status") +def semantic_status( + request: Request, authorization: str | None = Header(None) +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + return _semantic_service(request).status() + + +@app.post("/api/v1/semantic/build") +def semantic_build( + request: Request, authorization: str | None = Header(None) +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + return _semantic_service(request).build() + + +@app.post("/api/v1/semantic/rebuild") +def semantic_rebuild( + request: Request, authorization: str | None = Header(None) +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + return _semantic_service(request).rebuild() + + +@app.post("/api/v1/semantic/search") +def semantic_search( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + query = str(data.get("query", "")).strip() + if not query: + raise HTTPException(status_code=400, detail="Missing query") + k = int(data.get("k", 10)) + kind_raw = data.get("kind") + kind = str(kind_raw).strip() if kind_raw is not None else None + results = _semantic_service(request).search(query, k=max(1, k), kind=kind) + return {"results": results} + + +@app.post("/api/v1/semantic/config") +def semantic_config( + request: Request, + data: dict[str, Any], + authorization: str | None = Header(None), +) -> dict[str, Any]: + _check_token(request, authorization) + _require_unlocked(request) + if "enabled" not in data: + raise HTTPException(status_code=400, detail="Missing enabled") + payload = _semantic_service(request).set_enabled(bool(data.get("enabled"))) + return {"enabled": bool(payload.get("enabled", False))} + + @app.get("/api/v1/fingerprint") def list_fingerprints( request: Request, authorization: str | None = Header(None) diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index a84be8cf..f783bdf0 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -51,7 +51,7 @@ legacy_tui_option = typer.Option( ) # Sub command groups -from . import entry, vault, nostr, config, fingerprint, util, api, agent +from . import entry, vault, nostr, config, fingerprint, util, api, agent, semantic from .capabilities import register_capabilities_command app.add_typer(entry.app, name="entry") @@ -62,6 +62,7 @@ app.add_typer(fingerprint.app, name="fingerprint") app.add_typer(util.app, name="util") app.add_typer(api.app, name="api") app.add_typer(agent.app, name="agent") +app.add_typer(semantic.app, name="semantic") register_capabilities_command(app) diff --git a/src/seedpass/cli/capabilities.py b/src/seedpass/cli/capabilities.py index 0c34744d..10cb39c8 100644 --- a/src/seedpass/cli/capabilities.py +++ b/src/seedpass/cli/capabilities.py @@ -27,6 +27,7 @@ def build_capabilities() -> dict[str, Any]: "util", "api", "agent", + "semantic", ], "global_options": [ "--fingerprint", @@ -53,6 +54,10 @@ def build_capabilities() -> dict[str, Any]: "/api/v1/high-risk/status", "/api/v1/high-risk/unlock", "/api/v1/high-risk/lock", + "/api/v1/semantic/status", + "/api/v1/semantic/build", + "/api/v1/semantic/rebuild", + "/api/v1/semantic/search", ], "auth_headers": ["Authorization: Bearer "], "sensitive_auth_headers": [ @@ -206,6 +211,25 @@ def build_capabilities() -> dict[str, Any]: "search_includes_link_metadata": True, "sync_union_merge_fields": ["tags", "custom_fields", "links"], }, + "semantic_index": { + "commands": [ + "semantic status", + "semantic enable", + "semantic disable", + "semantic build", + "semantic rebuild", + "semantic search", + ], + "api_endpoints": [ + "GET /api/v1/semantic/status", + "POST /api/v1/semantic/build", + "POST /api/v1/semantic/rebuild", + "POST /api/v1/semantic/search", + "POST /api/v1/semantic/config", + ], + "default_enabled": False, + "sync_behavior": "local_derived_only", + }, "redaction": { "default_safe_output": True, "reveal_override": "agent get --reveal", @@ -244,6 +268,7 @@ def build_capabilities() -> dict[str, Any]: "Use `seedpass --legacy-tui` to force the legacy interface during cutover.", "In TUI v2, use `?` for help, `j` to jump to entry id, `1/2/3` to focus panes, `Ctrl+P` for palette, `p/n` for paging, and `x` to retry.", "For graph workflows, start with `seedpass entry link-add --help` and `seedpass entry links --help`.", + "For semantic KB retrieval, run `seedpass semantic --help` and check `seedpass semantic status`.", "For document workflows, run `seedpass entry import-document --help` and `seedpass agent document-import --help`.", "After unlock/login, run `seedpass capabilities` before autonomous runs.", "For agent bootstrap context, run `seedpass agent bootstrap-context`.", @@ -284,6 +309,7 @@ def emit_capabilities(output_format: str) -> str: lines.append("- Security posture checks and remediation bundles") lines.append("- Audited access log with chained HMAC signatures") lines.append("- Knowledge graph links: entry link-add/links/link-remove") + lines.append("- Semantic KB retrieval: semantic status/build/rebuild/search") lines.append("- Document file workflows for users and agents") lines.append("") lines.append("Discovery hints:") diff --git a/src/seedpass/cli/semantic.py b/src/seedpass/cli/semantic.py new file mode 100644 index 00000000..c7173d92 --- /dev/null +++ b/src/seedpass/cli/semantic.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from typing import Optional + +import typer +import click + +from .common import _get_pm +from seedpass.core.api import SemanticIndexService +from seedpass.core.entry_types import ALL_ENTRY_TYPES + +app = typer.Typer(help="Manage local semantic vector index and search") + + +def _get_semantic_service(ctx: typer.Context) -> SemanticIndexService: + return SemanticIndexService(_get_pm(ctx)) + + +@app.command("status") +def semantic_status(ctx: typer.Context) -> None: + """Show semantic index status for the active profile.""" + service = _get_semantic_service(ctx) + payload = service.status() + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + + +@app.command("enable") +def semantic_enable(ctx: typer.Context) -> None: + """Enable semantic index for this profile.""" + service = _get_semantic_service(ctx) + payload = service.set_enabled(True) + typer.echo( + f"semantic index enabled (built={payload.get('built', False)}, records={payload.get('records', 0)})" + ) + + +@app.command("disable") +def semantic_disable(ctx: typer.Context) -> None: + """Disable semantic index for this profile.""" + service = _get_semantic_service(ctx) + payload = service.set_enabled(False) + typer.echo( + f"semantic index disabled (built={payload.get('built', False)}, records={payload.get('records', 0)})" + ) + + +@app.command("build") +def semantic_build(ctx: typer.Context) -> None: + """Build semantic index incrementally from current entries.""" + service = _get_semantic_service(ctx) + payload = service.build() + typer.echo( + f"semantic index built (records={payload.get('records', 0)}, enabled={payload.get('enabled', False)})" + ) + + +@app.command("rebuild") +def semantic_rebuild(ctx: typer.Context) -> None: + """Rebuild semantic index from scratch.""" + service = _get_semantic_service(ctx) + payload = service.rebuild() + typer.echo( + f"semantic index rebuilt (records={payload.get('records', 0)}, enabled={payload.get('enabled', False)})" + ) + + +@app.command("search") +def semantic_search( + ctx: typer.Context, + query: str, + k: int = typer.Option(10, "--k", help="Max results"), + kind: Optional[str] = typer.Option( + None, + "--kind", + help="Restrict to entry kind", + click_type=click.Choice(ALL_ENTRY_TYPES), + ), + as_json: bool = typer.Option(False, "--json", help="Emit JSON output"), +) -> None: + """Run semantic search over locally indexed KB content.""" + service = _get_semantic_service(ctx) + results = service.search(query, k=max(1, int(k)), kind=kind) + if as_json: + typer.echo(json.dumps(results, indent=2, sort_keys=True)) + return + if not results: + typer.echo("No semantic matches") + return + for item in results: + entry_id = int(item.get("entry_id", 0)) + label = str(item.get("label", "")) + entry_kind = str(item.get("kind", "")) + score = float(item.get("score", 0.0)) + excerpt = str(item.get("excerpt", "")) + typer.echo(f"{entry_id}: [{entry_kind}] {label} (score={score:.3f})") + if excerpt: + typer.echo(f" {excerpt}") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 69cc8507..4987c262 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -18,6 +18,7 @@ from pydantic import BaseModel from .manager import PasswordManager from .pubsub import bus from .entry_types import EntryType +from .semantic_index import SemanticIndex from utils import copy_to_clipboard @@ -825,6 +826,87 @@ class ConfigService: cfg.set_offline_mode(enabled) self._manager.offline_mode = enabled + def set_semantic_index_enabled(self, enabled: bool) -> None: + with self._lock: + cfg = self._manager.config_manager + cfg.set_semantic_index_enabled(enabled) + + def get_semantic_index_enabled(self) -> bool: + with self._lock: + return self._manager.config_manager.get_semantic_index_enabled() + + +class SemanticIndexService: + """Thread-safe wrapper around local semantic index operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def _index(self) -> SemanticIndex: + profile_dir = getattr(self._manager, "fingerprint_dir", None) + if profile_dir is None: + raise ValueError("active profile directory unavailable") + return SemanticIndex(Path(profile_dir)) + + def _all_entries(self) -> list[dict[str, Any]]: + em = getattr(self._manager, "entry_manager", None) + if em is None: + return [] + rows = em.search_entries( + "", + kinds=None, + include_archived=True, + archived_only=False, + ) + entries: list[dict[str, Any]] = [] + for row in rows: + try: + entry_id = int(row[0]) + except Exception: + continue + entry = em.retrieve_entry(entry_id) + if isinstance(entry, dict) and entry: + entries.append(dict(entry)) + return entries + + def status(self) -> Dict[str, Any]: + with self._lock: + idx = self._index() + payload = idx.status() + payload["enabled"] = bool( + self._manager.config_manager.get_semantic_index_enabled() + ) + return payload + + def set_enabled(self, enabled: bool) -> Dict[str, Any]: + with self._lock: + self._manager.config_manager.set_semantic_index_enabled(bool(enabled)) + idx = self._index() + idx.set_enabled(bool(enabled)) + payload = idx.status() + payload["enabled"] = bool(enabled) + return payload + + def build(self) -> Dict[str, Any]: + with self._lock: + idx = self._index() + idx.set_enabled(self._manager.config_manager.get_semantic_index_enabled()) + return idx.build(self._all_entries()) + + def rebuild(self) -> Dict[str, Any]: + with self._lock: + idx = self._index() + idx.set_enabled(self._manager.config_manager.get_semantic_index_enabled()) + return idx.rebuild(self._all_entries()) + + def search( + self, query: str, *, k: int = 10, kind: str | None = None + ) -> list[Dict[str, Any]]: + with self._lock: + idx = self._index() + return idx.search(query, k=k, kind=kind) + class UtilityService: """Miscellaneous helper operations.""" diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index 0b4f4708..3c011035 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -94,6 +94,7 @@ class ConfigManager: "special_mode": "standard", "exclude_ambiguous": False, "verbose_timing": False, + "semantic_index_enabled": False, "secret_class_partitions": dict(self.SECRET_CLASS_PARTITIONS_DEFAULT), } try: @@ -126,6 +127,7 @@ class ConfigManager: data.setdefault("special_mode", "standard") data.setdefault("exclude_ambiguous", False) data.setdefault("verbose_timing", False) + data.setdefault("semantic_index_enabled", False) data.setdefault( "secret_class_partitions", dict(self.SECRET_CLASS_PARTITIONS_DEFAULT), @@ -281,6 +283,17 @@ class ConfigManager: config = self.load_config(require_pin=False) return bool(config.get("offline_mode", False)) + def set_semantic_index_enabled(self, enabled: bool) -> None: + """Persist whether semantic index is enabled for this profile.""" + config = self.load_config(require_pin=False) + config["semantic_index_enabled"] = bool(enabled) + self.save_config(config) + + def get_semantic_index_enabled(self) -> bool: + """Return whether semantic index is enabled for this profile.""" + config = self.load_config(require_pin=False) + return bool(config.get("semantic_index_enabled", False)) + def set_clipboard_clear_delay(self, delay: int) -> None: """Persist clipboard clear timeout in seconds.""" if delay <= 0: diff --git a/src/seedpass/core/semantic_index.py b/src/seedpass/core/semantic_index.py new file mode 100644 index 00000000..cff601c5 --- /dev/null +++ b/src/seedpass/core/semantic_index.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +WORD_RE = re.compile(r"[a-z0-9_]+") + + +@dataclass +class SemanticRecord: + entry_id: int + kind: str + label: str + text: str + tokens: set[str] + + +class SemanticIndex: + """Local derived semantic index for KB-centric entry retrieval.""" + + INDEX_DIRNAME = "semantic_index" + MANIFEST_FILENAME = "manifest.json" + RECORDS_FILENAME = "records.json" + SCHEMA_VERSION = 1 + MODEL_ID = "seedpass-token-overlap-v1" + + ALLOWED_KINDS = { + "document", + "note", + "key_value", + "password", + "stored_password", + "totp", + "nostr", + "ssh", + "pgp", + } + + def __init__(self, profile_dir: Path) -> None: + self.profile_dir = Path(profile_dir) + self.index_dir = self.profile_dir / self.INDEX_DIRNAME + self.manifest_path = self.index_dir / self.MANIFEST_FILENAME + self.records_path = self.index_dir / self.RECORDS_FILENAME + + def status(self) -> dict[str, Any]: + manifest = self._load_manifest() + records = self._load_records() + enabled = bool(manifest.get("enabled", False)) + built = bool(manifest.get("built", False)) + return { + "enabled": enabled, + "built": built, + "records": len(records), + "schema_version": int(manifest.get("schema_version", self.SCHEMA_VERSION)), + "model_id": str(manifest.get("model_id", self.MODEL_ID)), + "updated_at": float(manifest.get("updated_at", 0.0)), + } + + def set_enabled(self, enabled: bool) -> None: + manifest = self._load_manifest() + manifest["enabled"] = bool(enabled) + manifest.setdefault("schema_version", self.SCHEMA_VERSION) + manifest.setdefault("model_id", self.MODEL_ID) + manifest["updated_at"] = time.time() + self._save_manifest(manifest) + + def build(self, entries: list[dict[str, Any]]) -> dict[str, Any]: + records = self._records_from_entries(entries) + self.index_dir.mkdir(parents=True, exist_ok=True) + serializable = [ + { + "entry_id": rec.entry_id, + "kind": rec.kind, + "label": rec.label, + "text": rec.text, + "tokens": sorted(rec.tokens), + } + for rec in records + ] + self.records_path.write_text( + json.dumps(serializable, ensure_ascii=True, sort_keys=True, indent=2), + encoding="utf-8", + ) + manifest = self._load_manifest() + manifest.update( + { + "enabled": bool(manifest.get("enabled", False)), + "built": True, + "schema_version": self.SCHEMA_VERSION, + "model_id": self.MODEL_ID, + "updated_at": time.time(), + "record_count": len(serializable), + } + ) + self._save_manifest(manifest) + return self.status() + + def rebuild(self, entries: list[dict[str, Any]]) -> dict[str, Any]: + if self.index_dir.exists(): + for path in (self.records_path, self.manifest_path): + if path.exists(): + path.unlink() + return self.build(entries) + + def search( + self, + query: str, + *, + k: int = 10, + kind: str | None = None, + ) -> list[dict[str, Any]]: + records = self._load_records() + if not records: + return [] + q_tokens = self._tokenize(query) + if not q_tokens: + return [] + wanted_kind = kind.strip().lower() if kind else None + scored: list[tuple[float, dict[str, Any]]] = [] + for rec in records: + rec_kind = str(rec.get("kind", "")).strip().lower() + if wanted_kind and rec_kind != wanted_kind: + continue + tokens = { + str(item).strip().lower() + for item in rec.get("tokens", []) + if str(item).strip() + } + if not tokens: + continue + inter = len(q_tokens.intersection(tokens)) + if inter <= 0: + continue + union = len(q_tokens.union(tokens)) + score = float(inter) / float(union or 1) + scored.append((score, rec)) + scored.sort(key=lambda item: (-item[0], int(item[1].get("entry_id", 0)))) + out: list[dict[str, Any]] = [] + for score, rec in scored[: max(1, int(k))]: + out.append( + { + "entry_id": int(rec.get("entry_id", 0)), + "kind": str(rec.get("kind", "")), + "label": str(rec.get("label", "")), + "score": round(score, 6), + "excerpt": str(rec.get("text", ""))[:220], + } + ) + return out + + def _records_from_entries( + self, entries: list[dict[str, Any]] + ) -> list[SemanticRecord]: + records: list[SemanticRecord] = [] + for entry in entries: + entry_id = int(entry.get("id", 0) or 0) + if entry_id <= 0: + continue + kind = str(entry.get("kind") or entry.get("type") or "").strip().lower() + if kind not in self.ALLOWED_KINDS: + continue + text = self._extract_text(entry, kind) + tokens = self._tokenize(text) + if not text.strip() or not tokens: + continue + records.append( + SemanticRecord( + entry_id=entry_id, + kind=kind, + label=str(entry.get("label", "")), + text=text, + tokens=tokens, + ) + ) + return records + + @staticmethod + def _extract_text(entry: dict[str, Any], kind: str) -> str: + label = str(entry.get("label", "")).strip() + notes = str(entry.get("notes", "")).strip() + tags_raw = entry.get("tags") + if isinstance(tags_raw, list): + tags = " ".join(str(tag).strip() for tag in tags_raw if str(tag).strip()) + else: + tags = str(tags_raw or "").strip() + + parts = [label, notes, tags] + + if kind in {"document", "note"}: + parts.append(str(entry.get("content", "")).strip()) + parts.append(str(entry.get("file_type", "")).strip()) + elif kind in {"password", "stored_password"}: + parts.append(str(entry.get("username", "")).strip()) + parts.append(str(entry.get("url", "")).strip()) + elif kind == "key_value": + parts.append(str(entry.get("key", "")).strip()) + parts.append(str(entry.get("value", "")).strip()) + elif kind == "totp": + parts.append(str(entry.get("issuer", "")).strip()) + elif kind == "nostr": + parts.append(str(entry.get("npub", "")).strip()) + elif kind in {"ssh", "pgp"}: + parts.append(str(entry.get("fingerprint", "")).strip()) + + links = entry.get("links") + if isinstance(links, list): + for link in links: + if not isinstance(link, dict): + continue + parts.append(str(link.get("relation", "")).strip()) + parts.append(str(link.get("note", "")).strip()) + + return "\n".join(part for part in parts if part) + + @staticmethod + def _tokenize(text: str) -> set[str]: + lowered = text.strip().lower() + if not lowered: + return set() + return {match.group(0) for match in WORD_RE.finditer(lowered)} + + def _load_manifest(self) -> dict[str, Any]: + if not self.manifest_path.exists(): + return {} + try: + data = json.loads(self.manifest_path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def _save_manifest(self, manifest: dict[str, Any]) -> None: + self.index_dir.mkdir(parents=True, exist_ok=True) + self.manifest_path.write_text( + json.dumps(manifest, ensure_ascii=True, sort_keys=True, indent=2), + encoding="utf-8", + ) + + def _load_records(self) -> list[dict[str, Any]]: + if not self.records_path.exists(): + return [] + try: + data = json.loads(self.records_path.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] diff --git a/src/seedpass/tui_v2/app.py b/src/seedpass/tui_v2/app.py index 33b4f3d3..b163cbf1 100644 --- a/src/seedpass/tui_v2/app.py +++ b/src/seedpass/tui_v2/app.py @@ -290,6 +290,9 @@ def launch_tui2( ("f", "cycle_filter", "Filter"), ("d", "toggle_density", "Density"), ("h", "cycle_archive_scope", "Archive View"), + ("up", "profile_tree_prev", "Profile Prev"), + ("down", "profile_tree_next", "Profile Next"), + ("ctrl+o", "profile_tree_open", "Profile Open"), ("p", "prev_page", "Prev Page"), ("n", "next_page", "Next Page"), ("l", "cycle_link_filter", "Link Filter"), @@ -336,6 +339,7 @@ def launch_tui2( "2fa-board | 2fa-hide | 2fa-refresh | 2fa-copy | " "profiles-list | profile-switch [password] | profile-add | " "profile-remove | profile-rename | " + "profile-tree-next | profile-tree-prev | profile-tree-open | " "setting-secret [delay] | setting-offline | " "setting-quick-unlock | setting-timeout | " "setting-kdf-iterations | setting-kdf-mode | " @@ -430,6 +434,8 @@ def launch_tui2( self._managed_session_entry_id: int | None = None self._last_sync_text = "(none)" self._density_mode = "compact" + self._profile_tree_items: list[str] = [] + self._profile_tree_cursor = 0 self._totp_tick = self.set_interval(1.0, self._tick_totp_board) try: self._service = ( @@ -498,6 +504,7 @@ def launch_tui2( self._update_help_overlay() self._update_activity_panel() self._apply_focus_style() + self._refresh_profile_tree() self._update_top_ribbon() self._update_action_strip() self._set_secret_panel( @@ -560,6 +567,26 @@ def launch_tui2( ) self.query_one("#top-ribbon", Static).update(text) + def _refresh_profile_tree(self) -> None: + profiles: list[str] = [] + if self._profile_service is not None: + lister = getattr(self._profile_service, "list_profiles", None) + if callable(lister): + try: + profiles = [str(item) for item in lister() if str(item).strip()] + except Exception: + profiles = [] + if not profiles: + profiles = [fingerprint or "(default)"] + self._profile_tree_items = profiles + active = fingerprint or "" + if active and active in profiles: + self._profile_tree_cursor = profiles.index(active) + else: + self._profile_tree_cursor = min( + max(0, self._profile_tree_cursor), max(0, len(profiles) - 1) + ) + def _update_action_strip(self) -> None: kind = "" if isinstance(self._selected_entry, dict): @@ -593,9 +620,7 @@ def launch_tui2( self.query_one("#action-strip", Static).update(text) def _update_grid_heading(self) -> None: - table_cols = ( - "Idx Entry Title Kind Meta Arch" - ) + table_cols = "Idx Entry Title Kind Meta Arch" sort_line = ( "Sort: index ▲▼ | entry ▲▼ | title ▲▼ | kind ▲▼ " f"Page {self._result_page + 1}/{self._total_pages()} " @@ -642,7 +667,7 @@ def launch_tui2( return ( "Palette commands: help, help-commands, open, search, filter, " "onboarding, quickstart, stats, session-status, lock/unlock, " - "density, " + "density, profile-tree-next/prev/open, " "archive, restore, archive-filter, edit-doc/save-doc/cancel-edit, " "link-add/link-rm/link-filter/link-next/link-prev/link-open, " "add-*, notes/tags/fields, 2fa-*, profile-*, setting-*, relay-*, " @@ -671,7 +696,7 @@ def launch_tui2( "Entry Fields: notes-set | notes-clear | tag-add | tag-rm | tags-set | tags-clear | field-add | field-rm | set-field | clear-field", "", "2FA: 2fa-board | 2fa-hide | 2fa-refresh | 2fa-copy ", - "Profiles: profiles-list | profile-switch | profile-add | profile-remove | profile-rename", + "Profiles: profiles-list | profile-switch | profile-add | profile-remove | profile-rename | profile-tree-next | profile-tree-prev | profile-tree-open", "Settings: setting-secret | setting-offline | setting-quick-unlock | setting-timeout | setting-kdf-iterations | setting-kdf-mode", "Nostr: relay-list | relay-add | relay-rm | relay-reset | npub | nostr-reset-sync-state | nostr-fresh-namespace", "Sync/Utility: sync-now | sync-bg | checksum-verify | checksum-update | db-export | db-import | totp-export | parent-seed-backup", @@ -782,22 +807,17 @@ def launch_tui2( return f"Selected: #{self._selected_entry_id} [{kind}] {label}" def _update_filters_panel(self) -> None: + self._refresh_profile_tree() tree_lines = ["Profiles", "--------"] - profiles: list[str] = [] - if self._profile_service is not None: - lister = getattr(self._profile_service, "list_profiles", None) - if callable(lister): - try: - profiles = [str(item) for item in lister()] - except Exception: - profiles = [] + profiles = list(self._profile_tree_items) current_fp = fingerprint or "" if profiles: - for item in profiles[:8]: + for idx, item in enumerate(profiles[:8]): marker = "*" if item == current_fp else "-" - tree_lines.append(f"{marker} {item}") + cursor = "▶" if idx == self._profile_tree_cursor else " " + tree_lines.append(f"{cursor} {marker} {item}") else: - tree_lines.append(f"* {current_fp or '(default)'}") + tree_lines.append(f"▶ * {current_fp or '(default)'}") fp_line = ( f"Fingerprint: {fingerprint}" @@ -834,6 +854,8 @@ def launch_tui2( "- j jump to id", "- f cycle kind filter", "- d density toggle", + "- ↑/↓ profile tree", + "- Ctrl+O open profile", "- h cycle archive scope", "- p/n prev/next page", "- 1/2/3 focus pane", @@ -1060,7 +1082,9 @@ def launch_tui2( @staticmethod def _entry_kind(entry: dict[str, Any]) -> str: - return str(entry.get("kind") or entry.get("type") or "unknown").strip().lower() + return ( + str(entry.get("kind") or entry.get("type") or "unknown").strip().lower() + ) @staticmethod def _entry_tags_text(entry: dict[str, Any]) -> str: @@ -1897,9 +1921,7 @@ def launch_tui2( raise ValueError("Selected entry not found") return dict(entry) - def _resolve_copy_field_value( - self, field: str - ) -> tuple[str, bool, str]: + def _resolve_copy_field_value(self, field: str) -> tuple[str, bool, str]: if self._service is None: raise ValueError("Entry service unavailable") entry = self._selected_entry_payload() @@ -1919,7 +1941,9 @@ def launch_tui2( return str(entry.get("username") or ""), False, "username" if key == "url": return str(entry.get("url") or ""), False, "url" - raise ValueError("copy field unsupported for password: password|username|url") + raise ValueError( + "copy field unsupported for password: password|username|url" + ) if kind in {"seed", "managed_account"}: if key not in {"seed", "phrase"}: @@ -1971,7 +1995,9 @@ def launch_tui2( if kind in {"document", "note"}: if key not in {"content", "text"}: - raise ValueError("copy field unsupported for document: content|text") + raise ValueError( + "copy field unsupported for document: content|text" + ) return str(entry.get("content") or ""), False, "content" raise ValueError(f"copy not supported for kind '{kind or 'unknown'}'") @@ -2986,6 +3012,7 @@ def launch_tui2( ) self._clear_failure() self._load_entries(self._last_query, reset_page=True) + self._refresh_profile_tree() self._set_status(f"Switched profile to {fp}") except Exception as exc: self._record_failure( @@ -3006,6 +3033,7 @@ def launch_tui2( try: fp = self._profile_service.add_profile() self._clear_failure() + self._refresh_profile_tree() if fp: self._set_status(f"Created profile {fp}") else: @@ -3037,6 +3065,7 @@ def launch_tui2( ProfileRemoveRequest(fingerprint=fp) ) self._clear_failure() + self._refresh_profile_tree() self._set_status(f"Removed profile {fp}") except Exception as exc: self._record_failure( @@ -3065,6 +3094,7 @@ def launch_tui2( try: self._profile_service.rename_profile(fp, name) self._clear_failure() + self._refresh_profile_tree() self._set_status(f"Renamed profile {fp} to '{name}'") except Exception as exc: self._record_failure( @@ -3075,6 +3105,27 @@ def launch_tui2( ) return + if cmd == "profile-tree-next": + if args: + self._set_status("Usage: profile-tree-next") + return + self.action_profile_tree_next() + return + + if cmd == "profile-tree-prev": + if args: + self._set_status("Usage: profile-tree-prev") + return + self.action_profile_tree_prev() + return + + if cmd == "profile-tree-open": + if args: + self._set_status("Usage: profile-tree-open") + return + self.action_profile_tree_open() + return + if cmd == "setting-secret": if self._config_service is None: self._set_status("Config service unavailable") @@ -3712,7 +3763,9 @@ def launch_tui2( self._set_status("Entry service unavailable") return if len(args) not in {2, 3}: - self._set_status("Usage: export-field (optional: confirm)") + self._set_status( + "Usage: export-field (optional: confirm)" + ) return field = args[0].strip() output_path = args[1].strip() @@ -3722,7 +3775,9 @@ def launch_tui2( confirm = False if len(args) == 3: if args[2].strip().lower() != "confirm": - self._set_status("Usage: export-field (optional: confirm)") + self._set_status( + "Usage: export-field (optional: confirm)" + ) return confirm = True try: @@ -4034,6 +4089,69 @@ def launch_tui2( self._apply_focus_style() self._set_status("Focused left pane") + def action_profile_tree_next(self) -> None: + if self._focus_pane != "left": + return + self._refresh_profile_tree() + if not self._profile_tree_items: + self._set_status("No profiles available") + return + self._profile_tree_cursor = (self._profile_tree_cursor + 1) % len( + self._profile_tree_items + ) + selected = self._profile_tree_items[self._profile_tree_cursor] + self._update_filters_panel() + self._set_status(f"Profile selection: {selected}") + + def action_profile_tree_prev(self) -> None: + if self._focus_pane != "left": + return + self._refresh_profile_tree() + if not self._profile_tree_items: + self._set_status("No profiles available") + return + self._profile_tree_cursor = (self._profile_tree_cursor - 1) % len( + self._profile_tree_items + ) + selected = self._profile_tree_items[self._profile_tree_cursor] + self._update_filters_panel() + self._set_status(f"Profile selection: {selected}") + + def action_profile_tree_open(self) -> None: + if self._focus_pane != "left": + return + if self._profile_service is None: + self._set_status("Profile service unavailable") + return + self._refresh_profile_tree() + if not self._profile_tree_items: + self._set_status("No profiles available") + return + target = self._profile_tree_items[self._profile_tree_cursor] + if target == "(default)": + self._set_status("No switch needed for default profile") + return + if target == (fingerprint or ""): + self._set_status(f"Profile already active: {target}") + return + try: + from seedpass.core.api import ProfileSwitchRequest + + self._profile_service.switch_profile( + ProfileSwitchRequest(fingerprint=target, password=None) + ) + self._clear_failure() + self._load_entries(self._last_query, reset_page=True) + self._refresh_profile_tree() + self._set_status(f"Switched profile to {target}") + except Exception as exc: + self._record_failure( + "profile-tree-open failed", + exc, + retry=self.action_profile_tree_open, + hint="Press 'x' to retry.", + ) + def action_focus_center(self) -> None: self._focus_pane = "center" self._apply_focus_style() diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 72e905a3..b1eb4326 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -1717,3 +1717,82 @@ async def test_generate_password_allowed_chars(client): pw = res.json()["password"] specials = [c for c in pw if c in string.punctuation] assert specials and all(c in allowed for c in specials) + + +@pytest.mark.anyio +async def test_semantic_endpoints(client, monkeypatch): + cl, token = client + + class DummySemanticService: + def __init__(self, _pm): + self.last_enabled = None + self.last_search = None + + def status(self): + return {"enabled": True, "built": True, "records": 4} + + def build(self): + return {"enabled": True, "built": True, "records": 4} + + def rebuild(self): + return {"enabled": True, "built": True, "records": 4} + + def set_enabled(self, enabled: bool): + self.last_enabled = bool(enabled) + return {"enabled": bool(enabled)} + + def search(self, query: str, *, k: int = 10, kind: str | None = None): + self.last_search = (query, k, kind) + return [{"entry_id": 10, "kind": kind or "document", "score": 0.5}] + + service = DummySemanticService(None) + monkeypatch.setattr(api, "SemanticIndexService", lambda _pm: service) + headers = {"Authorization": f"Bearer {token}"} + + status = await cl.get("/api/v1/semantic/status", headers=headers) + assert status.status_code == 200 + assert status.json()["records"] == 4 + + built = await cl.post("/api/v1/semantic/build", headers=headers) + assert built.status_code == 200 + assert built.json()["built"] is True + + rebuilt = await cl.post("/api/v1/semantic/rebuild", headers=headers) + assert rebuilt.status_code == 200 + assert rebuilt.json()["built"] is True + + configured = await cl.post( + "/api/v1/semantic/config", + json={"enabled": False}, + headers=headers, + ) + assert configured.status_code == 200 + assert configured.json() == {"enabled": False} + assert service.last_enabled is False + + searched = await cl.post( + "/api/v1/semantic/search", + json={"query": "relay notes", "k": 6, "kind": "document"}, + headers=headers, + ) + assert searched.status_code == 200 + assert searched.json()["results"][0]["entry_id"] == 10 + assert service.last_search == ("relay notes", 6, "document") + + +@pytest.mark.anyio +async def test_semantic_search_requires_query(client): + cl, token = client + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post("/api/v1/semantic/search", json={}, headers=headers) + assert res.status_code == 400 + assert res.json() == {"detail": "Missing query"} + + +@pytest.mark.anyio +async def test_semantic_config_requires_enabled_flag(client): + cl, token = client + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post("/api/v1/semantic/config", json={}, headers=headers) + assert res.status_code == 400 + assert res.json() == {"detail": "Missing enabled"} diff --git a/src/tests/test_cli_semantic.py b/src/tests/test_cli_semantic.py new file mode 100644 index 00000000..e8c96313 --- /dev/null +++ b/src/tests/test_cli_semantic.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace + +from typer.testing import CliRunner + +from seedpass.cli import app +from seedpass.cli import semantic as semantic_cli + +runner = CliRunner() + + +class _DummySemanticService: + def status(self): + return {"enabled": True, "built": True, "records": 2} + + def set_enabled(self, enabled: bool): + return {"enabled": bool(enabled), "built": True, "records": 2} + + def build(self): + return {"enabled": True, "built": True, "records": 3} + + def rebuild(self): + return {"enabled": True, "built": True, "records": 3} + + def search(self, query: str, *, k: int = 10, kind: str | None = None): + if not query: + return [] + return [ + { + "entry_id": 7, + "kind": kind or "document", + "label": "Runbook", + "score": 0.8, + "excerpt": f"query={query} k={k}", + } + ] + + +def test_semantic_cli_commands(monkeypatch): + monkeypatch.setattr( + semantic_cli, + "_get_semantic_service", + lambda _ctx: _DummySemanticService(), + ) + monkeypatch.setattr(semantic_cli, "_get_pm", lambda _ctx: SimpleNamespace()) + + result = runner.invoke(app, ["semantic", "status"]) + assert result.exit_code == 0 + assert '"enabled": true' in result.stdout.lower() + + result = runner.invoke(app, ["semantic", "enable"]) + assert result.exit_code == 0 + assert "semantic index enabled" in result.stdout.lower() + + result = runner.invoke(app, ["semantic", "disable"]) + assert result.exit_code == 0 + assert "semantic index disabled" in result.stdout.lower() + + result = runner.invoke(app, ["semantic", "build"]) + assert result.exit_code == 0 + assert "semantic index built" in result.stdout.lower() + + result = runner.invoke(app, ["semantic", "rebuild"]) + assert result.exit_code == 0 + assert "semantic index rebuilt" in result.stdout.lower() + + result = runner.invoke(app, ["semantic", "search", "relay recovery"]) + assert result.exit_code == 0 + assert "Runbook" in result.stdout + assert "score=0.800" in result.stdout + + result = runner.invoke( + app, + ["semantic", "search", "relay", "--k", "3", "--kind", "document", "--json"], + ) + assert result.exit_code == 0 + assert '"entry_id": 7' in result.stdout diff --git a/src/tests/test_semantic_index.py b/src/tests/test_semantic_index.py new file mode 100644 index 00000000..14c57347 --- /dev/null +++ b/src/tests/test_semantic_index.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +from seedpass.core.semantic_index import SemanticIndex +from seedpass.core.api import SemanticIndexService + + +def test_semantic_index_build_and_search(tmp_path: Path) -> None: + idx = SemanticIndex(tmp_path) + assert idx.status()["built"] is False + + idx.set_enabled(True) + payload = idx.build( + [ + { + "id": 1, + "kind": "document", + "label": "Runbook", + "content": "Nostr relay recovery process and checklist", + "tags": ["ops", "relay"], + }, + { + "id": 2, + "kind": "password", + "label": "GitHub", + "username": "alice", + "url": "https://github.com", + }, + { + "id": 3, + "kind": "seed", + "label": "Seed", + "seed_phrase": "abandon " * 11 + "about", + }, + ] + ) + assert payload["built"] is True + assert payload["enabled"] is True + assert payload["records"] == 2 + + results = idx.search("relay checklist", k=5) + assert len(results) >= 1 + assert results[0]["entry_id"] == 1 + assert "Runbook" in results[0]["label"] + + +class _DummyConfig: + def __init__(self) -> None: + self.enabled = False + + def set_semantic_index_enabled(self, enabled: bool) -> None: + self.enabled = bool(enabled) + + def get_semantic_index_enabled(self) -> bool: + return bool(self.enabled) + + +class _DummyEntryManager: + def __init__(self) -> None: + self._entries = { + 1: { + "id": 1, + "kind": "document", + "label": "Doc", + "content": "agent context", + }, + 2: { + "id": 2, + "kind": "key_value", + "label": "Env", + "key": "REGION", + "value": "us-east", + }, + } + + def search_entries(self, *_args, **_kwargs): + return [ + (1, "Doc", None, None, False, SimpleNamespace(value="document")), + (2, "Env", None, None, False, SimpleNamespace(value="key_value")), + ] + + def retrieve_entry(self, entry_id: int): + return dict(self._entries[int(entry_id)]) + + +def test_semantic_index_service_build_and_status(tmp_path: Path) -> None: + manager = SimpleNamespace( + fingerprint_dir=tmp_path, + config_manager=_DummyConfig(), + entry_manager=_DummyEntryManager(), + ) + service = SemanticIndexService(manager) + status = service.status() + assert status["enabled"] is False + assert status["built"] is False + + enabled = service.set_enabled(True) + assert enabled["enabled"] is True + + built = service.build() + assert built["built"] is True + assert built["records"] == 2 + + results = service.search("agent", k=3) + assert len(results) >= 1 + assert results[0]["entry_id"] == 1 diff --git a/src/tests/test_tui_v2_action_matrix.py b/src/tests/test_tui_v2_action_matrix.py index 0a385181..9aea85e5 100644 --- a/src/tests/test_tui_v2_action_matrix.py +++ b/src/tests/test_tui_v2_action_matrix.py @@ -551,6 +551,9 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None: app._run_palette_command("profile-add") app._run_palette_command("profile-remove") app._run_palette_command("profile-rename") + app._run_palette_command("profile-tree-next now") + app._run_palette_command("profile-tree-prev now") + app._run_palette_command("profile-tree-open now") app._run_palette_command("setting-secret on") app._run_palette_command("setting-offline on") app._run_palette_command("setting-quick-unlock on") @@ -598,6 +601,10 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None: app._run_palette_command("lock") app._run_palette_command("unlock hunter2") app._run_palette_command("search Doc") + app.action_focus_left() + app._run_palette_command("profile-tree-next") + app._run_palette_command("profile-tree-prev") + app._run_palette_command("profile-tree-open") app._run_palette_command("density comfortable") app._run_palette_command("density compact") app._run_palette_command("filter document") @@ -633,12 +640,16 @@ async def test_tui2_matrix_actions_palette_events_and_guards() -> None: app._run_palette_command("2fa-hide") app._run_palette_command("doc-export /tmp/seedpass-matrix-export") app._run_palette_command("copy content") - app._run_palette_command("export-field content /tmp/seedpass-matrix-content.txt") + app._run_palette_command( + "export-field content /tmp/seedpass-matrix-content.txt" + ) await pilot.pause() app._run_palette_command("open 2") app._run_palette_command("copy password confirm") - app._run_palette_command("export-field password /tmp/seedpass-matrix-password.txt confirm") + app._run_palette_command( + "export-field password /tmp/seedpass-matrix-password.txt confirm" + ) app.action_reveal_selected() await pilot.pause() assert "pw-2-" in str(app.query_one("#secret-detail").render()) diff --git a/src/tests/test_tui_v2_textual_interactions.py b/src/tests/test_tui_v2_textual_interactions.py index 7802ea9e..cffdd2ce 100644 --- a/src/tests/test_tui_v2_textual_interactions.py +++ b/src/tests/test_tui_v2_textual_interactions.py @@ -1007,8 +1007,20 @@ async def test_tui2_textual_copy_command_for_core_and_advanced_fields(tmp_path) "private_key": "SSH_PRIVATE_2", "public_key": "ssh-ed25519 AAAA-2", }, - {"id": 3, "kind": "nostr", "label": "Nostr 3", "npub": "npub3", "nsec": "nsec3"}, - {"id": 4, "kind": "key_value", "label": "KV 4", "key": "TOKEN", "value": "abc123"}, + { + "id": 3, + "kind": "nostr", + "label": "Nostr 3", + "npub": "npub3", + "nsec": "nsec3", + }, + { + "id": 4, + "kind": "key_value", + "label": "KV 4", + "key": "TOKEN", + "value": "abc123", + }, ] ) app = _build_app(service) @@ -1552,6 +1564,17 @@ async def test_tui2_textual_profiles_and_settings_palette_commands() -> None: await pilot.pause() assert "Profiles (2): fp-a, fp-b" in _status_text(app) + app.action_focus_left() + await pilot.pause() + app.action_profile_tree_next() + await pilot.pause() + assert "Profile selection: fp-b" in _status_text(app) + + app.action_profile_tree_open() + await pilot.pause() + assert profiles.last_switch == ("fp-b", None) + assert "Switched profile to fp-b" in _status_text(app) + app._run_palette_command("profile-switch fp-b") await pilot.pause() assert profiles.last_switch == ("fp-b", None) @@ -1792,6 +1815,18 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None: await pilot.pause() assert "Usage: profile-rename " in _status_text(app) + app._run_palette_command("profile-tree-next now") + await pilot.pause() + assert "Usage: profile-tree-next" in _status_text(app) + + app._run_palette_command("profile-tree-prev now") + await pilot.pause() + assert "Usage: profile-tree-prev" in _status_text(app) + + app._run_palette_command("profile-tree-open now") + await pilot.pause() + assert "Usage: profile-tree-open" in _status_text(app) + app._run_palette_command("relay-list") await pilot.pause() assert "Nostr service unavailable" in _status_text(app) @@ -1905,11 +1940,15 @@ async def test_tui2_textual_profiles_and_settings_palette_validation() -> None: app._run_palette_command("export-field") await pilot.pause() - assert "Usage: export-field (optional: confirm)" in _status_text(app) + assert "Usage: export-field (optional: confirm)" in _status_text( + app + ) app._run_palette_command("export-field password /tmp/x nope") await pilot.pause() - assert "Usage: export-field (optional: confirm)" in _status_text(app) + assert "Usage: export-field (optional: confirm)" in _status_text( + app + ) app._run_palette_command("density") await pilot.pause()