polyglav 0.37.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 (106) hide show
  1. polyglav/__init__.py +8 -0
  2. polyglav/__main__.py +5 -0
  3. polyglav/asks.py +141 -0
  4. polyglav/bundled_roles.json +127 -0
  5. polyglav/bundled_teams.json +54 -0
  6. polyglav/chat.py +332 -0
  7. polyglav/cli.py +796 -0
  8. polyglav/commands/__init__.py +0 -0
  9. polyglav/commands/builtins.py +1790 -0
  10. polyglav/commands/registry.py +46 -0
  11. polyglav/config.py +238 -0
  12. polyglav/engine.py +1638 -0
  13. polyglav/eval.py +303 -0
  14. polyglav/fleet.py +597 -0
  15. polyglav/focus.py +96 -0
  16. polyglav/jobs.py +631 -0
  17. polyglav/main.py +342 -0
  18. polyglav/memory.py +86 -0
  19. polyglav/models.py +96 -0
  20. polyglav/modes.py +75 -0
  21. polyglav/plugins/__init__.py +3 -0
  22. polyglav/plugins/bundled/__init__.py +4 -0
  23. polyglav/plugins/bundled/polyglav-core-anthropic/manifest.json +13 -0
  24. polyglav/plugins/bundled/polyglav-core-anthropic/src/plugin.py +24 -0
  25. polyglav/plugins/bundled/polyglav-core-anthropic/tests/test_anthropic_provider.py +53 -0
  26. polyglav/plugins/bundled/polyglav-core-dev/manifest.json +17 -0
  27. polyglav/plugins/bundled/polyglav-core-dev/src/plugin.py +234 -0
  28. polyglav/plugins/bundled/polyglav-core-dev/tests/test_tools.py +200 -0
  29. polyglav/plugins/bundled/polyglav-core-edit/manifest.json +15 -0
  30. polyglav/plugins/bundled/polyglav-core-edit/src/plugin.py +103 -0
  31. polyglav/plugins/bundled/polyglav-core-edit/tests/test_tools.py +127 -0
  32. polyglav/plugins/bundled/polyglav-core-eval/manifest.json +20 -0
  33. polyglav/plugins/bundled/polyglav-core-eval/src/plugin.py +89 -0
  34. polyglav/plugins/bundled/polyglav-core-eval/tests/test_fixtures.py +46 -0
  35. polyglav/plugins/bundled/polyglav-core-exec/manifest.json +15 -0
  36. polyglav/plugins/bundled/polyglav-core-exec/src/plugin.py +127 -0
  37. polyglav/plugins/bundled/polyglav-core-exec/tests/test_tools.py +157 -0
  38. polyglav/plugins/bundled/polyglav-core-fs/manifest.json +19 -0
  39. polyglav/plugins/bundled/polyglav-core-fs/src/plugin.py +357 -0
  40. polyglav/plugins/bundled/polyglav-core-fs/tests/test_tools.py +335 -0
  41. polyglav/plugins/bundled/polyglav-core-git/manifest.json +15 -0
  42. polyglav/plugins/bundled/polyglav-core-git/src/plugin.py +203 -0
  43. polyglav/plugins/bundled/polyglav-core-git/tests/test_tools.py +220 -0
  44. polyglav/plugins/bundled/polyglav-core-groq/manifest.json +13 -0
  45. polyglav/plugins/bundled/polyglav-core-groq/src/plugin.py +11 -0
  46. polyglav/plugins/bundled/polyglav-core-groq/tests/test_groq_provider.py +36 -0
  47. polyglav/plugins/bundled/polyglav-core-mcp/manifest.json +19 -0
  48. polyglav/plugins/bundled/polyglav-core-mcp/src/client.py +260 -0
  49. polyglav/plugins/bundled/polyglav-core-mcp/src/client_http.py +141 -0
  50. polyglav/plugins/bundled/polyglav-core-mcp/src/client_stdio.py +176 -0
  51. polyglav/plugins/bundled/polyglav-core-mcp/src/jsonrpc.py +128 -0
  52. polyglav/plugins/bundled/polyglav-core-mcp/src/plugin.py +248 -0
  53. polyglav/plugins/bundled/polyglav-core-mcp/src/resources.py +31 -0
  54. polyglav/plugins/bundled/polyglav-core-mcp/src/server.py +184 -0
  55. polyglav/plugins/bundled/polyglav-core-mcp/tests/test_mcp.py +423 -0
  56. polyglav/plugins/bundled/polyglav-core-ollama/manifest.json +13 -0
  57. polyglav/plugins/bundled/polyglav-core-ollama/src/plugin.py +20 -0
  58. polyglav/plugins/bundled/polyglav-core-ollama/tests/test_ollama_provider.py +146 -0
  59. polyglav/plugins/bundled/polyglav-core-openai/manifest.json +13 -0
  60. polyglav/plugins/bundled/polyglav-core-openai/src/plugin.py +21 -0
  61. polyglav/plugins/bundled/polyglav-core-openai/tests/test_openai_provider.py +61 -0
  62. polyglav/plugins/bundled/polyglav-core-opencode/manifest.json +13 -0
  63. polyglav/plugins/bundled/polyglav-core-opencode/src/opencode.py +14 -0
  64. polyglav/plugins/bundled/polyglav-core-opencode/src/opencode_base.py +19 -0
  65. polyglav/plugins/bundled/polyglav-core-opencode/src/opencode_go.py +14 -0
  66. polyglav/plugins/bundled/polyglav-core-opencode/src/plugin.py +7 -0
  67. polyglav/plugins/bundled/polyglav-core-opencode/tests/test_opencode_providers.py +88 -0
  68. polyglav/plugins/bundled/polyglav-core-web/manifest.json +16 -0
  69. polyglav/plugins/bundled/polyglav-core-web/src/display.py +25 -0
  70. polyglav/plugins/bundled/polyglav-core-web/src/plugin.py +186 -0
  71. polyglav/plugins/bundled/polyglav-core-web/src/search.py +68 -0
  72. polyglav/plugins/bundled/polyglav-core-web/tests/test_tools.py +236 -0
  73. polyglav/plugins/bundled/polyglav-core-webhook/manifest.json +14 -0
  74. polyglav/plugins/bundled/polyglav-core-webhook/src/plugin.py +28 -0
  75. polyglav/plugins/bundled/polyglav-core-webhook/tests/test_webhook.py +67 -0
  76. polyglav/plugins/manager.py +477 -0
  77. polyglav/providers/__init__.py +35 -0
  78. polyglav/providers/base.py +237 -0
  79. polyglav/providers/registry.py +129 -0
  80. polyglav/roles.py +203 -0
  81. polyglav/runs.py +114 -0
  82. polyglav/scheduler.py +321 -0
  83. polyglav/server.py +137 -0
  84. polyglav/sessions/__init__.py +0 -0
  85. polyglav/sessions/manager.py +277 -0
  86. polyglav/sessions/render.py +329 -0
  87. polyglav/sessions/turns.py +187 -0
  88. polyglav/skills.py +149 -0
  89. polyglav/teams.py +182 -0
  90. polyglav/tools/__init__.py +0 -0
  91. polyglav/tools/ask.py +236 -0
  92. polyglav/tools/catalog.py +283 -0
  93. polyglav/tools/delegate.py +182 -0
  94. polyglav/tools/handoff.py +100 -0
  95. polyglav/tools/policy.py +100 -0
  96. polyglav/tools/registry.py +266 -0
  97. polyglav/tools/team.py +154 -0
  98. polyglav/ui.py +782 -0
  99. polyglav/utils/__init__.py +0 -0
  100. polyglav/utils/http.py +54 -0
  101. polyglav-0.37.0.dist-info/METADATA +79 -0
  102. polyglav-0.37.0.dist-info/RECORD +106 -0
  103. polyglav-0.37.0.dist-info/WHEEL +5 -0
  104. polyglav-0.37.0.dist-info/entry_points.txt +2 -0
  105. polyglav-0.37.0.dist-info/licenses/LICENSE +21 -0
  106. polyglav-0.37.0.dist-info/top_level.txt +1 -0
