diff --git a/docs/assets/theme.js b/docs/assets/theme.js index 518779eb..88670fbe 100644 --- a/docs/assets/theme.js +++ b/docs/assets/theme.js @@ -46,9 +46,26 @@ document.addEventListener('DOMContentLoaded', () => { } } + function escapeHtml(text) { + if (!text) return text; + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + function highlight(text, q) { + if (!q) return escapeHtml(text); const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&') + ')', 'gi'); - return text.replace(re, '$1'); + return text.split(re).map((part, index) => { + if (index % 2 === 1) { + return '' + escapeHtml(part) + ''; + } else { + return escapeHtml(part); + } + }).join(''); } searchInput?.addEventListener('input', async e => { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..76f66ca5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "SeedPass", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/src/main.py b/src/main.py index 2839db0e..eca4f940 100644 --- a/src/main.py +++ b/src/main.py @@ -11,6 +11,7 @@ import os import logging import signal import time +import queue import argparse import tomli from tomli import TOMLDecodeError @@ -45,7 +46,6 @@ from utils.logging_utils import ( ) from local_bip85.bip85 import Bip85Error - colorama_init() OPTIONAL_DEPENDENCIES = { diff --git a/src/nostr/event_handler.py b/src/nostr/event_handler.py deleted file mode 100644 index 416de6a1..00000000 --- a/src/nostr/event_handler.py +++ /dev/null @@ -1,33 +0,0 @@ -# nostr/event_handler.py - -import time -import logging - -from nostr_sdk import Event - - -# Instantiate the logger -logger = logging.getLogger(__name__) - - -class EventHandler: - """ - Handles incoming Nostr events. - """ - - def __init__(self): - pass # Initialize if needed - - def handle_new_event(self, evt: Event): - """Process and log details from a Nostr event.""" - - try: - created_at = evt.created_at().as_secs() - created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at)) - event_id = evt.id().to_hex() - - logger.info( - f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: {evt.content()}" - ) - except Exception as e: - logger.error(f"Error handling new event: {e}", exc_info=True) diff --git a/src/repo-context.txt b/src/repo-context.txt index 0e3a669f..86ff7ca8 100644 --- a/src/repo-context.txt +++ b/src/repo-context.txt @@ -3081,52 +3081,6 @@ __all__ = ['NostrClient'] ``` -## nostr/event_handler.py -```python -# nostr/event_handler.py - -import time -import logging -import traceback -from monstr.event.event import Event - -# Instantiate the logger -logger = logging.getLogger(__name__) - -class EventHandler: - """ - Handles incoming Nostr events. - """ - - def __init__(self): - pass # Initialize if needed - - def handle_new_event(self, evt: Event): - """ - Processes incoming events by logging their details. - - :param evt: The received Event object. - """ - try: - # Assuming evt.created_at is always an integer Unix timestamp - if isinstance(evt.created_at, int): - created_at_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(evt.created_at)) - else: - # Handle unexpected types gracefully - created_at_str = str(evt.created_at) - - # Log the event details without extra newlines - logger.info( - f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}" - ) - except Exception as e: - logger.error(f"Error handling new event: {e}") - logger.error(traceback.format_exc()) - # Optionally, handle the exception without re-raising - # For example, continue processing other events - -``` - ## nostr/encryption_manager.py ```python # nostr/encryption_manager.py @@ -3276,7 +3230,6 @@ import fcntl from .key_manager import KeyManager from .encryption_manager import EncryptionManager -from .event_handler import EventHandler from constants import APP_DIR from utils.file_lock import lock_file @@ -3326,7 +3279,6 @@ class NostrClient: ) # Initialize event handler and client pool - self.event_handler = EventHandler() self.relays = relays if relays else DEFAULT_RELAYS self.client_pool = ClientPool(self.relays) self.subscriptions = {} @@ -3582,7 +3534,6 @@ class NostrClient: try: await asyncio.gather( self.do_post_async(text), - self.subscribe_feed_async(self.event_handler.handle_new_event) ) except Exception as e: logger.error(f"An error occurred in publish_and_subscribe_async: {e}", exc_info=True) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 1aaef773..4c496885 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -28,7 +28,6 @@ from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType from seedpass.core.api import UtilityService - _RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100")) _RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60")) _RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds" diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 3b76f6ba..3babfcd0 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -28,14 +28,12 @@ no_clipboard_option = typer.Option( False, "--no-clipboard", help="Disable clipboard support and print secrets instead", - is_flag=True, ) deterministic_totp_option = typer.Option( False, "--deterministic-totp", help="Derive TOTP secrets deterministically", - is_flag=True, ) # Sub command groups diff --git a/src/seedpass/cli/api.py b/src/seedpass/cli/api.py index a4d33d08..618d8d9a 100644 --- a/src/seedpass/cli/api.py +++ b/src/seedpass/cli/api.py @@ -5,7 +5,6 @@ import uvicorn from .. import api as api_module - app = typer.Typer(help="Run the API server") diff --git a/src/seedpass/cli/config.py b/src/seedpass/cli/config.py index 36e78c6d..4d58e5d3 100644 --- a/src/seedpass/cli/config.py +++ b/src/seedpass/cli/config.py @@ -4,7 +4,6 @@ import typer from .common import _get_config_service - app = typer.Typer(help="Get or set configuration values") diff --git a/src/seedpass/cli/entry.py b/src/seedpass/cli/entry.py index a90201d9..1474cdfb 100644 --- a/src/seedpass/cli/entry.py +++ b/src/seedpass/cli/entry.py @@ -12,7 +12,6 @@ from .common import _get_entry_service, EntryType from seedpass.core.entry_types import ALL_ENTRY_TYPES from utils.clipboard import ClipboardUnavailableError - app = typer.Typer(help="Manage individual entries") @@ -124,7 +123,7 @@ def entry_add( username: Optional[str] = typer.Option(None, "--username"), url: Optional[str] = typer.Option(None, "--url"), no_special: bool = typer.Option( - False, "--no-special", help="Exclude special characters", is_flag=True + False, "--no-special", help="Exclude special characters" ), allowed_special_chars: Optional[str] = typer.Option( None, "--allowed-special-chars", help="Explicit set of special characters" @@ -138,7 +137,6 @@ def entry_add( False, "--exclude-ambiguous", help="Exclude ambiguous characters", - is_flag=True, ), min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), diff --git a/src/seedpass/cli/fingerprint.py b/src/seedpass/cli/fingerprint.py index 6d0653f5..630cead1 100644 --- a/src/seedpass/cli/fingerprint.py +++ b/src/seedpass/cli/fingerprint.py @@ -4,7 +4,6 @@ import typer from .common import _get_services, ProfileRemoveRequest, ProfileSwitchRequest - app = typer.Typer(help="Manage seed profiles") diff --git a/src/seedpass/cli/nostr.py b/src/seedpass/cli/nostr.py index ba26e491..07a1b113 100644 --- a/src/seedpass/cli/nostr.py +++ b/src/seedpass/cli/nostr.py @@ -4,7 +4,6 @@ import typer from .common import _get_services, _get_nostr_service - app = typer.Typer( help="Interact with Nostr relays. See docs/nostr_setup.md for configuration and troubleshooting." ) diff --git a/src/seedpass/cli/util.py b/src/seedpass/cli/util.py index 83aa0b5e..77314944 100644 --- a/src/seedpass/cli/util.py +++ b/src/seedpass/cli/util.py @@ -6,7 +6,6 @@ import typer from .common import _get_util_service - app = typer.Typer(help="Utility commands") @@ -15,7 +14,7 @@ def generate_password( ctx: typer.Context, length: int = 24, no_special: bool = typer.Option( - False, "--no-special", help="Exclude special characters", is_flag=True + False, "--no-special", help="Exclude special characters" ), allowed_special_chars: Optional[str] = typer.Option( None, "--allowed-special-chars", help="Explicit set of special characters" @@ -29,7 +28,6 @@ def generate_password( False, "--exclude-ambiguous", help="Exclude ambiguous characters", - is_flag=True, ), min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), diff --git a/src/seedpass/cli/vault.py b/src/seedpass/cli/vault.py index 89e69ce6..a5269340 100644 --- a/src/seedpass/cli/vault.py +++ b/src/seedpass/cli/vault.py @@ -13,7 +13,6 @@ from .common import ( BackupParentSeedRequest, ) - app = typer.Typer(help="Manage the entire vault") diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index ed41776a..f02e6a77 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from pathlib import Path -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING from utils.seed_prompt import masked_input @@ -15,6 +15,9 @@ from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY +if TYPE_CHECKING: + from .password_generation import PasswordPolicy + logger = logging.getLogger(__name__) diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index b0b5c7c5..9e33d26a 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -6,7 +6,7 @@ import unicodedata try: import orjson as json_lib # type: ignore - JSONDecodeError = orjson.JSONDecodeError + JSONDecodeError = json_lib.JSONDecodeError USE_ORJSON = True except Exception: # pragma: no cover - fallback for environments without orjson import json as json_lib diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index f953fbdb..ca4174bd 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -49,7 +49,6 @@ from .vault import Vault from .backup import BackupManager from .errors import SeedPassError - # Instantiate the logger logger = logging.getLogger(__name__) @@ -1337,7 +1336,7 @@ class EntryManager: atomic_write(checksum_path, lambda f: f.write(checksum)) logger.debug(f"Checksum updated and written to '{checksum_path}'.") - print(colored(f"[+] Checksum updated successfully.", "green")) + print(colored("[+] Checksum updated successfully.", "green")) except Exception as e: logger.error(f"Failed to update checksum: {e}", exc_info=True) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 9c25101a..d74540c2 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1671,7 +1671,7 @@ class PasswordManager: self.error_queue.put(exc) try: - loop = asyncio.get_running_loop() + asyncio.get_running_loop() except RuntimeError: thread = threading.Thread( target=lambda: asyncio.run(_worker()), daemon=True @@ -1734,7 +1734,7 @@ class PasswordManager: self.error_queue.put(exc) try: - loop = asyncio.get_running_loop() + asyncio.get_running_loop() except RuntimeError: threading.Thread(target=_worker, daemon=True).start() else: diff --git a/src/tests/conftest.py b/src/tests/conftest.py index ae376de3..f46b6f30 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -4,14 +4,30 @@ import sys from pathlib import Path import pytest +from types import SimpleNamespace +from httpx import ASGITransport, AsyncClient +import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) -from helpers import create_vault, TEST_PASSWORD, TEST_SEED +from seedpass import api +from seedpass.core.entry_types import EntryType +from helpers import ( + create_vault, + TEST_PASSWORD, + TEST_SEED, + DummyBuilder, + DummyFilter, + DummyTag, + DummyTimestamp, + DummyEventId, + DummyRelayClient, +) from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from seedpass.core.entry_management import EntryManager from seedpass.core.manager import EncryptionMode, PasswordManager +from seedpass.core.encryption import EncryptionManager as EncMgr @pytest.fixture( @@ -94,3 +110,87 @@ def password_manager(vault, tmp_path): pm.is_dirty = False pm.secret_mode_enabled = False return pm + + +@pytest.fixture +async def client(monkeypatch): + dummy = SimpleNamespace( + entry_manager=SimpleNamespace( + search_entries=lambda q: [ + (1, "Site", "user", "url", False, EntryType.PASSWORD) + ], + retrieve_entry=lambda i: {"label": "Site"}, + add_entry=lambda *a, **k: 1, + modify_entry=lambda *a, **k: None, + archive_entry=lambda i: None, + restore_entry=lambda i: None, + ), + config_manager=SimpleNamespace( + load_config=lambda require_pin=False: {"k": "v"}, + set_pin=lambda v: None, + set_password_hash=lambda v: None, + set_relays=lambda v, require_pin=False: None, + set_inactivity_timeout=lambda v: None, + set_additional_backup_path=lambda v: None, + set_secret_mode_enabled=lambda v: None, + set_clipboard_clear_delay=lambda v: None, + set_quick_unlock=lambda v: None, + ), + fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]), + nostr_client=SimpleNamespace( + key_manager=SimpleNamespace(get_npub=lambda: "np") + ), + verify_password=lambda pw: True, + ) + monkeypatch.setattr(api, "PasswordManager", lambda: dummy) + monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com") + token = api.start_server() + transport = ASGITransport(app=api.app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac, token + + +@pytest.fixture +def make_dummy_nostr_client(monkeypatch): + """Factory to return a NostrClient wired to a DummyRelayClient.""" + from cryptography.fernet import Fernet + from nostr.client import NostrClient + + def _make(base_path): + relay = DummyRelayClient() + monkeypatch.setattr("nostr.client.Client", lambda signer: relay) + monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder) + monkeypatch.setattr("nostr.client.Filter", DummyFilter) + monkeypatch.setattr("nostr.client.Tag", DummyTag) + monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp) + monkeypatch.setattr("nostr.client.EventId", DummyEventId) + from nostr.backup_models import KIND_DELTA as KD + + monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False) + monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None) + + enc_mgr = EncMgr(Fernet.generate_key(), base_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client = NostrClient(enc_mgr, "fp") + return client, relay + + return _make + + +@pytest.fixture +def dummy_nostr_client(make_dummy_nostr_client, tmp_path): + return make_dummy_nostr_client(tmp_path) diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 8373cd37..ad87a6f6 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -1,10 +1,7 @@ -import sys -import time import json +import uuid from pathlib import Path -sys.path.append(str(Path(__file__).resolve().parents[1])) - from seedpass.core.vault import Vault from seedpass.core.encryption import EncryptionManager from utils.key_derivation import ( @@ -12,8 +9,16 @@ from utils.key_derivation import ( derive_key_from_password, ) from utils.fingerprint import generate_fingerprint +from nostr.backup_models import ( + KIND_MANIFEST, + KIND_SNAPSHOT_CHUNK, + KIND_DELTA, +) -TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +TEST_SEED = ( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon " + "abandon abandon about" +) TEST_PASSWORD = "pw" @@ -34,17 +39,6 @@ def create_vault( return vault, enc_mgr -import uuid -import asyncio -import pytest - -from nostr.backup_models import ( - KIND_MANIFEST, - KIND_SNAPSHOT_CHUNK, - KIND_DELTA, -) - - class DummyEvent: def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None): self.kind = kind @@ -237,41 +231,3 @@ class DummyRelayClient: return self._evs return Result(events) - - -@pytest.fixture -def dummy_nostr_client(tmp_path, monkeypatch): - """Return a NostrClient wired to a DummyRelayClient.""" - from cryptography.fernet import Fernet - from nostr.client import NostrClient - - relay = DummyRelayClient() - monkeypatch.setattr("nostr.client.Client", lambda signer: relay) - monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder) - monkeypatch.setattr("nostr.client.Filter", DummyFilter) - monkeypatch.setattr("nostr.client.Tag", DummyTag) - monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp) - monkeypatch.setattr("nostr.client.EventId", DummyEventId) - from nostr.backup_models import KIND_DELTA as KD - - monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False) - monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None) - - enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) - - class DummyKeys: - def private_key_hex(self): - return "1" * 64 - - def public_key_hex(self): - return "2" * 64 - - class DummyKeyManager: - def __init__(self, *a, **k): - self.keys = DummyKeys() - - with pytest.MonkeyPatch().context() as mp: - mp.setattr("nostr.client.KeyManager", DummyKeyManager) - mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) - client = NostrClient(enc_mgr, "fp") - return client, relay diff --git a/src/tests/test_api.py b/src/tests/test_api.py index ec0fabc5..64a85cbf 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -1,53 +1,12 @@ -from types import SimpleNamespace from pathlib import Path import sys import pytest -from httpx import ASGITransport, AsyncClient import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass import api -from seedpass.core.entry_types import EntryType - - -@pytest.fixture -async def client(monkeypatch): - dummy = SimpleNamespace( - entry_manager=SimpleNamespace( - search_entries=lambda q: [ - (1, "Site", "user", "url", False, EntryType.PASSWORD) - ], - retrieve_entry=lambda i: {"label": "Site"}, - add_entry=lambda *a, **k: 1, - modify_entry=lambda *a, **k: None, - archive_entry=lambda i: None, - restore_entry=lambda i: None, - ), - config_manager=SimpleNamespace( - load_config=lambda require_pin=False: {"k": "v"}, - set_pin=lambda v: None, - set_password_hash=lambda v: None, - set_relays=lambda v, require_pin=False: None, - set_inactivity_timeout=lambda v: None, - set_additional_backup_path=lambda v: None, - set_secret_mode_enabled=lambda v: None, - set_clipboard_clear_delay=lambda v: None, - set_quick_unlock=lambda v: None, - ), - fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]), - nostr_client=SimpleNamespace( - key_manager=SimpleNamespace(get_npub=lambda: "np") - ), - verify_password=lambda pw: True, - ) - monkeypatch.setattr(api, "PasswordManager", lambda: dummy) - monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com") - token = api.start_server() - transport = ASGITransport(app=api.app) - async with AsyncClient(transport=transport, base_url="http://test") as ac: - yield ac, token @pytest.mark.anyio diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index c4503411..674737c0 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -5,8 +5,6 @@ import base64 import pytest from seedpass import api -from test_api import client -from helpers import dummy_nostr_client import string from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from seedpass.core.encryption import EncryptionManager diff --git a/src/tests/test_api_notifications.py b/src/tests/test_api_notifications.py index 57a02a54..1d9069f9 100644 --- a/src/tests/test_api_notifications.py +++ b/src/tests/test_api_notifications.py @@ -1,4 +1,3 @@ -from test_api import client from types import SimpleNamespace import queue import pytest diff --git a/src/tests/test_api_profile_stats.py b/src/tests/test_api_profile_stats.py index 2e172bd9..2b646c9d 100644 --- a/src/tests/test_api_profile_stats.py +++ b/src/tests/test_api_profile_stats.py @@ -1,4 +1,3 @@ -from test_api import client import pytest diff --git a/src/tests/test_cli_clipboard_flag.py b/src/tests/test_cli_clipboard_flag.py index 29911ff0..a51a5498 100644 --- a/src/tests/test_cli_clipboard_flag.py +++ b/src/tests/test_cli_clipboard_flag.py @@ -4,7 +4,6 @@ from seedpass.cli import app, entry as cli_entry from seedpass.core.entry_types import EntryType from utils.clipboard import ClipboardUnavailableError - runner = CliRunner() diff --git a/src/tests/test_core_api_services.py b/src/tests/test_core_api_services.py new file mode 100644 index 00000000..cfeb1b30 --- /dev/null +++ b/src/tests/test_core_api_services.py @@ -0,0 +1,529 @@ +import pytest +from unittest.mock import MagicMock, patch, call +from pathlib import Path +from threading import Lock +import json +from dataclasses import dataclass + +from seedpass.core.api import ( + VaultService, + ProfileService, + SyncService, + EntryService, + ConfigService, + UtilityService, + NostrService, + VaultExportRequest, + VaultExportResponse, + VaultImportRequest, + ChangePasswordRequest, + UnlockRequest, + UnlockResponse, + BackupParentSeedRequest, + ProfileSwitchRequest, + ProfileRemoveRequest, + PasswordPolicyOptions, + GeneratePasswordRequest, + GeneratePasswordResponse, + AddPasswordEntryRequest, +) +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType + + +@dataclass +class MockPolicy: + include_special_chars: bool = False + min_uppercase: int = 0 + + +@pytest.fixture +def mock_manager(): + """Create a mock PasswordManager with all sub-components mocked.""" + manager = MagicMock(spec=PasswordManager) + + # Mock sub-managers + manager.entry_manager = MagicMock() + manager.config_manager = MagicMock() + manager.fingerprint_manager = MagicMock() + manager.encryption_manager = MagicMock() + manager.vault = MagicMock() + manager.password_generator = MagicMock() + manager.nostr_client = MagicMock() + manager.state_manager = MagicMock() + + # Mock specific attributes + manager.KEY_TOTP_DET = b"mock_totp_key" + manager.parent_seed = "mock_parent_seed" + manager.current_fingerprint = "fp1" + + return manager + + +@pytest.fixture +def mock_bus(): + """Mock the pubsub bus.""" + with patch("seedpass.core.api.bus") as mock: + yield mock + + +class TestVaultService: + @pytest.fixture + def service(self, mock_manager): + return VaultService(mock_manager) + + def test_export_vault(self, service, mock_manager): + req = VaultExportRequest(path=Path("/tmp/export.json")) + resp = service.export_vault(req) + + mock_manager.handle_export_database.assert_called_once_with(req.path) + assert resp.path == req.path + + def test_import_vault(self, service, mock_manager): + req = VaultImportRequest(path=Path("/tmp/import.json")) + service.import_vault(req) + + mock_manager.handle_import_database.assert_called_once_with(req.path) + mock_manager.sync_vault.assert_called_once() + + def test_export_profile(self, service, mock_manager): + mock_index = {"entries": {}} + mock_manager.vault.load_index.return_value = mock_index + mock_manager.vault.encryption_manager.encrypt_data.return_value = b"encrypted" + + result = service.export_profile() + + mock_manager.vault.load_index.assert_called_once() + expected_payload = json.dumps( + mock_index, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + mock_manager.vault.encryption_manager.encrypt_data.assert_called_once_with( + expected_payload + ) + assert result == b"encrypted" + + def test_import_profile(self, service, mock_manager): + data = b"encrypted_data" + decrypted_json = json.dumps({"entries": {}}) + mock_manager.vault.encryption_manager.decrypt_data.return_value = ( + decrypted_json.encode("utf-8") + ) + + service.import_profile(data) + + mock_manager.vault.encryption_manager.decrypt_data.assert_called_once_with( + data, context="profile" + ) + mock_manager.vault.save_index.assert_called_once_with( + json.loads(decrypted_json) + ) + mock_manager.sync_vault.assert_called_once() + + def test_change_password(self, service, mock_manager): + req = ChangePasswordRequest(old_password="old", new_password="new") + service.change_password(req) + + mock_manager.change_password.assert_called_once_with("old", "new") + + def test_unlock(self, service, mock_manager): + mock_manager.unlock_vault.return_value = 0.5 + req = UnlockRequest(password="secret") + resp = service.unlock(req) + + mock_manager.unlock_vault.assert_called_once_with("secret") + assert resp.duration == 0.5 + + def test_lock(self, service, mock_manager): + service.lock() + mock_manager.lock_vault.assert_called_once() + + def test_backup_parent_seed(self, service, mock_manager): + req = BackupParentSeedRequest(path=Path("/tmp/seed.enc"), password="pw") + service.backup_parent_seed(req) + + mock_manager.handle_backup_reveal_parent_seed.assert_called_once_with( + req.path, password=req.password + ) + + def test_stats(self, service, mock_manager): + expected_stats = {"entries": 10} + mock_manager.get_profile_stats.return_value = expected_stats + + stats = service.stats() + + mock_manager.get_profile_stats.assert_called_once() + assert stats == expected_stats + + +class TestProfileService: + @pytest.fixture + def service(self, mock_manager): + return ProfileService(mock_manager) + + def test_list_profiles(self, service, mock_manager): + mock_manager.fingerprint_manager.list_fingerprints.return_value = ["fp1", "fp2"] + profiles = service.list_profiles() + + mock_manager.fingerprint_manager.list_fingerprints.assert_called_once() + assert profiles == ["fp1", "fp2"] + + def test_add_profile(self, service, mock_manager): + mock_manager.fingerprint_manager.current_fingerprint = "new_fp" + result = service.add_profile() + + mock_manager.add_new_fingerprint.assert_called_once() + assert result == "new_fp" + + def test_remove_profile(self, service, mock_manager): + req = ProfileRemoveRequest(fingerprint="fp1") + service.remove_profile(req) + + mock_manager.fingerprint_manager.remove_fingerprint.assert_called_once_with( + "fp1" + ) + + def test_switch_profile(self, service, mock_manager): + req = ProfileSwitchRequest(fingerprint="fp2", password="pw") + service.switch_profile(req) + + mock_manager.select_fingerprint.assert_called_once_with("fp2", password="pw") + + +class TestSyncService: + @pytest.fixture + def service(self, mock_manager): + return SyncService(mock_manager) + + def test_sync(self, service, mock_manager, mock_bus): + mock_result = {"manifest_id": "m1"} + mock_manager.sync_vault.return_value = mock_result + + result = service.sync() + + mock_bus.publish.assert_has_calls( + [call("sync_started"), call("sync_finished", mock_result)] + ) + mock_manager.sync_vault.assert_called_once() + assert result.manifest_id == "m1" + + def test_sync_none(self, service, mock_manager, mock_bus): + mock_manager.sync_vault.return_value = None + + result = service.sync() + + mock_bus.publish.assert_has_calls( + [call("sync_started"), call("sync_finished", None)] + ) + assert result is None + + def test_start_background_sync(self, service, mock_manager): + service.start_background_sync() + mock_manager.start_background_sync.assert_called_once() + + def test_start_background_vault_sync(self, service, mock_manager): + service.start_background_vault_sync("summary") + mock_manager.start_background_vault_sync.assert_called_once_with("summary") + + +class TestEntryService: + @pytest.fixture + def service(self, mock_manager): + return EntryService(mock_manager) + + def test_list_entries(self, service, mock_manager): + mock_manager.entry_manager.list_entries.return_value = [] + entries = service.list_entries() + + mock_manager.entry_manager.list_entries.assert_called_once_with( + sort_by="index", filter_kinds=None, include_archived=False + ) + assert entries == [] + + def test_search_entries(self, service, mock_manager): + mock_manager.entry_manager.search_entries.return_value = [] + results = service.search_entries("query") + + mock_manager.entry_manager.search_entries.assert_called_once_with( + "query", kinds=None + ) + assert results == [] + + def test_retrieve_entry(self, service, mock_manager): + mock_entry = {"id": 1, "label": "test"} + mock_manager.entry_manager.retrieve_entry.return_value = mock_entry + entry = service.retrieve_entry(1) + + mock_manager.entry_manager.retrieve_entry.assert_called_once_with(1) + assert entry == mock_entry + + def test_generate_password_default(self, service, mock_manager): + # Case where _generate_password_for_entry is not available + del mock_manager._generate_password_for_entry + mock_manager.password_generator.generate_password.return_value = "pass123" + mock_manager.entry_manager.retrieve_entry.return_value = {} + + password = service.generate_password(10, 1) + + mock_manager.password_generator.generate_password.assert_called_once_with(10, 1) + assert password == "pass123" + + def test_generate_password_custom(self, service, mock_manager): + # Case where _generate_password_for_entry is available + mock_manager._generate_password_for_entry = MagicMock(return_value="custom123") + mock_manager.entry_manager.retrieve_entry.return_value = {"policy": {}} + + password = service.generate_password(10, 1) + + mock_manager._generate_password_for_entry.assert_called_once() + assert password == "custom123" + + def test_get_totp_code(self, service, mock_manager): + mock_manager.KEY_TOTP_DET = b"key" + mock_manager.entry_manager.get_totp_code.return_value = "123456" + + code = service.get_totp_code(1) + + mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key") + assert code == "123456" + + def test_add_entry(self, service, mock_manager): + mock_manager.entry_manager.add_entry.return_value = 1 + + idx = service.add_entry("label", 12, "user", "url") + + mock_manager.entry_manager.add_entry.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 1 + + def test_add_totp(self, service, mock_manager): + mock_manager.entry_manager.add_totp.return_value = "otpauth://..." + + uri = service.add_totp("label") + + mock_manager.entry_manager.add_totp.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert uri == "otpauth://..." + + def test_add_ssh_key(self, service, mock_manager): + mock_manager.entry_manager.add_ssh_key.return_value = 2 + + idx = service.add_ssh_key("label") + + mock_manager.entry_manager.add_ssh_key.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 2 + + def test_add_pgp_key(self, service, mock_manager): + mock_manager.entry_manager.add_pgp_key.return_value = 3 + + idx = service.add_pgp_key("label") + + mock_manager.entry_manager.add_pgp_key.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 3 + + def test_add_nostr_key(self, service, mock_manager): + mock_manager.entry_manager.add_nostr_key.return_value = 4 + + idx = service.add_nostr_key("label") + + mock_manager.entry_manager.add_nostr_key.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 4 + + def test_add_seed(self, service, mock_manager): + mock_manager.entry_manager.add_seed.return_value = 5 + + idx = service.add_seed("label") + + mock_manager.entry_manager.add_seed.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 5 + + def test_add_key_value(self, service, mock_manager): + mock_manager.entry_manager.add_key_value.return_value = 6 + + idx = service.add_key_value("label", "k", "v") + + mock_manager.entry_manager.add_key_value.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 6 + + def test_add_managed_account(self, service, mock_manager): + mock_manager.entry_manager.add_managed_account.return_value = 7 + + idx = service.add_managed_account("label") + + mock_manager.entry_manager.add_managed_account.assert_called_once() + mock_manager.start_background_vault_sync.assert_called_once() + assert idx == 7 + + def test_modify_entry(self, service, mock_manager): + service.modify_entry(1, label="new_label") + + mock_manager.entry_manager.modify_entry.assert_called_once_with( + 1, + username=None, + url=None, + notes=None, + label="new_label", + period=None, + digits=None, + key=None, + value=None, + ) + mock_manager.start_background_vault_sync.assert_called_once() + + def test_archive_entry(self, service, mock_manager): + service.archive_entry(1) + + mock_manager.entry_manager.archive_entry.assert_called_once_with(1) + mock_manager.start_background_vault_sync.assert_called_once() + + def test_restore_entry(self, service, mock_manager): + service.restore_entry(1) + + mock_manager.entry_manager.restore_entry.assert_called_once_with(1) + mock_manager.start_background_vault_sync.assert_called_once() + + def test_export_totp_entries(self, service, mock_manager): + mock_manager.entry_manager.export_totp_entries.return_value = {} + + result = service.export_totp_entries() + + mock_manager.entry_manager.export_totp_entries.assert_called_once() + assert result == {} + + def test_display_totp_codes(self, service, mock_manager): + service.display_totp_codes() + mock_manager.handle_display_totp_codes.assert_called_once() + + +class TestConfigService: + @pytest.fixture + def service(self, mock_manager): + return ConfigService(mock_manager) + + def test_get(self, service, mock_manager): + mock_manager.config_manager.load_config.return_value = {"key": "value"} + val = service.get("key") + + mock_manager.config_manager.load_config.assert_called_once_with( + require_pin=False + ) + assert val == "value" + + def test_set_simple(self, service, mock_manager): + service.set("inactivity_timeout", "10") + mock_manager.config_manager.set_inactivity_timeout.assert_called_once_with(10.0) + + def test_set_bool(self, service, mock_manager): + service.set("secret_mode_enabled", "true") + mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with( + True + ) + + def test_set_relays(self, service, mock_manager): + # relays setter logic: ("set_relays", lambda v: (v, {"require_pin": False})), + # but the lambda returns a tuple (arg, kwargs) + # and then getattr(cfg, method_name)(arg, **kwargs) is called. + # Wait, the lambda returns (v, {"require_pin": False}) + # So arg is v, kwargs is {"require_pin": False} + + service.set("relays", ["r1"]) + mock_manager.config_manager.set_relays.assert_called_once_with( + ["r1"], require_pin=False + ) + + def test_set_invalid_key(self, service, mock_manager): + with pytest.raises(KeyError): + service.set("invalid_key", "val") + + def test_get_secret_mode_enabled(self, service, mock_manager): + mock_manager.config_manager.get_secret_mode_enabled.return_value = True + assert service.get_secret_mode_enabled() is True + + def test_get_clipboard_clear_delay(self, service, mock_manager): + mock_manager.config_manager.get_clipboard_clear_delay.return_value = 45 + assert service.get_clipboard_clear_delay() == 45 + + def test_set_secret_mode(self, service, mock_manager): + service.set_secret_mode(True, 30) + + mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with( + True + ) + mock_manager.config_manager.set_clipboard_clear_delay.assert_called_once_with( + 30 + ) + assert mock_manager.secret_mode_enabled is True + assert mock_manager.clipboard_clear_delay == 30 + + def test_get_offline_mode(self, service, mock_manager): + mock_manager.config_manager.get_offline_mode.return_value = False + assert service.get_offline_mode() is False + + def test_set_offline_mode(self, service, mock_manager): + service.set_offline_mode(True) + + mock_manager.config_manager.set_offline_mode.assert_called_once_with(True) + assert mock_manager.offline_mode is True + + +class TestUtilityService: + @pytest.fixture + def service(self, mock_manager): + return UtilityService(mock_manager) + + def test_generate_password(self, service, mock_manager): + mock_manager.password_generator.generate_password.return_value = "pw" + + # Mock policy with a real dataclass + base_policy = MockPolicy(include_special_chars=False, min_uppercase=0) + mock_manager.password_generator.policy = base_policy + + result = service.generate_password( + 10, include_special_chars=True, min_uppercase=5 + ) + + mock_manager.password_generator.generate_password.assert_called_once_with(10) + assert result == "pw" + + # Verify policy was restored + assert mock_manager.password_generator.policy == base_policy + + def test_verify_checksum(self, service, mock_manager): + service.verify_checksum() + mock_manager.handle_verify_checksum.assert_called_once() + + def test_update_checksum(self, service, mock_manager): + service.update_checksum() + mock_manager.handle_update_script_checksum.assert_called_once() + + +class TestNostrService: + @pytest.fixture + def service(self, mock_manager): + return NostrService(mock_manager) + + def test_get_pubkey(self, service, mock_manager): + mock_manager.nostr_client.key_manager.get_npub.return_value = "npub1..." + assert service.get_pubkey() == "npub1..." + + def test_list_relays(self, service, mock_manager): + mock_manager.state_manager.list_relays.return_value = ["r1"] + assert service.list_relays() == ["r1"] + + def test_add_relay(self, service, mock_manager): + mock_manager.state_manager.list_relays.return_value = ["r1", "r2"] + service.add_relay("r2") + + mock_manager.state_manager.add_relay.assert_called_once_with("r2") + assert mock_manager.nostr_client.relays == ["r1", "r2"] + + def test_remove_relay(self, service, mock_manager): + mock_manager.state_manager.list_relays.return_value = [] + service.remove_relay(0) + + mock_manager.state_manager.remove_relay.assert_called_once_with(0) + assert mock_manager.nostr_client.relays == [] diff --git a/src/tests/test_event_handler.py b/src/tests/test_event_handler.py deleted file mode 100644 index 85ba7868..00000000 --- a/src/tests/test_event_handler.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging -import time - -import sys -from pathlib import Path - -sys.path.append(str(Path(__file__).resolve().parents[1])) - -from nostr import event_handler -from nostr_sdk import EventBuilder, Keys - - -def test_handle_new_event_logs(caplog): - handler = event_handler.EventHandler() - keys = Keys.generate() - evt = EventBuilder.text_note("hello").sign_with_keys(keys) - - caplog.set_level(logging.INFO, logger=event_handler.logger.name) - handler.handle_new_event(evt) - - event_id = evt.id().to_hex() - created_at = evt.created_at().as_secs() - created_at_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(created_at)) - - assert ( - f"[New Event] ID: {event_id} | Created At: {created_at_str} | Content: hello" - in caplog.text - ) - - -def test_handle_new_event_error(monkeypatch, caplog): - handler = event_handler.EventHandler() - keys = Keys.generate() - evt = EventBuilder.text_note("boom").sign_with_keys(keys) - - def raise_info(*args, **kwargs): - raise RuntimeError("fail") - - monkeypatch.setattr(event_handler.logger, "info", raise_info) - caplog.set_level(logging.ERROR, logger=event_handler.logger.name) - - handler.handle_new_event(evt) - - assert "Error handling new event: fail" in caplog.text diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index 69a24d3d..c9709f7a 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path from tempfile import TemporaryDirectory -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 69a24d3d..c9709f7a 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path from tempfile import TemporaryDirectory -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py index ac85e417..0849408d 100644 --- a/src/tests/test_fuzz_key_derivation.py +++ b/src/tests/test_fuzz_key_derivation.py @@ -16,7 +16,6 @@ from utils.key_derivation import ( from utils.fingerprint import generate_fingerprint from seedpass.core.encryption import EncryptionManager - cfg_values = st.one_of( st.integers(min_value=0, max_value=100), st.text(min_size=0, max_size=20), diff --git a/src/tests/test_generate_test_profile_sync.py b/src/tests/test_generate_test_profile_sync.py index 216bb3d6..b8c5435e 100644 --- a/src/tests/test_generate_test_profile_sync.py +++ b/src/tests/test_generate_test_profile_sync.py @@ -5,8 +5,6 @@ from tempfile import TemporaryDirectory import asyncio import gzip -from helpers import dummy_nostr_client - def load_script(): script_path = ( diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py index 92bc12c6..422827b9 100644 --- a/src/tests/test_manager_add_password.py +++ b/src/tests/test_manager_add_password.py @@ -5,7 +5,7 @@ from types import SimpleNamespace import pytest -from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client +from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) diff --git a/src/tests/test_manifest_id_privacy.py b/src/tests/test_manifest_id_privacy.py index aa06c1c1..35a0ba56 100644 --- a/src/tests/test_manifest_id_privacy.py +++ b/src/tests/test_manifest_id_privacy.py @@ -1,7 +1,5 @@ import asyncio -from helpers import dummy_nostr_client - def test_published_events_no_fingerprint(dummy_nostr_client): client, relay = dummy_nostr_client diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py index c28fad78..5abb75d2 100644 --- a/src/tests/test_manifest_state_restore.py +++ b/src/tests/test_manifest_state_restore.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path from tempfile import TemporaryDirectory -from helpers import create_vault, dummy_nostr_client, TEST_SEED +from helpers import create_vault, TEST_SEED from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager @@ -34,8 +34,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_manifest_state_restored(monkeypatch, tmp_path): - client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) +def test_manifest_state_restored(monkeypatch, tmp_path, make_dummy_nostr_client): + client, relay = make_dummy_nostr_client(tmp_path / "c1") with TemporaryDirectory() as tmpdir: fp_dir = Path(tmpdir) pm1 = _init_pm(fp_dir, client) @@ -48,7 +48,7 @@ def test_manifest_state_restored(monkeypatch, tmp_path): assert delta_ts > 0 assert result["manifest_id"] == manifest_id - client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch) + client2, _ = make_dummy_nostr_client(tmp_path / "c2") monkeypatch.setattr( "seedpass.core.manager.NostrClient", lambda *a, **k: client2 ) diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py index 3b2b2f71..296f6421 100644 --- a/src/tests/test_multiple_deltas_sync.py +++ b/src/tests/test_multiple_deltas_sync.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path from tempfile import TemporaryDirectory -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 763e5b0d..5087794f 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -3,7 +3,7 @@ import gzip import math import pytest -from helpers import create_vault, dummy_nostr_client, TEST_SEED +from helpers import create_vault, TEST_SEED from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager diff --git a/src/tests/test_nostr_legacy_key_fallback.py b/src/tests/test_nostr_legacy_key_fallback.py index a551b120..07f42558 100644 --- a/src/tests/test_nostr_legacy_key_fallback.py +++ b/src/tests/test_nostr_legacy_key_fallback.py @@ -3,7 +3,7 @@ import base64 import hashlib import json -from helpers import DummyEvent, DummyFilter, dummy_nostr_client +from helpers import DummyEvent, DummyFilter from nostr.backup_models import KIND_MANIFEST, KIND_SNAPSHOT_CHUNK from nostr_sdk import Keys diff --git a/src/tests/test_nostr_restore_flow.py b/src/tests/test_nostr_restore_flow.py index 759f6f86..1c5c1f86 100644 --- a/src/tests/test_nostr_restore_flow.py +++ b/src/tests/test_nostr_restore_flow.py @@ -1,7 +1,7 @@ from pathlib import Path import main -from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD +from helpers import create_vault, TEST_SEED, TEST_PASSWORD from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager @@ -28,8 +28,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_restore_flow_from_snapshot(monkeypatch, tmp_path): - client, relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch) +def test_restore_flow_from_snapshot(monkeypatch, tmp_path, make_dummy_nostr_client): + client, relay = make_dummy_nostr_client(tmp_path / "srv") dir_a = tmp_path / "A" dir_b = tmp_path / "B" diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index e76f7ef6..699adb57 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -19,7 +19,6 @@ from seedpass.core.portable_backup import PortableMode from utils.key_derivation import derive_index_key, derive_key_from_password from utils.fingerprint import generate_fingerprint - SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" PASSWORD = "passw0rd" diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index 02db4b3d..a7cfb4f2 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -6,11 +6,10 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager from seedpass.core.manager import PasswordManager, EncryptionMode -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault import gzip from nostr.backup_models import Manifest, ChunkMeta - VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" diff --git a/src/tests/test_restore_from_nostr_setup.py b/src/tests/test_restore_from_nostr_setup.py index 9f304779..8df98c91 100644 --- a/src/tests/test_restore_from_nostr_setup.py +++ b/src/tests/test_restore_from_nostr_setup.py @@ -1,6 +1,6 @@ from pathlib import Path -from helpers import create_vault, dummy_nostr_client, TEST_SEED, TEST_PASSWORD +from helpers import create_vault, TEST_SEED, TEST_PASSWORD from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager @@ -28,8 +28,10 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_handle_new_seed_setup_restore_from_nostr(monkeypatch, tmp_path, capsys): - client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch) +def test_handle_new_seed_setup_restore_from_nostr( + monkeypatch, tmp_path, capsys, make_dummy_nostr_client +): + client, _relay = make_dummy_nostr_client(tmp_path / "srv") dir_a = tmp_path / "A" dir_b = tmp_path / "B" @@ -133,8 +135,10 @@ async def _no_snapshot(): return None -def test_restore_from_nostr_warns(monkeypatch, tmp_path, capsys): - client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch) +def test_restore_from_nostr_warns( + monkeypatch, tmp_path, capsys, make_dummy_nostr_client +): + client, _relay = make_dummy_nostr_client(tmp_path / "srv") monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot) pm = PasswordManager.__new__(PasswordManager) @@ -150,8 +154,10 @@ def test_restore_from_nostr_warns(monkeypatch, tmp_path, capsys): assert "No Nostr backup" in out -def test_restore_from_nostr_abort(monkeypatch, tmp_path, capsys): - client, _relay = dummy_nostr_client.__wrapped__(tmp_path / "srv", monkeypatch) +def test_restore_from_nostr_abort( + monkeypatch, tmp_path, capsys, make_dummy_nostr_client +): + client, _relay = make_dummy_nostr_client(tmp_path / "srv") monkeypatch.setattr(client, "fetch_latest_snapshot", _no_snapshot) pm = PasswordManager.__new__(PasswordManager) diff --git a/src/tests/test_service_classes.py b/src/tests/test_service_classes.py index 530e60ff..e29ade7d 100644 --- a/src/tests/test_service_classes.py +++ b/src/tests/test_service_classes.py @@ -4,7 +4,7 @@ from pathlib import Path import pytest -from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client +from helpers import create_vault, TEST_SEED, TEST_PASSWORD from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.manager import PasswordManager, EncryptionMode diff --git a/src/tests/test_sync_race_conditions.py b/src/tests/test_sync_race_conditions.py index 07866594..b7dbc45a 100644 --- a/src/tests/test_sync_race_conditions.py +++ b/src/tests/test_sync_race_conditions.py @@ -3,7 +3,7 @@ import threading from pathlib import Path -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager @@ -31,8 +31,8 @@ def _init_pm(dir_path: Path, client) -> PasswordManager: return pm -def test_sync_race_conditions(monkeypatch, tmp_path): - client_a, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) +def test_sync_race_conditions(monkeypatch, tmp_path, make_dummy_nostr_client): + client_a, relay = make_dummy_nostr_client(tmp_path / "c1") from cryptography.fernet import Fernet from nostr.client import NostrClient diff --git a/src/tests/test_totp_uri.py b/src/tests/test_totp_uri.py index 1970794b..fb8c2b27 100644 --- a/src/tests/test_totp_uri.py +++ b/src/tests/test_totp_uri.py @@ -7,7 +7,6 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.totp import TotpManager - # Test parsing a normal otpauth URI with custom period and digits diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index a623b256..ff2517ec 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -2,7 +2,6 @@ import asyncio import logging from seedpass.core.manager import PasswordManager -from helpers import dummy_nostr_client def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 6b91dace..da73c00e 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -40,14 +40,15 @@ from .atomic_write import atomic_write try: # pragma: no cover - exercised when dependency missing from .clipboard import ClipboardUnavailableError, copy_to_clipboard except Exception as exc: # pragma: no cover - executed only if pyperclip missing + _error_msg = str(exc) class ClipboardUnavailableError(RuntimeError): """Stub exception when clipboard support is unavailable.""" def copy_to_clipboard(*_args, **_kwargs): """Stub when clipboard support is unavailable.""" - logger.warning("Clipboard support unavailable: %s", exc) - raise ClipboardUnavailableError(str(exc)) + logger.warning("Clipboard support unavailable: %s", _error_msg) + raise ClipboardUnavailableError(_error_msg) __all__ = [ diff --git a/src/utils/color_scheme.py b/src/utils/color_scheme.py index d0c13762..64d1faa9 100644 --- a/src/utils/color_scheme.py +++ b/src/utils/color_scheme.py @@ -2,7 +2,6 @@ from termcolor import colored - # ANSI escape for 256-color orange (color code 208) _ORANGE = "\033[38;5;208m" _RESET = "\033[0m" diff --git a/src/utils/key_derivation.py b/src/utils/key_derivation.py index 712a8e95..b473889a 100644 --- a/src/utils/key_derivation.py +++ b/src/utils/key_derivation.py @@ -21,7 +21,7 @@ import hmac import time from dataclasses import dataclass, field from enum import Enum -from typing import Optional, Union, Dict, Any +from typing import Optional, Union, Dict, Any, TYPE_CHECKING from bip_utils import Bip39SeedGenerator from local_bip85 import BIP85 @@ -46,6 +46,9 @@ DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY # Purpose constant for TOTP secret derivation using BIP85 TOTP_PURPOSE = 39 +if TYPE_CHECKING: + from seedpass.core.config_manager import ConfigManager + @dataclass class KdfConfig: diff --git a/src/utils/seed_prompt.py b/src/utils/seed_prompt.py index 45759e17..889ca8ea 100644 --- a/src/utils/seed_prompt.py +++ b/src/utils/seed_prompt.py @@ -17,7 +17,6 @@ except ImportError: # pragma: no cover - POSIX only from utils.terminal_utils import clear_screen from utils.logging_utils import pause_console_logging, resume_console_logging - DEFAULT_MAX_ATTEMPTS = 5 diff --git a/torch/eslint.config.mjs b/torch/eslint.config.mjs new file mode 100644 index 00000000..e65162b3 --- /dev/null +++ b/torch/eslint.config.mjs @@ -0,0 +1,21 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.node, + ...globals.es2021, + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + 'no-undef': 'error', + }, + ignores: ['node_modules/', 'task-logs/', 'package.json', 'package-lock.json', 'torch-config.json'], + }, +]; diff --git a/torch/src/cli-parser.mjs b/torch/src/cli-parser.mjs new file mode 100644 index 00000000..21e20b53 --- /dev/null +++ b/torch/src/cli-parser.mjs @@ -0,0 +1,137 @@ +import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; + +/** + * Parses command-line arguments into a structured object. + * + * @param {string[]} argv - Arguments from process.argv.slice(2) + * @returns {Object} - Parsed arguments (command, flags, values) + */ +export function parseArgs(argv) { + const args = { + command: null, + agent: null, + cadence: null, + json: false, + jsonFile: null, + quiet: false, + dryRun: false, + force: false, + port: DEFAULT_DASHBOARD_PORT, + host: null, + logDir: 'task-logs', + ignoreLogs: false, + id: null, + type: null, + tags: [], + pinned: null, + limit: null, + offset: null, + retentionMs: null, + windowMs: null, + timeoutMs: null, + allRelaysDownMinutes: null, + minSuccessRate: null, + windowMinutes: null, + platform: null, + model: null, + }; + let i = 0; + + if (argv.length > 0 && !argv[0].startsWith('-')) { + args.command = argv[0]; + i = 1; + } + + for (; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--agent' || arg === '-a') { + args.agent = argv[++i]; + } else if (arg === '--cadence' || arg === '-c') { + args.cadence = argv[++i]; + } else if (arg === '--json') { + args.json = true; + } else if (arg === '--json-file') { + args.jsonFile = argv[++i]; + } else if (arg.startsWith('--json-file=')) { + args.jsonFile = arg.split('=')[1]; + } else if (arg === '--quiet') { + args.quiet = true; + } else if (arg.startsWith('--agent=')) { + args.agent = arg.split('=')[1]; + } else if (arg.startsWith('--cadence=')) { + args.cadence = arg.split('=')[1]; + } else if (arg === '--dry-run') { + args.dryRun = true; + } else if (arg === '--force') { + args.force = true; + } else if (arg === '--port') { + args.port = parseInt(argv[++i], 10) || DEFAULT_DASHBOARD_PORT; + } else if (arg === '--host') { + args.host = argv[++i]; + } else if (arg === '--log-dir') { + args.logDir = argv[++i]; + } else if (arg === '--ignore-logs') { + args.ignoreLogs = true; + } else if (arg === '--id') { + args.id = argv[++i]; + } else if (arg.startsWith('--id=')) { + args.id = arg.split('=')[1]; + } else if (arg === '--type') { + args.type = argv[++i]; + } else if (arg.startsWith('--type=')) { + args.type = arg.split('=')[1]; + } else if (arg === '--tags') { + args.tags = String(argv[++i]).split(',').map((tag) => tag.trim()).filter(Boolean); + } else if (arg.startsWith('--tags=')) { + args.tags = String(arg.split('=')[1]).split(',').map((tag) => tag.trim()).filter(Boolean); + } else if (arg === '--pinned') { + const value = String(argv[++i]).toLowerCase(); + args.pinned = value === 'true' ? true : value === 'false' ? false : null; + } else if (arg.startsWith('--pinned=')) { + const value = String(arg.split('=')[1]).toLowerCase(); + args.pinned = value === 'true' ? true : value === 'false' ? false : null; + } else if (arg === '--limit') { + args.limit = parseInt(argv[++i], 10); + } else if (arg.startsWith('--limit=')) { + args.limit = parseInt(arg.split('=')[1], 10); + } else if (arg === '--offset') { + args.offset = parseInt(argv[++i], 10); + } else if (arg.startsWith('--offset=')) { + args.offset = parseInt(arg.split('=')[1], 10); + } else if (arg === '--retention-ms') { + args.retentionMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--retention-ms=')) { + args.retentionMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--window-ms') { + args.windowMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--window-ms=')) { + args.windowMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--timeout-ms') { + args.timeoutMs = parseInt(argv[++i], 10); + } else if (arg.startsWith('--timeout-ms=')) { + args.timeoutMs = parseInt(arg.split('=')[1], 10); + } else if (arg === '--all-relays-down-minutes') { + args.allRelaysDownMinutes = parseInt(argv[++i], 10); + } else if (arg.startsWith('--all-relays-down-minutes=')) { + args.allRelaysDownMinutes = parseInt(arg.split('=')[1], 10); + } else if (arg === '--min-success-rate') { + args.minSuccessRate = parseFloat(argv[++i]); + } else if (arg.startsWith('--min-success-rate=')) { + args.minSuccessRate = parseFloat(arg.split('=')[1]); + } else if (arg === '--window-minutes') { + args.windowMinutes = parseInt(argv[++i], 10); + } else if (arg.startsWith('--window-minutes=')) { + args.windowMinutes = parseInt(arg.split('=')[1], 10); + } else if (arg === '--platform') { + args.platform = argv[++i]; + } else if (arg.startsWith('--platform=')) { + args.platform = arg.split('=')[1]; + } else if (arg === '--model') { + args.model = argv[++i]; + } else if (arg.startsWith('--model=')) { + args.model = arg.split('=')[1]; + } + } + + return args; +} diff --git a/torch/src/cmd-check.mjs b/torch/src/cmd-check.mjs new file mode 100644 index 00000000..a0d16f4f --- /dev/null +++ b/torch/src/cmd-check.mjs @@ -0,0 +1,61 @@ +import { + loadTorchConfig, + getRelays, + getNamespace, +} from './torch-config.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks } from './lock-ops.mjs'; +import { todayDateStr } from './utils.mjs'; + +export async function cmdCheck(cadence, deps = {}) { + const { + loadTorchConfigFn = loadTorchConfig, + getRelaysFn = getRelays, + getNamespaceFn = getNamespace, + getRosterFn = getRoster, + queryLocksFn = queryLocks, + todayDateStrFn = todayDateStr, + } = deps; + + const relays = await getRelaysFn(); + const namespace = await getNamespaceFn(); + const dateStr = todayDateStrFn(); + const config = await loadTorchConfigFn(); + const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; + + console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + console.error(`Relays: ${relays.join(', ')}`); + if (pausedAgents.length > 0) { + console.error(`Paused agents: ${pausedAgents.join(', ')}`); + } + + const locks = await queryLocksFn(relays, cadence, dateStr, namespace); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = await getRosterFn(cadence); + + const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; + const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !excludedAgents.includes(a)); + + const result = { + namespace, + cadence, + date: dateStr, + locked: lockedAgents.sort(), + paused: pausedAgents.sort(), + excluded: excludedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + unknownLockedAgents: unknownLockedAgents.sort(), + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + console.log(JSON.stringify(result, null, 2)); + return result; +} diff --git a/torch/src/cmd-list.mjs b/torch/src/cmd-list.mjs new file mode 100644 index 00000000..65a9166e --- /dev/null +++ b/torch/src/cmd-list.mjs @@ -0,0 +1,62 @@ +import { + getRelays, + getNamespace, +} from './torch-config.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks } from './lock-ops.mjs'; +import { todayDateStr, nowUnix } from './utils.mjs'; + +export async function cmdList(cadence) { + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = todayDateStr(); + const cadences = cadence ? [cadence] : ['daily', 'weekly']; + + console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + + const results = await Promise.all( + cadences.map(async (c) => { + const locks = await queryLocks(relays, c, dateStr, namespace); + return { c, locks }; + }), + ); + + for (const { c, locks } of results) { + console.log(`\n${'='.repeat(72)}`); + console.log(`Active ${namespace} ${c} locks (${dateStr})`); + console.log('='.repeat(72)); + + if (locks.length === 0) { + console.log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null; + const remainMin = remaining ? Math.round(remaining / 60) : '?'; + + console.log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)}m ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = await getRoster(c); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !lockedAgents.has(a)); + + if (unknownLockedAgents.length > 0) { + console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + } + + console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + console.log(` Available: ${available.join(', ') || '(none)'}`); + } +} diff --git a/torch/src/cmd-lock.mjs b/torch/src/cmd-lock.mjs new file mode 100644 index 00000000..195baa56 --- /dev/null +++ b/torch/src/cmd-lock.mjs @@ -0,0 +1,122 @@ +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { + getRelays, + getNamespace, + getTtl, +} from './torch-config.mjs'; +import { + RACE_CHECK_DELAY_MS, + KIND_APP_DATA, +} from './constants.mjs'; +import { getRoster } from './roster.mjs'; +import { queryLocks, publishLock } from './lock-ops.mjs'; +import { todayDateStr, nowUnix } from './utils.mjs'; +import { ExitError } from './errors.mjs'; + +export async function cmdLock(agent, cadence, dryRun = false) { + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = todayDateStr(); + const ttl = await getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + console.error(`Relays: ${relays.join(', ')}`); + + const roster = await getRoster(cadence); + if (!roster.includes(agent)) { + console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + throw new ExitError(1, 'Agent not in roster'); + } + + console.error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + console.error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + console.log('LOCK_STATUS=denied'); + console.log('LOCK_REASON=already_locked'); + console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + throw new ExitError(3, 'Lock denied'); + } + + console.error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + console.error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', `${namespace}-agent-lock`], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + namespace, + date: dateStr, + platform: process.env.AGENT_PLATFORM || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + console.error(` Event ID: ${event.id}`); + + if (dryRun) { + console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + console.error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + console.error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + console.error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS)); + + const postLocks = await queryLocks(relays, cadence, dateStr, namespace); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => a.createdAt - b.createdAt); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + console.error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, + ); + console.log('LOCK_STATUS=race_lost'); + console.log('LOCK_REASON=earlier_claim_exists'); + console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); + throw new ExitError(3, 'Race check lost'); + } + + console.error('RACE CHECK: won'); + } + + console.log('LOCK_STATUS=ok'); + console.log(`LOCK_EVENT_ID=${event.id}`); + console.log(`LOCK_PUBKEY=${pk}`); + console.log(`LOCK_AGENT=${agent}`); + console.log(`LOCK_CADENCE=${cadence}`); + console.log(`LOCK_DATE=${dateStr}`); + console.log(`LOCK_EXPIRES=${expiresAt}`); + console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + return { status: 'ok', eventId: event.id }; +} diff --git a/torch/src/constants.mjs b/torch/src/constants.mjs new file mode 100644 index 00000000..8e5a6534 --- /dev/null +++ b/torch/src/constants.mjs @@ -0,0 +1,67 @@ +export const DEFAULT_DASHBOARD_PORT = 4173; +export const RACE_CHECK_DELAY_MS = 1500; +export const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', +]; +export const DEFAULT_TTL = 7200; +export const DEFAULT_NAMESPACE = 'torch'; +export const DEFAULT_QUERY_TIMEOUT_MS = 30_000; +export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000; +export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1; +export const DEFAULT_MIN_ACTIVE_RELAY_POOL = 1; +export const DEFAULT_RETRY_ATTEMPTS = 4; +export const DEFAULT_RETRY_BASE_DELAY_MS = 500; +export const DEFAULT_RETRY_CAP_DELAY_MS = 8000; +export const DEFAULT_ROLLING_WINDOW_SIZE = 25; +export const DEFAULT_FAILURE_THRESHOLD = 3; +export const DEFAULT_QUARANTINE_COOLDOWN_MS = 30_000; +export const DEFAULT_MAX_QUARANTINE_COOLDOWN_MS = 300_000; +export const DEFAULT_SNAPSHOT_INTERVAL_MS = 60_000; +export const VALID_CADENCES = new Set(['daily', 'weekly']); +export const KIND_APP_DATA = 30078; + +export const USAGE_TEXT = `Usage: torch-lock [options] + +Commands: + check --cadence Check locked agents (JSON) + lock --agent --cadence Claim a lock + complete --agent --cadence Mark task as completed (permanent) + list [--cadence ] Print active lock table + health --cadence Probe relay websocket + publish/read health + dashboard [--port ] [--host ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) + init [--force] Initialize torch/ directory in current project + update [--force] Update torch/ configuration (backups, merges) + + list-memories [--agent ] [--type ] [--tags ] [--pinned ] [--full] + inspect-memory --id + pin-memory --id + unpin-memory --id + trigger-prune-dry-run [--retention-ms ] + memory-stats [--window-ms ] + +Options: + --dry-run Build and sign the event but do not publish + --force Overwrite existing files (for init) or all files (for update) + --log-dir Path to task logs directory (default: task-logs) + --ignore-logs Skip checking local logs for completed tasks + --json Emit compact single-line JSON + --json-file Write JSON output to a file path + --quiet Suppress stderr progress logs (pairs well with --json) + +Environment: + NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch) + NOSTR_LOCK_RELAYS Comma-separated relay WSS URLs + NOSTR_LOCK_TTL Lock TTL in seconds (default: 7200) + NOSTR_LOCK_QUERY_TIMEOUT_MS Relay query timeout in milliseconds (default: 15000) + NOSTR_LOCK_DAILY_ROSTER Comma-separated daily roster (optional) + NOSTR_LOCK_WEEKLY_ROSTER Comma-separated weekly roster (optional) + TORCH_CONFIG_PATH Optional path to torch-config.json (default: ./torch-config.json) + AGENT_PLATFORM Platform identifier (e.g., codex) + +Exit codes: + 0 Success + 1 Usage error + 2 Relay/network error + 3 Lock denied (already locked or race lost)`; diff --git a/torch/src/dashboard.mjs b/torch/src/dashboard.mjs new file mode 100644 index 00000000..740357ac --- /dev/null +++ b/torch/src/dashboard.mjs @@ -0,0 +1,210 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import http from 'node:http'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { DEFAULT_DASHBOARD_PORT } from './constants.mjs'; +import { getDashboardAuth, parseTorchConfig } from './torch-config.mjs'; + +function timingSafeCompare(a, b) { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + // Still do a comparison to avoid some timing leaks, though length leak is hard to avoid entirely + crypto.timingSafeEqual(bufA, bufA); + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT, host = '127.0.0.1') { + // Resolve package root relative to this file (src/dashboard.mjs) + // this file is in /src/dashboard.mjs, so '..' goes to + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const auth = await getDashboardAuth(); + + const server = http.createServer(async (req, res) => { + try { + // Basic Auth check + if (auth) { + const authHeader = req.headers.authorization; + if (!authHeader) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Authentication required'); + return; + } + const parts = authHeader.split(' '); + const type = parts[0]; + const credentials = parts[1]; + let isValid = false; + if (type === 'Basic' && credentials) { + try { + const decoded = Buffer.from(credentials, 'base64').toString(); + isValid = timingSafeCompare(decoded, auth); + } catch { + isValid = false; + } + } + + if (!isValid) { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="TORCH Dashboard"' }); + res.end('Invalid credentials'); + return; + } + } + + // URL parsing + const url = new URL(req.url, `http://${req.headers.host}`); + let pathname = url.pathname; + + // Redirect / to /dashboard/ + if (pathname === '/' || pathname === '/dashboard') { + res.writeHead(302, { 'Location': '/dashboard/' }); + res.end(); + return; + } + + async function statSafe(p) { + try { + return await fsp.stat(p); + } catch { + return null; + } + } + + // Special case: /torch-config.json + // Priority: Env Var > User's CWD > Package default + if (pathname === '/torch-config.json') { + let configContent = null; + + if (process.env.TORCH_CONFIG_PATH) { + const envPath = path.resolve(process.cwd(), process.env.TORCH_CONFIG_PATH); + try { + configContent = await fsp.readFile(envPath, 'utf8'); + } catch { + // Explicitly provided config path not found, do not fall back + } + } else { + const userConfigPath = path.resolve(process.cwd(), 'torch-config.json'); + try { + configContent = await fsp.readFile(userConfigPath, 'utf8'); + } catch { + // Try package default + const packageConfigPath = path.join(packageRoot, 'torch-config.json'); + try { + configContent = await fsp.readFile(packageConfigPath, 'utf8'); + } catch { + // Not found in either location + } + } + } + + if (configContent) { + try { + const rawConfig = JSON.parse(configContent); + const safeConfig = parseTorchConfig(rawConfig); + + // Security: Sanitize sensitive fields + if (safeConfig.dashboard) { + delete safeConfig.dashboard.auth; + } + delete safeConfig.configPath; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(safeConfig, null, 2)); + return; + } catch (err) { + console.error('Error parsing torch-config.json:', err); + res.writeHead(500); + res.end('Internal Server Error'); + return; + } + } else { + res.writeHead(404); + res.end('Not Found'); + return; + } + } + + // Security check: prevent directory traversal + const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), ''); + let filePath = path.join(packageRoot, safePath); + + // Security check: restrict access to allowed paths + const allowedPaths = [ + path.join(packageRoot, 'dashboard'), + path.join(packageRoot, 'assets'), + path.join(packageRoot, 'src', 'docs'), + path.join(packageRoot, 'src', 'constants.mjs'), + path.join(packageRoot, 'torch-config.json') + ]; + + const isAllowed = allowedPaths.some(allowedPath => { + const rel = path.relative(allowedPath, filePath); + return !rel.startsWith('..') && !path.isAbsolute(rel); + }); + + if (!isAllowed) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + // If directory, try index.html + let fileStat = await statSafe(filePath); + if (fileStat && fileStat.isDirectory()) { + filePath = path.join(filePath, 'index.html'); + fileStat = await statSafe(filePath); + } + + // Check if file exists and is a file + if (!fileStat || !fileStat.isFile()) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + // MIME types + const extname = path.extname(filePath); + let contentType = 'text/plain'; + switch (extname) { + case '.html': contentType = 'text/html'; break; + case '.js': contentType = 'text/javascript'; break; + case '.mjs': contentType = 'text/javascript'; break; + case '.css': contentType = 'text/css'; break; + case '.json': contentType = 'application/json'; break; + case '.png': contentType = 'image/png'; break; + case '.jpg': contentType = 'image/jpeg'; break; + case '.svg': contentType = 'image/svg+xml'; break; + case '.ico': contentType = 'image/x-icon'; break; + case '.md': contentType = 'text/markdown'; break; + } + + res.writeHead(200, { 'Content-Type': contentType }); + fs.createReadStream(filePath).pipe(res); + } catch (err) { + console.error('Dashboard Server Error:', err); + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal Server Error'); + } + } + }); + + return new Promise((resolve, reject) => { + server.listen(port, host, () => { + const listenUrl = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}/dashboard/`; + console.log(`Dashboard running at ${listenUrl}`); + if (auth) { + console.log('Authentication: enabled (Basic Auth)'); + } else { + console.warn('Authentication: DISABLED (Dashboard is public)'); + } + console.log(`Serving files from ${packageRoot}`); + console.log(`Using configuration from ${process.cwd()}`); + resolve(server); + }); + server.on('error', reject); + }); +} diff --git a/torch/src/errors.mjs b/torch/src/errors.mjs new file mode 100644 index 00000000..c997a9f8 --- /dev/null +++ b/torch/src/errors.mjs @@ -0,0 +1,7 @@ +// Custom Error class for controlled exits +export class ExitError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} diff --git a/torch/src/lib.mjs b/torch/src/lib.mjs new file mode 100644 index 00000000..ec97d947 --- /dev/null +++ b/torch/src/lib.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env node + +// TORCH — Task Orchestration via Relay-Coordinated Handoff +// Generic Nostr-based task locking for multi-agent development. + +import { generateSecretKey as _generateSecretKey, getPublicKey as _getPublicKey, finalizeEvent as _finalizeEvent } from 'nostr-tools/pure'; +import { useWebSocketImplementation } from 'nostr-tools/relay'; +import WebSocket from 'ws'; +import { + loadTorchConfig as _loadTorchConfig, + getRelays as _getRelays, + getNamespace as _getNamespace, + getTtl as _getTtl, + getHashtag as _getHashtag, +} from './torch-config.mjs'; +import { + VALID_CADENCES, + KIND_APP_DATA, + RACE_CHECK_DELAY_MS, + USAGE_TEXT, +} from './constants.mjs'; +import { cmdInit, cmdUpdate } from './ops.mjs'; +import { parseArgs } from './cli-parser.mjs'; +import { getRoster as _getRoster } from './roster.mjs'; +import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs'; +import { cmdDashboard } from './dashboard.mjs'; +import { + inspectMemory, + listMemories, + memoryStats, + pinMemory, + triggerPruneDryRun, + unpinMemory, +} from './services/memory/index.js'; +import { ExitError } from './errors.mjs'; +import { todayDateStr, nowUnix, detectPlatform } from './utils.mjs'; +import { runRelayHealthCheck } from './relay-health.mjs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getCompletedAgents } from './lock-utils.mjs'; + +useWebSocketImplementation(WebSocket); + +// Re-export for backward compatibility/library usage +export { parseLockEvent, cmdDashboard, _queryLocks as queryLocks, _publishLock as publishLock }; + +/** + * Checks the status of the repository locks for a given cadence. + * + * It aggregates data from three sources: + * 1. Configuration (paused agents) + * 2. Local logs (completed agents) + * 3. Nostr relays (current active locks) + * + * It outputs a JSON object describing the state (locked, available, excluded agents). + * + * @param {string} cadence - 'daily' or 'weekly' + * @param {Object} [deps] - Dependency injection + * @returns {Promise} - The check result object + */ +export async function cmdCheck(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + loadTorchConfig = _loadTorchConfig, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error, + logDir = 'task-logs', + ignoreLogs = false, + json = false, + jsonFile = null, + quiet = false, + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = getDateStr(); + const config = await loadTorchConfig(); + const pausedAgents = config.scheduler.paused[cadence] || []; + + if (!quiet) { + error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); + if (pausedAgents.length > 0) { + error(`Paused agents: ${pausedAgents.join(', ')}`); + } + } + + let completedAgents = new Set(); + if (!ignoreLogs) { + completedAgents = await getCompletedAgents(cadence, logDir, deps); + if (!quiet && completedAgents.size > 0) { + error(`Completed agents (logs): ${[...completedAgents].join(', ')}`); + } + } + + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = await getRoster(cadence); + const rosterSet = new Set(roster); + + const excludedAgentsSet = new Set([...lockedAgents, ...pausedAgents, ...completedAgents]); + const excludedAgents = [...excludedAgentsSet]; + const unknownLockedAgents = lockedAgents.filter((agent) => !rosterSet.has(agent)); + const available = roster.filter((a) => !excludedAgentsSet.has(a)); + + const result = { + namespace, + cadence, + date: dateStr, + locked: lockedAgents.sort(), + paused: pausedAgents.sort(), + completed: [...completedAgents].sort(), + excluded: excludedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + unknownLockedAgents: unknownLockedAgents.sort(), + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + const output = json ? JSON.stringify(result) : JSON.stringify(result, null, 2); + + if (jsonFile) { + const resolvedPath = path.resolve(process.cwd(), jsonFile); + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.writeFile(resolvedPath, `${output}\n`, 'utf8'); + } + + if (json || !quiet) { + log(output); + } + + return result; +} + +/** + * Attempts to acquire an exclusive lock for an agent on the specified cadence. + * + * Algorithm: + * 1. Validate agent against the roster. + * 2. Query relays for existing valid locks (checking for conflicts). + * 3. Generate a new ephemeral keypair and build a lock event (kind 30078). + * 4. Publish the lock event to relays. + * 5. Wait for propagation (raceCheckDelayMs) and re-query to confirm no earlier lock won the race. + * + * @param {string} agent - Agent name + * @param {string} cadence - 'daily' or 'weekly' + * @param {boolean} [dryRun=false] - If true, skips publishing + * @param {Object} [deps] - Dependency injection + * @returns {Promise<{status: string, eventId: string}>} + * @throws {ExitError} If lock is denied (already locked, completed, or race lost) + */ +export async function cmdLock(agent, cadence, optionsOrDryRun = false, deps = {}) { + const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun }; + const { dryRun = false, platform = null, model = null } = options; + + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + getHashtag = _getHashtag, + getTtl = _getTtl, + queryLocks = _queryLocks, + getRoster = _getRoster, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + raceCheckDelayMs = RACE_CHECK_DELAY_MS, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); + const dateStr = getDateStr(); + const ttl = await getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`Hashtag: #${hashtag}`); + error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + error(`Relays: ${relays.join(', ')}`); + + const roster = await getRoster(cadence); + if (!roster.includes(agent)) { + error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + throw new ExitError(1, 'Agent not in roster'); + } + + error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + + // Check if it is a completed task + if (earliest.status === 'completed') { + error(`LOCK DENIED: Task already completed by event ${earliest.eventId}`); + log('LOCK_STATUS=denied'); + log('LOCK_REASON=already_completed'); + throw new ExitError(3, 'Task already completed'); + } + + error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + log('LOCK_STATUS=denied'); + log('LOCK_REASON=already_locked'); + log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + throw new ExitError(3, 'Lock denied'); + } + + error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', hashtag], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + namespace, + date: dateStr, + platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', + model: model || process.env.AGENT_MODEL || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + error(` Event ID: ${event.id}`); + + if (dryRun) { + error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, raceCheckDelayMs)); + + const postLocks = await queryLocks(relays, cadence, dateStr, namespace); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => (a.createdAt - b.createdAt) || String(a.eventId).localeCompare(String(b.eventId))); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, + ); + log('LOCK_STATUS=race_lost'); + log('LOCK_REASON=earlier_claim_exists'); + log(`LOCK_WINNER_EVENT=${winner.eventId}`); + throw new ExitError(3, 'Race check lost'); + } + + error('RACE CHECK: won'); + } + + log('LOCK_STATUS=ok'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_HASHTAG=${hashtag}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + log(`LOCK_EXPIRES=${expiresAt}`); + log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + return { status: 'ok', eventId: event.id }; +} + +/** + * Lists all active locks for the specified cadence (or all cadences if null). + * It prints a formatted table to stdout with lock age, TTL, and event ID. + * + * @param {string|null} cadence - Filter by cadence ('daily', 'weekly') or null for all + * @param {Object} [deps] - Dependency injection + * @returns {Promise} + */ +export async function cmdList(cadence, deps = {}) { + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + queryLocks = _queryLocks, + getRoster = _getRoster, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const dateStr = getDateStr(); + const cadences = cadence ? [cadence] : [...VALID_CADENCES]; + + error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + + const results = await Promise.all( + cadences.map(async (c) => { + const locks = await queryLocks(relays, c, dateStr, namespace); + return { c, locks }; + }), + ); + + for (const { c, locks } of results) { + log(`\n${'='.repeat(72)}`); + log(`Active ${namespace} ${c} locks (${dateStr})`); + log('='.repeat(72)); + + if (locks.length === 0) { + log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + + let remainMin = '?'; + if (lock.status === 'completed') { + remainMin = 'done'; + } else if (lock.expiresAt) { + const remaining = lock.expiresAt - nowUnix(); + remainMin = Math.round(remaining / 60); + } + + log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)} ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = await getRoster(c); + const rosterSet = new Set(roster); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const unknownLockedAgents = [...lockedAgents].filter((agent) => !rosterSet.has(agent)); + const available = roster.filter((a) => !lockedAgents.has(a)); + + if (unknownLockedAgents.length > 0) { + log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + } + + log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + log(` Available: ${available.join(', ') || '(none)'}`); + } +} + +/** + * Marks a task as permanently completed by publishing a new lock event with + * `status: 'completed'` and no expiration. + * + * This function: + * 1. Verifies that the agent currently holds a valid lock. + * 2. Publishes a replacement event that preserves the original `startedAt` time. + * + * @param {string} agent - Agent name + * @param {string} cadence - 'daily' or 'weekly' + * @param {boolean} [dryRun=false] - If true, skips publishing + * @param {Object} [deps] - Dependency injection + * @returns {Promise<{status: string, eventId: string}>} + * @throws {ExitError} If no active lock exists for the agent + */ +export async function cmdComplete(agent, cadence, optionsOrDryRun = false, deps = {}) { + const options = typeof optionsOrDryRun === 'object' ? optionsOrDryRun : { dryRun: !!optionsOrDryRun }; + const { dryRun = false, platform = null, model = null } = options; + + const { + getRelays = _getRelays, + getNamespace = _getNamespace, + getHashtag = _getHashtag, + queryLocks = _queryLocks, + publishLock = _publishLock, + generateSecretKey = _generateSecretKey, + getPublicKey = _getPublicKey, + finalizeEvent = _finalizeEvent, + getDateStr = todayDateStr, + log = console.log, + error = console.error + } = deps; + + const relays = await getRelays(); + const namespace = await getNamespace(); + const hashtag = await getHashtag(); + const dateStr = getDateStr(); + const now = nowUnix(); + + error(`Completing task: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + error(`Relays: ${relays.join(', ')}`); + + // 1. Find existing lock + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const myLock = locks.find((l) => l.agent === agent); + + if (!myLock) { + error(`ERROR: No active lock found for agent "${agent}" on ${dateStr}.`); + error(`Cannot complete a task that is not locked or has already expired.`); + throw new ExitError(1, 'No active lock found'); + } + + if (myLock.status === 'completed') { + error(`Task is already marked as completed (event ${myLock.eventId}).`); + log('LOCK_STATUS=completed'); + return { status: 'completed', eventId: myLock.eventId }; + } + + // 2. Build completion event + const startedAtIso = myLock.createdAtIso; + + error('Step 1: Generating completion event...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + + const event = finalizeEvent( + { + kind: KIND_APP_DATA, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', hashtag], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + // No expiration tag -> permanent + ], + content: JSON.stringify({ + agent, + cadence, + status: 'completed', + namespace, + date: dateStr, + platform: platform || process.env.AGENT_PLATFORM || detectPlatform() || 'unknown', + model: model || process.env.AGENT_MODEL || 'unknown', + startedAt: startedAtIso, + completedAt: new Date(now * 1000).toISOString(), + }), + }, + sk, + ); + + error(` Event ID: ${event.id}`); + + if (dryRun) { + error('Step 2: [DRY RUN] Skipping publish — event built but not sent'); + } else { + error('Step 2: Publishing completion event...'); + await publishLock(relays, event); + error(' Published successfully.'); + } + + log('LOCK_STATUS=completed'); + log(`LOCK_EVENT_ID=${event.id}`); + log(`LOCK_PUBKEY=${pk}`); + log(`LOCK_AGENT=${agent}`); + log(`LOCK_CADENCE=${cadence}`); + log(`LOCK_DATE=${dateStr}`); + + return { status: 'completed', eventId: event.id }; +} + +function usage() { + console.error(USAGE_TEXT); +} + +/** + * Main entry point for the torch-lock CLI. + * Dispatches to specific commands (check, lock, complete, etc.) based on argv. + * + * @param {string[]} argv - Arguments from process.argv.slice(2) + */ +export async function main(argv) { + try { + const args = parseArgs(argv); + + if (!args.command) { + usage(); + throw new ExitError(1, 'No command specified'); + } + + switch (args.command) { + case 'check': { + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for check`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdCheck(args.cadence, { + logDir: args.logDir, + ignoreLogs: args.ignoreLogs, + json: args.json, + jsonFile: args.jsonFile, + quiet: args.quiet, + }); + break; + } + + case 'lock': { + if (!args.agent) { + console.error('ERROR: --agent is required for lock'); + throw new ExitError(1, 'Missing agent'); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for lock`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdLock(args.agent, args.cadence, { + dryRun: args.dryRun, + platform: args.platform, + model: args.model + }); + break; + } + + case 'complete': { + if (!args.agent) { + console.error('ERROR: --agent is required for complete'); + throw new ExitError(1, 'Missing agent'); + } + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for complete`); + throw new ExitError(1, 'Missing cadence'); + } + await cmdComplete(args.agent, args.cadence, { + dryRun: args.dryRun, + platform: args.platform, + model: args.model + }); + break; + } + + case 'list': { + if (args.cadence && !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence must be one of: ${[...VALID_CADENCES].join(', ')}`); + throw new ExitError(1, 'Invalid cadence'); + } + await cmdList(args.cadence || null); + break; + } + + case 'health': { + if (!args.cadence || !VALID_CADENCES.has(args.cadence)) { + console.error(`ERROR: --cadence <${[...VALID_CADENCES].join('|')}> is required for health`); + throw new ExitError(1, 'Missing cadence'); + } + const result = await runRelayHealthCheck({ + cadence: args.cadence, + ...(Number.isFinite(args.timeoutMs) ? { timeoutMs: args.timeoutMs } : {}), + ...(Number.isFinite(args.allRelaysDownMinutes) ? { allRelaysDownMinutes: args.allRelaysDownMinutes } : {}), + ...(Number.isFinite(args.minSuccessRate) ? { minSuccessRate: args.minSuccessRate } : {}), + ...(Number.isFinite(args.windowMinutes) ? { windowMinutes: args.windowMinutes } : {}), + }); + if (!result.ok) { + result.failureCategory = 'all relays unhealthy'; + } + console.log(JSON.stringify(result, null, 2)); + if (!result.ok) throw new ExitError(2, 'Relay health check failed'); + break; + } + + case 'dashboard': { + await cmdDashboard(args.port, args.host); + break; + } + + case 'init': { + await cmdInit(args.force); + break; + } + + case 'update': { + await cmdUpdate(args.force); + break; + } + + case 'list-memories': { + const result = await listMemories({ + agent_id: args.agent, + type: args.type, + tags: args.tags, + pinned: args.pinned, + limit: args.limit, + offset: args.offset, + }); + + if (!args.full && Array.isArray(result)) { + for (const memory of result) { + if (typeof memory.content === 'string' && memory.content.length > 200) { + memory.content = memory.content.slice(0, 200) + '... (truncated, use --full to see all)'; + } + } + } + + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'inspect-memory': { + if (!args.id) { + console.error('ERROR: --id is required for inspect-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await inspectMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'pin-memory': { + if (!args.id) { + console.error('ERROR: --id is required for pin-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await pinMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'unpin-memory': { + if (!args.id) { + console.error('ERROR: --id is required for unpin-memory'); + throw new ExitError(1, 'Missing memory id'); + } + const result = await unpinMemory(args.id); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'trigger-prune-dry-run': { + const result = await triggerPruneDryRun({ retentionMs: args.retentionMs ?? undefined }); + console.log(JSON.stringify(result, null, 2)); + break; + } + + case 'memory-stats': { + const result = await memoryStats({ windowMs: args.windowMs ?? undefined }); + console.log(JSON.stringify(result, null, 2)); + break; + } + + default: + console.error(`ERROR: Unknown command: ${args.command}`); + usage(); + throw new ExitError(1, 'Unknown command'); + } + } catch (err) { + if (err instanceof ExitError) { + process.exit(err.code); + } else { + console.error(`torch-lock failed: ${err.message}`); + process.exit(2); + } + } +} diff --git a/torch/src/lock-ops.mjs b/torch/src/lock-ops.mjs new file mode 100644 index 00000000..5fac6227 --- /dev/null +++ b/torch/src/lock-ops.mjs @@ -0,0 +1,697 @@ +import { randomInt, randomUUID } from 'node:crypto'; +import { SimplePool } from 'nostr-tools/pool'; +import { + getQueryTimeoutMs, + getPublishTimeoutMs, + getMinSuccessfulRelayPublishes, + getRelayFallbacks, + getMinActiveRelayPool, +} from './torch-config.mjs'; +import { + KIND_APP_DATA, + DEFAULT_RETRY_ATTEMPTS, + DEFAULT_RETRY_BASE_DELAY_MS, + DEFAULT_RETRY_CAP_DELAY_MS, + DEFAULT_ROLLING_WINDOW_SIZE, + DEFAULT_FAILURE_THRESHOLD, + DEFAULT_QUARANTINE_COOLDOWN_MS, + DEFAULT_MAX_QUARANTINE_COOLDOWN_MS, + DEFAULT_SNAPSHOT_INTERVAL_MS, +} from './constants.mjs'; + +function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +export function parseLockEvent(event) { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + const expTag = event.tags.find((t) => t[0] === 'expiration')?.[1]; + const expiresAt = expTag ? parseInt(expTag, 10) : null; + + let content = {}; + try { + const parsed = JSON.parse(event.content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + content = parsed; + } + } catch { + // Ignore malformed JSON content + } + + return { + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + createdAtIso: new Date(event.created_at * 1000).toISOString(), + expiresAt, + expiresAtIso: expiresAt ? new Date(expiresAt * 1000).toISOString() : null, + dTag, + agent: content.agent ?? null, + cadence: content.cadence ?? null, + status: content.status ?? null, + date: content.date ?? null, + platform: content.platform ?? null, + }; +} + +function filterActiveLocks(locks) { + const now = nowUnix(); + return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now); +} + +function withTimeout(promise, timeoutMs, timeoutMessage) { + let timeoutHandle; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); +} + +function relayListLabel(relays) { + return relays.join(', '); +} + +function classifyPublishError(message) { + const normalized = String(message || '').toLowerCase(); + if (normalized.includes('publish timed out after') || normalized.includes('publish timeout')) { + return 'publish_timeout'; + } + if ( + normalized.includes('enotfound') + || normalized.includes('eai_again') + || normalized.includes('getaddrinfo') + || (normalized.includes('dns') && normalized.includes('websocket')) + ) { + return 'dns_resolution'; + } + if ( + normalized.includes('connect etimedout') + || normalized.includes('tcp connect timed out') + || normalized.includes('connect timeout') + ) { + return 'tcp_connect_timeout'; + } + if ( + normalized.includes('tls') + || normalized.includes('ssl') + || normalized.includes('certificate') + || normalized.includes('handshake') + ) { + return 'tls_handshake'; + } + if ( + normalized.includes('websocket') + || normalized.includes('bad response') + || normalized.includes('unexpected server response') + ) { + return 'websocket_open_failure'; + } + if (normalized.includes('timed out') || normalized.includes('timeout') || normalized.includes('etimedout')) { + return 'network_timeout'; + } + if (normalized.includes('econnreset') || normalized.includes('connection reset') || normalized.includes('socket hang up')) { + return 'connection_reset'; + } + if ( + normalized.includes('unavailable') + || normalized.includes('offline') + || normalized.includes('econnrefused') + || normalized.includes('connection refused') + || normalized.includes('enotfound') + || normalized.includes('503') + ) { + return 'relay_unavailable'; + } + return 'permanent_validation_error'; +} + +function isTransientPublishCategory(category) { + return [ + 'publish_timeout', + 'dns_resolution', + 'tcp_connect_timeout', + 'tls_handshake', + 'websocket_open_failure', + 'network_timeout', + 'connection_reset', + 'relay_unavailable', + ].includes(category); +} + +const MAX_RANDOM = 281474976710655; // 2**48 - 1 + +function secureRandom() { + return randomInt(0, MAX_RANDOM) / MAX_RANDOM; +} + +function calculateBackoffDelayMs(attemptNumber, baseMs, capMs, randomFn = secureRandom) { + const maxDelay = Math.min(capMs, baseMs * (2 ** Math.max(0, attemptNumber - 1))); + return Math.floor(randomFn() * maxDelay); +} + +function summarizeRelayMetrics(metrics, nowMs) { + const total = metrics.recentOutcomes.length; + const successCount = metrics.recentOutcomes.filter((entry) => entry.success).length; + const timeoutCount = metrics.recentOutcomes.filter((entry) => entry.timedOut).length; + const latencyEntries = metrics.recentOutcomes.map((entry) => entry.latencyMs).filter((v) => Number.isFinite(v)); + const averageLatencyMs = latencyEntries.length + ? Math.round(latencyEntries.reduce((sum, value) => sum + value, 0) / latencyEntries.length) + : null; + const successRate = total > 0 ? successCount / total : 0.5; + const timeoutRate = total > 0 ? timeoutCount / total : 0; + const quarantineRemainingMs = metrics.quarantineUntil > nowMs ? metrics.quarantineUntil - nowMs : 0; + return { + relay: metrics.relay, + sampleSize: total, + successRate, + timeoutRate, + averageLatencyMs, + failureStreak: metrics.failureStreak, + quarantined: quarantineRemainingMs > 0, + quarantineRemainingMs, + cooldownMs: metrics.cooldownMs, + lastResultAt: metrics.lastResultAt, + }; +} + +function computeRelayScore(summary) { + const latencyPenalty = summary.averageLatencyMs === null + ? 0.1 + : Math.min(0.35, summary.averageLatencyMs / 10_000); + const quarantinePenalty = summary.quarantined ? 1 : 0; + return (summary.successRate * 1.4) - (summary.timeoutRate * 0.9) - latencyPenalty - quarantinePenalty; +} + +export class RelayHealthManager { + constructor() { + this.metricsByRelay = new Map(); + this.lastSnapshotAt = 0; + } + + ensureMetrics(relay, config) { + let metrics = this.metricsByRelay.get(relay); + if (!metrics) { + metrics = { + relay, + recentOutcomes: [], + failureStreak: 0, + quarantineUntil: 0, + cooldownMs: config.quarantineCooldownMs, + lastLatencyMs: null, + lastResultAt: null, + }; + this.metricsByRelay.set(relay, metrics); + } + return metrics; + } + + rankRelays(relays, config, nowMs = Date.now()) { + const summaries = relays.map((relay) => { + const metrics = this.ensureMetrics(relay, config); + const summary = summarizeRelayMetrics(metrics, nowMs); + return { + relay, + summary, + score: computeRelayScore(summary), + }; + }); + + return summaries.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (a.summary.quarantined !== b.summary.quarantined) return a.summary.quarantined ? 1 : -1; + if (a.summary.averageLatencyMs !== b.summary.averageLatencyMs) { + if (a.summary.averageLatencyMs === null) return 1; + if (b.summary.averageLatencyMs === null) return -1; + return a.summary.averageLatencyMs - b.summary.averageLatencyMs; + } + return a.relay.localeCompare(b.relay); + }); + } + + prioritizeRelays(relays, config, nowMs = Date.now()) { + const minActive = Math.max(1, Math.min(config.minActiveRelayPool, relays.length || 1)); + const ranked = this.rankRelays([...new Set(relays)], config, nowMs); + const active = ranked.filter((entry) => !entry.summary.quarantined); + const quarantined = ranked + .filter((entry) => entry.summary.quarantined) + .sort((a, b) => a.summary.quarantineRemainingMs - b.summary.quarantineRemainingMs); + + const selected = [...active]; + if (selected.length < minActive) { + const additionalNeeded = Math.min(minActive - selected.length, quarantined.length); + selected.push(...quarantined.slice(0, additionalNeeded)); + } + + return { + prioritized: selected.map((entry) => entry.relay), + ranked, + }; + } + + recordOutcome(relay, success, errorMessage, latencyMs, config, nowMs = Date.now()) { + const metrics = this.ensureMetrics(relay, config); + const timedOut = String(errorMessage || '').toLowerCase().includes('timeout') + || String(errorMessage || '').toLowerCase().includes('timed out'); + metrics.recentOutcomes.push({ success, timedOut, latencyMs, atMs: nowMs }); + if (metrics.recentOutcomes.length > config.rollingWindowSize) { + metrics.recentOutcomes.splice(0, metrics.recentOutcomes.length - config.rollingWindowSize); + } + metrics.lastResultAt = nowMs; + metrics.lastLatencyMs = Number.isFinite(latencyMs) ? latencyMs : null; + + if (success) { + metrics.failureStreak = 0; + if (metrics.quarantineUntil > nowMs) { + metrics.quarantineUntil = 0; + metrics.cooldownMs = config.quarantineCooldownMs; + } + return; + } + + metrics.failureStreak += 1; + if (metrics.failureStreak >= config.failureThreshold) { + metrics.quarantineUntil = nowMs + metrics.cooldownMs; + metrics.cooldownMs = Math.min(config.maxQuarantineCooldownMs, Math.floor(metrics.cooldownMs * 1.5)); + } + } + + collectSnapshot(relays, config, nowMs = Date.now()) { + const uniqueRelays = [...new Set(relays)]; + return this.rankRelays(uniqueRelays, config, nowMs).map((entry) => ({ + relay: entry.relay, + score: Number(entry.score.toFixed(4)), + ...entry.summary, + })); + } + + maybeLogSnapshot(relays, config, logger, reason, force = false, nowMs = Date.now()) { + const intervalReached = nowMs - this.lastSnapshotAt >= config.snapshotIntervalMs; + if (!force && !intervalReached) return; + this.lastSnapshotAt = nowMs; + logger(JSON.stringify({ + event: 'relay_health_snapshot', + reason, + relays: this.collectSnapshot(relays, config, nowMs), + })); + } + + reset() { + this.metricsByRelay.clear(); + this.lastSnapshotAt = 0; + } +} + +export const defaultHealthManager = new RelayHealthManager(); + +function buildRelayHealthConfig(deps) { + return { + rollingWindowSize: deps.rollingWindowSize ?? DEFAULT_ROLLING_WINDOW_SIZE, + failureThreshold: deps.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD, + quarantineCooldownMs: deps.quarantineCooldownMs ?? DEFAULT_QUARANTINE_COOLDOWN_MS, + maxQuarantineCooldownMs: deps.maxQuarantineCooldownMs ?? DEFAULT_MAX_QUARANTINE_COOLDOWN_MS, + snapshotIntervalMs: deps.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS, + minActiveRelayPool: Math.max(1, deps.minActiveRelayPool), + }; +} + +function mergeRelayList(primaryRelays, fallbackRelays) { + return [...new Set([...primaryRelays, ...fallbackRelays])]; +} + +export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) { + const { + poolFactory = () => new SimplePool(), + getQueryTimeoutMsFn = getQueryTimeoutMs, + getRelayFallbacksFn = getRelayFallbacks, + getMinActiveRelayPoolFn = getMinActiveRelayPool, + errorLogger = console.error, + healthLogger = console.error, + healthManager = defaultHealthManager, + } = deps; + + const pool = poolFactory(); + const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; + const queryTimeoutMs = await getQueryTimeoutMsFn(); + const fallbackRelays = (await getRelayFallbacksFn()).filter((relay) => !relays.includes(relay)); + const healthConfig = buildRelayHealthConfig({ + ...deps, + minActiveRelayPool: await getMinActiveRelayPoolFn(), + }); + + const runQuery = async (relaySet, phase) => { + const { prioritized } = healthManager.prioritizeRelays(relaySet, healthConfig); + if (prioritized.length > 0) { + errorLogger(`[${phase}] Querying ${prioritized.length} relays (${relayListLabel(prioritized)})...`); + } + + const startedAtMs = Date.now(); + try { + const events = await withTimeout( + pool.querySync(prioritized, { + kinds: [KIND_APP_DATA], + '#t': [tagFilter], + }), + queryTimeoutMs, + `[${phase}] Relay query timed out after ${queryTimeoutMs}ms (relays: ${relayListLabel(prioritized)})`, + ); + const elapsedMs = Date.now() - startedAtMs; + for (const relay of prioritized) { + healthManager.recordOutcome(relay, true, null, elapsedMs, healthConfig); + } + return filterActiveLocks(events.map(parseLockEvent)); + } catch (err) { + const elapsedMs = Date.now() - startedAtMs; + const message = err instanceof Error ? err.message : String(err); + for (const relay of prioritized) { + healthManager.recordOutcome(relay, false, message, elapsedMs, healthConfig); + } + throw new Error( + `[${phase}] Relay query failed (timeout=${queryTimeoutMs}ms, relays=${relayListLabel(prioritized)}): ${message}`, + { cause: err }, + ); + } + }; + + const allRelays = mergeRelayList(relays, fallbackRelays); + + try { + healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:periodic'); + try { + return await runQuery(relays, 'query:primary'); + } catch (primaryErr) { + if (!fallbackRelays.length) { + healthManager.maybeLogSnapshot(allRelays, healthConfig, healthLogger, 'query:failure', true); + throw primaryErr; + } + errorLogger(`WARN: ${primaryErr.message}`); + errorLogger(`WARN: retrying query with fallback relays (${relayListLabel(fallbackRelays)})`); + return await runQuery(fallbackRelays, 'query:fallback'); + } + } finally { + pool.close(allRelays); + } +} + +async function publishToRelays(pool, relays, event, publishTimeoutMs, phase) { + const publishPromises = pool.publish(relays, event); + const settled = await Promise.allSettled( + publishPromises.map((publishPromise, index) => withTimeout( + publishPromise, + publishTimeoutMs, + `[${phase}] Publish timed out after ${publishTimeoutMs}ms (relay=${relays[index]})`, + )), + ); + + return settled.map((result, index) => { + if (result.status === 'fulfilled') { + return { + relay: relays[index], + success: true, + phase, + latencyMs: null, + }; + } + const reason = result.reason; + const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); + return { + relay: relays[index], + success: false, + phase, + message, + latencyMs: null, + }; + }); +} + +export class LockPublisher { + constructor(relays, event, deps = {}) { + this.relays = relays; + this.event = event; + this.deps = deps; + this.pool = null; + this.healthConfig = null; + this.allRelays = []; + this.fallbackRelays = []; + this.publishTimeoutMs = 0; + this.minSuccesses = 0; + this.minActiveRelayPool = 0; + this.maxAttempts = 0; + this.retryBaseDelayMs = 0; + this.retryCapDelayMs = 0; + this.sleepFn = null; + this.randomFn = null; + this.telemetryLogger = null; + this.healthLogger = null; + this.healthManager = null; + this.correlationId = null; + this.attemptId = null; + } + + async publish() { + const { + poolFactory = () => new SimplePool(), + retryAttempts = DEFAULT_RETRY_ATTEMPTS, + retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, + retryCapDelayMs = DEFAULT_RETRY_CAP_DELAY_MS, + sleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + randomFn = Math.random, + telemetryLogger = console.error, + healthLogger = console.error, + diagnostics = {}, + healthManager = defaultHealthManager, + } = this.deps; + + this.healthManager = healthManager; + this.pool = poolFactory(); + this.publishTimeoutMs = this.deps.resolvedConfig?.publishTimeoutMs; + this.minSuccesses = this.deps.resolvedConfig?.minSuccesses; + this.fallbackRelays = (this.deps.resolvedConfig?.fallbackRelays || []).filter((relay) => !this.relays.includes(relay)); + this.maxAttempts = Math.max(1, Math.floor(retryAttempts)); + this.healthConfig = buildRelayHealthConfig({ + ...this.deps, + minActiveRelayPool: this.deps.resolvedConfig?.minActiveRelayPool, + }); + + this.retryBaseDelayMs = retryBaseDelayMs; + this.retryCapDelayMs = retryCapDelayMs; + this.sleepFn = sleepFn; + this.randomFn = randomFn; + this.telemetryLogger = telemetryLogger; + this.healthLogger = healthLogger; + this.healthManager = healthManager; + this.correlationId = diagnostics.correlationId || randomUUID(); + this.attemptId = diagnostics.attemptId || randomUUID(); + + this.allRelays = mergeRelayList(this.relays, this.fallbackRelays); + + try { + this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:periodic'); + let lastAttemptState = null; + const retryTimeline = []; + const overallStartedAt = Date.now(); + let terminalFailureCategory = 'relay_publish_non_retryable'; + + for (let attemptNumber = 1; attemptNumber <= this.maxAttempts; attemptNumber += 1) { + const startedAtMs = Date.now(); + lastAttemptState = await this.executePublishCycle(); + const elapsedMs = Date.now() - startedAtMs; + retryTimeline.push({ + publishAttempt: attemptNumber, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + elapsedMs, + }); + + if (lastAttemptState.successCount >= this.minSuccesses) { + this.logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt); + return this.event; + } + + const { hasTransientFailure, hasPermanentFailure } = this.analyzeFailures(lastAttemptState.failures); + const canRetry = attemptNumber < this.maxAttempts && hasTransientFailure && !hasPermanentFailure; + + terminalFailureCategory = this.determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber); + + if (!canRetry) { + break; + } + + const nextDelayMs = calculateBackoffDelayMs(attemptNumber, this.retryBaseDelayMs, this.retryCapDelayMs, this.randomFn); + this.logRetry(attemptNumber, lastAttemptState.failures, elapsedMs, nextDelayMs); + await this.sleepFn(nextDelayMs); + } + + this.handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt); + } finally { + this.pool.close(this.allRelays); + } + } + + async executePublishCycle() { + const attempted = new Set(); + const publishResults = []; + + await this.attemptPhase(this.relays, 'publish:primary', attempted, publishResults); + + let successCount = publishResults.filter((result) => result.success).length; + if (successCount < this.minSuccesses && this.fallbackRelays.length > 0) { + await this.attemptPhase(this.fallbackRelays, 'publish:fallback', attempted, publishResults); + successCount = publishResults.filter((result) => result.success).length; + } + + const failures = publishResults + .filter((result) => !result.success) + .map((result) => ({ + ...result, + category: classifyPublishError(result.message), + })); + + return { attempted, publishResults, successCount, failures }; + } + + async attemptPhase(phaseRelays, phaseName, attempted, publishResults) { + if (!phaseRelays.length) return; + const { prioritized } = this.healthManager.prioritizeRelays(phaseRelays, this.healthConfig); + if (!prioritized.length) return; + + console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${prioritized.join(', ')})...`); + + const startedAtMs = Date.now(); + for (const relay of prioritized) { + attempted.add(relay); + } + const phaseResults = await publishToRelays(this.pool, prioritized, this.event, this.publishTimeoutMs, phaseName); + const elapsedMs = Date.now() - startedAtMs; + for (const result of phaseResults) { + result.latencyMs = elapsedMs; + this.healthManager.recordOutcome(result.relay, result.success, result.message, elapsedMs, this.healthConfig); + } + publishResults.push(...phaseResults); + } + + analyzeFailures(failures) { + const hasTransientFailure = failures.some((failure) => isTransientPublishCategory(failure.category)); + const hasPermanentFailure = failures.some((failure) => !isTransientPublishCategory(failure.category)); + return { hasTransientFailure, hasPermanentFailure }; + } + + determineFailureCategory(hasTransientFailure, hasPermanentFailure, attemptNumber) { + if (hasTransientFailure && !hasPermanentFailure && attemptNumber >= this.maxAttempts) { + return 'relay_publish_quorum_failure'; + } else if (hasPermanentFailure) { + return 'relay_publish_non_retryable'; + } else { + return 'relay_publish_quorum_failure'; + } + } + + logSuccess(attemptNumber, lastAttemptState, retryTimeline, overallStartedAt) { + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_quorum_met', + correlationId: this.correlationId, + attemptId: this.attemptId, + publishAttempt: attemptNumber, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + requiredSuccesses: this.minSuccesses, + timeoutMs: this.publishTimeoutMs, + retryTimeline, + totalElapsedMs: Date.now() - overallStartedAt, + })); + console.error( + ` Published to ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} relays ` + + `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms)`, + ); + } + + logRetry(attemptNumber, failures, elapsedMs, nextDelayMs) { + for (const failure of failures) { + if (!isTransientPublishCategory(failure.category)) continue; + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_retry', + correlationId: this.correlationId, + attemptId: this.attemptId, + publishAttempt: attemptNumber, + relayUrl: failure.relay, + errorCategory: failure.category, + elapsedMs, + nextDelayMs, + })); + } + } + + handleFinalFailure(lastAttemptState, terminalFailureCategory, retryTimeline, overallStartedAt) { + const reasonDistribution = {}; + const failureLines = lastAttemptState.failures + .map((result) => { + reasonDistribution[result.category] = (reasonDistribution[result.category] || 0) + 1; + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_failure', + correlationId: this.correlationId, + attemptId: this.attemptId, + relayUrl: result.relay, + phase: result.phase, + reason: result.category, + message: result.message, + })); + return `${result.relay} (${result.phase}, reason=${result.category}): ${result.message}`; + }); + + this.telemetryLogger(JSON.stringify({ + event: 'lock_publish_quorum_failed', + correlationId: this.correlationId, + attemptId: this.attemptId, + errorCategory: terminalFailureCategory, + successCount: lastAttemptState.successCount, + relayAttemptedCount: lastAttemptState.attempted.size, + requiredSuccesses: this.minSuccesses, + timeoutMs: this.publishTimeoutMs, + attempts: this.maxAttempts, + reasonDistribution, + retryTimeline, + totalElapsedMs: Date.now() - overallStartedAt, + })); + + this.healthManager.maybeLogSnapshot(this.allRelays, this.healthConfig, this.healthLogger, 'publish:failure', true); + throw new Error( + `Failed relay publish quorum in publish phase: ${lastAttemptState.successCount}/${lastAttemptState.attempted.size} successful ` + + `(required=${this.minSuccesses}, timeout=${this.publishTimeoutMs}ms, attempts=${this.maxAttempts}, attempt_id=${this.attemptId}, correlation_id=${this.correlationId}, error_category=${terminalFailureCategory}, total_retry_timeline_ms=${Date.now() - overallStartedAt})\n` + + ` retry timeline: ${retryTimeline.map((item) => `#${item.publishAttempt}:${item.elapsedMs}ms`).join(', ')}\n` + + ` ${failureLines.join('\n ')}`, + ); + } +} + +export async function publishLock(relays, event, deps = {}) { + const { + getPublishTimeoutMsFn = getPublishTimeoutMs, + getMinSuccessfulRelayPublishesFn = getMinSuccessfulRelayPublishes, + getRelayFallbacksFn = getRelayFallbacks, + getMinActiveRelayPoolFn = getMinActiveRelayPool, + } = deps; + + const [publishTimeoutMs, minSuccesses, fallbackRelays, minActiveRelayPool] = await Promise.all([ + getPublishTimeoutMsFn(), + getMinSuccessfulRelayPublishesFn(), + getRelayFallbacksFn(), + getMinActiveRelayPoolFn(), + ]); + + return new LockPublisher(relays, event, { + ...deps, + resolvedConfig: { + publishTimeoutMs, + minSuccesses, + fallbackRelays, + minActiveRelayPool, + }, + }).publish(); +} + +export function _resetRelayHealthState() { + defaultHealthManager.reset(); +} + +export const _secureRandom = secureRandom; diff --git a/torch/src/lock-utils.mjs b/torch/src/lock-utils.mjs new file mode 100644 index 00000000..29863e48 --- /dev/null +++ b/torch/src/lock-utils.mjs @@ -0,0 +1,49 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { todayDateStr, getIsoWeekStr } from './utils.mjs'; + +/** + * Scans the local log directory to identify agents that have already completed their task + * for the current period (daily or weekly). + * + * @param {string} cadence - 'daily' or 'weekly' + * @param {string} logDir - Path to the log directory (e.g., 'task-logs') + * @param {Object} deps - Dependency injection for testing + * @returns {Promise>} - Set of agent names that have completed their task + */ +export async function getCompletedAgents(cadence, logDir, deps) { + const { readdir = fs.readdir, getDateStr = todayDateStr, getIsoWeek = getIsoWeekStr } = deps; + const completed = new Set(); + const targetDir = path.join(logDir, cadence); + + try { + const files = await readdir(targetDir); + const today = getDateStr(); + const currentWeek = getIsoWeek(); + + for (const file of files) { + // Format: YYYY-MM-DDTHH-mm-ssZ____.md + const match = file.match(/^(\d{4}-\d{2}-\d{2})T.*__([a-zA-Z0-9-_]+)__(.*)\.md$/); + if (!match) continue; + + const [, datePart, agent, status] = match; + + if (status === 'completed') { + if (cadence === 'daily' && datePart === today) { + completed.add(agent); + } else if (cadence === 'weekly') { + const fileWeek = getIsoWeek(datePart); // datePart is YYYY-MM-DD + if (fileWeek === currentWeek) { + completed.add(agent); + } + } + } + } + } catch (err) { + if (err.code !== 'ENOENT') { + console.error(`Warning: Failed to read log dir ${targetDir}: ${err.message}`); + } + } + + return completed; +} diff --git a/torch/src/ops.mjs b/torch/src/ops.mjs new file mode 100644 index 00000000..34063417 --- /dev/null +++ b/torch/src/ops.mjs @@ -0,0 +1,449 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import crypto from 'node:crypto'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { DEFAULT_RELAYS } from './constants.mjs'; + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Source paths (in the package) +const SRC_PROMPTS_DIR = path.join(PKG_ROOT, 'src', 'prompts'); + +// Files to treat as "Static" (always overwrite on update, with transformations) +const STATIC_FILES = [ + 'META_PROMPTS.md', + 'scheduler-flow.md', + 'daily-scheduler.md', + 'weekly-scheduler.md', +]; + +// Directories containing "Evolving" files (copy if missing, preserve if present) +const EVOLVING_DIRS = ['daily', 'weekly']; + +// New constants for full application install +const APP_DIRS = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts']; +const APP_FILES = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json', 'TORCH.md']; + +function getPaths(root, installDirName) { + const torchDir = path.resolve(root, installDirName); + return { + root, + torchDir, + promptsDir: path.join(torchDir, 'prompts'), + roster: path.join(torchDir, 'roster.json'), + }; +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function copyDir(src, dest) { + if (fs.existsSync(src)) { + fs.cpSync(src, dest, { recursive: true }); + } +} + +function transformContent(content, installDirName) { + // Replace source paths with user paths + // We assume the user is running from root, so 'src/prompts/daily/' becomes 'torch/prompts/daily/' + // If installDirName is different, we should use that. + // Note: These replacements are somewhat fragile regexes. + // We'll replace 'src/' with 'installDirName/' essentially? + // The original code replaced 'src/prompts/' with 'torch/prompts/'. + + // If installDirName is '.', we want 'prompts/daily/'. + // If installDirName is 'torch', we want 'torch/prompts/daily/'. + + const prefix = installDirName === '.' ? '' : `${installDirName}/`; + + return content + .replace(/src\/prompts\/daily\//g, `${prefix}prompts/daily/`) + .replace(/src\/prompts\/weekly\//g, `${prefix}prompts/weekly/`) + .replace(/src\/prompts\/roster\.json/g, `${prefix}roster.json`) + .replace(/src\/prompts\/scheduler-flow\.md/g, `${prefix}scheduler-flow.md`) + .replace(/TORCH\.md/g, `${prefix}TORCH.md`); +} + +function copyFile(src, dest, transform = false, overwrite = true, installDirName = 'torch') { + if (fs.existsSync(dest) && !overwrite) { + return false; // Skipped + } + if (!fs.existsSync(src)) return false; + + const content = fs.readFileSync(src, 'utf8'); + const finalContent = transform ? transformContent(content, installDirName) : content; + fs.writeFileSync(dest, finalContent, 'utf8'); + return true; // Copied/Overwritten +} + +function syncAppDirectories(torchDir, verb = 'Copied') { + console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application directories...`); + for (const dir of APP_DIRS) { + const src = path.join(PKG_ROOT, dir); + const dest = path.join(torchDir, dir); + if (fs.existsSync(src)) { + copyDir(src, dest); + console.log(` ${verb} ${dir}/`); + } + } +} + +function syncAppFiles(torchDir, installDir, verb = 'Copied') { + console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application files...`); + for (const file of APP_FILES) { + const src = path.join(PKG_ROOT, file); + const dest = path.join(torchDir, file); + if (fs.existsSync(src)) { + if (installDir === '.' && file === 'package.json') { + if (verb === 'Copied' && fs.existsSync(dest)) { + console.warn(' Skipping package.json to avoid overwriting host package.json (installing to root).'); + continue; + } + if (verb === 'Updated') { + console.log(' Skipping package.json update (installed in root).'); + continue; + } + } + + fs.copyFileSync(src, dest); + console.log(` ${verb} ${file}`); + } + } +} + +async function interactiveInit(cwd) { + const rl = readline.createInterface({ input, output }); + const currentDirName = path.basename(cwd); + + console.log('\n🔥 TORCH Initialization 🔥\n'); + + try { + // 1. Install Directory + let defaultDir = 'torch'; + if (currentDirName === 'torch') { + defaultDir = '.'; + } + + const dirAnswer = await rl.question(`Install directory (default: ${defaultDir}): `); + const installDir = dirAnswer.trim() || defaultDir; + + // 2. Namespace + const randomSuffix = crypto.randomBytes(4).toString('hex'); + const defaultNamespace = `torch-${randomSuffix}`; + const namespaceAnswer = await rl.question(`Nostr Namespace (default: ${defaultNamespace}): `); + const namespace = namespaceAnswer.trim() || defaultNamespace; + + // 3. Relays + console.log(`\nDefault Relays:\n ${DEFAULT_RELAYS.join('\n ')}`); + const relaysAnswer = await rl.question('Enter relays (comma-separated) or press Enter to use defaults: '); + let relays = DEFAULT_RELAYS; + if (relaysAnswer.trim()) { + relays = relaysAnswer.split(',').map(r => r.trim()).filter(Boolean); + } + + return { installDir, namespace, relays }; + } finally { + rl.close(); + } +} + +async function resolveConfiguration(cwd, mockAnswers) { + if (mockAnswers) { + return mockAnswers; + } + return await interactiveInit(cwd); +} + +function ensureInstallDirectory(paths, force, installDir) { + if (fs.existsSync(paths.torchDir) && !force) { + const entries = fs.readdirSync(paths.torchDir); + if (entries.length > 0 && installDir !== '.') { + throw new Error(`Directory ${paths.torchDir} already exists and is not empty. Use --force to overwrite.`); + } + } + ensureDir(paths.torchDir); + ensureDir(paths.promptsDir); +} + +function installAppAssets(torchDir, installDir) { + // 1. Copy App Directories + syncAppDirectories(torchDir, 'Copied'); + + // 2. Copy App Files + syncAppFiles(torchDir, installDir, 'Copied'); +} + +function installTorchAssets(paths, installDir) { + // 3. Copy Roster + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + copyFile(srcRoster, paths.roster, false, true, installDir); + console.log(`Created ${path.relative(paths.root, paths.roster)}`); + } + + // 4. Copy Static Files + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true, installDir); + console.log(`Created ${path.relative(paths.root, dest)}`); + } + } + + // 5. Copy Prompts + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + copyFile(srcFile, destFile, false, true, installDir); + } + console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`); + } + } +} + +function configureTorch(cwd, paths, installDir, namespace, relays) { + // 6. Create/Update torch-config.json + const configPath = path.join(paths.root, 'torch-config.json'); + + let configData = {}; + + // Try to load existing or example + if (fs.existsSync(configPath)) { + try { + configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + console.log(`Updating existing ${path.relative(cwd, configPath)}...`); + } catch (e) { + console.warn(`Could not parse existing config: ${e.message}`); + } + } else { + const exampleConfigPath = path.join(PKG_ROOT, 'torch-config.example.json'); + if (fs.existsSync(exampleConfigPath)) { + configData = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8')); + } + } + + // Apply user choices + if (!configData.nostrLock) configData.nostrLock = {}; + configData.nostrLock.namespace = namespace; + configData.nostrLock.relays = relays; + + // Configure memory policy with correct paths + if (!configData.scheduler) configData.scheduler = {}; + // Always ensure memory policy exists and points to the correct scripts + // We use the installDir to construct the path + const scriptPrefix = installDir === '.' ? '' : `${installDir}/`; + + if (!configData.scheduler.memoryPolicyByCadence) { + configData.scheduler.memoryPolicyByCadence = {}; + } + + // Populate daily/weekly if missing + ['daily', 'weekly'].forEach(cadence => { + if (!configData.scheduler.memoryPolicyByCadence[cadence]) { + configData.scheduler.memoryPolicyByCadence[cadence] = { + mode: "required", + retrieveCommand: `node ${scriptPrefix}scripts/memory/retrieve.mjs`, + storeCommand: `node ${scriptPrefix}scripts/memory/store.mjs`, + retrieveSuccessMarkers: ["MEMORY_RETRIEVED"], + storeSuccessMarkers: ["MEMORY_STORED"], + retrieveArtifacts: [`.scheduler-memory/latest/${cadence}/retrieve.ok`], + storeArtifacts: [`.scheduler-memory/latest/${cadence}/store.ok`] + }; + } + }); + + fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8'); + console.log(`Saved configuration to ${path.relative(cwd, configPath)}`); +} + +function injectHostScriptsIfNeeded(paths, installDir) { + // 7. Inject Scripts into Host Package.json + // If we are NOT installing to '.', the host package.json is in paths.root + if (installDir !== '.') { + injectScriptsIntoHost(paths.root, installDir); + } +} + +export async function cmdInit(force = false, cwd = process.cwd(), mockAnswers = null) { + const config = await resolveConfiguration(cwd, mockAnswers); + const { installDir, namespace, relays } = config; + + const paths = getPaths(cwd, installDir); + console.log(`\nInitializing torch in ${paths.torchDir}...`); + + ensureInstallDirectory(paths, force, installDir); + + installAppAssets(paths.torchDir, installDir); + installTorchAssets(paths, installDir); + configureTorch(cwd, paths, installDir, namespace, relays); + injectHostScriptsIfNeeded(paths, installDir); + + console.log('\nInitialization complete.'); + console.log('You can now customize the files in ' + path.relative(cwd, paths.torchDir) + '/'); +} + +function injectScriptsIntoHost(hostRoot, installDirName) { + const hostPkgPath = path.join(hostRoot, 'package.json'); + if (!fs.existsSync(hostPkgPath)) { + console.warn('No package.json found in host root. Skipping script injection.'); + return; + } + + try { + const pkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8')); + if (!pkg.scripts) pkg.scripts = {}; + + const scriptsToAdd = { + 'torch:dashboard': `npm run --prefix ${installDirName} dashboard:serve`, + 'torch:check': `npm run --prefix ${installDirName} lock:check:daily`, // Default to daily check + 'torch:lock': `npm run --prefix ${installDirName} lock:lock`, + 'torch:health': `npm run --prefix ${installDirName} lock:health`, + 'torch:memory:list': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs list-memories`, + 'torch:memory:inspect': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs inspect-memory`, + }; + + let modified = false; + for (const [key, cmd] of Object.entries(scriptsToAdd)) { + if (!pkg.scripts[key]) { + pkg.scripts[key] = cmd; + console.log(` Added script: "${key}"`); + modified = true; + } else { + console.log(` Script "${key}" already exists, skipping.`); + } + } + + if (modified) { + fs.writeFileSync(hostPkgPath, JSON.stringify(pkg, null, 2), 'utf8'); + console.log('Updated package.json with convenience scripts.'); + } + + } catch (e) { + console.error(`Failed to inject scripts: ${e.message}`); + } +} + +export function cmdUpdate(force = false, cwd = process.cwd()) { + // Update logic needs to know WHERE torch is installed. + // We can look for torch directory? Or assume 'torch'? + // For now, let's look for 'torch' directory first, then fallback to '.' if we detect torch files? + // Or just default to 'torch' and let user move files if they changed it? + // Realistically, 'update' should probably take an argument for the dir, or we just default to 'torch'. + + // If the user installed to 'custom-dir', cmdUpdate will fail unless we auto-detect. + // Auto-detection strategy: check if 'torch' exists. If not, check if 'package.json' has 'torch-lock' name? + + let installDirName = 'torch'; + if (!fs.existsSync(path.join(cwd, 'torch')) && fs.existsSync(path.join(cwd, 'package.json'))) { + // Check if current dir is the torch dir + try { + const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + if (pkg.name === 'torch-lock') { + installDirName = '.'; + } + } catch { + // Ignore error if package.json is missing or invalid + } + } + + const paths = getPaths(cwd, installDirName); + console.log(`Updating torch configuration in ${paths.torchDir}...`); + + if (!fs.existsSync(paths.torchDir)) { + throw new Error(`${paths.torchDir} not found. Run 'torch-lock init' first.`); + } + + // 1. Backup + const backupName = `backup_${new Date().toISOString().replace(/[:.]/g, '-')}`; + const backupRoot = path.join(paths.torchDir, '_backups'); + const thisBackupDir = path.join(backupRoot, backupName); + + ensureDir(thisBackupDir); + console.log(`Creating backup at ${path.relative(paths.root, thisBackupDir)}...`); + + // We backup EVERYTHING in torchDir except _backups and node_modules + const entries = fs.readdirSync(paths.torchDir); + for (const entry of entries) { + if (entry === '_backups' || entry === 'node_modules' || entry === '.git') continue; + + const srcPath = path.join(paths.torchDir, entry); + const destPath = path.join(thisBackupDir, entry); + fs.cpSync(srcPath, destPath, { recursive: true }); + } + + // 2. Update App Directories (Overwrite) + syncAppDirectories(paths.torchDir, 'Updated'); + + // 3. Update App Files (Overwrite) + syncAppFiles(paths.torchDir, installDirName, 'Updated'); + + // 4. Update Static Files (Always Overwrite) + console.log('Updating static files...'); + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true, installDirName); + console.log(` Updated ${file}`); + } + } + + // 5. Update Roster (Preserve unless force) + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + if (force) { + copyFile(srcRoster, paths.roster, false, true, installDirName); + console.log(' Overwrote roster.json (forced)'); + } else { + console.log(' Skipped roster.json (preserved)'); + } + } + + // 6. Update Prompts (Copy missing, preserve existing unless force) + console.log('Updating prompts...'); + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + let added = 0; + let updated = 0; + let skipped = 0; + + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + + if (force) { + copyFile(srcFile, destFile, false, true, installDirName); + updated++; + } else { + if (!fs.existsSync(destFile)) { + copyFile(srcFile, destFile, false, true, installDirName); + added++; + } else { + skipped++; + } + } + } + console.log(` ${dir}/: ${added} added, ${updated} updated, ${skipped} preserved`); + } + } + + console.log('\nUpdate complete.'); +} diff --git a/torch/src/prompts/daily/ci-health-agent.md b/torch/src/prompts/daily/ci-health-agent.md new file mode 100644 index 00000000..1ce0b343 --- /dev/null +++ b/torch/src/prompts/daily/ci-health-agent.md @@ -0,0 +1,9 @@ +# CI Health Agent + +This agent is responsible for ensuring the repository is healthy by running tests and linters. + +1. *Run repository checks.* + - Execute `flake8 .` from the root directory to check code style. + - Execute `pytest` from the root directory to run tests. +2. *Report status.* + - Log the output of the checks. diff --git a/torch/src/prompts/roster.json b/torch/src/prompts/roster.json new file mode 100644 index 00000000..ee25987e --- /dev/null +++ b/torch/src/prompts/roster.json @@ -0,0 +1,5 @@ +{ + "daily": [ + "ci-health-agent" + ] +} diff --git a/torch/src/relay-health.mjs b/torch/src/relay-health.mjs new file mode 100644 index 00000000..0c0a824d --- /dev/null +++ b/torch/src/relay-health.mjs @@ -0,0 +1,290 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import WebSocket from 'ws'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { getNamespace, getRelays } from './torch-config.mjs'; + +const PROBE_KIND = 27235; + +function withTimeout(promise, timeoutMs, message) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)); +} + +function nowIso() { + return new Date().toISOString(); +} + +export function summarizeHistory(historyEntries, { nowMs = Date.now(), windowMinutes = 60 } = {}) { + const windowStartMs = nowMs - (windowMinutes * 60_000); + const recent = historyEntries.filter((entry) => Date.parse(entry.timestamp || '') >= windowStartMs); + const relayProbeCount = recent.reduce((sum, entry) => sum + (entry.summary?.totalRelays || 0), 0); + const successCount = recent.reduce((sum, entry) => sum + (entry.summary?.healthyRelays || 0), 0); + const successRate = relayProbeCount > 0 ? successCount / relayProbeCount : 1; + + let lastHealthyAtMs = null; + for (let i = historyEntries.length - 1; i >= 0; i -= 1) { + if ((historyEntries[i].summary?.healthyRelays || 0) > 0) { + lastHealthyAtMs = Date.parse(historyEntries[i].timestamp || ''); + break; + } + } + + const allDownDurationMinutes = lastHealthyAtMs === null + ? null + : Math.max(0, Math.floor((nowMs - lastHealthyAtMs) / 60_000)); + + return { + windowMinutes, + sampleCount: recent.length, + successRate, + allDownDurationMinutes, + }; +} + +export function evaluateAlertThresholds(currentResult, historyEntries, thresholds, { nowMs = Date.now() } = {}) { + const stats = summarizeHistory(historyEntries, { nowMs, windowMinutes: thresholds.windowMinutes }); + const alerts = []; + + if (currentResult.summary.allRelaysUnhealthy && stats.allDownDurationMinutes !== null && stats.allDownDurationMinutes >= thresholds.allRelaysDownMinutes) { + alerts.push({ + type: 'all_relays_down_duration', + thresholdMinutes: thresholds.allRelaysDownMinutes, + actualMinutes: stats.allDownDurationMinutes, + severity: 'critical', + }); + } + + if (stats.sampleCount > 0 && stats.successRate < thresholds.minSuccessRate) { + alerts.push({ + type: 'relay_success_rate_below_threshold', + threshold: thresholds.minSuccessRate, + actual: Number(stats.successRate.toFixed(4)), + windowMinutes: thresholds.windowMinutes, + severity: 'warning', + }); + } + + return { alerts, stats }; +} + +async function probeWebSocketReachability(relayUrl, timeoutMs) { + const startedAt = Date.now(); + return withTimeout(new Promise((resolve, reject) => { + const ws = new WebSocket(relayUrl); + let settled = false; + + const finish = (fn) => (value) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // ignore + } + fn(value); + }; + + ws.once('open', finish(() => resolve({ ok: true, latencyMs: Date.now() - startedAt }))); + ws.once('error', finish((err) => reject(err))); + }), timeoutMs, `WebSocket reachability timeout after ${timeoutMs}ms`); +} + +async function probePublishRead(relayUrl, namespace, timeoutMs) { + const ws = new WebSocket(relayUrl); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + const createdAt = Math.floor(Date.now() / 1000); + const nonce = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; + const probeTag = `${namespace}-health-probe-${nonce}`; + const event = finalizeEvent({ + kind: PROBE_KIND, + created_at: createdAt, + tags: [ + ['t', `${namespace}-relay-health`], + ['d', probeTag], + ], + content: JSON.stringify({ type: 'relay_health_probe', nonce, namespace, pubkey: pk }), + }, sk); + + const subId = `probe-${nonce}`; + + return withTimeout(new Promise((resolve, reject) => { + let okAck = false; + let readSeen = false; + let settled = false; + + const settle = (fn) => (value) => { + if (settled) return; + settled = true; + try { + ws.close(); + } catch { + // ignore + } + fn(value); + }; + + ws.once('open', () => { + ws.send(JSON.stringify(['EVENT', event])); + ws.send(JSON.stringify(['REQ', subId, { ids: [event.id], limit: 1 }])); + }); + + ws.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + // Ignore malformed messages. The relay might be sending non-JSON data + // or we might have a protocol mismatch. We only care about valid JSON. + return; + } + if (!Array.isArray(message) || message.length < 2) return; + const type = message[0]; + + if (type === 'OK' && message[1] === event.id) { + okAck = Boolean(message[2]); + if (!okAck) { + settle(reject)(new Error(`Relay rejected health probe event: ${String(message[3] || 'unknown reason')}`)); + return; + } + } + + if (type === 'EVENT' && message[1] === subId && message[2]?.id === event.id) { + readSeen = true; + } + + if (okAck && readSeen) { + ws.send(JSON.stringify(['CLOSE', subId])); + settle(resolve)({ ok: true, eventId: event.id }); + } + }); + + ws.once('error', settle(reject)); + }), timeoutMs, `Publish/read probe timeout after ${timeoutMs}ms`); +} + +async function readHistory(historyPath) { + try { + const raw = await fs.readFile(historyPath, 'utf8'); + return raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((entry) => entry && typeof entry === 'object'); + } catch (error) { + if (error && error.code === 'ENOENT') return []; + throw error; + } +} + +async function appendHistory(historyPath, entry) { + await fs.mkdir(path.dirname(historyPath), { recursive: true }); + await fs.appendFile(historyPath, `${JSON.stringify(entry)}\n`, 'utf8'); +} + +export async function runRelayHealthCheck(options = {}) { + const { + cadence = 'daily', + timeoutMs = 6000, + allRelaysDownMinutes = 10, + minSuccessRate = 0.7, + windowMinutes = 60, + } = options; + + let { + relays, + namespace, + historyPath, + } = options; + + if (!relays) relays = await getRelays(); + if (!namespace) namespace = await getNamespace(); + if (!historyPath) historyPath = path.resolve(process.cwd(), 'task-logs', 'relay-health', `${cadence}.jsonl`); + + const relayResults = []; + for (const relay of relays) { + const relayResult = { + relay, + websocketReachable: false, + publishReadOk: false, + healthy: false, + checks: {}, + }; + + try { + const wsProbe = await probeWebSocketReachability(relay, timeoutMs); + relayResult.websocketReachable = true; + relayResult.checks.websocket = wsProbe; + } catch (error) { + relayResult.checks.websocket = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + + if (relayResult.websocketReachable) { + try { + const prProbe = await probePublishRead(relay, namespace, timeoutMs); + relayResult.publishReadOk = true; + relayResult.checks.publishRead = prProbe; + } catch (error) { + relayResult.checks.publishRead = { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + } + + relayResult.healthy = relayResult.websocketReachable && relayResult.publishReadOk; + relayResults.push(relayResult); + } + + const summary = { + totalRelays: relayResults.length, + healthyRelays: relayResults.filter((relay) => relay.healthy).length, + }; + summary.unhealthyRelays = summary.totalRelays - summary.healthyRelays; + summary.allRelaysUnhealthy = summary.healthyRelays === 0; + + const result = { + ok: !summary.allRelaysUnhealthy, + cadence, + namespace, + timestamp: nowIso(), + relays, + relayResults, + summary, + incidentSignal: null, + alerts: [], + alertStats: null, + historyPath, + }; + + const historyEntries = await readHistory(historyPath); + const prospectiveHistory = [...historyEntries, result]; + const thresholdEval = evaluateAlertThresholds(result, prospectiveHistory, { + allRelaysDownMinutes, + minSuccessRate, + windowMinutes, + }); + result.alerts = thresholdEval.alerts; + result.alertStats = thresholdEval.stats; + + if (summary.allRelaysUnhealthy) { + const incidentId = `relay-health-${cadence}-${Date.now()}`; + result.incidentSignal = { + id: incidentId, + reason: 'All relays unhealthy before lock acquisition', + severity: 'critical', + suggestedAction: 'Defer scheduler run and investigate relay availability before retrying lock acquisition.', + }; + } + + await appendHistory(historyPath, result); + return result; +} diff --git a/torch/src/roster.mjs b/torch/src/roster.mjs new file mode 100644 index 00000000..99ab4149 --- /dev/null +++ b/torch/src/roster.mjs @@ -0,0 +1,114 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadTorchConfig } from './torch-config.mjs'; + +const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); +const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json'); +const CWD_ROSTER_FILE = path.resolve(process.cwd(), 'roster.json'); + +const FALLBACK_ROSTER = { + daily: [ + 'audit-agent', + 'ci-health-agent', + 'const-refactor-agent', + 'content-audit-agent', + 'decompose-agent', + 'deps-security-agent', + 'design-system-audit-agent', + 'docs-agent', + 'docs-alignment-agent', + 'docs-code-investigator', + 'innerhtml-migration-agent', + 'known-issues-agent', + 'load-test-agent', + 'onboarding-audit-agent', + 'perf-agent', + 'prompt-curator-agent', + 'protocol-research-agent', + 'scheduler-update-agent', + 'style-agent', + 'test-audit-agent', + 'todo-triage-agent', + ], + weekly: [ + 'bug-reproducer-agent', + 'changelog-agent', + 'dead-code-agent', + 'event-schema-agent', + 'frontend-console-debug-agent', + 'fuzz-agent', + 'interop-agent', + 'perf-deepdive-agent', + 'perf-optimization-agent', + 'pr-review-agent', + 'race-condition-agent', + 'refactor-agent', + 'smoke-agent', + 'telemetry-agent', + 'test-coverage-agent', + 'weekly-synthesis-agent', + ], +}; + +let cachedCanonicalRoster = null; + +function loadCanonicalRoster() { + if (cachedCanonicalRoster) return cachedCanonicalRoster; + + let rosterPath = ROSTER_FILE; + + // Prefer user-managed roster if present + if (fs.existsSync(USER_ROSTER_FILE)) { + rosterPath = USER_ROSTER_FILE; + } else if (fs.existsSync(CWD_ROSTER_FILE)) { + rosterPath = CWD_ROSTER_FILE; + } + + try { + const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; + const weekly = Array.isArray(parsed.weekly) + ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) + : []; + + if (daily.length > 0 && weekly.length > 0) { + cachedCanonicalRoster = { daily, weekly }; + return cachedCanonicalRoster; + } + + console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`); + } catch { + // It's okay if roster file is missing when used as a library/CLI without the file present + } + + cachedCanonicalRoster = FALLBACK_ROSTER; + return cachedCanonicalRoster; +} + +function parseEnvRoster(value) { + if (!value) return null; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +export async function getRoster(cadence) { + const config = await loadTorchConfig(); + const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); + const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); + const canonical = loadCanonicalRoster(); + const dailyFromConfig = config.nostrLock.dailyRoster; + const weeklyFromConfig = config.nostrLock.weeklyRoster; + + if (cadence === 'daily') { + if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv; + if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig; + return canonical.daily; + } + + if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv; + if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig; + return canonical.weekly; +} diff --git a/torch/src/services/memory/cache.js b/torch/src/services/memory/cache.js new file mode 100644 index 00000000..63170421 --- /dev/null +++ b/torch/src/services/memory/cache.js @@ -0,0 +1,218 @@ +const DEFAULT_MAX_NAMESPACES = 128; +const DEFAULT_MAX_EVENTS_PER_NAMESPACE = 200; +const DEFAULT_MAX_TOTAL_EVENTS = 5000; + +function normalizeScope(scope = {}) { + const signerId = typeof scope.signer_id === 'string' && scope.signer_id.trim() + ? scope.signer_id.trim() + : 'anonymous-signer'; + const sessionId = typeof scope.session_id === 'string' && scope.session_id.trim() + ? scope.session_id.trim() + : 'anonymous-session'; + + return { signer_id: signerId, session_id: sessionId }; +} + +function getNamespaceKey(scope) { + const normalized = normalizeScope(scope); + return `${normalized.signer_id}::${normalized.session_id}`; +} + +function isEventSensitive(event) { + if (!event || typeof event !== 'object') return false; + if (event.is_decrypted === true || event.session_sensitive === true) return true; + + const classification = typeof event.classification === 'string' ? event.classification.toLowerCase() : ''; + return classification === 'decrypted' || classification === 'session-sensitive'; +} + +function canPromoteToDurableStorage(event) { + if (!isEventSensitive(event)) return true; + return event.sanitized_for_durable === true; +} + +/** + * @param {{ maxNamespaces?: number, maxEventsPerNamespace?: number, maxTotalEvents?: number }} [options] + * @returns {{ + * get: (key: string) => unknown, + * set: (key: string, value: unknown) => void, + * delete: (key: string) => boolean, + * clear: () => void, + * setRuntimeEvent: (scope: { signer_id?: string, session_id?: string }, event: Record, ttlMs?: number) => boolean, + * getRecentRuntimeEvents: (scope: { signer_id?: string, session_id?: string }, params?: { limit?: number, since?: number }) => Record[], + * clearScope: (scope: { signer_id?: string, session_id?: string }) => void, + * getMetrics: () => Record + * }} + */ +export function createMemoryCache(options = {}) { + const store = new Map(); + const runtimeNamespaces = new Map(); + const maxNamespaces = Number.isFinite(options.maxNamespaces) && options.maxNamespaces > 0 + ? Math.floor(options.maxNamespaces) + : DEFAULT_MAX_NAMESPACES; + const maxEventsPerNamespace = Number.isFinite(options.maxEventsPerNamespace) && options.maxEventsPerNamespace > 0 + ? Math.floor(options.maxEventsPerNamespace) + : DEFAULT_MAX_EVENTS_PER_NAMESPACE; + const maxTotalEvents = Number.isFinite(options.maxTotalEvents) && options.maxTotalEvents > 0 + ? Math.floor(options.maxTotalEvents) + : DEFAULT_MAX_TOTAL_EVENTS; + + let totalRuntimeEvents = 0; + const metrics = { + runtime_events_added: 0, + runtime_events_expired: 0, + runtime_events_lru_evicted: 0, + runtime_scopes_cleared: 0, + runtime_sensitive_events_blocked: 0, + }; + + function touchNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + runtimeNamespaces.delete(key); + runtimeNamespaces.set(key, bucket); + } + + function evictNamespace(key) { + const bucket = runtimeNamespaces.get(key); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_events_lru_evicted += bucket.events.length; + runtimeNamespaces.delete(key); + } + + function evictOldestNamespaceIfNeeded() { + while (runtimeNamespaces.size > maxNamespaces) { + const oldestKey = runtimeNamespaces.keys().next().value; + if (!oldestKey) return; + evictNamespace(oldestKey); + } + } + + function removeExpired(now = Date.now()) { + for (const [namespaceKey, bucket] of runtimeNamespaces.entries()) { + if (!bucket.events.length) continue; + + const originalLength = bucket.events.length; + bucket.events = bucket.events.filter((entry) => entry.expiresAt > now); + const removed = originalLength - bucket.events.length; + if (removed > 0) { + totalRuntimeEvents -= removed; + metrics.runtime_events_expired += removed; + } + + if (bucket.events.length === 0) { + runtimeNamespaces.delete(namespaceKey); + } + } + } + + function enforceEventBounds(namespaceKey) { + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + + while (bucket.events.length > maxEventsPerNamespace) { + bucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + } + + while (totalRuntimeEvents > maxTotalEvents) { + const oldestNamespaceKey = runtimeNamespaces.keys().next().value; + if (!oldestNamespaceKey) return; + const oldestBucket = runtimeNamespaces.get(oldestNamespaceKey); + if (!oldestBucket || oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + continue; + } + + oldestBucket.events.shift(); + totalRuntimeEvents -= 1; + metrics.runtime_events_lru_evicted += 1; + if (oldestBucket.events.length === 0) { + runtimeNamespaces.delete(oldestNamespaceKey); + } + } + } + + return { + get(key) { + return store.get(key); + }, + set(key, value) { + store.set(key, value); + }, + delete(key) { + return store.delete(key); + }, + clear() { + store.clear(); + runtimeNamespaces.clear(); + totalRuntimeEvents = 0; + }, + setRuntimeEvent(scope, event, ttlMs = 60_000) { + const now = Date.now(); + removeExpired(now); + + const safeTtlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : 60_000; + const expiresAt = now + safeTtlMs; + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey) ?? { events: [] }; + + bucket.events.push({ + event, + createdAt: now, + expiresAt, + durableEligible: canPromoteToDurableStorage(event), + }); + + totalRuntimeEvents += 1; + metrics.runtime_events_added += 1; + if (!canPromoteToDurableStorage(event)) { + metrics.runtime_sensitive_events_blocked += 1; + } + + runtimeNamespaces.set(namespaceKey, bucket); + touchNamespace(namespaceKey); + evictOldestNamespaceIfNeeded(); + enforceEventBounds(namespaceKey); + return true; + }, + getRecentRuntimeEvents(scope, params = {}) { + const now = Date.now(); + removeExpired(now); + + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return []; + + touchNamespace(namespaceKey); + + const limit = Number.isFinite(params.limit) && params.limit > 0 ? Math.floor(params.limit) : 50; + const since = Number.isFinite(params.since) ? params.since : 0; + + return bucket.events + .filter((entry) => entry.createdAt >= since) + .slice(-limit) + .map((entry) => ({ + ...entry.event, + durable_eligible: entry.durableEligible, + })); + }, + clearScope(scope) { + const namespaceKey = getNamespaceKey(scope); + const bucket = runtimeNamespaces.get(namespaceKey); + if (!bucket) return; + totalRuntimeEvents -= bucket.events.length; + metrics.runtime_scopes_cleared += 1; + runtimeNamespaces.delete(namespaceKey); + }, + getMetrics() { + return { + ...metrics, + runtime_active_namespaces: runtimeNamespaces.size, + runtime_active_events: totalRuntimeEvents, + }; + }, + }; +} diff --git a/torch/src/services/memory/embedder.js b/torch/src/services/memory/embedder.js new file mode 100644 index 00000000..ba26c63d --- /dev/null +++ b/torch/src/services/memory/embedder.js @@ -0,0 +1,134 @@ +const DEFAULT_VECTOR_BACKEND = 'inmemory'; +const VECTOR_BACKEND_ENV = 'MEMORY_VECTOR_BACKEND'; + +/** + * @typedef {{ + * embedText: (text: string) => Promise | number[], + * upsertVector: (input: { id: string, vector: number[], metadata?: Record }) => Promise | void, + * queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise }>> | Array<{ id: string, score: number, vector: number[], metadata: Record }>, + * deleteVector: (id: string) => Promise | boolean, + * }} EmbedderAdapter + */ + +/** + * @param {string} text + * @returns {number[]} + */ +function fallbackEmbedding(_text) { + // Disabled as per configuration: Jules uses text-based memory, not vector embeddings. + return []; +} + +function cosineSimilarity(left, right) { + const size = Math.max(left.length, right.length); + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + + for (let index = 0; index < size; index += 1) { + const leftValue = Number(left[index] ?? 0); + const rightValue = Number(right[index] ?? 0); + dot += leftValue * rightValue; + leftNorm += leftValue ** 2; + rightNorm += rightValue ** 2; + } + + if (leftNorm === 0 || rightNorm === 0) return 0; + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); +} + +/** @implements {EmbedderAdapter} */ +class InMemoryEmbedderAdapter { + constructor() { + this.vectors = new Map(); + } + + /** @param {string} text */ + async embedText(text) { + return fallbackEmbedding(text); + } + + /** @param {{ id: string, vector: number[], metadata?: Record }} input */ + async upsertVector({ id, vector, metadata = {} }) { + if (typeof id !== 'string' || id.length === 0) { + throw new TypeError('upsertVector requires a non-empty id'); + } + if (!Array.isArray(vector) || vector.length === 0) { + // In strict mode this might throw, but since we return [] from embedText, + // ingestor won't call this. If someone calls it manually with [], we can just ignore or warn. + // But adhering to the interface, we should probably throw or just return. + // Given we want to support "no embedding", simply returning is safer than throwing. + return; + } + this.vectors.set(id, { vector: [...vector], metadata: { ...metadata } }); + } + + /** @param {{ vector: number[], k?: number, filter?: Record }} input */ + async queryVector({ vector, k = 5, filter = {} }) { + if (!Array.isArray(vector) || vector.length === 0) return []; + + const matchesFilter = (metadata) => Object.entries(filter).every(([key, value]) => metadata?.[key] === value); + + return [...this.vectors.entries()] + .filter(([, value]) => matchesFilter(value.metadata)) + .map(([id, value]) => ({ + id, + score: cosineSimilarity(vector, value.vector), + vector: [...value.vector], + metadata: { ...value.metadata }, + })) + .sort((left, right) => right.score - left.score) + .slice(0, Number.isFinite(k) && k > 0 ? Math.floor(k) : 5); + } + + /** @param {string} id */ + async deleteVector(id) { + if (typeof id !== 'string' || id.length === 0) return false; + return this.vectors.delete(id); + } +} + +const ADAPTER_FACTORIES = { + inmemory: () => new InMemoryEmbedderAdapter(), + local: () => new InMemoryEmbedderAdapter(), + mock: () => new InMemoryEmbedderAdapter(), +}; + +let defaultAdapter; + +/** + * @param {{ backend?: string }} [options] + * @returns {EmbedderAdapter} + */ +export function createEmbedderAdapter(options = {}) { + const configuredBackend = String(options.backend ?? process.env[VECTOR_BACKEND_ENV] ?? DEFAULT_VECTOR_BACKEND).trim().toLowerCase(); + const factory = ADAPTER_FACTORIES[configuredBackend]; + if (!factory) { + throw new Error(`Unsupported vector backend "${configuredBackend}". Configure ${VECTOR_BACKEND_ENV}=inmemory (default) until provider adapters are added.`); + } + return factory(); +} + +/** + * @returns {EmbedderAdapter} + */ +export function getDefaultEmbedderAdapter() { + if (!defaultAdapter) { + defaultAdapter = createEmbedderAdapter(); + } + return defaultAdapter; +} + +/** + * @param {string} text + * @param {{ embedText?: (value: string) => Promise | number[], adapter?: EmbedderAdapter }} [options] + * @returns {Promise} + */ +export async function embedText(text, options = {}) { + if (typeof options.embedText === 'function') { + return options.embedText(text); + } + + const adapter = options.adapter ?? getDefaultEmbedderAdapter(); + return adapter.embedText(text); +} diff --git a/torch/src/services/memory/feature-flags.js b/torch/src/services/memory/feature-flags.js new file mode 100644 index 00000000..784f3760 --- /dev/null +++ b/torch/src/services/memory/feature-flags.js @@ -0,0 +1,61 @@ +const FALSEY = new Set(['0', 'false', 'off', 'no', 'disabled']); +const TRUTHY = new Set(['1', 'true', 'on', 'yes', 'enabled', 'all']); + +function normalize(value) { + return String(value ?? '').trim(); +} + +function parseAgentToggle(value) { + const raw = normalize(value); + if (!raw) return { kind: 'unset' }; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return { kind: 'off' }; + if (TRUTHY.has(lower)) return { kind: 'all' }; + + const allowList = raw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + return allowList.length > 0 ? { kind: 'allow-list', allowList } : { kind: 'off' }; +} + +function isToggleEnabledForAgent(value, agentId, fallbackWhenUnset) { + const parsed = parseAgentToggle(value); + if (parsed.kind === 'unset') return fallbackWhenUnset; + if (parsed.kind === 'off') return false; + if (parsed.kind === 'all') return true; + if (!agentId) return false; + return parsed.allowList.includes(String(agentId)); +} + +export function isMemoryEnabled(env = process.env) { + return isToggleEnabledForAgent(env.TORCH_MEMORY_ENABLED, undefined, true); +} + +export function isMemoryIngestEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_INGEST_ENABLED, agentId, true); +} + +export function isMemoryRetrievalEnabled(agentId, env = process.env) { + if (!isMemoryEnabled(env)) return false; + return isToggleEnabledForAgent(env.TORCH_MEMORY_RETRIEVAL_ENABLED, agentId, false); +} + +export function getMemoryPruneMode(env = process.env) { + if (!isMemoryEnabled(env)) return 'off'; + + const raw = normalize(env.TORCH_MEMORY_PRUNE_ENABLED); + if (!raw) return 'active'; + + const lower = raw.toLowerCase(); + if (FALSEY.has(lower)) return 'off'; + if (['dry-run', 'dryrun', 'report', 'audit'].includes(lower)) return 'dry-run'; + return 'active'; +} + +export function isMemoryPruneEnabled(env = process.env) { + return getMemoryPruneMode(env) === 'active'; +} diff --git a/torch/src/services/memory/formatter.js b/torch/src/services/memory/formatter.js new file mode 100644 index 00000000..3f3509b8 --- /dev/null +++ b/torch/src/services/memory/formatter.js @@ -0,0 +1,194 @@ +import { isMemoryRetrievalEnabled } from './feature-flags.js'; +export { isMemoryRetrievalEnabled } from './feature-flags.js'; +const DEFAULT_TOKEN_BUDGET = 1000; +const DEFAULT_MAX_SUMMARY_CHARS = 240; +const DEFAULT_MAX_CONTEXT_CHARS = 320; +const MEMORY_CONTEXT_HEADER = '[CONTEXT — Relevant memories]'; + +function clampString(value, maxChars) { + if (typeof value !== 'string') return ''; + if (!Number.isFinite(maxChars) || maxChars <= 0) return ''; + if (value.length <= maxChars) return value; + return `${value.slice(0, Math.max(0, maxChars - 1))}…`; +} + +/** + * Fast heuristic (approx 4 chars/token) suitable for guardrail budgeting. + * @param {string} text + */ +export function estimateTokenCount(text) { + const normalized = String(text ?? ''); + if (normalized.length === 0) return 0; + return Math.ceil(normalized.length / 4); +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ maxSummaryChars?: number, maxContextChars?: number }} [options] + */ +export function formatMemoryBlock(memory, options = {}) { + const summary = clampString(memory.summary ?? '', options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS); + const shortContext = clampString(memory.content ?? '', options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS); + const timestamp = new Date(memory.created_at).toISOString(); + const tags = Array.isArray(memory.tags) ? memory.tags.join(',') : ''; + + return { + id: memory.id, + timestamp, + importance: Number(memory.importance ?? 0).toFixed(2), + summary, + short_context: shortContext, + tags, + }; +} + + +function fitBlockToBudget(memory, options, maxTokens) { + let maxSummaryChars = options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS; + let maxContextChars = options.maxContextChars ?? DEFAULT_MAX_CONTEXT_CHARS; + + while (maxSummaryChars >= 8 && maxContextChars >= 8) { + const block = formatMemoryBlock(memory, { maxSummaryChars, maxContextChars }); + const line = blockToLine(block); + if (estimateTokenCount(line) <= maxTokens) { + return { block, line }; + } + + if (maxContextChars >= maxSummaryChars) { + maxContextChars = Math.max(8, Math.floor(maxContextChars * 0.7)); + } else { + maxSummaryChars = Math.max(8, Math.floor(maxSummaryChars * 0.7)); + } + + if (maxSummaryChars === 8 && maxContextChars === 8) break; + } + + const block = formatMemoryBlock(memory, { maxSummaryChars: 8, maxContextChars: 8 }); + return { block, line: blockToLine(block) }; +} +function blockToLine(block) { + return `[${block.id}, ${block.timestamp}, ${block.importance}, ${block.summary}, ${block.short_context}, ${block.tags}]`; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ k?: number, tokenBudget?: number, maxSummaryChars?: number, maxContextChars?: number }} [options] + */ +export function formatMemoriesForPrompt(memories, options = {}) { + const k = Number.isFinite(options.k) && options.k > 0 ? Math.floor(options.k) : memories.length; + const tokenBudget = Number.isFinite(options.tokenBudget) && options.tokenBudget > 0 + ? Math.floor(options.tokenBudget) + : DEFAULT_TOKEN_BUDGET; + + const blocks = []; + let usedTokens = 0; + + for (const memory of memories.slice(0, k)) { + const remainingTokens = tokenBudget - usedTokens; + if (remainingTokens <= 0) break; + + const fitted = fitBlockToBudget(memory, options, remainingTokens); + const block = fitted.block; + const line = fitted.line; + const lineTokens = estimateTokenCount(line); + + if (usedTokens + lineTokens > tokenBudget) { + break; + } + + blocks.push(block); + usedTokens += lineTokens; + } + + return { + blocks, + text: blocks.map(blockToLine).join('\n'), + usedTokens, + truncated: blocks.length < Math.min(k, memories.length), + }; +} + +function toContextString(agentContext) { + if (typeof agentContext === 'string') { + return agentContext.trim(); + } + + if (agentContext == null) { + return ''; + } + + if (Array.isArray(agentContext)) { + return agentContext.map((value) => String(value)).join('\n').trim(); + } + + if (typeof agentContext === 'object') { + try { + return JSON.stringify(agentContext); + } catch { + return String(agentContext).trim(); + } + } + + return String(agentContext).trim(); +} + +/** + * Builds a final prompt with retrieved memory context inserted ahead of the base prompt. + * + * @param {{ + * basePrompt: string, + * userRequest: string, + * agentContext?: unknown, + * agent_id: string, + * memoryService: { getRelevantMemories: (params: { agent_id: string, query: string, k?: number }) => Promise | import('./schema.js').MemoryRecord[] }, + * retrievalK?: number, + * tokenBudget?: number, + * env?: Record + * }} params + */ +export async function renderPromptWithMemoryContext(params) { + const { + basePrompt, + userRequest, + agentContext, + agent_id, + memoryService, + retrievalK = 5, + tokenBudget = 320, + env = process.env, + } = params; + + if (!isMemoryRetrievalEnabled(agent_id, env)) { + return String(basePrompt ?? ''); + } + + const requestText = String(userRequest ?? '').trim(); + const contextText = toContextString(agentContext); + const query = [requestText, contextText].filter(Boolean).join('\n\n'); + + if (!query || !memoryService?.getRelevantMemories) { + return String(basePrompt ?? ''); + } + + const memories = await memoryService.getRelevantMemories({ + agent_id, + query, + k: retrievalK, + }); + + if (memoryService?.updateMemoryUsage) { + const now = Date.now(); + await Promise.all((memories ?? []).map((memory) => memoryService.updateMemoryUsage(memory.id, now))); + } + + const rendered = formatMemoriesForPrompt(memories ?? [], { + k: retrievalK, + tokenBudget, + }); + + if (!rendered.text) { + return String(basePrompt ?? ''); + } + + return `${MEMORY_CONTEXT_HEADER}\n${rendered.text}\n\n${String(basePrompt ?? '')}`; +} diff --git a/torch/src/services/memory/index.js b/torch/src/services/memory/index.js new file mode 100644 index 00000000..cf7375fb --- /dev/null +++ b/torch/src/services/memory/index.js @@ -0,0 +1,439 @@ +import { createMemoryCache } from './cache.js'; +import { ingestMemoryWindow } from './ingestor.js'; +import { listPruneCandidates, selectPrunableMemories } from './pruner.js'; +import { filterAndRankMemories, updateMemoryUsage } from './retriever.js'; +import { startScheduler } from './scheduler.js'; +import { getMemoryPruneMode, isMemoryIngestEnabled } from './feature-flags.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import util from 'node:util'; + +const MEMORY_FILE_PATH = path.join(process.cwd(), '.scheduler-memory', 'memory-store.json'); +const debug = util.debuglog('torch-memory'); + +function loadMemoryStore() { + try { + if (fs.existsSync(MEMORY_FILE_PATH)) { + const data = fs.readFileSync(MEMORY_FILE_PATH, 'utf8'); + const entries = JSON.parse(data); + if (Array.isArray(entries)) { + return new Map(entries); + } + } + } catch (err) { + console.error('Failed to load memory store:', err); + } + return new Map(); +} + +function saveMemoryStore(store) { + try { + const dir = path.dirname(MEMORY_FILE_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const entries = [...store.entries()]; + fs.writeFileSync(MEMORY_FILE_PATH, JSON.stringify(entries, null, 2), 'utf8'); + } catch (err) { + console.error('Failed to save memory store:', err); + } +} + +const memoryStore = loadMemoryStore(); +const cache = createMemoryCache(); + +const memoryRepository = { + async insertMemory(memory) { + memoryStore.set(memory.id, memory); + saveMemoryStore(memoryStore); + return memory; + }, + async updateMemoryUsage(id, lastSeen = Date.now()) { + const existing = memoryStore.get(id); + if (!existing) return null; + const updated = { ...existing, last_seen: lastSeen }; + memoryStore.set(id, updated); + saveMemoryStore(memoryStore); + return updated; + }, + async listPruneCandidates({ cutoff }) { + return [...memoryStore.values()].filter((memory) => !memory.pinned && memory.last_seen < cutoff); + }, + async markMerged(id, mergedInto) { + const existing = memoryStore.get(id); + if (!existing) return false; + memoryStore.set(id, { ...existing, merged_into: mergedInto, last_seen: Date.now() }); + saveMemoryStore(memoryStore); + return true; + }, + async setPinned(id, pinned) { + const existing = memoryStore.get(id); + if (!existing) return null; + const updated = { ...existing, pinned, last_seen: Date.now() }; + memoryStore.set(id, updated); + saveMemoryStore(memoryStore); + return updated; + }, + async getMemoryById(id) { + return memoryStore.get(id) ?? null; + }, + async listMemories() { + return [...memoryStore.values()]; + }, +}; + +const memoryStatsState = { + ingested: [], + retrieved: [], + pruned: [], + archived: 0, + deleted: 0, +}; + +const ESTIMATED_INDEX_BYTES_PER_RECORD = 512; + +function toSafeTelemetryPayload(payload = {}) { + const blockedFields = new Set(['content', 'summary', 'query', 'raw']); + return Object.entries(payload).reduce((safe, [key, value]) => { + if (blockedFields.has(key)) return safe; + if (typeof value === 'string' && value.length > 300) { + safe[key] = `${value.slice(0, 300)}…`; + return safe; + } + safe[key] = value; + return safe; + }, {}); +} + +function buildTelemetryEmitter(options = {}) { + if (typeof options.emitTelemetry === 'function') { + return (event, payload) => options.emitTelemetry(event, toSafeTelemetryPayload(payload)); + } + + if (typeof options.telemetry?.emit === 'function') { + return (event, payload) => options.telemetry.emit(event, toSafeTelemetryPayload(payload)); + } + + return (event, payload) => { + debug('memory_telemetry', { + event, + payload: toSafeTelemetryPayload(payload), + ts: Date.now(), + }); + }; +} + +function emitMetric(options = {}, metric, payload) { + if (typeof options.emitMetric === 'function') { + options.emitMetric(metric, payload); + return; + } + + if (typeof options.metrics?.emit === 'function') { + options.metrics.emit(metric, payload); + return; + } + + debug('memory_metric', { metric, payload, ts: Date.now() }); +} + +function applyMemoryFilters(memories, filters = {}) { + const tags = Array.isArray(filters.tags) + ? filters.tags.map((tag) => String(tag).trim()).filter(Boolean) + : []; + + return memories.filter((memory) => { + if (filters.agent_id && memory.agent_id !== filters.agent_id) return false; + if (filters.type && memory.type !== filters.type) return false; + if (typeof filters.pinned === 'boolean' && memory.pinned !== filters.pinned) return false; + if (typeof filters.includeMerged === 'boolean' && !filters.includeMerged && memory.merged_into) return false; + if (tags.length > 0) { + const memoryTags = new Set(memory.tags); + if (!tags.every((tag) => memoryTags.has(tag))) return false; + } + return true; + }); +} + +function recordThroughput(samples, now) { + samples.push(now); + if (samples.length > 10_000) { + samples.splice(0, samples.length - 10_000); + } +} + +/** + * Ingests agent events and stores them as memory records. + * + * @param {import('./schema.js').MemoryEvent[]} events - Ordered list of raw events to persist. + * @param {{ maxSummaryLength?: number, embedText?: (value: string) => Promise | number[], repository?: typeof memoryRepository }} [options] - Optional ingest tuning. + * @returns {Promise} Stored memory records. + */ +export async function ingestEvents(events, options = {}) { + const repository = options.repository ?? memoryRepository; + const telemetry = buildTelemetryEmitter(options); + if (!isMemoryIngestEnabled(options.agent_id, options.env)) { + telemetry('memory:ingest_skipped', { reason: 'flag_disabled', agent_id: options.agent_id ?? null }); + return []; + } + const records = await ingestMemoryWindow({ events }, { + ...options, + repository, + emitTelemetry: (event, payload) => { + telemetry(event, payload); + if (event === 'memory:ingested') { + recordThroughput(memoryStatsState.ingested, Date.now()); + emitMetric(options, 'memory_ingested_total', { count: 1 }); + } + }, + }); + cache.clear(); + return records; +} + +/** + * Retrieves top-k memories for an agent query. + * + * @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, repository?: typeof memoryRepository }} params - Retrieval filter and ranking inputs. + * @returns {Promise} Sorted list of relevant memories. + */ +export async function getRelevantMemories(params) { + const { + repository = memoryRepository, + ranker = filterAndRankMemories, + cache: cacheProvider = cache, + ...queryParams + } = params; + const telemetry = buildTelemetryEmitter(params); + const cacheKey = JSON.stringify(queryParams); + const cached = cacheProvider.get(cacheKey); + if (cached) { + telemetry('memory:retrieved', { + agent_id: queryParams.agent_id, + result_count: cached.length, + cache_hit: true, + query_present: Boolean(queryParams.query), + query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0, + tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0, + }); + emitMetric(params, 'memory_retrieved_total', { count: cached.length, cache_hit: true }); + recordThroughput(memoryStatsState.retrieved, Date.now()); + return cached; + } + + const source = typeof repository.listMemories === 'function' + ? await repository.listMemories(queryParams) + : [...memoryStore.values()]; + + const ranked = await ranker(source, queryParams); + await updateMemoryUsage(repository, ranked.map((memory) => memory.id)); + cacheProvider.set(cacheKey, ranked); + + telemetry('memory:retrieved', { + agent_id: queryParams.agent_id, + result_count: ranked.length, + cache_hit: false, + query_present: Boolean(queryParams.query), + query_length: typeof queryParams.query === 'string' ? queryParams.query.length : 0, + tags_count: Array.isArray(queryParams.tags) ? queryParams.tags.length : 0, + }); + emitMetric(params, 'memory_retrieved_total', { count: ranked.length, cache_hit: false }); + recordThroughput(memoryStatsState.retrieved, Date.now()); + + return ranked; +} + +/** + * Runs a memory pruning pass and removes unpinned stale records. + * + * @param {{ retentionMs?: number, now?: number, scheduleEveryMs?: number, repository?: typeof memoryRepository }} [options] - Prune configuration; if scheduleEveryMs is set, returns a scheduler handle. + * @returns {Promise<{ pruned: import('./schema.js').MemoryRecord[], scheduler?: { stop: () => void } }>} Pruned records and optional scheduler handle. + */ +export async function runPruneCycle(options = {}) { + const repository = options.repository ?? memoryRepository; + const telemetry = buildTelemetryEmitter(options); + const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30); + const pruneMode = options.pruneMode ?? getMemoryPruneMode(options.env); + const dbCandidates = await listPruneCandidates(repository, { + retentionMs, + now: options.now, + }); + + const prunable = dbCandidates.length > 0 + ? dbCandidates + : selectPrunableMemories([...memoryStore.values()], { + retentionMs, + now: options.now, + }); + + if (pruneMode === 'off') { + telemetry('memory:prune_skipped', { reason: 'flag_disabled', retention_ms: retentionMs }); + return { pruned: [], mode: 'off' }; + } + + if (pruneMode === 'dry-run') { + telemetry('memory:pruned', { + pruned_count: prunable.length, + retention_ms: retentionMs, + dry_run: true, + }); + emitMetric(options, 'memory_pruned_total', { count: 0, retention_ms: retentionMs, dry_run: true }); + return { pruned: [], candidates: prunable, mode: 'dry-run' }; + } + + for (const memory of prunable) { + memoryStore.delete(memory.id); + } + + if (prunable.length > 0) { + saveMemoryStore(memoryStore); + } + + memoryStatsState.deleted += prunable.length; + recordThroughput(memoryStatsState.pruned, Date.now()); + telemetry('memory:pruned', { + pruned_count: prunable.length, + retention_ms: retentionMs, + }); + emitMetric(options, 'memory_pruned_total', { count: prunable.length, retention_ms: retentionMs }); + + cache.clear(); + + const result = { pruned: prunable }; + if (Number.isFinite(options.scheduleEveryMs) && options.scheduleEveryMs > 0) { + result.scheduler = startScheduler(() => runPruneCycle({ retentionMs, repository, env: options.env, pruneMode: options.pruneMode }), { + intervalMs: options.scheduleEveryMs, + }); + } + + return result; +} + +/** + * Pins a memory to prevent pruning and boost retrieval ranking. + * + * @param {string} id - Memory record identifier. + * @returns {Promise} Updated memory record or null when not found. + */ +export async function pinMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + const updated = await repository.setPinned(id, true); + cache.clear(); + return updated; +} + +/** + * Removes a pin from a memory so it can be pruned normally. + * + * @param {string} id - Memory record identifier. + * @returns {Promise} Updated memory record or null when not found. + */ +export async function unpinMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + const updated = await repository.setPinned(id, false); + cache.clear(); + return updated; +} + +/** + * Marks one memory as merged into another. + * + * @param {string} id + * @param {string} mergedInto + */ +export async function markMemoryMerged(id, mergedInto, options = {}) { + const repository = options.repository ?? memoryRepository; + const merged = await repository.markMerged(id, mergedInto); + cache.clear(); + return merged; +} + +export async function listMemories(filters = {}, options = {}) { + const repository = options.repository ?? memoryRepository; + const source = typeof repository.listMemories === 'function' + ? await repository.listMemories(filters) + : [...memoryStore.values()]; + + const filtered = applyMemoryFilters(source, filters) + .sort((left, right) => right.last_seen - left.last_seen); + + const offset = Number.isFinite(filters.offset) ? Math.max(0, Number(filters.offset)) : 0; + const limit = Number.isFinite(filters.limit) + ? Math.max(1, Math.floor(Number(filters.limit))) + : filtered.length; + + return filtered.slice(offset, offset + limit); +} + +export async function inspectMemory(id, options = {}) { + const repository = options.repository ?? memoryRepository; + if (typeof repository.getMemoryById === 'function') { + return repository.getMemoryById(id); + } + return memoryStore.get(id) ?? null; +} + +export async function triggerPruneDryRun(options = {}) { + const repository = options.repository ?? memoryRepository; + const retentionMs = options.retentionMs ?? (1000 * 60 * 60 * 24 * 30); + const dbCandidates = await listPruneCandidates(repository, { + retentionMs, + now: options.now, + }); + + const candidates = dbCandidates.length > 0 + ? dbCandidates + : selectPrunableMemories(await listMemories({}, { repository }), { + retentionMs, + now: options.now, + }); + + return { + dryRun: true, + retentionMs, + candidateCount: candidates.length, + candidates: candidates.map((memory) => ({ + id: memory.id, + agent_id: memory.agent_id, + type: memory.type, + pinned: memory.pinned, + last_seen: memory.last_seen, + merged_into: memory.merged_into, + })), + }; +} + +export async function memoryStats(options = {}) { + const repository = options.repository ?? memoryRepository; + const now = options.now ?? Date.now(); + const windowMs = Number.isFinite(options.windowMs) ? Math.max(1, Number(options.windowMs)) : (60 * 60 * 1000); + const memories = await listMemories({}, { repository }); + + const countsByType = memories.reduce((acc, memory) => { + acc[memory.type] = (acc[memory.type] ?? 0) + 1; + return acc; + }, {}); + + const archivedCount = memories.filter((memory) => Boolean(memory.merged_into)).length; + const deletedRate = memoryStatsState.deleted / Math.max(1, memoryStatsState.ingested.length); + const archivedRate = archivedCount / Math.max(1, memories.length); + + const ingestedInWindow = memoryStatsState.ingested.filter((timestamp) => timestamp >= (now - windowMs)).length; + const ingestThroughputPerMinute = ingestedInWindow / Math.max(1, (windowMs / 60_000)); + + return { + countsByType, + totals: { + total: memories.length, + pinned: memories.filter((memory) => memory.pinned).length, + archived: archivedCount, + deletedObserved: memoryStatsState.deleted, + }, + rates: { + archivedRate, + deletedRate, + }, + indexSizeEstimateBytes: memories.length * ESTIMATED_INDEX_BYTES_PER_RECORD, + ingestThroughputPerMinute, + }; +} diff --git a/torch/src/services/memory/ingestor.js b/torch/src/services/memory/ingestor.js new file mode 100644 index 00000000..5f9ac422 --- /dev/null +++ b/torch/src/services/memory/ingestor.js @@ -0,0 +1,291 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { createMemoryRecord, normalizeEvent, validateMemoryItem } from './schema.js'; +import { embedText, getDefaultEmbedderAdapter } from './embedder.js'; +import { summarizeEvents } from './summarizer.js'; + +const WINDOW_BUCKET_MS = 60_000; +const MAX_DEDUPE_KEYS = 2000; +const dedupeWindow = new Map(); + +function pruneDedupeWindow() { + while (dedupeWindow.size > MAX_DEDUPE_KEYS) { + const oldestKey = dedupeWindow.keys().next().value; + if (!oldestKey) break; + dedupeWindow.delete(oldestKey); + } +} + +function redactObviousPii(text) { + if (typeof text !== 'string' || text.length === 0) return ''; + return text + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]') + .replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]') + .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]'); +} + +function estimateTokens(text) { + if (!text) return 0; + return Math.ceil(text.length / 4); +} + +function chunkEvents(events, options = {}) { + const maxChunkChars = Number.isFinite(options.maxChunkChars) && options.maxChunkChars > 0 + ? Math.floor(options.maxChunkChars) + : 1_200; + const maxChunkTokens = Number.isFinite(options.maxChunkTokens) && options.maxChunkTokens > 0 + ? Math.floor(options.maxChunkTokens) + : 300; + + const chunks = []; + let currentChunk = []; + let currentChars = 0; + let currentTokens = 0; + + const flush = () => { + if (currentChunk.length > 0) { + chunks.push(currentChunk); + currentChunk = []; + currentChars = 0; + currentTokens = 0; + } + }; + + for (const event of events) { + const eventChars = event.content.length; + const eventTokens = estimateTokens(event.content); + const overBudget = (currentChars + eventChars > maxChunkChars) || (currentTokens + eventTokens > maxChunkTokens); + if (overBudget) { + flush(); + } + + currentChunk.push(event); + currentChars += eventChars; + currentTokens += eventTokens; + } + + flush(); + return chunks; +} + +function hashIngestionWindow({ agentId, bucket, content }) { + return createHash('sha256').update(`${agentId}|${bucket}|${content}`).digest('hex'); +} + +async function isDuplicateWindow(repository, dedupeKey) { + if (dedupeWindow.has(dedupeKey)) return true; + if (typeof repository?.hasIngestionFingerprint === 'function') { + const exists = await repository.hasIngestionFingerprint(dedupeKey); + if (exists) return true; + } + return false; +} + +async function rememberWindowFingerprint(repository, dedupeKey, payload) { + dedupeWindow.set(dedupeKey, payload); + pruneDedupeWindow(); + if (typeof repository?.storeIngestionFingerprint === 'function') { + await repository.storeIngestionFingerprint(dedupeKey, payload); + } +} + + +function compactContextExcerpt(text, maxChars = 280) { + if (typeof text !== 'string' || text.length === 0) return ''; + const compact = text.replace(/\s+/g, ' ').trim(); + if (compact.length <= maxChars) return compact; + return `${compact.slice(0, maxChars - 1)}…`; +} + +function buildTelemetryEmitter(options = {}) { + if (typeof options.emitTelemetry === 'function') return options.emitTelemetry; + if (typeof options.telemetry?.emit === 'function') { + return (event, payload) => options.telemetry.emit(event, payload); + } + return () => {}; +} + +/** + * @param {import('./schema.js').MemoryEvent[]} events + * @returns {import('./schema.js').MemoryEvent[]} + */ +export function normalizeEvents(events) { + return events.map(normalizeEvent); +} + +/** + * @param {{ + * events?: import('./schema.js').MemoryEvent[], + * runtimeCache?: { getRecentRuntimeEvents?: (scope: Record, params?: { since?: number, limit?: number }) => Record[] }, + * runtimeScope?: Record, + * logSource?: { getEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise[]> | Record[], fetchEvents?: (params: { from: number, to: number, agent_id?: string }) => Promise[]> | Record[] }, + * agent_id?: string, + * windowStart?: number, + * windowEnd?: number, + * }} input + * @param {{ + * repository?: { insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise, hasIngestionFingerprint?: (dedupeKey: string) => Promise, storeIngestionFingerprint?: (dedupeKey: string, payload: Record) => Promise, linkEmbedding?: (memoryId: string, embedding: { id: string, vector: number[] }) => Promise }, + * maxSummaryLength?: number, + * maxChunkChars?: number, + * maxChunkTokens?: number, + * embedText?: (value: string) => Promise | number[], + * adapter?: import('./embedder.js').EmbedderAdapter, + * embedderAdapter?: import('./embedder.js').EmbedderAdapter, + * contextExcerptChars?: number, + * emitTelemetry?: (event: string, payload: Record) => void, + * telemetry?: { emit: (event: string, payload: Record) => void }, + * now?: number, + * windowBucketMs?: number, + * }} options + * @returns {Promise} + */ +export async function ingestMemoryWindow(input = {}, options = {}) { + const repository = options.repository; + if (!repository || typeof repository.insertMemory !== 'function') { + throw new TypeError('Memory ingest requires a repository with insertMemory'); + } + + const emitTelemetry = buildTelemetryEmitter(options); + const windowStart = Number.isFinite(input.windowStart) ? Number(input.windowStart) : 0; + const windowEnd = Number.isFinite(input.windowEnd) ? Number(input.windowEnd) : (Number.isFinite(options.now) ? Number(options.now) : Date.now()); + + const runtimeEvents = typeof input.runtimeCache?.getRecentRuntimeEvents === 'function' + ? input.runtimeCache.getRecentRuntimeEvents(input.runtimeScope ?? {}, { since: windowStart, limit: 500 }) + : []; + + const logReader = input.logSource?.fetchEvents ?? input.logSource?.getEvents; + const logEvents = typeof logReader === 'function' + ? await logReader({ from: windowStart, to: windowEnd, agent_id: input.agent_id }) + : []; + + const normalizedEvents = normalizeEvents([ + ...(Array.isArray(input.events) ? input.events : []), + ...(Array.isArray(runtimeEvents) ? runtimeEvents : []), + ...(Array.isArray(logEvents) ? logEvents : []), + ]); + + const redactedEvents = normalizedEvents.map((event) => ({ + ...event, + content: redactObviousPii(event.content), + })); + + const eventsByAgent = new Map(); + for (const event of redactedEvents) { + const events = eventsByAgent.get(event.agent_id) ?? []; + events.push(event); + eventsByAgent.set(event.agent_id, events); + } + + const insertedRecords = []; + const bucketMs = Number.isFinite(options.windowBucketMs) && options.windowBucketMs > 0 + ? Math.floor(options.windowBucketMs) + : WINDOW_BUCKET_MS; + const bucket = Math.floor(windowEnd / bucketMs); + + for (const [agentId, agentEvents] of eventsByAgent.entries()) { + if (!agentEvents.length) continue; + const sorted = [...agentEvents].sort((a, b) => a.timestamp - b.timestamp); + const dedupeContent = sorted.map((event) => `${event.timestamp}:${event.content}`).join('\n'); + const dedupeKey = hashIngestionWindow({ agentId, bucket, content: dedupeContent }); + + if (await isDuplicateWindow(repository, dedupeKey)) { + continue; + } + + const chunks = chunkEvents(sorted, options); + for (const chunk of chunks) { + const summarization = await summarizeEvents(chunk, options); + const contextText = chunk.map((event) => event.content).join('\n'); + const summaryEmbedding = await embedText(summarization.summary, options); + const contextExcerpt = compactContextExcerpt(contextText, options.contextExcerptChars); + const chunkStart = Math.min(...chunk.map((event) => event.timestamp)); + const chunkEnd = Math.max(...chunk.map((event) => event.timestamp)); + + const record = createMemoryRecord({ + agent_id: agentId, + session_id: typeof chunk[0]?.metadata?.session_id === 'string' ? chunk[0].metadata.session_id : 'unknown', + type: 'batch_window', + content: contextText, + summary: summarization.summary, + tags: [...new Set(chunk.flatMap((event) => event.tags))], + importance: Number.isFinite(chunk[0]?.metadata?.importance) + ? Number(chunk[0].metadata.importance) + : summarization.importance, + embedding_id: Array.isArray(summaryEmbedding) && summaryEmbedding.length > 0 ? randomUUID() : null, + created_at: chunkStart, + last_seen: chunkEnd, + source: typeof chunk[0]?.metadata?.source === 'string' ? chunk[0].metadata.source : 'ingest', + ttl_days: Number.isFinite(chunk[0]?.metadata?.ttl_days) ? Number(chunk[0].metadata.ttl_days) : null, + merged_into: null, + pinned: false, + }); + + const validation = validateMemoryItem(record); + if (!validation.valid) { + console.error('memory_validation_error', { + stage: 'ingest', + reason: 'record_failed_schema_validation', + fields: validation.errors, + item: record, + }); + throw new TypeError('Memory ingest rejected: invalid durable record format'); + } + + await insertMemory(repository, record); + insertedRecords.push(record); + + if (record.embedding_id) { + const vectorMetadata = { + memory_id: record.id, + agent_id: record.agent_id, + summary: record.summary, + context_excerpt: contextExcerpt, + source: record.source, + created_at: record.created_at, + last_seen: record.last_seen, + }; + + if (typeof repository.linkEmbedding === 'function') { + await repository.linkEmbedding(record.id, { + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } + + const adapter = options.embedderAdapter ?? options.adapter ?? getDefaultEmbedderAdapter(); + if (typeof adapter?.upsertVector === 'function') { + await adapter.upsertVector({ + id: record.embedding_id, + vector: summaryEmbedding, + metadata: vectorMetadata, + }); + } + } + + emitTelemetry('memory:ingested', { + memory_id: record.id, + agent_id: record.agent_id, + dedupe_key: dedupeKey, + chunk_events: chunk.length, + window_start: windowStart, + window_end: windowEnd, + }); + } + + await rememberWindowFingerprint(repository, dedupeKey, { + agent_id: agentId, + window_start: windowStart, + window_end: windowEnd, + }); + } + + return insertedRecords; +} + +/** + * @param {{ insertMemory: (memory: import('./schema.js').MemoryRecord) => Promise }} repository + * @param {import('./schema.js').MemoryRecord} memory + */ +export async function insertMemory(repository, memory) { + return repository.insertMemory(memory); +} diff --git a/torch/src/services/memory/pruner.js b/torch/src/services/memory/pruner.js new file mode 100644 index 00000000..bf996d6a --- /dev/null +++ b/torch/src/services/memory/pruner.js @@ -0,0 +1,313 @@ +import { loadMemoryPromptTemplates } from './summarizer.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * @typedef {'keep' | 'archive' | 'delete'} LifecycleAction + */ + +/** + * @param {number[] | null | undefined} a + * @param {number[] | null | undefined} b + */ +function cosineSimilarity(a, b) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) { + return 0; + } + + let dot = 0; + let normA = 0; + let normB = 0; + + for (let i = 0; i < a.length; i += 1) { + const av = Number(a[i]) || 0; + const bv = Number(b[i]) || 0; + dot += av * bv; + normA += av * av; + normB += bv * bv; + } + + const denom = Math.sqrt(normA) * Math.sqrt(normB); + return denom > 0 ? dot / denom : 0; +} + +/** + * @param {string[]} left + * @param {string[]} right + */ +function hasTagOverlap(left = [], right = []) { + if (!left.length || !right.length) return false; + const rightSet = new Set(right); + return left.some((tag) => rightSet.has(tag)); +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ now: number, retentionMs: number, lowImportanceThreshold: number, recentUsageMs: number }} policy + */ +function evaluateCandidate(memory, policy) { + const ttlExpired = Number.isInteger(memory.ttl_days) + ? memory.created_at + (memory.ttl_days * DAY_MS) <= policy.now + : false; + const staleByRetention = memory.last_seen < (policy.now - policy.retentionMs); + const lowImportance = memory.importance <= policy.lowImportanceThreshold; + const lowRecentUsage = memory.last_seen <= (policy.now - policy.recentUsageMs); + + return { + ttlExpired, + staleByRetention, + lowImportance, + lowRecentUsage, + candidate: !memory.pinned && (ttlExpired || staleByRetention) && lowImportance && lowRecentUsage, + }; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, similarityThreshold: number, duplicateWindowMs: number }} options + */ +function groupNearDuplicates(memories, options) { + const groups = []; + const consumed = new Set(); + + for (let i = 0; i < memories.length; i += 1) { + const base = memories[i]; + if (consumed.has(base.id) || base.merged_into) continue; + + const baseEmbedding = options.getEmbedding?.(base); + const group = [base]; + + for (let j = i + 1; j < memories.length; j += 1) { + const probe = memories[j]; + if (consumed.has(probe.id) || probe.merged_into || probe.id === base.id) continue; + + const withinWindow = Math.abs(base.created_at - probe.created_at) <= options.duplicateWindowMs; + if (!withinWindow || !hasTagOverlap(base.tags, probe.tags)) continue; + + const similarity = cosineSimilarity(baseEmbedding, options.getEmbedding?.(probe)); + if (similarity >= options.similarityThreshold) { + group.push(probe); + } + } + + if (group.length > 1) { + group.forEach((item) => consumed.add(item.id)); + groups.push(group.sort((a, b) => b.last_seen - a.last_seen)); + } + } + + return groups; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} group + * @param {(prompt: string) => Promise | string} [generateSummary] + * @param {string} condenseTemplate + */ +async function condenseGroup(group, generateSummary, condenseTemplate) { + const [primary, ...rest] = group; + const fallbackSummary = group.map((item) => item.summary || item.content).filter(Boolean).join(' ').slice(0, 280); + const fallback = { + summary: fallbackSummary || primary.summary, + importance: Math.max(...group.map((item) => item.importance)), + }; + + if (typeof generateSummary !== 'function') { + return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true }; + } + + const sourceBlock = group + .map((item) => `- id=${item.id}; tags=${item.tags.join(',')}; ts=${item.created_at}; summary=${item.summary || item.content}`) + .join('\n'); + const prompt = [ + 'Condense the following near-duplicate memories into one factual summary.', + 'Return STRICT JSON: {"summary":"...","importance":0.0}', + 'Do not invent facts.', + '', + `Memories:\n${sourceBlock}`, + '', + 'If your first response is malformed, repair it using this policy:', + condenseTemplate, + ].join('\n'); + + try { + const raw = await generateSummary(prompt); + const parsed = JSON.parse(raw); + const summary = typeof parsed?.summary === 'string' && parsed.summary.trim() ? parsed.summary.trim() : fallback.summary; + const importance = Number.isFinite(Number(parsed?.importance)) + ? Math.max(0, Math.min(1, Number(parsed.importance))) + : fallback.importance; + + return { + merged: { + ...primary, + summary, + importance, + tags: [...new Set(group.flatMap((item) => item.tags))], + last_seen: Math.max(...group.map((item) => item.last_seen)), + }, + mergedIds: rest.map((item) => item.id), + usedFallback: false, + }; + } catch { + return { merged: { ...primary, summary: fallback.summary, importance: fallback.importance }, mergedIds: rest.map((item) => item.id), usedFallback: true }; + } +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ + * retentionMs: number, + * now?: number, + * lowImportanceThreshold?: number, + * deleteImportanceThreshold?: number, + * recentUsageMs?: number, + * duplicateWindowMs?: number, + * similarityThreshold?: number, + * getEmbedding?: (memory: import('./schema.js').MemoryRecord) => number[] | null | undefined, + * generateSummary?: (prompt: string) => Promise | string, + * templateDir?: string, + * }} options + */ +export async function createLifecyclePlan(memories, options) { + const now = options.now ?? Date.now(); + const policy = { + now, + retentionMs: options.retentionMs, + lowImportanceThreshold: options.lowImportanceThreshold ?? 0.35, + deleteImportanceThreshold: options.deleteImportanceThreshold ?? 0.12, + recentUsageMs: options.recentUsageMs ?? Math.max(Math.floor(options.retentionMs / 2), DAY_MS), + duplicateWindowMs: options.duplicateWindowMs ?? (7 * DAY_MS), + similarityThreshold: options.similarityThreshold ?? 0.93, + }; + + const candidateById = new Map(memories.map((memory) => [memory.id, evaluateCandidate(memory, policy)])); + const groups = groupNearDuplicates(memories.filter((memory) => !memory.pinned), { + getEmbedding: options.getEmbedding, + similarityThreshold: policy.similarityThreshold, + duplicateWindowMs: policy.duplicateWindowMs, + }); + + const templates = loadMemoryPromptTemplates({ templateDir: options.templateDir }); + const condensedGroups = []; + const mergedIntoMap = new Map(); + + for (const group of groups) { + const condensed = await condenseGroup(group, options.generateSummary, templates.condense); + condensedGroups.push(condensed); + for (const mergedId of condensed.mergedIds) { + mergedIntoMap.set(mergedId, condensed.merged.id); + } + } + + const protectedFromDelete = new Set(mergedIntoMap.keys()); + const actions = []; + const policyLogs = []; + + for (const memory of memories) { + const candidate = candidateById.get(memory.id) ?? evaluateCandidate(memory, policy); + + /** @type {LifecycleAction} */ + let action = 'keep'; + let reason = 'retained: does not satisfy low-value prune gates'; + + if (memory.pinned) { + action = 'keep'; + reason = 'retained: pinned memories are never deleted'; + } else if (mergedIntoMap.has(memory.id)) { + action = 'archive'; + reason = `archived: merged into ${mergedIntoMap.get(memory.id)} and retained as source provenance`; + } else if (candidate.candidate) { + const veryLowImportance = memory.importance <= policy.deleteImportanceThreshold; + const veryOld = memory.last_seen < (now - (policy.retentionMs * 2)); + const decisiveDelete = candidate.ttlExpired && candidate.staleByRetention && veryLowImportance && veryOld; + + if (protectedFromDelete.has(memory.id)) { + action = 'archive'; + reason = 'archived: referenced by retained merged summary'; + } else if (decisiveDelete) { + action = 'delete'; + reason = 'deleted: expired, very low importance, and long-unseen beyond retention grace'; + } else { + action = 'archive'; + reason = 'archived: borderline low-value candidate; archive preferred over delete'; + } + } + + actions.push({ id: memory.id, action, merged_into: mergedIntoMap.get(memory.id) ?? null, reason }); + policyLogs.push({ + id: memory.id, + action, + reason, + signals: { + ttlExpired: candidate.ttlExpired, + staleByRetention: candidate.staleByRetention, + lowImportance: candidate.lowImportance, + lowRecentUsage: candidate.lowRecentUsage, + }, + }); + } + + return { + actions, + policyLogs, + condensedGroups, + }; +} + +/** + * @param {{ + * archiveMemory?: (id: string, reason: string) => Promise | unknown, + * deleteMemory?: (id: string, reason: string) => Promise | unknown, + * keepMemory?: (id: string, reason: string) => Promise | unknown, + * markMerged?: (id: string, mergedInto: string) => Promise | unknown, + * }} repository + * @param {Awaited>} plan + */ +export async function applyLifecycleActions(repository, plan) { + const results = []; + + for (const decision of plan.actions) { + if (decision.merged_into && typeof repository.markMerged === 'function') { + await repository.markMerged(decision.id, decision.merged_into); + } + + if (decision.action === 'archive') { + await repository.archiveMemory?.(decision.id, decision.reason); + } else if (decision.action === 'delete') { + await repository.deleteMemory?.(decision.id, decision.reason); + } else { + await repository.keepMemory?.(decision.id, decision.reason); + } + + results.push(decision); + } + + return { + applied: results, + policyLogs: plan.policyLogs, + }; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ retentionMs: number, now?: number }} options + * @returns {import('./schema.js').MemoryRecord[]} + */ +export function selectPrunableMemories(memories, options) { + const now = options.now ?? Date.now(); + const cutoff = now - options.retentionMs; + + return memories.filter((memory) => !memory.pinned && memory.last_seen < cutoff); +} + + +/** + * @param {{ listPruneCandidates: (options: { cutoff: number, limit?: number }) => Promise }} repository + * @param {{ retentionMs: number, now?: number, limit?: number }} options + */ +export async function listPruneCandidates(repository, options) { + const now = options.now ?? Date.now(); + const cutoff = now - options.retentionMs; + return repository.listPruneCandidates({ cutoff, limit: options.limit }); +} diff --git a/torch/src/services/memory/retriever.js b/torch/src/services/memory/retriever.js new file mode 100644 index 00000000..8b3d76f4 --- /dev/null +++ b/torch/src/services/memory/retriever.js @@ -0,0 +1,181 @@ +const DEFAULT_WEIGHTS = { + w_sim: 0.6, + w_imp: 0.25, + w_rec: 0.15, + pinBoost: 0.15, +}; + +const DEFAULT_RECENCY_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 7; + +function cosineSimilarity(left = [], right = []) { + const size = Math.max(left.length, right.length); + let dot = 0; + let leftNorm = 0; + let rightNorm = 0; + + for (let index = 0; index < size; index += 1) { + const leftValue = Number(left[index] ?? 0); + const rightValue = Number(right[index] ?? 0); + dot += leftValue * rightValue; + leftNorm += leftValue ** 2; + rightNorm += rightValue ** 2; + } + + if (leftNorm === 0 || rightNorm === 0) return 0; + return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm)); +} + +function computeRecencyDecay(timestamp, now, halfLifeMs) { + if (!Number.isFinite(timestamp) || !Number.isFinite(now) || halfLifeMs <= 0) return 0; + const age = Math.max(0, now - timestamp); + return Math.exp(-(age / halfLifeMs)); +} + +function textMatchSimilarity(query, memory) { + const normalizedQuery = String(query ?? '').trim().toLowerCase(); + if (!normalizedQuery) return 0; + + const haystack = `${memory.summary} ${memory.content}`.toLowerCase(); + if (haystack.includes(normalizedQuery)) return 1; + + const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean); + if (queryTerms.length === 0) return 0; + + const matchedTerms = queryTerms.filter((term) => haystack.includes(term)).length; + return matchedTerms / queryTerms.length; +} + +/** + * @param {import('./schema.js').MemoryRecord} memory + * @param {{ tags: string[], timeframe?: { from?: number, to?: number }, pinnedPreference?: 'prefer' | 'only' | 'exclude' }} params + */ +function matchesMetadataFilters(memory, params) { + const inTimeframe = !params.timeframe + || ((params.timeframe.from == null || memory.created_at >= params.timeframe.from) + && (params.timeframe.to == null || memory.created_at <= params.timeframe.to)); + + if (!inTimeframe) return false; + + if (params.tags.length > 0) { + const memoryTags = new Set(memory.tags); + const matchesAllTags = params.tags.every((tag) => memoryTags.has(tag)); + if (!matchesAllTags) return false; + } + + if (params.pinnedPreference === 'only' && !memory.pinned) return false; + if (params.pinnedPreference === 'exclude' && memory.pinned) return false; + + return true; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ query: string, agent_id: string, k: number, vectorAdapter?: { embedText: (text: string) => Promise | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise | number[] }} params + */ +async function buildSimilarityIndex(memories, params) { + const similarityById = new Map(); + const normalizedQuery = params.query.trim(); + + if (!normalizedQuery) { + return similarityById; + } + + if (params.vectorAdapter && typeof params.vectorAdapter.queryVector === 'function') { + const queryVector = typeof params.embedText === 'function' + ? await params.embedText(normalizedQuery) + : await params.vectorAdapter.embedText(normalizedQuery); + + const vectorHits = await params.vectorAdapter.queryVector({ + vector: queryVector, + k: Math.max(params.k * 4, memories.length), + filter: { agent_id: params.agent_id }, + }); + + for (const hit of vectorHits) { + if (typeof hit.id === 'string' && Number.isFinite(hit.score)) { + similarityById.set(hit.id, Math.max(0, Number(hit.score))); + } + } + + if (similarityById.size > 0) { + return similarityById; + } + } + + for (const memory of memories) { + const lexicalScore = textMatchSimilarity(normalizedQuery, memory); + if (lexicalScore > 0) { + similarityById.set(memory.id, lexicalScore); + } + } + + return similarityById; +} + +/** + * @param {import('./schema.js').MemoryRecord[]} memories + * @param {{ agent_id: string, query?: string, tags?: string[], timeframe?: { from?: number, to?: number }, k?: number, pinnedPreference?: 'prefer' | 'only' | 'exclude', weights?: { w_sim?: number, w_imp?: number, w_rec?: number, pinBoost?: number }, now?: number, recencyHalfLifeMs?: number, vectorAdapter?: { embedText: (text: string) => Promise | number[], queryVector: (input: { vector: number[], k?: number, filter?: Record }) => Promise> | Array<{ id: string, score: number }> }, embedText?: (value: string) => Promise | number[] }} params + * @returns {Promise} + */ +export async function filterAndRankMemories(memories, params) { + const { + agent_id, + query = '', + tags = [], + timeframe, + k = 10, + pinnedPreference = 'prefer', + weights = {}, + now = Date.now(), + recencyHalfLifeMs = DEFAULT_RECENCY_HALF_LIFE_MS, + vectorAdapter, + embedText, + } = params; + + const mergedWeights = { + ...DEFAULT_WEIGHTS, + ...weights, + }; + + const filtered = memories.filter((memory) => { + if (memory.agent_id !== agent_id) return false; + return matchesMetadataFilters(memory, { tags, timeframe, pinnedPreference }); + }); + + const similarityById = await buildSimilarityIndex(filtered, { + query, + agent_id, + k, + vectorAdapter, + embedText, + }); + + return filtered + .map((memory) => { + const memoryTimestamp = Number.isFinite(memory.last_seen) ? memory.last_seen : memory.created_at; + const recencyDecay = computeRecencyDecay(memoryTimestamp, now, recencyHalfLifeMs); + const similarity = similarityById.get(memory.id) ?? 0; + const pinBoost = memory.pinned && pinnedPreference !== 'exclude' ? mergedWeights.pinBoost : 0; + + const score = (similarity * mergedWeights.w_sim) + + (memory.importance * mergedWeights.w_imp) + + (recencyDecay * mergedWeights.w_rec) + + pinBoost; + + return { memory, score }; + }) + .sort((left, right) => right.score - left.score) + .slice(0, k) + .map((entry) => entry.memory); +} + +/** + * @param {{ updateMemoryUsage: (id: string, lastSeen?: number) => Promise }} repository + * @param {string[]} ids + * @param {number} [lastSeen] + */ +export async function updateMemoryUsage(repository, ids, lastSeen = Date.now()) { + await Promise.all(ids.map((id) => repository.updateMemoryUsage(id, lastSeen))); +} + +export { cosineSimilarity }; diff --git a/torch/src/services/memory/scheduler.js b/torch/src/services/memory/scheduler.js new file mode 100644 index 00000000..533734d9 --- /dev/null +++ b/torch/src/services/memory/scheduler.js @@ -0,0 +1,317 @@ +import { getMemoryPruneMode, isMemoryEnabled, isMemoryIngestEnabled } from './feature-flags.js'; +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; + +const JOBS = { + ingestRecentRuntimeEvents: { + lockKey: 'memory:job:ingest-recent-runtime-events', + minIntervalMs: MINUTE_MS, + maxIntervalMs: 5 * MINUTE_MS, + }, + consolidateObservations: { + lockKey: 'memory:job:consolidate-observations', + intervalMs: HOUR_MS, + }, + pruningCycle: { + lockKey: 'memory:job:pruning-cycle', + intervalMs: DAY_MS, + }, + deepMergeArchivalMaintenance: { + lockKey: 'memory:job:deep-merge-archival-maintenance', + intervalMs: WEEK_MS, + }, +}; + +/** + * @param {() => Promise | unknown} task + * @param {{ intervalMs: number }} options + * @returns {{ stop: () => void }} + */ +export function startScheduler(task, options) { + const handle = setInterval(() => { + void task(); + }, options.intervalMs); + + return { + stop() { + clearInterval(handle); + }, + }; +} + +function normalizeItemCount(result) { + if (typeof result === 'number' && Number.isFinite(result)) { + return Math.max(0, Math.floor(result)); + } + + if (result && typeof result === 'object') { + const count = result.itemCount ?? result.count ?? result.processed ?? result.rows; + if (typeof count === 'number' && Number.isFinite(count)) { + return Math.max(0, Math.floor(count)); + } + } + + return 0; +} + +/** + * @param {string} key + */ +function hashLockKey(key) { + let hash = 0; + for (let i = 0; i < key.length; i += 1) { + hash = ((hash * 31) + key.charCodeAt(i)) | 0; + } + return hash; +} + +/** + * @param {{ query: (sql: string, params?: unknown[]) => Promise<{ rows?: any[] }> }} db + * @param {{ namespace?: string }} [options] + */ +export function createDbAdvisoryLockProvider(db, options = {}) { + const namespace = options.namespace ?? 'torch-memory-jobs'; + + return { + /** + * @param {string} key + * @param {() => Promise} task + */ + async withLock(key, task) { + const lockId = `${namespace}:${key}`; + const keyA = hashLockKey(namespace); + const keyB = hashLockKey(lockId); + const acquiredResult = await db.query('SELECT pg_try_advisory_lock($1, $2) AS locked', [keyA, keyB]); + const acquired = Boolean(acquiredResult?.rows?.[0]?.locked); + if (!acquired) { + return { acquired: false, result: null }; + } + + try { + const result = await task(); + return { acquired: true, result }; + } finally { + await db.query('SELECT pg_advisory_unlock($1, $2)', [keyA, keyB]); + } + }, + }; +} + +function createInMemoryLockProvider() { + const locks = new Set(); + return { + async withLock(key, task) { + if (locks.has(key)) { + return { acquired: false, result: null }; + } + + locks.add(key); + try { + const result = await task(); + return { acquired: true, result }; + } finally { + locks.delete(key); + } + }, + }; +} + +/** + * @param {{ + * jobName: string, + * lockKey: string, + * handler: () => Promise | unknown, + * lockProvider: { withLock: (key: string, task: () => Promise) => Promise<{ acquired: boolean, result: unknown }> }, + * maxRetries: number, + * retryDelayMs: number, + * emitMetric?: (metricName: string, payload: Record) => void, + * }} options + */ +async function runScheduledJob(options) { + const startedAt = Date.now(); + let failures = 0; + let retries = 0; + + for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) { + try { + const lockRun = await options.lockProvider.withLock(options.lockKey, async () => options.handler()); + + if (!lockRun.acquired) { + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'skipped_lock_unavailable', + durationMs: Date.now() - startedAt, + itemCount: 0, + failures, + retries, + }); + return; + } + + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'success', + durationMs: Date.now() - startedAt, + itemCount: normalizeItemCount(lockRun.result), + failures, + retries, + }); + return; + } catch (error) { + failures += 1; + if (attempt < options.maxRetries) { + retries += 1; + await new Promise((resolve) => { + setTimeout(resolve, options.retryDelayMs); + }); + continue; + } + + options.emitMetric?.('memory_scheduler_job', { + job: options.jobName, + status: 'failed', + durationMs: Date.now() - startedAt, + itemCount: 0, + failures, + retries, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + } +} + +function shouldRunJob(jobName, options) { + const env = options.env ?? process.env; + if (!isMemoryEnabled(env)) return false; + + if (jobName === 'ingestRecentRuntimeEvents') { + return isMemoryIngestEnabled(options.ingestAgentId, env); + } + + if (jobName === 'pruningCycle') { + return getMemoryPruneMode(env) !== 'off'; + } + + return true; +} + +/** + * @param {{ + * handlers: { + * ingestRecentRuntimeEvents: () => Promise | unknown, + * consolidateObservations: () => Promise | unknown, + * pruningCycle: () => Promise | unknown, + * deepMergeArchivalMaintenance: () => Promise | unknown, + * }, + * lockProvider?: { withLock: (key: string, task: () => Promise) => Promise<{ acquired: boolean, result: unknown }> }, + * emitMetric?: (metricName: string, payload: Record) => void, + * maxRetries?: number, + * retryDelayMs?: number, + * runImmediately?: boolean, + * random?: () => number, + * }} options + */ +export function startMemoryMaintenanceScheduler(options) { + const lockProvider = options.lockProvider ?? createInMemoryLockProvider(); + const maxRetries = Number.isFinite(options.maxRetries) ? Math.max(0, Math.floor(options.maxRetries)) : 2; + const retryDelayMs = Number.isFinite(options.retryDelayMs) ? Math.max(0, Math.floor(options.retryDelayMs)) : 1_000; + const random = typeof options.random === 'function' ? options.random : Math.random; + const handles = []; + const timeouts = new Set(); + + const runJob = async (jobName, definition, handler) => runScheduledJob({ + jobName, + lockKey: definition.lockKey, + handler, + lockProvider, + maxRetries, + retryDelayMs, + emitMetric: options.emitMetric, + }); + + const startFixedIntervalJob = (jobName, definition, handler) => { + const run = () => { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + return; + } + void runJob(jobName, definition, handler); + }; + + if (options.runImmediately !== false) run(); + const intervalHandle = setInterval(run, definition.intervalMs); + handles.push(() => clearInterval(intervalHandle)); + }; + + const startRandomIntervalJob = (jobName, definition, handler) => { + const scheduleNext = () => { + const jitterRatio = Math.min(1, Math.max(0, random())); + const delayMs = definition.minIntervalMs + Math.floor((definition.maxIntervalMs - definition.minIntervalMs) * jitterRatio); + const timeout = setTimeout(async () => { + timeouts.delete(timeout); + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } + await runJob(jobName, definition, handler); + scheduleNext(); + }, delayMs); + timeouts.add(timeout); + }; + + if (options.runImmediately !== false) { + if (!shouldRunJob(jobName, options)) { + options.emitMetric?.('memory_scheduler_job', { + job: jobName, + status: 'skipped_flag_disabled', + durationMs: 0, + itemCount: 0, + failures: 0, + retries: 0, + }); + scheduleNext(); + return; + } + void runJob(jobName, definition, handler).then(() => { + scheduleNext(); + }); + } else { + scheduleNext(); + } + }; + + startRandomIntervalJob('ingestRecentRuntimeEvents', JOBS.ingestRecentRuntimeEvents, options.handlers.ingestRecentRuntimeEvents); + startFixedIntervalJob('consolidateObservations', JOBS.consolidateObservations, options.handlers.consolidateObservations); + startFixedIntervalJob('pruningCycle', JOBS.pruningCycle, options.handlers.pruningCycle); + startFixedIntervalJob('deepMergeArchivalMaintenance', JOBS.deepMergeArchivalMaintenance, options.handlers.deepMergeArchivalMaintenance); + + return { + stop() { + for (const stopHandle of handles) { + stopHandle(); + } + for (const timeout of timeouts) { + clearTimeout(timeout); + } + timeouts.clear(); + }, + }; +} diff --git a/torch/src/services/memory/schema.js b/torch/src/services/memory/schema.js new file mode 100644 index 00000000..ef374aaf --- /dev/null +++ b/torch/src/services/memory/schema.js @@ -0,0 +1,136 @@ +/** + * @typedef {Object} MemoryEvent + * @property {string} agent_id + * @property {string} content + * @property {number} [timestamp] + * @property {string[]} [tags] + * @property {Record} [metadata] + */ + +export const SCHEMA_VERSION = 1; + +/** + * @typedef {Object} MemoryRecord + * @property {number} schema_version + * @property {string} id + * @property {string} agent_id + * @property {string} session_id + * @property {string} type + * @property {string} content + * @property {string} summary + * @property {string[]} tags + * @property {number} importance + * @property {string | null} embedding_id + * @property {number} created_at + * @property {number} last_seen + * @property {string} source + * @property {number | null} ttl_days + * @property {string | null} merged_into + * @property {boolean} pinned + */ + +/** + * @param {MemoryEvent} event + * @returns {MemoryEvent} + */ +export function normalizeEvent(event) { + return { + ...event, + timestamp: Number.isFinite(event?.timestamp) ? Number(event.timestamp) : Date.now(), + tags: Array.isArray(event?.tags) + ? event.tags.map((tag) => String(tag)).filter((tag) => tag.length > 0) + : [], + metadata: event?.metadata && typeof event.metadata === 'object' ? event.metadata : {}, + }; +} + +/** + * @param {Partial} input + * @returns {MemoryRecord} + */ +import crypto from 'node:crypto'; + +export function normalizeMemoryItem(input = {}) { + const now = Date.now(); + + return { + schema_version: SCHEMA_VERSION, + id: typeof input.id === 'string' && input.id.trim() ? input.id.trim() : crypto.randomUUID(), + agent_id: typeof input.agent_id === 'string' ? input.agent_id.trim() : '', + session_id: typeof input.session_id === 'string' ? input.session_id.trim() : 'unknown', + type: typeof input.type === 'string' && input.type.trim() ? input.type.trim() : 'event', + content: typeof input.content === 'string' ? input.content : '', + summary: typeof input.summary === 'string' ? input.summary : '', + tags: Array.isArray(input.tags) + ? input.tags.map((tag) => String(tag).trim()).filter((tag) => tag.length > 0) + : [], + importance: Number.isFinite(input.importance) + ? Math.max(0, Math.min(1, Number(input.importance))) + : 0.5, + embedding_id: typeof input.embedding_id === 'string' && input.embedding_id.trim() + ? input.embedding_id.trim() + : null, + created_at: Number.isFinite(input.created_at) ? Number(input.created_at) : now, + last_seen: Number.isFinite(input.last_seen) ? Number(input.last_seen) : now, + source: typeof input.source === 'string' && input.source.trim() ? input.source.trim() : 'ingest', + ttl_days: Number.isFinite(input.ttl_days) ? Math.max(0, Math.floor(Number(input.ttl_days))) : null, + merged_into: typeof input.merged_into === 'string' && input.merged_into.trim() + ? input.merged_into.trim() + : null, + pinned: Boolean(input.pinned), + }; +} + +/** + * @param {unknown} item + * @returns {{ valid: boolean, errors: Array<{ field: string, expected: string, received: string, value: unknown }> }} + */ +export function validateMemoryItem(item) { + const errors = []; + + const check = (condition, field, expected, value) => { + if (!condition) { + errors.push({ + field, + expected, + received: Array.isArray(value) ? 'array' : typeof value, + value, + }); + } + }; + + check(item && typeof item === 'object', 'item', 'object', item); + if (!item || typeof item !== 'object') { + return { valid: false, errors }; + } + + check(item.schema_version === SCHEMA_VERSION, 'schema_version', `number (${SCHEMA_VERSION})`, item.schema_version); + check(typeof item.id === 'string' && item.id.length > 0, 'id', 'non-empty string', item.id); + check(typeof item.agent_id === 'string' && item.agent_id.length > 0, 'agent_id', 'non-empty string', item.agent_id); + check(typeof item.session_id === 'string' && item.session_id.length > 0, 'session_id', 'non-empty string', item.session_id); + check(typeof item.type === 'string' && item.type.length > 0, 'type', 'non-empty string', item.type); + check(typeof item.content === 'string', 'content', 'string', item.content); + check(typeof item.summary === 'string', 'summary', 'string', item.summary); + check(Array.isArray(item.tags) && item.tags.every((tag) => typeof tag === 'string'), 'tags', 'string[]', item.tags); + check(Number.isFinite(item.importance) && item.importance >= 0 && item.importance <= 1, 'importance', 'number between 0 and 1', item.importance); + check(item.embedding_id === null || (typeof item.embedding_id === 'string' && item.embedding_id.length > 0), 'embedding_id', 'null or non-empty string', item.embedding_id); + check(Number.isFinite(item.created_at), 'created_at', 'finite number', item.created_at); + check(Number.isFinite(item.last_seen), 'last_seen', 'finite number', item.last_seen); + check(typeof item.source === 'string' && item.source.length > 0, 'source', 'non-empty string', item.source); + check(item.ttl_days === null || (Number.isInteger(item.ttl_days) && item.ttl_days >= 0), 'ttl_days', 'null or non-negative integer', item.ttl_days); + check(item.merged_into === null || (typeof item.merged_into === 'string' && item.merged_into.length > 0), 'merged_into', 'null or non-empty string', item.merged_into); + check(typeof item.pinned === 'boolean', 'pinned', 'boolean', item.pinned); + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * @param {Partial} record + * @returns {MemoryRecord} + */ +export function createMemoryRecord(record) { + return normalizeMemoryItem(record); +} diff --git a/torch/src/services/memory/summarizer.js b/torch/src/services/memory/summarizer.js new file mode 100644 index 00000000..0f128509 --- /dev/null +++ b/torch/src/services/memory/summarizer.js @@ -0,0 +1,151 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const DEFAULT_TEMPLATE_DIR = path.resolve(__dirname, '../../prompts/memory'); +const TEMPLATE_FILES = { + summarize: 'summarize_prompt.txt', + condense: 'condense_prompt.txt', + score: 'score_prompt.txt', + prune: 'prune_prompt.txt', +}; + +const templateCache = new Map(); + +function redactObviousPii(text) { + if (typeof text !== 'string' || text.length === 0) return ''; + return text + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[redacted:email]') + .replace(/\b(?:\+?\d{1,2}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4})\b/g, '[redacted:phone]') + .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[redacted:ssn]'); +} + +function renderTemplate(template, replacements) { + return Object.entries(replacements).reduce( + (output, [key, value]) => output.replaceAll(`{{${key}}}`, String(value ?? '')), + template + ); +} + +export function loadMemoryPromptTemplates(options = {}) { + const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR; + if (templateCache.has(templateDir)) { + return templateCache.get(templateDir); + } + + const templates = Object.fromEntries( + Object.entries(TEMPLATE_FILES).map(([key, fileName]) => { + const filePath = path.join(templateDir, fileName); + const content = readFileSync(filePath, 'utf8').trim(); + return [key, content]; + }) + ); + + templateCache.set(templateDir, templates); + return templates; +} + +function parseSummaryPayload(rawResponse) { + const parsed = JSON.parse(rawResponse); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new TypeError('Response must be a JSON object'); + } + + const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; + if (!summary) { + throw new TypeError('Response.summary must be a non-empty string'); + } + + const rawImportance = Number(parsed.importance); + const importance = Number.isFinite(rawImportance) + ? Math.min(1, Math.max(0, rawImportance)) + : 0.25; + + return { summary, importance }; +} + +function deterministicFallback(events, maxSummaryLength) { + const joined = events.map((event) => redactObviousPii(event.content)).join(' ').trim(); + const bounded = joined.length > maxSummaryLength ? `${joined.slice(0, maxSummaryLength - 1)}…` : joined; + return { summary: bounded, importance: 0.25 }; +} + +function isLikelyFactual(summary, sourceText) { + const sourceTokens = new Set( + sourceText + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 3) + ); + const summaryTokens = summary + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 3); + + if (!summaryTokens.length) return true; + const overlap = summaryTokens.filter((token) => sourceTokens.has(token)).length; + return overlap / summaryTokens.length >= 0.35; +} + +function sanitizeResult(parsed, sourceText, maxSummaryLength, fallback) { + const sanitizedSummary = redactObviousPii(parsed.summary); + if (!isLikelyFactual(sanitizedSummary, sourceText)) { + return fallback; + } + + return { + summary: sanitizedSummary.length > maxSummaryLength + ? `${sanitizedSummary.slice(0, maxSummaryLength - 1)}…` + : sanitizedSummary, + importance: parsed.importance, + }; +} + +/** + * @param {import('./schema.js').MemoryEvent[]} events + * @param {{ + * maxSummaryLength?: number, + * generateSummary?: (prompt: string) => Promise | string, + * templateDir?: string, + * }} [options] + * @returns {Promise<{ summary: string, importance: number }>} + */ +export async function summarizeEvents(events, options = {}) { + const maxSummaryLength = options.maxSummaryLength ?? 280; + const fallback = deterministicFallback(events, maxSummaryLength); + if (!fallback.summary) return fallback; + + const templates = loadMemoryPromptTemplates(options); + const sourceText = events.map((event) => redactObviousPii(event.content)).join('\n').trim(); + const generateSummary = options.generateSummary; + + if (typeof generateSummary !== 'function') { + return fallback; + } + + const summaryPrompt = renderTemplate(templates.summarize, { + EVENTS: sourceText, + MAX_SUMMARY_LENGTH: maxSummaryLength, + }); + + let firstResponse = ''; + try { + firstResponse = await generateSummary(summaryPrompt); + const parsed = parseSummaryPayload(firstResponse); + return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback); + } catch (firstError) { + try { + const repairPrompt = renderTemplate(templates.condense, { + RAW_RESPONSE: firstResponse, + PARSE_ERROR: firstError instanceof Error ? firstError.message : 'unknown_parse_error', + }); + const repairedResponse = await generateSummary(repairPrompt); + const parsed = parseSummaryPayload(repairedResponse); + return sanitizeResult(parsed, sourceText, maxSummaryLength, fallback); + } catch { + return fallback; + } + } +} diff --git a/torch/src/todo/2026-02-13-dom-migration-todo.md b/torch/src/todo/2026-02-13-dom-migration-todo.md new file mode 100644 index 00000000..8d38192e --- /dev/null +++ b/torch/src/todo/2026-02-13-dom-migration-todo.md @@ -0,0 +1,12 @@ +# TODO — Rendering Migration (Example) + +## Pending +- [ ] Replace remaining string-based rendering paths. +- [ ] Add safe helper utilities where needed. +- [ ] Update related tests. + +## Completed +- [x] Audited current rendering entry points. + +## Blocked +- [ ] Confirm final acceptance criteria with maintainers. diff --git a/torch/src/todo/TODO_2026-02-14.md b/torch/src/todo/TODO_2026-02-14.md new file mode 100644 index 00000000..5be3b018 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14.md @@ -0,0 +1,11 @@ +# TODO Checklist + +- [ ] Update `src/constants.mjs` with defaults. +- [ ] Update `src/torch-config.mjs` with config getters. +- [ ] Create `src/roster.mjs` with roster logic. +- [ ] Create `src/lock-ops.mjs` with lock operations. +- [ ] Create `src/dashboard.mjs` with dashboard logic. +- [ ] Update `src/lib.mjs` to import new modules. +- [ ] Verify `npm run lint` passes. +- [ ] Verify `npm test` passes. +- [ ] Update baseline using `scripts/check-file-size.mjs --update`. diff --git a/torch/src/todo/TODO_2026-02-14T04-53-53Z.md b/torch/src/todo/TODO_2026-02-14T04-53-53Z.md new file mode 100644 index 00000000..1f600ada --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T04-53-53Z.md @@ -0,0 +1 @@ +# Todo diff --git a/torch/src/todo/TODO_2026-02-14T06-04-19Z.md b/torch/src/todo/TODO_2026-02-14T06-04-19Z.md new file mode 100644 index 00000000..b882ef9c --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T06-04-19Z.md @@ -0,0 +1,6 @@ +# Audit Run TODO + +- [ ] Run check-file-size.mjs +- [ ] Run check-innerhtml.mjs +- [ ] Run npm run lint +- [ ] Create summary diff --git a/torch/src/todo/TODO_2026-02-14T13-01-37Z.md b/torch/src/todo/TODO_2026-02-14T13-01-37Z.md new file mode 100644 index 00000000..8bb31aac --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T13-01-37Z.md @@ -0,0 +1,5 @@ +# Todo: Content Audit 2026-02-14T13-01-37Z + +- [ ] Check if /content directory exists +- [ ] Inventory claims if content exists +- [ ] Verify claims if content exists diff --git a/torch/src/todo/TODO_2026-02-14T13-30-00Z.md b/torch/src/todo/TODO_2026-02-14T13-30-00Z.md new file mode 100644 index 00000000..01527ef8 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T13-30-00Z.md @@ -0,0 +1,11 @@ +# Decomposition TODO + +- [x] Create `src/errors.mjs` with `ExitError`. +- [x] Create `src/utils.mjs` with `todayDateStr`, `nowUnix`. +- [x] Create `src/cmd-check.mjs`. +- [x] Create `src/cmd-lock.mjs`. +- [x] Create `src/cmd-list.mjs`. +- [x] Update `src/lib.mjs` to import new modules. +- [x] Verify with lint. +- [ ] Verify with tests. +- [ ] Update file size baseline. diff --git a/torch/src/todo/TODO_2026-02-14T23-00-00Z.md b/torch/src/todo/TODO_2026-02-14T23-00-00Z.md new file mode 100644 index 00000000..542d8427 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-14T23-00-00Z.md @@ -0,0 +1,10 @@ +# TODO: dashboard/index.html innerHTML Migration + +- [ ] `summaryBar.innerHTML = cards.map(...).join('')` (Line ~436) + - Strategy: Replace with `document.createElement` loop and `replaceChildren`. +- [ ] `lockGrid.innerHTML = ''` (Line ~448) + - Strategy: `lockGrid.replaceChildren()` + `appendChild(emptyState)`. +- [ ] `lockGrid.innerHTML = locks.map(...).join('')` (Line ~455) + - Strategy: Create `createLockCard` function using `document.createElement`, then `lockGrid.replaceChildren(...cards)`. +- [ ] `rawLog.innerHTML = ''` (Line ~534) + - Strategy: `rawLog.replaceChildren()`. diff --git a/torch/src/todo/TODO_2026-02-15.md b/torch/src/todo/TODO_2026-02-15.md new file mode 100644 index 00000000..bc05a2e4 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15.md @@ -0,0 +1,19 @@ +# Todo List (2026-02-15) - Protocol Research Agent + +- **agent**: protocol-research-agent +- **cadence**: daily +- **run-start**: 2026-02-15T00:00:00Z +- **prompt**: src/prompts/daily/protocol-research-agent.md + +## Pending Tasks +- [x] Scan for protocol references. +- [x] Create `PROTOCOL_INVENTORY.md`. +- [x] Create `protocol-report-2026-02-15.md`. + +## Completed Tasks +- Scanned repository for `nostr-tools` and `ws` (executed by `protocol-research-agent`). +- Created inventory of protocols. +- Generated protocol report. + +## Blocked Tasks +- None. diff --git a/torch/src/todo/TODO_2026-02-15T01-03-31Z.md b/torch/src/todo/TODO_2026-02-15T01-03-31Z.md new file mode 100644 index 00000000..999c23ca --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T01-03-31Z.md @@ -0,0 +1,14 @@ +--- +agent: onboarding-audit-agent +cadence: daily +run-start: 2026-02-15T01:03:31Z +prompt: src/prompts/daily/onboarding-audit-agent.md +--- + +# TODO Update - 2026-02-15T01:03:31Z + +Agent: onboarding-audit-agent +Prompt: src/prompts/daily/onboarding-audit-agent.md + +## Pending Tasks +- [ ] Fix prompt contract violations in `src/prompts/` files. diff --git a/torch/src/todo/TODO_2026-02-15T01-22-33Z.md b/torch/src/todo/TODO_2026-02-15T01-22-33Z.md new file mode 100644 index 00000000..cb0d27cb --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T01-22-33Z.md @@ -0,0 +1,5 @@ +# TODO for Perf Agent Run 2026-02-15T01-22-33Z + +- [x] Scan codebase +- [x] Generate report +- [ ] Fix critical issues (none identified yet) diff --git a/torch/src/todo/TODO_2026-02-15T05-06-49Z.md b/torch/src/todo/TODO_2026-02-15T05-06-49Z.md new file mode 100644 index 00000000..59828dbd --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T05-06-49Z.md @@ -0,0 +1,18 @@ +--- +agent: prompt-fixer-agent +cadence: weekly +run-start: 2026-02-15T05:03:00Z +prompt: src/prompts/weekly/prompt-fixer-agent.md +--- +# Todo - Prompt Safety Fixes + +## Completed Tasks +- [x] Read `artifacts/prompt-safety-audit.md` +- [x] Identify agents needing fixes (24 total) +- [x] Create `fix_prompts.py` script to automate fixes +- [x] Apply fixes to 23 agents via script +- [x] Manually fix `protocol-research-agent.md` (no-op logic insertion) +- [x] Verify changes (checked `const-refactor-agent.md` and script output) +- [x] Generate run artifacts +- [ ] Run lint checks +- [ ] Complete lock diff --git a/torch/src/todo/TODO_2026-02-15T08-00-00Z.md b/torch/src/todo/TODO_2026-02-15T08-00-00Z.md new file mode 100644 index 00000000..1f600ada --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T08-00-00Z.md @@ -0,0 +1 @@ +# Todo diff --git a/torch/src/todo/TODO_2026-02-15T15-22-42Z.md b/torch/src/todo/TODO_2026-02-15T15-22-42Z.md new file mode 100644 index 00000000..bc7f30c4 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T15-22-42Z.md @@ -0,0 +1,17 @@ +--- +agent: race-condition-agent +cadence: weekly +run-start: 2026-02-15T15:22:42Z +prompt: src/prompts/weekly/race-condition-agent.md +--- +## Completed Tasks +- [x] Audit lock logic. +- [x] Implement fix in `src/lib.mjs`. +- [x] Add regression test in `test/lib.test.mjs`. +- [x] Verify tests pass. +- [x] Create weekly report. +- [x] Complete memory storage. + +## Pending Tasks +- [ ] Verify artifacts. +- [ ] Complete lock. diff --git a/torch/src/todo/TODO_2026-02-15T16-15-05Z.md b/torch/src/todo/TODO_2026-02-15T16-15-05Z.md new file mode 100644 index 00000000..f8957791 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T16-15-05Z.md @@ -0,0 +1,15 @@ +--- +agent: test-audit-agent +cadence: daily +run-start: 2026-02-15T16-15-05Z +prompt: src/prompts/daily/test-audit-agent.md +--- + +# Pending tasks +- [ ] Run tests with coverage +- [ ] Detect flakiness +- [ ] Run static analysis +- [ ] Generate report + +# Completed tasks +- [x] Initialize artifacts diff --git a/torch/src/todo/TODO_2026-02-15T18-00-00Z.md b/torch/src/todo/TODO_2026-02-15T18-00-00Z.md new file mode 100644 index 00000000..ade295c5 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T18-00-00Z.md @@ -0,0 +1,16 @@ +--- +agent: todo-triage-agent +cadence: daily +run-start: 2026-02-15T18:00:00Z +prompt: src/prompts/daily/todo-triage-agent.md +--- + +# Todo + +## Pending tasks +- None (Scan complete, no actionable items found). + +## Completed tasks +- Scan repository for TODOs. +- Generate inventory `artifacts/todos.txt`. +- Analyze findings (0 actionable items). diff --git a/torch/src/todo/TODO_2026-02-15T18-51-10Z.md b/torch/src/todo/TODO_2026-02-15T18-51-10Z.md new file mode 100644 index 00000000..c14e1d68 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T18-51-10Z.md @@ -0,0 +1,12 @@ +--- +agent: audit-agent +cadence: daily +run-start: 2026-02-15T18-51-10Z +prompt: src/prompts/daily/audit-agent.md +--- +# Pending Tasks + +- [ ] Run file size check +- [ ] Run innerHTML check +- [ ] Run lint check +- [ ] Synthesize summary diff --git a/torch/src/todo/TODO_2026-02-15T19-12-02Z.md b/torch/src/todo/TODO_2026-02-15T19-12-02Z.md new file mode 100644 index 00000000..9817903a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T19-12-02Z.md @@ -0,0 +1,14 @@ +--- +agent: ci-health-agent +cadence: daily +run-start: 2026-02-15T19-12-02Z +prompt: src/prompts/daily/ci-health-agent.md +--- + +# Todo for ci-health-agent +## Pending +- None +## Completed +- Run tests (5 loops) +- Run lint +Prompt: src/prompts/daily/ci-health-agent.md diff --git a/torch/src/todo/TODO_2026-02-15T20-00-00Z.md b/torch/src/todo/TODO_2026-02-15T20-00-00Z.md new file mode 100644 index 00000000..b1182bc1 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T20-00-00Z.md @@ -0,0 +1,2 @@ +# Todo +- [x] Replace 30078 with KIND_APP_DATA in src/cmd-lock.mjs diff --git a/torch/src/todo/TODO_2026-02-15T23-20-19Z.md b/torch/src/todo/TODO_2026-02-15T23-20-19Z.md new file mode 100644 index 00000000..401c0e3a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-15T23-20-19Z.md @@ -0,0 +1,16 @@ +--- +agent: decompose-agent +cadence: daily +run-start: 2026-02-15T23:20:19Z +prompt: src/prompts/daily/decompose-agent.md +--- + +# Pending tasks +- [ ] Run `scripts/check-file-size.mjs --report` +- [ ] Select largest file +- [ ] Verify recent changes via git log +- [ ] Plan decomposition blocks +- [ ] Create extraction plan + +# Completed tasks +- [x] Initialized artifacts diff --git a/torch/src/todo/TODO_2026-02-16.md b/torch/src/todo/TODO_2026-02-16.md new file mode 100644 index 00000000..f23f62f6 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16.md @@ -0,0 +1,10 @@ +# TODO: perf-agent (2026-02-16) + +- [ ] Create context file. +- [ ] Run grep searches. +- [ ] Create hits file. +- [ ] Implement visibility gating in `dashboard/index.html`. +- [ ] Document decisions. +- [ ] Create test logs. +- [ ] Create daily report. +- [ ] Commit and push. diff --git a/torch/src/todo/TODO_2026-02-16T01-18-43Z.md b/torch/src/todo/TODO_2026-02-16T01-18-43Z.md new file mode 100644 index 00000000..06999e7a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16T01-18-43Z.md @@ -0,0 +1,13 @@ +# Todo + +**Agent**: docs-alignment-agent +**Cadence**: daily +**Session**: session-1771204722 +**Run-Start**: 2026-02-16T01-18-43Z +**Prompt**: src/prompts/daily/docs-alignment-agent.md + +## Pending Tasks +- [ ] Update other docs + +## Completed Tasks +- [x] Check README.md vs package.json diff --git a/torch/src/todo/TODO_2026-02-16T02-11-24Z.md b/torch/src/todo/TODO_2026-02-16T02-11-24Z.md new file mode 100644 index 00000000..55b2a199 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16T02-11-24Z.md @@ -0,0 +1,12 @@ +# Todo +- Agent: docs-code-investigator +- Cadence: daily +- Run-Start: 2026-02-16T02:11:24Z +- Prompt Path: src/prompts/daily/docs-code-investigator.md + +## Pending Tasks +- [ ] [pending] Review `src/lock-ops.mjs` + +## Completed Tasks +- [x] [completed] Select candidate file (`src/lock-ops.mjs`) +- [x] [completed] Create overview document diff --git a/torch/src/todo/TODO_2026-02-16T11-47-38Z.md b/torch/src/todo/TODO_2026-02-16T11-47-38Z.md new file mode 100644 index 00000000..79240ef3 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-16T11-47-38Z.md @@ -0,0 +1,5 @@ +# TODO: bug-reproducer-agent run 2026-02-16T11:47:38Z + +- Agent: bug-reproducer-agent +- Status: Completed +- Resolution: Fixed XSS vulnerability in docs/assets/theme.js diff --git a/torch/src/todo/TODO_2026-02-17T00-00-00Z.md b/torch/src/todo/TODO_2026-02-17T00-00-00Z.md new file mode 100644 index 00000000..d349d292 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T00-00-00Z.md @@ -0,0 +1,15 @@ +--- +agent: perf-agent +cadence: daily +run-start: 2026-02-17T00-00-00Z +prompt: src/prompts/daily/perf-agent.md +--- +# Tasks + +## Pending Tasks +- [x] Analyze search results + +## Completed Tasks +- [x] Run search patterns + +References: src/prompts/daily/perf-agent.md diff --git a/torch/src/todo/TODO_2026-02-17T02-04-05Z.md b/torch/src/todo/TODO_2026-02-17T02-04-05Z.md new file mode 100644 index 00000000..8764d20d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T02-04-05Z.md @@ -0,0 +1,5 @@ +# TODO for Scheduler Update Agent + +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_2026-02-17T02-04-31Z.md b/torch/src/todo/TODO_2026-02-17T02-04-31Z.md new file mode 100644 index 00000000..491cee4f --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T02-04-31Z.md @@ -0,0 +1,10 @@ +# TODO for Scheduler Update Agent + +**Agent**: scheduler-update-agent +**Cadence**: daily +**Session**: manual-jules-1771293773 +Prompt: src/prompts/daily/scheduler-update-agent.md + +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_2026-02-17T05-04-24Z.md b/torch/src/todo/TODO_2026-02-17T05-04-24Z.md new file mode 100644 index 00000000..a7cb26f3 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T05-04-24Z.md @@ -0,0 +1,15 @@ +# Todo +- Agent: changelog-agent +- Cadence: weekly +- Session: 2026-02-17T05-04-24Z +- Prompt Path: src/prompts/weekly/changelog-agent.md + +## Pending Tasks +- [x] Read git log +- [x] Classify changes +- [x] Create release draft +- [x] Create memory update + +## Completed Tasks +- Generated changelog draft +- Updated artifacts diff --git a/torch/src/todo/TODO_2026-02-17T15-00-00Z.md b/torch/src/todo/TODO_2026-02-17T15-00-00Z.md new file mode 100644 index 00000000..a26e55a8 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T15-00-00Z.md @@ -0,0 +1,17 @@ +# Todo - Feature Proposer Agent Run + +**Agent**: feature-proposer-agent +**Cadence**: weekly +**Run-Start**: 2026-02-17T15:00:00Z +**Prompt-Path**: src/prompts/weekly/feature-proposer-agent.md + +## Completed +- [x] Analyze repository for potential features. +- [x] Select `log-summary.mjs` as the target feature. +- [x] Create `features/` directory. +- [x] Implement `features/log-summary.mjs`. +- [x] Verify the script functionality. +- [x] Follow instructions in `src/prompts/weekly/feature-proposer-agent.md`. + +## Pending +- [ ] Incorporate into main CLI (future work). diff --git a/torch/src/todo/TODO_2026-02-17T16-37-11Z.md b/torch/src/todo/TODO_2026-02-17T16-37-11Z.md new file mode 100644 index 00000000..d0e1bbfb --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T16-37-11Z.md @@ -0,0 +1,13 @@ +--- +agent: torch-garbage-collection-agent +cadence: daily +session: 2026-02-17T16-37-11Z +run-start: 2026-02-17T16-37-11Z +--- +# TODO for torch-garbage-collection-agent + +Active prompt: src/prompts/daily/torch-garbage-collection-agent.md + +## Completed +- [x] Scan for stale files +- [x] Verify no stale files exist diff --git a/torch/src/todo/TODO_2026-02-17T18-04-00Z.md b/torch/src/todo/TODO_2026-02-17T18-04-00Z.md new file mode 100644 index 00000000..dfa09b8e --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T18-04-00Z.md @@ -0,0 +1,18 @@ +# Todo + + + +## Pending tasks +- [ ] Run memory retrieval +- [ ] Run audit scripts +- [ ] Parse results +- [ ] Generate summary +- [ ] Run memory storage + +## Completed tasks +- [x] Initialize artifacts +- [x] Run `src/prompts/daily/audit-agent.md` workflow diff --git a/torch/src/todo/TODO_2026-02-17T19-36-32Z.md b/torch/src/todo/TODO_2026-02-17T19-36-32Z.md new file mode 100644 index 00000000..c24aa23d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T19-36-32Z.md @@ -0,0 +1,10 @@ +# Pending Tasks +- [ ] Run `npm test` to identify flakes. +- [ ] Fix identified flakes. +- [ ] Document findings. + +- agent: ci-health-agent +- cadence: daily +- session: 2026-02-17T19-36-32Z +- run-start: 2026-02-17T19-36-32Z +- prompt-path: src/prompts/daily/ci-health-agent.md diff --git a/torch/src/todo/TODO_2026-02-17T19-43-02Z.md b/torch/src/todo/TODO_2026-02-17T19-43-02Z.md new file mode 100644 index 00000000..96de603a --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T19-43-02Z.md @@ -0,0 +1 @@ +- [x] Extract lock-ops constants to src/constants.mjs diff --git a/torch/src/todo/TODO_2026-02-17T21-29-25Z.md b/torch/src/todo/TODO_2026-02-17T21-29-25Z.md new file mode 100644 index 00000000..00b76424 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T21-29-25Z.md @@ -0,0 +1,13 @@ +--- +agent: content-audit-agent +cadence: daily +run-start: 2026-02-17T21:29:25Z +prompt-path: src/prompts/daily/content-audit-agent.md +--- + +# Audit Tasks + +## Completed Tasks +- [x] Check for existence of `/content` directory +- [x] If exists, inventory claims (Skipped - missing content) +- [x] If missing, report finding and exit (Done) diff --git a/torch/src/todo/TODO_2026-02-17T22-29-00Z.md b/torch/src/todo/TODO_2026-02-17T22-29-00Z.md new file mode 100644 index 00000000..4b931687 --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T22-29-00Z.md @@ -0,0 +1,20 @@ +# Todo: Decompose scripts/agent/run-scheduler-cycle.mjs + +- [ ] Extract `scripts/agent/scheduler-utils.mjs` + - [ ] `runCommand` + - [ ] `getSafeEnv` + - [ ] `sleep` + - [ ] `parseJsonFromOutput` + - [ ] `parseJsonEventsFromOutput` + - [ ] `redactSensitive` + - [ ] `excerptText` + - [ ] `parseNonNegativeInt` + - [ ] `parseBooleanFlag` + - [ ] `readJson` + - [ ] `normalizeStringList` +- [ ] Extract `scripts/agent/scheduler-lock.mjs` + - [ ] `acquireLockWithRetry` + - [ ] `summarizeLockFailureReasons` + - [ ] `classifyLockBackendError` + - [ ] `buildLockBackendRemediation` + - [ ] `runLockHealthPreflight` diff --git a/torch/src/todo/TODO_2026-02-17T23-13-04Z.md b/torch/src/todo/TODO_2026-02-17T23-13-04Z.md new file mode 100644 index 00000000..862a9b1b --- /dev/null +++ b/torch/src/todo/TODO_2026-02-17T23-13-04Z.md @@ -0,0 +1,11 @@ +# Tasks + +- Agent: deps-security-agent +- Cadence: daily +- Run Start: 2026-02-17T23-13-04Z +- Prompt: src/prompts/daily/deps-security-agent.md + +## Completed Tasks +- [x] Document `nostr-tools` update availability in report. +- [x] Document `eslint` vulnerabilities in report. +- [x] Create `artifacts/deps-report.md`. diff --git a/torch/src/todo/TODO_2026-02-18.md b/torch/src/todo/TODO_2026-02-18.md new file mode 100644 index 00000000..58b7ef1d --- /dev/null +++ b/torch/src/todo/TODO_2026-02-18.md @@ -0,0 +1,7 @@ +# Todo: Docs Agent Run 2026-02-18 + +- [ ] Create `AGENTS.md` defining repo-wide policies. +- [ ] Create `docs/event-schemas.md`. +- [ ] Create `docs/runtime-fallback.md`. +- [ ] Add `npm run format` script (e.g., using Prettier) to `package.json`. +- [ ] Investigate why `test:unit` is not standardized in `package.json`. diff --git a/torch/src/todo/TODO_daily_style-agent.md b/torch/src/todo/TODO_daily_style-agent.md new file mode 100644 index 00000000..3cd61042 --- /dev/null +++ b/torch/src/todo/TODO_daily_style-agent.md @@ -0,0 +1,16 @@ +--- +agent: style-agent +cadence: daily +run-start: 2026-02-15T08:00:00Z +prompt: src/prompts/daily/style-agent.md +--- + +# Todo: Style Agent Daily Run + +## Pending tasks + +## Completed tasks +- [x] Run `npm run lint` +- [x] Run `npm run lint -- --fix` (not needed) +- [x] Run `node scripts/check-innerhtml.mjs` +- [x] Document results in test log diff --git a/torch/src/todo/TODO_manual-jules-1771293773.md b/torch/src/todo/TODO_manual-jules-1771293773.md new file mode 100644 index 00000000..8e60593c --- /dev/null +++ b/torch/src/todo/TODO_manual-jules-1771293773.md @@ -0,0 +1,14 @@ +# TODO for Scheduler Update Agent + +**Agent**: scheduler-update-agent +**Cadence**: daily +**Session**: manual-jules-1771293773 +Prompt: src/prompts/daily/scheduler-update-agent.md + +## Pending Tasks +- None + +## Completed Tasks +- [x] Scan prompt directories +- [x] Update roster.json +- [x] Update scheduler markdown tables diff --git a/torch/src/todo/TODO_weekly-synthesis-agent.md b/torch/src/todo/TODO_weekly-synthesis-agent.md new file mode 100644 index 00000000..d17ad5ca --- /dev/null +++ b/torch/src/todo/TODO_weekly-synthesis-agent.md @@ -0,0 +1,10 @@ +# Todo: Weekly Synthesis Agent + +- [x] Acquire lock. +- [x] Retrieve memory. +- [x] Gather git logs. +- [x] Gather task logs. +- [x] Analyze data. +- [x] Generate weekly report. +- [ ] Store memory. +- [ ] Publish completion. diff --git a/torch/src/torch-config.mjs b/torch/src/torch-config.mjs new file mode 100644 index 00000000..a759051e --- /dev/null +++ b/torch/src/torch-config.mjs @@ -0,0 +1,311 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + DEFAULT_RELAYS, + DEFAULT_TTL, + DEFAULT_NAMESPACE, + DEFAULT_QUERY_TIMEOUT_MS, + DEFAULT_PUBLISH_TIMEOUT_MS, + DEFAULT_MIN_SUCCESSFUL_PUBLISHES, + DEFAULT_MIN_ACTIVE_RELAY_POOL, +} from './constants.mjs'; + +const DEFAULT_CONFIG_PATH = 'torch-config.json'; + +let cachedConfig = null; +const MIN_TIMEOUT_MS = 100; +const MAX_TIMEOUT_MS = 120_000; + +function parsePositiveInteger(value) { + if (!Number.isFinite(value) || value <= 0) return null; + return Math.floor(value); +} + +function parseStringList(value) { + if (!Array.isArray(value)) return null; + const parsed = value.map((item) => String(item).trim()).filter(Boolean); + return parsed.length > 0 ? parsed : null; +} + +function assertValidRelayUrl(relay, sourceLabel) { + let parsed; + try { + parsed = new URL(relay); + } catch { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (must be an absolute ws:// or wss:// URL)`); + } + if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') { + throw new Error(`Invalid relay URL in ${sourceLabel}: "${relay}" (protocol must be ws:// or wss://)`); + } +} + +function assertTimeoutInRange(value, sourceLabel) { + if (!Number.isInteger(value) || value < MIN_TIMEOUT_MS || value > MAX_TIMEOUT_MS) { + throw new Error( + `Invalid ${sourceLabel}: ${value} (must be an integer between ${MIN_TIMEOUT_MS} and ${MAX_TIMEOUT_MS} ms)`, + ); + } +} + +function assertPositiveCount(value, sourceLabel) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Invalid ${sourceLabel}: ${value} (must be a positive integer)`); + } +} + +function normalizeCadence(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'daily' || normalized === 'weekly' || normalized === 'all' ? normalized : fallback; +} + +function normalizeStatus(value, fallback) { + const normalized = String(value || '').trim().toLowerCase(); + return normalized === 'active' || normalized === 'all' ? normalized : fallback; +} + + +export function getTorchConfigPath() { + const explicitPath = (process.env.TORCH_CONFIG_PATH || '').trim(); + if (explicitPath) return path.resolve(process.cwd(), explicitPath); + + const localPath = path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + if (fs.existsSync(localPath)) return localPath; + + const parentPath = path.resolve(process.cwd(), '..', DEFAULT_CONFIG_PATH); + if (fs.existsSync(parentPath)) return parentPath; + + return localPath; +} + +export function parseTorchConfig(raw, configPath = null) { + const nostrLock = raw.nostrLock || {}; + const dashboard = raw.dashboard || {}; + const scheduler = raw.scheduler || {}; + const firstPromptByCadence = scheduler.firstPromptByCadence || {}; + const paused = scheduler.paused || {}; + + return { + configPath, + raw, + nostrLock: { + namespace: typeof nostrLock.namespace === 'string' ? nostrLock.namespace.trim() : null, + relays: parseStringList(nostrLock.relays), + relayFallbacks: parseStringList(nostrLock.relayFallbacks), + ttlSeconds: Number.isFinite(nostrLock.ttlSeconds) && nostrLock.ttlSeconds > 0 + ? Math.floor(nostrLock.ttlSeconds) + : null, + queryTimeoutMs: parsePositiveInteger(nostrLock.queryTimeoutMs), + publishTimeoutMs: parsePositiveInteger(nostrLock.publishTimeoutMs), + minSuccessfulRelayPublishes: parsePositiveInteger(nostrLock.minSuccessfulRelayPublishes), + minActiveRelayPool: parsePositiveInteger(nostrLock.minActiveRelayPool), + dailyRoster: parseStringList(nostrLock.dailyRoster), + weeklyRoster: parseStringList(nostrLock.weeklyRoster), + }, + dashboard: { + defaultCadenceView: normalizeCadence(dashboard.defaultCadenceView, 'daily'), + defaultStatusView: normalizeStatus(dashboard.defaultStatusView, 'active'), + relays: parseStringList(dashboard.relays), + namespace: typeof dashboard.namespace === 'string' ? dashboard.namespace.trim() : null, + hashtag: typeof dashboard.hashtag === 'string' ? dashboard.hashtag.trim() : null, + auth: typeof dashboard.auth === 'string' ? dashboard.auth.trim() : null, + }, + scheduler: { + firstPromptByCadence: { + daily: typeof firstPromptByCadence.daily === 'string' ? firstPromptByCadence.daily.trim() : null, + weekly: typeof firstPromptByCadence.weekly === 'string' ? firstPromptByCadence.weekly.trim() : null, + }, + paused: { + daily: parseStringList(paused.daily) || [], + weekly: parseStringList(paused.weekly) || [], + }, + }, + }; +} + +/** @internal */ +export function _resetTorchConfigCache() { + cachedConfig = null; +} + +export async function loadTorchConfig(fileSystem = fs) { + if (cachedConfig) return cachedConfig; + + const configPath = getTorchConfigPath(); + let raw = {}; + + try { + const content = await fileSystem.promises.readFile(configPath, 'utf8'); + raw = JSON.parse(content); + } catch (err) { + if (err.code !== 'ENOENT') { + throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err }); + } + // If file missing (ENOENT), we use empty object defaults + } + + cachedConfig = parseTorchConfig(raw, configPath); + validateLockBackendConfig(cachedConfig); + return cachedConfig; +} + +function validateLockBackendConfig(config) { + const relays = config.nostrLock.relays || []; + const relayFallbacks = config.nostrLock.relayFallbacks || []; + + for (const relay of relays) { + assertValidRelayUrl(relay, 'nostrLock.relays'); + } + for (const relay of relayFallbacks) { + assertValidRelayUrl(relay, 'nostrLock.relayFallbacks'); + } + + if (config.nostrLock.queryTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.queryTimeoutMs, 'nostrLock.queryTimeoutMs'); + } + if (config.nostrLock.publishTimeoutMs !== null) { + assertTimeoutInRange(config.nostrLock.publishTimeoutMs, 'nostrLock.publishTimeoutMs'); + } + if (config.nostrLock.minSuccessfulRelayPublishes !== null) { + assertPositiveCount(config.nostrLock.minSuccessfulRelayPublishes, 'nostrLock.minSuccessfulRelayPublishes'); + } + if (config.nostrLock.minActiveRelayPool !== null) { + assertPositiveCount(config.nostrLock.minActiveRelayPool, 'nostrLock.minActiveRelayPool'); + } +} + +function parseEnvRelayList(envValue, envName) { + const relays = envValue.split(',').map((r) => r.trim()).filter(Boolean); + for (const relay of relays) { + assertValidRelayUrl(relay, envName); + } + return relays; +} + +function parseEnvInteger(envValue, envName) { + const parsed = parseInt(envValue, 10); + if (Number.isNaN(parsed)) { + throw new Error(`Invalid ${envName}: "${envValue}" (must be an integer)`); + } + return parsed; +} + +function resolveIntegerConfig(envKey, configValue, defaultValue, validator, configLabel) { + const envValue = process.env[envKey]; + if (envValue) { + const parsed = parseEnvInteger(envValue, envKey); + validator(parsed, envKey); + return parsed; + } + const value = configValue || defaultValue; + validator(value, `effective ${configLabel}`); + return value; +} + +export async function getRelays() { + const config = await loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAYS; + if (envRelays) { + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAYS'); + } + if (config.nostrLock.relays?.length) { + return config.nostrLock.relays; + } + for (const relay of DEFAULT_RELAYS) { + assertValidRelayUrl(relay, 'DEFAULT_RELAYS'); + } + return DEFAULT_RELAYS; +} + +export async function getRelayFallbacks() { + const config = await loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAY_FALLBACKS; + if (envRelays) { + return parseEnvRelayList(envRelays, 'NOSTR_LOCK_RELAY_FALLBACKS'); + } + return config.nostrLock.relayFallbacks || []; +} + +export async function getNamespace() { + const config = await loadTorchConfig(); + const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); + return namespace || DEFAULT_NAMESPACE; +} + +export async function getTtl() { + const config = await loadTorchConfig(); + const envTtl = process.env.NOSTR_LOCK_TTL; + if (envTtl) { + const parsed = parseInt(envTtl, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + if (config.nostrLock.ttlSeconds) { + return config.nostrLock.ttlSeconds; + } + return DEFAULT_TTL; +} + +export async function getQueryTimeoutMs() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_QUERY_TIMEOUT_MS', + config.nostrLock.queryTimeoutMs, + DEFAULT_QUERY_TIMEOUT_MS, + assertTimeoutInRange, + 'query timeout', + ); +} + +export async function getPublishTimeoutMs() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_PUBLISH_TIMEOUT_MS', + config.nostrLock.publishTimeoutMs, + DEFAULT_PUBLISH_TIMEOUT_MS, + assertTimeoutInRange, + 'publish timeout', + ); +} + +export async function getMinSuccessfulRelayPublishes() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_MIN_SUCCESSFUL_PUBLISHES', + config.nostrLock.minSuccessfulRelayPublishes, + DEFAULT_MIN_SUCCESSFUL_PUBLISHES, + assertPositiveCount, + 'min successful relay publishes', + ); +} + +export async function getMinActiveRelayPool() { + const config = await loadTorchConfig(); + return resolveIntegerConfig( + 'NOSTR_LOCK_MIN_ACTIVE_RELAY_POOL', + config.nostrLock.minActiveRelayPool, + DEFAULT_MIN_ACTIVE_RELAY_POOL, + assertPositiveCount, + 'min active relay pool', + ); +} + +export async function getHashtag() { + const config = await loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_HASHTAG; + if (envValue) { + return envValue.trim(); + } + if (config.dashboard.hashtag) { + return config.dashboard.hashtag; + } + const namespace = await getNamespace(); + return `${namespace}-agent-lock`; +} + +export async function getDashboardAuth() { + const config = await loadTorchConfig(); + const envValue = process.env.TORCH_DASHBOARD_AUTH; + if (envValue) { + return envValue.trim(); + } + return config.dashboard.auth; +} diff --git a/torch/src/utils.mjs b/torch/src/utils.mjs new file mode 100644 index 00000000..4b7c76c7 --- /dev/null +++ b/torch/src/utils.mjs @@ -0,0 +1,36 @@ +export function todayDateStr() { + return new Date().toISOString().slice(0, 10); +} + +export function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +export function getIsoWeekStr(dateInput) { + // Parse input to ensure we work with a Date object + // If dateInput is YYYY-MM-DD string, new Date() treats it as UTC. + const d = dateInput ? new Date(dateInput) : new Date(); + + // If valid date, use UTC components to avoid local timezone issues + if (isNaN(d.getTime())) return ''; + + // Copy date so we don't mutate the original if passed by reference (though new Date() handles that) + const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + + // ISO week date system: Thursday determines the year + // day: 0 (Sun) -> 6 (Sat). We want 1 (Mon) -> 7 (Sun) + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() + 4 - day); + + const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((date - yearStart) / 86400000) + 1) / 7); + + return `${date.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; +} + +export function detectPlatform() { + if (process.env.JULES_API_KEY || process.env.JULES_SESSION_ID) return 'jules'; + if (process.env.CODEX_API_KEY || process.env.CODEX_SESSION_ID) return 'codex'; + if (process.env.GOOSE_API_KEY || process.env.GOOSE_SESSION_ID) return 'goose'; + return null; +} diff --git a/torch/task-logs/daily/2026-02-17T16-56-00Z__ci-health-agent__failed.md b/torch/task-logs/daily/2026-02-17T16-56-00Z__ci-health-agent__failed.md new file mode 100644 index 00000000..1c024b3a --- /dev/null +++ b/torch/task-logs/daily/2026-02-17T16-56-00Z__ci-health-agent__failed.md @@ -0,0 +1,10 @@ +# CI Health Agent Check Failed + +The repository checks failed during the CI run. + +## Check Failures + +1. **flake8**: Found numerous style violations (E501 line too long, unused imports, etc.). +2. **pytest**: Failed to start due to configuration error (`unrecognized arguments: -n`). + +This indicates the codebase is currently unhealthy or the environment is misconfigured. diff --git a/torch/task-logs/daily/2026-02-17T17-37-50Z__ci-health-agent__failed.md b/torch/task-logs/daily/2026-02-17T17-37-50Z__ci-health-agent__failed.md new file mode 100644 index 00000000..1cde8404 --- /dev/null +++ b/torch/task-logs/daily/2026-02-17T17-37-50Z__ci-health-agent__failed.md @@ -0,0 +1,3262 @@ +# CI Health Agent Check Failed + +The repository checks failed during the CI run. + +## Check Failures + +1. **flake8**: Failed (exit code 1). + - Found style violations (E402, E501, etc.). +2. **pytest**: Passed (exit code 0). + - Tests passed successfully. + +## Details + +### flake8 Output +``` +./scripts/generate_test_profile.py:32:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:34:80: E501 line too long (80 > 79 characters) +./scripts/generate_test_profile.py:39:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:40:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:41:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:42:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:43:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:44:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:45:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:46:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:47:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:48:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:49:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:50:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:51:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:52:1: E402 module level import not at top of file +./scripts/generate_test_profile.py:60:80: E501 line too long (98 > 79 characters) +./scripts/generate_test_profile.py:107:80: E501 line too long (80 > 79 characters) +./scripts/generate_test_profile.py:139:80: E501 line too long (84 > 79 characters) +./scripts/generate_test_profile.py:141:80: E501 line too long (88 > 79 characters) +./scripts/generate_test_profile.py:172:80: E501 line too long (82 > 79 characters) +./scripts/generate_test_profile.py:189:80: E501 line too long (86 > 79 characters) +./scripts/generate_test_profile.py:218:80: E501 line too long (87 > 79 characters) +./scripts/generate_test_profile.py:221:80: E501 line too long (86 > 79 characters) +./scripts/update_checksum.py:10:1: E402 module level import not at top of file +./scripts/update_checksum.py:11:1: E402 module level import not at top of file +./scripts/update_checksum.py:15:80: E501 line too long (86 > 79 characters) +./src/constants.py:39:80: E501 line too long (86 > 79 characters) +./src/local_bip85/__init__.py:12:80: E501 line too long (94 > 79 characters) +./src/local_bip85/bip85.py:6:80: E501 line too long (97 > 79 characters) +./src/local_bip85/bip85.py:7:80: E501 line too long (101 > 79 characters) +./src/local_bip85/bip85.py:8:80: E501 line too long (100 > 79 characters) +./src/local_bip85/bip85.py:10:80: E501 line too long (160 > 79 characters) +./src/local_bip85/bip85.py:11:80: E501 line too long (167 > 79 characters) +./src/local_bip85/bip85.py:13:80: E501 line too long (87 > 79 characters) +./src/local_bip85/bip85.py:16:1: F401 'sys' imported but unused +./src/local_bip85/bip85.py:20:1: F401 'os' imported but unused +./src/local_bip85/bip85.py:25:80: E501 line too long (82 > 79 characters) +./src/local_bip85/bip85.py:27:1: F401 'cryptography.hazmat.primitives.kdf.hkdf.HKDF' imported but unused +./src/local_bip85/bip85.py:28:1: F401 'cryptography.hazmat.primitives.hashes' imported but unused +./src/local_bip85/bip85.py:29:1: F401 'cryptography.hazmat.backends.default_backend' imported but unused +./src/local_bip85/bip85.py:54:80: E501 line too long (83 > 79 characters) +./src/local_bip85/bip85.py:57:80: E501 line too long (82 > 79 characters) +./src/local_bip85/bip85.py:74:80: E501 line too long (80 > 79 characters) +./src/local_bip85/bip85.py:77:80: E501 line too long (82 > 79 characters) +./src/local_bip85/bip85.py:109:80: E501 line too long (102 > 79 characters) +./src/local_bip85/bip85.py:112:80: E501 line too long (119 > 79 characters) +./src/local_bip85/bip85.py:115:80: E501 line too long (102 > 79 characters) +./src/local_bip85/bip85.py:139:80: E501 line too long (82 > 79 characters) +./src/local_bip85/bip85.py:152:80: E501 line too long (83 > 79 characters) +./src/main.py:5:80: E501 line too long (82 > 79 characters) +./src/main.py:10:1: E402 module level import not at top of file +./src/main.py:11:1: E402 module level import not at top of file +./src/main.py:12:1: E402 module level import not at top of file +./src/main.py:13:1: E402 module level import not at top of file +./src/main.py:14:1: E402 module level import not at top of file +./src/main.py:15:1: E402 module level import not at top of file +./src/main.py:16:1: E402 module level import not at top of file +./src/main.py:17:1: E402 module level import not at top of file +./src/main.py:18:1: E402 module level import not at top of file +./src/main.py:19:1: E402 module level import not at top of file +./src/main.py:20:1: E402 module level import not at top of file +./src/main.py:21:1: E402 module level import not at top of file +./src/main.py:23:1: E402 module level import not at top of file +./src/main.py:24:1: F401 'nostr.client.NostrClient' imported but unused +./src/main.py:24:1: E402 module level import not at top of file +./src/main.py:25:1: E402 module level import not at top of file +./src/main.py:26:1: E402 module level import not at top of file +./src/main.py:27:1: E402 module level import not at top of file +./src/main.py:28:1: E402 module level import not at top of file +./src/main.py:33:1: E402 module level import not at top of file +./src/main.py:40:1: E402 module level import not at top of file +./src/main.py:41:1: E402 module level import not at top of file +./src/main.py:42:1: E402 module level import not at top of file +./src/main.py:47:1: E402 module level import not at top of file +./src/main.py:65:80: E501 line too long (85 > 79 characters) +./src/main.py:177:80: E501 line too long (97 > 79 characters) +./src/main.py:197:80: E501 line too long (87 > 79 characters) +./src/main.py:202:80: E501 line too long (88 > 79 characters) +./src/main.py:243:80: E501 line too long (128 > 79 characters) +./src/main.py:263:80: E501 line too long (85 > 79 characters) +./src/main.py:305:80: E501 line too long (80 > 79 characters) +./src/main.py:335:80: E501 line too long (81 > 79 characters) +./src/main.py:442:80: E501 line too long (87 > 79 characters) +./src/main.py:455:80: E501 line too long (80 > 79 characters) +./src/main.py:481:80: E501 line too long (83 > 79 characters) +./src/main.py:489:80: E501 line too long (107 > 79 characters) +./src/main.py:491:80: E501 line too long (85 > 79 characters) +./src/main.py:503:80: E501 line too long (88 > 79 characters) +./src/main.py:604:80: E501 line too long (99 > 79 characters) +./src/main.py:813:80: E501 line too long (84 > 79 characters) +./src/main.py:863:80: E501 line too long (85 > 79 characters) +./src/main.py:864:80: E501 line too long (81 > 79 characters) +./src/main.py:900:80: E501 line too long (85 > 79 characters) +./src/main.py:901:80: E501 line too long (81 > 79 characters) +./src/main.py:922:80: E501 line too long (81 > 79 characters) +./src/main.py:971:80: E501 line too long (81 > 79 characters) +./src/main.py:1015:80: E501 line too long (81 > 79 characters) +./src/main.py:1048:80: E501 line too long (82 > 79 characters) +./src/main.py:1089:80: E501 line too long (84 > 79 characters) +./src/main.py:1092:80: E501 line too long (85 > 79 characters) +./src/main.py:1117:80: E501 line too long (84 > 79 characters) +./src/main.py:1138:80: E501 line too long (81 > 79 characters) +./src/main.py:1152:80: E501 line too long (85 > 79 characters) +./src/main.py:1180:80: E501 line too long (85 > 79 characters) +./src/main.py:1256:80: E501 line too long (85 > 79 characters) +./src/main.py:1283:80: E501 line too long (82 > 79 characters) +./src/main.py:1286:80: E501 line too long (82 > 79 characters) +./src/main.py:1354:80: E501 line too long (84 > 79 characters) +./src/main.py:1385:80: E501 line too long (82 > 79 characters) +./src/main.py:1388:80: E501 line too long (87 > 79 characters) +./src/main.py:1391:80: E501 line too long (81 > 79 characters) +./src/main.py:1392:80: E501 line too long (82 > 79 characters) +./src/main.py:1395:80: E501 line too long (81 > 79 characters) +./src/main.py:1396:80: E501 line too long (82 > 79 characters) +./src/main.py:1429:80: E501 line too long (83 > 79 characters) +./src/main.py:1468:80: E501 line too long (85 > 79 characters) +./src/main.py:1469:80: E501 line too long (87 > 79 characters) +./src/main.py:1484:80: E501 line too long (84 > 79 characters) +./src/nostr/client.py:1:1: F401 'asyncio' imported but unused +./src/nostr/client.py:2:1: F401 'base64' imported but unused +./src/nostr/client.py:3:1: F401 'json' imported but unused +./src/nostr/client.py:5:1: F401 'time' imported but unused +./src/nostr/client.py:7:1: F401 'datetime.timedelta' imported but unused +./src/nostr/client.py:10:1: F401 'websockets' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.EventBuilder' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.Filter' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.Kind' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.KindStandard' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.Tag' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.RelayUrl' imported but unused +./src/nostr/client.py:11:1: F401 'nostr_sdk.PublicKey' imported but unused +./src/nostr/client.py:22:1: F401 'nostr_sdk.EventId' imported but unused +./src/nostr/client.py:22:1: F401 'nostr_sdk.Timestamp' imported but unused +./src/nostr/client.py:24:1: F401 'constants.MAX_RETRIES' imported but unused +./src/nostr/client.py:24:1: F401 'constants.RETRY_DELAY' imported but unused +./src/nostr/client.py:27:1: F401 '.backup_models.ChunkMeta' imported but unused +./src/nostr/client.py:27:1: F401 '.backup_models.KIND_DELTA' imported but unused +./src/nostr/client.py:27:1: F401 '.backup_models.KIND_MANIFEST' imported but unused +./src/nostr/client.py:27:1: F401 '.backup_models.KIND_SNAPSHOT_CHUNK' imported but unused +./src/nostr/coincurve_keys.py:4:1: F401 'coincurve.PublicKey' imported but unused +./src/nostr/connection.py:93:80: E501 line too long (80 > 79 characters) +./src/nostr/connection.py:99:80: E501 line too long (84 > 79 characters) +./src/nostr/connection.py:103:80: E501 line too long (83 > 79 characters) +./src/nostr/connection.py:132:80: E501 line too long (87 > 79 characters) +./src/nostr/connection.py:140:80: E501 line too long (81 > 79 characters) +./src/nostr/connection.py:167:80: E501 line too long (85 > 79 characters) +./src/nostr/connection.py:209:80: E501 line too long (82 > 79 characters) +./src/nostr/key_manager.py:22:80: E501 line too long (82 > 79 characters) +./src/nostr/key_manager.py:33:80: E501 line too long (82 > 79 characters) +./src/nostr/key_manager.py:50:80: E501 line too long (89 > 79 characters) +./src/nostr/key_manager.py:100:80: E501 line too long (86 > 79 characters) +./src/nostr/key_manager.py:108:80: E501 line too long (81 > 79 characters) +./src/nostr/key_manager.py:119:80: E501 line too long (85 > 79 characters) +./src/nostr/snapshot.py:34:27: E203 whitespace before ':' +./src/nostr/snapshot.py:34:80: E501 line too long (82 > 79 characters) +./src/nostr/snapshot.py:58:80: E501 line too long (82 > 79 characters) +./src/nostr/snapshot.py:90:80: E501 line too long (83 > 79 characters) +./src/nostr/snapshot.py:94:80: E501 line too long (80 > 79 characters) +./src/nostr/snapshot.py:121:80: E501 line too long (86 > 79 characters) +./src/nostr/snapshot.py:188:80: E501 line too long (82 > 79 characters) +./src/nostr/snapshot.py:230:80: E501 line too long (87 > 79 characters) +./src/nostr/snapshot.py:279:80: E501 line too long (81 > 79 characters) +./src/nostr/snapshot.py:296:80: E501 line too long (89 > 79 characters) +./src/nostr/snapshot.py:300:80: E501 line too long (84 > 79 characters) +./src/nostr/snapshot.py:343:80: E501 line too long (88 > 79 characters) +./src/nostr/snapshot.py:375:80: E501 line too long (82 > 79 characters) +./src/nostr/snapshot.py:385:80: E501 line too long (82 > 79 characters) +./src/nostr/snapshot.py:408:80: E501 line too long (81 > 79 characters) +./src/seedpass/api.py:10:1: F401 'typing.Optional' imported but unused +./src/seedpass/api.py:35:80: E501 line too long (80 > 79 characters) +./src/seedpass/api.py:67:80: E501 line too long (88 > 79 characters) +./src/seedpass/api.py:87:80: E501 line too long (82 > 79 characters) +./src/seedpass/api.py:167:80: E501 line too long (80 > 79 characters) +./src/seedpass/api.py:187:80: E501 line too long (83 > 79 characters) +./src/seedpass/api.py:288:80: E501 line too long (81 > 79 characters) +./src/seedpass/api.py:363:80: E501 line too long (81 > 79 characters) +./src/seedpass/api.py:422:80: E501 line too long (80 > 79 characters) +./src/seedpass/api.py:475:80: E501 line too long (83 > 79 characters) +./src/seedpass/api.py:514:80: E501 line too long (88 > 79 characters) +./src/seedpass/api.py:521:80: E501 line too long (84 > 79 characters) +./src/seedpass/api.py:564:80: E501 line too long (82 > 79 characters) +./src/seedpass/api.py:690:80: E501 line too long (85 > 79 characters) +./src/seedpass/cli/__init__.py:42:1: E402 module level import not at top of file +./src/seedpass/cli/__init__.py:117:80: E501 line too long (87 > 79 characters) +./src/seedpass/cli/__init__.py:121:80: E501 line too long (87 > 79 characters) +./src/seedpass/cli/__init__.py:125:80: E501 line too long (87 > 79 characters) +./src/seedpass/cli/__init__.py:135:80: E501 line too long (89 > 79 characters) +./src/seedpass/cli/__init__.py:148:80: E501 line too long (88 > 79 characters) +./src/seedpass/cli/__init__.py:167:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/__init__.py:175:80: E501 line too long (84 > 79 characters) +./src/seedpass/cli/api.py:12:80: E501 line too long (85 > 79 characters) +./src/seedpass/cli/api.py:16:80: E501 line too long (90 > 79 characters) +./src/seedpass/cli/common.py:6:1: F401 'seedpass.core.entry_types.EntryType' imported but unused +./src/seedpass/cli/common.py:7:1: F401 'seedpass.core.api.ChangePasswordRequest' imported but unused +./src/seedpass/cli/common.py:7:1: F401 'seedpass.core.api.UnlockRequest' imported but unused +./src/seedpass/cli/common.py:7:1: F401 'seedpass.core.api.BackupParentSeedRequest' imported but unused +./src/seedpass/cli/common.py:7:1: F401 'seedpass.core.api.ProfileSwitchRequest' imported but unused +./src/seedpass/cli/common.py:7:1: F401 'seedpass.core.api.ProfileRemoveRequest' imported but unused +./src/seedpass/cli/config.py:61:80: E501 line too long (86 > 79 characters) +./src/seedpass/cli/config.py:72:80: E501 line too long (85 > 79 characters) +./src/seedpass/cli/config.py:107:80: E501 line too long (87 > 79 characters) +./src/seedpass/cli/entry.py:30:80: E501 line too long (80 > 79 characters) +./src/seedpass/cli/entry.py:90:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:129:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:175:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:176:80: E501 line too long (81 > 79 characters) +./src/seedpass/cli/entry.py:200:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:217:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:238:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:255:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/entry.py:288:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/fingerprint.py:29:80: E501 line too long (81 > 79 characters) +./src/seedpass/cli/nostr.py:8:80: E501 line too long (101 > 79 characters) +./src/seedpass/cli/util.py:20:80: E501 line too long (82 > 79 characters) +./src/seedpass/cli/vault.py:46:80: E501 line too long (84 > 79 characters) +./src/seedpass/cli/vault.py:93:80: E501 line too long (80 > 79 characters) +./src/seedpass/cli/vault.py:97:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/api.py:5:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/api.py:142:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/api.py:206:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/api.py:218:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/api.py:224:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/api.py:251:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/api.py:292:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/api.py:301:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/api.py:303:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/api.py:541:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/api.py:570:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/api.py:668:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/backup.py:6:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/backup.py:7:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/backup.py:11:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/backup.py:24:1: F401 'constants.APP_DIR' imported but unused +./src/seedpass/core/backup.py:34:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/backup.py:58:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/backup.py:68:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/backup.py:81:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/backup.py:87:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/backup.py:96:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/backup.py:104:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:124:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/backup.py:131:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/backup.py:134:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/backup.py:139:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/backup.py:143:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/backup.py:154:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:159:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:162:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:166:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/backup.py:188:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/backup.py:194:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/backup.py:199:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/backup.py:209:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/backup.py:212:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:217:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/backup.py:221:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/config_manager.py:88:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/config_manager.py:101:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/config_manager.py:265:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:11:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:38:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:40:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:50:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:69:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:78:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:85:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/encryption.py:88:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:101:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:127:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/encryption.py:142:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:146:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:159:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:164:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:169:80: E501 line too long (98 > 79 characters) +./src/seedpass/core/encryption.py:174:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:177:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:181:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/encryption.py:186:80: E501 line too long (96 > 79 characters) +./src/seedpass/core/encryption.py:201:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:207:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/encryption.py:209:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:218:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:220:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/encryption.py:240:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/encryption.py:245:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/encryption.py:248:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:277:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/encryption.py:287:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:292:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:293:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/encryption.py:298:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:326:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:331:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:375:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:393:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/encryption.py:403:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:418:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/encryption.py:430:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/encryption.py:467:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/encryption.py:474:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/encryption.py:494:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/encryption.py:500:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/encryption.py:504:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/encryption.py:519:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:523:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/encryption.py:537:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/encryption.py:552:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/encryption.py:576:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/encryption.py:579:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/encryption.py:585:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/encryption.py:606:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/encryption.py:619:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/encryption.py:627:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/encryption.py:634:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/encryption.py:635:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_management.py:7:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:8:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/entry_management.py:11:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:13:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/entry_management.py:14:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/entry_management.py:15:80: E501 line too long (101 > 79 characters) +./src/seedpass/core/entry_management.py:23:5: F401 'json as json_lib' imported but unused +./src/seedpass/core/entry_management.py:70:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:74:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:92:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:117:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/entry_management.py:179:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_management.py:225:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:233:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:369:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_management.py:375:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:435:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:529:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:747:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:773:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/entry_management.py:775:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:784:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_management.py:799:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/entry_management.py:805:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:810:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_management.py:813:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/entry_management.py:862:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/entry_management.py:866:80: E501 line too long (103 > 79 characters) +./src/seedpass/core/entry_management.py:872:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:976:80: E501 line too long (102 > 79 characters) +./src/seedpass/core/entry_management.py:982:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:985:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:988:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:992:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:997:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:1001:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:1006:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:1021:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/entry_management.py:1062:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/entry_management.py:1071:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:1075:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/entry_management.py:1077:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:1131:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:1141:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:1151:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:1154:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:1162:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:1172:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_management.py:1185:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1187:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/entry_management.py:1191:80: E501 line too long (106 > 79 characters) +./src/seedpass/core/entry_management.py:1198:80: E501 line too long (91 > 79 characters) +./src/seedpass/core/entry_management.py:1204:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:1207:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_management.py:1210:80: E501 line too long (123 > 79 characters) +./src/seedpass/core/entry_management.py:1215:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1218:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/entry_management.py:1235:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_management.py:1237:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:1252:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_management.py:1262:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_management.py:1304:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1309:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/entry_management.py:1313:80: E501 line too long (103 > 79 characters) +./src/seedpass/core/entry_management.py:1319:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/entry_management.py:1321:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_management.py:1326:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_management.py:1339:27: F541 f-string is missing placeholders +./src/seedpass/core/entry_management.py:1357:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1370:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1379:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_management.py:1383:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:1412:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_management.py:1436:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_service.py:41:80: E501 line too long (90 > 79 characters) +./src/seedpass/core/entry_service.py:47:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/entry_service.py:51:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_service.py:54:80: E501 line too long (123 > 79 characters) +./src/seedpass/core/entry_service.py:66:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:70:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_service.py:80:80: E501 line too long (119 > 79 characters) +./src/seedpass/core/entry_service.py:85:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_service.py:92:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_service.py:94:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:99:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:114:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:132:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/entry_service.py:146:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:168:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/entry_service.py:183:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_service.py:185:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_service.py:191:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/entry_service.py:193:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/entry_service.py:195:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/entry_service.py:197:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/entry_service.py:205:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/entry_service.py:231:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:6:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/manager.py:7:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:8:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:23:1: F401 'builtins' imported but unused +./src/seedpass/core/manager.py:44:1: F401 'utils.key_derivation.derive_key_from_parent_seed' imported but unused +./src/seedpass/core/manager.py:53:1: F401 'utils.checksum.json_checksum' imported but unused +./src/seedpass/core/manager.py:69:1: F401 'utils.terminal_utils.clear_screen' imported but unused +./src/seedpass/core/manager.py:69:1: F401 'utils.terminal_utils.clear_and_print_profile_chain' imported but unused +./src/seedpass/core/manager.py:81:1: F401 'constants.PARENT_SEED_FILE' imported but unused +./src/seedpass/core/manager.py:81:1: F401 'constants.MIN_PASSWORD_LENGTH' imported but unused +./src/seedpass/core/manager.py:81:1: F401 'constants.MAX_PASSWORD_LENGTH' imported but unused +./src/seedpass/core/manager.py:100:1: F401 'bip_utils.Bip39MnemonicGenerator' imported but unused +./src/seedpass/core/manager.py:100:1: F401 'bip_utils.Bip39Languages' imported but unused +./src/seedpass/core/manager.py:100:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:102:1: F811 redefinition of unused 'datetime' from line 22 +./src/seedpass/core/manager.py:137:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:231:80: E501 line too long (101 > 79 characters) +./src/seedpass/core/manager.py:232:80: E501 line too long (103 > 79 characters) +./src/seedpass/core/manager.py:233:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/manager.py:236:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:245:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:320:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:381:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/manager.py:423:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:530:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:548:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:559:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:561:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:563:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:567:80: E501 line too long (120 > 79 characters) +./src/seedpass/core/manager.py:600:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:603:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:617:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:651:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:664:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:678:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:692:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:697:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:714:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:737:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:738:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:739:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:767:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:777:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:785:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:786:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:788:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:841:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:842:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:850:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:908:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:919:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:924:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:937:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:943:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:947:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:966:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:971:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/manager.py:991:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:993:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1012:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:1019:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:1028:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1058:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1064:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1075:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:1083:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1091:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1098:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1106:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1111:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1113:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1137:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1154:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:1172:80: E501 line too long (105 > 79 characters) +./src/seedpass/core/manager.py:1175:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1180:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1187:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1193:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1201:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1208:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1212:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1214:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1219:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1221:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1225:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1232:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1284:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1285:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1288:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1289:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1294:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1301:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1313:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1314:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1335:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1355:80: E501 line too long (90 > 79 characters) +./src/seedpass/core/manager.py:1361:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1362:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1363:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1396:80: E501 line too long (114 > 79 characters) +./src/seedpass/core/manager.py:1407:80: E501 line too long (106 > 79 characters) +./src/seedpass/core/manager.py:1423:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1468:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:1469:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:1504:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1510:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:1524:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:1537:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1548:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1564:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1575:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1591:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1598:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1605:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:1611:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1624:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:1648:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1670:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:1674:13: F841 local variable 'loop' is assigned to but never used +./src/seedpass/core/manager.py:1706:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1709:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:1721:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:1729:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1732:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1737:13: F841 local variable 'loop' is assigned to but never used +./src/seedpass/core/manager.py:1785:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1789:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1808:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1816:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:1828:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1835:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1842:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1866:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1878:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1882:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1909:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:1919:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:1932:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1949:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:1958:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1961:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:1969:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1978:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1984:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:1985:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:1991:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:2010:80: E501 line too long (117 > 79 characters) +./src/seedpass/core/manager.py:2019:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:2052:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2068:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/manager.py:2073:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2111:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2122:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2124:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2129:80: E501 line too long (102 > 79 characters) +./src/seedpass/core/manager.py:2159:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2180:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:2185:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2206:80: E501 line too long (98 > 79 characters) +./src/seedpass/core/manager.py:2211:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2248:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2257:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:2260:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:2266:80: E501 line too long (113 > 79 characters) +./src/seedpass/core/manager.py:2275:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/manager.py:2313:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2327:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2347:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:2354:80: E501 line too long (114 > 79 characters) +./src/seedpass/core/manager.py:2390:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2399:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:2404:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2413:80: E501 line too long (117 > 79 characters) +./src/seedpass/core/manager.py:2432:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2459:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2460:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2464:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2482:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:2541:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2572:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:2579:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/manager.py:2584:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2628:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:2664:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2672:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2687:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:2689:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:2739:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2767:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2773:80: E501 line too long (139 > 79 characters) +./src/seedpass/core/manager.py:2788:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2815:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2816:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2823:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/manager.py:2845:80: E501 line too long (128 > 79 characters) +./src/seedpass/core/manager.py:2853:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2861:80: E501 line too long (102 > 79 characters) +./src/seedpass/core/manager.py:2866:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:2880:80: E501 line too long (124 > 79 characters) +./src/seedpass/core/manager.py:2890:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2892:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2900:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:2902:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:2903:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:2910:80: E501 line too long (98 > 79 characters) +./src/seedpass/core/manager.py:2931:80: E501 line too long (120 > 79 characters) +./src/seedpass/core/manager.py:2956:80: E501 line too long (117 > 79 characters) +./src/seedpass/core/manager.py:2969:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:2985:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:2992:80: E501 line too long (114 > 79 characters) +./src/seedpass/core/manager.py:3012:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3021:80: E501 line too long (134 > 79 characters) +./src/seedpass/core/manager.py:3026:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3044:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:3049:80: E501 line too long (96 > 79 characters) +./src/seedpass/core/manager.py:3062:80: E501 line too long (124 > 79 characters) +./src/seedpass/core/manager.py:3085:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3106:80: E501 line too long (138 > 79 characters) +./src/seedpass/core/manager.py:3118:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:3122:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:3144:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3153:80: E501 line too long (136 > 79 characters) +./src/seedpass/core/manager.py:3158:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3194:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3201:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/manager.py:3232:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:3245:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3250:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3279:80: E501 line too long (97 > 79 characters) +./src/seedpass/core/manager.py:3292:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:3299:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3304:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3309:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:3313:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:3315:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3319:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3350:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3355:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3360:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3373:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:3380:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3386:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3424:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3429:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3434:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:3439:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:3444:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3457:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:3464:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3469:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3480:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:3482:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3486:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3518:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3523:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:3527:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3534:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:3541:80: E501 line too long (91 > 79 characters) +./src/seedpass/core/manager.py:3547:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3553:80: E501 line too long (97 > 79 characters) +./src/seedpass/core/manager.py:3566:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/manager.py:3573:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3578:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3583:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:3587:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:3589:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3593:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3616:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3678:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:3708:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3712:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:3743:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3761:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:3767:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3770:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3773:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:3775:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3787:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3806:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:3807:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3814:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3825:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:3826:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:3831:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:3865:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:3879:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:3947:80: E501 line too long (100 > 79 characters) +./src/seedpass/core/manager.py:3974:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:3975:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:3984:80: E501 line too long (96 > 79 characters) +./src/seedpass/core/manager.py:3997:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:4000:80: E501 line too long (126 > 79 characters) +./src/seedpass/core/manager.py:4012:80: E501 line too long (91 > 79 characters) +./src/seedpass/core/manager.py:4018:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:4037:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4063:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:4066:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4067:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4072:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4077:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:4081:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4085:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:4088:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/manager.py:4111:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4160:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4217:80: E501 line too long (100 > 79 characters) +./src/seedpass/core/manager.py:4228:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:4254:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:4267:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/manager.py:4285:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4287:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4319:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4321:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4388:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:4392:80: E501 line too long (101 > 79 characters) +./src/seedpass/core/manager.py:4407:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/manager.py:4417:80: E501 line too long (99 > 79 characters) +./src/seedpass/core/manager.py:4428:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4432:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/manager.py:4434:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4439:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4447:80: E501 line too long (112 > 79 characters) +./src/seedpass/core/manager.py:4454:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4458:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:4459:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/manager.py:4496:80: E501 line too long (122 > 79 characters) +./src/seedpass/core/manager.py:4508:80: E501 line too long (94 > 79 characters) +./src/seedpass/core/manager.py:4516:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4529:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/manager.py:4534:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4549:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4557:80: E501 line too long (90 > 79 characters) +./src/seedpass/core/manager.py:4560:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4561:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4633:80: E501 line too long (97 > 79 characters) +./src/seedpass/core/manager.py:4641:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4659:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4722:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:4725:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/manager.py:4732:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/manager.py:4735:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:4738:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/manager.py:4744:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/manager.py:4749:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/manager.py:4752:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/menu_handler.py:46:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/menu_handler.py:75:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/menu_handler.py:79:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/menu_handler.py:108:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/menu_handler.py:141:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/menu_handler.py:149:80: E501 line too long (108 > 79 characters) +./src/seedpass/core/menu_handler.py:153:80: E501 line too long (109 > 79 characters) +./src/seedpass/core/menu_handler.py:162:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/menu_handler.py:170:80: E501 line too long (108 > 79 characters) +./src/seedpass/core/menu_handler.py:174:80: E501 line too long (104 > 79 characters) +./src/seedpass/core/menu_handler.py:179:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/password_generation.py:6:80: E501 line too long (98 > 79 characters) +./src/seedpass/core/password_generation.py:7:80: E501 line too long (90 > 79 characters) +./src/seedpass/core/password_generation.py:10:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/password_generation.py:12:80: E501 line too long (142 > 79 characters) +./src/seedpass/core/password_generation.py:13:80: E501 line too long (167 > 79 characters) +./src/seedpass/core/password_generation.py:21:1: F401 'os' imported but unused +./src/seedpass/core/password_generation.py:26:1: F401 'base64' imported but unused +./src/seedpass/core/password_generation.py:27:1: F401 'typing.Optional' imported but unused +./src/seedpass/core/password_generation.py:30:1: F401 'pathlib.Path' imported but unused +./src/seedpass/core/password_generation.py:31:1: F401 'shutil' imported but unused +./src/seedpass/core/password_generation.py:73:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/password_generation.py:90:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/password_generation.py:91:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/password_generation.py:103:80: E501 line too long (103 > 79 characters) +./src/seedpass/core/password_generation.py:106:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/password_generation.py:108:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/password_generation.py:119:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/password_generation.py:125:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/password_generation.py:126:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/password_generation.py:131:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/password_generation.py:141:9: F841 local variable 'hkdf_derived' is assigned to but never used +./src/seedpass/core/password_generation.py:155:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/password_generation.py:178:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/password_generation.py:185:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/password_generation.py:206:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/password_generation.py:209:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/password_generation.py:213:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/password_generation.py:216:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/password_generation.py:252:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:265:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/password_generation.py:279:80: E501 line too long (97 > 79 characters) +./src/seedpass/core/password_generation.py:280:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/password_generation.py:312:80: E501 line too long (146 > 79 characters) +./src/seedpass/core/password_generation.py:337:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/password_generation.py:344:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/password_generation.py:358:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:363:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/password_generation.py:364:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/password_generation.py:372:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:374:80: E501 line too long (102 > 79 characters) +./src/seedpass/core/password_generation.py:390:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/password_generation.py:394:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/password_generation.py:406:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/password_generation.py:408:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:409:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/password_generation.py:414:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:416:80: E501 line too long (97 > 79 characters) +./src/seedpass/core/password_generation.py:425:80: E501 line too long (136 > 79 characters) +./src/seedpass/core/password_generation.py:431:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/password_generation.py:432:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/password_generation.py:472:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/password_generation.py:516:80: E501 line too long (84 > 79 characters) +./src/seedpass/core/portable_backup.py:57:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/portable_backup.py:110:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/profile_service.py:14:5: F401 'nostr.client.NostrClient' imported but unused +./src/seedpass/core/profile_service.py:23:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/profile_service.py:37:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/profile_service.py:38:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/profile_service.py:39:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/profile_service.py:48:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/profile_service.py:52:80: E501 line too long (95 > 79 characters) +./src/seedpass/core/profile_service.py:71:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/profile_service.py:96:80: E501 line too long (93 > 79 characters) +./src/seedpass/core/profile_service.py:101:80: E501 line too long (86 > 79 characters) +./src/seedpass/core/profile_service.py:107:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/profile_service.py:108:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/pubsub.py:9:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/pubsub.py:21:80: E501 line too long (85 > 79 characters) +./src/seedpass/core/totp.py:45:80: E501 line too long (83 > 79 characters) +./src/seedpass/core/totp.py:71:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/vault.py:70:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/vault.py:84:80: E501 line too long (80 > 79 characters) +./src/seedpass/core/vault.py:92:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/vault.py:93:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/vault.py:98:80: E501 line too long (88 > 79 characters) +./src/seedpass/core/vault.py:105:80: E501 line too long (87 > 79 characters) +./src/seedpass/core/vault.py:132:80: E501 line too long (92 > 79 characters) +./src/seedpass/core/vault.py:150:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/vault.py:172:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/vault.py:184:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/vault.py:199:80: E501 line too long (89 > 79 characters) +./src/seedpass/core/vault.py:210:80: E501 line too long (82 > 79 characters) +./src/seedpass/core/vault.py:222:80: E501 line too long (81 > 79 characters) +./src/seedpass/core/vault.py:260:80: E501 line too long (80 > 79 characters) +./src/seedpass_gui/app.py:34:80: E501 line too long (80 > 79 characters) +./src/seedpass_gui/app.py:93:80: E501 line too long (81 > 79 characters) +./src/seedpass_gui/app.py:252:80: E501 line too long (85 > 79 characters) +./src/seedpass_gui/app.py:451:80: E501 line too long (85 > 79 characters) +./src/tests/conftest.py:10:1: E402 module level import not at top of file +./src/tests/conftest.py:11:1: E402 module level import not at top of file +./src/tests/conftest.py:12:1: E402 module level import not at top of file +./src/tests/conftest.py:13:1: E402 module level import not at top of file +./src/tests/conftest.py:14:1: E402 module level import not at top of file +./src/tests/helpers.py:2:1: F401 'time' imported but unused +./src/tests/helpers.py:8:1: E402 module level import not at top of file +./src/tests/helpers.py:9:1: E402 module level import not at top of file +./src/tests/helpers.py:10:1: E402 module level import not at top of file +./src/tests/helpers.py:14:1: E402 module level import not at top of file +./src/tests/helpers.py:16:80: E501 line too long (107 > 79 characters) +./src/tests/helpers.py:37:1: E402 module level import not at top of file +./src/tests/helpers.py:38:1: F401 'asyncio' imported but unused +./src/tests/helpers.py:38:1: E402 module level import not at top of file +./src/tests/helpers.py:39:1: E402 module level import not at top of file +./src/tests/helpers.py:41:1: E402 module level import not at top of file +./src/tests/helpers.py:49:80: E501 line too long (88 > 79 characters) +./src/tests/helpers.py:228:80: E501 line too long (83 > 79 characters) +./src/tests/helpers.py:258:80: E501 line too long (81 > 79 characters) +./src/tests/test_add_new_fingerprint_words.py:23:80: E501 line too long (87 > 79 characters) +./src/tests/test_add_tags_from_retrieve.py:10:1: E402 module level import not at top of file +./src/tests/test_add_tags_from_retrieve.py:11:1: E402 module level import not at top of file +./src/tests/test_add_tags_from_retrieve.py:12:1: E402 module level import not at top of file +./src/tests/test_add_tags_from_retrieve.py:13:1: E402 module level import not at top of file +./src/tests/test_additional_backup.py:25:80: E501 line too long (83 > 79 characters) +./src/tests/test_additional_backup.py:36:80: E501 line too long (84 > 79 characters) +./src/tests/test_api.py:11:1: E402 module level import not at top of file +./src/tests/test_api.py:12:1: E402 module level import not at top of file +./src/tests/test_api.py:63:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:66:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:91:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:104:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:117:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:130:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:136:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:172:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:181:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:188:80: E501 line too long (83 > 79 characters) +./src/tests/test_api.py:191:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:206:80: E501 line too long (87 > 79 characters) +./src/tests/test_api.py:207:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:216:80: E501 line too long (81 > 79 characters) +./src/tests/test_api.py:222:80: E501 line too long (82 > 79 characters) +./src/tests/test_api.py:253:80: E501 line too long (81 > 79 characters) +./src/tests/test_api_new_endpoints.py:8:1: F401 'test_api.client' imported but unused +./src/tests/test_api_new_endpoints.py:9:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_api_new_endpoints.py:11:1: F401 'seedpass.core.password_generation.PasswordPolicy' imported but unused +./src/tests/test_api_new_endpoints.py:17:45: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:71:44: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:107:35: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:115:80: E501 line too long (82 > 79 characters) +./src/tests/test_api_new_endpoints.py:121:42: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:141:37: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:143:80: E501 line too long (88 > 79 characters) +./src/tests/test_api_new_endpoints.py:152:36: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:165:80: E501 line too long (82 > 79 characters) +./src/tests/test_api_new_endpoints.py:171:45: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:180:38: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:184:80: E501 line too long (80 > 79 characters) +./src/tests/test_api_new_endpoints.py:188:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_new_endpoints.py:213:35: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:217:80: E501 line too long (86 > 79 characters) +./src/tests/test_api_new_endpoints.py:236:38: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:264:40: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:290:47: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:308:52: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:325:36: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:350:37: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:376:38: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:398:44: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:403:80: E501 line too long (81 > 79 characters) +./src/tests/test_api_new_endpoints.py:429:58: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:447:43: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:447:51: F811 redefinition of unused 'dummy_nostr_client' from line 9 +./src/tests/test_api_new_endpoints.py:447:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_new_endpoints.py:468:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_new_endpoints.py:479:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_new_endpoints.py:497:51: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_new_endpoints.py:527:48: F811 redefinition of unused 'client' from line 8 +./src/tests/test_api_notifications.py:1:1: F401 'test_api.client' imported but unused +./src/tests/test_api_notifications.py:9:39: F811 redefinition of unused 'client' from line 1 +./src/tests/test_api_notifications.py:12:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_notifications.py:13:80: E501 line too long (86 > 79 characters) +./src/tests/test_api_notifications.py:26:52: F811 redefinition of unused 'client' from line 1 +./src/tests/test_api_notifications.py:29:80: E501 line too long (83 > 79 characters) +./src/tests/test_api_notifications.py:43:62: F811 redefinition of unused 'client' from line 1 +./src/tests/test_api_profile_stats.py:1:1: F401 'test_api.client' imported but unused +./src/tests/test_api_profile_stats.py:6:39: F811 redefinition of unused 'client' from line 1 +./src/tests/test_api_profile_stats.py:12:80: E501 line too long (85 > 79 characters) +./src/tests/test_api_rate_limit.py:4:1: F811 redefinition of unused 'importlib' from line 1 +./src/tests/test_api_rate_limit.py:24:80: E501 line too long (84 > 79 characters) +./src/tests/test_api_rate_limit.py:27:80: E501 line too long (81 > 79 characters) +./src/tests/test_api_rate_limit.py:37:80: E501 line too long (82 > 79 characters) +./src/tests/test_api_rate_limit.py:46:80: E501 line too long (87 > 79 characters) +./src/tests/test_api_reload_relays.py:21:80: E501 line too long (80 > 79 characters) +./src/tests/test_archive_from_retrieve.py:11:1: E402 module level import not at top of file +./src/tests/test_archive_from_retrieve.py:12:1: E402 module level import not at top of file +./src/tests/test_archive_from_retrieve.py:13:1: E402 module level import not at top of file +./src/tests/test_archive_from_retrieve.py:14:1: E402 module level import not at top of file +./src/tests/test_archive_nonpassword.py:9:1: E402 module level import not at top of file +./src/tests/test_archive_nonpassword.py:10:1: E402 module level import not at top of file +./src/tests/test_archive_nonpassword.py:11:1: E402 module level import not at top of file +./src/tests/test_archive_nonpassword.py:12:1: E402 module level import not at top of file +./src/tests/test_archive_restore.py:7:1: F401 'pytest' imported but unused +./src/tests/test_archive_restore.py:13:1: E402 module level import not at top of file +./src/tests/test_archive_restore.py:14:1: E402 module level import not at top of file +./src/tests/test_archive_restore.py:15:1: E402 module level import not at top of file +./src/tests/test_archive_restore.py:16:1: E402 module level import not at top of file +./src/tests/test_archive_restore.py:17:1: E402 module level import not at top of file +./src/tests/test_atomic_write.py:20:80: E501 line too long (86 > 79 characters) +./src/tests/test_audit_logger.py:8:1: F401 'importlib' imported but unused +./src/tests/test_audit_logger.py:9:1: F401 'pytest' imported but unused +./src/tests/test_audit_logger.py:39:80: E501 line too long (87 > 79 characters) +./src/tests/test_audit_logger.py:44:80: E501 line too long (80 > 79 characters) +./src/tests/test_audit_logger.py:46:80: E501 line too long (87 > 79 characters) +./src/tests/test_audit_logger.py:50:32: E741 ambiguous variable name 'l' +./src/tests/test_auto_sync.py:10:1: E402 module level import not at top of file +./src/tests/test_background_relay_check.py:9:1: E402 module level import not at top of file +./src/tests/test_background_relay_check.py:10:1: E402 module level import not at top of file +./src/tests/test_background_relay_check.py:19:80: E501 line too long (84 > 79 characters) +./src/tests/test_background_sync_always.py:7:1: E402 module level import not at top of file +./src/tests/test_background_sync_always.py:8:1: E402 module level import not at top of file +./src/tests/test_background_sync_always.py:29:80: E501 line too long (82 > 79 characters) +./src/tests/test_background_sync_always.py:30:80: E501 line too long (85 > 79 characters) +./src/tests/test_background_sync_always.py:31:80: E501 line too long (87 > 79 characters) +./src/tests/test_background_sync_always.py:53:80: E501 line too long (84 > 79 characters) +./src/tests/test_background_sync_always.py:54:80: E501 line too long (83 > 79 characters) +./src/tests/test_background_sync_always.py:55:80: E501 line too long (86 > 79 characters) +./src/tests/test_background_sync_always.py:56:80: E501 line too long (82 > 79 characters) +./src/tests/test_backup_restore.py:11:1: E402 module level import not at top of file +./src/tests/test_backup_restore.py:12:1: E402 module level import not at top of file +./src/tests/test_backup_restore.py:104:80: E501 line too long (83 > 79 characters) +./src/tests/test_bip85_init.py:6:1: E402 module level import not at top of file +./src/tests/test_bip85_init.py:7:1: E402 module level import not at top of file +./src/tests/test_bip85_init.py:8:1: E402 module level import not at top of file +./src/tests/test_bip85_init.py:10:80: E501 line too long (127 > 79 characters) +./src/tests/test_bip85_vectors.py:7:1: E402 module level import not at top of file +./src/tests/test_bip85_vectors.py:8:1: E402 module level import not at top of file +./src/tests/test_bip85_vectors.py:12:1: E402 module level import not at top of file +./src/tests/test_bip85_vectors.py:14:80: E501 line too long (127 > 79 characters) +./src/tests/test_bip85_vectors.py:16:80: E501 line too long (87 > 79 characters) +./src/tests/test_bip85_vectors.py:18:80: E501 line too long (175 > 79 characters) +./src/tests/test_bip85_vectors.py:20:80: E501 line too long (86 > 79 characters) +./src/tests/test_bip85_vectors.py:22:80: E501 line too long (85 > 79 characters) +./src/tests/test_checksum_utils.py:3:1: F401 'pathlib.Path' imported but unused +./src/tests/test_cli_clipboard_flag.py:30:80: E501 line too long (82 > 79 characters) +./src/tests/test_cli_clipboard_flag.py:40:80: E501 line too long (80 > 79 characters) +./src/tests/test_cli_core_services.py:3:1: F401 'typer' imported but unused +./src/tests/test_cli_core_services.py:21:80: E501 line too long (87 > 79 characters) +./src/tests/test_cli_core_services.py:50:80: E501 line too long (82 > 79 characters) +./src/tests/test_cli_doc_examples.py:9:1: E402 module level import not at top of file +./src/tests/test_cli_doc_examples.py:10:1: E402 module level import not at top of file +./src/tests/test_cli_doc_examples.py:11:1: E402 module level import not at top of file +./src/tests/test_cli_doc_examples.py:12:1: E402 module level import not at top of file +./src/tests/test_cli_doc_examples.py:13:1: E402 module level import not at top of file +./src/tests/test_cli_doc_examples.py:19:80: E501 line too long (93 > 79 characters) +./src/tests/test_cli_doc_examples.py:25:80: E501 line too long (87 > 79 characters) +./src/tests/test_cli_doc_examples.py:28:80: E501 line too long (118 > 79 characters) +./src/tests/test_cli_doc_examples.py:30:80: E501 line too long (100 > 79 characters) +./src/tests/test_cli_doc_examples.py:55:80: E501 line too long (80 > 79 characters) +./src/tests/test_cli_doc_examples.py:88:80: E501 line too long (86 > 79 characters) +./src/tests/test_cli_doc_examples.py:111:1: E402 module level import not at top of file +./src/tests/test_cli_entry_add_commands.py:7:1: F401 'helpers.TEST_SEED' imported but unused +./src/tests/test_cli_entry_add_commands.py:139:80: E501 line too long (82 > 79 characters) +./src/tests/test_cli_export_import.py:8:1: E402 module level import not at top of file +./src/tests/test_cli_export_import.py:9:1: E402 module level import not at top of file +./src/tests/test_cli_export_import.py:10:1: E402 module level import not at top of file +./src/tests/test_cli_export_import.py:11:1: E402 module level import not at top of file +./src/tests/test_cli_export_import.py:12:1: E402 module level import not at top of file +./src/tests/test_cli_integration.py:26:80: E501 line too long (87 > 79 characters) +./src/tests/test_cli_integration.py:27:80: E501 line too long (85 > 79 characters) +./src/tests/test_cli_integration.py:28:80: E501 line too long (85 > 79 characters) +./src/tests/test_cli_integration.py:30:80: E501 line too long (81 > 79 characters) +./src/tests/test_cli_integration.py:35:80: E501 line too long (85 > 79 characters) +./src/tests/test_cli_integration.py:56:80: E501 line too long (88 > 79 characters) +./src/tests/test_cli_integration.py:61:80: E501 line too long (87 > 79 characters) +./src/tests/test_cli_invalid_input.py:10:1: E402 module level import not at top of file +./src/tests/test_cli_relays.py:41:80: E501 line too long (84 > 79 characters) +./src/tests/test_cli_subcommands.py:7:1: E402 module level import not at top of file +./src/tests/test_cli_subcommands.py:8:1: E402 module level import not at top of file +./src/tests/test_cli_subcommands.py:17:51: E741 ambiguous variable name 'l' +./src/tests/test_cli_subcommands.py:43:80: E501 line too long (86 > 79 characters) +./src/tests/test_cli_subcommands.py:56:80: E501 line too long (82 > 79 characters) +./src/tests/test_cli_subcommands.py:63:80: E501 line too long (86 > 79 characters) +./src/tests/test_cli_subcommands.py:103:80: E501 line too long (82 > 79 characters) +./src/tests/test_cli_subcommands.py:132:80: E501 line too long (86 > 79 characters) +./src/tests/test_cli_toggle_secret_mode.py:1:1: F401 'types' imported but unused +./src/tests/test_cli_toggle_secret_mode.py:31:80: E501 line too long (82 > 79 characters) +./src/tests/test_clipboard_utils.py:10:1: E402 module level import not at top of file +./src/tests/test_clipboard_utils.py:12:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:10:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:11:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:12:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:13:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:14:1: E402 module level import not at top of file +./src/tests/test_concurrency_stress.py:51:80: E501 line too long (82 > 79 characters) +./src/tests/test_config_manager.py:10:1: E402 module level import not at top of file +./src/tests/test_config_manager.py:11:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_config_manager.py:11:1: E402 module level import not at top of file +./src/tests/test_config_manager.py:12:1: E402 module level import not at top of file +./src/tests/test_config_manager.py:13:1: E402 module level import not at top of file +./src/tests/test_config_manager.py:49:1: E402 module level import not at top of file +./src/tests/test_config_manager.py:125:80: E501 line too long (85 > 79 characters) +./src/tests/test_core_api_services.py:4:1: F401 'threading.Lock' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.VaultExportResponse' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.UnlockResponse' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.PasswordPolicyOptions' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.GeneratePasswordRequest' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.GeneratePasswordResponse' imported but unused +./src/tests/test_core_api_services.py:8:1: F401 'seedpass.core.api.AddPasswordEntryRequest' imported but unused +./src/tests/test_core_api_services.py:31:1: F401 'seedpass.core.entry_types.EntryType' imported but unused +./src/tests/test_core_api_services.py:92:80: E501 line too long (86 > 79 characters) +./src/tests/test_core_api_services.py:100:80: E501 line too long (83 > 79 characters) +./src/tests/test_core_api_services.py:114:80: E501 line too long (83 > 79 characters) +./src/tests/test_core_api_services.py:141:80: E501 line too long (80 > 79 characters) +./src/tests/test_core_api_services.py:164:80: E501 line too long (88 > 79 characters) +./src/tests/test_core_api_services.py:181:80: E501 line too long (84 > 79 characters) +./src/tests/test_core_api_services.py:189:80: E501 line too long (85 > 79 characters) +./src/tests/test_core_api_services.py:225:80: E501 line too long (83 > 79 characters) +./src/tests/test_core_api_services.py:262:80: E501 line too long (82 > 79 characters) +./src/tests/test_core_api_services.py:267:80: E501 line too long (88 > 79 characters) +./src/tests/test_core_api_services.py:272:80: E501 line too long (87 > 79 characters) +./src/tests/test_core_api_services.py:286:80: E501 line too long (83 > 79 characters) +./src/tests/test_core_api_services.py:418:80: E501 line too long (88 > 79 characters) +./src/tests/test_core_api_services.py:422:80: E501 line too long (84 > 79 characters) +./src/tests/test_core_api_services.py:427:80: E501 line too long (85 > 79 characters) +./src/tests/test_core_api_services.py:453:80: E501 line too long (84 > 79 characters) +./src/tests/test_core_api_services.py:456:80: E501 line too long (86 > 79 characters) +./src/tests/test_core_api_services.py:469:80: E501 line too long (82 > 79 characters) +./src/tests/test_core_api_services.py:489:80: E501 line too long (85 > 79 characters) +./src/tests/test_core_api_services.py:510:80: E501 line too long (80 > 79 characters) +./src/tests/test_core_services.py:1:1: F401 'types' imported but unused +./src/tests/test_core_services.py:4:80: E501 line too long (84 > 79 characters) +./src/tests/test_core_services.py:51:80: E501 line too long (80 > 79 characters) +./src/tests/test_custom_fields_display.py:8:80: E501 line too long (83 > 79 characters) +./src/tests/test_custom_fields_display.py:10:70: E741 ambiguous variable name 'l' +./src/tests/test_custom_fields_display.py:10:80: E501 line too long (80 > 79 characters) +./src/tests/test_decrypt_messages.py:25:80: E501 line too long (81 > 79 characters) +./src/tests/test_decrypt_messages.py:32:80: E501 line too long (84 > 79 characters) +./src/tests/test_decrypt_messages.py:37:80: E501 line too long (85 > 79 characters) +./src/tests/test_decrypt_messages.py:45:80: E501 line too long (81 > 79 characters) +./src/tests/test_decrypt_messages.py:56:80: E501 line too long (81 > 79 characters) +./src/tests/test_default_encryption_mode.py:6:1: E402 module level import not at top of file +./src/tests/test_default_encryption_mode.py:7:1: E402 module level import not at top of file +./src/tests/test_default_encryption_mode.py:9:1: E402 module level import not at top of file +./src/tests/test_default_encryption_mode.py:10:1: E402 module level import not at top of file +./src/tests/test_default_encryption_mode.py:26:80: E501 line too long (80 > 79 characters) +./src/tests/test_delta_merge.py:4:1: F401 'pytest' imported but unused +./src/tests/test_duplicate_seed_profile_creation.py:1:1: F401 'pytest' imported but unused +./src/tests/test_duplicate_seed_profile_creation.py:9:80: E501 line too long (108 > 79 characters) +./src/tests/test_duplicate_seed_profile_creation.py:15:80: E501 line too long (81 > 79 characters) +./src/tests/test_duplicate_seed_profile_creation.py:16:80: E501 line too long (82 > 79 characters) +./src/tests/test_duplicate_seed_profile_creation.py:17:80: E501 line too long (86 > 79 characters) +./src/tests/test_duplicate_seed_profile_creation.py:30:80: E501 line too long (80 > 79 characters) +./src/tests/test_duplicate_seed_profile_creation.py:38:80: E501 line too long (81 > 79 characters) +./src/tests/test_encryption_checksum.py:12:1: E402 module level import not at top of file +./src/tests/test_encryption_checksum.py:13:1: E402 module level import not at top of file +./src/tests/test_encryption_files.py:12:1: E402 module level import not at top of file +./src/tests/test_encryption_validate_seed.py:4:1: F401 'pytest' imported but unused +./src/tests/test_encryption_validate_seed.py:9:1: E402 module level import not at top of file +./src/tests/test_encryption_validate_seed.py:19:80: E501 line too long (107 > 79 characters) +./src/tests/test_encryption_validate_seed.py:27:80: E501 line too long (111 > 79 characters) +./src/tests/test_entries_empty.py:8:1: E402 module level import not at top of file +./src/tests/test_entries_empty.py:9:1: E402 module level import not at top of file +./src/tests/test_entries_empty.py:10:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_entries_empty.py:10:1: E402 module level import not at top of file +./src/tests/test_entries_empty.py:11:1: E402 module level import not at top of file +./src/tests/test_entry_add.py:4:1: F401 'unittest.mock.patch' imported but unused +./src/tests/test_entry_add.py:11:1: E402 module level import not at top of file +./src/tests/test_entry_add.py:12:1: E402 module level import not at top of file +./src/tests/test_entry_add.py:13:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_entry_add.py:13:1: E402 module level import not at top of file +./src/tests/test_entry_add.py:14:1: E402 module level import not at top of file +./src/tests/test_entry_add.py:29:80: E501 line too long (84 > 79 characters) +./src/tests/test_entry_management_checksum_path.py:8:1: E402 module level import not at top of file +./src/tests/test_entry_management_checksum_path.py:9:1: E402 module level import not at top of file +./src/tests/test_entry_management_checksum_path.py:10:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_entry_management_checksum_path.py:10:1: E402 module level import not at top of file +./src/tests/test_entry_management_checksum_path.py:11:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:11:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:12:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:13:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:14:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:15:1: E402 module level import not at top of file +./src/tests/test_entry_policy_override.py:24:80: E501 line too long (88 > 79 characters) +./src/tests/test_export_totp_codes.py:3:1: F401 'tempfile.TemporaryDirectory' imported but unused +./src/tests/test_export_totp_codes.py:11:1: E402 module level import not at top of file +./src/tests/test_export_totp_codes.py:12:1: E402 module level import not at top of file +./src/tests/test_export_totp_codes.py:13:1: E402 module level import not at top of file +./src/tests/test_export_totp_codes.py:14:1: E402 module level import not at top of file +./src/tests/test_export_totp_codes.py:15:1: F401 'seedpass.core.totp.TotpManager' imported but unused +./src/tests/test_export_totp_codes.py:15:1: E402 module level import not at top of file +./src/tests/test_export_totp_codes.py:45:80: E501 line too long (88 > 79 characters) +./src/tests/test_file_lock.py:52:80: E501 line too long (82 > 79 characters) +./src/tests/test_file_locking.py:24:1: E402 module level import not at top of file +./src/tests/test_file_locking.py:27:80: E501 line too long (82 > 79 characters) +./src/tests/test_file_locking.py:38:80: E501 line too long (86 > 79 characters) +./src/tests/test_fingerprint_encryption.py:11:1: E402 module level import not at top of file +./src/tests/test_fingerprint_encryption.py:12:1: E402 module level import not at top of file +./src/tests/test_fingerprint_encryption.py:16:80: E501 line too long (106 > 79 characters) +./src/tests/test_fingerprint_encryption.py:18:80: E501 line too long (85 > 79 characters) +./src/tests/test_fingerprint_manager_utils.py:6:80: E501 line too long (108 > 79 characters) +./src/tests/test_full_sync_roundtrip.py:5:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_full_sync_roundtrip.py:32:30: F811 redefinition of unused 'dummy_nostr_client' from line 5 +./src/tests/test_full_sync_roundtrip_new.py:5:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_full_sync_roundtrip_new.py:32:30: F811 redefinition of unused 'dummy_nostr_client' from line 5 +./src/tests/test_fuzz_key_derivation.py:8:1: F811 redefinition of unused 'os' from line 1 +./src/tests/test_fuzz_key_derivation.py:29:80: E501 line too long (85 > 79 characters) +./src/tests/test_fuzz_key_derivation.py:37:80: E501 line too long (81 > 79 characters) +./src/tests/test_generate_test_profile.py:18:80: E501 line too long (88 > 79 characters) +./src/tests/test_generate_test_profile.py:27:80: E501 line too long (82 > 79 characters) +./src/tests/test_generate_test_profile_sync.py:8:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_generate_test_profile_sync.py:13:80: E501 line too long (84 > 79 characters) +./src/tests/test_generate_test_profile_sync.py:15:80: E501 line too long (87 > 79 characters) +./src/tests/test_generate_test_profile_sync.py:22:50: F811 redefinition of unused 'dummy_nostr_client' from line 8 +./src/tests/test_generate_test_profile_sync.py:34:80: E501 line too long (88 > 79 characters) +./src/tests/test_get_entry_summaries_archived_view.py:14:80: E501 line too long (85 > 79 characters) +./src/tests/test_gui_features.py:9:1: E402 module level import not at top of file +./src/tests/test_gui_features.py:10:1: E402 module level import not at top of file +./src/tests/test_gui_features.py:11:1: E402 module level import not at top of file +./src/tests/test_gui_features.py:33:80: E501 line too long (87 > 79 characters) +./src/tests/test_gui_features.py:35:80: E501 line too long (84 > 79 characters) +./src/tests/test_gui_features.py:82:80: E501 line too long (88 > 79 characters) +./src/tests/test_gui_headless.py:37:80: E501 line too long (86 > 79 characters) +./src/tests/test_gui_headless.py:67:80: E501 line too long (81 > 79 characters) +./src/tests/test_gui_headless.py:91:5: F841 local variable 'app' is assigned to but never used +./src/tests/test_gui_headless.py:158:80: E501 line too long (84 > 79 characters) +./src/tests/test_gui_sync.py:12:80: E501 line too long (87 > 79 characters) +./src/tests/test_import.py:4:5: F401 'bip_utils.Bip39SeedGenerator' imported but unused +./src/tests/test_inactivity_lock.py:10:1: E402 module level import not at top of file +./src/tests/test_index_cache.py:22:80: E501 line too long (81 > 79 characters) +./src/tests/test_index_import_export.py:4:1: F401 'pytest' imported but unused +./src/tests/test_index_import_export.py:11:1: E402 module level import not at top of file +./src/tests/test_index_import_export.py:12:1: E402 module level import not at top of file +./src/tests/test_index_import_export.py:13:1: E402 module level import not at top of file +./src/tests/test_index_import_export.py:15:80: E501 line too long (102 > 79 characters) +./src/tests/test_kdf_iteration_fallback.py:21:80: E501 line too long (87 > 79 characters) +./src/tests/test_kdf_iteration_fallback.py:46:80: E501 line too long (86 > 79 characters) +./src/tests/test_kdf_modes.py:6:1: F401 'types.SimpleNamespace' imported but unused +./src/tests/test_kdf_modes.py:19:80: E501 line too long (107 > 79 characters) +./src/tests/test_kdf_modes.py:34:80: E501 line too long (80 > 79 characters) +./src/tests/test_kdf_modes.py:44:80: E501 line too long (80 > 79 characters) +./src/tests/test_kdf_modes.py:90:80: E501 line too long (87 > 79 characters) +./src/tests/test_key_derivation.py:3:1: F811 redefinition of unused 'logging' from line 1 +./src/tests/test_key_derivation.py:38:80: E501 line too long (106 > 79 characters) +./src/tests/test_key_derivation.py:46:80: E501 line too long (106 > 79 characters) +./src/tests/test_key_derivation.py:57:80: E501 line too long (87 > 79 characters) +./src/tests/test_key_derivation.py:61:80: E501 line too long (87 > 79 characters) +./src/tests/test_key_hierarchy.py:15:80: E501 line too long (106 > 79 characters) +./src/tests/test_key_manager_helpers.py:1:1: F401 'pytest' imported but unused +./src/tests/test_key_manager_helpers.py:19:80: E501 line too long (84 > 79 characters) +./src/tests/test_key_validation_failures.py:27:80: E501 line too long (82 > 79 characters) +./src/tests/test_key_validation_failures.py:36:80: E501 line too long (85 > 79 characters) +./src/tests/test_key_validation_failures.py:45:80: E501 line too long (80 > 79 characters) +./src/tests/test_key_value_entry.py:9:1: E402 module level import not at top of file +./src/tests/test_key_value_entry.py:10:1: E402 module level import not at top of file +./src/tests/test_key_value_entry.py:11:1: E402 module level import not at top of file +./src/tests/test_last_used_fingerprint.py:8:1: F401 'seedpass.core.manager.EncryptionMode' imported but unused +./src/tests/test_last_used_fingerprint.py:33:80: E501 line too long (85 > 79 characters) +./src/tests/test_last_used_fingerprint.py:41:80: E501 line too long (81 > 79 characters) +./src/tests/test_last_used_fingerprint.py:44:80: E501 line too long (84 > 79 characters) +./src/tests/test_last_used_fingerprint.py:52:80: E501 line too long (83 > 79 characters) +./src/tests/test_legacy_format_exception.py:27:80: E501 line too long (86 > 79 characters) +./src/tests/test_legacy_format_exception.py:38:80: E501 line too long (88 > 79 characters) +./src/tests/test_legacy_migration.py:83:80: E501 line too long (84 > 79 characters) +./src/tests/test_legacy_migration.py:170:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration.py:179:80: E501 line too long (87 > 79 characters) +./src/tests/test_legacy_migration.py:207:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration.py:216:80: E501 line too long (88 > 79 characters) +./src/tests/test_legacy_migration.py:223:80: E501 line too long (80 > 79 characters) +./src/tests/test_legacy_migration.py:264:80: E501 line too long (87 > 79 characters) +./src/tests/test_legacy_migration.py:272:80: E501 line too long (84 > 79 characters) +./src/tests/test_legacy_migration.py:298:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration.py:304:80: E501 line too long (87 > 79 characters) +./src/tests/test_legacy_migration.py:340:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration_iterations.py:8:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:10:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:11:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:12:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:13:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:17:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:18:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:19:1: E402 module level import not at top of file +./src/tests/test_legacy_migration_iterations.py:23:80: E501 line too long (87 > 79 characters) +./src/tests/test_legacy_migration_iterations.py:25:80: E501 line too long (81 > 79 characters) +./src/tests/test_legacy_migration_iterations.py:56:80: E501 line too long (88 > 79 characters) +./src/tests/test_legacy_migration_iterations.py:69:80: E501 line too long (81 > 79 characters) +./src/tests/test_legacy_migration_prompt.py:17:80: E501 line too long (81 > 79 characters) +./src/tests/test_legacy_migration_prompt.py:34:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration_prompt.py:50:80: E501 line too long (85 > 79 characters) +./src/tests/test_legacy_migration_prompt.py:53:80: E501 line too long (81 > 79 characters) +./src/tests/test_legacy_migration_second_session.py:33:80: E501 line too long (85 > 79 characters) +./src/tests/test_list_entries_all_types.py:56:80: E501 line too long (107 > 79 characters) +./src/tests/test_list_entries_all_types.py:63:80: E501 line too long (84 > 79 characters) +./src/tests/test_list_entries_all_types.py:69:80: E501 line too long (81 > 79 characters) +./src/tests/test_list_entries_all_types.py:84:80: E501 line too long (81 > 79 characters) +./src/tests/test_list_entries_sort_filter.py:9:1: E402 module level import not at top of file +./src/tests/test_list_entries_sort_filter.py:10:1: E402 module level import not at top of file +./src/tests/test_list_entries_sort_filter.py:11:1: E402 module level import not at top of file +./src/tests/test_list_entries_sort_filter.py:12:1: E402 module level import not at top of file +./src/tests/test_load_global_config.py:6:1: F401 'pytest' imported but unused +./src/tests/test_managed_account.py:12:1: E402 module level import not at top of file +./src/tests/test_managed_account.py:13:1: E402 module level import not at top of file +./src/tests/test_managed_account.py:14:1: E402 module level import not at top of file +./src/tests/test_managed_account.py:59:80: E501 line too long (83 > 79 characters) +./src/tests/test_managed_account.py:72:80: E501 line too long (81 > 79 characters) +./src/tests/test_managed_account_entry.py:12:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:13:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:14:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:15:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:16:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:17:1: E402 module level import not at top of file +./src/tests/test_managed_account_entry.py:88:80: E501 line too long (83 > 79 characters) +./src/tests/test_managed_account_entry.py:101:80: E501 line too long (81 > 79 characters) +./src/tests/test_manager_add_password.py:4:1: F401 'types.SimpleNamespace' imported but unused +./src/tests/test_manager_add_password.py:6:1: F401 'pytest' imported but unused +./src/tests/test_manager_add_password.py:8:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_manager_add_password.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_add_password.py:13:1: E402 module level import not at top of file +./src/tests/test_manager_add_password.py:14:1: E402 module level import not at top of file +./src/tests/test_manager_add_password.py:15:1: E402 module level import not at top of file +./src/tests/test_manager_add_password.py:16:1: E402 module level import not at top of file +./src/tests/test_manager_add_password.py:24:43: F811 redefinition of unused 'dummy_nostr_client' from line 8 +./src/tests/test_manager_add_password.py:67:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_add_password.py:68:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_add_password.py:93:55: F811 redefinition of unused 'dummy_nostr_client' from line 8 +./src/tests/test_manager_add_password.py:93:80: E501 line too long (82 > 79 characters) +./src/tests/test_manager_add_password.py:137:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_add_password.py:138:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_add_password.py:154:54: F811 redefinition of unused 'dummy_nostr_client' from line 8 +./src/tests/test_manager_add_password.py:154:80: E501 line too long (81 > 79 characters) +./src/tests/test_manager_add_password.py:187:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_add_password.py:188:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_add_totp.py:4:1: F401 'types.SimpleNamespace' imported but unused +./src/tests/test_manager_add_totp.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_add_totp.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_add_totp.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_add_totp.py:13:1: E402 module level import not at top of file +./src/tests/test_manager_add_totp.py:54:80: E501 line too long (83 > 79 characters) +./src/tests/test_manager_add_totp.py:56:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_checksum_backup.py:6:1: E402 module level import not at top of file +./src/tests/test_manager_checksum_backup.py:7:1: E402 module level import not at top of file +./src/tests/test_manager_checksum_backup.py:33:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_checksum_backup.py:44:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_checksum_backup.py:54:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_current_notification.py:8:1: F401 'seedpass.core.manager.Notification' imported but unused +./src/tests/test_manager_current_notification.py:8:1: E402 module level import not at top of file +./src/tests/test_manager_current_notification.py:9:1: E402 module level import not at top of file +./src/tests/test_manager_current_notification.py:23:80: E501 line too long (82 > 79 characters) +./src/tests/test_manager_display_totp_codes.py:24:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_display_totp_codes.py:25:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_display_totp_codes.py:40:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_display_totp_codes.py:48:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_display_totp_codes.py:49:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_edit_totp.py:9:1: E402 module level import not at top of file +./src/tests/test_manager_edit_totp.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_edit_totp.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_edit_totp.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_edit_totp.py:48:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_edit_totp.py:52:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_edit_totp.py:53:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_import_database.py:8:1: E402 module level import not at top of file +./src/tests/test_manager_import_database.py:33:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_import_database.py:50:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_list_entries.py:13:1: E402 module level import not at top of file +./src/tests/test_manager_list_entries.py:14:1: E402 module level import not at top of file +./src/tests/test_manager_list_entries.py:15:1: E402 module level import not at top of file +./src/tests/test_manager_list_entries.py:16:1: E402 module level import not at top of file +./src/tests/test_manager_list_entries.py:17:1: E402 module level import not at top of file +./src/tests/test_manager_list_entries.py:78:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_list_entries.py:82:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_list_entries.py:137:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_list_entries.py:141:74: E741 ambiguous variable name 'l' +./src/tests/test_manager_list_entries.py:141:80: E501 line too long (87 > 79 characters) +./src/tests/test_manager_list_entries.py:176:80: E501 line too long (86 > 79 characters) +./src/tests/test_manager_list_entries.py:177:80: E501 line too long (81 > 79 characters) +./src/tests/test_manager_list_entries.py:178:80: E501 line too long (81 > 79 characters) +./src/tests/test_manager_list_entries.py:180:70: E741 ambiguous variable name 'l' +./src/tests/test_manager_list_entries.py:180:80: E501 line too long (83 > 79 characters) +./src/tests/test_manager_list_entries.py:182:80: E501 line too long (87 > 79 characters) +./src/tests/test_manager_list_entries.py:298:74: E741 ambiguous variable name 'l' +./src/tests/test_manager_list_entries.py:298:80: E501 line too long (87 > 79 characters) +./src/tests/test_manager_list_entries.py:306:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_list_entries.py:307:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_list_entries.py:309:80: E501 line too long (83 > 79 characters) +./src/tests/test_manager_list_entries.py:315:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_retrieve_totp.py:9:1: E402 module level import not at top of file +./src/tests/test_manager_retrieve_totp.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_retrieve_totp.py:11:1: F401 'seedpass.core.manager.TotpManager' imported but unused +./src/tests/test_manager_retrieve_totp.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_retrieve_totp.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_retrieve_totp.py:48:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_retrieve_totp.py:52:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_search_display.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_search_display.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_search_display.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_search_display.py:13:1: E402 module level import not at top of file +./src/tests/test_manager_search_display.py:37:80: E501 line too long (88 > 79 characters) +./src/tests/test_manager_search_display.py:41:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_search_display.py:42:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_seed_setup.py:17:80: E501 line too long (85 > 79 characters) +./src/tests/test_manager_seed_setup.py:35:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_seed_setup.py:55:80: E501 line too long (80 > 79 characters) +./src/tests/test_manager_warning_notifications.py:8:1: E402 module level import not at top of file +./src/tests/test_manager_warning_notifications.py:9:1: E402 module level import not at top of file +./src/tests/test_manager_warning_notifications.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_warning_notifications.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_warning_notifications.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_warning_notifications.py:37:80: E501 line too long (84 > 79 characters) +./src/tests/test_manager_workflow.py:8:1: E402 module level import not at top of file +./src/tests/test_manager_workflow.py:9:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_manager_workflow.py:9:1: E402 module level import not at top of file +./src/tests/test_manager_workflow.py:10:1: E402 module level import not at top of file +./src/tests/test_manager_workflow.py:11:1: E402 module level import not at top of file +./src/tests/test_manager_workflow.py:12:1: E402 module level import not at top of file +./src/tests/test_manager_workflow.py:37:80: E501 line too long (81 > 79 characters) +./src/tests/test_manager_workflow.py:81:80: E501 line too long (83 > 79 characters) +./src/tests/test_manager_workflow.py:91:80: E501 line too long (83 > 79 characters) +./src/tests/test_manifest_id_privacy.py:3:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_manifest_id_privacy.py:6:42: F811 redefinition of unused 'dummy_nostr_client' from line 3 +./src/tests/test_manifest_state_restore.py:1:1: F401 'asyncio' imported but unused +./src/tests/test_manifest_state_restore.py:38:80: E501 line too long (80 > 79 characters) +./src/tests/test_manifest_state_restore.py:51:80: E501 line too long (81 > 79 characters) +./src/tests/test_memory_protection.py:6:1: E402 module level import not at top of file +./src/tests/test_memory_protection.py:8:1: E402 module level import not at top of file +./src/tests/test_menu_navigation.py:9:1: E402 module level import not at top of file +./src/tests/test_menu_navigation.py:46:80: E501 line too long (85 > 79 characters) +./src/tests/test_menu_notifications.py:10:1: E402 module level import not at top of file +./src/tests/test_menu_notifications.py:19:80: E501 line too long (80 > 79 characters) +./src/tests/test_menu_options.py:9:1: E402 module level import not at top of file +./src/tests/test_menu_options.py:48:80: E501 line too long (85 > 79 characters) +./src/tests/test_menu_search.py:9:1: E402 module level import not at top of file +./src/tests/test_migrations.py:8:1: E402 module level import not at top of file +./src/tests/test_migrations.py:36:80: E501 line too long (86 > 79 characters) +./src/tests/test_migrations.py:104:80: E501 line too long (81 > 79 characters) +./src/tests/test_modify_ssh_managed_entries.py:9:1: E402 module level import not at top of file +./src/tests/test_modify_ssh_managed_entries.py:10:1: E402 module level import not at top of file +./src/tests/test_modify_ssh_managed_entries.py:11:1: E402 module level import not at top of file +./src/tests/test_modify_ssh_managed_entries.py:27:80: E501 line too long (82 > 79 characters) +./src/tests/test_multiple_deltas_sync.py:5:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_multiple_deltas_sync.py:32:39: F811 redefinition of unused 'dummy_nostr_client' from line 5 +./src/tests/test_multiple_deltas_sync.py:68:47: F811 redefinition of unused 'dummy_nostr_client' from line 5 +./src/tests/test_multiple_fingerprint_prompt.py:12:80: E501 line too long (81 > 79 characters) +./src/tests/test_multiple_fingerprint_prompt.py:32:80: E501 line too long (85 > 79 characters) +./src/tests/test_multiple_fingerprint_prompt.py:40:80: E501 line too long (81 > 79 characters) +./src/tests/test_multiple_fingerprint_prompt.py:43:80: E501 line too long (84 > 79 characters) +./src/tests/test_multiple_fingerprint_prompt.py:51:80: E501 line too long (83 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:1:1: F401 'pytest' imported but unused +./src/tests/test_new_seed_profile_creation.py:10:80: E501 line too long (108 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:16:80: E501 line too long (81 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:18:80: E501 line too long (82 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:19:80: E501 line too long (86 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:32:80: E501 line too long (80 > 79 characters) +./src/tests/test_new_seed_profile_creation.py:48:80: E501 line too long (84 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:37:80: E501 line too long (81 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:40:80: E501 line too long (84 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:43:80: E501 line too long (82 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:46:9: F841 local variable 'pm' is assigned to but never used +./src/tests/test_noninteractive_init_unlock.py:46:80: E501 line too long (83 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:66:80: E501 line too long (83 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:76:80: E501 line too long (81 > 79 characters) +./src/tests/test_noninteractive_init_unlock.py:79:80: E501 line too long (84 > 79 characters) +./src/tests/test_nostr_backup.py:10:1: E402 module level import not at top of file +./src/tests/test_nostr_backup.py:11:1: E402 module level import not at top of file +./src/tests/test_nostr_backup.py:12:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_nostr_backup.py:12:1: E402 module level import not at top of file +./src/tests/test_nostr_backup.py:13:1: E402 module level import not at top of file +./src/tests/test_nostr_backup.py:14:1: E402 module level import not at top of file +./src/tests/test_nostr_backup.py:42:80: E501 line too long (80 > 79 characters) +./src/tests/test_nostr_client.py:12:1: E402 module level import not at top of file +./src/tests/test_nostr_client.py:13:1: E402 module level import not at top of file +./src/tests/test_nostr_client.py:14:1: E402 module level import not at top of file +./src/tests/test_nostr_client.py:15:1: F401 'constants' imported but unused +./src/tests/test_nostr_client.py:15:1: E402 module level import not at top of file +./src/tests/test_nostr_client.py:27:13: F841 local variable 'mock_builder' is assigned to but never used +./src/tests/test_nostr_client.py:28:80: E501 line too long (83 > 79 characters) +./src/tests/test_nostr_client.py:83:80: E501 line too long (83 > 79 characters) +./src/tests/test_nostr_client.py:198:80: E501 line too long (84 > 79 characters) +./src/tests/test_nostr_client.py:199:80: E501 line too long (80 > 79 characters) +./src/tests/test_nostr_contract.py:11:1: E402 module level import not at top of file +./src/tests/test_nostr_contract.py:12:1: F401 'nostr.client.Manifest' imported but unused +./src/tests/test_nostr_contract.py:12:1: E402 module level import not at top of file +./src/tests/test_nostr_contract.py:68:80: E501 line too long (80 > 79 characters) +./src/tests/test_nostr_contract.py:70:80: E501 line too long (83 > 79 characters) +./src/tests/test_nostr_dummy_client.py:6:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_nostr_dummy_client.py:12:1: F401 'constants' imported but unused +./src/tests/test_nostr_dummy_client.py:34:40: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_dummy_client.py:55:35: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_dummy_client.py:72:51: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_dummy_client.py:72:80: E501 line too long (83 > 79 characters) +./src/tests/test_nostr_dummy_client.py:74:5: F401 'gzip' imported but unused +./src/tests/test_nostr_dummy_client.py:120:40: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_dummy_client.py:147:66: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_dummy_client.py:147:80: E501 line too long (85 > 79 characters) +./src/tests/test_nostr_entry.py:11:1: E402 module level import not at top of file +./src/tests/test_nostr_entry.py:12:1: E402 module level import not at top of file +./src/tests/test_nostr_entry.py:13:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_nostr_entry.py:13:1: E402 module level import not at top of file +./src/tests/test_nostr_entry.py:14:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:14:1: F811 redefinition of unused 'os' from line 1 +./src/tests/test_nostr_index_size.py:18:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:19:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:20:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:21:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:22:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:23:1: E402 module level import not at top of file +./src/tests/test_nostr_index_size.py:59:80: E501 line too long (82 > 79 characters) +./src/tests/test_nostr_index_size.py:74:80: E501 line too long (88 > 79 characters) +./src/tests/test_nostr_index_size.py:80:80: E501 line too long (84 > 79 characters) +./src/tests/test_nostr_index_size.py:88:80: E501 line too long (84 > 79 characters) +./src/tests/test_nostr_index_size.py:91:80: E501 line too long (83 > 79 characters) +./src/tests/test_nostr_legacy_decrypt_fallback.py:17:80: E501 line too long (84 > 79 characters) +./src/tests/test_nostr_legacy_key_fallback.py:6:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_nostr_legacy_key_fallback.py:11:45: F811 redefinition of unused 'dummy_nostr_client' from line 6 +./src/tests/test_nostr_legacy_key_fallback.py:29:80: E501 line too long (86 > 79 characters) +./src/tests/test_nostr_qr.py:9:1: E402 module level import not at top of file +./src/tests/test_nostr_qr.py:10:1: E402 module level import not at top of file +./src/tests/test_nostr_qr.py:11:1: F401 'seedpass.core.manager.TotpManager' imported but unused +./src/tests/test_nostr_qr.py:11:1: E402 module level import not at top of file +./src/tests/test_nostr_qr.py:12:1: E402 module level import not at top of file +./src/tests/test_nostr_qr.py:13:1: E402 module level import not at top of file +./src/tests/test_nostr_real.py:16:1: E402 module level import not at top of file +./src/tests/test_nostr_real.py:17:1: E402 module level import not at top of file +./src/tests/test_nostr_real.py:41:80: E501 line too long (80 > 79 characters) +./src/tests/test_nostr_restore_flow.py:32:80: E501 line too long (81 > 79 characters) +./src/tests/test_nostr_sdk_workflow.py:45:80: E501 line too long (106 > 79 characters) +./src/tests/test_nostr_snapshot.py:92:80: E501 line too long (87 > 79 characters) +./src/tests/test_nostr_snapshot.py:99:80: E501 line too long (84 > 79 characters) +./src/tests/test_offline_mode_profile_creation.py:4:1: F401 'pytest' imported but unused +./src/tests/test_parent_seed_backup.py:9:1: E402 module level import not at top of file +./src/tests/test_parent_seed_backup.py:10:1: F401 'constants.DEFAULT_SEED_BACKUP_FILENAME' imported but unused +./src/tests/test_parent_seed_backup.py:10:1: E402 module level import not at top of file +./src/tests/test_parent_seed_backup.py:18:80: E501 line too long (87 > 79 characters) +./src/tests/test_parent_seed_backup.py:24:80: E501 line too long (81 > 79 characters) +./src/tests/test_parent_seed_backup.py:29:80: E501 line too long (80 > 79 characters) +./src/tests/test_parent_seed_backup.py:47:80: E501 line too long (80 > 79 characters) +./src/tests/test_parent_seed_backup.py:50:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_change.py:11:1: E402 module level import not at top of file +./src/tests/test_password_change.py:12:1: E402 module level import not at top of file +./src/tests/test_password_change.py:13:1: E402 module level import not at top of file +./src/tests/test_password_change.py:14:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_password_change.py:14:1: E402 module level import not at top of file +./src/tests/test_password_change.py:15:1: E402 module level import not at top of file +./src/tests/test_password_change.py:42:80: E501 line too long (83 > 79 characters) +./src/tests/test_password_generation_policy.py:7:1: E402 module level import not at top of file +./src/tests/test_password_generation_policy.py:16:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_helpers.py:11:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_length_constraints.py:7:1: E402 module level import not at top of file +./src/tests/test_password_length_constraints.py:8:1: E402 module level import not at top of file +./src/tests/test_password_length_constraints.py:17:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_notes_display.py:10:1: E402 module level import not at top of file +./src/tests/test_password_notes_display.py:11:1: E402 module level import not at top of file +./src/tests/test_password_notes_display.py:12:1: E402 module level import not at top of file +./src/tests/test_password_notes_display.py:13:1: E402 module level import not at top of file +./src/tests/test_password_notes_display.py:30:74: E741 ambiguous variable name 'l' +./src/tests/test_password_notes_display.py:30:80: E501 line too long (84 > 79 characters) +./src/tests/test_password_notes_display.py:39:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_notes_display.py:41:80: E501 line too long (80 > 79 characters) +./src/tests/test_password_prompt.py:4:1: F401 'pytest' imported but unused +./src/tests/test_password_prompt.py:12:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_prompt.py:19:80: E501 line too long (82 > 79 characters) +./src/tests/test_password_prompt.py:27:80: E501 line too long (85 > 79 characters) +./src/tests/test_password_properties.py:8:1: E402 module level import not at top of file +./src/tests/test_password_properties.py:9:1: E402 module level import not at top of file +./src/tests/test_password_properties.py:18:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_shuffle_consistency.py:6:1: E402 module level import not at top of file +./src/tests/test_password_shuffle_consistency.py:15:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_special_chars.py:7:1: E402 module level import not at top of file +./src/tests/test_password_special_chars.py:9:1: E402 module level import not at top of file +./src/tests/test_password_special_chars.py:18:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_special_modes.py:7:1: E402 module level import not at top of file +./src/tests/test_password_special_modes.py:8:1: E402 module level import not at top of file +./src/tests/test_password_special_modes.py:17:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_unlock_after_change.py:10:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:11:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:12:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:13:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:14:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:15:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:16:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:17:1: E402 module level import not at top of file +./src/tests/test_password_unlock_after_change.py:19:80: E501 line too long (102 > 79 characters) +./src/tests/test_password_unlock_after_change.py:70:80: E501 line too long (88 > 79 characters) +./src/tests/test_password_unlock_after_change.py:84:80: E501 line too long (83 > 79 characters) +./src/tests/test_password_unlock_after_change.py:85:80: E501 line too long (86 > 79 characters) +./src/tests/test_password_unlock_after_change.py:86:80: E501 line too long (88 > 79 characters) +./src/tests/test_pgp_entry.py:9:1: E402 module level import not at top of file +./src/tests/test_pgp_entry.py:10:1: E402 module level import not at top of file +./src/tests/test_pgp_entry.py:11:1: E402 module level import not at top of file +./src/tests/test_pgp_entry.py:54:80: E501 line too long (85 > 79 characters) +./src/tests/test_portable_backup.py:12:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:13:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:14:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:15:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:16:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:17:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:18:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:19:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:20:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:22:80: E501 line too long (102 > 79 characters) +./src/tests/test_portable_backup.py:73:1: E402 module level import not at top of file +./src/tests/test_portable_backup.py:91:80: E501 line too long (80 > 79 characters) +./src/tests/test_portable_backup.py:143:80: E501 line too long (81 > 79 characters) +./src/tests/test_post_sync_messages.py:7:1: E402 module level import not at top of file +./src/tests/test_profile_cleanup.py:21:80: E501 line too long (81 > 79 characters) +./src/tests/test_profile_cleanup.py:43:80: E501 line too long (83 > 79 characters) +./src/tests/test_profile_deletion_sync.py:9:1: E402 module level import not at top of file +./src/tests/test_profile_deletion_sync.py:10:1: E402 module level import not at top of file +./src/tests/test_profile_deletion_sync.py:11:1: E402 module level import not at top of file +./src/tests/test_profile_export_import.py:1:1: F401 'pathlib.Path' imported but unused +./src/tests/test_profile_init_integration.py:11:80: E501 line too long (84 > 79 characters) +./src/tests/test_profile_init_integration.py:13:80: E501 line too long (87 > 79 characters) +./src/tests/test_profile_init_integration.py:27:80: E501 line too long (83 > 79 characters) +./src/tests/test_profile_init_integration.py:39:80: E501 line too long (83 > 79 characters) +./src/tests/test_profile_init_integration.py:40:80: E501 line too long (86 > 79 characters) +./src/tests/test_profile_management.py:12:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:13:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:14:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:15:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_profile_management.py:15:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:16:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:17:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:18:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:19:1: E402 module level import not at top of file +./src/tests/test_profile_management.py:30:80: E501 line too long (83 > 79 characters) +./src/tests/test_profile_management.py:41:80: E501 line too long (84 > 79 characters) +./src/tests/test_profile_management.py:43:80: E501 line too long (85 > 79 characters) +./src/tests/test_profile_management.py:55:80: E501 line too long (80 > 79 characters) +./src/tests/test_profiles.py:7:1: E402 module level import not at top of file +./src/tests/test_profiles.py:8:1: E402 module level import not at top of file +./src/tests/test_profiles.py:9:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_profiles.py:9:1: E402 module level import not at top of file +./src/tests/test_profiles.py:10:1: E402 module level import not at top of file +./src/tests/test_profiles.py:11:1: E402 module level import not at top of file +./src/tests/test_profiles.py:13:80: E501 line too long (108 > 79 characters) +./src/tests/test_profiles.py:39:80: E501 line too long (86 > 79 characters) +./src/tests/test_profiles.py:40:80: E501 line too long (83 > 79 characters) +./src/tests/test_profiles.py:41:80: E501 line too long (86 > 79 characters) +./src/tests/test_profiles.py:43:80: E501 line too long (82 > 79 characters) +./src/tests/test_profiles.py:55:51: F811 redefinition of unused 'dummy_nostr_client' from line 9 +./src/tests/test_profiles.py:86:60: F811 redefinition of unused 'dummy_nostr_client' from line 9 +./src/tests/test_publish_json_result.py:12:1: E402 module level import not at top of file +./src/tests/test_publish_json_result.py:13:1: E402 module level import not at top of file +./src/tests/test_publish_json_result.py:22:80: E501 line too long (83 > 79 characters) +./src/tests/test_publish_json_result.py:85:80: E501 line too long (88 > 79 characters) +./src/tests/test_publish_json_result.py:86:80: E501 line too long (82 > 79 characters) +./src/tests/test_quick_unlock_default.py:6:1: F401 'pytest' imported but unused +./src/tests/test_quick_unlock_default.py:10:1: E402 module level import not at top of file +./src/tests/test_quick_unlock_default.py:11:1: E402 module level import not at top of file +./src/tests/test_quick_unlock_default.py:12:1: E402 module level import not at top of file +./src/tests/test_quick_unlock_default.py:34:80: E501 line too long (87 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:31:80: E501 line too long (81 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:32:80: E501 line too long (82 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:65:80: E501 line too long (83 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:77:80: E501 line too long (88 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:119:80: E501 line too long (83 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:137:80: E501 line too long (82 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:144:80: E501 line too long (80 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:154:80: E501 line too long (82 > 79 characters) +./src/tests/test_restore_from_nostr_setup.py:167:80: E501 line too long (81 > 79 characters) +./src/tests/test_retrieve_pause_sensitive_entries.py:8:1: E402 module level import not at top of file +./src/tests/test_search_entries.py:9:1: E402 module level import not at top of file +./src/tests/test_search_entries.py:10:1: E402 module level import not at top of file +./src/tests/test_search_entries.py:11:1: E402 module level import not at top of file +./src/tests/test_search_entries.py:12:1: E402 module level import not at top of file +./src/tests/test_search_entries.py:31:80: E501 line too long (88 > 79 characters) +./src/tests/test_search_entries.py:43:80: E501 line too long (83 > 79 characters) +./src/tests/test_search_entries.py:56:80: E501 line too long (83 > 79 characters) +./src/tests/test_search_entries.py:65:9: F841 local variable 'idx_pw' is assigned to but never used +./src/tests/test_search_entries.py:87:9: F841 local variable 'idx' is assigned to but never used +./src/tests/test_search_entries.py:98:9: F841 local variable 'idx' is assigned to but never used +./src/tests/test_search_entries.py:122:80: E501 line too long (81 > 79 characters) +./src/tests/test_search_entries.py:134:80: E501 line too long (81 > 79 characters) +./src/tests/test_search_entries.py:151:80: E501 line too long (80 > 79 characters) +./src/tests/test_secret_mode.py:11:1: E402 module level import not at top of file +./src/tests/test_secret_mode.py:12:1: E402 module level import not at top of file +./src/tests/test_secret_mode.py:13:1: E402 module level import not at top of file +./src/tests/test_secret_mode.py:14:1: E402 module level import not at top of file +./src/tests/test_secret_mode.py:28:70: E741 ambiguous variable name 'l' +./src/tests/test_secret_mode.py:28:80: E501 line too long (80 > 79 characters) +./src/tests/test_secret_mode.py:65:80: E501 line too long (88 > 79 characters) +./src/tests/test_secret_mode.py:115:80: E501 line too long (88 > 79 characters) +./src/tests/test_secret_mode_profile_creation.py:3:1: F401 'types.SimpleNamespace' imported but unused +./src/tests/test_secret_mode_profile_creation.py:5:1: F401 'pytest' imported but unused +./src/tests/test_seed_entry.py:10:1: E402 module level import not at top of file +./src/tests/test_seed_entry.py:11:1: E402 module level import not at top of file +./src/tests/test_seed_entry.py:12:1: E402 module level import not at top of file +./src/tests/test_seed_entry.py:13:1: E402 module level import not at top of file +./src/tests/test_seed_entry.py:14:1: E402 module level import not at top of file +./src/tests/test_seed_entry.py:15:1: E402 module level import not at top of file +./src/tests/test_seed_generation.py:11:80: E501 line too long (89 > 79 characters) +./src/tests/test_seed_generation.py:21:80: E501 line too long (81 > 79 characters) +./src/tests/test_seed_import.py:11:1: E402 module level import not at top of file +./src/tests/test_seed_import.py:12:1: E402 module level import not at top of file +./src/tests/test_seed_migration.py:13:1: E402 module level import not at top of file +./src/tests/test_seed_prompt.py:20:80: E501 line too long (82 > 79 characters) +./src/tests/test_seed_prompt.py:61:80: E501 line too long (84 > 79 characters) +./src/tests/test_seed_prompt.py:89:80: E501 line too long (80 > 79 characters) +./src/tests/test_seed_word_by_word_flow.py:52:80: E501 line too long (80 > 79 characters) +./src/tests/test_seed_word_by_word_flow.py:85:80: E501 line too long (80 > 79 characters) +./src/tests/test_seedqr_encoding.py:6:1: E402 module level import not at top of file +./src/tests/test_service_classes.py:5:1: F401 'pytest' imported but unused +./src/tests/test_service_classes.py:7:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_service_classes.py:43:50: F811 redefinition of unused 'dummy_nostr_client' from line 7 +./src/tests/test_service_classes.py:69:80: E501 line too long (86 > 79 characters) +./src/tests/test_service_classes.py:70:80: E501 line too long (84 > 79 characters) +./src/tests/test_service_classes.py:128:80: E501 line too long (81 > 79 characters) +./src/tests/test_settings_menu.py:16:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:17:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:18:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:19:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:20:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_settings_menu.py:20:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:21:1: E402 module level import not at top of file +./src/tests/test_settings_menu.py:60:80: E501 line too long (107 > 79 characters) +./src/tests/test_settings_menu.py:63:80: E501 line too long (89 > 79 characters) +./src/tests/test_settings_menu.py:116:80: E501 line too long (81 > 79 characters) +./src/tests/test_settings_menu.py:136:80: E501 line too long (81 > 79 characters) +./src/tests/test_settings_menu.py:153:80: E501 line too long (88 > 79 characters) +./src/tests/test_settings_menu.py:161:80: E501 line too long (82 > 79 characters) +./src/tests/test_ssh_entry.py:9:1: E402 module level import not at top of file +./src/tests/test_ssh_entry.py:10:1: E402 module level import not at top of file +./src/tests/test_ssh_entry.py:11:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_ssh_entry.py:11:1: E402 module level import not at top of file +./src/tests/test_ssh_entry.py:12:1: E402 module level import not at top of file +./src/tests/test_ssh_entry_valid.py:9:1: E402 module level import not at top of file +./src/tests/test_ssh_entry_valid.py:10:1: E402 module level import not at top of file +./src/tests/test_ssh_entry_valid.py:11:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_ssh_entry_valid.py:11:1: E402 module level import not at top of file +./src/tests/test_ssh_entry_valid.py:12:1: E402 module level import not at top of file +./src/tests/test_ssh_entry_valid.py:13:1: E402 module level import not at top of file +./src/tests/test_stats_screen.py:4:1: F401 'pytest' imported but unused +./src/tests/test_stats_screen.py:9:1: E402 module level import not at top of file +./src/tests/test_stats_screen.py:50:80: E501 line too long (85 > 79 characters) +./src/tests/test_sync_race_conditions.py:35:80: E501 line too long (82 > 79 characters) +./src/tests/test_tag_persistence.py:9:1: E402 module level import not at top of file +./src/tests/test_tag_persistence.py:10:1: E402 module level import not at top of file +./src/tests/test_tag_persistence.py:11:1: E402 module level import not at top of file +./src/tests/test_tag_persistence.py:12:1: E402 module level import not at top of file +./src/tests/test_totp.py:9:1: E402 module level import not at top of file +./src/tests/test_totp.py:10:1: E402 module level import not at top of file +./src/tests/test_totp.py:28:80: E501 line too long (83 > 79 characters) +./src/tests/test_totp_entry.py:4:1: F401 'unittest.mock.patch' imported but unused +./src/tests/test_totp_entry.py:6:1: F401 'pytest' imported but unused +./src/tests/test_totp_entry.py:12:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:13:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:14:1: F401 'seedpass.core.vault.Vault' imported but unused +./src/tests/test_totp_entry.py:14:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:15:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:16:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:17:1: E402 module level import not at top of file +./src/tests/test_totp_entry.py:101:80: E501 line too long (86 > 79 characters) +./src/tests/test_totp_uri.py:8:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:7:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:9:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:10:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:11:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:12:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:13:1: E402 module level import not at top of file +./src/tests/test_typer_cli.py:21:80: E501 line too long (81 > 79 characters) +./src/tests/test_typer_cli.py:62:69: E741 ambiguous variable name 'l' +./src/tests/test_typer_cli.py:62:80: E501 line too long (80 > 79 characters) +./src/tests/test_typer_cli.py:79:80: E501 line too long (82 > 79 characters) +./src/tests/test_typer_cli.py:80:80: E501 line too long (81 > 79 characters) +./src/tests/test_typer_cli.py:94:80: E501 line too long (82 > 79 characters) +./src/tests/test_typer_cli.py:95:80: E501 line too long (81 > 79 characters) +./src/tests/test_typer_cli.py:113:80: E501 line too long (82 > 79 characters) +./src/tests/test_typer_cli.py:115:80: E501 line too long (85 > 79 characters) +./src/tests/test_typer_cli.py:131:80: E501 line too long (87 > 79 characters) +./src/tests/test_typer_cli.py:133:80: E501 line too long (86 > 79 characters) +./src/tests/test_typer_cli.py:187:80: E501 line too long (83 > 79 characters) +./src/tests/test_typer_cli.py:215:80: E501 line too long (82 > 79 characters) +./src/tests/test_typer_cli.py:323:80: E501 line too long (83 > 79 characters) +./src/tests/test_typer_cli.py:398:80: E501 line too long (86 > 79 characters) +./src/tests/test_typer_cli.py:412:80: E501 line too long (81 > 79 characters) +./src/tests/test_typer_cli.py:481:80: E501 line too long (82 > 79 characters) +./src/tests/test_typer_cli.py:491:80: E501 line too long (80 > 79 characters) +./src/tests/test_typer_cli.py:506:80: E501 line too long (80 > 79 characters) +./src/tests/test_typer_cli.py:599:80: E501 line too long (80 > 79 characters) +./src/tests/test_unlock_sync.py:10:1: E402 module level import not at top of file +./src/tests/test_unlock_sync.py:11:1: E402 module level import not at top of file +./src/tests/test_unlock_sync.py:26:80: E501 line too long (82 > 79 characters) +./src/tests/test_unlock_sync.py:45:80: E501 line too long (83 > 79 characters) +./src/tests/test_unlock_sync.py:75:80: E501 line too long (84 > 79 characters) +./src/tests/test_unlock_sync.py:76:80: E501 line too long (82 > 79 characters) +./src/tests/test_vault_initialization.py:4:1: F401 'unittest.mock.patch' imported but unused +./src/tests/test_vault_initialization.py:8:1: E402 module level import not at top of file +./src/tests/test_vault_initialization.py:9:1: E402 module level import not at top of file +./src/tests/test_vault_initialization.py:11:80: E501 line too long (108 > 79 characters) +./src/tests/test_vault_initialization.py:23:80: E501 line too long (86 > 79 characters) +./src/tests/test_verbose_timing.py:5:1: F401 'helpers.dummy_nostr_client' imported but unused +./src/tests/test_verbose_timing.py:18:80: E501 line too long (87 > 79 characters) +./src/tests/test_verbose_timing.py:23:37: F811 redefinition of unused 'dummy_nostr_client' from line 5 +./src/utils/__init__.py:9:1: E402 module level import not at top of file +./src/utils/__init__.py:10:1: E402 module level import not at top of file +./src/utils/__init__.py:19:1: E402 module level import not at top of file +./src/utils/__init__.py:27:1: E402 module level import not at top of file +./src/utils/__init__.py:28:1: E402 module level import not at top of file +./src/utils/__init__.py:29:1: E402 module level import not at top of file +./src/utils/__init__.py:30:1: E402 module level import not at top of file +./src/utils/__init__.py:31:1: E402 module level import not at top of file +./src/utils/__init__.py:37:1: E402 module level import not at top of file +./src/utils/__init__.py:42:80: E501 line too long (81 > 79 characters) +./src/utils/checksum.py:6:80: E501 line too long (89 > 79 characters) +./src/utils/checksum.py:7:80: E501 line too long (85 > 79 characters) +./src/utils/checksum.py:10:80: E501 line too long (87 > 79 characters) +./src/utils/checksum.py:15:1: F401 'sys' imported but unused +./src/utils/checksum.py:16:1: F401 'os' imported but unused +./src/utils/checksum.py:22:1: F401 'constants.APP_DIR' imported but unused +./src/utils/checksum.py:22:1: F401 'constants.SCRIPT_CHECKSUM_FILE' imported but unused +./src/utils/checksum.py:48:80: E501 line too long (82 > 79 characters) +./src/utils/checksum.py:59:80: E501 line too long (80 > 79 characters) +./src/utils/checksum.py:62:80: E501 line too long (87 > 79 characters) +./src/utils/checksum.py:72:80: E501 line too long (84 > 79 characters) +./src/utils/checksum.py:97:80: E501 line too long (85 > 79 characters) +./src/utils/checksum.py:102:80: E501 line too long (82 > 79 characters) +./src/utils/checksum.py:125:80: E501 line too long (88 > 79 characters) +./src/utils/checksum.py:129:80: E501 line too long (87 > 79 characters) +./src/utils/checksum.py:133:80: E501 line too long (84 > 79 characters) +./src/utils/checksum.py:140:80: E501 line too long (80 > 79 characters) +./src/utils/checksum.py:142:80: E501 line too long (92 > 79 characters) +./src/utils/checksum.py:156:80: E501 line too long (83 > 79 characters) +./src/utils/checksum.py:160:80: E501 line too long (81 > 79 characters) +./src/utils/checksum.py:167:80: E501 line too long (80 > 79 characters) +./src/utils/checksum.py:183:80: E501 line too long (89 > 79 characters) +./src/utils/checksum.py:194:80: E501 line too long (89 > 79 characters) +./src/utils/checksum.py:202:80: E501 line too long (83 > 79 characters) +./src/utils/checksum.py:209:80: E501 line too long (92 > 79 characters) +./src/utils/checksum.py:219:80: E501 line too long (87 > 79 characters) +./src/utils/clipboard.py:19:80: E501 line too long (82 > 79 characters) +./src/utils/clipboard.py:27:80: E501 line too long (85 > 79 characters) +./src/utils/clipboard.py:30:80: E501 line too long (86 > 79 characters) +./src/utils/clipboard.py:36:80: E501 line too long (86 > 79 characters) +./src/utils/file_lock.py:1:80: E501 line too long (80 > 79 characters) +./src/utils/fingerprint.py:6:80: E501 line too long (83 > 79 characters) +./src/utils/fingerprint.py:7:80: E501 line too long (80 > 79 characters) +./src/utils/fingerprint.py:36:80: E501 line too long (81 > 79 characters) +./src/utils/fingerprint_manager.py:3:1: F401 'os' imported but unused +./src/utils/fingerprint_manager.py:23:80: E501 line too long (82 > 79 characters) +./src/utils/fingerprint_manager.py:48:80: E501 line too long (89 > 79 characters) +./src/utils/fingerprint_manager.py:65:80: E501 line too long (80 > 79 characters) +./src/utils/fingerprint_manager.py:69:80: E501 line too long (84 > 79 characters) +./src/utils/fingerprint_manager.py:70:80: E501 line too long (86 > 79 characters) +./src/utils/fingerprint_manager.py:79:80: E501 line too long (81 > 79 characters) +./src/utils/fingerprint_manager.py:83:80: E501 line too long (83 > 79 characters) +./src/utils/fingerprint_manager.py:105:80: E501 line too long (98 > 79 characters) +./src/utils/fingerprint_manager.py:142:80: E501 line too long (84 > 79 characters) +./src/utils/fingerprint_manager.py:178:80: E501 line too long (85 > 79 characters) +./src/utils/fingerprint_manager.py:242:80: E501 line too long (81 > 79 characters) +./src/utils/fingerprint_manager.py:248:80: E501 line too long (84 > 79 characters) +./src/utils/imghdr_stub.py:103:80: E501 line too long (82 > 79 characters) +./src/utils/imghdr_stub.py:112:80: E501 line too long (82 > 79 characters) +./src/utils/imghdr_stub.py:121:80: E501 line too long (82 > 79 characters) +./src/utils/imghdr_stub.py:195:80: E501 line too long (81 > 79 characters) +./src/utils/key_derivation.py:24:1: F401 'typing.Optional' imported but unused +./src/utils/key_derivation.py:78:80: E501 line too long (99 > 79 characters) +./src/utils/key_derivation.py:80:80: E501 line too long (97 > 79 characters) +./src/utils/key_derivation.py:81:80: E501 line too long (99 > 79 characters) +./src/utils/key_derivation.py:87:80: E501 line too long (82 > 79 characters) +./src/utils/key_derivation.py:101:80: E501 line too long (82 > 79 characters) +./src/utils/key_derivation.py:151:80: E501 line too long (80 > 79 characters) +./src/utils/key_derivation.py:172:80: E501 line too long (84 > 79 characters) +./src/utils/key_derivation.py:175:80: E501 line too long (86 > 79 characters) +./src/utils/key_derivation.py:178:80: E501 line too long (86 > 79 characters) +./src/utils/key_derivation.py:188:80: E501 line too long (87 > 79 characters) +./src/utils/key_derivation.py:205:80: E501 line too long (85 > 79 characters) +./src/utils/key_derivation.py:240:80: E501 line too long (85 > 79 characters) +./src/utils/key_derivation.py:266:80: E501 line too long (80 > 79 characters) +./src/utils/key_derivation.py:286:80: E501 line too long (80 > 79 characters) +./src/utils/key_derivation.py:297:80: E501 line too long (86 > 79 characters) +./src/utils/key_validation.py:26:80: E501 line too long (83 > 79 characters) +./src/utils/password_prompt.py:6:80: E501 line too long (94 > 79 characters) +./src/utils/password_prompt.py:7:80: E501 line too long (90 > 79 characters) +./src/utils/password_prompt.py:8:80: E501 line too long (94 > 79 characters) +./src/utils/password_prompt.py:9:80: E501 line too long (86 > 79 characters) +./src/utils/password_prompt.py:11:80: E501 line too long (87 > 79 characters) +./src/utils/password_prompt.py:17:1: F401 'sys' imported but unused +./src/utils/password_prompt.py:39:80: E501 line too long (80 > 79 characters) +./src/utils/password_prompt.py:73:80: E501 line too long (88 > 79 characters) +./src/utils/password_prompt.py:75:80: E501 line too long (93 > 79 characters) +./src/utils/password_prompt.py:76:80: E501 line too long (96 > 79 characters) +./src/utils/password_prompt.py:80:80: E501 line too long (96 > 79 characters) +./src/utils/password_prompt.py:81:80: E501 line too long (98 > 79 characters) +./src/utils/password_prompt.py:87:80: E501 line too long (99 > 79 characters) +./src/utils/password_prompt.py:106:80: E501 line too long (88 > 79 characters) +./src/utils/password_prompt.py:116:80: E501 line too long (99 > 79 characters) +./src/utils/password_prompt.py:121:80: E501 line too long (93 > 79 characters) +./src/utils/password_prompt.py:129:80: E501 line too long (86 > 79 characters) +./src/utils/password_prompt.py:154:80: E501 line too long (85 > 79 characters) +./src/utils/password_prompt.py:159:80: E501 line too long (81 > 79 characters) +./src/utils/password_prompt.py:168:80: E501 line too long (80 > 79 characters) +./src/utils/password_prompt.py:169:80: E501 line too long (83 > 79 characters) +./src/utils/password_prompt.py:170:80: E501 line too long (80 > 79 characters) +./src/utils/password_prompt.py:192:80: E501 line too long (88 > 79 characters) +./src/utils/password_prompt.py:223:80: E501 line too long (96 > 79 characters) +./src/utils/password_prompt.py:226:80: E501 line too long (89 > 79 characters) +./src/utils/password_prompt.py:261:80: E501 line too long (90 > 79 characters) +./src/utils/password_prompt.py:263:80: E501 line too long (101 > 79 characters) +./src/utils/seed_prompt.py:109:80: E501 line too long (84 > 79 characters) +./src/utils/seed_prompt.py:121:80: E501 line too long (82 > 79 characters) +./src/utils/seed_prompt.py:177:80: E501 line too long (80 > 79 characters) +./src/utils/seed_prompt.py:180:80: E501 line too long (87 > 79 characters) +./src/utils/seed_prompt.py:189:80: E501 line too long (88 > 79 characters) +./src/utils/seed_prompt.py:192:80: E501 line too long (85 > 79 characters) +./src/utils/seed_prompt.py:200:80: E501 line too long (81 > 79 characters) +./src/utils/terminal_utils.py:23:80: E501 line too long (80 > 79 characters) +./src/utils/terminal_utils.py:41:80: E501 line too long (83 > 79 characters) +./src/utils/terminal_utils.py:45:80: E501 line too long (123 > 79 characters) +./src/utils/terminal_utils.py:80:80: E501 line too long (81 > 79 characters) +./src/utils/terminal_utils.py:85:80: E501 line too long (123 > 79 characters) +./tests/perf/test_bip85_cache.py:12:80: E501 line too long (88 > 79 characters) +./tests/perf/test_bip85_cache.py:41:5: F841 local variable 'uncached_time' is assigned to but never used +./tests/perf/test_bip85_cache.py:48:5: F841 local variable 'cached_time' is assigned to but never used +./torch/node_modules/torch-lock/find_dead_code.py:3:1: F401 'sys' imported but unused +./torch/node_modules/torch-lock/find_dead_code.py:5:1: E302 expected 2 blank lines, found 1 +./torch/node_modules/torch-lock/find_dead_code.py:8:80: E501 line too long (93 > 79 characters) +./torch/node_modules/torch-lock/find_dead_code.py:15:1: E302 expected 2 blank lines, found 1 +./torch/node_modules/torch-lock/find_dead_code.py:41:14: E111 indentation is not a multiple of 4 +./torch/node_modules/torch-lock/find_dead_code.py:41:14: E117 over-indented +./torch/node_modules/torch-lock/find_dead_code.py:44:13: E122 continuation line missing indentation or outdented +./torch/node_modules/torch-lock/find_dead_code.py:62:17: E261 at least two spaces before inline comment +./torch/node_modules/torch-lock/find_dead_code.py:64:1: E302 expected 2 blank lines, found 1 +./torch/node_modules/torch-lock/find_dead_code.py:68:80: E501 line too long (81 > 79 characters) +./torch/node_modules/torch-lock/find_dead_code.py:72:25: E261 at least two spaces before inline comment +./torch/node_modules/torch-lock/find_dead_code.py:73:31: E261 at least two spaces before inline comment +./torch/node_modules/torch-lock/find_dead_code.py:101:1: E305 expected 2 blank lines after class or function definition, found 1 +``` + +### pytest Output +``` +============================= test session starts ============================== +platform linux -- Python 3.12.12, pytest-8.4.1, pluggy-1.6.0 +rootdir: /app +configfile: pytest.ini +testpaths: src/tests, tests +plugins: anyio-4.9.0, hypothesis-6.136.7, cov-6.2.1, xdist-3.8.0 +created: 4/4 workers +4 workers [661 items] + +scheduling tests via LoadScheduling + +src/tests/test_bip85_vectors.py::test_bip85_mnemonic_12 +src/tests/test_api_new_endpoints.py::test_vault_lock_endpoint[asyncio] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-totp Email --secret JBSW...] +src/tests/test_add_new_fingerprint_words.py::test_add_new_fingerprint_word_entry_exits +[gw0] [ 0%] PASSED src/tests/test_add_new_fingerprint_words.py::test_add_new_fingerprint_word_entry_exits +src/tests/test_add_tags_from_retrieve.py::test_add_tags_from_retrieve +[gw2] [ 0%] PASSED src/tests/test_bip85_vectors.py::test_bip85_mnemonic_12 +src/tests/test_bip85_vectors.py::test_bip85_mnemonic_24 +[gw2] [ 0%] PASSED src/tests/test_bip85_vectors.py::test_bip85_mnemonic_24 +src/tests/test_bip85_vectors.py::test_bip85_symmetric_key +[gw2] [ 0%] PASSED src/tests/test_bip85_vectors.py::test_bip85_symmetric_key +src/tests/test_bip85_vectors.py::test_derive_totp_secret +[gw2] [ 0%] PASSED src/tests/test_bip85_vectors.py::test_derive_totp_secret +src/tests/test_bip85_vectors.py::test_derive_ssh_key +[gw2] [ 0%] PASSED src/tests/test_bip85_vectors.py::test_derive_ssh_key +src/tests/test_bip85_vectors.py::test_derive_seed_phrase +[gw3] [ 1%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-totp Email --secret JBSW...] +[gw2] [ 1%] PASSED src/tests/test_bip85_vectors.py::test_derive_seed_phrase +src/tests/test_bip85_vectors.py::test_invalid_params +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry archive 1] +[gw2] [ 1%] PASSED src/tests/test_bip85_vectors.py::test_invalid_params +src/tests/test_checksum_utils.py::test_json_checksum +[gw2] [ 1%] PASSED src/tests/test_checksum_utils.py::test_json_checksum +src/tests/test_checksum_utils.py::test_calculate_checksum +[gw2] [ 1%] PASSED src/tests/test_checksum_utils.py::test_calculate_checksum +src/tests/test_checksum_utils.py::test_calculate_checksum_missing +[gw2] [ 1%] PASSED src/tests/test_checksum_utils.py::test_calculate_checksum_missing +src/tests/test_checksum_utils.py::test_verify_and_update +[gw2] [ 1%] PASSED src/tests/test_checksum_utils.py::test_verify_and_update +src/tests/test_checksum_utils.py::test_initialize_checksum +[gw2] [ 2%] PASSED src/tests/test_checksum_utils.py::test_initialize_checksum +src/tests/test_cli_clipboard_flag.py::test_entry_get_handles_missing_clipboard +[gw3] [ 2%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry archive 1] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry export-totp --file totp.json] +[gw2] [ 2%] PASSED src/tests/test_cli_clipboard_flag.py::test_entry_get_handles_missing_clipboard +src/tests/test_cli_clipboard_flag.py::test_entry_get_no_clipboard_flag +[gw3] [ 2%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry export-totp --file totp.json] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry get "GitHub"] +[gw2] [ 2%] PASSED src/tests/test_cli_clipboard_flag.py::test_entry_get_no_clipboard_flag +src/tests/test_cli_config_set_extra.py::test_config_set_variants[secret_mode_enabled-true-set_secret_mode_enabled-True] +[gw3] [ 2%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry get "GitHub"] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry list] +[gw0] [ 3%] PASSED src/tests/test_add_tags_from_retrieve.py::test_add_tags_from_retrieve +src/tests/test_additional_backup.py::test_entry_manager_additional_backup +[gw2] [ 3%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[secret_mode_enabled-true-set_secret_mode_enabled-True] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[clipboard_clear_delay-10-set_clipboard_clear_delay-10] +[gw3] [ 3%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry list] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry list --sort label] +[gw2] [ 3%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[clipboard_clear_delay-10-set_clipboard_clear_delay-10] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[additional_backup_path--set_additional_backup_path-None] +[gw3] [ 3%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry list --sort label] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry modify 1 --key new --value updated] +[gw2] [ 3%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[additional_backup_path--set_additional_backup_path-None] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[backup_interval-5-set_backup_interval-5.0] +[gw3] [ 3%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry modify 1 --key new --value updated] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry search "GitHub"] +[gw2] [ 4%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[backup_interval-5-set_backup_interval-5.0] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[kdf_iterations-123-set_kdf_iterations-123] +[gw3] [ 4%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry search "GitHub"] +[gw0] [ 4%] PASSED src/tests/test_additional_backup.py::test_entry_manager_additional_backup +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry totp-codes] +src/tests/test_api.py::test_token_hashed[asyncio] +[gw2] [ 4%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[kdf_iterations-123-set_kdf_iterations-123] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[kdf_mode-argon2-set_kdf_mode-argon2] +[gw2] [ 4%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[kdf_mode-argon2-set_kdf_mode-argon2] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[quick_unlock-true-set_quick_unlock-True] +[gw2] [ 4%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[quick_unlock-true-set_quick_unlock-True] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[nostr_max_retries-3-set_nostr_max_retries-3] +[gw2] [ 4%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[nostr_max_retries-3-set_nostr_max_retries-3] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[nostr_retry_delay-1.5-set_nostr_retry_delay-1.5] +[gw3] [ 5%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry totp-codes] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry unarchive 1] +[gw2] [ 5%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[nostr_retry_delay-1.5-set_nostr_retry_delay-1.5] +src/tests/test_cli_config_set_extra.py::test_config_set_variants[relays-wss://a.com, wss://b.com-set_relays-expected9] +[gw3] [ 5%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry unarchive 1] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[fingerprint add] +[gw2] [ 5%] PASSED src/tests/test_cli_config_set_extra.py::test_config_set_variants[relays-wss://a.com, wss://b.com-set_relays-expected9] +src/tests/test_cli_core_services.py::test_cli_vault_unlock +[gw3] [ 5%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[fingerprint add] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[fingerprint list] +[gw2] [ 5%] PASSED src/tests/test_cli_core_services.py::test_cli_vault_unlock +src/tests/test_cli_core_services.py::test_cli_entry_add_search_sync +[gw3] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[fingerprint list] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[nostr get-pubkey] +[gw3] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[nostr get-pubkey] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[nostr sync] +[gw3] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[nostr sync] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util generate-password] +[gw2] [ 6%] PASSED src/tests/test_cli_core_services.py::test_cli_entry_add_search_sync +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[api start] +[gw3] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util generate-password] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util generate-password --length 24 --special-mode safe --exclude-ambiguous] +[gw2] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[api start] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[api start --host 0.0.0.0 --port 8000] +[gw3] [ 6%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util generate-password --length 24 --special-mode safe --exclude-ambiguous] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util update-checksum] +[gw2] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[api start --host 0.0.0.0 --port 8000] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config get kdf_iterations] +[gw3] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util update-checksum] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util verify-checksum] +[gw2] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config get kdf_iterations] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config set backup_interval 3600] +[gw3] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[util verify-checksum] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault change-password] +[gw2] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config set backup_interval 3600] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config set kdf_iterations 200000] +[gw3] [ 7%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault change-password] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault lock] +[gw2] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config set kdf_iterations 200000] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config toggle-offline] +[gw3] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault lock] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault reveal-parent-seed] +[gw2] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config toggle-offline] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config toggle-secret-mode] +[gw3] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault reveal-parent-seed] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault reveal-parent-seed --file backup.enc] +[gw2] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[config toggle-secret-mode] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry --help] +[gw3] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault reveal-parent-seed --file backup.enc] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault stats] +[gw2] [ 8%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry --help] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add Example --length 16 --no-special --exclude-ambiguous] +[gw3] [ 9%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[vault stats] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-add_entry-cli_args0-expected_args0-expected_kwargs0-1] +[gw1] [ 9%] PASSED src/tests/test_api_new_endpoints.py::test_vault_lock_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_secret_mode_endpoint[asyncio] +[gw2] [ 9%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add Example --length 16 --no-special --exclude-ambiguous] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-key-value "API Token" --key api --value abc123] +[gw3] [ 9%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-add_entry-cli_args0-expected_args0-expected_kwargs0-1] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-totp-add_totp-cli_args1-expected_args1-expected_kwargs1-otpauth://uri] +[gw2] [ 9%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-key-value "API Token" --key api --value abc123] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-managed-account Trading] +[gw3] [ 9%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-totp-add_totp-cli_args1-expected_args1-expected_kwargs1-otpauth://uri] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-ssh-add_ssh_key-cli_args2-expected_args2-expected_kwargs2-3] +[gw2] [ 9%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-managed-account Trading] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-nostr Chat] +[gw3] [ 10%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-ssh-add_ssh_key-cli_args2-expected_args2-expected_kwargs2-3] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-pgp-add_pgp_key-cli_args3-expected_args3-expected_kwargs3-4] +[gw2] [ 10%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-nostr Chat] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-pgp Personal --user-id me@example.com] +[gw3] [ 10%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-pgp-add_pgp_key-cli_args3-expected_args3-expected_kwargs3-4] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-nostr-add_nostr_key-cli_args4-expected_args4-expected_kwargs4-5] +[gw2] [ 10%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-pgp Personal --user-id me@example.com] +[gw0] [ 10%] PASSED src/tests/test_api.py::test_token_hashed[asyncio] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-seed Backup --words 24] +src/tests/test_api.py::test_cors_and_auth[asyncio] +[gw3] [ 10%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-nostr-add_nostr_key-cli_args4-expected_args4-expected_kwargs4-5] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-seed-add_seed-cli_args5-expected_args5-expected_kwargs5-6] +[gw2] [ 11%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-seed Backup --words 24] +src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-ssh Server --index 0] +[gw3] [ 11%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-seed-add_seed-cli_args5-expected_args5-expected_kwargs5-6] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-key-value-add_key_value-cli_args6-expected_args6-expected_kwargs6-7] +[gw2] [ 11%] PASSED src/tests/test_cli_doc_examples.py::test_doc_cli_examples[entry add-ssh Server --index 0] +src/tests/test_cli_subcommands.py::test_get_command +[gw2] [ 11%] PASSED src/tests/test_cli_subcommands.py::test_get_command +src/tests/test_cli_subcommands.py::test_totp_command +[gw2] [ 11%] PASSED src/tests/test_cli_subcommands.py::test_totp_command +src/tests/test_cli_subcommands.py::test_search_command_no_results +[gw3] [ 11%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-key-value-add_key_value-cli_args6-expected_args6-expected_kwargs6-7] +src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-managed-account-add_managed_account-cli_args7-expected_args7-expected_kwargs7-8] +[gw2] [ 11%] PASSED src/tests/test_cli_subcommands.py::test_search_command_no_results +src/tests/test_cli_subcommands.py::test_get_command_multiple_matches +[gw2] [ 12%] PASSED src/tests/test_cli_subcommands.py::test_get_command_multiple_matches +src/tests/test_cli_subcommands.py::test_get_command_wrong_type +[gw2] [ 12%] PASSED src/tests/test_cli_subcommands.py::test_get_command_wrong_type +src/tests/test_cli_subcommands.py::test_totp_command_multiple_matches +[gw2] [ 12%] PASSED src/tests/test_cli_subcommands.py::test_totp_command_multiple_matches +src/tests/test_cli_subcommands.py::test_totp_command_wrong_type +[gw2] [ 12%] PASSED src/tests/test_cli_subcommands.py::test_totp_command_wrong_type +src/tests/test_cli_subcommands.py::test_main_fingerprint_option +[gw2] [ 12%] PASSED src/tests/test_cli_subcommands.py::test_main_fingerprint_option +src/tests/test_cli_toggle_offline_mode.py::test_toggle_offline_updates +[gw3] [ 12%] PASSED src/tests/test_cli_entry_add_commands.py::test_entry_add_commands[add-managed-account-add_managed_account-cli_args7-expected_args7-expected_kwargs7-8] +src/tests/test_cli_export_import.py::test_cli_export_creates_file +[gw2] [ 13%] PASSED src/tests/test_cli_toggle_offline_mode.py::test_toggle_offline_updates +src/tests/test_cli_toggle_offline_mode.py::test_toggle_offline_keep +[gw2] [ 13%] PASSED src/tests/test_cli_toggle_offline_mode.py::test_toggle_offline_keep +src/tests/test_cli_toggle_secret_mode.py::test_toggle_secret_mode_updates +[gw2] [ 13%] PASSED src/tests/test_cli_toggle_secret_mode.py::test_toggle_secret_mode_updates +src/tests/test_cli_toggle_secret_mode.py::test_toggle_secret_mode_keep +[gw3] [ 13%] PASSED src/tests/test_cli_export_import.py::test_cli_export_creates_file +src/tests/test_cli_export_import.py::test_cli_import_round_trip +[gw2] [ 13%] PASSED src/tests/test_cli_toggle_secret_mode.py::test_toggle_secret_mode_keep +src/tests/test_cli_vault_stats.py::test_vault_stats_command +[gw2] [ 13%] PASSED src/tests/test_cli_vault_stats.py::test_vault_stats_command +src/tests/test_clipboard_utils.py::test_copy_to_clipboard_clears +[gw2] [ 13%] PASSED src/tests/test_clipboard_utils.py::test_copy_to_clipboard_clears +src/tests/test_clipboard_utils.py::test_copy_to_clipboard_does_not_clear_if_changed +[gw2] [ 14%] PASSED src/tests/test_clipboard_utils.py::test_copy_to_clipboard_does_not_clear_if_changed +src/tests/test_clipboard_utils.py::test_copy_to_clipboard_missing_dependency +[gw2] [ 14%] PASSED src/tests/test_clipboard_utils.py::test_copy_to_clipboard_missing_dependency +src/tests/test_concurrency_stress.py::test_concurrency_stress[0-5] +[gw3] [ 14%] PASSED src/tests/test_cli_export_import.py::test_cli_import_round_trip +src/tests/test_cli_export_import.py::test_cli_export_import_unencrypted +[gw3] [ 14%] PASSED src/tests/test_cli_export_import.py::test_cli_export_import_unencrypted +src/tests/test_cli_integration.py::test_cli_integration +[gw2] [ 14%] PASSED src/tests/test_concurrency_stress.py::test_concurrency_stress[0-5] +src/tests/test_concurrency_stress.py::test_concurrency_stress[0-20] +[gw2] [ 14%] SKIPPED src/tests/test_concurrency_stress.py::test_concurrency_stress[0-20] +src/tests/test_concurrency_stress.py::test_concurrency_stress[1-5] +[gw1] [ 14%] PASSED src/tests/test_api_new_endpoints.py::test_secret_mode_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_vault_export_endpoint[asyncio] +[gw2] [ 15%] PASSED src/tests/test_concurrency_stress.py::test_concurrency_stress[1-5] +src/tests/test_concurrency_stress.py::test_concurrency_stress[1-20] +[gw2] [ 15%] SKIPPED src/tests/test_concurrency_stress.py::test_concurrency_stress[1-20] +src/tests/test_concurrency_stress.py::test_concurrency_stress[2-5] +[gw0] [ 15%] PASSED src/tests/test_api.py::test_cors_and_auth[asyncio] +src/tests/test_api.py::test_invalid_token[asyncio] +[gw2] [ 15%] PASSED src/tests/test_concurrency_stress.py::test_concurrency_stress[2-5] +src/tests/test_concurrency_stress.py::test_concurrency_stress[2-20] +[gw2] [ 15%] SKIPPED src/tests/test_concurrency_stress.py::test_concurrency_stress[2-20] +src/tests/test_config_manager.py::test_config_defaults_and_round_trip +[gw0] [ 15%] PASSED src/tests/test_api.py::test_invalid_token[asyncio] +src/tests/test_api.py::test_get_entry_by_id[asyncio] +[gw2] [ 16%] PASSED src/tests/test_config_manager.py::test_config_defaults_and_round_trip +src/tests/test_config_manager.py::test_pin_verification_and_change +[gw1] [ 16%] PASSED src/tests/test_api_new_endpoints.py::test_vault_export_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_backup_parent_seed_endpoint[asyncio] +[gw3] [ 16%] PASSED src/tests/test_cli_integration.py::test_cli_integration +src/tests/test_cli_invalid_input.py::test_empty_and_non_numeric_choice +[gw3] [ 16%] PASSED src/tests/test_cli_invalid_input.py::test_empty_and_non_numeric_choice +src/tests/test_cli_invalid_input.py::test_out_of_range_menu +[gw3] [ 16%] PASSED src/tests/test_cli_invalid_input.py::test_out_of_range_menu +src/tests/test_cli_invalid_input.py::test_invalid_add_entry_submenu +[gw3] [ 16%] PASSED src/tests/test_cli_invalid_input.py::test_invalid_add_entry_submenu +src/tests/test_cli_invalid_input.py::test_inactivity_timeout_loop +[gw3] [ 16%] PASSED src/tests/test_cli_invalid_input.py::test_inactivity_timeout_loop +src/tests/test_cli_relays.py::test_cli_relay_crud +[gw3] [ 17%] PASSED src/tests/test_cli_relays.py::test_cli_relay_crud +src/tests/test_cli_subcommands.py::test_search_command +[gw3] [ 17%] PASSED src/tests/test_cli_subcommands.py::test_search_command +src/tests/test_config_manager.py::test_backup_interval_round_trip +[gw3] [ 17%] PASSED src/tests/test_config_manager.py::test_backup_interval_round_trip +src/tests/test_config_manager.py::test_quick_unlock_round_trip +[gw0] [ 17%] PASSED src/tests/test_api.py::test_get_entry_by_id[asyncio] +src/tests/test_api.py::test_get_config_value[asyncio] +[gw3] [ 17%] PASSED src/tests/test_config_manager.py::test_quick_unlock_round_trip +src/tests/test_config_manager.py::test_nostr_retry_settings_round_trip +[gw3] [ 17%] PASSED src/tests/test_config_manager.py::test_nostr_retry_settings_round_trip +src/tests/test_config_manager.py::test_special_char_settings_round_trip +[gw3] [ 18%] PASSED src/tests/test_config_manager.py::test_special_char_settings_round_trip +src/tests/test_config_manager.py::test_password_policy_extended_fields +[gw3] [ 18%] PASSED src/tests/test_config_manager.py::test_password_policy_extended_fields +src/tests/test_core_api_services.py::TestVaultService::test_export_vault +[gw3] [ 18%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_export_vault +src/tests/test_core_api_services.py::TestVaultService::test_import_vault +[gw3] [ 18%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_import_vault +src/tests/test_core_api_services.py::TestVaultService::test_export_profile +[gw3] [ 18%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_export_profile +src/tests/test_core_api_services.py::TestVaultService::test_import_profile +[gw3] [ 18%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_import_profile +src/tests/test_core_api_services.py::TestVaultService::test_change_password +[gw3] [ 18%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_change_password +src/tests/test_core_api_services.py::TestVaultService::test_unlock +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_unlock +src/tests/test_core_api_services.py::TestVaultService::test_lock +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_lock +src/tests/test_core_api_services.py::TestVaultService::test_backup_parent_seed +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_backup_parent_seed +src/tests/test_core_api_services.py::TestVaultService::test_stats +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestVaultService::test_stats +src/tests/test_core_api_services.py::TestProfileService::test_list_profiles +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestProfileService::test_list_profiles +src/tests/test_core_api_services.py::TestProfileService::test_add_profile +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestProfileService::test_add_profile +src/tests/test_core_api_services.py::TestProfileService::test_remove_profile +[gw3] [ 19%] PASSED src/tests/test_core_api_services.py::TestProfileService::test_remove_profile +src/tests/test_core_api_services.py::TestProfileService::test_switch_profile +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestProfileService::test_switch_profile +src/tests/test_core_api_services.py::TestSyncService::test_sync +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestSyncService::test_sync +src/tests/test_core_api_services.py::TestSyncService::test_sync_none +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestSyncService::test_sync_none +src/tests/test_core_api_services.py::TestSyncService::test_start_background_sync +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestSyncService::test_start_background_sync +src/tests/test_core_api_services.py::TestSyncService::test_start_background_vault_sync +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestSyncService::test_start_background_vault_sync +src/tests/test_core_api_services.py::TestEntryService::test_list_entries +[gw3] [ 20%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_list_entries +src/tests/test_core_api_services.py::TestEntryService::test_search_entries +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_search_entries +src/tests/test_core_api_services.py::TestEntryService::test_retrieve_entry +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_retrieve_entry +src/tests/test_core_api_services.py::TestEntryService::test_generate_password_default +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_generate_password_default +src/tests/test_core_api_services.py::TestEntryService::test_generate_password_custom +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_generate_password_custom +src/tests/test_core_api_services.py::TestEntryService::test_get_totp_code +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_get_totp_code +src/tests/test_core_api_services.py::TestEntryService::test_add_entry +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_entry +src/tests/test_core_api_services.py::TestEntryService::test_add_totp +[gw3] [ 21%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_totp +src/tests/test_core_services.py::test_vault_service_unlock +[gw3] [ 22%] PASSED src/tests/test_core_services.py::test_vault_service_unlock +src/tests/test_core_services.py::test_entry_service_add_entry_and_search +[gw3] [ 22%] PASSED src/tests/test_core_services.py::test_entry_service_add_entry_and_search +src/tests/test_core_services.py::test_sync_service_sync +[gw3] [ 22%] PASSED src/tests/test_core_services.py::test_sync_service_sync +src/tests/test_custom_fields_display.py::test_retrieve_entry_shows_custom_fields +[gw1] [ 22%] PASSED src/tests/test_api_new_endpoints.py::test_backup_parent_seed_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_backup_parent_seed_path_traversal_blocked[asyncio] +[gw3] [ 22%] PASSED src/tests/test_custom_fields_display.py::test_retrieve_entry_shows_custom_fields +src/tests/test_decrypt_messages.py::test_wrong_password_message +[gw3] [ 22%] PASSED src/tests/test_decrypt_messages.py::test_wrong_password_message +src/tests/test_decrypt_messages.py::test_legacy_file_requires_migration_message +[gw3] [ 22%] PASSED src/tests/test_decrypt_messages.py::test_legacy_file_requires_migration_message +src/tests/test_decrypt_messages.py::test_corrupted_data_message +[gw3] [ 23%] PASSED src/tests/test_decrypt_messages.py::test_corrupted_data_message +src/tests/test_default_encryption_mode.py::test_default_encryption_mode +[gw3] [ 23%] PASSED src/tests/test_default_encryption_mode.py::test_default_encryption_mode +src/tests/test_delta_merge.py::test_merge_modified_ts +[gw0] [ 23%] PASSED src/tests/test_api.py::test_get_config_value[asyncio] +src/tests/test_api.py::test_list_fingerprint[asyncio] +[gw3] [ 23%] PASSED src/tests/test_delta_merge.py::test_merge_modified_ts +src/tests/test_duplicate_seed_profile_creation.py::test_duplicate_seed_profile_creation +[gw3] [ 23%] PASSED src/tests/test_duplicate_seed_profile_creation.py::test_duplicate_seed_profile_creation +src/tests/test_edit_tags_from_retrieve.py::test_edit_tags_from_retrieve +[gw3] [ 23%] PASSED src/tests/test_edit_tags_from_retrieve.py::test_edit_tags_from_retrieve +src/tests/test_encryption_checksum.py::test_encryption_checksum_workflow +[gw3] [ 24%] PASSED src/tests/test_encryption_checksum.py::test_encryption_checksum_workflow +src/tests/test_encryption_checksum.py::test_update_checksum_removes_legacy +[gw3] [ 24%] PASSED src/tests/test_encryption_checksum.py::test_update_checksum_removes_legacy +src/tests/test_encryption_files.py::test_json_save_and_load_round_trip +[gw3] [ 24%] PASSED src/tests/test_encryption_files.py::test_json_save_and_load_round_trip +src/tests/test_encryption_files.py::test_encrypt_and_decrypt_file_binary_round_trip +[gw3] [ 24%] PASSED src/tests/test_encryption_files.py::test_encrypt_and_decrypt_file_binary_round_trip +src/tests/test_encryption_files.py::test_encrypt_file_rejects_traversal +[gw3] [ 24%] PASSED src/tests/test_encryption_files.py::test_encrypt_file_rejects_traversal +src/tests/test_encryption_fuzz.py::test_encrypt_decrypt_roundtrip +[gw1] [ 24%] PASSED src/tests/test_api_new_endpoints.py::test_backup_parent_seed_path_traversal_blocked[asyncio] +src/tests/test_api_new_endpoints.py::test_relay_management_endpoints[asyncio] +[gw0] [ 24%] PASSED src/tests/test_api.py::test_list_fingerprint[asyncio] +src/tests/test_api.py::test_get_nostr_pubkey[asyncio] +[gw2] [ 25%] PASSED src/tests/test_config_manager.py::test_pin_verification_and_change +src/tests/test_config_manager.py::test_config_file_encrypted_after_save +[gw3] [ 25%] PASSED src/tests/test_encryption_fuzz.py::test_encrypt_decrypt_roundtrip +src/tests/test_encryption_fuzz.py::test_corrupted_ciphertext_fails +[gw2] [ 25%] PASSED src/tests/test_config_manager.py::test_config_file_encrypted_after_save +src/tests/test_config_manager.py::test_set_relays_persists_changes +[gw3] [ 25%] PASSED src/tests/test_encryption_fuzz.py::test_corrupted_ciphertext_fails +src/tests/test_encryption_validate_seed.py::test_validate_seed_valid_mnemonic +[gw3] [ 25%] PASSED src/tests/test_encryption_validate_seed.py::test_validate_seed_valid_mnemonic +src/tests/test_encryption_validate_seed.py::test_validate_seed_invalid_mnemonic +[gw3] [ 25%] PASSED src/tests/test_encryption_validate_seed.py::test_validate_seed_invalid_mnemonic +src/tests/test_entries_empty.py::test_list_entries_empty +[gw2] [ 26%] PASSED src/tests/test_config_manager.py::test_set_relays_persists_changes +src/tests/test_config_manager.py::test_set_relays_requires_at_least_one +[gw3] [ 26%] PASSED src/tests/test_entries_empty.py::test_list_entries_empty +src/tests/test_entry_add.py::test_add_and_retrieve_entry +[gw2] [ 26%] PASSED src/tests/test_config_manager.py::test_set_relays_requires_at_least_one +src/tests/test_config_manager.py::test_inactivity_timeout_round_trip +[gw3] [ 26%] PASSED src/tests/test_entry_add.py::test_add_and_retrieve_entry +src/tests/test_entry_add.py::test_round_trip_entry_types[add_entry-password] +[gw2] [ 26%] PASSED src/tests/test_config_manager.py::test_inactivity_timeout_round_trip +src/tests/test_config_manager.py::test_password_hash_migrates_from_file +[gw3] [ 26%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_entry-password] +src/tests/test_entry_add.py::test_round_trip_entry_types[add_totp-totp] +[gw0] [ 26%] PASSED src/tests/test_api.py::test_get_nostr_pubkey[asyncio] +src/tests/test_api.py::test_create_modify_archive_entry[asyncio] +[gw3] [ 27%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_totp-totp] +src/tests/test_entry_add.py::test_round_trip_entry_types[add_ssh_key-ssh] +[gw3] [ 27%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_ssh_key-ssh] +src/tests/test_entry_add.py::test_round_trip_entry_types[add_seed-seed] +[gw3] [ 27%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_seed-seed] +src/tests/test_entry_add.py::test_round_trip_entry_types[add_key_value-key_value] +[gw3] [ 27%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_key_value-key_value] +src/tests/test_entry_add.py::test_round_trip_entry_types[add_managed_account-managed_account] +[gw2] [ 27%] PASSED src/tests/test_config_manager.py::test_password_hash_migrates_from_file +src/tests/test_config_manager.py::test_additional_backup_path_round_trip +[gw3] [ 27%] PASSED src/tests/test_entry_add.py::test_round_trip_entry_types[add_managed_account-managed_account] +src/tests/test_entry_add.py::test_legacy_entry_defaults_to_password +[gw2] [ 27%] PASSED src/tests/test_config_manager.py::test_additional_backup_path_round_trip +src/tests/test_config_manager.py::test_secret_mode_round_trip +[gw3] [ 28%] PASSED src/tests/test_entry_add.py::test_legacy_entry_defaults_to_password +src/tests/test_entry_add.py::test_add_default_archived_false[add_entry-args0] +[gw2] [ 28%] PASSED src/tests/test_config_manager.py::test_secret_mode_round_trip +src/tests/test_config_manager.py::test_kdf_iterations_round_trip +[gw3] [ 28%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_entry-args0] +src/tests/test_entry_add.py::test_add_default_archived_false[add_totp-args1] +[gw2] [ 28%] PASSED src/tests/test_config_manager.py::test_kdf_iterations_round_trip +src/tests/test_core_api_services.py::TestEntryService::test_add_ssh_key +[gw2] [ 28%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_ssh_key +src/tests/test_core_api_services.py::TestEntryService::test_add_pgp_key +[gw2] [ 28%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_pgp_key +src/tests/test_core_api_services.py::TestEntryService::test_add_nostr_key +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_nostr_key +src/tests/test_core_api_services.py::TestEntryService::test_add_seed +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_seed +src/tests/test_core_api_services.py::TestEntryService::test_add_key_value +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_key_value +src/tests/test_core_api_services.py::TestEntryService::test_add_managed_account +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_add_managed_account +src/tests/test_core_api_services.py::TestEntryService::test_modify_entry +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_modify_entry +[gw3] [ 29%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_totp-args1] +src/tests/test_entry_add.py::test_add_default_archived_false[add_ssh_key-args2] +src/tests/test_core_api_services.py::TestEntryService::test_archive_entry +[gw2] [ 29%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_archive_entry +src/tests/test_core_api_services.py::TestEntryService::test_restore_entry +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_restore_entry +src/tests/test_core_api_services.py::TestEntryService::test_export_totp_entries +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_export_totp_entries +src/tests/test_core_api_services.py::TestEntryService::test_display_totp_codes +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestEntryService::test_display_totp_codes +src/tests/test_core_api_services.py::TestConfigService::test_get +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_get +src/tests/test_core_api_services.py::TestConfigService::test_set_simple +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_simple +src/tests/test_core_api_services.py::TestConfigService::test_set_bool +[gw2] [ 30%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_bool +src/tests/test_core_api_services.py::TestConfigService::test_set_relays +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_relays +src/tests/test_core_api_services.py::TestConfigService::test_set_invalid_key +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_invalid_key +src/tests/test_core_api_services.py::TestConfigService::test_get_secret_mode_enabled +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_get_secret_mode_enabled +src/tests/test_core_api_services.py::TestConfigService::test_get_clipboard_clear_delay +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_get_clipboard_clear_delay +src/tests/test_core_api_services.py::TestConfigService::test_set_secret_mode +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_secret_mode +src/tests/test_core_api_services.py::TestConfigService::test_get_offline_mode +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_get_offline_mode +src/tests/test_core_api_services.py::TestConfigService::test_set_offline_mode +[gw2] [ 31%] PASSED src/tests/test_core_api_services.py::TestConfigService::test_set_offline_mode +src/tests/test_core_api_services.py::TestUtilityService::test_generate_password +[gw3] [ 32%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_ssh_key-args2] +[gw2] [ 32%] PASSED src/tests/test_core_api_services.py::TestUtilityService::test_generate_password +src/tests/test_entry_add.py::test_add_default_archived_false[add_pgp_key-args3] +src/tests/test_core_api_services.py::TestUtilityService::test_verify_checksum +[gw2] [ 32%] PASSED src/tests/test_core_api_services.py::TestUtilityService::test_verify_checksum +src/tests/test_core_api_services.py::TestUtilityService::test_update_checksum +[gw2] [ 32%] PASSED src/tests/test_core_api_services.py::TestUtilityService::test_update_checksum +src/tests/test_core_api_services.py::TestNostrService::test_get_pubkey +[gw1] [ 32%] PASSED src/tests/test_api_new_endpoints.py::test_relay_management_endpoints[asyncio] +[gw2] [ 32%] PASSED src/tests/test_core_api_services.py::TestNostrService::test_get_pubkey +src/tests/test_core_api_services.py::TestNostrService::test_list_relays +src/tests/test_api_new_endpoints.py::test_generate_password_no_special_chars[asyncio] +[gw2] [ 32%] PASSED src/tests/test_core_api_services.py::TestNostrService::test_list_relays +src/tests/test_core_api_services.py::TestNostrService::test_add_relay +[gw2] [ 33%] PASSED src/tests/test_core_api_services.py::TestNostrService::test_add_relay +src/tests/test_core_api_services.py::TestNostrService::test_remove_relay +[gw2] [ 33%] PASSED src/tests/test_core_api_services.py::TestNostrService::test_remove_relay +src/tests/test_get_entry_summaries_archived_view.py::test_get_entry_summaries_excludes_archived_and_view_handler +[gw2] [ 33%] PASSED src/tests/test_get_entry_summaries_archived_view.py::test_get_entry_summaries_excludes_archived_and_view_handler +src/tests/test_get_entry_summaries_updates.py::test_get_entry_summaries_updates_label +[gw3] [ 33%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_pgp_key-args3] +src/tests/test_entry_add.py::test_add_default_archived_false[add_nostr_key-args4] +[gw2] [ 33%] PASSED src/tests/test_get_entry_summaries_updates.py::test_get_entry_summaries_updates_label +src/tests/test_get_entry_summaries_updates.py::test_get_entry_summaries_updates_archive_restore +[gw3] [ 33%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_nostr_key-args4] +src/tests/test_entry_add.py::test_add_default_archived_false[add_seed-args5] +[gw2] [ 34%] PASSED src/tests/test_get_entry_summaries_updates.py::test_get_entry_summaries_updates_archive_restore +src/tests/test_gui_features.py::test_relay_manager_add_remove +[gw2] [ 34%] SKIPPED src/tests/test_gui_features.py::test_relay_manager_add_remove +src/tests/test_gui_features.py::test_status_bar_updates_and_lock +[gw2] [ 34%] SKIPPED src/tests/test_gui_features.py::test_status_bar_updates_and_lock +src/tests/test_gui_features.py::test_totp_viewer_refresh_on_sync +[gw2] [ 34%] SKIPPED src/tests/test_gui_features.py::test_totp_viewer_refresh_on_sync +src/tests/test_gui_headless.py::test_unlock_creates_main_window +[gw3] [ 34%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_seed-args5] +src/tests/test_entry_add.py::test_add_default_archived_false[add_key_value-args6] +[gw2] [ 34%] PASSED src/tests/test_gui_headless.py::test_unlock_creates_main_window +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[password-expect0] +[gw2] [ 34%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[password-expect0] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[totp-expect1] +[gw2] [ 35%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[totp-expect1] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[ssh-expect2] +[gw2] [ 35%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[ssh-expect2] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[seed-expect3] +[gw2] [ 35%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[seed-expect3] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[pgp-expect4] +[gw3] [ 35%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_key_value-args6] +src/tests/test_entry_add.py::test_add_default_archived_false[add_managed_account-args7] +[gw2] [ 35%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[pgp-expect4] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[nostr-expect5] +[gw2] [ 35%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[nostr-expect5] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[key_value-expect6] +[gw2] [ 36%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[key_value-expect6] +src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[managed_account-expect7] +[gw2] [ 36%] PASSED src/tests/test_gui_headless.py::test_entrydialog_add_calls_service[managed_account-expect7] +src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[password-expected0] +[gw2] [ 36%] PASSED src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[password-expected0] +src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[key_value-expected1] +[gw2] [ 36%] PASSED src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[key_value-expected1] +src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[totp-expected2] +[gw2] [ 36%] PASSED src/tests/test_gui_headless.py::test_entrydialog_edit_calls_service[totp-expected2] +src/tests/test_gui_sync.py::test_start_vault_sync_schedules_task +[gw2] [ 36%] PASSED src/tests/test_gui_sync.py::test_start_vault_sync_schedules_task +src/tests/test_gui_sync.py::test_status_updates_on_bus_events +[gw3] [ 36%] PASSED src/tests/test_entry_add.py::test_add_default_archived_false[add_managed_account-args7] +[gw2] [ 37%] PASSED src/tests/test_gui_sync.py::test_status_updates_on_bus_events +src/tests/test_handle_switch_fingerprint.py::test_handle_switch_fingerprint_active_profile +[gw2] [ 37%] PASSED src/tests/test_handle_switch_fingerprint.py::test_handle_switch_fingerprint_active_profile +src/tests/test_inactivity_lock.py::test_inactivity_triggers_lock +[gw2] [ 37%] PASSED src/tests/test_inactivity_lock.py::test_inactivity_triggers_lock +src/tests/test_inactivity_lock.py::test_input_timeout_triggers_lock +[gw2] [ 37%] PASSED src/tests/test_inactivity_lock.py::test_input_timeout_triggers_lock +src/tests/test_inactivity_lock.py::test_update_activity_checks_timeout +src/tests/test_entry_management_checksum_path.py::test_update_checksum_writes_to_expected_path +[gw2] [ 37%] PASSED src/tests/test_inactivity_lock.py::test_update_activity_checks_timeout +src/tests/test_index_cache.py::test_index_caching +[gw3] [ 37%] PASSED src/tests/test_entry_management_checksum_path.py::test_update_checksum_writes_to_expected_path +src/tests/test_entry_management_checksum_path.py::test_backup_index_file_creates_backup_in_directory +[gw2] [ 37%] PASSED src/tests/test_index_cache.py::test_index_caching +src/tests/test_index_import_export.py::test_index_export_import_round_trip +[gw1] [ 38%] PASSED src/tests/test_api_new_endpoints.py::test_generate_password_no_special_chars[asyncio] +src/tests/test_api_new_endpoints.py::test_generate_password_allowed_chars[asyncio] +[gw0] [ 38%] PASSED src/tests/test_api.py::test_create_modify_archive_entry[asyncio] +src/tests/test_api.py::test_update_config[asyncio] +[gw3] [ 38%] PASSED src/tests/test_entry_management_checksum_path.py::test_backup_index_file_creates_backup_in_directory +src/tests/test_entry_policy_override.py::test_entry_policy_override_changes_password +[gw2] [ 38%] PASSED src/tests/test_index_import_export.py::test_index_export_import_round_trip +src/tests/test_index_import_export.py::test_get_encrypted_index_missing_file +[gw2] [ 38%] PASSED src/tests/test_index_import_export.py::test_get_encrypted_index_missing_file +src/tests/test_invalid_password_message.py::test_invalid_password_shows_friendly_message_once +[gw3] [ 38%] PASSED src/tests/test_entry_policy_override.py::test_entry_policy_override_changes_password +src/tests/test_export_totp_codes.py::test_handle_export_totp_codes +[gw2] [ 39%] PASSED src/tests/test_invalid_password_message.py::test_invalid_password_shows_friendly_message_once +src/tests/test_kdf_iteration_fallback.py::test_kdf_iteration_fallback +[gw3] [ 39%] PASSED src/tests/test_export_totp_codes.py::test_handle_export_totp_codes +src/tests/test_file_lock.py::test_exclusive_lock_blocks_until_released +[gw0] [ 39%] PASSED src/tests/test_api.py::test_update_config[asyncio] +src/tests/test_api.py::test_update_config_quick_unlock[asyncio] +[gw1] [ 39%] PASSED src/tests/test_api_new_endpoints.py::test_generate_password_allowed_chars[asyncio] +src/tests/test_api_notifications.py::test_notifications_endpoint[asyncio] +[gw2] [ 39%] PASSED src/tests/test_kdf_iteration_fallback.py::test_kdf_iteration_fallback +src/tests/test_kdf_modes.py::test_setup_encryption_manager_kdf_modes +[gw3] [ 39%] PASSED src/tests/test_file_lock.py::test_exclusive_lock_blocks_until_released +src/tests/test_file_locking.py::test_concurrent_shared_and_exclusive_lock +[gw3] [ 39%] PASSED src/tests/test_file_locking.py::test_concurrent_shared_and_exclusive_lock +src/tests/test_fingerprint_encryption.py::test_generate_fingerprint_deterministic +[gw3] [ 40%] PASSED src/tests/test_fingerprint_encryption.py::test_generate_fingerprint_deterministic +src/tests/test_fingerprint_encryption.py::test_encryption_round_trip +[gw3] [ 40%] PASSED src/tests/test_fingerprint_encryption.py::test_encryption_round_trip +src/tests/test_fingerprint_manager_utils.py::test_add_and_remove_fingerprint +[gw3] [ 40%] PASSED src/tests/test_fingerprint_manager_utils.py::test_add_and_remove_fingerprint +src/tests/test_fingerprint_manager_utils.py::test_remove_nonexistent_fingerprint +[gw3] [ 40%] PASSED src/tests/test_fingerprint_manager_utils.py::test_remove_nonexistent_fingerprint +src/tests/test_full_sync_roundtrip.py::test_full_sync_roundtrip +[gw0] [ 40%] PASSED src/tests/test_api.py::test_update_config_quick_unlock[asyncio] +src/tests/test_api.py::test_change_password_route[asyncio] +[gw1] [ 40%] PASSED src/tests/test_api_notifications.py::test_notifications_endpoint[asyncio] +src/tests/test_api_notifications.py::test_notifications_endpoint_clears_queue[asyncio] +[gw2] [ 40%] PASSED src/tests/test_kdf_modes.py::test_setup_encryption_manager_kdf_modes +src/tests/test_kdf_modes.py::test_kdf_param_round_trip +[gw2] [ 41%] PASSED src/tests/test_kdf_modes.py::test_kdf_param_round_trip +src/tests/test_kdf_modes.py::test_vault_kdf_migration +[gw2] [ 41%] PASSED src/tests/test_kdf_modes.py::test_vault_kdf_migration +src/tests/test_kdf_strength_slider.py::test_kdf_strength_slider_persists +[gw3] [ 41%] PASSED src/tests/test_full_sync_roundtrip.py::test_full_sync_roundtrip +src/tests/test_full_sync_roundtrip_new.py::test_full_sync_roundtrip +[gw2] [ 41%] PASSED src/tests/test_kdf_strength_slider.py::test_kdf_strength_slider_persists +src/tests/test_key_derivation.py::test_pbkdf2_fingerprint_affects_key +[gw2] [ 41%] PASSED src/tests/test_key_derivation.py::test_pbkdf2_fingerprint_affects_key +src/tests/test_key_derivation.py::test_derive_key_empty_password_error +[gw2] [ 41%] PASSED src/tests/test_key_derivation.py::test_derive_key_empty_password_error +src/tests/test_key_derivation.py::test_seed_only_key_deterministic +[gw2] [ 42%] PASSED src/tests/test_key_derivation.py::test_seed_only_key_deterministic +src/tests/test_key_derivation.py::test_derive_index_key_seed_only +[gw2] [ 42%] PASSED src/tests/test_key_derivation.py::test_derive_index_key_seed_only +src/tests/test_key_derivation.py::test_argon2_fingerprint_affects_key +[gw2] [ 42%] PASSED src/tests/test_key_derivation.py::test_argon2_fingerprint_affects_key +src/tests/test_key_hierarchy.py::test_kd_distinct_infos +[gw2] [ 42%] PASSED src/tests/test_key_hierarchy.py::test_kd_distinct_infos +src/tests/test_key_hierarchy.py::test_derive_index_key_matches_hierarchy +[gw2] [ 42%] PASSED src/tests/test_key_hierarchy.py::test_derive_index_key_matches_hierarchy +src/tests/test_key_manager_helpers.py::test_key_manager_getters +[gw2] [ 42%] PASSED src/tests/test_key_manager_helpers.py::test_key_manager_getters +src/tests/test_key_validation_failures.py::test_add_totp_invalid_secret +[gw2] [ 42%] PASSED src/tests/test_key_validation_failures.py::test_add_totp_invalid_secret +src/tests/test_key_validation_failures.py::test_add_ssh_key_validation_failure +[gw3] [ 43%] PASSED src/tests/test_full_sync_roundtrip_new.py::test_full_sync_roundtrip +src/tests/test_fuzz_key_derivation.py::test_fuzz_key_round_trip +[gw2] [ 43%] PASSED src/tests/test_key_validation_failures.py::test_add_ssh_key_validation_failure +src/tests/test_key_validation_failures.py::test_add_pgp_key_validation_failure +[gw2] [ 43%] PASSED src/tests/test_key_validation_failures.py::test_add_pgp_key_validation_failure +src/tests/test_key_validation_failures.py::test_add_nostr_key_validation_failure +[gw0] [ 43%] PASSED src/tests/test_api.py::test_change_password_route[asyncio] +src/tests/test_api.py::test_update_config_unknown_key[asyncio] +[gw2] [ 43%] PASSED src/tests/test_key_validation_failures.py::test_add_nostr_key_validation_failure +src/tests/test_list_entries_sort_filter.py::test_sort_by_updated +[gw2] [ 43%] PASSED src/tests/test_list_entries_sort_filter.py::test_sort_by_updated +src/tests/test_list_entries_sort_filter.py::test_filter_by_type +[gw2] [ 44%] PASSED src/tests/test_list_entries_sort_filter.py::test_filter_by_type +src/tests/test_load_global_config.py::test_load_global_config_invalid_toml +[gw2] [ 44%] PASSED src/tests/test_load_global_config.py::test_load_global_config_invalid_toml +src/tests/test_managed_account.py::test_add_managed_account_fields_and_dir +[gw2] [ 44%] PASSED src/tests/test_managed_account.py::test_add_managed_account_fields_and_dir +src/tests/test_managed_account.py::test_load_and_exit_managed_account +[gw1] [ 44%] PASSED src/tests/test_api_notifications.py::test_notifications_endpoint_clears_queue[asyncio] +src/tests/test_api_notifications.py::test_notifications_endpoint_does_not_clear_current[asyncio] +[gw2] [ 44%] PASSED src/tests/test_managed_account.py::test_load_and_exit_managed_account +src/tests/test_managed_account_entry.py::test_add_and_get_managed_account_seed +[gw2] [ 44%] PASSED src/tests/test_managed_account_entry.py::test_add_and_get_managed_account_seed +src/tests/test_managed_account_entry.py::test_load_and_exit_managed_account +[gw0] [ 44%] PASSED src/tests/test_api.py::test_update_config_unknown_key[asyncio] +src/tests/test_api.py::test_shutdown[asyncio] +[gw2] [ 45%] PASSED src/tests/test_managed_account_entry.py::test_load_and_exit_managed_account +src/tests/test_manager_add_password.py::test_handle_add_password +[gw2] [ 45%] PASSED src/tests/test_manager_add_password.py::test_handle_add_password +src/tests/test_manager_add_password.py::test_handle_add_password_secret_mode +[gw2] [ 45%] PASSED src/tests/test_manager_add_password.py::test_handle_add_password_secret_mode +src/tests/test_manager_add_password.py::test_handle_add_password_quick_mode +[gw2] [ 45%] PASSED src/tests/test_manager_add_password.py::test_handle_add_password_quick_mode +src/tests/test_manager_add_totp.py::test_handle_add_totp +[gw1] [ 45%] PASSED src/tests/test_api_notifications.py::test_notifications_endpoint_does_not_clear_current[asyncio] +src/tests/test_api_profile_stats.py::test_profile_stats_endpoint[asyncio] +[gw2] [ 45%] PASSED src/tests/test_manager_add_totp.py::test_handle_add_totp +src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_success +[gw2] [ 45%] PASSED src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_success +src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_failure +[gw2] [ 46%] PASSED src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_failure +src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_missing +[gw2] [ 46%] PASSED src/tests/test_manager_checksum_backup.py::test_handle_verify_checksum_missing +src/tests/test_manager_checksum_backup.py::test_backup_and_restore_database +[gw2] [ 46%] PASSED src/tests/test_manager_checksum_backup.py::test_backup_and_restore_database +src/tests/test_manager_current_notification.py::test_notify_sets_current +[gw2] [ 46%] PASSED src/tests/test_manager_current_notification.py::test_notify_sets_current +src/tests/test_manager_current_notification.py::test_get_current_notification_ttl +[gw2] [ 46%] PASSED src/tests/test_manager_current_notification.py::test_get_current_notification_ttl +src/tests/test_manager_display_totp_codes.py::test_handle_display_totp_codes +[gw2] [ 46%] PASSED src/tests/test_manager_display_totp_codes.py::test_handle_display_totp_codes +src/tests/test_manager_display_totp_codes.py::test_display_totp_codes_excludes_archived +[gw2] [ 47%] PASSED src/tests/test_manager_display_totp_codes.py::test_display_totp_codes_excludes_archived +src/tests/test_manager_edit_totp.py::test_edit_totp_period_from_retrieve +[gw0] [ 47%] PASSED src/tests/test_api.py::test_shutdown[asyncio] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/entry/1] +[gw2] [ 47%] PASSED src/tests/test_manager_edit_totp.py::test_edit_totp_period_from_retrieve +src/tests/test_manager_import_database.py::test_import_non_backup_file +[gw2] [ 47%] PASSED src/tests/test_manager_import_database.py::test_import_non_backup_file +src/tests/test_manager_import_database.py::test_import_missing_file +[gw2] [ 47%] PASSED src/tests/test_manager_import_database.py::test_import_missing_file +src/tests/test_manager_list_entries.py::test_handle_list_entries +[gw2] [ 47%] PASSED src/tests/test_manager_list_entries.py::test_handle_list_entries +src/tests/test_manager_list_entries.py::test_list_entries_show_details +[gw2] [ 47%] PASSED src/tests/test_manager_list_entries.py::test_list_entries_show_details +src/tests/test_manager_list_entries.py::test_show_entry_details_by_index +[gw1] [ 48%] PASSED src/tests/test_api_profile_stats.py::test_profile_stats_endpoint[asyncio] +src/tests/test_api_rate_limit.py::test_rate_limit_exceeded[asyncio] +[gw2] [ 48%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_by_index +src/tests/test_manager_list_entries.py::test_show_seed_entry_details +[gw2] [ 48%] PASSED src/tests/test_manager_list_entries.py::test_show_seed_entry_details +src/tests/test_manager_list_entries.py::test_show_ssh_entry_details +[gw2] [ 48%] PASSED src/tests/test_manager_list_entries.py::test_show_ssh_entry_details +src/tests/test_manager_list_entries.py::test_show_pgp_entry_details +[gw0] [ 48%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/entry/1] +[gw2] [ 48%] PASSED src/tests/test_manager_list_entries.py::test_show_pgp_entry_details +src/tests/test_manager_list_entries.py::test_show_nostr_entry_details +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/config/k] +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_nostr_entry_details +src/tests/test_manager_list_entries.py::test_show_managed_account_entry_details +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_managed_account_entry_details +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[password] +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[password] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[seed] +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[seed] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[ssh] +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[ssh] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[pgp] +[gw2] [ 49%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[pgp] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[nostr] +[gw0] [ 49%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/config/k] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/fingerprint] +[gw1] [ 50%] PASSED src/tests/test_api_rate_limit.py::test_rate_limit_exceeded[asyncio] +src/tests/test_api_reload_relays.py::test_reload_relays_logs_errors +[gw1] [ 50%] PASSED src/tests/test_api_reload_relays.py::test_reload_relays_logs_errors +src/tests/test_archive_from_retrieve.py::test_archive_entry_from_retrieve +[gw2] [ 50%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[nostr] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[totp] +[gw1] [ 50%] PASSED src/tests/test_archive_from_retrieve.py::test_archive_entry_from_retrieve +src/tests/test_archive_from_retrieve.py::test_restore_entry_from_retrieve +[gw2] [ 50%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[totp] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[key_value] +[gw1] [ 50%] PASSED src/tests/test_archive_from_retrieve.py::test_restore_entry_from_retrieve +src/tests/test_archive_nonpassword.py::test_archive_nonpassword_list_search +[gw2] [ 50%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[key_value] +src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[managed_account] +[gw1] [ 51%] PASSED src/tests/test_archive_nonpassword.py::test_archive_nonpassword_list_search +src/tests/test_archive_restore.py::test_archive_restore_affects_listing_and_search +[gw2] [ 51%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_sensitive[managed_account] +src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[password] +[gw1] [ 51%] PASSED src/tests/test_archive_restore.py::test_archive_restore_affects_listing_and_search +src/tests/test_archive_restore.py::test_view_archived_entries_cli +[gw2] [ 51%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[password] +src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[totp] +[gw1] [ 51%] PASSED src/tests/test_archive_restore.py::test_view_archived_entries_cli +src/tests/test_archive_restore.py::test_view_archived_entries_view_only +[gw2] [ 51%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[totp] +src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[key_value] +[gw1] [ 52%] PASSED src/tests/test_archive_restore.py::test_view_archived_entries_view_only +src/tests/test_archive_restore.py::test_view_archived_entries_removed_after_restore +[gw2] [ 52%] PASSED src/tests/test_manager_list_entries.py::test_show_entry_details_with_enum_type[key_value] +src/tests/test_manager_retrieve_totp.py::test_handle_retrieve_totp_entry +[gw0] [ 52%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/fingerprint] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/nostr/pubkey] +[gw1] [ 52%] PASSED src/tests/test_archive_restore.py::test_view_archived_entries_removed_after_restore +src/tests/test_archive_restore.py::test_archived_entries_menu_hides_active +[gw2] [ 52%] PASSED src/tests/test_manager_retrieve_totp.py::test_handle_retrieve_totp_entry +src/tests/test_manager_search_display.py::test_search_entries_prompt_for_details +[gw1] [ 52%] PASSED src/tests/test_archive_restore.py::test_archived_entries_menu_hides_active +src/tests/test_atomic_write.py::test_atomic_write_concurrent +[gw2] [ 52%] PASSED src/tests/test_manager_search_display.py::test_search_entries_prompt_for_details +src/tests/test_manager_seed_setup.py::test_validate_bip85_seed_invalid_word +[gw2] [ 53%] PASSED src/tests/test_manager_seed_setup.py::test_validate_bip85_seed_invalid_word +src/tests/test_manager_seed_setup.py::test_validate_bip85_seed_checksum_failure +[gw2] [ 53%] PASSED src/tests/test_manager_seed_setup.py::test_validate_bip85_seed_checksum_failure +src/tests/test_manager_seed_setup.py::test_setup_existing_seed_words +[gw2] [ 53%] PASSED src/tests/test_manager_seed_setup.py::test_setup_existing_seed_words +src/tests/test_manager_seed_setup.py::test_setup_existing_seed_paste +[gw2] [ 53%] PASSED src/tests/test_manager_seed_setup.py::test_setup_existing_seed_paste +src/tests/test_manager_seed_setup.py::test_setup_existing_seed_with_args +[gw2] [ 53%] PASSED src/tests/test_manager_seed_setup.py::test_setup_existing_seed_with_args +src/tests/test_manager_warning_notifications.py::test_handle_search_entries_no_query +[gw2] [ 53%] PASSED src/tests/test_manager_warning_notifications.py::test_handle_search_entries_no_query +src/tests/test_manager_workflow.py::test_manager_workflow +[gw1] [ 54%] PASSED src/tests/test_atomic_write.py::test_atomic_write_concurrent +src/tests/test_audit_logger.py::test_audit_logger_records_events +[gw1] [ 54%] PASSED src/tests/test_audit_logger.py::test_audit_logger_records_events +src/tests/test_audit_logger.py::test_audit_log_tamper_evident +[gw1] [ 54%] PASSED src/tests/test_audit_logger.py::test_audit_log_tamper_evident +src/tests/test_auto_sync.py::test_auto_sync_triggers_post +[gw1] [ 54%] PASSED src/tests/test_auto_sync.py::test_auto_sync_triggers_post +src/tests/test_background_error_reporting.py::test_start_background_sync_error +[gw1] [ 54%] PASSED src/tests/test_background_error_reporting.py::test_start_background_sync_error +src/tests/test_background_error_reporting.py::test_start_background_relay_check_error +[gw1] [ 54%] PASSED src/tests/test_background_error_reporting.py::test_start_background_relay_check_error +src/tests/test_background_relay_check.py::test_background_relay_check_runs_async +[gw2] [ 54%] PASSED src/tests/test_manager_workflow.py::test_manager_workflow +src/tests/test_manifest_id_privacy.py::test_published_events_no_fingerprint +[gw2] [ 55%] PASSED src/tests/test_manifest_id_privacy.py::test_published_events_no_fingerprint +src/tests/test_manifest_state_restore.py::test_manifest_state_restored +[gw1] [ 55%] PASSED src/tests/test_background_relay_check.py::test_background_relay_check_runs_async +src/tests/test_background_relay_check.py::test_background_relay_check_warns_when_unhealthy +[gw1] [ 55%] PASSED src/tests/test_background_relay_check.py::test_background_relay_check_warns_when_unhealthy +src/tests/test_background_sync_always.py::test_switch_fingerprint_triggers_bg_sync +[gw1] [ 55%] PASSED src/tests/test_background_sync_always.py::test_switch_fingerprint_triggers_bg_sync +src/tests/test_background_sync_always.py::test_exit_managed_account_triggers_bg_sync +[gw1] [ 55%] PASSED src/tests/test_background_sync_always.py::test_exit_managed_account_triggers_bg_sync +src/tests/test_backup_interval.py::test_backup_interval +[gw2] [ 55%] PASSED src/tests/test_manifest_state_restore.py::test_manifest_state_restored +src/tests/test_memory_protection.py::test_inmemory_secret_round_trip_bytes_and_str +[gw2] [ 55%] PASSED src/tests/test_memory_protection.py::test_inmemory_secret_round_trip_bytes_and_str +src/tests/test_memory_protection.py::test_inmemory_secret_invalid_type +[gw2] [ 56%] PASSED src/tests/test_memory_protection.py::test_inmemory_secret_invalid_type +src/tests/test_memory_protection.py::test_inmemory_secret_wipe_clears_attributes +[gw2] [ 56%] PASSED src/tests/test_memory_protection.py::test_inmemory_secret_wipe_clears_attributes +src/tests/test_menu_navigation.py::test_navigate_all_main_menu_options +[gw2] [ 56%] PASSED src/tests/test_menu_navigation.py::test_navigate_all_main_menu_options +src/tests/test_menu_notifications.py::test_display_menu_prints_notifications +[gw2] [ 56%] PASSED src/tests/test_menu_notifications.py::test_display_menu_prints_notifications +src/tests/test_menu_notifications.py::test_display_menu_reuses_notification_line +[gw2] [ 56%] PASSED src/tests/test_menu_notifications.py::test_display_menu_reuses_notification_line +src/tests/test_menu_options.py::test_menu_totp_option +[gw2] [ 56%] PASSED src/tests/test_menu_options.py::test_menu_totp_option +src/tests/test_menu_options.py::test_menu_settings_option +[gw2] [ 57%] PASSED src/tests/test_menu_options.py::test_menu_settings_option +src/tests/test_menu_search.py::test_menu_search_option +[gw2] [ 57%] PASSED src/tests/test_menu_search.py::test_menu_search_option +src/tests/test_migrations.py::test_migrate_v0_to_v4 +[gw1] [ 57%] PASSED src/tests/test_backup_interval.py::test_backup_interval +src/tests/test_backup_restore.py::test_backup_restore_workflow +[gw0] [ 57%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-get-/api/v1/nostr/pubkey] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/shutdown] +[gw2] [ 57%] PASSED src/tests/test_migrations.py::test_migrate_v0_to_v4 +src/tests/test_migrations.py::test_migrate_v1_to_v4 +[gw3] [ 57%] PASSED src/tests/test_fuzz_key_derivation.py::test_fuzz_key_round_trip +src/tests/test_generate_test_profile.py::test_initialize_profile_creates_directories +[gw1] [ 57%] PASSED src/tests/test_backup_restore.py::test_backup_restore_workflow +src/tests/test_backup_restore.py::test_additional_backup_location +[gw2] [ 58%] PASSED src/tests/test_migrations.py::test_migrate_v1_to_v4 +src/tests/test_migrations.py::test_migrate_v2_to_v4 +[gw1] [ 58%] PASSED src/tests/test_backup_restore.py::test_additional_backup_location +src/tests/test_backup_restore_startup.py::test_cli_flag_restores_before_init +[gw1] [ 58%] PASSED src/tests/test_backup_restore_startup.py::test_cli_flag_restores_before_init +src/tests/test_backup_restore_startup.py::test_menu_option_restores_before_init +[gw1] [ 58%] PASSED src/tests/test_backup_restore_startup.py::test_menu_option_restores_before_init +src/tests/test_bip85_derivation_path.py::test_derivation_paths_for_entropy_lengths +[gw1] [ 58%] PASSED src/tests/test_bip85_derivation_path.py::test_derivation_paths_for_entropy_lengths +src/tests/test_bip85_derivation_path.py::test_default_word_count_from_entropy_bytes +[gw1] [ 58%] PASSED src/tests/test_bip85_derivation_path.py::test_default_word_count_from_entropy_bytes +src/tests/test_bip85_init.py::test_init_with_seed_bytes +[gw1] [ 59%] PASSED src/tests/test_bip85_init.py::test_init_with_seed_bytes +src/tests/test_bip85_init.py::test_init_with_xprv +[gw1] [ 59%] PASSED src/tests/test_bip85_init.py::test_init_with_xprv +src/tests/test_modify_ssh_managed_entries.py::test_modify_ssh_entry +[gw2] [ 59%] PASSED src/tests/test_migrations.py::test_migrate_v2_to_v4 +src/tests/test_migrations.py::test_error_on_future_version +[gw2] [ 59%] PASSED src/tests/test_migrations.py::test_error_on_future_version +src/tests/test_migrations.py::test_schema_migration_persisted_once +[gw1] [ 59%] PASSED src/tests/test_modify_ssh_managed_entries.py::test_modify_ssh_entry +src/tests/test_modify_ssh_managed_entries.py::test_modify_managed_account_entry +[gw2] [ 59%] PASSED src/tests/test_migrations.py::test_schema_migration_persisted_once +src/tests/test_nostr_client.py::test_check_relay_health_runs_async +[gw2] [ 59%] PASSED src/tests/test_nostr_client.py::test_check_relay_health_runs_async +src/tests/test_nostr_client.py::test_ping_relay_accepts_eose +[gw2] [ 60%] PASSED src/tests/test_nostr_client.py::test_ping_relay_accepts_eose +src/tests/test_nostr_client.py::test_update_relays_reinitializes_pool +[gw2] [ 60%] PASSED src/tests/test_nostr_client.py::test_update_relays_reinitializes_pool +src/tests/test_nostr_client.py::test_retrieve_json_sync_backoff +[gw3] [ 60%] PASSED src/tests/test_generate_test_profile.py::test_initialize_profile_creates_directories +[gw2] [ 60%] PASSED src/tests/test_nostr_client.py::test_retrieve_json_sync_backoff +src/tests/test_nostr_client.py::test_client_methods_run_in_event_loop +src/tests/test_generate_test_profile_sync.py::test_generate_test_profile_sync +[gw2] [ 60%] PASSED src/tests/test_nostr_client.py::test_client_methods_run_in_event_loop +src/tests/test_nostr_contract.py::test_publish_and_retrieve +[gw2] [ 60%] PASSED src/tests/test_nostr_contract.py::test_publish_and_retrieve +src/tests/test_nostr_dummy_client.py::test_manifest_generation +[gw1] [ 60%] PASSED src/tests/test_modify_ssh_managed_entries.py::test_modify_managed_account_entry +src/tests/test_modify_totp_entry.py::test_modify_totp_entry_period_digits_and_archive +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_manifest_generation +src/tests/test_nostr_dummy_client.py::test_retrieve_multi_chunk_snapshot +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_retrieve_multi_chunk_snapshot +src/tests/test_nostr_dummy_client.py::test_publish_and_fetch_deltas +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_publish_and_fetch_deltas +src/tests/test_nostr_dummy_client.py::test_fetch_snapshot_fallback_on_missing_chunk +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_fetch_snapshot_fallback_on_missing_chunk +src/tests/test_nostr_dummy_client.py::test_fetch_snapshot_uses_event_ids +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_fetch_snapshot_uses_event_ids +src/tests/test_nostr_dummy_client.py::test_publish_delta_aborts_if_outdated +[gw2] [ 61%] PASSED src/tests/test_nostr_dummy_client.py::test_publish_delta_aborts_if_outdated +src/tests/test_nostr_entry.py::test_nostr_key_determinism +[gw0] [ 62%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/shutdown] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry] +[gw1] [ 62%] PASSED src/tests/test_modify_totp_entry.py::test_modify_totp_entry_period_digits_and_archive +src/tests/test_modify_totp_entry.py::test_modify_totp_entry_invalid_field +[gw1] [ 62%] PASSED src/tests/test_modify_totp_entry.py::test_modify_totp_entry_invalid_field +src/tests/test_multiple_deltas_sync.py::test_sync_applies_multiple_deltas +[gw2] [ 62%] PASSED src/tests/test_nostr_entry.py::test_nostr_key_determinism +src/tests/test_nostr_index_size.py::test_nostr_index_size_limits +[gw2] [ 62%] SKIPPED src/tests/test_nostr_index_size.py::test_nostr_index_size_limits +src/tests/test_nostr_legacy_decrypt_fallback.py::test_legacy_password_only_fallback +[gw2] [ 62%] PASSED src/tests/test_nostr_legacy_decrypt_fallback.py::test_legacy_password_only_fallback +src/tests/test_nostr_legacy_key_fallback.py::test_fetch_snapshot_legacy_key_fallback +[gw2] [ 62%] PASSED src/tests/test_nostr_legacy_key_fallback.py::test_fetch_snapshot_legacy_key_fallback +src/tests/test_nostr_qr.py::test_show_qr_for_nostr_keys +[gw3] [ 63%] PASSED src/tests/test_generate_test_profile_sync.py::test_generate_test_profile_sync +src/tests/test_key_validation_failures.py::test_add_seed_validation_failure +[gw2] [ 63%] PASSED src/tests/test_nostr_qr.py::test_show_qr_for_nostr_keys +src/tests/test_nostr_qr.py::test_show_private_key_qr +[gw1] [ 63%] PASSED src/tests/test_multiple_deltas_sync.py::test_sync_applies_multiple_deltas +src/tests/test_multiple_deltas_sync.py::test_initial_sync_applies_multiple_deltas +[gw3] [ 63%] PASSED src/tests/test_key_validation_failures.py::test_add_seed_validation_failure +src/tests/test_key_validation_failures.py::test_add_managed_account_validation_failure +[gw2] [ 63%] PASSED src/tests/test_nostr_qr.py::test_show_private_key_qr +src/tests/test_nostr_qr.py::test_qr_menu_case_insensitive +[gw3] [ 63%] PASSED src/tests/test_key_validation_failures.py::test_add_managed_account_validation_failure +src/tests/test_key_value_entry.py::test_add_and_modify_key_value +[gw2] [ 63%] PASSED src/tests/test_nostr_qr.py::test_qr_menu_case_insensitive +src/tests/test_nostr_real.py::test_nostr_publish_and_retrieve +[gw2] [ 64%] SKIPPED src/tests/test_nostr_real.py::test_nostr_publish_and_retrieve +src/tests/test_nostr_restore_flow.py::test_restore_flow_from_snapshot +[gw1] [ 64%] PASSED src/tests/test_multiple_deltas_sync.py::test_initial_sync_applies_multiple_deltas +[gw3] [ 64%] PASSED src/tests/test_key_value_entry.py::test_add_and_modify_key_value +src/tests/test_multiple_fingerprint_prompt.py::test_prompt_when_multiple_fingerprints +src/tests/test_last_used_fingerprint.py::test_last_used_fingerprint +[gw1] [ 64%] PASSED src/tests/test_multiple_fingerprint_prompt.py::test_prompt_when_multiple_fingerprints +src/tests/test_new_seed_profile_creation.py::test_generate_new_seed_creates_profile +[gw3] [ 64%] PASSED src/tests/test_last_used_fingerprint.py::test_last_used_fingerprint +src/tests/test_legacy_format_exception.py::test_decrypt_data_raises_legacy_exception +[gw3] [ 64%] PASSED src/tests/test_legacy_format_exception.py::test_decrypt_data_raises_legacy_exception +src/tests/test_legacy_format_exception.py::test_vault_handles_legacy_exception +[gw1] [ 65%] PASSED src/tests/test_new_seed_profile_creation.py::test_generate_new_seed_creates_profile +src/tests/test_nonce_uniqueness.py::test_nonce_uniqueness +[gw1] [ 65%] PASSED src/tests/test_nonce_uniqueness.py::test_nonce_uniqueness +src/tests/test_noninteractive_init_unlock.py::test_init_with_password +[gw0] [ 65%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-put-/api/v1/entry/1] +[gw2] [ 65%] PASSED src/tests/test_nostr_restore_flow.py::test_restore_flow_from_snapshot +src/tests/test_nostr_sdk_workflow.py::test_nostr_sdk_send_receive +[gw3] [ 65%] PASSED src/tests/test_legacy_format_exception.py::test_vault_handles_legacy_exception +src/tests/test_legacy_migration.py::test_legacy_index_migrates +[gw3] [ 65%] PASSED src/tests/test_legacy_migration.py::test_legacy_index_migrates +src/tests/test_legacy_migration.py::test_failed_migration_restores_legacy +[gw1] [ 65%] PASSED src/tests/test_noninteractive_init_unlock.py::test_init_with_password +src/tests/test_noninteractive_init_unlock.py::test_unlock_with_password +[gw3] [ 66%] PASSED src/tests/test_legacy_migration.py::test_failed_migration_restores_legacy +src/tests/test_legacy_migration.py::test_migrated_index_has_v3_prefix +[gw3] [ 66%] PASSED src/tests/test_legacy_migration.py::test_migrated_index_has_v3_prefix +src/tests/test_legacy_migration.py::test_legacy_index_migration_removes_strays +[gw2] [ 66%] PASSED src/tests/test_nostr_sdk_workflow.py::test_nostr_sdk_send_receive +src/tests/test_nostr_snapshot.py::test_prepare_snapshot_roundtrip +[gw2] [ 66%] PASSED src/tests/test_nostr_snapshot.py::test_prepare_snapshot_roundtrip +src/tests/test_nostr_snapshot.py::test_fetch_latest_snapshot +[gw2] [ 66%] PASSED src/tests/test_nostr_snapshot.py::test_fetch_latest_snapshot +src/tests/test_offline_mode_behavior.py::test_sync_vault_skips_network +[gw2] [ 66%] PASSED src/tests/test_offline_mode_behavior.py::test_sync_vault_skips_network +src/tests/test_offline_mode_behavior.py::test_start_background_sync_offline +[gw3] [ 67%] PASSED src/tests/test_legacy_migration.py::test_legacy_index_migration_removes_strays +src/tests/test_legacy_migration.py::test_migration_syncs_when_confirmed +[gw2] [ 67%] PASSED src/tests/test_offline_mode_behavior.py::test_start_background_sync_offline +src/tests/test_offline_mode_default_enabled.py::test_offline_mode_default_enabled +[gw0] [ 67%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-put-/api/v1/entry/1] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-put-/api/v1/config/inactivity_timeout] +[gw2] [ 67%] PASSED src/tests/test_offline_mode_default_enabled.py::test_offline_mode_default_enabled +src/tests/test_offline_mode_profile_creation.py::test_toggle_offline_mode_after_profile_creation +[gw3] [ 67%] PASSED src/tests/test_legacy_migration.py::test_migration_syncs_when_confirmed +src/tests/test_legacy_migration.py::test_migration_declines_sync +[gw1] [ 67%] PASSED src/tests/test_noninteractive_init_unlock.py::test_unlock_with_password +src/tests/test_nostr_backup.py::test_backup_and_publish_to_nostr +[gw2] [ 67%] PASSED src/tests/test_offline_mode_profile_creation.py::test_toggle_offline_mode_after_profile_creation +src/tests/test_parent_seed_backup.py::test_handle_backup_reveal_parent_seed_confirm +[gw2] [ 68%] PASSED src/tests/test_parent_seed_backup.py::test_handle_backup_reveal_parent_seed_confirm +src/tests/test_parent_seed_backup.py::test_handle_backup_reveal_parent_seed_cancel +[gw2] [ 68%] PASSED src/tests/test_parent_seed_backup.py::test_handle_backup_reveal_parent_seed_cancel +src/tests/test_parent_seed_backup.py::test_is_valid_filename +[gw2] [ 68%] PASSED src/tests/test_parent_seed_backup.py::test_is_valid_filename +src/tests/test_password_change.py::test_change_password_triggers_nostr_backup +[gw3] [ 68%] PASSED src/tests/test_legacy_migration.py::test_migration_declines_sync +src/tests/test_legacy_migration.py::test_legacy_nostr_payload_syncs_when_confirmed +[gw1] [ 68%] PASSED src/tests/test_nostr_backup.py::test_backup_and_publish_to_nostr +src/tests/test_nostr_client.py::test_nostr_client_uses_custom_relays +[gw1] [ 68%] PASSED src/tests/test_nostr_client.py::test_nostr_client_uses_custom_relays +src/tests/test_nostr_client.py::test_initialize_client_pool_add_relays_used +[gw1] [ 68%] PASSED src/tests/test_nostr_client.py::test_initialize_client_pool_add_relays_used +src/tests/test_nostr_client.py::test_initialize_client_pool_add_relay_fallback +[gw1] [ 69%] PASSED src/tests/test_nostr_client.py::test_initialize_client_pool_add_relay_fallback +src/tests/test_password_prompt.py::test_prompt_new_password_retry +[gw3] [ 69%] PASSED src/tests/test_legacy_migration.py::test_legacy_nostr_payload_syncs_when_confirmed +src/tests/test_legacy_migration.py::test_legacy_index_reinit_syncs_once_when_confirmed +[gw2] [ 69%] PASSED src/tests/test_password_change.py::test_change_password_triggers_nostr_backup +src/tests/test_password_generation_policy.py::test_zero_policy_preserves_length +[gw2] [ 69%] PASSED src/tests/test_password_generation_policy.py::test_zero_policy_preserves_length +src/tests/test_password_generation_policy.py::test_custom_policy_applied +[gw2] [ 69%] PASSED src/tests/test_password_generation_policy.py::test_custom_policy_applied +src/tests/test_password_generation_policy.py::test_generate_password_respects_policy +[gw3] [ 69%] PASSED src/tests/test_legacy_migration.py::test_legacy_index_reinit_syncs_once_when_confirmed +src/tests/test_legacy_migration.py::test_schema_migration_no_sync_prompt +[gw2] [ 70%] PASSED src/tests/test_password_generation_policy.py::test_generate_password_respects_policy +src/tests/test_password_helpers.py::test_derive_password_entropy_length +[gw3] [ 70%] PASSED src/tests/test_legacy_migration.py::test_schema_migration_no_sync_prompt +src/tests/test_legacy_migration.py::test_declined_migration_no_sync_prompt +[gw2] [ 70%] PASSED src/tests/test_password_helpers.py::test_derive_password_entropy_length +src/tests/test_password_helpers.py::test_map_entropy_to_chars_only_uses_alphabet +[gw2] [ 70%] PASSED src/tests/test_password_helpers.py::test_map_entropy_to_chars_only_uses_alphabet +src/tests/test_password_helpers.py::test_enforce_complexity_minimum_counts +[gw3] [ 70%] PASSED src/tests/test_legacy_migration.py::test_declined_migration_no_sync_prompt +[gw2] [ 70%] PASSED src/tests/test_password_helpers.py::test_enforce_complexity_minimum_counts +src/tests/test_legacy_migration.py::test_failed_migration_no_sync_prompt +src/tests/test_password_helpers.py::test_shuffle_deterministically_repeatable +[gw2] [ 70%] PASSED src/tests/test_password_helpers.py::test_shuffle_deterministically_repeatable +src/tests/test_password_length_constraints.py::test_generate_password_too_short_raises +[gw2] [ 71%] PASSED src/tests/test_password_length_constraints.py::test_generate_password_too_short_raises +src/tests/test_password_notes_display.py::test_password_notes_shown +[gw0] [ 71%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-put-/api/v1/config/inactivity_timeout] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry/1/archive] +[gw3] [ 71%] PASSED src/tests/test_legacy_migration.py::test_failed_migration_no_sync_prompt +src/tests/test_legacy_migration_iterations.py::test_migrate_iterations[50000] +[gw2] [ 71%] PASSED src/tests/test_password_notes_display.py::test_password_notes_shown +src/tests/test_password_prompt.py::test_prompt_new_password +[gw2] [ 71%] PASSED src/tests/test_password_prompt.py::test_prompt_new_password +src/tests/test_password_special_modes.py::test_enforce_complexity_min_special_zero +[gw2] [ 71%] PASSED src/tests/test_password_special_modes.py::test_enforce_complexity_min_special_zero +src/tests/test_password_unlock_after_change.py::test_password_change_and_unlock +[gw3] [ 72%] PASSED src/tests/test_legacy_migration_iterations.py::test_migrate_iterations[50000] +src/tests/test_legacy_migration_iterations.py::test_migrate_iterations[100000] +[gw3] [ 72%] PASSED src/tests/test_legacy_migration_iterations.py::test_migrate_iterations[100000] +src/tests/test_legacy_migration_prompt.py::test_open_legacy_without_migrating +[gw3] [ 72%] PASSED src/tests/test_legacy_migration_prompt.py::test_open_legacy_without_migrating +src/tests/test_legacy_migration_prompt.py::test_migrate_legacy_sets_flag +[gw3] [ 72%] PASSED src/tests/test_legacy_migration_prompt.py::test_migrate_legacy_sets_flag +src/tests/test_legacy_migration_second_session.py::test_legacy_migration_second_session +[gw1] [ 72%] PASSED src/tests/test_password_prompt.py::test_prompt_new_password_retry +src/tests/test_password_prompt.py::test_prompt_existing_password +[gw1] [ 72%] PASSED src/tests/test_password_prompt.py::test_prompt_existing_password +src/tests/test_password_prompt.py::test_confirm_action_yes_no +[gw1] [ 72%] PASSED src/tests/test_password_prompt.py::test_confirm_action_yes_no +src/tests/test_password_properties.py::test_password_properties +[gw3] [ 73%] PASSED src/tests/test_legacy_migration_second_session.py::test_legacy_migration_second_session +src/tests/test_list_entries_all_types.py::test_cli_list_all_types +[gw0] [ 73%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry/1/archive] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry/1/unarchive] +[gw3] [ 73%] PASSED src/tests/test_list_entries_all_types.py::test_cli_list_all_types +src/tests/test_list_entries_all_types.py::test_menu_list_all_types +[gw3] [ 73%] PASSED src/tests/test_list_entries_all_types.py::test_menu_list_all_types +src/tests/test_list_entries_sort_filter.py::test_sort_by_label +[gw3] [ 73%] PASSED src/tests/test_list_entries_sort_filter.py::test_sort_by_label +src/tests/test_portable_backup.py::test_export_creates_additional_backup_and_import +[gw3] [ 73%] PASSED src/tests/test_portable_backup.py::test_export_creates_additional_backup_and_import +src/tests/test_post_sync_messages.py::test_handle_post_success +[gw3] [ 73%] PASSED src/tests/test_post_sync_messages.py::test_handle_post_success +src/tests/test_post_sync_messages.py::test_handle_post_failure +[gw3] [ 74%] PASSED src/tests/test_post_sync_messages.py::test_handle_post_failure +src/tests/test_post_sync_messages.py::test_handle_post_prints_all_ids +[gw3] [ 74%] PASSED src/tests/test_post_sync_messages.py::test_handle_post_prints_all_ids +src/tests/test_profile_cleanup.py::test_generate_seed_cleanup_on_failure +[gw3] [ 74%] PASSED src/tests/test_profile_cleanup.py::test_generate_seed_cleanup_on_failure +src/tests/test_profile_deletion_sync.py::test_profile_deletion_stops_sync +[gw3] [ 74%] PASSED src/tests/test_profile_deletion_sync.py::test_profile_deletion_stops_sync +src/tests/test_profile_export_import.py::test_profile_export_import_round_trip +[gw0] [ 74%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/entry/1/unarchive] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/change-password] +[gw3] [ 74%] PASSED src/tests/test_profile_export_import.py::test_profile_export_import_round_trip +src/tests/test_profile_init_integration.py::test_initialize_profile_and_manager +[gw2] [ 75%] PASSED src/tests/test_password_unlock_after_change.py::test_password_change_and_unlock +src/tests/test_pgp_entry.py::test_pgp_key_determinism +[gw0] [ 75%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/change-password] +src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/vault/lock] +[gw2] [ 75%] PASSED src/tests/test_pgp_entry.py::test_pgp_key_determinism +src/tests/test_pgp_entry.py::test_pgp_rsa_key_determinism +[gw3] [ 75%] PASSED src/tests/test_profile_init_integration.py::test_initialize_profile_and_manager +src/tests/test_profile_management.py::test_add_and_delete_entry +[gw3] [ 75%] PASSED src/tests/test_profile_management.py::test_add_and_delete_entry +src/tests/test_profiles.py::test_add_and_switch_fingerprint +[gw3] [ 75%] PASSED src/tests/test_profiles.py::test_add_and_switch_fingerprint +src/tests/test_profiles.py::test_sync_index_missing_bad_data +[gw3] [ 75%] PASSED src/tests/test_profiles.py::test_sync_index_missing_bad_data +src/tests/test_profiles.py::test_attempt_initial_sync_incomplete_data +[gw3] [ 76%] PASSED src/tests/test_profiles.py::test_attempt_initial_sync_incomplete_data +src/tests/test_publish_json_result.py::test_publish_snapshot_success +[gw3] [ 76%] PASSED src/tests/test_publish_json_result.py::test_publish_snapshot_success +src/tests/test_publish_json_result.py::test_publish_snapshot_failure +[gw3] [ 76%] PASSED src/tests/test_publish_json_result.py::test_publish_snapshot_failure +src/tests/test_pubsub.py::test_subscribe_and_publish +[gw3] [ 76%] PASSED src/tests/test_pubsub.py::test_subscribe_and_publish +src/tests/test_pubsub.py::test_unsubscribe +[gw3] [ 76%] PASSED src/tests/test_pubsub.py::test_unsubscribe +src/tests/test_quick_unlock_default.py::test_quick_unlock_default_off +[gw3] [ 76%] PASSED src/tests/test_quick_unlock_default.py::test_quick_unlock_default_off +src/tests/test_quick_unlock_default.py::test_quick_unlock_logs_event +[gw3] [ 77%] PASSED src/tests/test_quick_unlock_default.py::test_quick_unlock_logs_event +src/tests/test_quick_unlock_profile_creation.py::test_toggle_quick_unlock_after_profile_creation +[gw0] [ 77%] PASSED src/tests/test_api.py::test_invalid_token_other_endpoints[asyncio-post-/api/v1/vault/lock] +src/tests/test_api_new_endpoints.py::test_create_and_modify_totp_entry[asyncio] +[gw3] [ 77%] PASSED src/tests/test_quick_unlock_profile_creation.py::test_toggle_quick_unlock_after_profile_creation +src/tests/test_restore_from_nostr_setup.py::test_handle_new_seed_setup_restore_from_nostr +[gw3] [ 77%] PASSED src/tests/test_restore_from_nostr_setup.py::test_handle_new_seed_setup_restore_from_nostr +src/tests/test_restore_from_nostr_setup.py::test_handle_new_seed_setup_restore_from_local_backup +[gw3] [ 77%] PASSED src/tests/test_restore_from_nostr_setup.py::test_handle_new_seed_setup_restore_from_local_backup +src/tests/test_restore_from_nostr_setup.py::test_restore_from_nostr_warns +[gw0] [ 77%] PASSED src/tests/test_api_new_endpoints.py::test_create_and_modify_totp_entry[asyncio] +src/tests/test_api_new_endpoints.py::test_create_and_modify_ssh_entry[asyncio] +[gw3] [ 77%] PASSED src/tests/test_restore_from_nostr_setup.py::test_restore_from_nostr_warns +src/tests/test_restore_from_nostr_setup.py::test_restore_from_nostr_abort +[gw0] [ 78%] PASSED src/tests/test_api_new_endpoints.py::test_create_and_modify_ssh_entry[asyncio] +src/tests/test_api_new_endpoints.py::test_update_entry_error[asyncio] +[gw3] [ 78%] PASSED src/tests/test_restore_from_nostr_setup.py::test_restore_from_nostr_abort +src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True0] +[gw3] [ 78%] PASSED src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True0] +src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True1] +[gw3] [ 78%] PASSED src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True1] +src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True2] +[gw0] [ 78%] PASSED src/tests/test_api_new_endpoints.py::test_update_entry_error[asyncio] +src/tests/test_api_new_endpoints.py::test_update_config_secret_mode[asyncio] +[gw3] [ 78%] PASSED src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-True2] +src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-False] +[gw3] [ 78%] PASSED src/tests/test_retrieve_pause_sensitive_entries.py::test_pause_before_entry_actions[-False] +src/tests/test_search_entries.py::test_search_by_website +[gw3] [ 79%] PASSED src/tests/test_search_entries.py::test_search_by_website +src/tests/test_search_entries.py::test_search_by_username +[gw2] [ 79%] PASSED src/tests/test_pgp_entry.py::test_pgp_rsa_key_determinism +src/tests/test_portable_backup.py::test_round_trip +[gw3] [ 79%] PASSED src/tests/test_search_entries.py::test_search_by_username +src/tests/test_search_entries.py::test_search_by_url +[gw2] [ 79%] PASSED src/tests/test_portable_backup.py::test_round_trip +src/tests/test_portable_backup.py::test_round_trip_unencrypted +[gw3] [ 79%] PASSED src/tests/test_search_entries.py::test_search_by_url +src/tests/test_search_entries.py::test_search_by_notes_and_totp +[gw2] [ 79%] PASSED src/tests/test_portable_backup.py::test_round_trip_unencrypted +src/tests/test_portable_backup.py::test_corruption_detection +[gw3] [ 80%] PASSED src/tests/test_search_entries.py::test_search_by_notes_and_totp +src/tests/test_search_entries.py::test_search_by_custom_field +[gw2] [ 80%] PASSED src/tests/test_portable_backup.py::test_corruption_detection +src/tests/test_portable_backup.py::test_import_over_existing +[gw3] [ 80%] PASSED src/tests/test_search_entries.py::test_search_by_custom_field +src/tests/test_search_entries.py::test_search_key_value_value +[gw0] [ 80%] PASSED src/tests/test_api_new_endpoints.py::test_update_config_secret_mode[asyncio] +[gw2] [ 80%] PASSED src/tests/test_portable_backup.py::test_import_over_existing +src/tests/test_portable_backup.py::test_checksum_mismatch_detection +src/tests/test_api_new_endpoints.py::test_totp_export_endpoint[asyncio] +[gw3] [ 80%] PASSED src/tests/test_search_entries.py::test_search_key_value_value +src/tests/test_search_entries.py::test_search_no_results +[gw2] [ 80%] PASSED src/tests/test_portable_backup.py::test_checksum_mismatch_detection +src/tests/test_portable_backup.py::test_export_import_seed_encrypted_with_different_key +[gw3] [ 81%] PASSED src/tests/test_search_entries.py::test_search_no_results +src/tests/test_search_entries.py::test_search_by_tag_password +[gw2] [ 81%] PASSED src/tests/test_portable_backup.py::test_export_import_seed_encrypted_with_different_key +src/tests/test_seed_import.py::test_seed_encryption_round_trip +[gw3] [ 81%] PASSED src/tests/test_search_entries.py::test_search_by_tag_password +src/tests/test_search_entries.py::test_search_by_tag_totp +[gw2] [ 81%] PASSED src/tests/test_seed_import.py::test_seed_encryption_round_trip +src/tests/test_seed_migration.py::test_parent_seed_migrates_from_fernet +[gw3] [ 81%] PASSED src/tests/test_search_entries.py::test_search_by_tag_totp +src/tests/test_search_entries.py::test_search_with_kind_filter +[gw2] [ 81%] PASSED src/tests/test_seed_migration.py::test_parent_seed_migrates_from_fernet +src/tests/test_seed_prompt.py::test_masked_input_posix_backspace +[gw2] [ 81%] PASSED src/tests/test_seed_prompt.py::test_masked_input_posix_backspace +src/tests/test_seed_prompt.py::test_masked_input_windows_space +[gw2] [ 82%] PASSED src/tests/test_seed_prompt.py::test_masked_input_windows_space +src/tests/test_seed_prompt.py::test_masked_input_posix_ctrl_c +[gw2] [ 82%] PASSED src/tests/test_seed_prompt.py::test_masked_input_posix_ctrl_c +src/tests/test_seed_prompt.py::test_masked_input_windows_ctrl_c +[gw2] [ 82%] PASSED src/tests/test_seed_prompt.py::test_masked_input_windows_ctrl_c +src/tests/test_seed_prompt.py::test_prompt_seed_words_valid +[gw2] [ 82%] PASSED src/tests/test_seed_prompt.py::test_prompt_seed_words_valid +src/tests/test_seed_prompt.py::test_prompt_seed_words_invalid_word +[gw3] [ 82%] PASSED src/tests/test_search_entries.py::test_search_with_kind_filter +src/tests/test_secret_mode.py::test_password_retrieve_secret_mode +[gw3] [ 82%] PASSED src/tests/test_secret_mode.py::test_password_retrieve_secret_mode +src/tests/test_secret_mode.py::test_totp_display_secret_mode +[gw3] [ 83%] PASSED src/tests/test_secret_mode.py::test_totp_display_secret_mode +src/tests/test_secret_mode.py::test_password_retrieve_no_secret_mode +[gw0] [ 83%] PASSED src/tests/test_api_new_endpoints.py::test_totp_export_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_totp_codes_endpoint[asyncio] +[gw3] [ 83%] PASSED src/tests/test_secret_mode.py::test_password_retrieve_no_secret_mode +src/tests/test_secret_mode.py::test_totp_display_no_secret_mode +[gw3] [ 83%] PASSED src/tests/test_secret_mode.py::test_totp_display_no_secret_mode +src/tests/test_secret_mode_profile_creation.py::test_add_new_fingerprint_initializes_managers +[gw3] [ 83%] PASSED src/tests/test_secret_mode_profile_creation.py::test_add_new_fingerprint_initializes_managers +src/tests/test_secret_mode_profile_creation.py::test_toggle_secret_mode_after_profile_creation +[gw3] [ 83%] PASSED src/tests/test_secret_mode_profile_creation.py::test_toggle_secret_mode_after_profile_creation +src/tests/test_seed_entry.py::test_seed_phrase_determinism +[gw3] [ 83%] PASSED src/tests/test_seed_entry.py::test_seed_phrase_determinism +src/tests/test_seed_generation.py::test_generate_bip85_and_new_seed +[gw3] [ 84%] PASSED src/tests/test_seed_generation.py::test_generate_bip85_and_new_seed +src/tests/test_settings_menu.py::test_relay_and_profile_actions +[gw3] [ 84%] PASSED src/tests/test_settings_menu.py::test_relay_and_profile_actions +src/tests/test_settings_menu.py::test_settings_menu_additional_backup +[gw3] [ 84%] PASSED src/tests/test_settings_menu.py::test_settings_menu_additional_backup +src/tests/test_settings_menu.py::test_settings_menu_change_password +[gw3] [ 84%] PASSED src/tests/test_settings_menu.py::test_settings_menu_change_password +src/tests/test_settings_menu.py::test_settings_menu_change_password_incorrect +[gw0] [ 84%] PASSED src/tests/test_api_new_endpoints.py::test_totp_codes_endpoint[asyncio] +src/tests/test_api_new_endpoints.py::test_parent_seed_endpoint_removed[asyncio] +[gw2] [ 84%] PASSED src/tests/test_seed_prompt.py::test_prompt_seed_words_invalid_word +src/tests/test_seed_word_by_word_flow.py::test_prompt_seed_words_confirmation_loop +[gw2] [ 85%] PASSED src/tests/test_seed_word_by_word_flow.py::test_prompt_seed_words_confirmation_loop +src/tests/test_seed_word_by_word_flow.py::test_prompt_seed_words_invalid_word +[gw2] [ 85%] PASSED src/tests/test_seed_word_by_word_flow.py::test_prompt_seed_words_invalid_word +src/tests/test_seed_word_by_word_flow.py::test_add_new_fingerprint_words_flow_success +[gw2] [ 85%] PASSED src/tests/test_seed_word_by_word_flow.py::test_add_new_fingerprint_words_flow_success +src/tests/test_seed_word_by_word_flow.py::test_add_new_fingerprint_words_flow_invalid_phrase +[gw2] [ 85%] PASSED src/tests/test_seed_word_by_word_flow.py::test_add_new_fingerprint_words_flow_invalid_phrase +src/tests/test_seedqr_encoding.py::test_seedqr_standard_example +[gw2] [ 85%] PASSED src/tests/test_seedqr_encoding.py::test_seedqr_standard_example +src/tests/test_service_classes.py::test_entry_service_add_password +[gw3] [ 85%] PASSED src/tests/test_settings_menu.py::test_settings_menu_change_password_incorrect +src/tests/test_settings_menu.py::test_settings_menu_without_nostr_client +[gw3] [ 85%] PASSED src/tests/test_settings_menu.py::test_settings_menu_without_nostr_client +src/tests/test_ssh_entry.py::test_add_and_retrieve_ssh_key_pair +[gw2] [ 86%] PASSED src/tests/test_service_classes.py::test_entry_service_add_password +src/tests/test_service_classes.py::test_menu_handler_list_entries +[gw3] [ 86%] PASSED src/tests/test_ssh_entry.py::test_add_and_retrieve_ssh_key_pair +src/tests/test_ssh_entry_valid.py::test_ssh_private_key_corresponds_to_public +[gw2] [ 86%] PASSED src/tests/test_service_classes.py::test_menu_handler_list_entries +src/tests/test_service_classes.py::test_profile_service_switch +[gw2] [ 86%] PASSED src/tests/test_service_classes.py::test_profile_service_switch +src/tests/test_stats_screen.py::test_stats_display_resets_after_exit +[gw2] [ 86%] PASSED src/tests/test_stats_screen.py::test_stats_display_resets_after_exit +src/tests/test_stats_screen.py::test_stats_screen_breaks_on_enter +[gw2] [ 86%] PASSED src/tests/test_stats_screen.py::test_stats_screen_breaks_on_enter +src/tests/test_sync_race_conditions.py::test_sync_race_conditions +[gw3] [ 86%] PASSED src/tests/test_ssh_entry_valid.py::test_ssh_private_key_corresponds_to_public +src/tests/test_state_manager.py::test_state_manager_round_trip +[gw3] [ 87%] PASSED src/tests/test_state_manager.py::test_state_manager_round_trip +src/tests/test_stats_screen.py::test_live_stats_shows_message +[gw3] [ 87%] PASSED src/tests/test_stats_screen.py::test_live_stats_shows_message +src/tests/test_stats_screen.py::test_live_stats_shows_notification +[gw3] [ 87%] PASSED src/tests/test_stats_screen.py::test_live_stats_shows_notification +src/tests/test_stats_screen.py::test_live_stats_triggers_background_sync +[gw3] [ 87%] PASSED src/tests/test_stats_screen.py::test_live_stats_triggers_background_sync +src/tests/test_stats_screen.py::test_stats_display_only_once +[gw3] [ 87%] PASSED src/tests/test_stats_screen.py::test_stats_display_only_once +src/tests/test_terminal_utils_failure_handling.py::test_format_profile_reraises +[gw3] [ 87%] PASSED src/tests/test_terminal_utils_failure_handling.py::test_format_profile_reraises +src/tests/test_terminal_utils_failure_handling.py::test_clear_header_with_notification_reraises +[gw3] [ 88%] PASSED src/tests/test_terminal_utils_failure_handling.py::test_clear_header_with_notification_reraises +src/tests/test_totp.py::test_current_code_matches_pyotp +[gw0] [ 88%] PASSED src/tests/test_api_new_endpoints.py::test_parent_seed_endpoint_removed[asyncio] +src/tests/test_api_new_endpoints.py::test_fingerprint_endpoints[asyncio] +[gw3] [ 88%] PASSED src/tests/test_totp.py::test_current_code_matches_pyotp +src/tests/test_totp.py::test_time_remaining +[gw3] [ 88%] PASSED src/tests/test_totp.py::test_time_remaining +[gw2] [ 88%] PASSED src/tests/test_sync_race_conditions.py::test_sync_race_conditions +src/tests/test_tag_persistence.py::test_tags_persist_on_new_entry +src/tests/test_totp.py::test_print_progress_bar_terminates +[gw3] [ 88%] PASSED src/tests/test_totp.py::test_print_progress_bar_terminates +src/tests/test_totp_entry.py::test_add_totp_and_get_code +[gw3] [ 88%] PASSED src/tests/test_totp_entry.py::test_add_totp_and_get_code +src/tests/test_totp_entry.py::test_totp_time_remaining +[gw2] [ 89%] PASSED src/tests/test_tag_persistence.py::test_tags_persist_on_new_entry +src/tests/test_tag_persistence.py::test_tags_persist_after_modify +[gw3] [ 89%] PASSED src/tests/test_totp_entry.py::test_totp_time_remaining +src/tests/test_totp_entry.py::test_add_totp_imported +[gw3] [ 89%] PASSED src/tests/test_totp_entry.py::test_add_totp_imported +src/tests/test_totp_entry.py::test_add_totp_with_notes +[gw2] [ 89%] PASSED src/tests/test_tag_persistence.py::test_tags_persist_after_modify +src/tests/test_totp_uri.py::test_parse_otpauth_missing_prefix +[gw2] [ 89%] PASSED src/tests/test_totp_uri.py::test_parse_otpauth_missing_prefix +src/tests/test_totp_uri.py::test_parse_otpauth_missing_secret +[gw2] [ 89%] PASSED src/tests/test_totp_uri.py::test_parse_otpauth_missing_secret +src/tests/test_totp_uri.py::test_make_otpauth_uri_roundtrip +[gw2] [ 90%] PASSED src/tests/test_totp_uri.py::test_make_otpauth_uri_roundtrip +src/tests/test_typer_cli.py::test_entry_list +[gw3] [ 90%] PASSED src/tests/test_totp_entry.py::test_add_totp_with_notes +src/tests/test_totp_entry.py::test_legacy_deterministic_entry +[gw2] [ 90%] PASSED src/tests/test_typer_cli.py::test_entry_list +src/tests/test_typer_cli.py::test_entry_search +[gw2] [ 90%] PASSED src/tests/test_typer_cli.py::test_entry_search +src/tests/test_typer_cli.py::test_entry_get_password +[gw2] [ 90%] PASSED src/tests/test_typer_cli.py::test_entry_get_password +src/tests/test_typer_cli.py::test_vault_export +[gw3] [ 90%] PASSED src/tests/test_totp_entry.py::test_legacy_deterministic_entry +[gw2] [ 90%] PASSED src/tests/test_typer_cli.py::test_vault_export +src/tests/test_typer_cli.py::test_vault_import +src/tests/test_totp_uri.py::test_parse_otpauth_normal +[gw3] [ 91%] PASSED src/tests/test_totp_uri.py::test_parse_otpauth_normal +src/tests/test_typer_cli.py::test_vault_change_password +[gw2] [ 91%] PASSED src/tests/test_typer_cli.py::test_vault_import +[gw3] [ 91%] PASSED src/tests/test_typer_cli.py::test_vault_change_password +src/tests/test_typer_cli.py::test_vault_import_triggers_sync +src/tests/test_typer_cli.py::test_vault_lock +[gw3] [ 91%] PASSED src/tests/test_typer_cli.py::test_vault_lock +src/tests/test_typer_cli.py::test_root_lock +[gw2] [ 91%] PASSED src/tests/test_typer_cli.py::test_vault_import_triggers_sync +src/tests/test_typer_cli.py::test_nostr_get_pubkey +[gw3] [ 91%] PASSED src/tests/test_typer_cli.py::test_root_lock +[gw2] [ 91%] PASSED src/tests/test_typer_cli.py::test_nostr_get_pubkey +src/tests/test_typer_cli.py::test_vault_reveal_parent_seed +src/tests/test_typer_cli.py::test_fingerprint_list +[gw2] [ 92%] PASSED src/tests/test_typer_cli.py::test_fingerprint_list +src/tests/test_typer_cli.py::test_fingerprint_add +[gw3] [ 92%] PASSED src/tests/test_typer_cli.py::test_vault_reveal_parent_seed +src/tests/test_typer_cli.py::test_fingerprint_remove +[gw2] [ 92%] PASSED src/tests/test_typer_cli.py::test_fingerprint_add +[gw3] [ 92%] PASSED src/tests/test_typer_cli.py::test_fingerprint_remove +src/tests/test_typer_cli.py::test_fingerprint_switch +src/tests/test_typer_cli.py::test_config_set +[gw3] [ 92%] PASSED src/tests/test_typer_cli.py::test_fingerprint_switch +[gw2] [ 92%] PASSED src/tests/test_typer_cli.py::test_config_set +src/tests/test_typer_cli.py::test_config_set_unknown_key +src/tests/test_typer_cli.py::test_config_get +[gw3] [ 93%] PASSED src/tests/test_typer_cli.py::test_config_get +src/tests/test_typer_cli.py::test_generate_password +[gw2] [ 93%] PASSED src/tests/test_typer_cli.py::test_config_set_unknown_key +src/tests/test_typer_cli.py::test_nostr_sync +[gw3] [ 93%] PASSED src/tests/test_typer_cli.py::test_generate_password +[gw2] [ 93%] PASSED src/tests/test_typer_cli.py::test_nostr_sync +src/tests/test_typer_cli.py::test_api_start_passes_fingerprint +src/tests/test_typer_cli.py::test_entry_list_passes_fingerprint +[gw3] [ 93%] PASSED src/tests/test_typer_cli.py::test_api_start_passes_fingerprint +[gw2] [ 93%] PASSED src/tests/test_typer_cli.py::test_entry_list_passes_fingerprint +src/tests/test_typer_cli.py::test_entry_modify +src/tests/test_typer_cli.py::test_entry_add +[gw3] [ 93%] PASSED src/tests/test_typer_cli.py::test_entry_modify +src/tests/test_typer_cli.py::test_entry_modify_invalid +[gw2] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_add +src/tests/test_typer_cli.py::test_entry_archive +[gw3] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_modify_invalid +src/tests/test_typer_cli.py::test_entry_unarchive +[gw2] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_archive +src/tests/test_typer_cli.py::test_entry_export_totp +[gw3] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_unarchive +src/tests/test_typer_cli.py::test_entry_totp_codes +[gw2] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_export_totp +src/tests/test_typer_cli.py::test_verify_checksum_command +[gw3] [ 94%] PASSED src/tests/test_typer_cli.py::test_entry_totp_codes +src/tests/test_typer_cli.py::test_update_checksum_command +[gw2] [ 95%] PASSED src/tests/test_typer_cli.py::test_verify_checksum_command +src/tests/test_typer_cli.py::test_tui_forward_fingerprint +[gw3] [ 95%] PASSED src/tests/test_typer_cli.py::test_update_checksum_command +src/tests/test_typer_cli.py::test_gui_command +[gw2] [ 95%] PASSED src/tests/test_typer_cli.py::test_tui_forward_fingerprint +src/tests/test_typer_cli.py::test_gui_command_no_backend +[gw3] [ 95%] PASSED src/tests/test_typer_cli.py::test_gui_command +src/tests/test_typer_cli.py::test_gui_command_install_backend +[gw2] [ 95%] PASSED src/tests/test_typer_cli.py::test_gui_command_no_backend +src/tests/test_unlock_sync.py::test_unlock_triggers_sync +[gw3] [ 95%] PASSED src/tests/test_typer_cli.py::test_gui_command_install_backend +src/tests/test_unlock_sync.py::test_quick_unlock_background_sync +[gw3] [ 95%] PASSED src/tests/test_unlock_sync.py::test_quick_unlock_background_sync +src/tests/test_v2_prefix_fallback.py::test_v2_prefix_fernet_fallback +[gw3] [ 96%] PASSED src/tests/test_v2_prefix_fallback.py::test_v2_prefix_fernet_fallback +src/tests/test_v2_prefix_fallback.py::test_aesgcm_payload_too_short +[gw3] [ 96%] PASSED src/tests/test_v2_prefix_fallback.py::test_aesgcm_payload_too_short +src/tests/test_vault_initialization.py::test_save_and_encrypt_seed_initializes_vault +[gw2] [ 96%] PASSED src/tests/test_unlock_sync.py::test_unlock_triggers_sync +src/tests/test_unlock_sync.py::test_start_background_sync_running_loop +[gw2] [ 96%] PASSED src/tests/test_unlock_sync.py::test_start_background_sync_running_loop +src/tests/test_vault_lock_flag.py::test_lock_vault_sets_flag_and_keeps_objects +[gw2] [ 96%] PASSED src/tests/test_vault_lock_flag.py::test_lock_vault_sets_flag_and_keeps_objects +src/tests/test_vault_lock_flag.py::test_entry_service_requires_unlocked +[gw2] [ 96%] PASSED src/tests/test_vault_lock_flag.py::test_entry_service_requires_unlocked +src/tests/test_vault_lock_flag.py::test_unlock_vault_clears_locked_flag +[gw2] [ 96%] PASSED src/tests/test_vault_lock_flag.py::test_unlock_vault_clears_locked_flag +src/tests/test_verbose_timing.py::test_unlock_vault_logs_time +[gw2] [ 97%] PASSED src/tests/test_verbose_timing.py::test_unlock_vault_logs_time +src/tests/test_verbose_timing.py::test_publish_snapshot_logs_time +[gw2] [ 97%] PASSED src/tests/test_verbose_timing.py::test_publish_snapshot_logs_time +tests/perf/test_bip85_cache.py::test_bip85_cache_benchmark +[gw2] [ 97%] PASSED tests/perf/test_bip85_cache.py::test_bip85_cache_benchmark +[gw0] [ 97%] PASSED src/tests/test_api_new_endpoints.py::test_fingerprint_endpoints[asyncio] +src/tests/test_api_new_endpoints.py::test_checksum_endpoints[asyncio] +[gw3] [ 97%] PASSED src/tests/test_vault_initialization.py::test_save_and_encrypt_seed_initializes_vault +src/tests/test_vault_lock_event.py::test_lock_vault_publishes_event +[gw3] [ 97%] PASSED src/tests/test_vault_lock_event.py::test_lock_vault_publishes_event +[gw0] [ 98%] PASSED src/tests/test_api_new_endpoints.py::test_checksum_endpoints[asyncio] +src/tests/test_api_new_endpoints.py::test_vault_import_via_path[asyncio] +[gw0] [ 98%] PASSED src/tests/test_api_new_endpoints.py::test_vault_import_via_path[asyncio] +src/tests/test_api_new_endpoints.py::test_vault_import_via_upload[asyncio] +[gw0] [ 98%] PASSED src/tests/test_api_new_endpoints.py::test_vault_import_via_upload[asyncio] +src/tests/test_api_new_endpoints.py::test_vault_import_invalid_extension[asyncio] +[gw0] [ 98%] PASSED src/tests/test_api_new_endpoints.py::test_vault_import_invalid_extension[asyncio] +src/tests/test_api_new_endpoints.py::test_vault_import_path_traversal_blocked[asyncio] +[gw0] [ 98%] PASSED src/tests/test_api_new_endpoints.py::test_vault_import_path_traversal_blocked[asyncio] +[gw1] [ 98%] PASSED src/tests/test_password_properties.py::test_password_properties +src/tests/test_password_shuffle_consistency.py::test_password_generation_consistent_output +[gw1] [ 98%] PASSED src/tests/test_password_shuffle_consistency.py::test_password_generation_consistent_output +src/tests/test_password_special_chars.py::test_no_special_chars +[gw1] [ 99%] PASSED src/tests/test_password_special_chars.py::test_no_special_chars +src/tests/test_password_special_chars.py::test_allowed_special_chars_only +[gw1] [ 99%] PASSED src/tests/test_password_special_chars.py::test_allowed_special_chars_only +src/tests/test_password_special_chars.py::test_exclude_ambiguous_chars +[gw1] [ 99%] PASSED src/tests/test_password_special_chars.py::test_exclude_ambiguous_chars +src/tests/test_password_special_chars.py::test_safe_special_chars_mode +[gw1] [ 99%] PASSED src/tests/test_password_special_chars.py::test_safe_special_chars_mode +src/tests/test_password_special_modes.py::test_include_special_chars_false +[gw1] [ 99%] PASSED src/tests/test_password_special_modes.py::test_include_special_chars_false +src/tests/test_password_special_modes.py::test_safe_mode_uses_safe_chars +[gw1] [ 99%] PASSED src/tests/test_password_special_modes.py::test_safe_mode_uses_safe_chars +src/tests/test_password_special_modes.py::test_allowed_chars_override_special_mode +[gw1] [100%] PASSED src/tests/test_password_special_modes.py::test_allowed_chars_override_special_mode + +=============================== warnings summary =============================== +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5 +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5 +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5 +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5 +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5 + /home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/pgpy/constants.py:5: DeprecationWarning: 'imghdr' is deprecated and slated for removal in Python 3.13 + import imghdr + +../home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/typer/params.py:206: 24 warnings +src/tests/test_cli_integration.py: 2 warnings + /home/jules/.pyenv/versions/3.12.12/lib/python3.12/site-packages/typer/params.py:206: DeprecationWarning: The 'is_flag' and 'flag_value' parameters are not supported by Typer and will be removed entirely in a future release. + return OptionInfo( + +src/tests/test_gui_headless.py: 14 warnings +src/tests/test_gui_sync.py: 4 warnings + :31: DeprecationWarning: Pack.padding_top is deprecated. Use Pack.margin_top instead. + +src/tests/test_gui_headless.py: 13 warnings +src/tests/test_gui_sync.py: 2 warnings + :30: DeprecationWarning: Pack.padding is deprecated. Use Pack.margin instead. + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +================= 653 passed, 8 skipped, 64 warnings in 45.30s ================= +``` diff --git a/torch/torch-config.json b/torch/torch-config.json index 9293bac0..e41366a3 100644 --- a/torch/torch-config.json +++ b/torch/torch-config.json @@ -26,4 +26,4 @@ "weekly": "bug-reproducer-agent" } } -} \ No newline at end of file +}