symbio-cli 0.1.0__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.
Files changed (67) hide show
  1. symbio/__init__.py +103 -0
  2. symbio/agent.py +439 -0
  3. symbio/ansi_scanner.py +157 -0
  4. symbio/app/__init__.py +21 -0
  5. symbio/app/chat.py +5706 -0
  6. symbio/app/cli.py +1056 -0
  7. symbio/app/config.py +648 -0
  8. symbio/app/cron.py +279 -0
  9. symbio/app/dispatch.py +1160 -0
  10. symbio/app/eval.py +409 -0
  11. symbio/app/golden.py +689 -0
  12. symbio/app/health.py +647 -0
  13. symbio/app/learn.py +890 -0
  14. symbio/app/local_telemetry.py +274 -0
  15. symbio/app/mcp_bridge.py +67 -0
  16. symbio/app/mcp_tools.py +309 -0
  17. symbio/app/memory.py +253 -0
  18. symbio/app/mlx_compat.py +249 -0
  19. symbio/app/pending.py +336 -0
  20. symbio/app/prompts.py +185 -0
  21. symbio/app/prune.py +279 -0
  22. symbio/app/retrain.py +104 -0
  23. symbio/app/sandbox.py +478 -0
  24. symbio/app/security.py +505 -0
  25. symbio/app/sessions.py +56 -0
  26. symbio/app/setup.py +306 -0
  27. symbio/app/skill_eval.py +853 -0
  28. symbio/app/skills.py +1330 -0
  29. symbio/app/telegram.py +809 -0
  30. symbio/app/telemetry.py +233 -0
  31. symbio/app/tool_eval.py +468 -0
  32. symbio/app/tooling.py +1826 -0
  33. symbio/app/training.py +2601 -0
  34. symbio/app/web.py +197 -0
  35. symbio/app/wildcards.py +391 -0
  36. symbio/chat.py +433 -0
  37. symbio/computer.py +518 -0
  38. symbio/config.py +323 -0
  39. symbio/constants.py +252 -0
  40. symbio/learn.py +304 -0
  41. symbio/llm.py +574 -0
  42. symbio/mcp/__init__.py +21 -0
  43. symbio/mcp/benchmark.py +241 -0
  44. symbio/mcp/benchmark_mlx.py +338 -0
  45. symbio/mcp/config.py +47 -0
  46. symbio/mcp/frontier_client.py +27 -0
  47. symbio/mcp/learn.py +177 -0
  48. symbio/mcp/memory.py +122 -0
  49. symbio/mcp/models.py +53 -0
  50. symbio/mcp/ollama_client.py +116 -0
  51. symbio/mcp/server.py +124 -0
  52. symbio/rag.py +587 -0
  53. symbio/safety.py +950 -0
  54. symbio/sandbox.py +174 -0
  55. symbio/store.py +94 -0
  56. symbio/tools.py +1119 -0
  57. symbio/utils.py +283 -0
  58. symbio_cli-0.1.0.dist-info/METADATA +1280 -0
  59. symbio_cli-0.1.0.dist-info/RECORD +67 -0
  60. symbio_cli-0.1.0.dist-info/WHEEL +5 -0
  61. symbio_cli-0.1.0.dist-info/entry_points.txt +4 -0
  62. symbio_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  63. symbio_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
  64. symbio_cli-0.1.0.dist-info/top_level.txt +2 -0
  65. symbio_desktop/__init__.py +1 -0
  66. symbio_desktop/cli.py +59 -0
  67. symbio_desktop/server.py +478 -0