polyglav/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+
4
+ def get_version() -> str:
5
+ try:
6
+ return version('polyglav')
7
+ except PackageNotFoundError:
8
+ return 'unknown'
polyglav/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .main import main
4
+
5
+ sys.exit(main())
polyglav/asks.py ADDED
@@ -0,0 +1,141 @@
1
+ import json
2
+ import threading
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class Ask:
10
+ def __init__(self, id: int, question: str, origin: str,
11
+ context: str = '', options: list | None = None,
12
+ kind: str = 'direction', permission: str = '',
13
+ status: str = 'pending', answer: str = '',
14
+ created_at: str | None = None,
15
+ answered_at: str | None = None):
16
+ now = datetime.now(timezone.utc).isoformat(timespec='seconds')
17
+ self.id = id
18
+ self.question = question
19
+ self.origin = origin
20
+ self.context = context
21
+ self.options = options or []
22
+ self.kind = kind
23
+ self.permission = permission
24
+ self.status = status
25
+ self.answer = answer
26
+ self.created_at = created_at or now
27
+ self.answered_at = answered_at or ''
28
+
29
+ def to_dict(self) -> dict:
30
+ return {
31
+ 'id': self.id,
32
+ 'question': self.question,
33
+ 'origin': self.origin,
34
+ 'context': self.context,
35
+ 'options': self.options,
36
+ 'kind': self.kind,
37
+ 'permission': self.permission,
38
+ 'status': self.status,
39
+ 'answer': self.answer,
40
+ 'created_at': self.created_at,
41
+ 'answered_at': self.answered_at,
42
+ }
43
+
44
+ @classmethod
45
+ def from_dict(cls, data: dict) -> 'Ask':
46
+ return cls(
47
+ int(data['id']),
48
+ data.get('question', ''),
49
+ data.get('origin', ''),
50
+ data.get('context', ''),
51
+ data.get('options', []),
52
+ data.get('kind', 'direction'),
53
+ data.get('permission', ''),
54
+ data.get('status', 'pending'),
55
+ data.get('answer', ''),
56
+ data.get('created_at'),
57
+ data.get('answered_at'),
58
+ )
59
+
60
+
61
+ class AskStore:
62
+ def __init__(self, path: Path):
63
+ self.path = path
64
+ self._lock = threading.Lock()
65
+
66
+ def _read(self) -> list[dict]:
67
+ if not self.path.exists():
68
+ return []
69
+ with open(self.path) as f:
70
+ data = json.load(f)
71
+ if not isinstance(data, list):
72
+ return []
73
+ return [d for d in data if isinstance(d, dict)]
74
+
75
+ def _write(self, asks: list[dict]):
76
+ self.path.parent.mkdir(parents=True, exist_ok=True)
77
+ tmp = self.path.with_suffix('.json.tmp')
78
+ with open(tmp, 'w') as f:
79
+ json.dump(asks, f, indent=2)
80
+ tmp.replace(self.path)
81
+
82
+ def add(self, question: str, origin: str, context: str = '',
83
+ options: list | None = None, kind: str = 'direction',
84
+ permission: str = '') -> Ask:
85
+ with self._lock:
86
+ asks = self._read()
87
+ next_id = max((int(a.get('id', 0)) for a in asks), default=0) + 1
88
+ ask = Ask(next_id, question, origin, context, options,
89
+ kind, permission)
90
+ asks.append(ask.to_dict())
91
+ self._write(asks)
92
+ return ask
93
+
94
+ def find(self, id: int) -> Ask | None:
95
+ asks = self._read()
96
+ for a in asks:
97
+ if int(a.get('id', -1)) == int(id):
98
+ return Ask.from_dict(a)
99
+ return None
100
+
101
+ def list(self, status: str | None = None) -> list[Ask]:
102
+ asks = [Ask.from_dict(a) for a in self._read()]
103
+ if status is not None:
104
+ asks = [a for a in asks if a.status == status]
105
+ return sorted(asks, key=lambda a: a.id)
106
+
107
+ def answer(self, id: int, text: str) -> Ask | None:
108
+ text = text.strip()
109
+ if not text:
110
+ return None
111
+ with self._lock:
112
+ asks = self._read()
113
+ target = None
114
+ for a in asks:
115
+ if int(a.get('id', -1)) == int(id):
116
+ target = a
117
+ break
118
+ if target is None:
119
+ return None
120
+ now = datetime.now(timezone.utc).isoformat(timespec='seconds')
121
+ target['status'] = 'answered'
122
+ target['answer'] = text
123
+ target['answered_at'] = now
124
+ self._write(asks)
125
+ return Ask.from_dict(target)
126
+
127
+
128
+ def inject_answer(store: AskStore, ask: Ask):
129
+ latest = store.find(ask.id)
130
+ if latest is None:
131
+ return False
132
+ sessions_dir = store.path.parent / 'sessions'
133
+ from .sessions.manager import SessionManager
134
+ manager = SessionManager(sessions_dir)
135
+ session = manager.read(latest.origin)
136
+ if session is None:
137
+ return False
138
+ session.add_user(
139
+ f'[answer to parked ask #{latest.id}] {latest.answer}')
140
+ manager.save(session)
141
+ return True
@@ -0,0 +1,127 @@
1
+ {
2
+ "assistant": {
3
+ "tags": ["management"],
4
+ "system_prompt": "You are the assistant, the operator's single point of contact. Answer small questions and quick tasks directly in this run. For work bigger than one step - building something, research and writing, a multi-part change - do not do it all yourself: delegate. Use the composer to design a team for the task (it creates the specialist types, skills, and stages), then run that team with the team tool. Use the leader type to supervise several pieces or a long job. Inspect what already exists with the catalog tool before composing. Keep the operator informed with short status updates, ask only when a decision is genuinely unclear, and finish with a concise summary of what was done and what needs attention."
5
+ },
6
+ "composer": {
7
+ "tags": ["management"],
8
+ "system_prompt": "You are the composer. Given a task, design a complete team and persist it with the catalog tool - never run the team yourself. Read the task and the existing catalog, decide the stages and the specialist roles each stage needs, create any missing roles and skills, then save the team with the right stage order, task hints, per-stage skills, and a review loop when the work needs checking. Use ask to resolve details the task leaves open. Report the team name, its stages, and the skills each member carries, so the caller can run it.",
9
+ "tool_permission": {
10
+ "bash": "deny",
11
+ "catalog": "allow",
12
+ "edit": "deny",
13
+ "list": "allow",
14
+ "read": "allow",
15
+ "team": "deny",
16
+ "web": "allow"
17
+ },
18
+ "ask_policy": {
19
+ "permission": "auto",
20
+ "direction": "human"
21
+ }
22
+ },
23
+ "leader": {
24
+ "tags": ["research", "writing", "programming", "review"],
25
+ "system_prompt": "You are the leader, a supervisor that coordinates delegated agents rather than doing the work itself. Plan the task, run the named team or delegate to the right roles, and park any question you cannot decide for the operator with ask. Finish each run with a short summary of what was done and what needs attention.",
26
+ "tool_permission": {
27
+ "ask": "allow",
28
+ "bash": "deny",
29
+ "edit": "allow",
30
+ "list": "allow",
31
+ "read": "allow",
32
+ "web": "deny"
33
+ },
34
+ "grant_permission": {
35
+ "ask": "allow",
36
+ "bash": "allow",
37
+ "edit": "allow",
38
+ "list": "allow",
39
+ "read": "allow",
40
+ "web": "allow"
41
+ },
42
+ "ask_policy": {
43
+ "permission": "auto",
44
+ "direction": "human"
45
+ }
46
+ },
47
+ "researcher": {
48
+ "tags": ["research", "writing"],
49
+ "system_prompt": "You are the researcher. Your job is to gather reliable information, not to write prose. Use web_search and fetch_page to find and evaluate sources, against the task you were given. Filter for credible, on-topic sources, note conflicts and gaps, and answer with a structured findings list: for each finding include the claim, the source URL, and a one-line assessment of its reliability. Do not draft final text, reference lists, or file writes.",
50
+ "tool_permission": {
51
+ "bash": "deny",
52
+ "edit": "deny",
53
+ "read": "allow",
54
+ "web": "allow"
55
+ }
56
+ },
57
+ "writer": {
58
+ "tags": ["writing"],
59
+ "system_prompt": "You are the writer. Take the findings you were given and write the requested document. Follow the brief closely: match the requested structure, tone, and length, and use the findings as the factual basis without inventing new claims. Write the result with write_file and report the resolved path as your answer. Do not go back to research; if something is missing, say so explicitly in your answer.",
60
+ "tool_permission": {
61
+ "bash": "deny",
62
+ "edit": "allow",
63
+ "read": "allow",
64
+ "web": "deny"
65
+ }
66
+ },
67
+ "referencer": {
68
+ "tags": ["writing"],
69
+ "system_prompt": "You are the referencer. Read the document you were given, find every citation and quoted or paraphrased source, resolve each to a stable reference, and produce a .bib file with write_file. Group entries consistently, include the URL and access date, and report the resolved .bib path. If a reference cannot be resolved, list it as unresolved rather than inventing it.",
70
+ "tool_permission": {
71
+ "bash": "deny",
72
+ "edit": "allow",
73
+ "read": "allow",
74
+ "web": "deny"
75
+ }
76
+ },
77
+ "editor": {
78
+ "tags": ["writing", "review"],
79
+ "system_prompt": "You are the editor. Review the document against the original task and the referenced sources it relies on. Check structure, factual consistency with the sources, and whether the document actually answers the prompt. Return findings as a list: each item is a severity (blocker, warning, note), the location, and the fix. Do not modify the document itself.",
80
+ "tool_permission": {
81
+ "bash": "deny",
82
+ "edit": "deny",
83
+ "read": "allow",
84
+ "web": "deny"
85
+ }
86
+ },
87
+ "planner": {
88
+ "tags": ["programming"],
89
+ "system_prompt": "You are the planner. Decompose the task into concrete, ordered steps without touching files. For a programming task name the modules or files likely involved and what change each needs; for a writing task outline the sections and their sources. Keep the plan implementable by another agent: each step says what, where, and how to verify. Return only the plan.",
90
+ "tool_permission": {
91
+ "bash": "deny",
92
+ "edit": "deny",
93
+ "read": "allow",
94
+ "web": "allow"
95
+ }
96
+ },
97
+ "programmer": {
98
+ "tags": ["programming"],
99
+ "system_prompt": "You are the programmer. Implement the requested change in the codebase: read the relevant files, write the code, and run the tests with run_command until they pass. Follow the project's existing conventions and keep changes minimal. Report what you changed, the test command, and its result.",
100
+ "tool_permission": {
101
+ "bash": "allow",
102
+ "edit": "allow",
103
+ "read": "allow",
104
+ "web": "deny"
105
+ }
106
+ },
107
+ "tester": {
108
+ "tags": ["programming"],
109
+ "system_prompt": "You are the tester. Write tests that cover the behavior you were asked to verify, run them with run_command, and report the outcome. For each failure include the failing assertion, the input that triggered it, and a short diagnosis. If a test is skipped or unreliable, mark it as such instead of removing it silently.",
110
+ "tool_permission": {
111
+ "bash": "allow",
112
+ "edit": "allow",
113
+ "read": "allow",
114
+ "web": "deny"
115
+ }
116
+ },
117
+ "code-reviewer": {
118
+ "tags": ["programming", "review"],
119
+ "system_prompt": "You are the code-reviewer. Review the change against the requirements you were given. Read the diff and the surrounding code, run the relevant checks only if they are quick, and return findings. Each finding has a severity (blocker, warning, nit), the file and line, and the suggested fix. Do not edit the code.",
120
+ "tool_permission": {
121
+ "bash": "allow",
122
+ "edit": "deny",
123
+ "read": "allow",
124
+ "web": "deny"
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,54 @@
1
+ {
2
+ "writing": {
3
+ "description": "Document pipeline: research, write, reference, edit",
4
+ "tags": ["research", "writing"],
5
+ "stages": [
6
+ {
7
+ "role": "researcher",
8
+ "task_hint": "Gather and evaluate sources on the topic and return a structured findings list.",
9
+ "handoff_note": "Hand the findings list to the writer as the factual basis."
10
+ },
11
+ {
12
+ "role": "writer",
13
+ "task_hint": "Write the requested document from the findings list, matching the brief's structure, tone, and length.",
14
+ "handoff_note": "Hand the written document path to the referencer."
15
+ },
16
+ {
17
+ "role": "referencer",
18
+ "task_hint": "Read the written document, resolve its citations and sources into a .bib file.",
19
+ "handoff_note": "Hand the document and .bib paths to the editor."
20
+ },
21
+ {
22
+ "role": "editor",
23
+ "task_hint": "Review the document against the original task and the sources, returning a findings list.",
24
+ "handoff_note": "Return the review findings as the final answer."
25
+ }
26
+ ]
27
+ },
28
+ "programming": {
29
+ "description": "Programming pipeline: plan, implement, test, review",
30
+ "tags": ["programming"],
31
+ "stages": [
32
+ {
33
+ "role": "planner",
34
+ "task_hint": "Decompose the task into concrete, ordered steps with the modules or files involved and how to verify each step.",
35
+ "handoff_note": "Hand the plan to the programmer."
36
+ },
37
+ {
38
+ "role": "programmer",
39
+ "task_hint": "Implement the plan, run the relevant checks, and report the changed files.",
40
+ "handoff_note": "Hand the changed files and implementation summary to the tester."
41
+ },
42
+ {
43
+ "role": "tester",
44
+ "task_hint": "Write tests covering the behavior to verify, run them, and report the outcome.",
45
+ "handoff_note": "Hand the test results and failure diagnoses to the code-reviewer."
46
+ },
47
+ {
48
+ "role": "code-reviewer",
49
+ "task_hint": "Review the implementation and tests against the plan, returning a findings list.",
50
+ "handoff_note": "Return the review findings as the final answer."
51
+ }
52
+ ]
53
+ }
54
+ }
polyglav/chat.py ADDED
@@ -0,0 +1,332 @@
1
+ import sys
2
+ import readline
3
+ from pathlib import Path
4
+
5
+ from .config import Config
6
+ from .engine import Engine
7
+ from .focus import FocusManager
8
+ from .ui import ReplUI
9
+ from . import get_version
10
+
11
+ HISTFILE = '.polyglav_history'
12
+
13
+ MAIN_PROMPT = '\001\033[1;36m\002>>>\001\033[0m\002 '
14
+ CONT_PROMPT = '\001\033[1;36m\002...\001\033[0m\002 '
15
+
16
+
17
+ def _open_delim(text: str) -> str | None:
18
+ if _overlap_count(text, '"""') % 2 == 1:
19
+ return '"""'
20
+ if _overlap_count(text, "'''") % 2 == 1:
21
+ return "'''"
22
+ return None
23
+
24
+
25
+ def _overlap_count(text: str, delim: str) -> int:
26
+ n = 0
27
+ i = text.find(delim)
28
+ while i != -1:
29
+ n += 1
30
+ i = text.find(delim, i + 1)
31
+ return n
32
+
33
+
34
+ def _strip_framing(text: str, delim: str) -> str:
35
+ first = text.find(delim)
36
+ last = text.rfind(delim)
37
+ if first == -1 or last <= first:
38
+ return text
39
+ return (text[:first] + text[first + len(delim):last]
40
+ + text[last + len(delim):]).strip('\n')
41
+
42
+
43
+ def _strip_open(text: str, delim: str) -> str:
44
+ idx = text.find(delim)
45
+ if idx == -1:
46
+ return text
47
+ return (text[:idx] + text[idx + len(delim):]).strip('\n')
48
+
49
+
50
+ class ChatLoop(Engine):
51
+ def __init__(self, config: Config):
52
+ ui = ReplUI(self)
53
+ super().__init__(config, ui=ui)
54
+ self._bind_assistant()
55
+ self.focus = FocusManager(self)
56
+ self._load_history(config)
57
+ self._setup_readline()
58
+
59
+ def active(self) -> Engine:
60
+ focus = getattr(self, 'focus', None)
61
+ if focus is None:
62
+ return self
63
+ return focus.active
64
+
65
+ def _bind_assistant(self):
66
+ if not self.config.get('assistant', True):
67
+ return
68
+ name = str(self.config.get('assistant_role') or '').strip()
69
+ if name:
70
+ self.bind_root_agent(name)
71
+
72
+ def _load_history(self, config):
73
+ hist = config.local_path.parent / HISTFILE
74
+ if hist.exists():
75
+ try:
76
+ readline.read_history_file(str(hist))
77
+ except OSError:
78
+ pass
79
+ readline.set_history_length(1000)
80
+
81
+ def _save_history(self):
82
+ hist = self.config.local_path.parent / HISTFILE
83
+ try:
84
+ hist.parent.mkdir(parents=True, exist_ok=True)
85
+ readline.write_history_file(str(hist))
86
+ except OSError:
87
+ pass
88
+
89
+ def _setup_readline(self):
90
+ readline.set_completer(self._completer)
91
+ readline.set_completer_delims(' \t\n')
92
+ if 'libedit' in (readline.__doc__ or ''):
93
+ readline.parse_and_bind('bind ^I rl_complete')
94
+ else:
95
+ readline.parse_and_bind('tab: complete')
96
+
97
+ def _completer(self, text: str, state: int) -> str | None:
98
+ line = readline.get_line_buffer()
99
+ head = line[: len(line) - len(text)]
100
+ for prefix in ('/session load ', '/sessions preview ', '/sessions delete ', '/sessions export '):
101
+ if head.endswith(prefix):
102
+ names = [n for n in self.sessions.list() if n.startswith(text)]
103
+ if state < len(names):
104
+ return names[state] + ' '
105
+ return None
106
+ for prefix in ('/plugins enable ', '/plugins disable ',
107
+ '/plugins update ', '/plugins uninstall '):
108
+ if head.endswith(prefix):
109
+ pm = getattr(self, '_plugin_manager', None)
110
+ names = [i.name for i in pm.status()] if pm else []
111
+ options = sorted(n for n in names if n.startswith(text))
112
+ if state < len(options):
113
+ return options[state] + ' '
114
+ return None
115
+ if head.endswith('/tool '):
116
+ options = sorted(n for n in self._tool_names() if n.startswith(text))
117
+ if state < len(options):
118
+ return options[state] + ' '
119
+ return None
120
+ if head.endswith('/mode '):
121
+ from .modes import mode_list
122
+ options = sorted(m.name for m in mode_list(self.config)
123
+ if m.name.startswith(text))
124
+ if state < len(options):
125
+ return options[state] + ' '
126
+ return None
127
+ if head.lstrip().startswith('/'):
128
+ cmd = head.lstrip()[1:].strip().split(maxsplit=1)[0]
129
+ meta = self.registry.meta.get(cmd)
130
+ if meta and meta.get('subcommands'):
131
+ options = sorted(n for n, _ in meta['subcommands']
132
+ if n.startswith(text))
133
+ if state < len(options):
134
+ return options[state] + ' '
135
+ return None
136
+ return self._path_complete(text, state)
137
+ if text.startswith('/'):
138
+ term = text[1:]
139
+ options = sorted(c for c in self.registry.commands if c.startswith(term))
140
+ if state < len(options):
141
+ return '/' + options[state] + ' '
142
+ return None
143
+ return None
144
+
145
+ def _tool_names(self):
146
+ if not getattr(self, '_tool_registry', None):
147
+ return []
148
+ policy = getattr(self, '_tool_policy', None)
149
+ names = self._tool_registry.names()
150
+ if policy is None:
151
+ return names
152
+ return [n for n in names
153
+ if policy.allowed(n, self._tool_registry.permission_for(n))]
154
+
155
+ def _path_complete(self, text: str, state: int) -> str | None:
156
+ path = Path(text) if text else Path('.')
157
+ name = path.name
158
+ parent = path.parent if str(path.parent) else Path('.')
159
+ try:
160
+ options = sorted(p for p in parent.glob(name + '*'))
161
+ except OSError:
162
+ options = []
163
+ if state < len(options):
164
+ cand = options[state]
165
+ return str(cand) + ('/' if cand.is_dir() else ' ')
166
+ return None
167
+
168
+ def _read_line(self) -> str | None:
169
+ try:
170
+ line = input(self._prompt()).strip()
171
+ except (EOFError, KeyboardInterrupt):
172
+ print()
173
+ return None
174
+ delim = _open_delim(line)
175
+ if delim is not None:
176
+ return self._read_block(line, delim)
177
+ if line.endswith('\\'):
178
+ return self._read_continued(line)
179
+ return line
180
+
181
+ def _read_block(self, line: str, delim: str) -> str | None:
182
+ parts = [line]
183
+ while True:
184
+ try:
185
+ nxt = input(CONT_PROMPT).rstrip()
186
+ except (EOFError, KeyboardInterrupt):
187
+ print()
188
+ return None
189
+ parts.append(nxt)
190
+ stripped = nxt.strip()
191
+ if stripped == delim:
192
+ composed = _strip_framing('\n'.join(parts), delim)
193
+ self._remember(composed)
194
+ return composed
195
+ if stripped == '':
196
+ composed = _strip_open('\n'.join(parts), delim)
197
+ self._remember(composed)
198
+ return composed
199
+
200
+ def _read_continued(self, line: str) -> str | None:
201
+ parts = [line[:-1].rstrip()]
202
+ while True:
203
+ try:
204
+ nxt = input(CONT_PROMPT).rstrip()
205
+ except (EOFError, KeyboardInterrupt):
206
+ print()
207
+ return None
208
+ if nxt.endswith('\\'):
209
+ parts.append(nxt[:-1].rstrip())
210
+ continue
211
+ parts.append(nxt)
212
+ composed = '\n'.join(parts)
213
+ self._remember(composed)
214
+ return composed
215
+
216
+ def _remember(self, text: str):
217
+ try:
218
+ readline.add_history(text)
219
+ except Exception:
220
+ pass
221
+
222
+ def _prompt(self) -> str:
223
+ role = ''
224
+ if self.config.get('prompt_role', False):
225
+ role = str(getattr(self.active(), 'role', '') or '')
226
+ if not role:
227
+ return MAIN_PROMPT
228
+ label = role[:1].upper() + role[1:]
229
+ return f'\001\033[1;36m\002{label} >>>\001\033[0m\002 '
230
+
231
+ def _open_output_log(self):
232
+ if not self.config.get('output_log', False):
233
+ return None
234
+ from .ui import TeeStream
235
+ worktree = self.config.local_path.parent.parent
236
+ base = Path(str(self.config.get('output_log_dir', '.polyglav/output')
237
+ or '.polyglav/output'))
238
+ if not base.is_absolute():
239
+ base = worktree / base
240
+ try:
241
+ base.mkdir(parents=True, exist_ok=True)
242
+ name = self.current_session.session_name or 'output'
243
+ path = base / f'{name}.txt'
244
+ stream = TeeStream(sys.stdout, path)
245
+ stream.write(f'# Polyglav output log - {path.name}\n')
246
+ return stream
247
+ except OSError:
248
+ return None
249
+
250
+ def run(self):
251
+ tee = self._open_output_log()
252
+ if tee is not None:
253
+ sys.stdout = tee
254
+ try:
255
+ self._run_repl()
256
+ finally:
257
+ if tee is not None:
258
+ sys.stdout = tee._stream
259
+ tee.close()
260
+
261
+ def _run_repl(self):
262
+ if self.config.get('clear_screen', True):
263
+ sys.stdout.write('\033[3J\033[2J\033[H')
264
+ sys.stdout.flush()
265
+
266
+ model_str = self.config.get('model', '?')
267
+ provider_str = self.config.get('provider', '?')
268
+ mode_str = self.config.get('mode', 'build')
269
+ suffix = f' [{mode_str} mode]' if mode_str != 'build' else ''
270
+ if self.config.get('show_version', True):
271
+ print(f'Polyglav v{get_version()} ({provider_str}: {model_str}){suffix} /help for commands')
272
+ else:
273
+ print(f'Polyglav ({provider_str}: {model_str}){suffix} /help for commands')
274
+
275
+ while True:
276
+ line = self._read_line()
277
+ if line is None:
278
+ self._save_sessions()
279
+ self._save_history()
280
+ break
281
+
282
+ if not line:
283
+ continue
284
+
285
+ engine = self.active()
286
+ try:
287
+ if line.startswith('/'):
288
+ engine.current_session.add_command(line)
289
+ engine.registry.dispatch(line)
290
+ engine.session_auto_save()
291
+ else:
292
+ engine.chat(line)
293
+ except Exception as e:
294
+ engine.current_session.add_error(0, str(e))
295
+ print(f'\001\033[91m\002[Error]\001\033[0m\002 {e}')
296
+
297
+ self._apply_handoff()
298
+
299
+ self._save_history()
300
+
301
+ def _apply_handoff(self):
302
+ pending = (getattr(self, '_pending_handoff', None)
303
+ or getattr(self, '_pending_focus', None))
304
+ if not pending:
305
+ return
306
+ self._pending_handoff = None
307
+ self._pending_focus = None
308
+ focus = getattr(self, 'focus', None)
309
+ if focus is None:
310
+ return
311
+ run_id = pending.get('run')
312
+ session_name = pending.get('session')
313
+ try:
314
+ if run_id is not None:
315
+ engine = focus.enter(run_id)
316
+ elif session_name:
317
+ engine = focus.focus_session(session_name)
318
+ else:
319
+ return
320
+ except ValueError as e:
321
+ print(f'\001\033[91m\002[Cannot focus]\001\033[0m\002 {e}')
322
+ return
323
+ run = getattr(engine, 'current_run', None)
324
+ prefix = f'#{run.id} ' if run is not None else ''
325
+ label = f'{prefix}{engine.role or "root"} ({engine.current_session.session_name})'
326
+ print(f'Focused: {label}')
327
+
328
+ def _save_sessions(self):
329
+ focus = getattr(self, 'focus', None)
330
+ engines = focus.engines() if focus is not None else [self]
331
+ for engine in engines:
332
+ engine.session_auto_save()