Commit Graph

41 Commits

Author SHA1 Message Date
curo1305 eaed52006f feat(daemon): add PID file management module
Atomic write-then-rename, stale-PID detection via os.kill on POSIX and
ctypes.OpenProcess on Windows, context manager for cleanup on exit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:22:04 +02:00
curo1305 0e052c4992 fix(setup): correct LM Studio loaded state value to "loaded" not "loaded_instance"
Querying the live /api/v0/models endpoint shows LM Studio uses state="loaded"
for in-memory models (not "loaded_instance"), so the filter never matched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:00:01 +02:00
curo1305 40aa934431 fix(setup): filter LM Studio models by state == "loaded_instance"
LM Studio's /v1/models returns all downloaded models, not just loaded
ones. Use /api/v0/models with state filtering in both fetch_loaded_models()
and _fetch_local_models() so only RAM-resident models are shown as loaded.
This also restores the _choose_model() fallback that offers downloaded-but-
unloaded models when nothing is active in LM Studio.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:54:06 +02:00
curo1305 833d1445f0 feat(setup,chat): detect actually-loaded local model via provider-specific API
For Ollama, /api/tags returns all installed models, not running ones.
Add fetch_loaded_models() using /api/ps for Ollama (and /v1/models for
LM Studio/llama.cpp, which already return only loaded models).

_show_local_model_status() now calls fetch_loaded_models() so the
setup wizard correctly shows only in-memory models for Ollama.

At chat session startup, local providers warn when the configured model
is not currently loaded, or when nothing is loaded at all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:46:54 +02:00
curo1305 01655124b5 Update description 2026-05-19 14:28:37 +02:00
curo1305 019e8044a9 feat(setup): model re-entry, status indicator, and resumable setup wizard
- _test_connection() now returns the (possibly changed) model name and
  offers a "Change model" option when the error is model-related
- _show_local_model_status() prints which models are currently loaded
  immediately after selecting a local provider
- Draft persistence: each completed wizard step is saved to
  ~/.pyra/setup.draft.json (chmod 600); on the next run a yellow panel
  summarises progress and offers [Resume / Start fresh]; draft is
  deleted on successful completion or Ctrl-C with no completed steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:43:49 +02:00
curo1305 9a392410e7 feat(setup): error controller with retry loops in setup wizard
Add _classify_error() that maps litellm/httpx exceptions to human-readable
labels and resolution hints without requiring a top-level litellm import.

_check_local_server() now loops with Retry / Continue anyway / Abort instead
of printing a one-shot warning and silently continuing.

_test_connection() now loops with Retry / Re-enter API key (auth errors only) /
Skip test / Abort instead of printing the raw exception string and falling through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:26:58 +02:00
curo1305 5eb81404c2 feat(setup): dynamic model discovery for local providers in wizard
Replace the static model text prompt with live API queries:
- _fetch_local_models(): queries /v1/models (LM Studio, llama.cpp) or
  /api/tags (Ollama) and returns a questionary.select list
- _fetch_lmstudio_available_models(): queries LM Studio's beta
  /api/v0/models to list downloaded-but-not-loaded models
- _load_lmstudio_model(): tries /api/v0/models/load to load a model
  in-place; falls back to telling the user to load manually
- Cloud providers keep the existing text-input behaviour

Also replace hardcoded LMSTUDIO_MODEL in integration tests with a
lmstudio_model fixture that queries the API at runtime and uses
whichever model is currently loaded (skips if none).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:53:15 +02:00
curo1305 ace9561c87 feat(setup): personalized setup wizard with purpose and plugin suggestions
Add a personalization step to `pyra setup` that asks for the user's name,
a one-sentence purpose, and interest areas, then surfaces relevant planned
plugins. Store purpose in GeneralConfig and use it in the system prompt so
Pyra stays task-focused rather than acting as a generic chatbot.

