aida-workbench 0.0.1__py3-none-any.whl

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.
aida/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """AIDA — AI Data Assistant.
2
+
3
+ A local scientific agent workbench. See PLAN.md at the repository root for
4
+ the full design and phase map.
5
+ """
6
+
7
+ __version__ = "0.0.1"
@@ -0,0 +1 @@
1
+ """Typed tool-result artifacts (image, file, table) and the artifact store. Never imports Qt. Filled in starting Phase 3."""
aida/cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """AIDA command-line interface.
2
+
3
+ Phase 1 ships only ``aida doctor``. ``chat.py`` (Phase 2), ``run.py``
4
+ (Phase 10), and ``config_cmds.py`` are placeholders that later phases fill
5
+ in — see PLAN.md §10.
6
+ """
aida/cli/__main__.py ADDED
@@ -0,0 +1,61 @@
1
+ """Entry point for the ``aida`` console script (and ``aida-gui`` stub)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from aida.config.logging_setup import configure_logging
9
+ from aida.config.settings import load_settings
10
+
11
+
12
+ def _build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(prog="aida", description="AIDA — AI Data Assistant")
14
+ sub = parser.add_subparsers(dest="command")
15
+
16
+ sub.add_parser("doctor", help="Report environment/config diagnostics")
17
+ sub.add_parser("chat", help="Interactive chat (Phase 2)")
18
+ sub.add_parser("run", help="Run a stored workflow headlessly (Phase 10)")
19
+ sub.add_parser("config", help="Show on-device config locations")
20
+
21
+ return parser
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ argv = sys.argv[1:] if argv is None else argv
26
+ parser = _build_parser()
27
+ args = parser.parse_args(argv)
28
+
29
+ settings = load_settings()
30
+ configure_logging(settings.app.log_level)
31
+
32
+ if args.command == "doctor":
33
+ from aida.cli.doctor import main as doctor_main
34
+
35
+ return doctor_main()
36
+ if args.command == "chat":
37
+ from aida.cli.chat import main as chat_main
38
+
39
+ return chat_main()
40
+ if args.command == "run":
41
+ from aida.cli.run import main as run_main
42
+
43
+ return run_main()
44
+ if args.command == "config":
45
+ from aida.cli.config_cmds import main as config_main
46
+
47
+ return config_main()
48
+
49
+ parser.print_help()
50
+ return 0
51
+
52
+
53
+ def main_gui() -> int:
54
+ """Stub for the ``aida-gui`` entry point; the real GUI arrives in Phase 5."""
55
+ print("aida-gui: not yet implemented (arrives in Phase 5 — see PLAN.md §10).")
56
+ print("Use `aida doctor` or `aida chat` (Phase 2) for now.")
57
+ return 1
58
+
59
+
60
+ if __name__ == "__main__":
61
+ raise SystemExit(main())
aida/cli/chat.py ADDED
@@ -0,0 +1,12 @@
1
+ """``aida chat`` — interactive streaming chat with a provider.
2
+
3
+ Placeholder: implemented in Phase 2 (provider layer + agent loop). Present
4
+ here only so the repository structure in PLAN.md §3 exists from Phase 1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def main(_argv: list[str] | None = None) -> int:
11
+ print("aida chat: not yet implemented (arrives in Phase 2 — see PLAN.md §10).")
12
+ return 1
@@ -0,0 +1,16 @@
1
+ """``aida config`` — inspect/edit on-device configuration.
2
+
3
+ Phase 1 provides just enough to show where things live; richer editing
4
+ (profile add/remove, workspace edit, etc.) arrives alongside the phases that
5
+ give those concepts real behavior (Phase 2 providers, Phase 4 workspaces).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from aida.config.paths import app_dir, ensure_records_dir
11
+
12
+
13
+ def main(_argv: list[str] | None = None) -> int:
14
+ print(f"AIDA config directory: {app_dir()}")
15
+ print(f"AIDA records directory: {ensure_records_dir()}")
16
+ return 0
aida/cli/doctor.py ADDED
@@ -0,0 +1,153 @@
1
+ """``aida doctor`` — environment and configuration diagnostics.
2
+
3
+ Reports, per PLAN.md Phase 1 acceptance criteria:
4
+
5
+ - Python version
6
+ - config file status/validity (config.yaml, providers.yaml, workspaces.yaml,
7
+ mcp.json)
8
+ - keyring availability
9
+ - reachable provider endpoints (ping only — Phase 1 has no provider layer
10
+ yet, so this is a placeholder that reports "not configured" rather than
11
+ failing)
12
+ - writable dirs (``~/.aida`` and its subdirectories, records dir)
13
+
14
+ Designed to be used both as a CLI command and importable for tests: the
15
+ report is built as a list of ``CheckResult`` and formatting is separate from
16
+ checking, so tests can assert on structured results without parsing text.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from aida.config import paths
26
+ from aida.config.secrets import keyring_available
27
+ from aida.config.settings import Settings, load_settings
28
+
29
+
30
+ @dataclass
31
+ class CheckResult:
32
+ name: str
33
+ ok: bool
34
+ detail: str
35
+
36
+
37
+ def _check_python_version() -> CheckResult:
38
+ major, minor = sys.version_info[:2]
39
+ ok = (major, minor) >= (3, 11)
40
+ return CheckResult(
41
+ "python_version",
42
+ ok,
43
+ f"Python {sys.version.split()[0]}" + ("" if ok else " (need >= 3.11)"),
44
+ )
45
+
46
+
47
+ def _check_writable(name: str, path: Path) -> CheckResult:
48
+ try:
49
+ path.mkdir(parents=True, exist_ok=True)
50
+ probe = path / ".aida_write_test"
51
+ probe.write_text("ok", encoding="utf-8")
52
+ probe.unlink()
53
+ return CheckResult(name, True, f"writable: {path}")
54
+ except OSError as exc:
55
+ return CheckResult(name, False, f"NOT writable: {path} ({exc})")
56
+
57
+
58
+ def _load_settings_safely() -> tuple[Settings | None, CheckResult]:
59
+ """Load settings once, turning a bad config into a failed check instead
60
+ of an exception — every other check that needs settings reuses this
61
+ result rather than calling ``load_settings()`` again unguarded."""
62
+ try:
63
+ settings = load_settings()
64
+ return settings, CheckResult(
65
+ "config_files",
66
+ True,
67
+ "config.yaml, providers.yaml, workspaces.yaml, mcp.json loaded "
68
+ f"(config_version={settings.app.config_version})",
69
+ )
70
+ except Exception as exc: # noqa: BLE001 - doctor must never crash on bad config
71
+ return None, CheckResult("config_files", False, f"failed to load: {exc}")
72
+
73
+
74
+ def _check_keyring() -> CheckResult:
75
+ ok = keyring_available()
76
+ return CheckResult(
77
+ "keyring",
78
+ ok,
79
+ "keyring backend available" if ok else "no usable keyring backend found",
80
+ )
81
+
82
+
83
+ def _check_provider_endpoints(settings: Settings | None) -> list[CheckResult]:
84
+ """Ping-only checks for configured provider profiles.
85
+
86
+ Phase 1 ships no provider layer, so with zero profiles configured this
87
+ reports an informational pass rather than a failure — providers arrive
88
+ in Phase 2. If settings failed to load at all, that is already reported
89
+ by the ``config_files`` check, so this is skipped rather than duplicated.
90
+ """
91
+ if settings is None:
92
+ return []
93
+
94
+ profiles = settings.providers.profiles
95
+ if not profiles:
96
+ return [
97
+ CheckResult(
98
+ "provider_endpoints",
99
+ True,
100
+ "no provider profiles configured yet (expected before Phase 2)",
101
+ )
102
+ ]
103
+
104
+ results: list[CheckResult] = []
105
+ for name, profile in profiles.items():
106
+ # Ping-only, best-effort: Phase 1 does not depend on network access,
107
+ # so a failed/absent connection is reported, not raised.
108
+ try:
109
+ import urllib.request
110
+
111
+ if not profile.base_url:
112
+ results.append(
113
+ CheckResult(f"provider:{name}", True, "no base_url set (SDK default)")
114
+ )
115
+ continue
116
+ req = urllib.request.Request(profile.base_url, method="HEAD")
117
+ urllib.request.urlopen(req, timeout=2) # noqa: S310
118
+ results.append(CheckResult(f"provider:{name}", True, f"reachable: {profile.base_url}"))
119
+ except Exception as exc: # noqa: BLE001
120
+ results.append(
121
+ CheckResult(f"provider:{name}", False, f"unreachable: {profile.base_url} ({exc})")
122
+ )
123
+ return results
124
+
125
+
126
+ def run_checks() -> list[CheckResult]:
127
+ results: list[CheckResult] = [_check_python_version()]
128
+ settings, config_result = _load_settings_safely()
129
+ results.append(config_result)
130
+ results.append(_check_writable("app_dir", paths.app_dir()))
131
+ results.append(_check_writable("logs_dir", paths.logs_dir()))
132
+ results.append(_check_writable("artifacts_dir", paths.artifacts_dir()))
133
+ results.append(_check_writable("records_dir", paths.ensure_records_dir()))
134
+ results.append(_check_keyring())
135
+ results.extend(_check_provider_endpoints(settings))
136
+ return results
137
+
138
+
139
+ def format_report(results: list[CheckResult]) -> str:
140
+ lines = ["AIDA doctor report", "=" * 19]
141
+ for r in results:
142
+ mark = "OK " if r.ok else "FAIL"
143
+ lines.append(f"[{mark}] {r.name}: {r.detail}")
144
+ n_fail = sum(1 for r in results if not r.ok)
145
+ lines.append("")
146
+ lines.append(f"{len(results) - n_fail}/{len(results)} checks passed.")
147
+ return "\n".join(lines)
148
+
149
+
150
+ def main() -> int:
151
+ results = run_checks()
152
+ print(format_report(results))
153
+ return 1 if any(not r.ok for r in results) else 0
aida/cli/run.py ADDED
@@ -0,0 +1,12 @@
1
+ """``aida run`` — headless execution of stored named workflows.
2
+
3
+ Placeholder: implemented in Phase 10. Present here only so the repository
4
+ structure in PLAN.md §3 exists from Phase 1.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def main(_argv: list[str] | None = None) -> int:
11
+ print("aida run: not yet implemented (arrives in Phase 10 — see PLAN.md §10).")
12
+ return 1
@@ -0,0 +1 @@
1
+ """Small-scale coding support: templates, sandboxed script execution. Never imports Qt. Filled in starting Phase 9."""
@@ -0,0 +1,4 @@
1
+ """Configuration subsystem: on-device paths, settings, and secrets.
2
+
3
+ Nothing in this package may import Qt (see PLAN.md §3, layering rules).
4
+ """
@@ -0,0 +1,64 @@
1
+ """Logging configuration: rotating file log + console handler.
2
+
3
+ Groundwork for "which layer failed" diagnostics (PLAN.md §7, §11): every
4
+ logger name is namespaced by subsystem (``aida.provider``, ``aida.mcp``,
5
+ ``aida.core``, ``aida.ui``, ...) and the formatter includes that name, so a
6
+ log line always identifies which layer produced it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import logging.handlers
13
+ from pathlib import Path
14
+
15
+ from aida.config.paths import logs_dir
16
+
17
+ LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
18
+ LOG_FILENAME = "aida.log"
19
+ MAX_BYTES = 5 * 1024 * 1024
20
+ BACKUP_COUNT = 5
21
+
22
+ _configured = False
23
+
24
+
25
+ def configure_logging(level: str = "INFO", *, log_dir: Path | None = None) -> Path:
26
+ """Configure the ``aida`` logger tree once per process.
27
+
28
+ Safe to call more than once (e.g. once from config defaults, once after
29
+ the user's real config loads with a different level) — later calls just
30
+ adjust the level rather than duplicating handlers.
31
+ """
32
+ global _configured
33
+
34
+ root = logging.getLogger("aida")
35
+ root.setLevel(level.upper())
36
+
37
+ target_dir = log_dir or logs_dir()
38
+ log_path = target_dir / LOG_FILENAME
39
+
40
+ if not _configured:
41
+ formatter = logging.Formatter(LOG_FORMAT)
42
+
43
+ file_handler = logging.handlers.RotatingFileHandler(
44
+ log_path, maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8"
45
+ )
46
+ file_handler.setFormatter(formatter)
47
+ root.addHandler(file_handler)
48
+
49
+ console_handler = logging.StreamHandler()
50
+ console_handler.setFormatter(formatter)
51
+ root.addHandler(console_handler)
52
+
53
+ _configured = True
54
+
55
+ return log_path
56
+
57
+
58
+ def get_logger(subsystem: str) -> logging.Logger:
59
+ """Return a logger namespaced under ``aida.<subsystem>``.
60
+
61
+ ``subsystem`` should be one of the recognized layer tags: ``provider``,
62
+ ``mcp``, ``core``, ``ui``, ``config``, ``cli``, ``workspace``, ``docs``.
63
+ """
64
+ return logging.getLogger(f"aida.{subsystem}")
aida/config/paths.py ADDED
@@ -0,0 +1,90 @@
1
+ """Resolve and create AIDA's on-device directories.
2
+
3
+ Two roots (PLAN.md §4):
4
+
5
+ - ``~/.aida/`` — app state: config files, secrets refs, the SQLite DB,
6
+ binary artifacts, and logs. Never inside a repo; never contains secrets.
7
+ - ``~/Documents/Aida/`` (configurable via ``config.yaml``'s ``records_dir``)
8
+ — human-readable conversation records / exported transcripts. Safe to
9
+ browse, safe to delete.
10
+
11
+ Every function here is idempotent: calling it repeatedly, or on first run
12
+ with nothing on disk yet, must succeed and create what's missing.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+
20
+ APP_DIR_ENV_VAR = "AIDA_HOME"
21
+ DEFAULT_RECORDS_DIRNAME = "Aida"
22
+
23
+
24
+ def app_dir() -> Path:
25
+ """Return ``~/.aida/`` (or ``$AIDA_HOME`` if set), creating it if needed.
26
+
27
+ ``AIDA_HOME`` override exists for tests and for headless/CI use so tests
28
+ never touch a developer's real ``~/.aida``.
29
+ """
30
+ override = os.environ.get(APP_DIR_ENV_VAR)
31
+ base = Path(override).expanduser() if override else Path.home() / ".aida"
32
+ base.mkdir(parents=True, exist_ok=True)
33
+ return base
34
+
35
+
36
+ def config_dir() -> Path:
37
+ """Directory holding config.yaml, providers.yaml, workspaces.yaml, mcp.json."""
38
+ d = app_dir()
39
+ d.mkdir(parents=True, exist_ok=True)
40
+ return d
41
+
42
+
43
+ def artifacts_dir() -> Path:
44
+ """Directory for binary tool-result artifacts (PNGs, etc.) — files, not DB blobs."""
45
+ d = app_dir() / "artifacts"
46
+ d.mkdir(parents=True, exist_ok=True)
47
+ return d
48
+
49
+
50
+ def logs_dir() -> Path:
51
+ """Directory for rotating log files."""
52
+ d = app_dir() / "logs"
53
+ d.mkdir(parents=True, exist_ok=True)
54
+ return d
55
+
56
+
57
+ def skills_dir() -> Path:
58
+ """Directory holding the user's own skills markdown files."""
59
+ d = app_dir() / "skills"
60
+ d.mkdir(parents=True, exist_ok=True)
61
+ return d
62
+
63
+
64
+ def workflows_dir() -> Path:
65
+ """Directory holding stored named workflows (Phase 10)."""
66
+ d = app_dir() / "workflows"
67
+ d.mkdir(parents=True, exist_ok=True)
68
+ return d
69
+
70
+
71
+ def db_path() -> Path:
72
+ """Path to the SQLite database file (created lazily by aida.persistence)."""
73
+ return app_dir() / "aida.db"
74
+
75
+
76
+ def default_records_dir() -> Path:
77
+ """Default location for human-readable conversation records.
78
+
79
+ Overridable per-install via ``config.yaml``'s ``records_dir`` — callers
80
+ that need the *effective* records dir should read it from settings, not
81
+ call this directly, except as the fallback default.
82
+ """
83
+ return Path.home() / "Documents" / DEFAULT_RECORDS_DIRNAME
84
+
85
+
86
+ def ensure_records_dir(path: Path | None = None) -> Path:
87
+ """Create (if needed) and return the records dir, honoring an override."""
88
+ target = Path(path).expanduser() if path else default_records_dir()
89
+ target.mkdir(parents=True, exist_ok=True)
90
+ return target
aida/config/secrets.py ADDED
@@ -0,0 +1,70 @@
1
+ """Secret storage: OS keychain via ``keyring``, with env-var override.
2
+
3
+ Hard rule (PLAN.md §4): secrets never touch ``~/.aida/*.yaml`` or ``*.json``.
4
+ ``providers.yaml`` stores only a ``secret_ref`` (a profile name); the actual
5
+ API key / ANL username lives in the OS keychain, or — for headless / CI /
6
+ pipeline use — in an ``AIDA_SECRET_<PROFILE>`` environment variable, checked
7
+ first so it always wins in non-interactive contexts.
8
+
9
+ This module never logs a secret value, and its own returned values must
10
+ never be handed to any of the YAML/JSON writers in ``aida.config.settings``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+
17
+ import keyring
18
+ from keyring.errors import KeyringError
19
+
20
+ SERVICE_NAME = "aida"
21
+ ENV_PREFIX = "AIDA_SECRET_"
22
+
23
+
24
+ def _env_var_name(profile: str) -> str:
25
+ return ENV_PREFIX + profile.upper().replace("-", "_")
26
+
27
+
28
+ def get_secret(profile: str) -> str | None:
29
+ """Return the secret for ``profile``, or None if not set anywhere.
30
+
31
+ Lookup order: environment variable override, then OS keychain.
32
+ """
33
+ import os
34
+
35
+ env_val = os.environ.get(_env_var_name(profile))
36
+ if env_val is not None:
37
+ return env_val
38
+
39
+ try:
40
+ return keyring.get_password(SERVICE_NAME, profile)
41
+ except KeyringError:
42
+ return None
43
+
44
+
45
+ def set_secret(profile: str, value: str) -> None:
46
+ """Store a secret for ``profile`` in the OS keychain.
47
+
48
+ Does not touch environment variables — an env override, if set, will
49
+ keep taking precedence over whatever is stored here until unset.
50
+ """
51
+ keyring.set_password(SERVICE_NAME, profile, value)
52
+
53
+
54
+ def delete_secret(profile: str) -> None:
55
+ """Remove a stored secret for ``profile`` from the OS keychain, if present."""
56
+ with contextlib.suppress(KeyringError):
57
+ keyring.delete_password(SERVICE_NAME, profile)
58
+
59
+
60
+ def keyring_available() -> bool:
61
+ """Best-effort check that a usable keyring backend is configured.
62
+
63
+ Used by ``aida doctor`` — a missing/broken backend (common on fresh
64
+ Linux CI images) is reported, not raised.
65
+ """
66
+ try:
67
+ backend = keyring.get_keyring()
68
+ return backend is not None and "fail" not in type(backend).__name__.lower()
69
+ except Exception:
70
+ return False
@@ -0,0 +1,354 @@
1
+ """Load, validate, and default AIDA's YAML/JSON config files.
2
+
3
+ Pattern (pyIrena rule, PLAN.md §10.3 / Phase 1 tasks): **old configs must
4
+ always load.** Every field has a default; a config file that predates a new
5
+ field simply gets that field's default rather than failing to load. Config
6
+ schema versioning (``config_version``) is present from day one so future
7
+ migrations have something to key off of.
8
+
9
+ No secret value is ever read from or written to these files — see
10
+ ``aida.config.secrets``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+ from aida.config.paths import config_dir
23
+
24
+ CURRENT_CONFIG_VERSION = 1
25
+
26
+
27
+ # --------------------------------------------------------------------------
28
+ # config.yaml
29
+ # --------------------------------------------------------------------------
30
+
31
+
32
+ @dataclass
33
+ class AppConfig:
34
+ """General settings: paths, safety default, UI prefs, log level."""
35
+
36
+ config_version: int = CURRENT_CONFIG_VERSION
37
+ records_dir: str | None = None # None -> aida.config.paths.default_records_dir()
38
+ log_level: str = "INFO"
39
+ default_safety_mode: str = "confirm" # "relaxed" | "confirm"
40
+ theme: str = "system"
41
+
42
+ @classmethod
43
+ def from_dict(cls, data: dict[str, Any]) -> AppConfig:
44
+ known = {f for f in cls.__dataclass_fields__}
45
+ filtered = {k: v for k, v in (data or {}).items() if k in known}
46
+ return cls(**filtered)
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "config_version": self.config_version,
51
+ "records_dir": self.records_dir,
52
+ "log_level": self.log_level,
53
+ "default_safety_mode": self.default_safety_mode,
54
+ "theme": self.theme,
55
+ }
56
+
57
+
58
+ # --------------------------------------------------------------------------
59
+ # providers.yaml
60
+ # --------------------------------------------------------------------------
61
+
62
+
63
+ @dataclass
64
+ class ProviderProfile:
65
+ """A named provider profile. NO secrets inline — secret refs only."""
66
+
67
+ name: str
68
+ kind: str = "openai_compat" # "openai_compat" | "anthropic"
69
+ base_url: str | None = None
70
+ model: str = ""
71
+ secret_ref: str | None = None # key into aida.config.secrets, not a value
72
+ capability_notes: str = ""
73
+
74
+ @classmethod
75
+ def from_dict(cls, name: str, data: dict[str, Any]) -> ProviderProfile:
76
+ return cls(
77
+ name=name,
78
+ kind=data.get("kind", "openai_compat"),
79
+ base_url=data.get("base_url"),
80
+ model=data.get("model", ""),
81
+ secret_ref=data.get("secret_ref"),
82
+ capability_notes=data.get("capability_notes", ""),
83
+ )
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ return {
87
+ "kind": self.kind,
88
+ "base_url": self.base_url,
89
+ "model": self.model,
90
+ "secret_ref": self.secret_ref,
91
+ "capability_notes": self.capability_notes,
92
+ }
93
+
94
+
95
+ @dataclass
96
+ class ProvidersConfig:
97
+ config_version: int = CURRENT_CONFIG_VERSION
98
+ profiles: dict[str, ProviderProfile] = field(default_factory=dict)
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: dict[str, Any]) -> ProvidersConfig:
102
+ data = data or {}
103
+ profiles = {
104
+ name: ProviderProfile.from_dict(name, pdata)
105
+ for name, pdata in (data.get("profiles") or {}).items()
106
+ }
107
+ return cls(
108
+ config_version=data.get("config_version", CURRENT_CONFIG_VERSION),
109
+ profiles=profiles,
110
+ )
111
+
112
+ def to_dict(self) -> dict[str, Any]:
113
+ return {
114
+ "config_version": self.config_version,
115
+ "profiles": {name: p.to_dict() for name, p in self.profiles.items()},
116
+ }
117
+
118
+
119
+ # --------------------------------------------------------------------------
120
+ # workspaces.yaml
121
+ # --------------------------------------------------------------------------
122
+
123
+
124
+ @dataclass
125
+ class WorkspaceConfig:
126
+ name: str
127
+ profile: str | None = None
128
+ source_folders: list[str] = field(default_factory=list)
129
+ target_folder: str | None = None
130
+ sidecar_folder_name: str = "figures"
131
+ mcp_group: str = "none"
132
+ skills: list[str] = field(default_factory=list)
133
+ system_prompt: str | None = None
134
+ safety: str = "confirm" # "relaxed" | "confirm"
135
+
136
+ @classmethod
137
+ def from_dict(cls, name: str, data: dict[str, Any]) -> WorkspaceConfig:
138
+ return cls(
139
+ name=name,
140
+ profile=data.get("profile"),
141
+ source_folders=list(data.get("source_folders", [])),
142
+ target_folder=data.get("target_folder"),
143
+ sidecar_folder_name=data.get("sidecar_folder_name", "figures"),
144
+ mcp_group=data.get("mcp_group", "none"),
145
+ skills=list(data.get("skills", [])),
146
+ system_prompt=data.get("system_prompt"),
147
+ safety=data.get("safety", "confirm"),
148
+ )
149
+
150
+ def to_dict(self) -> dict[str, Any]:
151
+ return {
152
+ "profile": self.profile,
153
+ "source_folders": self.source_folders,
154
+ "target_folder": self.target_folder,
155
+ "sidecar_folder_name": self.sidecar_folder_name,
156
+ "mcp_group": self.mcp_group,
157
+ "skills": self.skills,
158
+ "system_prompt": self.system_prompt,
159
+ "safety": self.safety,
160
+ }
161
+
162
+
163
+ @dataclass
164
+ class WorkspacesConfig:
165
+ config_version: int = CURRENT_CONFIG_VERSION
166
+ workspaces: dict[str, WorkspaceConfig] = field(default_factory=dict)
167
+
168
+ @classmethod
169
+ def from_dict(cls, data: dict[str, Any]) -> WorkspacesConfig:
170
+ data = data or {}
171
+ workspaces = {
172
+ name: WorkspaceConfig.from_dict(name, wdata)
173
+ for name, wdata in (data.get("workspaces") or {}).items()
174
+ }
175
+ return cls(
176
+ config_version=data.get("config_version", CURRENT_CONFIG_VERSION),
177
+ workspaces=workspaces,
178
+ )
179
+
180
+ def to_dict(self) -> dict[str, Any]:
181
+ return {
182
+ "config_version": self.config_version,
183
+ "workspaces": {name: w.to_dict() for name, w in self.workspaces.items()},
184
+ }
185
+
186
+
187
+ # --------------------------------------------------------------------------
188
+ # mcp.json (standard-style + aida extras)
189
+ # --------------------------------------------------------------------------
190
+
191
+
192
+ @dataclass
193
+ class McpServerConfig:
194
+ name: str
195
+ command: str = ""
196
+ args: list[str] = field(default_factory=list)
197
+ env: dict[str, str] = field(default_factory=dict)
198
+ groups: list[str] = field(default_factory=list)
199
+ skills: list[str] = field(default_factory=list)
200
+
201
+ @classmethod
202
+ def from_dict(cls, name: str, data: dict[str, Any]) -> McpServerConfig:
203
+ return cls(
204
+ name=name,
205
+ command=data.get("command", ""),
206
+ args=list(data.get("args", [])),
207
+ env=dict(data.get("env", {})),
208
+ groups=list(data.get("groups", [])),
209
+ skills=list(data.get("skills", [])),
210
+ )
211
+
212
+ def to_dict(self) -> dict[str, Any]:
213
+ return {
214
+ "command": self.command,
215
+ "args": self.args,
216
+ "env": self.env,
217
+ "groups": self.groups,
218
+ "skills": self.skills,
219
+ }
220
+
221
+
222
+ @dataclass
223
+ class McpConfig:
224
+ config_version: int = CURRENT_CONFIG_VERSION
225
+ servers: dict[str, McpServerConfig] = field(default_factory=dict)
226
+
227
+ @classmethod
228
+ def from_dict(cls, data: dict[str, Any]) -> McpConfig:
229
+ data = data or {}
230
+ servers = {
231
+ name: McpServerConfig.from_dict(name, sdata)
232
+ for name, sdata in (data.get("mcpServers") or {}).items()
233
+ }
234
+ return cls(
235
+ config_version=data.get("config_version", CURRENT_CONFIG_VERSION),
236
+ servers=servers,
237
+ )
238
+
239
+ def to_dict(self) -> dict[str, Any]:
240
+ return {
241
+ "config_version": self.config_version,
242
+ "mcpServers": {name: s.to_dict() for name, s in self.servers.items()},
243
+ }
244
+
245
+
246
+ # --------------------------------------------------------------------------
247
+ # Loading / saving
248
+ # --------------------------------------------------------------------------
249
+
250
+
251
+ def _read_yaml(path: Path) -> dict[str, Any]:
252
+ if not path.exists():
253
+ return {}
254
+ with path.open("r", encoding="utf-8") as fh:
255
+ return yaml.safe_load(fh) or {}
256
+
257
+
258
+ def _write_yaml(path: Path, data: dict[str, Any]) -> None:
259
+ path.parent.mkdir(parents=True, exist_ok=True)
260
+ with path.open("w", encoding="utf-8") as fh:
261
+ yaml.safe_dump(data, fh, sort_keys=False)
262
+
263
+
264
+ def _read_json(path: Path) -> dict[str, Any]:
265
+ if not path.exists():
266
+ return {}
267
+ with path.open("r", encoding="utf-8") as fh:
268
+ return json.load(fh) or {}
269
+
270
+
271
+ def _write_json(path: Path, data: dict[str, Any]) -> None:
272
+ path.parent.mkdir(parents=True, exist_ok=True)
273
+ with path.open("w", encoding="utf-8") as fh:
274
+ json.dump(data, fh, indent=2)
275
+ fh.write("\n")
276
+
277
+
278
+ def load_app_config(base_dir: Path | None = None) -> AppConfig:
279
+ path = (base_dir or config_dir()) / "config.yaml"
280
+ return AppConfig.from_dict(_read_yaml(path))
281
+
282
+
283
+ def save_app_config(cfg: AppConfig, base_dir: Path | None = None) -> Path:
284
+ path = (base_dir or config_dir()) / "config.yaml"
285
+ _write_yaml(path, cfg.to_dict())
286
+ return path
287
+
288
+
289
+ def load_providers_config(base_dir: Path | None = None) -> ProvidersConfig:
290
+ path = (base_dir or config_dir()) / "providers.yaml"
291
+ return ProvidersConfig.from_dict(_read_yaml(path))
292
+
293
+
294
+ def save_providers_config(cfg: ProvidersConfig, base_dir: Path | None = None) -> Path:
295
+ path = (base_dir or config_dir()) / "providers.yaml"
296
+ _write_yaml(path, cfg.to_dict())
297
+ return path
298
+
299
+
300
+ def load_workspaces_config(base_dir: Path | None = None) -> WorkspacesConfig:
301
+ path = (base_dir or config_dir()) / "workspaces.yaml"
302
+ return WorkspacesConfig.from_dict(_read_yaml(path))
303
+
304
+
305
+ def save_workspaces_config(cfg: WorkspacesConfig, base_dir: Path | None = None) -> Path:
306
+ path = (base_dir or config_dir()) / "workspaces.yaml"
307
+ _write_yaml(path, cfg.to_dict())
308
+ return path
309
+
310
+
311
+ def load_mcp_config(base_dir: Path | None = None) -> McpConfig:
312
+ path = (base_dir or config_dir()) / "mcp.json"
313
+ return McpConfig.from_dict(_read_json(path))
314
+
315
+
316
+ def save_mcp_config(cfg: McpConfig, base_dir: Path | None = None) -> Path:
317
+ path = (base_dir or config_dir()) / "mcp.json"
318
+ _write_json(path, cfg.to_dict())
319
+ return path
320
+
321
+
322
+ @dataclass
323
+ class Settings:
324
+ """Bundle of everything loaded from ``~/.aida`` for one process."""
325
+
326
+ app: AppConfig
327
+ providers: ProvidersConfig
328
+ workspaces: WorkspacesConfig
329
+ mcp: McpConfig
330
+
331
+
332
+ def load_settings(base_dir: Path | None = None) -> Settings:
333
+ """Load all four config files, defaulting anything missing.
334
+
335
+ Also ensures the files exist on disk on first run (writing out the
336
+ defaults), per the Phase 1 acceptance criterion "first run creates
337
+ ~/.aida with valid default configs".
338
+ """
339
+ base = base_dir or config_dir()
340
+ app = load_app_config(base)
341
+ providers = load_providers_config(base)
342
+ workspaces = load_workspaces_config(base)
343
+ mcp = load_mcp_config(base)
344
+
345
+ if not (base / "config.yaml").exists():
346
+ save_app_config(app, base)
347
+ if not (base / "providers.yaml").exists():
348
+ save_providers_config(providers, base)
349
+ if not (base / "workspaces.yaml").exists():
350
+ save_workspaces_config(workspaces, base)
351
+ if not (base / "mcp.json").exists():
352
+ save_mcp_config(mcp, base)
353
+
354
+ return Settings(app=app, providers=providers, workspaces=workspaces, mcp=mcp)
aida/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Agent core: agent loop, conversation, events, context, permissions, workflows. Never imports Qt (see PLAN.md section 3). Filled in starting Phase 2."""
@@ -0,0 +1 @@
1
+ """Document readers/writers (PDF, DOCX, XLSX, PPTX, MD, TXT, code). Never imports Qt. Filled in starting Phase 6."""
@@ -0,0 +1 @@
1
+ """Skills files and RAG (retrieval-augmented generation). Never imports Qt. Filled in starting Phase 8."""
aida/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MCP manager: stdio servers, groups, typed result artifacts, skills linkage. Never imports Qt. Filled in starting Phase 3."""
@@ -0,0 +1 @@
1
+ """SQLite persistence: conversations, messages, tool calls, artifact metadata. Never imports Qt. Filled in starting Phase 4."""
@@ -0,0 +1 @@
1
+ """LLM/embedding provider layer (openai-compat + Anthropic/Argo). Never imports Qt. Filled in starting Phase 2."""
aida/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """AIDA frontends. Only aida.ui.qt may import Qt, and only through aida.ui.qt._qt."""
aida/ui/qt/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """PySide6 desktop GUI. All Qt imports must go through _qt.py (see that module). Filled in starting Phase 5."""
aida/ui/qt/_qt.py ADDED
@@ -0,0 +1,22 @@
1
+ """Single Qt import shim (pyIrena pattern).
2
+
3
+ Every Qt import anywhere in AIDA must go through this module, so that:
4
+
5
+ - normalizing between PySide6/PyQt6 (if ever needed) touches one file, and
6
+ - the layering contract test (``tests/test_contract_layering.py``) can
7
+ reliably assert "no Qt import outside aida.ui" by checking for direct
8
+ ``PySide6``/``PyQt6`` imports anywhere else in the tree.
9
+
10
+ Phase 1 ships no GUI, so this shim is currently unused scaffolding for
11
+ Phase 5. It intentionally does not import PySide6 yet — doing so would add
12
+ a hard runtime dependency to a package whose ``gui`` extra is optional.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ try: # pragma: no cover - exercised once PySide6 is an installed extra
18
+ from PySide6 import QtCore, QtGui, QtWidgets # noqa: F401
19
+
20
+ QT_AVAILABLE = True
21
+ except ImportError: # pragma: no cover
22
+ QT_AVAILABLE = False
@@ -0,0 +1 @@
1
+ """Workspace files, search, and the allowed-folders safety model. Never imports Qt. Filled in starting Phase 4/6."""
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.5
2
+ Name: aida-workbench
3
+ Version: 0.0.1
4
+ Summary: AIDA — AI Data Assistant: a local scientific agent workbench.
5
+ Project-URL: Homepage, https://github.com/jilavsky/aida
6
+ Project-URL: Repository, https://github.com/jilavsky/aida
7
+ Project-URL: Issues, https://github.com/jilavsky/aida/issues
8
+ Author-email: Jan Ilavsky <jilavsky@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: anthropic>=0.34
20
+ Requires-Dist: keyring>=24.0
21
+ Requires-Dist: mcp>=1.0
22
+ Requires-Dist: openai>=1.40
23
+ Requires-Dist: pyyaml>=6.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.6; extra == 'dev'
28
+ Provides-Extra: docs
29
+ Requires-Dist: openpyxl>=3.1; extra == 'docs'
30
+ Requires-Dist: pymupdf>=1.24; extra == 'docs'
31
+ Requires-Dist: python-docx>=1.1; extra == 'docs'
32
+ Requires-Dist: python-pptx>=0.6.23; extra == 'docs'
33
+ Provides-Extra: gui
34
+ Requires-Dist: pyside6>=6.6; extra == 'gui'
35
+ Provides-Extra: rag
36
+ Requires-Dist: chromadb>=0.5; extra == 'rag'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # AIDA — AI Data Assistant
40
+
41
+ > **Status: pre-alpha.** AIDA is under active, phased development (see
42
+ > `PLAN.md`). Nothing here is stable yet; APIs, config formats, and CLI
43
+ > commands will change without notice until Phase 5 (first GUI release).
44
+
45
+ AIDA is a local scientific agent workbench: a simple, reliable GUI (and CLI)
46
+ for using AI agents in scientific work — conversation with local or cloud
47
+ LLMs, correct use of domain MCP servers (pyIrena, bait_mcp, ...), correct
48
+ display of rich tool results (especially PNG plots), reading and producing
49
+ documents, and controlled access to the user's data folders.
50
+
51
+ It is built for pyIrena and USAXS-instrument users and is deliberately **not**
52
+ a general-purpose AI platform — see `PLAN.md` §1 for the full rationale.
53
+
54
+ ## Install (development)
55
+
56
+ ```bash
57
+ git clone https://github.com/jilavsky/aida.git
58
+ cd aida
59
+ conda env create -f environment.yml # or: pip install -e ".[dev]"
60
+ conda activate aida
61
+ aida doctor
62
+ ```
63
+
64
+ ## Status
65
+
66
+ Following the phased plan in [`PLAN.md`](PLAN.md); per-phase checklists live
67
+ under [`planning/`](planning/). Phase 1 (this scaffold) delivers packaging,
68
+ configuration, logging, and CI — no agent functionality yet.
69
+
70
+ ## License
71
+
72
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,29 @@
1
+ aida/__init__.py,sha256=F_SklSHWRvU7uCTQSMV50S_Hbn5njZpwdl9ZyOsR5Lo,165
2
+ aida/artifacts/__init__.py,sha256=Qe9eHNndbfE62wEunOW4mnPG4BGOiWityhdfPqLG0ak,125
3
+ aida/cli/__init__.py,sha256=q0oIhQqryClWVSZ7IMa6GgGrMwVxtUlmeKu-NCB1TKQ,207
4
+ aida/cli/__main__.py,sha256=uIgMYSsugUCERSVXF_FLdnm2iBWOq84MPeKIuZuyJbA,1808
5
+ aida/cli/chat.py,sha256=iZkOhtGDU5w-FXgC5tzUv-2P5vQpGG21ku7VukLLmX8,406
6
+ aida/cli/config_cmds.py,sha256=LaMlohMkCSQCASYt4PSNgYvNdQHF6XZ250Wa4yogw9o,558
7
+ aida/cli/doctor.py,sha256=eD7KFEzfbv-YbdnAfiCUr8uHL19kuCGfoj5EwMl9QwI,5434
8
+ aida/cli/run.py,sha256=JS-Z2SqTrEPQ4MCVV7oFYYIYLnA32EC8YKAsrvAodJA,378
9
+ aida/coding/__init__.py,sha256=dgRdxFHf6plXRmi6J2NKa8O1XWQ1dsO0PdLBLMfPiAw,119
10
+ aida/config/__init__.py,sha256=0FrDY7YkqGAjGpV9DSxb6j9nILd4JPietmkq6FD5qLM,146
11
+ aida/config/logging_setup.py,sha256=AVour9N5H-iR25eL-rGqXfGK-SFk3YfXKbdtvmr6pg8,2012
12
+ aida/config/paths.py,sha256=2pOeRkU1wzfYUztfCmyXmlJq44PTYmLpWtuhX5F4xJY,2805
13
+ aida/config/secrets.py,sha256=DSi8Gds6rUGNSIjzJHo33iTI4q8WYC7QanWW6f5ikqo,2231
14
+ aida/config/settings.py,sha256=T55t8EFMugF2N4pdnxc98ClEDNEJEEanVVwYiEvI5sE,11529
15
+ aida/core/__init__.py,sha256=czMLl7YTZzy_z3agwzq0LFhXOdDYRd7NlTfRSImopw8,155
16
+ aida/documents/__init__.py,sha256=2e5Qi2OBquynjNvgenDN4npX0Ze-JdjjPtWW-xd1Pww,117
17
+ aida/knowledge/__init__.py,sha256=hRzAHPREnaN3ceZSLHvAeDF1PjmiOiVSrpwZmlqmp-w,107
18
+ aida/mcp/__init__.py,sha256=Jw5BHyF12tZmgeEEY7dHhm6oZq699rmz6DaHKZKwTj8,128
19
+ aida/persistence/__init__.py,sha256=wSXu1dubV3GhEQJc78y3HRT8BYpghOfDKs9jkhEYdLo,128
20
+ aida/providers/__init__.py,sha256=i8IcazHlg_bqCAxvuo7_h-KlurfAsnuXeZ9tY9iP1f0,115
21
+ aida/ui/__init__.py,sha256=r2G1z_LdgENDO_eIrKc4cNQsx_hI_VRHCMOURzBRNSI,86
22
+ aida/ui/qt/__init__.py,sha256=jYt_0Z0YiUeZ4abc0fLLlCf9eqevX77ZNtm9Ac9w7rw,112
23
+ aida/ui/qt/_qt.py,sha256=g7veZ_-hrc785MFLmy8gOSTh9vevaI5pRxFPdEnEBYs,881
24
+ aida/workspace/__init__.py,sha256=V0yE2Ppu5g4_PjWOTE1w3tvXPMb4nprim_kjKxsfhmU,117
25
+ aida_workbench-0.0.1.dist-info/METADATA,sha256=wCSwddqLCNBqWJppGwL34w1MXPBHUdtmjUBRcZ0RSic,2668
26
+ aida_workbench-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
27
+ aida_workbench-0.0.1.dist-info/entry_points.txt,sha256=24qIt3MpLiwxMXWteaoEzR0SzLiM956CaJ3uFabRRCM,86
28
+ aida_workbench-0.0.1.dist-info/licenses/LICENSE,sha256=622TYrxFs9J2n7JoMJ5akAiKUv7cgmwX-QCp4eLHBhE,1068
29
+ aida_workbench-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ aida = aida.cli.__main__:main
3
+ aida-gui = aida.cli.__main__:main_gui
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jan Ilavsky
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.