symbio/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """Symbio: a personal, autonomous, self-fine-tuning AI assistant."""
2
+
3
+ from symbio.agent import AIAgent
4
+ from symbio.chat import build_system_prompt, chat_loop
5
+ from symbio.config import (
6
+ detect_model_type,
7
+ list_model_presets,
8
+ load_config,
9
+ maybe_update_names_from_message,
10
+ save_config,
11
+ setup_names,
12
+ switch_model_preset,
13
+ )
14
+ from symbio.constants import (
15
+ ADAPTER_DIR,
16
+ CONFIG_FILE,
17
+ DATA_DIR,
18
+ DEFAULT_CONFIG,
19
+ DIGEST_MANIFEST,
20
+ LOG_DIR,
21
+ MISTAKES_ARCHIVE_DIR,
22
+ MISTAKES_DIR,
23
+ MODELS_FILE,
24
+ NOTES_DIR,
25
+ PROJECT_DIR,
26
+ SANDBOX_DIR,
27
+ SCREENSHOTS_DIR,
28
+ TRAIN_FILE,
29
+ VALID_FILE,
30
+ )
31
+ from symbio.learn import (
32
+ _archive_mistake_notes,
33
+ _digest_mistakes_to_training,
34
+ _find_correction_sample,
35
+ _is_correction,
36
+ _is_system_observation,
37
+ _looks_like_correction,
38
+ _mistake_note_count,
39
+ _safe_mistake_filename,
40
+ _save_mistake_note,
41
+ learn_from_last_correction,
42
+ maybe_train_on_mistakes,
43
+ )
44
+ from symbio.llm import (
45
+ append_chat_pair,
46
+ append_training_text,
47
+ build_chat_training_sample,
48
+ digest_notes_to_training,
49
+ prune_adapters,
50
+ run_training,
51
+ seed_training_data,
52
+ )
53
+ from symbio.sandbox import _is_code_safe, _run_execute_code, _run_sandboxed, _write_symbio_tools_stub
54
+ from symbio.store import SessionStore
55
+ from symbio.utils import (
56
+ parse_tools,
57
+ clean_response,
58
+ ensure_seed_notes,
59
+ save_note,
60
+ strip_generation_artifacts,
61
+ strip_tool_tags,
62
+ )
63
+
64
+ __all__ = [
65
+ "AIAgent",
66
+ "ADAPTER_DIR",
67
+ "CONFIG_FILE",
68
+ "DATA_DIR",
69
+ "DEFAULT_CONFIG",
70
+ "DIGEST_MANIFEST",
71
+ "LOG_DIR",
72
+ "MISTAKES_ARCHIVE_DIR",
73
+ "MISTAKES_DIR",
74
+ "MODELS_FILE",
75
+ "NOTES_DIR",
76
+ "PROJECT_DIR",
77
+ "SANDBOX_DIR",
78
+ "SCREENSHOTS_DIR",
79
+ "TRAIN_FILE",
80
+ "VALID_FILE",
81
+ "SessionStore",
82
+ "build_system_prompt",
83
+ "chat_loop",
84
+ "clean_response",
85
+ "detect_model_type",
86
+ "digest_notes_to_training",
87
+ "ensure_seed_notes",
88
+ "learn_from_last_correction",
89
+ "list_model_presets",
90
+ "load_config",
91
+ "maybe_train_on_mistakes",
92
+ "maybe_update_names_from_message",
93
+ "parse_tools",
94
+ "prune_adapters",
95
+ "run_training",
96
+ "save_config",
97
+ "save_note",
98
+ "seed_training_data",
99
+ "setup_names",
100
+ "strip_generation_artifacts",
101
+ "strip_tool_tags",
102
+ "switch_model_preset",
103
+ ]
symbio/agent.py ADDED
@@ -0,0 +1,439 @@
1
+ """AIAgent class for Symbio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import sys
9
+ import threading
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from mlx_lm import load, stream_generate
15
+ from mlx_lm.sample_utils import make_logits_processors, make_sampler
16
+
17
+ from symbio.chat import build_system_prompt
18
+ from symbio.config import can_run_lora, detect_model_type
19
+ from symbio.constants import ADAPTER_DIR, DEFAULT_CONFIG, LOG_DIR, PROJECT_DIR
20
+ from symbio.learn import _is_system_observation
21
+ from symbio.llm import run_training, save_history_pairs
22
+ from symbio.app.training import THINKING_ENABLED
23
+ from symbio.store import SessionStore
24
+ from symbio.tools import (
25
+ build_tool_registry,
26
+ execute_tools,
27
+ openai_tool_schemas,
28
+ run_single_tool,
29
+ tool_few_shots,
30
+ tool_metadata,
31
+ )
32
+ from symbio.utils import (
33
+ clean_response,
34
+ has_dangling_tool_call,
35
+ parse_tools,
36
+ strip_dangling_tool_call,
37
+ strip_generation_artifacts,
38
+ strip_tool_tags,
39
+ )
40
+
41
+ from symbio.rag import Retriever
42
+
43
+ try:
44
+ from planner import TrainingPlanner
45
+ except ImportError:
46
+ # planner.py lives at the project root; ensure the project root is on
47
+ # sys.path when symbio is installed as a package (e.g. the `symb` console
48
+ # entry point does not add the CWD to sys.path in some environments).
49
+ sys.path.insert(0, str(PROJECT_DIR))
50
+ from planner import TrainingPlanner
51
+
52
+ # Browser / desktop automation helpers (lazy-imported inside runners if missing).
53
+ try:
54
+ from symbio.computer import (
55
+ BrowserSession,
56
+ desktop_click,
57
+ desktop_move,
58
+ desktop_press,
59
+ desktop_screenshot,
60
+ desktop_type,
61
+ )
62
+ except Exception:
63
+ BrowserSession = None # type: ignore
64
+ desktop_click = desktop_move = desktop_press = desktop_screenshot = desktop_type = None # type: ignore
65
+
66
+
67
+ logger = logging.getLogger("chat")
68
+
69
+
70
+ class _Spinner:
71
+ """Terminal spinner shown while waiting for visible model output.
72
+
73
+ Runs on a daemon thread and anchors itself with carriage returns; stop()
74
+ erases the line so streamed text can take its place. No-op when stdout
75
+ is not a TTY (tests, pipes).
76
+ """
77
+
78
+ _FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
79
+
80
+ def __init__(self, label: str = "thinking…"):
81
+ self.label = label
82
+ self._stop_event = threading.Event()
83
+ self._thread: threading.Thread | None = None
84
+ self.active = sys.stdout.isatty()
85
+
86
+ def start(self):
87
+ if not self.active or self._thread is not None:
88
+ return
89
+ self._stop_event.clear()
90
+
91
+ def _spin():
92
+ i = 0
93
+ while not self._stop_event.wait(0.08):
94
+ frame = self._FRAMES[i % len(self._FRAMES)]
95
+ sys.stdout.write(f"\r{frame} {self.label}")
96
+ sys.stdout.flush()
97
+ i += 1
98
+
99
+ self._thread = threading.Thread(target=_spin, daemon=True)
100
+ self._thread.start()
101
+
102
+ def stop(self):
103
+ if self._thread is None:
104
+ return
105
+ self._stop_event.set()
106
+ self._thread.join()
107
+ self._thread = None
108
+ sys.stdout.write("\r\033[K")
109
+ sys.stdout.flush()
110
+
111
+
112
+ class _StreamPrinter:
113
+ """Incrementally print the visible (tool-markup-free) part of a streaming reply.
114
+
115
+ Tool calls and other tags are suppressed live via strip_tool_tags; the
116
+ name prefix is only shown once the first visible character arrives, so
117
+ tool-only replies print nothing.
118
+ """
119
+
120
+ def __init__(self, prefix: str, spinner: _Spinner | None = None):
121
+ self.prefix = prefix
122
+ self.spinner = spinner
123
+ self.printed = ""
124
+ self.prefix_shown = False
125
+
126
+ def update(self, full_text: str):
127
+ visible = strip_tool_tags(full_text)
128
+ # Only print monotonic extensions; cleanup can transiently shrink the
129
+ # visible text while a tag is being generated.
130
+ if not visible or not visible.startswith(self.printed):
131
+ return
132
+ delta = visible[len(self.printed):]
133
+ if not delta:
134
+ return
135
+ if not self.prefix_shown:
136
+ if self.spinner is not None:
137
+ self.spinner.stop()
138
+ sys.stdout.write(self.prefix)
139
+ self.prefix_shown = True
140
+ sys.stdout.write(delta)
141
+ sys.stdout.flush()
142
+ self.printed = visible
143
+
144
+ def close(self, final_display: str) -> bool:
145
+ """Reconcile streamed output with the final cleaned text.
146
+
147
+ Returns True if the reply is now fully printed on screen.
148
+ """
149
+ if not self.prefix_shown:
150
+ return False
151
+ if final_display.startswith(self.printed):
152
+ sys.stdout.write(final_display[len(self.printed):] + "\n")
153
+ else:
154
+ # A retry or final cleanup diverged from what was streamed;
155
+ # reprint the canonical line so the transcript is correct.
156
+ sys.stdout.write("\n" + self.prefix + final_display + "\n")
157
+ sys.stdout.flush()
158
+ self.printed = final_display
159
+ return True
160
+
161
+
162
+ class AIAgent:
163
+ """Hermes-style autonomous agent loop over an MLX model + LoRA adapter."""
164
+
165
+ def __init__(
166
+ self,
167
+ config: dict[str, Any],
168
+ model: Any,
169
+ tokenizer: Any,
170
+ adapter_loaded: bool,
171
+ ):
172
+ self.config = config
173
+ self.model = model
174
+ self.tokenizer = tokenizer
175
+ self.adapter_loaded = adapter_loaded
176
+ self.tools = build_tool_registry(self)
177
+ self.system_prompt = build_system_prompt(
178
+ config["assistant_name"], config["user_name"], self.tools
179
+ )
180
+ self.history: list[dict[str, str]] = []
181
+ self.sampler = make_sampler(
182
+ temp=config["agent"]["temperature"],
183
+ top_p=config["agent"]["top_p"],
184
+ )
185
+ # Speculative decoding: a tiny draft model predicts ahead cheaply and
186
+ # the big model verifies several tokens per forward pass. On a
187
+ # bandwidth-bound 2-bit model this is the single biggest speed lever.
188
+ # Only loaded if configured — leave "draft_model" unset to disable.
189
+ self.draft_model = None
190
+ draft_name = config["agent"].get("draft_model")
191
+ if draft_name:
192
+ from mlx_lm import load as _mlx_load
193
+ self.draft_model, _ = _mlx_load(draft_name)
194
+
195
+ # Near-greedy sampling on a small overfit model degenerates into
196
+ # repetition loops on out-of-distribution input; penalize repeats.
197
+ self.logits_processors = make_logits_processors(
198
+ repetition_penalty=config["agent"].get("repetition_penalty", 1.15),
199
+ repetition_context_size=64,
200
+ )
201
+ self.session_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
202
+ self.session_log = LOG_DIR / f"session_{self.session_id}.jsonl"
203
+ self.store = SessionStore(PROJECT_DIR / "logs" / "sessions.db")
204
+ self.store.new_session(self.session_id)
205
+ self.retriever = Retriever(
206
+ config, session_store=self.store, exclude_session_id=self.session_id
207
+ )
208
+ self.planner = TrainingPlanner(config)
209
+ self._code_calls_this_turn = 0
210
+ self._browser_session = BrowserSession() if BrowserSession else None
211
+
212
+ def _openai_tool_schemas(self) -> list[dict[str, Any]]:
213
+ return openai_tool_schemas(self.tools)
214
+
215
+ def _tool_few_shots(self) -> list[dict[str, str]]:
216
+ return tool_few_shots(self.config)
217
+
218
+ def _tool_metadata(self, name: str) -> dict[str, Any]:
219
+ return tool_metadata(name, self.tools, self)
220
+
221
+ def _execute_tools(self, tools: list[tuple[str, dict[str, Any]]]) -> list[tuple[str, str]]:
222
+ return execute_tools(self, tools)
223
+
224
+ def _run_single_tool(self, name: str, params: dict[str, Any]) -> str:
225
+ return run_single_tool(self, name, params)
226
+
227
+ def _generate_stream(self, prompt: str, printer: _StreamPrinter | None) -> str:
228
+ """Generate a reply, echoing visible text to stdout as tokens arrive.
229
+
230
+ A spinner covers prompt processing and any stretch of non-visible
231
+ output (e.g. while a tool call is being written); it disappears the
232
+ moment the first visible character streams.
233
+ """
234
+ spinner = _Spinner()
235
+ if printer is not None:
236
+ printer.spinner = spinner
237
+ spinner.start()
238
+ parts: list[str] = []
239
+ try:
240
+ for response in stream_generate(
241
+ self.model,
242
+ self.tokenizer,
243
+ prompt=prompt,
244
+ sampler=self.sampler,
245
+ logits_processors=self.logits_processors,
246
+ max_tokens=self.config["agent"].get("max_output_len", 1024),
247
+ draft_model=self.draft_model,
248
+ ):
249
+ parts.append(response.text)
250
+ # Runaway-loop breaker: degenerate output (repeated chars,
251
+ # cycling patterns) reuses the same 4-grams over and over,
252
+ # while natural text keeps them mostly unique.
253
+ if len(parts) % 16 == 0:
254
+ tail = "".join(parts)[-320:]
255
+ if len(tail) >= 320:
256
+ grams = {tail[i:i + 4] for i in range(len(tail) - 3)}
257
+ if len(grams) / (len(tail) - 3) < 0.35:
258
+ break
259
+ if printer is not None:
260
+ printer.update("".join(parts))
261
+ finally:
262
+ spinner.stop()
263
+ return "".join(parts)
264
+
265
+ def update_identity(self, assistant_name: str, user_name: str):
266
+ self.config["assistant_name"] = assistant_name
267
+ self.config["user_name"] = user_name
268
+ self.system_prompt = build_system_prompt(assistant_name, user_name, self.tools)
269
+
270
+ def _persist_turn(self, role: str, content: str):
271
+ entry = {
272
+ "timestamp": datetime.now().isoformat(),
273
+ "role": role,
274
+ "content": content,
275
+ }
276
+ with open(self.session_log, "a", encoding="utf-8") as f:
277
+ f.write(json.dumps(entry) + "\n")
278
+ self.store.append(self.session_id, role, content)
279
+
280
+ def run(self, user_input: str) -> dict[str, Any]:
281
+ self._code_calls_this_turn = 0
282
+ self.history.append({"role": "user", "content": user_input})
283
+ self._persist_turn("user", user_input)
284
+
285
+ final_text = ""
286
+ max_turns = self.config["agent"].get("max_turns") or self.config["agent"].get("max_tool_rounds", 10)
287
+ history_limit = self.config["agent"]["history_limit"]
288
+
289
+ executed_sigs: set[tuple[str, str]] = set()
290
+ mutating_types_executed: set[str] = set()
291
+ tools_used: list[str] = []
292
+
293
+ for _round in range(max_turns):
294
+ messages = [{"role": "system", "content": self.system_prompt}]
295
+
296
+ # Retrieve relevant notes/sessions on the first round and inject
297
+ # them as additional context before the conversation history.
298
+ if _round == 0:
299
+ context = self.retriever.build_context(user_input)
300
+ if context:
301
+ messages.append({"role": "user", "content": context})
302
+
303
+ if _round == 0:
304
+ messages.extend(self._tool_few_shots())
305
+
306
+ messages.extend(self.history[-history_limit:])
307
+
308
+ native_tools = self.config.get("agent", {}).get("native_tools", False)
309
+ prompt = self.tokenizer.apply_chat_template(
310
+ messages,
311
+ tokenize=False,
312
+ add_generation_prompt=True,
313
+ enable_thinking=THINKING_ENABLED,
314
+ **({"tools": self._openai_tool_schemas()} if native_tools else {}),
315
+ )
316
+
317
+ reply = ""
318
+ tools: list[tuple[str, dict[str, Any]]] = []
319
+ mlx_error = False
320
+ printer = _StreamPrinter(f"{self.config['assistant_name']:8}: ")
321
+ for _attempt in range(2):
322
+ try:
323
+ # Stream only the first attempt; a retry reconciles later
324
+ # via printer.close() so text is never shown twice.
325
+ raw_reply = self._generate_stream(
326
+ prompt, printer if _attempt == 0 else None
327
+ )
328
+ except Exception as e:
329
+ print(f"[MLX Error: {e}]")
330
+ mlx_error = True
331
+ break
332
+ reply = strip_generation_artifacts(clean_response(raw_reply.strip()))
333
+ tools = parse_tools(reply)
334
+ # A dangling <tool_call> means generation stopped mid-call;
335
+ # resample once before giving up on the tool call.
336
+ if not has_dangling_tool_call(reply):
337
+ break
338
+ if mlx_error:
339
+ break
340
+ if not tools:
341
+ # Drop truncated tool markup so it never reaches the user,
342
+ # the history, or the session store (RAG would re-inject it).
343
+ reply = strip_dangling_tool_call(reply)
344
+
345
+ unique_tools: list[tuple[str, dict[str, Any]]] = []
346
+ for name, params in tools:
347
+ sig = (name, json.dumps(params, sort_keys=True, ensure_ascii=False))
348
+ if sig in executed_sigs:
349
+ continue
350
+ executed_sigs.add(sig)
351
+ meta = self._tool_metadata(name)
352
+ if not meta.get("readonly") and name in mutating_types_executed:
353
+ continue
354
+ if not meta.get("readonly"):
355
+ mutating_types_executed.add(name)
356
+ unique_tools.append((name, params))
357
+ tools = unique_tools
358
+
359
+ display = strip_tool_tags(reply)
360
+ final_text = display
361
+
362
+ if display.strip():
363
+ if not printer.close(display):
364
+ print(f"{self.config['assistant_name']:8}: {display}")
365
+ logger.info(f"{self.config['assistant_name']}: {display}")
366
+ elif printer.prefix_shown:
367
+ # Streamed text that cleanup later removed; end the line.
368
+ print()
369
+
370
+ self.history.append({"role": "assistant", "content": reply})
371
+ self._persist_turn("assistant", reply)
372
+
373
+ if not tools:
374
+ if not display.strip():
375
+ final_text = "[Received an empty reply.]"
376
+ print(f"{self.config['assistant_name']:8}: {final_text}")
377
+ break
378
+
379
+ tool_results = self._execute_tools(tools)
380
+ tools_used.extend(name for name, _ in tools)
381
+ observations: list[str] = []
382
+ for name, out in tool_results:
383
+ indented = out.replace("\n", "\n ")
384
+ print(f" [Observation {name}] {indented}")
385
+ observations.append(f"{name}: {out}")
386
+ tool_id = f"{name}_{hashlib.md5(out.encode()).hexdigest()[:8]}"
387
+ self.history.append({"role": "tool", "content": out, "tool_call_id": tool_id})
388
+ self._persist_turn("tool", f"[{tool_id}] {out}")
389
+
390
+ obs_text = "\n".join(observations)
391
+ observation_msg = (
392
+ f"[System observation — do NOT repeat the same tool call; reply to the user]: {obs_text}"
393
+ )
394
+ self.history.append({"role": "user", "content": observation_msg})
395
+ self._persist_turn("user", observation_msg)
396
+ else:
397
+ final_text = "[Reached the maximum number of turns.]"
398
+ print(f"{self.config['assistant_name']:8}: {final_text}")
399
+
400
+ while len(self.history) > history_limit + 8:
401
+ self.history.pop(0)
402
+
403
+ self.planner.record_turn(user_input, final_text, tools=list(dict.fromkeys(tools_used)))
404
+ return {"text": final_text, "history": self.history}
405
+
406
+ def chat(self, user_input: str) -> str:
407
+ return self.run(user_input)["text"]
408
+
409
+ def forget_last(self) -> int:
410
+ removed = 0
411
+ while self.history and self.history[-1]["role"] == "assistant":
412
+ self.history.pop()
413
+ removed += 1
414
+ while (
415
+ self.history
416
+ and self.history[-1]["role"] == "user"
417
+ and not _is_system_observation(self.history[-1]["content"])
418
+ ):
419
+ self.history.pop()
420
+ removed += 1
421
+ return removed
422
+
423
+ def save_history_pairs(self) -> int:
424
+ return save_history_pairs(self.history, self.tokenizer, self.system_prompt, planner=self.planner)
425
+
426
+ def digest_notes(self) -> int:
427
+ from symbio.llm import digest_notes_to_training
428
+ return digest_notes_to_training(self.tokenizer, self.system_prompt, planner=self.planner)
429
+
430
+ def reload_adapter(self):
431
+ model_type = detect_model_type(self.model)
432
+ ok, reason = can_run_lora(self.config, model_type)
433
+ if not ok:
434
+ print(f" [System] Cannot reload adapter: {reason}")
435
+ return
436
+ self.model, self.tokenizer = load(
437
+ self.config["model_name"], adapter_path=str(ADAPTER_DIR)
438
+ )
439
+ self.adapter_loaded = True
symbio/ansi_scanner.py ADDED
@@ -0,0 +1,157 @@
1
+ """Detect and read ANSI-colored terminal text, especially red error output.
2
+
3
+ This module captures terminal output with ANSI escape codes preserved, then
4
+ parses colour regions. The caller gets both the original text and a list of
5
+ red segments, so the machine can say "the terminal showed this in red".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import subprocess
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Iterable
15
+
16
+
17
+ # Foreground red codes: 31 (normal red), 91 (bright red).
18
+ _RED_FG = frozenset({"31", "91"})
19
+ # ANSI SGR colour open codes we care about.
20
+ _ANSI_COLOR_RE = re.compile(r"\x1b\[(\d+)m")
21
+ # Strip all ANSI escape sequences.
22
+ _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
23
+
24
+
25
+ @dataclass
26
+ class AnsiScanResult:
27
+ """Result of scanning a terminal output string."""
28
+
29
+ text: str
30
+ stripped: str
31
+ red_segments: list[str]
32
+ has_red: bool
33
+ error_keywords: list[str]
34
+ looks_bad: bool
35
+
36
+
37
+ def strip_ansi(text: str) -> str:
38
+ """Remove all ANSI escape sequences from text."""
39
+ return _ANSI_ESCAPE_RE.sub("", text)
40
+
41
+
42
+ def extract_red_segments(text: str) -> list[str]:
43
+ """Return plain-text fragments that were rendered in red.
44
+
45
+ Handles stacked/redundant resets. Only SGR colour codes are tracked; we
46
+ ignore bold, italic, cursor movement, etc.
47
+ """
48
+ segments: list[str] = []
49
+ current: list[str] = []
50
+ in_red = False
51
+
52
+ parts = _ANSI_COLOR_RE.split(text)
53
+ # parts alternates: text, code, text, code, ...
54
+ for i, part in enumerate(parts):
55
+ if i % 2 == 0:
56
+ # Text fragment.
57
+ if in_red:
58
+ current.append(part)
59
+ else:
60
+ # ANSI code number.
61
+ code = part
62
+ if code in _RED_FG:
63
+ in_red = True
64
+ elif code == "0":
65
+ if in_red and current:
66
+ joined = "".join(current)
67
+ if joined.strip():
68
+ segments.append(strip_ansi(joined))
69
+ current = []
70
+ in_red = False
71
+ # Other colour codes are ignored: we only care about red vs reset.
72
+
73
+ # Trailing red text without an explicit reset.
74
+ if in_red and current:
75
+ joined = "".join(current)
76
+ if joined.strip():
77
+ segments.append(strip_ansi(joined))
78
+
79
+ return [s.strip() for s in segments if s.strip()]
80
+
81
+
82
+ _ERROR_KEYWORDS = (
83
+ "error",
84
+ "fatal",
85
+ "failed",
86
+ "traceback",
87
+ "exception",
88
+ "cannot",
89
+ "could not",
90
+ "permission denied",
91
+ "command not found",
92
+ "no such file",
93
+ )
94
+
95
+
96
+ def find_error_keywords(text: str) -> list[str]:
97
+ """Return error-related keywords found in text (case-insensitive)."""
98
+ low = text.lower()
99
+ return [kw for kw in _ERROR_KEYWORDS if kw in low]
100
+
101
+
102
+ def scan_text(text: str) -> AnsiScanResult:
103
+ """Analyse a terminal output string."""
104
+ stripped = strip_ansi(text)
105
+ red_segments = extract_red_segments(text)
106
+ keywords = find_error_keywords(stripped)
107
+ return AnsiScanResult(
108
+ text=text,
109
+ stripped=stripped,
110
+ red_segments=red_segments,
111
+ has_red=bool(red_segments),
112
+ error_keywords=keywords,
113
+ looks_bad=bool(red_segments or keywords),
114
+ )
115
+
116
+
117
+ def run_and_scan(
118
+ args: list[str],
119
+ *,
120
+ cwd: str | Path | None = None,
121
+ timeout: float | None = None,
122
+ env: dict[str, str] | None = None,
123
+ ) -> tuple[AnsiScanResult, int]:
124
+ """Run a subprocess and scan its combined stdout/stderr for red text."""
125
+ result = subprocess.run(
126
+ args,
127
+ capture_output=True,
128
+ text=False,
129
+ timeout=timeout,
130
+ cwd=str(cwd) if cwd else None,
131
+ env=env,
132
+ )
133
+ raw = result.stdout + b"\n" + result.stderr
134
+ # Decode preserving bytes that produced ANSI codes; replace invalid chars.
135
+ text = raw.decode("utf-8", errors="replace")
136
+ return scan_text(text), result.returncode
137
+
138
+
139
+ def format_scan_report(result: AnsiScanResult, returncode: int | None = None) -> str:
140
+ """Return a human/machine-readable summary of a scan."""
141
+ lines: list[str] = []
142
+ if returncode is not None and returncode != 0:
143
+ lines.append(f"Process exited with code {returncode}.")
144
+ if result.has_red:
145
+ lines.append("Red terminal text detected:")
146
+ for seg in result.red_segments:
147
+ lines.append(f" - {seg}")
148
+ if result.error_keywords:
149
+ lines.append("Error keywords found: " + ", ".join(result.error_keywords))
150
+ if not lines:
151
+ lines.append("No red text or error keywords detected.")
152
+ return "\n".join(lines)
153
+
154
+
155
+ def scan_lines(lines: Iterable[str]) -> AnsiScanResult:
156
+ """Scan multiple lines (e.g. from a log file)."""
157
+ return scan_text("\n".join(lines))