- config/schema.py: add `purpose: str = ""` to GeneralConfig
- setup/wizard.py: add _collect_user_profile(), _suggest_plugins(), _USE_CASE_PLUGINS
- chat/history.py: replace hardcoded _SYSTEM_BASE with _build_system_base() using config values
- config/tui.py: expose purpose field in /config General tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 10:43:15 +02:00
curo1305 cfebc3cb1f fix(providers): remove url_suffix from qwen (cloud provider, fixed endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:58:32 +02:00
curo1305 fd6313acd9 feat(tui): auto-correct base URL if required path suffix is missing
Providers that need /v1 (lmstudio, llamacpp, qwen) now declare a
url_suffix field. The AI settings save action appends it automatically
and notifies the user if they entered a URL without it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:56:37 +02:00
curo1305 a523fa61a3 fix(chat): fall back to provider default base_url when config value is blank
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:53:11 +02:00
curo1305 1cf7bdf908 fix(chat): show "tools disabled" info message only once per session
When a local model rejects function calling (BadRequestError), the flag
is set in a session-scoped dict so subsequent messages skip the tool-use
path entirely — no repeated info message on every turn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:18:11 +02:00
curo1305 bf29ffc7d8 fix(tui): refresh API key placeholder when switching providers
When the user switches providers in the AI tab, the key Input now shows
"set" or "not set" based on what's actually stored in the vault for that
provider, and clears any in-progress key entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:10:31 +02:00
curo1305 0b0cd07330 fix(chat): fall back to streaming when provider rejects function calling
Local models (e.g. Gemma on LM Studio) return HTTP 400 when sent a
tools-spec request. Catch litellm.BadRequestError in the tool-use loop,
inform the user once that tools are disabled, and retry as a plain
streaming call so the conversation continues normally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 00:03:14 +02:00
curo1305 f1213e28c8 feat(tui): AI provider tab + expanded General settings
Add a dedicated AI tab with provider Select, model Input, base URL Input,
and masked API key Input (write-only, stored in vault). Switching providers
reactively updates the model placeholder, base URL default, and shows/hides
the API key row for cloud vs. local providers. ctrl+s saves config and vault.

Extend GENERAL_FIELDS with Memory, Security, Plugin, and Daemon sections
using a new "section" header type and optional int cast for numeric fields.
_CoreField gains cast: type | None for automatic value coercion on save.

Add 5 new tests covering AI tab rendering, config save, vault key write,
vault key skip-on-empty, and section header rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:43:36 +02:00
curo1305 3b89d940de fix(tui): remove border-bottom from _TitleBar so title text renders
height: 1 + border-bottom: ascii left no row for content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:32:11 +02:00
curo1305 ee6c32b035 feat(tui): keyboard-only ASCII redesign of config TUI
Remove all Button widgets — saves and plugin toggles are keyboard-only
(ctrl+s, e, d). Replace Header with a plain _TitleBar Static. Apply a
dark monochrome ASCII theme: +---+ borders on inputs, DataTable, and
tab panes; #0d0d0d background; grey/white palette. Disable mouse at the
driver level via run(mouse=False). Update save test to drive via ctrl+s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:28:59 +02:00
curo1305 1412ced7a8 feat(tui): full keyboard support for config TUI and chat slash completion
Add Header/Footer with visible key hints, ctrl+right/ctrl+left tab navigation,
ctrl+s save bindings for General and plugin config tabs, e/d bindings for
plugin enable/disable in the Plugins tab. Extract shared _do_save() and
_toggle_plugin() helpers so button and key paths share one code path.

Add WordCompleter to the chat REPL so Tab completes slash commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 23:11:39 +02:00
curo1305 54241a9e4e fix(config): fix empty General tab — height collapse and invalid CSS variable
_GeneralTab and _PluginConfigTab inherited from Widget (height: auto), causing
the inner VerticalScroll to get height: 1fr of an auto-height parent, which
collapsed to 0. Fix: inherit from VerticalScroll directly and remove the inner
wrapper. _PluginsTab gets DEFAULT_CSS to fill its TabPane.

Also replace $text-muted (invalid in Textual 8.x) with $foreground 50%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:15:45 +02:00
curo1305 1201606187 feat(config): add /config TUI with tab-based settings and plugin config framework
- textual-based ConfigApp with General, Plugins, and per-plugin tabs
- GeneralConfig (user_name, assistant_name) + plugin_settings dict added to PyraConfig
- ConfigField dataclass and config_fields() method added to plugin protocol
- /config slash command in chat REPL launches the TUI
- pyra auto-runs setup wizard on first invocation when no config.yaml exists
- CLAUDE.md updated with config_fields() plugin guide and Code Inventory entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:28:19 +02:00
curo1305 b9b0918d3a feat(memory): wire database into reader, writer, and bootstrap
- reader: list_memories() queries memory_meta; lookup_memories() uses FTS5 with
  fallback to JSON index substring search
- writer: write_memory() and append_memory() upsert to DB after every file write
- dirs: bootstrap() calls init_db() + migrate_from_files() on startup

Existing .md files remain the canonical store; SQLite is the search index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:23:49 +02:00
curo1305 45e6ec32ec feat(memory): add SQLite+FTS5 database layer
New memory/database.py with memory_meta table (path, category, size_bytes,
modified, summary, keywords, embedding BLOB reserved for Stage 8) and
memory_fts virtual table for full-text search. Public API: init_db, upsert,
remove, search, list_all, migrate_from_files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 15:23:43 +02:00
curo1305 e56e9779ec feat(memory): add JSON index and runtime memory_lookup/read/write tools
Gives Pyra an active memory brain: memory_index.json tracks summary +
keywords per file (like an inode table), and three built-in tools let
the AI look up, read, and overwrite memory mid-session. write_memory
accepts summary/keywords; update_index() merges the JSON index without
losing existing metadata.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 23:01:54 +02:00
curo1305 ad024807bc feat(chat): add agent orchestration system with plan_and_execute
Introduces TaskPlanner and AgentSpec so Pyra can decompose multi-step
tasks into sequential steps, each executed with a focused sub-agent
context rather than the full conversation history.

- plugins/base.py: AgentSpec dataclass + agent_spec() on Protocol/BasePlugin
- plugins/registry.py: register_builtin, get_agent, list_agents
- chat/planner.py: TaskPlanner with plan approval, per-step tool-use loop,
  verification call, and agent-aware routing
- chat/session.py: wires plan_and_execute as a built-in tool after load_all
- chat/history.py: planning hint in system prompt + dynamic agents listing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 21:03:42 +02:00
curo1305 72dae1e048 perf(plugins): cache tool index in PluginRegistry for O(1) find_tool
load_all() now builds a _tools: dict[str, Tool] index at startup.
get_all_tools() returns list(_tools.values()) and find_tool() is a
direct dict.get() instead of rebuilding the full tool list from every
plugin on every tool call during a session.

Updated test helper to populate _tools alongside _plugins to match
the actual load_all() behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:09:51 +02:00
curo1305 bbe9bcfe0a refactor(memory): centralize _MEMORY_ROOT; fix mkdir order in append_memory
_MEMORY_ROOT was defined independently in reader.py, writer.py, and
index.py. Moved to memory/__init__.py; all three import from there.

Also fixes a bug in append_memory where path.write_text() was called
before path.parent.mkdir(), which would crash when creating a file in
a new subdirectory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:09:45 +02:00
curo1305 18b2b94194 refactor(vault): centralize _KEYS_FILE constant in vault/__init__.py
reader.py and writer.py each independently computed the same path via
pyra_home(). Single definition in __init__.py; both modules import it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:09:41 +02:00
curo1305 7bdb2c3faf chore: remove dead expand() function and skills/ bootstrap dirs
expand() had zero callers anywhere in the codebase. The skills/bash,
skills/powershell, and skills/python directories were created on every
startup but the skills/ tree is not part of the current architecture.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:09:36 +02:00
curo1305 c0c0156468 feat(plugins): Stage 2.1 — plugin framework and AI tool-use
Introduces a standalone plugin system where every integration lives as
an independent Python script in ~/.pyra/plugins/, not hardcoded in core.

Plugin framework (src/pyra/plugins/):
- base.py: Tool dataclass, PyraPlugin Protocol, BasePlugin helper
- loader.py: importlib-based discovery; one bad plugin never crashes pyra
- registry.py: singleton aggregating tools, slash commands, system prompts
- executor.py: approval gate — scans args, prompts y/N, scans result, logs
- install.py: copies bundled_plugins/ to ~/.pyra/plugins/ on install

Chat integration:
- AI tool-use loop (litellm function calling, up to 10 iterations)
- Plugin system prompt additions injected per session
- Plugin slash commands merged with static commands

CLI additions:
- pyra plugin list/install/enable/disable/setup
- pyra daemon start/stop/status/restart/install/uninstall (stubs for 2.4)

Config: PluginConfig + DaemonConfig added to PyraConfig (backwards-compatible)
Bootstrap: ~/.pyra/plugins/ and ~/.pyra/logs/ created on startup
Security: tool args and results always injection-scanned; plugin dirs
validated with assert_safe_path() before loading (symlink protection)

Tests: 37 new tests (loader, registry, executor, plugin isolation security)
161 total, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:35:20 +02:00
curo1305 30cda28ec8 fix(setup,chat): pass dummy api_key for local providers
litellm requires the api_key field even for local OpenAI-compatible
servers (LM Studio, llama.cpp). Use "local" as a sentinel value for
providers that don't require a real key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:54:18 +02:00
curo1305 6e138bcec2 fix: remove self-defeating assert_safe_path from vault modules, clarify traversal test scope
vault/reader.py, vault/writer.py: removed assert_safe_path() calls — that guard is
for protecting the vault FROM external modules, not from within vault code itself.
Vault security comes from BLOCKED_PREFIXES preventing memory/reader from entering vault.

test_path_traversal.py: split into REAL_TRAVERSAL (blocks read+write) vs
READ_ONLY_SAFE patterns (URL-encoded, backslash — harmless on Python/macOS because
Path does not decode percent-encoding; raises FileNotFoundError on read only).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:59:59 +02:00
curo1305 e792c5e0c9 feat(cli): wire all subcommands
pyra (default→chat), pyra setup, pyra chat, pyra memory list/read/write/append
All routes call bootstrap() first; PyraSecurityError exits with clear message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:52:26 +02:00
curo1305 0fe6332316 feat(chat): streaming REPL with rich renderer
- chat/renderer.py: Live streaming markdown, injection warning panel, redaction
- chat/history.py: ConversationHistory with system prompt + memory context injection,
  token budget trimming
- chat/session.py: prompt_toolkit REPL, slash commands (/quit /clear /help /memory list),
  vault key retrieval inline at call time (not stored), injection scan after each response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:52:08 +02:00
curo1305 1448bb4650 feat(security): prompt injection scanner and API key redaction
- 15 regex patterns across 4 categories: instruction-override, role-switch,
  jailbreak, exfiltration, credential-fishing
- scan_response() returns InjectionWarning list and logs to ~/.pyra/security.log
- redact_api_keys() strips sk-ant-, sk-, AIza patterns before display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:51:23 +02:00
curo1305 18c39cc152 feat(memory): sandboxed markdown memory system
- memory/index.py: auto-regenerate MEMORY_INDEX.md on every write
- memory/reader.py: list_memories(), read_memory(), load_context_for_session()
  all go through assert_safe_path() + relative_to check
- memory/writer.py: write_memory(), append_memory() — relative names only,
  no absolute paths or traversal, calls update_index() after every write

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:50:55 +02:00
curo1305 7617a80595 feat(vault): API key storage in vault only
- vault/reader.py: get_key() reads from ~/.pyra/vault/secrets/api_keys.json
- vault/writer.py: set_key(), delete_key() — only writer callable from setup
- Both call assert_safe_path() as defense-in-depth
- Keys file stays chmod 400; temporarily 600 during write then locked again
- Config.yaml never touched by either module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:50:24 +02:00
curo1305 43a8a363ba feat(setup): provider registry and interactive setup wizard
- setup/providers.py: registry for 8 providers (3 local, 5 cloud), frozen dataclasses
- setup/wizard.py: questionary-based wizard — provider select, model input,
  API key collected via vault.writer (not config.yaml), connectivity check,
  test call via litellm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:50:04 +02:00
curo1305 ae565b0d68 feat(config): directory bootstrap and config manager
- config/schema.py: Pydantic v2 models — no API keys, only provider_id/model/base_url
- config/manager.py: ruamel.yaml round-trip load/save, chmod 600 enforced on write
- config/dirs.py: bootstrap() creates ~/.pyra/ tree, vault sentinel checked every startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:49:22 +02:00
curo1305 a96b540234 feat(security): vault wall, path guard, and utils
- utils/paths.py: pyra_home(), ensure_dir(), safe_chmod(), expand()
- security/boundaries.py: VaultAccessError, PyraSecurityError,
  assert_safe_path() (called before every file read), check_vault_lock()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:48:50 +02:00
curo1305 0a04e04490 chore: init project skeleton
Directory structure, pyproject.toml with hatchling build, and all
subpackage stubs for pyra Stage 1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 12:48:32 +02:00