mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-02 19:02:06 +00:00
feat: add semantic index scaffolding, cli/api surfaces, and tui tree nav polish
This commit is contained in:
1 parent
7c9b6253b2
commit
165e2db204
16 files changed
+1087
-67
No files matched your search
+13
-14
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+64
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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 <token>"],
|
||||
"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:")
|
||||
|
||||
@@ -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}")
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
+142
-24
@@ -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 <entry_id> | "
|
||||
"profiles-list | profile-switch <fp> [password] | profile-add | "
|
||||
"profile-remove <fp> | profile-rename <fp> <name> | "
|
||||
"profile-tree-next | profile-tree-prev | profile-tree-open | "
|
||||
"setting-secret <on|off> [delay] | setting-offline <on|off> | "
|
||||
"setting-quick-unlock <on|off> | setting-timeout <seconds> | "
|
||||
"setting-kdf-iterations <n> | setting-kdf-mode <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 <entry_id>",
|
||||
"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 <field> <path> (optional: confirm)")
|
||||
self._set_status(
|
||||
"Usage: export-field <field> <path> (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 <field> <path> (optional: confirm)")
|
||||
self._set_status(
|
||||
"Usage: export-field <field> <path> (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()
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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 <fingerprint> <name>" 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 <field> <path> (optional: confirm)" in _status_text(app)
|
||||
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(
|
||||
app
|
||||
)
|
||||
|
||||
app._run_palette_command("export-field password /tmp/x nope")
|
||||
await pilot.pause()
|
||||
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(app)
|
||||
assert "Usage: export-field <field> <path> (optional: confirm)" in _status_text(
|
||||
app
|
||||
)
|
||||
|
||||
app._run_palette_command("density")
|
||||
await pilot.pause()
|
||||
|
||||
Reference in new issue
Block a user