@agxnte/reidx 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,7 @@ reid doctor
65
65
  Expected output:
66
66
 
67
67
  ```
68
- reid 2.0.2
68
+ reid 2.0.3
69
69
  settings <path> (found|missing)
70
70
  python <path> (3.13.x)
71
71
  workspace <cwd>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agxnte/reidx",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Terminal-native personal intelligence and coding CLI (agent-first runtime)",
5
5
  "bin": {
6
6
  "reid": "bin/reidx.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reidx"
3
- version = "2.0.1"
3
+ version = "2.0.3"
4
4
  description = "Terminal-native personal intelligence and coding CLI (agent-first runtime)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,3 +1,3 @@
1
1
  """ReidX: terminal-native agent-first CLI runtime."""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.0.3"
@@ -16,9 +16,11 @@ from reidx import __version__
16
16
  from reidx.automation.exec import exec_run
17
17
  from reidx.config.loader import ConfigLoader
18
18
  from reidx.config.models import Config
19
+ from reidx.config.storage import storage_root
19
20
  from reidx.deepreid import format_markdown, run_deepreid, save_deepreid_result
20
21
  from reidx.diagnostics.logger import get_logger
21
22
  from reidx.provider.registry import default_registry
23
+ from reidx.provider.store import load_from_database
22
24
  from reidx.provider.store import load_into as load_stored_providers
23
25
  from reidx.runtime.orchestrator import Orchestrator
24
26
  from reidx.tools import default_registry as tools_registry
@@ -97,11 +99,9 @@ def _main(
97
99
  def build_orchestrator(config: Config | None = None) -> Orchestrator:
98
100
  cfg = config or ConfigLoader().load()
99
101
  providers = default_registry(cfg)
100
- load_stored_providers(providers, cfg.storage_root or (Path.home() / ".reidx"))
101
- # `/connect` may have persisted a provider that isn't yet `cfg.default_provider`.
102
- # We never auto-promote — stub stays default unless the user did `/use` last
103
- # session (that's a session setting, not a config change). Fall back to stub
104
- # if the configured default isn't registered.
102
+ root = cfg.storage_root or storage_root()
103
+ load_stored_providers(providers, root)
104
+ load_from_database(providers, root)
105
105
  default_name = cfg.default_provider if providers.has(cfg.default_provider) else "stub"
106
106
  provider = providers.get(default_name)
107
107
  return Orchestrator(cfg, provider, tools_registry(), providers=providers)
@@ -1,10 +1,13 @@
1
1
  from reidx.config.loader import ConfigLoader
2
2
  from reidx.config.models import Config, PolicyConfig, ProviderConfig, default_config
3
+ from reidx.config.storage import app_data_dir, storage_root
3
4
 
4
5
  __all__ = [
5
6
  "Config",
6
7
  "ConfigLoader",
7
8
  "PolicyConfig",
8
9
  "ProviderConfig",
10
+ "app_data_dir",
9
11
  "default_config",
12
+ "storage_root",
10
13
  ]
@@ -11,11 +11,12 @@ from pathlib import Path
11
11
 
12
12
  from reidx.config.models import Config, default_config
13
13
  from reidx.config.settings import apply_settings_env, read_reidx_block
14
+ from reidx.config.storage import storage_root
14
15
  from reidx.diagnostics.logger import get_logger
15
16
 
16
17
  log = get_logger("reidx.config")
17
18
 
18
- GLOBAL_DIR = Path.home() / ".reidx"
19
+ GLOBAL_DIR = storage_root()
19
20
  PROJECT_DIR = Path(".reidx")
20
21
  CONFIG_FILENAME = "config.json"
21
22
 
@@ -24,12 +24,13 @@ import json
24
24
  import os
25
25
  from pathlib import Path
26
26
 
27
+ from reidx.config.storage import app_data_dir
27
28
  from reidx.diagnostics.logger import get_logger
28
29
 
29
30
  log = get_logger("reidx.config.settings")
30
31
 
31
32
  LEGACY_SETTINGS_PATH = Path("E:/leech/Reidchat.json")
32
- GLOBAL_SETTINGS_PATH = Path.home() / ".reidx" / "settings.json"
33
+ GLOBAL_SETTINGS_PATH = app_data_dir() / "settings.json"
33
34
  PROJECT_SETTINGS_FILENAME = "settings.json"
34
35
 
35
36
 
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ def app_data_dir() -> Path:
9
+ if sys.platform == "win32":
10
+ base = os.environ.get("APPDATA")
11
+ if base:
12
+ return Path(base) / "Reid"
13
+ return Path.home() / ".reidx"
14
+
15
+
16
+ def storage_root() -> Path:
17
+ return app_data_dir()
18
+
19
+
20
+ def settings_file() -> Path:
21
+ return app_data_dir() / "settings.json"
@@ -27,3 +27,16 @@ def post_json(url: str, payload: dict, headers: dict[str, str], timeout: int = T
27
27
  except urllib.error.URLError as exc:
28
28
  raise RuntimeError(f"connection error: {exc}") from exc
29
29
  return json.loads(raw)
30
+
31
+
32
+ def get_json(url: str, headers: dict[str, str], timeout: int = TIMEOUT_SECONDS) -> dict:
33
+ req = urllib.request.Request(url, headers=headers, method="GET")
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
36
+ raw = resp.read().decode("utf-8")
37
+ except urllib.error.HTTPError as exc:
38
+ err_body = exc.read().decode("utf-8", errors="replace")[:500]
39
+ raise RuntimeError(f"HTTP {exc.code}: {err_body}") from exc
40
+ except urllib.error.URLError as exc:
41
+ raise RuntimeError(f"connection error: {exc}") from exc
42
+ return json.loads(raw)
@@ -48,3 +48,6 @@ class BaseProvider(ABC):
48
48
  model: str | None = None,
49
49
  ) -> ProviderResponse:
50
50
  """Run one model turn. Returns text and/or tool calls."""
51
+
52
+ def fetch_models(self) -> list[str]:
53
+ return []
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  from typing import Any
10
10
 
11
11
  from reidx.diagnostics.logger import get_logger
12
- from reidx.provider._http import post_json
12
+ from reidx.provider._http import get_json, post_json
13
13
  from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
14
14
 
15
15
  log = get_logger("reidx.provider.ollama")
@@ -107,3 +107,16 @@ class OllamaProvider(BaseProvider):
107
107
  ),
108
108
  stop_reason=body.get("done_reason", "stop"),
109
109
  )
110
+
111
+ def fetch_models(self) -> list[str]:
112
+ try:
113
+ body = get_json(f"{self.base_url}/api/tags", {})
114
+ except RuntimeError:
115
+ log.debug("failed to fetch models from ollama")
116
+ return []
117
+ models: list[str] = []
118
+ for item in body.get("models", []):
119
+ name = item.get("name", "")
120
+ if name:
121
+ models.append(name)
122
+ return sorted(models)
@@ -14,7 +14,7 @@ import os
14
14
  from typing import Any
15
15
 
16
16
  from reidx.diagnostics.logger import get_logger
17
- from reidx.provider._http import post_json
17
+ from reidx.provider._http import get_json, post_json
18
18
  from reidx.provider.base import BaseProvider, Message, ProviderResponse, ToolCall, Usage
19
19
 
20
20
  log = get_logger("reidx.provider.openai")
@@ -125,6 +125,20 @@ class OpenAIProvider(BaseProvider):
125
125
  raise
126
126
  return self._parse(body, model)
127
127
 
128
+ def fetch_models(self) -> list[str]:
129
+ url = f"{self.base_url}/v1/models"
130
+ try:
131
+ body = get_json(url, self._headers())
132
+ except RuntimeError:
133
+ log.debug("failed to fetch models from %s", url)
134
+ return []
135
+ models: list[str] = []
136
+ for item in body.get("data", []):
137
+ mid = item.get("id", "")
138
+ if mid:
139
+ models.append(mid)
140
+ return sorted(models)
141
+
128
142
 
129
143
  class OpenAICompatibleProvider(OpenAIProvider):
130
144
  """OpenAI-compatible endpoints (llama.cpp server, LM Studio, vLLM, etc.).
@@ -125,11 +125,25 @@ class ProviderStore:
125
125
  return True
126
126
 
127
127
 
128
+ def validate_provider(record: ProviderRecord) -> tuple[bool, str]:
129
+ try:
130
+ provider = build_provider(record)
131
+ except (ValueError, TypeError) as exc:
132
+ return False, str(exc)
133
+ if not record.api_key and record.kind != "ollama":
134
+ return True, "no key required"
135
+ try:
136
+ models = provider.fetch_models()
137
+ except Exception:
138
+ return False, "could not connect to provider"
139
+ if models:
140
+ return True, f"ok ({len(models)} models available)"
141
+ if record.kind == "ollama":
142
+ return True, "connected"
143
+ return False, "key rejected or no models returned"
144
+
145
+
128
146
  def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
129
- """Register every persisted provider into `registry`. Returns the list of
130
- names successfully registered. Skips (with a warning) any record whose
131
- kind is unknown or fails to construct — the app must not crash on a
132
- single bad entry."""
133
147
  added: list[str] = []
134
148
  store = ProviderStore(storage_root)
135
149
  for record in store.list():
@@ -139,3 +153,39 @@ def load_into(registry: ProviderRegistry, storage_root: Path) -> list[str]:
139
153
  except (ValueError, TypeError):
140
154
  log.exception("skipping provider %s (kind=%s): failed to build", record.name, record.kind)
141
155
  return added
156
+
157
+
158
+ def load_from_database(registry: ProviderRegistry, storage_root: Path) -> list[str]:
159
+ from reidx.provider_manager.database import ProviderDatabase
160
+
161
+ added: list[str] = []
162
+ db = ProviderDatabase(storage_root)
163
+ for sp in db.list_providers():
164
+ if registry.has(sp.name):
165
+ continue
166
+ api_key = sp.decrypted_api_key()
167
+ record = ProviderRecord(
168
+ name=sp.name,
169
+ kind=sp.kind,
170
+ base_url=sp.base_url,
171
+ api_key=api_key,
172
+ default_model=sp.default_model,
173
+ )
174
+ try:
175
+ provider = build_provider(record)
176
+ except (ValueError, TypeError):
177
+ log.warning("skipping provider %s (kind=%s): failed to build", sp.name, sp.kind)
178
+ continue
179
+ if not record.default_model:
180
+ try:
181
+ models = provider.fetch_models()
182
+ except Exception:
183
+ models = []
184
+ if models:
185
+ provider.default_model = models[0]
186
+ sp.default_model = models[0]
187
+ db.save_provider(sp)
188
+ log.info("auto-fetched model for %s: %s", sp.name, models[0])
189
+ registry.register(sp.name, provider)
190
+ added.append(sp.name)
191
+ return added
@@ -9,7 +9,7 @@ from typing import Any
9
9
  from prompt_toolkit.buffer import Buffer
10
10
 
11
11
  from reidx.diagnostics.logger import get_logger
12
- from reidx.provider.store import ProviderRecord, build_provider
12
+ from reidx.provider.store import ProviderRecord, build_provider, validate_provider
13
13
  from reidx.provider_manager.catalog import (
14
14
  ProviderDefinition,
15
15
  all_providers,
@@ -602,6 +602,25 @@ class ProviderPalette:
602
602
  auth = d.get("auth_method", "bearer")
603
603
  api_key = d.get("api_key", "").strip()
604
604
 
605
+ if api_key:
606
+ record = ProviderRecord(
607
+ name=name, kind=kind, base_url=base_url,
608
+ api_key=api_key, default_model=model,
609
+ )
610
+ ok, msg = validate_provider(record)
611
+ if not ok:
612
+ self._show_message(f"Key validation failed: {msg}", self.WIZARD)
613
+ self.wizard_step_idx = len(WIZARD_STEPS) - 1
614
+ return
615
+ if not model:
616
+ try:
617
+ provider = build_provider(record)
618
+ models = provider.fetch_models()
619
+ except Exception:
620
+ models = []
621
+ if models:
622
+ model = models[0]
623
+
605
624
  existing = self.db.get_provider(name)
606
625
  if existing:
607
626
  existing.kind = kind
@@ -647,10 +666,19 @@ class ProviderPalette:
647
666
  if not self.current_provider:
648
667
  return
649
668
  name = self.current_provider.name
669
+ sp = self.current_provider
670
+ record = ProviderRecord(
671
+ name=name, kind=sp.kind, base_url=sp.base_url,
672
+ api_key=api_key, default_model=sp.default_model,
673
+ )
674
+ ok, msg = validate_provider(record)
675
+ if not ok:
676
+ self._show_message(f"Key validation failed: {msg}", self.MANAGE)
677
+ return
650
678
  self.db.add_key(name, label, api_key)
651
679
  self.current_provider = self.db.get_provider(name)
652
680
  self._register_current()
653
- self._show_message(f"Added key '{label}'", self.MANAGE)
681
+ self._show_message(f"Added key '{label}' ({msg})", self.MANAGE)
654
682
 
655
683
  def _do_delete_key(self, key_id: str, label: str) -> str:
656
684
  if not self.current_provider:
@@ -682,6 +710,15 @@ class ProviderPalette:
682
710
  api_key=api_key, default_model=sp.default_model,
683
711
  )
684
712
  provider = build_provider(record)
713
+ if not sp.default_model:
714
+ try:
715
+ models = provider.fetch_models()
716
+ except Exception:
717
+ models = []
718
+ if models:
719
+ provider.default_model = models[0]
720
+ sp.default_model = models[0]
721
+ self.db.save_provider(sp)
685
722
  self.orchestrator.providers.register(sp.name, provider)
686
723
  except (ValueError, TypeError):
687
724
  log.exception("failed to register provider %s", sp.name)
@@ -458,8 +458,10 @@ class ChatApp:
458
458
  if self._palette is not None:
459
459
  return
460
460
  from pathlib import Path
461
- storage_root = self.orchestrator.config.storage_root or (Path.home() / ".reidx")
462
- db = ProviderDatabase(Path(storage_root))
461
+
462
+ from reidx.config.storage import storage_root as get_storage_root
463
+ root = self.orchestrator.config.storage_root or get_storage_root()
464
+ db = ProviderDatabase(Path(root))
463
465
  self._palette = ProviderPalette(
464
466
  db=db,
465
467
  orchestrator=self.orchestrator,
@@ -10,15 +10,22 @@ from, so they can't drift out of sync.
10
10
  """
11
11
  from __future__ import annotations
12
12
 
13
- from pathlib import Path
13
+ import uuid
14
14
 
15
15
  from rich.console import Group
16
16
  from rich.panel import Panel
17
17
  from rich.text import Text
18
18
 
19
+ from reidx.config.storage import storage_root
19
20
  from reidx.goals.models import Goal, GoalNodeKind, GoalStatus
20
21
  from reidx.policy.models import PermissionMode
21
- from reidx.provider.store import SUPPORTED_KINDS, ProviderRecord, ProviderStore, build_provider
22
+ from reidx.provider.store import (
23
+ SUPPORTED_KINDS,
24
+ ProviderRecord,
25
+ ProviderStore,
26
+ build_provider,
27
+ validate_provider,
28
+ )
22
29
  from reidx.runtime.orchestrator import Orchestrator
23
30
  from reidx.ui import render
24
31
  from reidx.ui.theme import APP_NAME, BOX, PRIMARY
@@ -472,10 +479,48 @@ _BUILTIN_PROVIDER_NAMES = ("stub", "anthropic")
472
479
 
473
480
 
474
481
  def _providers_store(orchestrator: Orchestrator) -> ProviderStore:
475
- root = orchestrator.config.storage_root or (Path.home() / ".reidx")
482
+ root = orchestrator.config.storage_root or storage_root()
476
483
  return ProviderStore(root)
477
484
 
478
485
 
486
+ def _save_to_database(orchestrator: Orchestrator, record: ProviderRecord) -> None:
487
+ from reidx.provider_manager import keychain
488
+ from reidx.provider_manager.database import ProviderDatabase, StoredKey, StoredProvider
489
+
490
+ root = orchestrator.config.storage_root or storage_root()
491
+ db = ProviderDatabase(root)
492
+ existing = db.get_provider(record.name)
493
+ if existing:
494
+ if record.api_key:
495
+ k = StoredKey(
496
+ id=uuid.uuid4().hex[:12],
497
+ label="Default",
498
+ encrypted_key=keychain.encrypt(record.api_key),
499
+ )
500
+ existing.keys.append(k)
501
+ if existing.active_key_id is None:
502
+ existing.active_key_id = k.id
503
+ existing.kind = record.kind
504
+ existing.base_url = record.base_url
505
+ if record.default_model:
506
+ existing.default_model = record.default_model
507
+ db.save_provider(existing)
508
+ else:
509
+ sp = StoredProvider(
510
+ name=record.name, kind=record.kind, base_url=record.base_url,
511
+ default_model=record.default_model,
512
+ )
513
+ if record.api_key:
514
+ k = StoredKey(
515
+ id=uuid.uuid4().hex[:12],
516
+ label="Default",
517
+ encrypted_key=keychain.encrypt(record.api_key),
518
+ )
519
+ sp.keys.append(k)
520
+ sp.active_key_id = k.id
521
+ db.save_provider(sp)
522
+
523
+
479
524
  def _handle_providers(orchestrator: Orchestrator) -> None:
480
525
  store = _providers_store(orchestrator)
481
526
  persisted = store.list()
@@ -507,12 +552,28 @@ def _handle_connect(orchestrator: Orchestrator, arg: str) -> None:
507
552
  api_key = parts[3] if len(parts) > 3 else ""
508
553
  model = parts[4] if len(parts) > 4 else ""
509
554
  record = ProviderRecord(name=name, kind=kind, base_url=base_url, api_key=api_key, default_model=model)
555
+ if api_key:
556
+ ok, msg = validate_provider(record)
557
+ if not ok:
558
+ render.print_error(f"key validation failed: {msg}")
559
+ return
560
+ render.print_info(f"key validated: {msg}")
510
561
  try:
511
562
  provider = build_provider(record)
512
563
  except ValueError as exc:
513
564
  render.print_error(f"failed to build provider: {exc}")
514
565
  return
566
+ if not model and api_key:
567
+ try:
568
+ models = provider.fetch_models()
569
+ except Exception:
570
+ models = []
571
+ if models:
572
+ provider.default_model = models[0]
573
+ record.default_model = models[0]
574
+ render.print_info(f"auto-detected model: {models[0]}")
515
575
  _providers_store(orchestrator).save(record)
576
+ _save_to_database(orchestrator, record)
516
577
  if orchestrator.providers is not None:
517
578
  orchestrator.providers.register(name, provider)
518
579
  render.print_info(f"connected provider '{name}' ({kind}) → {base_url or '(default)'}")