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 +7 -0
- aida/artifacts/__init__.py +1 -0
- aida/cli/__init__.py +6 -0
- aida/cli/__main__.py +61 -0
- aida/cli/chat.py +12 -0
- aida/cli/config_cmds.py +16 -0
- aida/cli/doctor.py +153 -0
- aida/cli/run.py +12 -0
- aida/coding/__init__.py +1 -0
- aida/config/__init__.py +4 -0
- aida/config/logging_setup.py +64 -0
- aida/config/paths.py +90 -0
- aida/config/secrets.py +70 -0
- aida/config/settings.py +354 -0
- aida/core/__init__.py +1 -0
- aida/documents/__init__.py +1 -0
- aida/knowledge/__init__.py +1 -0
- aida/mcp/__init__.py +1 -0
- aida/persistence/__init__.py +1 -0
- aida/providers/__init__.py +1 -0
- aida/ui/__init__.py +1 -0
- aida/ui/qt/__init__.py +1 -0
- aida/ui/qt/_qt.py +22 -0
- aida/workspace/__init__.py +1 -0
- aida_workbench-0.0.1.dist-info/METADATA +72 -0
- aida_workbench-0.0.1.dist-info/RECORD +29 -0
- aida_workbench-0.0.1.dist-info/WHEEL +4 -0
- aida_workbench-0.0.1.dist-info/entry_points.txt +3 -0
- aida_workbench-0.0.1.dist-info/licenses/LICENSE +21 -0
aida/__init__.py
ADDED
|
@@ -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
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
|
aida/cli/config_cmds.py
ADDED
|
@@ -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
|
aida/coding/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Small-scale coding support: templates, sandboxed script execution. Never imports Qt. Filled in starting Phase 9."""
|
aida/config/__init__.py
ADDED
|
@@ -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
|
aida/config/settings.py
ADDED
|
@@ -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,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.
|