wizard-central-toolkit 0.2.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.
- wiz_central_toolkit/__init__.py +2 -0
- wiz_central_toolkit/bannerwiz/__main__.py +6 -0
- wiz_central_toolkit/bannerwiz/cli.py +79 -0
- wiz_central_toolkit/bannerwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/bannerwiz/lib/banner_wizard.py +999 -0
- wiz_central_toolkit/bannerwiz/lib/banner_wrapper.py +199 -0
- wiz_central_toolkit/bannerwiz/lib/cli.py +79 -0
- wiz_central_toolkit/codeguardwiz/__init__.py +1 -0
- wiz_central_toolkit/codeguardwiz/__main__.py +19 -0
- wiz_central_toolkit/codeguardwiz/cli.py +971 -0
- wiz_central_toolkit/codeguardwiz/codeguard_wizard.py +269 -0
- wiz_central_toolkit/codeguardwiz/rust_engine.py +348 -0
- wiz_central_toolkit/codeguardwiz/static_engine.py +307 -0
- wiz_central_toolkit/common/__init__.py +1 -0
- wiz_central_toolkit/debugwiz/__init__.py +1 -0
- wiz_central_toolkit/debugwiz/__main__.py +360 -0
- wiz_central_toolkit/debugwiz/debug_wizard.py +364 -0
- wiz_central_toolkit/diffwiz/__init__.py +0 -0
- wiz_central_toolkit/diffwiz/__main__.py +21 -0
- wiz_central_toolkit/diffwiz/cli.py +134 -0
- wiz_central_toolkit/diffwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/diffwiz/lib/cli.py +144 -0
- wiz_central_toolkit/diffwiz/lib/diff_wizard.py +89 -0
- wiz_central_toolkit/godfatherwiz/__init__.py +0 -0
- wiz_central_toolkit/godfatherwiz/__main__.py +18 -0
- wiz_central_toolkit/godfatherwiz/ai.py +136 -0
- wiz_central_toolkit/godfatherwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/godfatherwiz/lib/cli.py +925 -0
- wiz_central_toolkit/godfatherwiz/lib/godfather_wizard.py +1552 -0
- wiz_central_toolkit/jsonwiz/__init__.py +0 -0
- wiz_central_toolkit/jsonwiz/__main__.py +21 -0
- wiz_central_toolkit/jsonwiz/cli.py +4 -0
- wiz_central_toolkit/jsonwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/jsonwiz/lib/cli.py +138 -0
- wiz_central_toolkit/jsonwiz/lib/json_wizard.py +133 -0
- wiz_central_toolkit/pythonwiz/__init__.py +0 -0
- wiz_central_toolkit/pythonwiz/__main__.py +21 -0
- wiz_central_toolkit/pythonwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/pythonwiz/lib/cli.py +247 -0
- wiz_central_toolkit/pythonwiz/lib/python_wizard.py +353 -0
- wiz_central_toolkit/readwiz/__init__.py +5 -0
- wiz_central_toolkit/readwiz/__main__.py +15 -0
- wiz_central_toolkit/readwiz/cli.py +46 -0
- wiz_central_toolkit/readwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/readwiz/lib/cli.py +47 -0
- wiz_central_toolkit/readwiz/lib/read_wizard.py +121 -0
- wiz_central_toolkit/rustwiz/__init__.py +5 -0
- wiz_central_toolkit/rustwiz/__main__.py +7 -0
- wiz_central_toolkit/rustwiz/cli.py +4 -0
- wiz_central_toolkit/rustwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/rustwiz/lib/cli.py +53 -0
- wiz_central_toolkit/rustwiz/lib/rust_wizard.py +128 -0
- wiz_central_toolkit/tmuxwiz/__init__.py +0 -0
- wiz_central_toolkit/tmuxwiz/__main__.py +15 -0
- wiz_central_toolkit/tmuxwiz/cli.py +7 -0
- wiz_central_toolkit/tmuxwiz/lib/__init__.py +1 -0
- wiz_central_toolkit/tmuxwiz/lib/cli.py +10 -0
- wiz_central_toolkit/tmuxwiz/lib/tmux_wizard.py +229 -0
- wiz_central_toolkit/todowiz/__init__.py +0 -0
- wiz_central_toolkit/todowiz/__main__.py +22 -0
- wiz_central_toolkit/todowiz/cli.py +4 -0
- wiz_central_toolkit/todowiz/lib/__init__.py +1 -0
- wiz_central_toolkit/todowiz/lib/cli.py +82 -0
- wiz_central_toolkit/todowiz/lib/todo_wizard.py +134 -0
- wiz_central_toolkit/tools_registry.py +62 -0
- wiz_central_toolkit/wiz/__init__.py +0 -0
- wiz_central_toolkit/wiz/__main__.py +141 -0
- wiz_central_toolkit/writewiz/__init__.py +1 -0
- wiz_central_toolkit/writewiz/__main__.py +9 -0
- wiz_central_toolkit/writewiz/cli.py +264 -0
- wiz_central_toolkit/writewiz/write_wizard.py +384 -0
- wizard_central_toolkit-0.2.0.dist-info/METADATA +203 -0
- wizard_central_toolkit-0.2.0.dist-info/RECORD +77 -0
- wizard_central_toolkit-0.2.0.dist-info/WHEEL +5 -0
- wizard_central_toolkit-0.2.0.dist-info/entry_points.txt +50 -0
- wizard_central_toolkit-0.2.0.dist-info/licenses/LICENSE +201 -0
- wizard_central_toolkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
"""godfather-wizard brain — the Grand Master, fleet dispatcher & REPL.
|
|
2
|
+
|
|
3
|
+
FATHER LAW:
|
|
4
|
+
He owns no logic. He routes intents to his citizens via shell.
|
|
5
|
+
Every mutation is staged through the Write Wizard consent wall; the
|
|
6
|
+
'y' keystroke belongs to the Governor alone. Routing is a plain,
|
|
7
|
+
deterministic keyword table — anything unmapped is out of scope.
|
|
8
|
+
The REPL exposes all /slashcommands from the fleet; voice is enabled
|
|
9
|
+
on all responses when VOICE_ENABLED=true. The Godfather Wizard
|
|
10
|
+
governs execution and conversation.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import difflib
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import os
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from importlib import metadata as importlib_metadata
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
HOME = Path.home()
|
|
25
|
+
BIN = HOME / "bin"
|
|
26
|
+
BANK = HOME / ".godfather"
|
|
27
|
+
LEDGER = BANK / "ledger.jsonl"
|
|
28
|
+
ROUTER_DB = BANK / "routes.json"
|
|
29
|
+
STATE_FILE = BANK / "session_state.json"
|
|
30
|
+
VOICE_ENABLED = os.environ.get("VOICE_ENABLED", "true").lower() == "true"
|
|
31
|
+
|
|
32
|
+
FATHER_LAW = """FATHER LAW (Godfather Wizard, the Grand Master)
|
|
33
|
+
1. DELEGATION: re-implements nothing; invokes siblings where
|
|
34
|
+
they live: the bin lane (~/bin) first, the installed package
|
|
35
|
+
second; a sibling found nowhere is honestly absent.
|
|
36
|
+
2. TRUTH FIRST: multi-step runs anchor facts with the Godfather Wizard's own senses before acting.
|
|
37
|
+
3. CONSENT WALL: mutations are staged via write_wizard propose; the
|
|
38
|
+
Godfather Wizard never supplies the 'y'. The commit keystroke
|
|
39
|
+
belongs to the Governor.
|
|
40
|
+
4. DETERMINISTIC ROUTES: run dispatches via the keyword table;
|
|
41
|
+
unmapped intents return 'out of scope', never improvised.
|
|
42
|
+
5. VERDICTS: plain [OK]/[FAIL]/out of scope, exit 0/1/2, no tracebacks.
|
|
43
|
+
6. The Godfather Wizard governs execution + conversation.
|
|
44
|
+
7. EVERY DISPATCH IS JOURNALED: all routing events recorded to ledger.
|
|
45
|
+
8. REPL MODE exposes all /slashcommands from the fleet under one roof.
|
|
46
|
+
9. VOICE ENABLED: when VOICE_ENABLED=true, every REPL response speaks.
|
|
47
|
+
10. BANNER ON LAUNCH: every godfather_wizard repl shows the Sovereign mural.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# NAME FORMS — one citizen, two spellings (AVS, 2026-11-09). The
|
|
52
|
+
# full underscored form (godfather_wizard) is the wizard's
|
|
53
|
+
# name-command; the short form (godfatherwiz) is its alias. Both
|
|
54
|
+
# invoke the same citizen everywhere: dispatch, stored route banks
|
|
55
|
+
# written under either spelling, and the oracle's TOOL lines.
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _citizen_key(name):
|
|
59
|
+
"""The canonical key for a citizen name in either spelling."""
|
|
60
|
+
if name.endswith("_wizard"):
|
|
61
|
+
return name[: -len("_wizard")] + "wiz"
|
|
62
|
+
return name
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _full_form(key):
|
|
66
|
+
"""The full-form command for a citizen key (the short spelling)."""
|
|
67
|
+
if key != "wiz" and key.endswith("wiz"):
|
|
68
|
+
return key[: -len("wiz")] + "_wizard"
|
|
69
|
+
return key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _short_form(name):
|
|
73
|
+
"""The short spelling for a citizen name in either spelling."""
|
|
74
|
+
return _citizen_key(name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ensure_bank():
|
|
78
|
+
BANK.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
if not LEDGER.exists():
|
|
80
|
+
LEDGER.touch()
|
|
81
|
+
if not ROUTER_DB.exists():
|
|
82
|
+
default = {
|
|
83
|
+
"syntax/pycheck": ["python_wizard", "check"],
|
|
84
|
+
"diff/compare": ["diff_wizard", "files"],
|
|
85
|
+
"json/validate": ["json_wizard", "validate"],
|
|
86
|
+
"list/listing/whatshere": ["godfather_wizard", "listing"],
|
|
87
|
+
"models/ollama": ["godfather_wizard", "models"],
|
|
88
|
+
"todos/tasklist": ["todo_wizard", "list"],
|
|
89
|
+
"tmux/sessions": ["tmux_wizard", "list"],
|
|
90
|
+
"symlinks/bins": ["godfather_wizard", "symlinks"],
|
|
91
|
+
"diag/audit": ["debug_wizard", "audit"],
|
|
92
|
+
"heal/repair": ["godfather_wizard", "heal"],
|
|
93
|
+
}
|
|
94
|
+
ROUTER_DB.write_text(json.dumps(default, indent=2))
|
|
95
|
+
if not STATE_FILE.exists():
|
|
96
|
+
STATE_FILE.write_text(json.dumps({"session": 1, "slang": {}}, indent=2))
|
|
97
|
+
|
|
98
|
+
def load_routes():
|
|
99
|
+
_ensure_bank()
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(ROUTER_DB.read_text())
|
|
102
|
+
except (json.JSONDecodeError, OSError):
|
|
103
|
+
return {}
|
|
104
|
+
|
|
105
|
+
def save_routes(routes):
|
|
106
|
+
_ensure_bank()
|
|
107
|
+
ROUTER_DB.write_text(json.dumps(routes, indent=2))
|
|
108
|
+
|
|
109
|
+
def _now():
|
|
110
|
+
return dt.datetime.now().isoformat(timespec="seconds")
|
|
111
|
+
|
|
112
|
+
def journal(event, target, detail=""):
|
|
113
|
+
_ensure_bank()
|
|
114
|
+
row = {"at": _now(), "event": event, "target": str(target), "detail": detail}
|
|
115
|
+
with LEDGER.open("a", encoding="utf-8") as f:
|
|
116
|
+
f.write(json.dumps(row, sort_keys=True) + "\n")
|
|
117
|
+
|
|
118
|
+
def ledger_rows():
|
|
119
|
+
if not LEDGER.exists():
|
|
120
|
+
return []
|
|
121
|
+
rows = []
|
|
122
|
+
for line in LEDGER.read_text(encoding="utf-8").splitlines():
|
|
123
|
+
try:
|
|
124
|
+
rows.append(json.loads(line))
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
rows.append({"at": "?", "event": "CORRUPT_ROW", "target": line, "detail": ""})
|
|
127
|
+
return rows
|
|
128
|
+
|
|
129
|
+
def installed_sibling_names():
|
|
130
|
+
"""Wizard names this installation itself provides (entry points).
|
|
131
|
+
|
|
132
|
+
A pip-installed copy of the toolkit registers its wizards as entry
|
|
133
|
+
points; those names are the fleet a user of the sampler actually has.
|
|
134
|
+
Returns an empty set when no distribution metadata is present (for
|
|
135
|
+
example when running from a bare source tree).
|
|
136
|
+
"""
|
|
137
|
+
dist = None
|
|
138
|
+
for dist_name in ("wizard-central-toolkit", "wiz-central-toolkit"):
|
|
139
|
+
try:
|
|
140
|
+
dist = importlib_metadata.distribution(dist_name)
|
|
141
|
+
break
|
|
142
|
+
except importlib_metadata.PackageNotFoundError:
|
|
143
|
+
continue
|
|
144
|
+
if dist is None:
|
|
145
|
+
return set()
|
|
146
|
+
names = set()
|
|
147
|
+
for ep in dist.entry_points:
|
|
148
|
+
if ep.group in ("console_scripts", "wiz_central_toolkit"):
|
|
149
|
+
names.add(ep.name)
|
|
150
|
+
return names
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_sibling(name):
|
|
154
|
+
"""Find a sibling wizard's executable, or None.
|
|
155
|
+
|
|
156
|
+
The sovereign fleet's bin lane (~/bin) wins when the citizen is there;
|
|
157
|
+
otherwise the PATH is asked, which is where a package installer puts
|
|
158
|
+
the console scripts. A name that resolves nowhere is honestly absent.
|
|
159
|
+
"""
|
|
160
|
+
exe = BIN / name
|
|
161
|
+
if exe.exists():
|
|
162
|
+
return exe
|
|
163
|
+
found = shutil.which(name)
|
|
164
|
+
return Path(found) if found else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def call_sibling(cmd_list, stdin=None):
|
|
168
|
+
exe = resolve_sibling(cmd_list[0])
|
|
169
|
+
if exe is None:
|
|
170
|
+
return 2, f"[FAIL] sibling not found (bin lane or PATH): {cmd_list[0]}"
|
|
171
|
+
try:
|
|
172
|
+
r = subprocess.run([str(exe)] + cmd_list[1:], input=stdin,
|
|
173
|
+
capture_output=True, text=True, timeout=180)
|
|
174
|
+
return r.returncode, (r.stdout + r.stderr).strip()
|
|
175
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
176
|
+
return 1, f"[FAIL] sibling call failed: {e}"
|
|
177
|
+
|
|
178
|
+
def _ollama_roster():
|
|
179
|
+
"""Model names a live Ollama daemon serves; [] when none serves."""
|
|
180
|
+
try:
|
|
181
|
+
r = subprocess.run(["ollama", "list"], capture_output=True,
|
|
182
|
+
text=True, timeout=60)
|
|
183
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError):
|
|
184
|
+
return []
|
|
185
|
+
if r.returncode != 0:
|
|
186
|
+
return []
|
|
187
|
+
return [ln.split()[0] for ln in r.stdout.strip().splitlines()[1:]
|
|
188
|
+
if ln.strip()]
|
|
189
|
+
|
|
190
|
+
def resolve_model():
|
|
191
|
+
"""Honest model pick: env override if real, else first model from the live roster."""
|
|
192
|
+
wanted = os.environ.get("GODFATHER_MODEL", "").strip()
|
|
193
|
+
names = _ollama_roster()
|
|
194
|
+
if wanted and wanted in names:
|
|
195
|
+
return wanted
|
|
196
|
+
if names:
|
|
197
|
+
return names[0]
|
|
198
|
+
return wanted or "llama3.2:1b" # last resort; if this is a ghost the oracle will say so
|
|
199
|
+
|
|
200
|
+
def seated_model():
|
|
201
|
+
"""The model a chat turn may consult, or None when no seat serves.
|
|
202
|
+
|
|
203
|
+
A seat is real only when a daemon is actually serving it — an
|
|
204
|
+
env var naming an unserved model is a wish, not a seat.
|
|
205
|
+
"""
|
|
206
|
+
names = _ollama_roster()
|
|
207
|
+
if not names:
|
|
208
|
+
return None
|
|
209
|
+
wanted = os.environ.get("GODFATHER_MODEL", "").strip()
|
|
210
|
+
return wanted if wanted in names else names[0]
|
|
211
|
+
|
|
212
|
+
MODEL_TASKS = ("default", "brainstorm", "build", "debug", "diff",
|
|
213
|
+
"audit")
|
|
214
|
+
_CODEGEN_TASKS = ("brainstorm", "build", "debug", "diff")
|
|
215
|
+
|
|
216
|
+
# ------------------------------------------------------------------
|
|
217
|
+
# PROVIDERS — the six seats a model can occupy. Ollama is local and
|
|
218
|
+
# needs no key. The five cloud providers are the user's opt-in:
|
|
219
|
+
# each is seated only with the user's own API key, read from the
|
|
220
|
+
# environment at call time. A key is never stored in the bank,
|
|
221
|
+
# never journaled, and never printed — displays say only whether
|
|
222
|
+
# one is set.
|
|
223
|
+
# ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
PROVIDERS = {
|
|
226
|
+
"ollama": {
|
|
227
|
+
"display": "Ollama", "kind": "local", "host": "127.0.0.1:11434",
|
|
228
|
+
"url": "http://127.0.0.1:11434/api/chat", "key_envs": (),
|
|
229
|
+
},
|
|
230
|
+
"meta": {
|
|
231
|
+
"display": "Meta (Muse)", "kind": "cloud",
|
|
232
|
+
"host": "api.meta.ai",
|
|
233
|
+
"url": "https://api.meta.ai/v1/chat/completions",
|
|
234
|
+
"key_envs": ("MODEL_API_KEY", "META_API_KEY"),
|
|
235
|
+
},
|
|
236
|
+
"anthropic": {
|
|
237
|
+
"display": "Anthropic", "kind": "cloud",
|
|
238
|
+
"host": "api.anthropic.com",
|
|
239
|
+
"url": "https://api.anthropic.com/v1/messages",
|
|
240
|
+
"key_envs": ("ANTHROPIC_API_KEY",),
|
|
241
|
+
},
|
|
242
|
+
"openai": {
|
|
243
|
+
"display": "OpenAI", "kind": "cloud", "host": "api.openai.com",
|
|
244
|
+
"url": "https://api.openai.com/v1/chat/completions",
|
|
245
|
+
"key_envs": ("OPENAI_API_KEY",),
|
|
246
|
+
},
|
|
247
|
+
"google": {
|
|
248
|
+
"display": "Google", "kind": "cloud",
|
|
249
|
+
"host": "generativelanguage.googleapis.com",
|
|
250
|
+
"url": "https://generativelanguage.googleapis.com"
|
|
251
|
+
"/v1beta/openai/chat/completions",
|
|
252
|
+
"key_envs": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
|
253
|
+
},
|
|
254
|
+
"deepseek": {
|
|
255
|
+
"display": "DeepSeek", "kind": "cloud",
|
|
256
|
+
"host": "api.deepseek.com",
|
|
257
|
+
"url": "https://api.deepseek.com/v1/chat/completions",
|
|
258
|
+
"key_envs": ("DEEPSEEK_API_KEY",),
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
PROVIDER_ORDER = ("ollama", "meta", "anthropic", "openai", "google",
|
|
262
|
+
"deepseek")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def parse_seat(value):
|
|
266
|
+
"""Split a stored seat value into (provider_id, model).
|
|
267
|
+
|
|
268
|
+
A bare name is an Ollama model — including tagged names like
|
|
269
|
+
'llama3.2:1b', whose colon belongs to the model, not to a
|
|
270
|
+
provider. Only a head that names one of the six providers
|
|
271
|
+
makes the value provider-qualified.
|
|
272
|
+
"""
|
|
273
|
+
text = str(value).strip()
|
|
274
|
+
head, sep, tail = text.partition(":")
|
|
275
|
+
if sep and head.lower() in PROVIDERS:
|
|
276
|
+
return head.lower(), tail
|
|
277
|
+
return "ollama", text
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def seat_label(seat):
|
|
281
|
+
"""'provider · model' — how a seat is named wherever it speaks."""
|
|
282
|
+
provider_id, model = seat
|
|
283
|
+
return f"{provider_id} · {model}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _provider_key(provider_id):
|
|
287
|
+
"""(key, env_name) for a cloud provider, read live from the env.
|
|
288
|
+
|
|
289
|
+
env_name is the variable that supplied the key, or the
|
|
290
|
+
provider's primary variable when none is set — so an honest
|
|
291
|
+
line can name what is missing without ever touching a value.
|
|
292
|
+
"""
|
|
293
|
+
envs = PROVIDERS[provider_id]["key_envs"]
|
|
294
|
+
for env in envs:
|
|
295
|
+
value = os.environ.get(env, "").strip()
|
|
296
|
+
if value:
|
|
297
|
+
return value, env
|
|
298
|
+
return None, (envs[0] if envs else "")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# Ollama model tags (the part after the colon in a bare name) are
|
|
302
|
+
# version-shaped: they start with a digit ('7b', '1b') or are one
|
|
303
|
+
# of a few plain words. A 'word:rest' value whose tail is not
|
|
304
|
+
# tag-shaped reads as a provider attempt, and an unknown provider
|
|
305
|
+
# is refused honestly rather than stored as a phantom Ollama name.
|
|
306
|
+
_OLLAMA_TAG_WORDS = ("latest", "mini", "small", "medium", "large",
|
|
307
|
+
"instruct", "chat", "base")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _seat_value_refusal(value):
|
|
311
|
+
"""None when a models.json value may be stored, else the refusal.
|
|
312
|
+
|
|
313
|
+
Bare Ollama names always store. A provider:model value stores
|
|
314
|
+
when its provider is one of the six. Anything else that looks
|
|
315
|
+
like a provider attempt is refused, listing the six.
|
|
316
|
+
"""
|
|
317
|
+
if ":" not in value:
|
|
318
|
+
return None
|
|
319
|
+
head, tail = value.split(":", 1)
|
|
320
|
+
if head.lower() in PROVIDERS:
|
|
321
|
+
return None
|
|
322
|
+
if any(ch.isdigit() for ch in head) or "." in head:
|
|
323
|
+
return None # 'llama3.2:1b', 'qwen2.5-coder:7b' — model-shaped
|
|
324
|
+
first = tail.split("-", 1)[0].strip().lower()
|
|
325
|
+
if tail[:1].isdigit() or first in _OLLAMA_TAG_WORDS:
|
|
326
|
+
return None # 'codellama:7b', 'phi3:mini', 'mistral:latest'
|
|
327
|
+
return (f"[FAIL] unknown provider '{head}' — a seat is a bare "
|
|
328
|
+
f"Ollama model name, or provider:model with provider "
|
|
329
|
+
f"one of: {', '.join(PROVIDER_ORDER)}")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _models_path():
|
|
333
|
+
"""The per-task model store in the godfather bank."""
|
|
334
|
+
return BANK / "models.json"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def load_model_assignments():
|
|
338
|
+
"""Task -> model assignments from ~/.godfather/models.json.
|
|
339
|
+
|
|
340
|
+
Only the six known task keys and non-empty string values are
|
|
341
|
+
honored; a missing or malformed store is simply no assignments.
|
|
342
|
+
Reading never creates the bank.
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
data = json.loads(_models_path().read_text(encoding="utf-8"))
|
|
346
|
+
except (json.JSONDecodeError, OSError):
|
|
347
|
+
return {}
|
|
348
|
+
if not isinstance(data, dict):
|
|
349
|
+
return {}
|
|
350
|
+
return {task: str(data[task]).strip() for task in MODEL_TASKS
|
|
351
|
+
if isinstance(data.get(task), str) and data[task].strip()}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def save_model_assignments(assignments):
|
|
355
|
+
"""Persist known task assignments to the godfather bank."""
|
|
356
|
+
_ensure_bank()
|
|
357
|
+
clean = {task: str(assignments[task]).strip()
|
|
358
|
+
for task in MODEL_TASKS
|
|
359
|
+
if isinstance(assignments.get(task), str)
|
|
360
|
+
and assignments[task].strip()}
|
|
361
|
+
_models_path().write_text(json.dumps(clean, indent=2) + "\n",
|
|
362
|
+
encoding="utf-8")
|
|
363
|
+
return clean
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def set_model_assignment(task, model):
|
|
367
|
+
"""Assign one task's model. Returns (rc, text)."""
|
|
368
|
+
if task not in MODEL_TASKS:
|
|
369
|
+
return 2, (f"[FAIL] unknown model task: {task} "
|
|
370
|
+
f"(choose one of: {', '.join(MODEL_TASKS)})")
|
|
371
|
+
model = str(model).strip()
|
|
372
|
+
if not model:
|
|
373
|
+
return 2, "[FAIL] model assignment requires a model name"
|
|
374
|
+
refusal = _seat_value_refusal(model)
|
|
375
|
+
if refusal:
|
|
376
|
+
return 2, refusal
|
|
377
|
+
assignments = load_model_assignments()
|
|
378
|
+
assignments[task] = model
|
|
379
|
+
save_model_assignments(assignments)
|
|
380
|
+
journal("MODEL_SET", task, model)
|
|
381
|
+
return 0, f"[OK] {task} model set to {model}"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def unset_model_assignment(task):
|
|
385
|
+
"""Remove one task's model assignment. Returns (rc, text)."""
|
|
386
|
+
if task not in MODEL_TASKS:
|
|
387
|
+
return 2, (f"[FAIL] unknown model task: {task} "
|
|
388
|
+
f"(choose one of: {', '.join(MODEL_TASKS)})")
|
|
389
|
+
assignments = load_model_assignments()
|
|
390
|
+
old = assignments.pop(task, None)
|
|
391
|
+
save_model_assignments(assignments)
|
|
392
|
+
journal("MODEL_UNSET", task, old or "no assignment was set")
|
|
393
|
+
if old is None:
|
|
394
|
+
return 0, f"[OK] no {task} model assignment was set"
|
|
395
|
+
return 0, f"[OK] {task} model assignment removed ({old})"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _assigned_model(task, assignments=None):
|
|
399
|
+
"""The explicit assignment seat_for would use, before fallback."""
|
|
400
|
+
assignments = load_model_assignments() if assignments is None \
|
|
401
|
+
else assignments
|
|
402
|
+
if task != "default":
|
|
403
|
+
return assignments.get(task) or assignments.get("default")
|
|
404
|
+
return assignments.get("default")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def seat_for(task):
|
|
408
|
+
"""The live seat for one task as (provider, model), or None.
|
|
409
|
+
|
|
410
|
+
Resolution is assignment first (the task's own entry, then
|
|
411
|
+
the store's default), then the existing live-roster pick. A
|
|
412
|
+
bare assignment is an Ollama model and must be in the live
|
|
413
|
+
roster; a provider:model assignment is a cloud seat and is
|
|
414
|
+
configured when the provider's key is in the environment —
|
|
415
|
+
the first real call is the proof of it, and its errors are
|
|
416
|
+
honest. An explicit assignment that does not serve is NOT
|
|
417
|
+
silently replaced: the seat is absent until that model
|
|
418
|
+
serves (or its key is set).
|
|
419
|
+
"""
|
|
420
|
+
if task not in MODEL_TASKS:
|
|
421
|
+
return None
|
|
422
|
+
assigned = _assigned_model(task)
|
|
423
|
+
if assigned:
|
|
424
|
+
provider_id, model = parse_seat(assigned)
|
|
425
|
+
if provider_id == "ollama":
|
|
426
|
+
return ("ollama", model) if model in _ollama_roster() else None
|
|
427
|
+
key, _env = _provider_key(provider_id)
|
|
428
|
+
return (provider_id, model) if key else None
|
|
429
|
+
active = seated_model()
|
|
430
|
+
return ("ollama", active) if active else None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def seat_unavailable_line(task):
|
|
434
|
+
"""One plain, factual line explaining why a task has no seat."""
|
|
435
|
+
assigned = _assigned_model(task)
|
|
436
|
+
if assigned:
|
|
437
|
+
provider_id, model = parse_seat(assigned)
|
|
438
|
+
if provider_id != "ollama":
|
|
439
|
+
_key, primary = _provider_key(provider_id)
|
|
440
|
+
return (f"no {task} model seat is serving — seat "
|
|
441
|
+
f"{provider_id}:{model} assigned but {primary} "
|
|
442
|
+
f"is not set")
|
|
443
|
+
names = _ollama_roster()
|
|
444
|
+
if names:
|
|
445
|
+
return (f"no {task} model seat is serving — assigned model "
|
|
446
|
+
f"'{assigned}' is not in the live Ollama roster")
|
|
447
|
+
return (f"no {task} model seat is serving — assigned model "
|
|
448
|
+
f"'{assigned}' is not serving")
|
|
449
|
+
return ("no model seat is configured or serving — "
|
|
450
|
+
f"the {task} feature needs a live model")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def seated_models():
|
|
454
|
+
"""The seats answer (/models): providers, roster, assignments."""
|
|
455
|
+
names = _ollama_roster()
|
|
456
|
+
assignments = load_model_assignments()
|
|
457
|
+
lines = []
|
|
458
|
+
any_seat = any(seat_for(task) is not None for task in MODEL_TASKS)
|
|
459
|
+
if not names and not any_seat:
|
|
460
|
+
lines.append("no model seat is configured or serving — "
|
|
461
|
+
"the fleet runs seatless")
|
|
462
|
+
elif names:
|
|
463
|
+
active = seated_model()
|
|
464
|
+
lines.append("[OK] model seats — Ollama is serving:")
|
|
465
|
+
for n in names:
|
|
466
|
+
lines.append(f" {n} <- seated" if n == active else f" {n}")
|
|
467
|
+
else:
|
|
468
|
+
lines.append("Ollama is not serving — no local roster "
|
|
469
|
+
"(a configured cloud seat may still serve)")
|
|
470
|
+
lines.append("providers:")
|
|
471
|
+
for provider_id in PROVIDER_ORDER:
|
|
472
|
+
spec = PROVIDERS[provider_id]
|
|
473
|
+
if provider_id == "ollama":
|
|
474
|
+
status = (f"serving — {len(names)} model(s)" if names
|
|
475
|
+
else "not serving")
|
|
476
|
+
else:
|
|
477
|
+
key, _env = _provider_key(provider_id)
|
|
478
|
+
status = "key set" if key else "key not set"
|
|
479
|
+
lines.append(f" {spec['display']} ({spec['kind']} — "
|
|
480
|
+
f"{spec['host']}): {status}")
|
|
481
|
+
lines.append("model assignments (~/.godfather/models.json):")
|
|
482
|
+
for task in MODEL_TASKS:
|
|
483
|
+
assigned = assignments.get(task)
|
|
484
|
+
if assigned:
|
|
485
|
+
provider_id, model = parse_seat(assigned)
|
|
486
|
+
if provider_id == "ollama":
|
|
487
|
+
state = "serving" if model in names else "not serving"
|
|
488
|
+
else:
|
|
489
|
+
key, _env = _provider_key(provider_id)
|
|
490
|
+
state = "key set" if key else "key not set"
|
|
491
|
+
lines.append(f" {task}: {assigned} ({state})")
|
|
492
|
+
else:
|
|
493
|
+
effective = seat_for(task)
|
|
494
|
+
detail = (f"effective seat: {seat_label(effective)}"
|
|
495
|
+
if effective else "no effective seat")
|
|
496
|
+
lines.append(f" {task}: (not set — {detail})")
|
|
497
|
+
lines.append("guide: PROVIDERS.md — where to get each provider's "
|
|
498
|
+
"key, and how to set up local models with Ollama")
|
|
499
|
+
return 0, "\n".join(lines)
|
|
500
|
+
|
|
501
|
+
def call_ollama(prompt, model=None):
|
|
502
|
+
"""Consult the oracle: ollamawiz first when it exists, Ollama
|
|
503
|
+
binary as honest fallback. When ollamawiz resolves nowhere, the
|
|
504
|
+
doomed sibling call is skipped silently — same destination,
|
|
505
|
+
one less corpse on the road."""
|
|
506
|
+
actual_model = model or resolve_model()
|
|
507
|
+
if resolve_sibling("ollamawiz") is not None:
|
|
508
|
+
rc, out = call_sibling(["ollamawiz", "ask", actual_model, prompt])
|
|
509
|
+
if rc == 0 and out and not out.startswith("[FAIL]"):
|
|
510
|
+
return rc, out
|
|
511
|
+
try:
|
|
512
|
+
r = subprocess.run(["ollama", "run", actual_model, prompt],
|
|
513
|
+
capture_output=True, text=True, timeout=300)
|
|
514
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
515
|
+
return 0, r.stdout.strip()
|
|
516
|
+
return 1, (r.stderr or "[FAIL] oracle returned nothing").strip()
|
|
517
|
+
except FileNotFoundError:
|
|
518
|
+
return 1, "[FAIL] neither ollamawiz ask nor ollama binary available"
|
|
519
|
+
except subprocess.TimeoutExpired:
|
|
520
|
+
return 1, "[FAIL] oracle timed out"
|
|
521
|
+
|
|
522
|
+
def speak_if_enabled(text):
|
|
523
|
+
"""Speak text if VOICE_ENABLED=true, otherwise silent."""
|
|
524
|
+
if not VOICE_ENABLED:
|
|
525
|
+
return
|
|
526
|
+
rc, out = call_sibling(["voicewiz", "speak", text]) # the whole word, uncut
|
|
527
|
+
# Silent success expected
|
|
528
|
+
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
# INTERNAL SENSES — truthwiz's office, absorbed into godfatherwiz
|
|
531
|
+
# (R2 ruling: absorb where the purpose lives — Law 2's anchor is
|
|
532
|
+
# godfather's own duty, so its senses live here now). Every sense
|
|
533
|
+
# is read-only, returns call_sibling's (rc, text) shape, and a
|
|
534
|
+
# missing path is a plain [FAIL], exit 1 — never a traceback.
|
|
535
|
+
# ------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
_READ_CAP = 20000 # chars a single `read` will show before truncating
|
|
538
|
+
|
|
539
|
+
def _human_bytes(n):
|
|
540
|
+
if n < 1024:
|
|
541
|
+
return f"{n} bytes"
|
|
542
|
+
if n < 1024 ** 2:
|
|
543
|
+
return f"{n} bytes ({n / 1024:.1f} KB)"
|
|
544
|
+
if n < 1024 ** 3:
|
|
545
|
+
return f"{n} bytes ({n / 1024 ** 2:.1f} MB)"
|
|
546
|
+
return f"{n} bytes ({n / 1024 ** 3:.1f} GB)"
|
|
547
|
+
|
|
548
|
+
def _sense_target(raw):
|
|
549
|
+
return Path(str(raw)).expanduser()
|
|
550
|
+
|
|
551
|
+
def _read_text_guarded(p):
|
|
552
|
+
"""(text, None) for a readable text file, else (None, fail_line).
|
|
553
|
+
|
|
554
|
+
A NUL byte marks the file binary even when its bytes happen to
|
|
555
|
+
decode — control bytes are not text a person can read.
|
|
556
|
+
"""
|
|
557
|
+
try:
|
|
558
|
+
data = p.read_bytes()
|
|
559
|
+
except OSError as e:
|
|
560
|
+
return None, f"[FAIL] cannot read {p}: {e.strerror or e}"
|
|
561
|
+
if b"\x00" in data:
|
|
562
|
+
return None, f"[FAIL] not a text file: {p}"
|
|
563
|
+
try:
|
|
564
|
+
return data.decode("utf-8"), None
|
|
565
|
+
except UnicodeDecodeError:
|
|
566
|
+
return None, f"[FAIL] not a text file: {p}"
|
|
567
|
+
|
|
568
|
+
def _walk_sizes(root):
|
|
569
|
+
"""(total_bytes, file_count) under root, symlinks not followed."""
|
|
570
|
+
total, count = 0, 0
|
|
571
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
572
|
+
for name in filenames:
|
|
573
|
+
try:
|
|
574
|
+
total += os.lstat(os.path.join(dirpath, name)).st_size
|
|
575
|
+
count += 1
|
|
576
|
+
except OSError:
|
|
577
|
+
continue
|
|
578
|
+
return total, count
|
|
579
|
+
|
|
580
|
+
def sense_brief(raw_path):
|
|
581
|
+
"""Ground truth: what the path is, its size, and its shape."""
|
|
582
|
+
p = _sense_target(raw_path)
|
|
583
|
+
if not p.exists() and not p.is_symlink():
|
|
584
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
585
|
+
lines = [f"[BRIEF] {p}"]
|
|
586
|
+
if p.is_symlink():
|
|
587
|
+
lines.append(f"link: symlink -> {os.readlink(p)}")
|
|
588
|
+
if p.is_dir():
|
|
589
|
+
try:
|
|
590
|
+
entries = sorted(p.iterdir(), key=lambda e: e.name)
|
|
591
|
+
except OSError as e:
|
|
592
|
+
return 1, f"[FAIL] cannot list {p}: {e.strerror or e}"
|
|
593
|
+
dirs = sum(1 for e in entries if e.is_dir() and not e.is_symlink())
|
|
594
|
+
links = sum(1 for e in entries if e.is_symlink())
|
|
595
|
+
files = len(entries) - dirs - links
|
|
596
|
+
total, deep_files = _walk_sizes(p)
|
|
597
|
+
lines.append("type: directory")
|
|
598
|
+
lines.append(f"entries: {len(entries)} "
|
|
599
|
+
f"({dirs} dirs, {files} files, {links} symlinks)")
|
|
600
|
+
lines.append(f"size: {_human_bytes(total)} across {deep_files} files")
|
|
601
|
+
lines.append("listing:")
|
|
602
|
+
for e in entries[:20]:
|
|
603
|
+
if e.is_symlink():
|
|
604
|
+
lines.append(f" {e.name} -> {os.readlink(e)}")
|
|
605
|
+
elif e.is_dir():
|
|
606
|
+
lines.append(f" {e.name}/")
|
|
607
|
+
else:
|
|
608
|
+
lines.append(f" {e.name}")
|
|
609
|
+
if len(entries) > 20:
|
|
610
|
+
lines.append(f" ... (+{len(entries) - 20} more)")
|
|
611
|
+
return 0, "\n".join(lines)
|
|
612
|
+
st = p.stat()
|
|
613
|
+
lines.append("type: file")
|
|
614
|
+
lines.append(f"size: {_human_bytes(st.st_size)}")
|
|
615
|
+
text, _err = _read_text_guarded(p)
|
|
616
|
+
if text is not None:
|
|
617
|
+
lines.append(f"lines: {len(text.splitlines())}")
|
|
618
|
+
else:
|
|
619
|
+
lines.append("lines: (binary — not counted)")
|
|
620
|
+
return 0, "\n".join(lines)
|
|
621
|
+
|
|
622
|
+
def sense_listing(raw_path):
|
|
623
|
+
"""The directory's entries, one per line, dirs marked."""
|
|
624
|
+
p = _sense_target(raw_path)
|
|
625
|
+
if not p.exists() and not p.is_symlink():
|
|
626
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
627
|
+
if not p.is_dir():
|
|
628
|
+
return 0, p.name
|
|
629
|
+
try:
|
|
630
|
+
entries = sorted(p.iterdir(), key=lambda e: e.name)
|
|
631
|
+
except OSError as e:
|
|
632
|
+
return 1, f"[FAIL] cannot list {p}: {e.strerror or e}"
|
|
633
|
+
if not entries:
|
|
634
|
+
return 0, "(empty directory)"
|
|
635
|
+
out = []
|
|
636
|
+
for e in entries:
|
|
637
|
+
if e.is_symlink():
|
|
638
|
+
out.append(f"{e.name} -> {os.readlink(e)}")
|
|
639
|
+
elif e.is_dir():
|
|
640
|
+
out.append(f"{e.name}/")
|
|
641
|
+
else:
|
|
642
|
+
out.append(e.name)
|
|
643
|
+
return 0, "\n".join(out)
|
|
644
|
+
|
|
645
|
+
def sense_read(raw_path):
|
|
646
|
+
"""A file's text, capped — truncation is stated, never silent."""
|
|
647
|
+
p = _sense_target(raw_path)
|
|
648
|
+
if not p.exists() and not p.is_symlink():
|
|
649
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
650
|
+
if p.is_dir():
|
|
651
|
+
return 1, f"[FAIL] {p} is a directory — use listing for directories"
|
|
652
|
+
text, err = _read_text_guarded(p)
|
|
653
|
+
if text is None:
|
|
654
|
+
return 1, err
|
|
655
|
+
if len(text) > _READ_CAP:
|
|
656
|
+
return 0, text[:_READ_CAP] + \
|
|
657
|
+
f"\n... [truncated: showing first {_READ_CAP} of {len(text)} chars]"
|
|
658
|
+
return 0, text
|
|
659
|
+
|
|
660
|
+
def sense_head(raw_path, n=20):
|
|
661
|
+
"""The first n lines of a text file."""
|
|
662
|
+
p = _sense_target(raw_path)
|
|
663
|
+
if not p.exists() and not p.is_symlink():
|
|
664
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
665
|
+
if p.is_dir():
|
|
666
|
+
return 1, f"[FAIL] {p} is a directory — use listing for directories"
|
|
667
|
+
text, err = _read_text_guarded(p)
|
|
668
|
+
if text is None:
|
|
669
|
+
return 1, err
|
|
670
|
+
all_lines = text.splitlines()
|
|
671
|
+
shown = all_lines[:n]
|
|
672
|
+
if len(all_lines) > n:
|
|
673
|
+
shown.append(f"... (first {n} of {len(all_lines)} lines)")
|
|
674
|
+
return 0, "\n".join(shown)
|
|
675
|
+
|
|
676
|
+
def sense_find(root, name_part):
|
|
677
|
+
"""Paths under root whose name contains name_part (case-insensitive)."""
|
|
678
|
+
p = _sense_target(root)
|
|
679
|
+
if not p.exists() and not p.is_symlink():
|
|
680
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
681
|
+
needle = str(name_part).lower()
|
|
682
|
+
hits = []
|
|
683
|
+
if p.is_dir():
|
|
684
|
+
for dirpath, dirnames, filenames in os.walk(p):
|
|
685
|
+
for name in dirnames + filenames:
|
|
686
|
+
if needle in name.lower():
|
|
687
|
+
hits.append(os.path.join(dirpath, name))
|
|
688
|
+
elif needle in p.name.lower():
|
|
689
|
+
hits.append(str(p))
|
|
690
|
+
hits.sort()
|
|
691
|
+
if not hits:
|
|
692
|
+
return 0, f"(no matches for '{name_part}' under {p})"
|
|
693
|
+
if len(hits) > 200:
|
|
694
|
+
return 0, "\n".join(hits[:200]) + \
|
|
695
|
+
f"\n... (+{len(hits) - 200} more matches)"
|
|
696
|
+
return 0, "\n".join(hits)
|
|
697
|
+
|
|
698
|
+
def sense_du(raw_path):
|
|
699
|
+
"""Disk usage, in human terms."""
|
|
700
|
+
p = _sense_target(raw_path)
|
|
701
|
+
if not p.exists() and not p.is_symlink():
|
|
702
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
703
|
+
if p.is_dir():
|
|
704
|
+
total, count = _walk_sizes(p)
|
|
705
|
+
return 0, f"{_human_bytes(total)} across {count} files — {p}"
|
|
706
|
+
return 0, f"{_human_bytes(os.lstat(p).st_size)} — {p}"
|
|
707
|
+
|
|
708
|
+
def sense_stat(raw_path):
|
|
709
|
+
"""Type, size, and modification time."""
|
|
710
|
+
p = _sense_target(raw_path)
|
|
711
|
+
if not p.exists() and not p.is_symlink():
|
|
712
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
713
|
+
st = os.lstat(p)
|
|
714
|
+
if p.is_symlink():
|
|
715
|
+
kind = "symlink"
|
|
716
|
+
elif p.is_dir():
|
|
717
|
+
kind = "directory"
|
|
718
|
+
elif p.is_file():
|
|
719
|
+
kind = "file"
|
|
720
|
+
else:
|
|
721
|
+
kind = "other"
|
|
722
|
+
lines = [f"[STAT] {p}",
|
|
723
|
+
f"type: {kind}",
|
|
724
|
+
f"size: {_human_bytes(st.st_size)}",
|
|
725
|
+
"modified: " + dt.datetime.fromtimestamp(st.st_mtime)
|
|
726
|
+
.isoformat(timespec="seconds")]
|
|
727
|
+
if p.is_symlink():
|
|
728
|
+
lines.append(f"target: {os.readlink(p)}")
|
|
729
|
+
return 0, "\n".join(lines)
|
|
730
|
+
|
|
731
|
+
def sense_symlinks(raw_path):
|
|
732
|
+
"""Symlinks at or under the path, each with its target."""
|
|
733
|
+
p = _sense_target(raw_path)
|
|
734
|
+
if not p.exists() and not p.is_symlink():
|
|
735
|
+
return 1, f"[FAIL] no such path: {p}"
|
|
736
|
+
found = []
|
|
737
|
+
if p.is_symlink():
|
|
738
|
+
found.append(p)
|
|
739
|
+
elif p.is_dir():
|
|
740
|
+
for dirpath, dirnames, filenames in os.walk(p):
|
|
741
|
+
for name in dirnames + filenames:
|
|
742
|
+
e = Path(dirpath) / name
|
|
743
|
+
if e.is_symlink():
|
|
744
|
+
found.append(e)
|
|
745
|
+
if not found:
|
|
746
|
+
return 0, f"(no symlinks at {p})"
|
|
747
|
+
return 0, "\n".join(f"{e} -> {os.readlink(e)}" for e in sorted(found))
|
|
748
|
+
|
|
749
|
+
SENSE_VERBS = ("brief", "listing", "read", "head", "find", "du", "stat",
|
|
750
|
+
"symlinks")
|
|
751
|
+
_PATH_SENSES = ("brief", "listing", "read", "du", "stat", "symlinks")
|
|
752
|
+
|
|
753
|
+
def call_sense(verb, args):
|
|
754
|
+
"""Dispatch one absorbed sense. args are raw string tokens.
|
|
755
|
+
|
|
756
|
+
Path senses default to '.' when no path is given — the anchor's
|
|
757
|
+
home ground, the same default truth_anchor has always used.
|
|
758
|
+
Returns (rc, text), call_sibling's shape.
|
|
759
|
+
"""
|
|
760
|
+
args = [str(a) for a in args]
|
|
761
|
+
if verb in _PATH_SENSES:
|
|
762
|
+
raw = args[0] if args else "."
|
|
763
|
+
fn = {"brief": sense_brief, "listing": sense_listing,
|
|
764
|
+
"read": sense_read, "du": sense_du, "stat": sense_stat,
|
|
765
|
+
"symlinks": sense_symlinks}[verb]
|
|
766
|
+
return fn(raw)
|
|
767
|
+
if verb == "head":
|
|
768
|
+
if not args:
|
|
769
|
+
return 2, "[FAIL] head requires a file"
|
|
770
|
+
n = 20
|
|
771
|
+
if len(args) > 1:
|
|
772
|
+
try:
|
|
773
|
+
n = int(args[1])
|
|
774
|
+
except ValueError:
|
|
775
|
+
return 2, "[FAIL] head line count must be a number"
|
|
776
|
+
return sense_head(args[0], n)
|
|
777
|
+
if verb == "find":
|
|
778
|
+
if len(args) < 2:
|
|
779
|
+
return 2, "[FAIL] find requires a root and a name part"
|
|
780
|
+
return sense_find(args[0], args[1])
|
|
781
|
+
return 2, f"out of scope - unknown sense: {verb}"
|
|
782
|
+
|
|
783
|
+
def call_internal(parts):
|
|
784
|
+
"""Serve an in-house capability, or return None.
|
|
785
|
+
|
|
786
|
+
parts = ['godfather_wizard', <verb>, <args...>] where <verb> is
|
|
787
|
+
one of the absorbed truth office's senses, or 'models' (the
|
|
788
|
+
seats answer); anything else is not internal and the caller
|
|
789
|
+
falls back to call_sibling. The alias spelling 'godfatherwiz'
|
|
790
|
+
names the same in-house citizen and is served identically.
|
|
791
|
+
"""
|
|
792
|
+
if len(parts) >= 2 and parts[0] in ("godfatherwiz", "godfather_wizard"):
|
|
793
|
+
if parts[1] in SENSE_VERBS:
|
|
794
|
+
return call_sense(parts[1], parts[2:])
|
|
795
|
+
if parts[1] == "models":
|
|
796
|
+
return seated_models()
|
|
797
|
+
return None
|
|
798
|
+
|
|
799
|
+
def truth_anchor(path="."):
|
|
800
|
+
"""Law 2: anchor ground truth before multi-step action.
|
|
801
|
+
|
|
802
|
+
Served by the absorbed senses — godfatherwiz's own brief, in-house.
|
|
803
|
+
"""
|
|
804
|
+
return sense_brief(str(Path(path).expanduser()))
|
|
805
|
+
|
|
806
|
+
_MUTATION_WORDS = {"write", "fix", "create", "delete", "remove",
|
|
807
|
+
"modify", "edit", "rm", "heal", "repair"}
|
|
808
|
+
|
|
809
|
+
# writewiz verbs that change files when executed. propose stages a
|
|
810
|
+
# draft — a suggestion, behind no wall; the rest of the read/inspect
|
|
811
|
+
# family changes nothing. commit lands the write; discard, rollback
|
|
812
|
+
# and purge-old destroy or restore; protect/unprotect rewrite the
|
|
813
|
+
# protection record itself.
|
|
814
|
+
_WRITEWIZ_WRITE_VERBS = {"commit", "discard", "rollback", "purge-old",
|
|
815
|
+
"protect", "unprotect"}
|
|
816
|
+
|
|
817
|
+
def pipeline_is_write_class(pipeline):
|
|
818
|
+
"""True when executing this pipeline would change files.
|
|
819
|
+
|
|
820
|
+
The wall's true scope (house law): read-only routes run free,
|
|
821
|
+
staging routes are suggestions and run free, and only a route
|
|
822
|
+
that would actually land a change is write-class.
|
|
823
|
+
"""
|
|
824
|
+
if not pipeline:
|
|
825
|
+
return False
|
|
826
|
+
head = _short_form(pipeline[0])
|
|
827
|
+
sub = pipeline[1] if len(pipeline) > 1 else ""
|
|
828
|
+
if head == "writewiz":
|
|
829
|
+
return sub in _WRITEWIZ_WRITE_VERBS
|
|
830
|
+
if head == "codeguardwiz" and sub == "fix":
|
|
831
|
+
# fix stages through writewiz by default; only its explicit
|
|
832
|
+
# --write lane lands a change directly.
|
|
833
|
+
return "--write" in pipeline[2:]
|
|
834
|
+
if head == "repairwiz" and sub in ("commit", "apply"):
|
|
835
|
+
return True
|
|
836
|
+
return False
|
|
837
|
+
|
|
838
|
+
def _path_tokens(directive, keys):
|
|
839
|
+
"""Directive tokens that name existing paths ride as arguments."""
|
|
840
|
+
out = []
|
|
841
|
+
for tok in directive.split():
|
|
842
|
+
if tok.lower() in keys:
|
|
843
|
+
continue
|
|
844
|
+
if Path(tok).expanduser().exists():
|
|
845
|
+
out.append(tok)
|
|
846
|
+
return out
|
|
847
|
+
|
|
848
|
+
def classify_route(directive, args):
|
|
849
|
+
"""Resolve a directive. Returns (pipeline, reason, wall).
|
|
850
|
+
|
|
851
|
+
- (None, reason, False): no route matches — out of scope.
|
|
852
|
+
- (None, reason, True): unmapped mutation intent — refused at
|
|
853
|
+
the wall, as it always has been.
|
|
854
|
+
- (pipeline, reason, False): a read-class or staging route —
|
|
855
|
+
runs free, whatever words the sentence carried.
|
|
856
|
+
- (pipeline, reason, True): a write-class route — the caller
|
|
857
|
+
asks twice before anything executes.
|
|
858
|
+
Explicit args win; otherwise directive tokens naming existing
|
|
859
|
+
paths ride along as the pipeline's arguments.
|
|
860
|
+
"""
|
|
861
|
+
d = directive.lower()
|
|
862
|
+
routes = load_routes()
|
|
863
|
+
for keywords, pipeline in routes.items():
|
|
864
|
+
keys = keywords.split("/")
|
|
865
|
+
if any(k in d for k in keys):
|
|
866
|
+
ride = list(args) if args else _path_tokens(directive, keys)
|
|
867
|
+
full = list(pipeline) + ride
|
|
868
|
+
if pipeline_is_write_class(full):
|
|
869
|
+
return full, f"write-class route: {keywords}", True
|
|
870
|
+
return full, f"matched route: {keywords}", False
|
|
871
|
+
if any(w in d for w in _MUTATION_WORDS):
|
|
872
|
+
return None, "mutation intent detected — route through write_wizard", True
|
|
873
|
+
return None, "no route matches this intent", False
|
|
874
|
+
|
|
875
|
+
def route_directive(directive, args):
|
|
876
|
+
"""Deterministic dispatch. Returns (pipeline, reason) or None."""
|
|
877
|
+
pipeline, reason, wall = classify_route(directive, args)
|
|
878
|
+
if wall:
|
|
879
|
+
return ("WALL", reason)
|
|
880
|
+
return (pipeline, reason)
|
|
881
|
+
|
|
882
|
+
def fleet_census():
|
|
883
|
+
"""Health sweep: the siblings that can actually be invoked.
|
|
884
|
+
|
|
885
|
+
Counts the bin lane's executables plus every wizard this
|
|
886
|
+
installation provides (its entry points) that resolves to a real
|
|
887
|
+
executable. One citizen under two spellings (godfatherwiz /
|
|
888
|
+
godfather_wizard) is ONE citizen, listed by its full-form
|
|
889
|
+
command when that spelling invokes, else by the spelling that
|
|
890
|
+
does. One meaning of 'present', shared by fleet, siblings,
|
|
891
|
+
census and status: installed and invocable.
|
|
892
|
+
"""
|
|
893
|
+
raw = {w.name for w in BIN.glob("*wiz")}
|
|
894
|
+
raw |= {w.name for w in BIN.glob("*_wizard")}
|
|
895
|
+
raw |= {n for n in installed_sibling_names()
|
|
896
|
+
if (n.endswith("wiz") or n.endswith("_wizard"))
|
|
897
|
+
and resolve_sibling(n) is not None}
|
|
898
|
+
spellings = {}
|
|
899
|
+
for name in raw:
|
|
900
|
+
spellings.setdefault(_citizen_key(name), set()).add(name)
|
|
901
|
+
names = []
|
|
902
|
+
for key in sorted(spellings):
|
|
903
|
+
full = _full_form(key)
|
|
904
|
+
names.append(full if full in spellings[key] else key)
|
|
905
|
+
return len(names), names
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def fleet_invocable_names():
|
|
909
|
+
"""Every spelling that invokes a citizen — the census names
|
|
910
|
+
plus their alias spellings. The oracle's whitelist and the
|
|
911
|
+
REPL's /wiz matching accept either spelling of a citizen."""
|
|
912
|
+
_count, names = fleet_census()
|
|
913
|
+
out = set(names)
|
|
914
|
+
for n in names:
|
|
915
|
+
out.add(_citizen_key(n))
|
|
916
|
+
out.add(_full_form(_citizen_key(n)))
|
|
917
|
+
return out
|
|
918
|
+
|
|
919
|
+
def list_all_slash_commands():
|
|
920
|
+
"""Return mapping of /slash -> wizard command.
|
|
921
|
+
|
|
922
|
+
One key per slash. The values are dispatch pipelines in
|
|
923
|
+
full-form commands (the REPL splits them into argv). /diff
|
|
924
|
+
carries ONE entry — the Diff Wizard's two-file comparison;
|
|
925
|
+
the codegen review sense of /diff is dispatched by argument
|
|
926
|
+
shape in the REPL before this table is consulted, and /help
|
|
927
|
+
names it on its own line.
|
|
928
|
+
"""
|
|
929
|
+
# Consolidated fleet-wide slash-command registry
|
|
930
|
+
slash_map = {
|
|
931
|
+
"/truth": "godfather_wizard brief",
|
|
932
|
+
"/ls": "godfather_wizard listing",
|
|
933
|
+
"/read": "godfather_wizard read",
|
|
934
|
+
"/pycheck": "python_wizard check",
|
|
935
|
+
"/fix": "godfather_wizard heal",
|
|
936
|
+
"/heal": "godfather_wizard heal",
|
|
937
|
+
"/todo": "todo_wizard add",
|
|
938
|
+
"/todos": "todo_wizard list",
|
|
939
|
+
"/diff": "diff_wizard files",
|
|
940
|
+
"/models": "godfather_wizard models",
|
|
941
|
+
"/brainstorm": "godfather_wizard brainstorm (model seat; hands off)",
|
|
942
|
+
"/build": "godfather_wizard build (proposal -> diff -> double ask -> write_wizard)",
|
|
943
|
+
"/debug": "godfather_wizard debug (proposal -> diff -> double ask -> write_wizard)",
|
|
944
|
+
"/banner": "banner_wizard",
|
|
945
|
+
"/codeguard": "codeguard_wizard",
|
|
946
|
+
"/json": "json_wizard",
|
|
947
|
+
"/rust": "rust_wizard",
|
|
948
|
+
"/tmux": "tmux_wizard",
|
|
949
|
+
"/write": "write_wizard",
|
|
950
|
+
"/verbs": "read_wizard verbs",
|
|
951
|
+
"/tasks": "todo_wizard list",
|
|
952
|
+
"/audit": "debug_wizard audit",
|
|
953
|
+
"/diag": "debug_wizard classify",
|
|
954
|
+
"/bins": "godfather_wizard symlinks",
|
|
955
|
+
"/history": "godfather_wizard history",
|
|
956
|
+
"/stats": "godfather_wizard stats",
|
|
957
|
+
"/routes": "godfather_wizard routes",
|
|
958
|
+
"/fleet": "godfather_wizard fleet",
|
|
959
|
+
"/law": "godfather_wizard law",
|
|
960
|
+
"/help": "godfather_wizard help",
|
|
961
|
+
"/status": "godfather_wizard status",
|
|
962
|
+
"/census": "godfather_wizard census",
|
|
963
|
+
}
|
|
964
|
+
return slash_map
|
|
965
|
+
|
|
966
|
+
def session_load():
|
|
967
|
+
_ensure_bank()
|
|
968
|
+
try:
|
|
969
|
+
return json.loads(STATE_FILE.read_text())
|
|
970
|
+
except (json.JSONDecodeError, OSError):
|
|
971
|
+
return {"session": 1}
|
|
972
|
+
|
|
973
|
+
def session_save(state):
|
|
974
|
+
_ensure_bank()
|
|
975
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
976
|
+
|
|
977
|
+
def schedule_task(intent, delay_secs=0):
|
|
978
|
+
"""Queue a task with timestamp."""
|
|
979
|
+
_ensure_bank()
|
|
980
|
+
task_id = hash(f"{intent}{_now()}") % 100000
|
|
981
|
+
task = {"id": task_id, "intent": intent, "scheduled_at": _now(),
|
|
982
|
+
"delay_sec": delay_secs, "status": "queued"}
|
|
983
|
+
tasks = load_routes()
|
|
984
|
+
tasks[f"task_{task_id}"] = task
|
|
985
|
+
save_routes(tasks)
|
|
986
|
+
journal("SCHEDULED", str(task_id), intent)
|
|
987
|
+
return task_id
|
|
988
|
+
|
|
989
|
+
def queue_tasks():
|
|
990
|
+
"""Show scheduled tasks."""
|
|
991
|
+
_ensure_bank()
|
|
992
|
+
tasks = load_routes()
|
|
993
|
+
return [v for k, v in tasks.items() if k.startswith("task_")]
|
|
994
|
+
|
|
995
|
+
def render_banner():
|
|
996
|
+
"""Sovereign mural: figlet + rich 16-color, guaranteed to fit the terminal."""
|
|
997
|
+
import shutil
|
|
998
|
+
try:
|
|
999
|
+
from pyfiglet import Figlet
|
|
1000
|
+
from rich.console import Console
|
|
1001
|
+
from rich.text import Text
|
|
1002
|
+
except ImportError:
|
|
1003
|
+
print("=== GODFATHER — GRAND MASTER OF THE SOVEREIGN FLEET ===")
|
|
1004
|
+
return
|
|
1005
|
+
|
|
1006
|
+
width = shutil.get_terminal_size((100, 24)).columns
|
|
1007
|
+
text = "GODFATHER"
|
|
1008
|
+
subtitle = "G R A N D M A S T E R O F T H E S O V E R E I G N F L E E T"
|
|
1009
|
+
|
|
1010
|
+
# Width-fit: walk fonts until the art fits with a 2-col margin
|
|
1011
|
+
art = None
|
|
1012
|
+
for font in ("slant", "smslant", "small", "term", "digital"):
|
|
1013
|
+
candidate = Figlet(font=font, width=width).renderText(text).rstrip("\n")
|
|
1014
|
+
longest = max((len(ln) for ln in candidate.splitlines()), default=0)
|
|
1015
|
+
if longest <= width - 2:
|
|
1016
|
+
art = candidate
|
|
1017
|
+
break
|
|
1018
|
+
if art is None: # terminal too narrow for any font — plain, clean, honest
|
|
1019
|
+
print("=== GODFATHER — GRAND MASTER OF THE SOVEREIGN FLEET ===")
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
console = Console(highlight=False)
|
|
1023
|
+
console.print(Text(art, style="bright_magenta bold"))
|
|
1024
|
+
if len(subtitle) <= width - 2:
|
|
1025
|
+
console.print(Text(subtitle, style="bright_yellow"))
|
|
1026
|
+
else:
|
|
1027
|
+
console.print(Text("GRAND MASTER", style="bright_yellow"))
|
|
1028
|
+
_, _census_names = fleet_census()
|
|
1029
|
+
console.print(Text(f"{width} cols | Sovereign Fleet: {len(_census_names)} citizens, zero lies",
|
|
1030
|
+
style="bright_cyan"))
|
|
1031
|
+
|
|
1032
|
+
# ------------------------------------------------------------------
|
|
1033
|
+
# ORACLE WITH HANDS — the Governor's tool channel for the cockpit
|
|
1034
|
+
# ------------------------------------------------------------------
|
|
1035
|
+
|
|
1036
|
+
ORACLE_SPINE = """You command the Sovereign Fleet — check AVAILABLE TOOLS below for the live citizen count and capacity.
|
|
1037
|
+
You act as the central dispatcher and reasoning engine for all interactive wizard queries.
|
|
1038
|
+
You command the real CLI wizards listed below on this machine. You have NO eyes and NO hands
|
|
1039
|
+
of your own. Your only way to act or observe is the TOOL channel.
|
|
1040
|
+
|
|
1041
|
+
TOOL CHANNEL — to request one real action, reply with a single line, exactly:
|
|
1042
|
+
TOOL: <command>
|
|
1043
|
+
where <command> is a real wizard invocation from the AVAILABLE TOOLS list.
|
|
1044
|
+
|
|
1045
|
+
Your eyes are godfather_wizard's own senses — brief, listing, read, head,
|
|
1046
|
+
find, du, stat, symlinks — invoked as pipelines, e.g.
|
|
1047
|
+
"godfather_wizard listing <path>". They are read-only and always lawful.
|
|
1048
|
+
|
|
1049
|
+
LAWS:
|
|
1050
|
+
- ONE tool call per reply. Then STOP and wait for the true result.
|
|
1051
|
+
- NEVER invent output, file names, or states you have not been shown.
|
|
1052
|
+
- NEVER use shell commands (ls, cat, rm, pwd...) — they are forbidden and rejected.
|
|
1053
|
+
- When you have enough truth, drop the channel and answer plainly.
|
|
1054
|
+
- If no tool fits, answer plainly and say so.
|
|
1055
|
+
|
|
1056
|
+
WORKED EXAMPLE — if the user asks "what files are in ~/wizard_central?",
|
|
1057
|
+
your ENTIRE reply must be exactly this one line:
|
|
1058
|
+
TOOL: godfather_wizard listing __HOME__/wizard_central
|
|
1059
|
+
|
|
1060
|
+
Then stop. The true result will be given to you, and you summarize it.
|
|
1061
|
+
"""
|
|
1062
|
+
|
|
1063
|
+
import os as _os_home
|
|
1064
|
+
ORACLE_SPINE = ORACLE_SPINE.replace("__HOME__", _os_home.path.expanduser("~"))
|
|
1065
|
+
|
|
1066
|
+
def _oracle_write_consent(tool_line, parts):
|
|
1067
|
+
"""The double ask (house law) for a write-class TOOL line.
|
|
1068
|
+
|
|
1069
|
+
Staging and read lanes never reach here — pipeline_is_write_class
|
|
1070
|
+
has already cleared them to run free. First the plain ask; then a
|
|
1071
|
+
typed confirmation naming the pipeline. Anything but the two
|
|
1072
|
+
exact answers — including EOF — is a no.
|
|
1073
|
+
"""
|
|
1074
|
+
action = " ".join(parts)
|
|
1075
|
+
try:
|
|
1076
|
+
first = input(f"\n⚠️ oracle wants to WRITE: {tool_line}\n"
|
|
1077
|
+
"this changes files. proceed? [y/N] ")
|
|
1078
|
+
except (KeyboardInterrupt, EOFError):
|
|
1079
|
+
return False
|
|
1080
|
+
if first.strip().lower() != "y":
|
|
1081
|
+
return False
|
|
1082
|
+
try:
|
|
1083
|
+
typed = input(f"confirm by typing: yes, {action}\n> ")
|
|
1084
|
+
except (KeyboardInterrupt, EOFError):
|
|
1085
|
+
return False
|
|
1086
|
+
return typed.strip() == f"yes, {action}"
|
|
1087
|
+
|
|
1088
|
+
def _ollama_chat_api(messages, model):
|
|
1089
|
+
"""Direct stdlib chat call — no external deps, honest failures."""
|
|
1090
|
+
import json as _j
|
|
1091
|
+
import urllib.request
|
|
1092
|
+
import urllib.error
|
|
1093
|
+
payload = _j.dumps({"model": model, "messages": messages,
|
|
1094
|
+
"stream": False}).encode()
|
|
1095
|
+
req = urllib.request.Request(
|
|
1096
|
+
"http://127.0.0.1:11434/api/chat",
|
|
1097
|
+
data=payload, headers={"Content-Type": "application/json"})
|
|
1098
|
+
import os as _os
|
|
1099
|
+
_timeout = int(_os.environ.get("WIZ_OLLAMA_TIMEOUT", "600"))
|
|
1100
|
+
with urllib.request.urlopen(req, timeout=_timeout) as r:
|
|
1101
|
+
data = _j.loads(r.read().decode())
|
|
1102
|
+
return data.get("message", {}).get("content", "")
|
|
1103
|
+
|
|
1104
|
+
# ------------------------------------------------------------------
|
|
1105
|
+
# SEAT CHAT — the one door every model turn walks through. A seat
|
|
1106
|
+
# is (provider, model): Ollama underneath is the local call above;
|
|
1107
|
+
# the cloud providers are stdlib-urllib calls to their own APIs
|
|
1108
|
+
# with the user's own key. Failures raise RuntimeError carrying a
|
|
1109
|
+
# clean line — status and the provider's own message, trimmed;
|
|
1110
|
+
# never a traceback, and never the key.
|
|
1111
|
+
# ------------------------------------------------------------------
|
|
1112
|
+
|
|
1113
|
+
def _cloud_timeout():
|
|
1114
|
+
try:
|
|
1115
|
+
return int(os.environ.get("WIZ_CLOUD_TIMEOUT", "120"))
|
|
1116
|
+
except ValueError:
|
|
1117
|
+
return 120
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _post_json(url, payload, headers, provider_id):
|
|
1121
|
+
"""POST one JSON body and return the parsed JSON reply."""
|
|
1122
|
+
import urllib.request
|
|
1123
|
+
import urllib.error
|
|
1124
|
+
display = PROVIDERS[provider_id]["display"]
|
|
1125
|
+
req = urllib.request.Request(
|
|
1126
|
+
url, data=json.dumps(payload).encode(), headers=headers)
|
|
1127
|
+
try:
|
|
1128
|
+
with urllib.request.urlopen(req, timeout=_cloud_timeout()) as r:
|
|
1129
|
+
raw = r.read().decode("utf-8", "replace")
|
|
1130
|
+
except urllib.error.HTTPError as e:
|
|
1131
|
+
detail = ""
|
|
1132
|
+
try:
|
|
1133
|
+
parsed = json.loads(e.read().decode("utf-8", "replace"))
|
|
1134
|
+
err = parsed.get("error")
|
|
1135
|
+
if isinstance(err, dict):
|
|
1136
|
+
detail = str(err.get("message") or "")
|
|
1137
|
+
elif isinstance(err, str):
|
|
1138
|
+
detail = err
|
|
1139
|
+
except Exception:
|
|
1140
|
+
detail = ""
|
|
1141
|
+
detail = " ".join(detail.split())[:200]
|
|
1142
|
+
raise RuntimeError(
|
|
1143
|
+
f"{display} seat returned HTTP {e.code}"
|
|
1144
|
+
+ (f": {detail}" if detail else "")) from None
|
|
1145
|
+
except (urllib.error.URLError, OSError) as e:
|
|
1146
|
+
raise RuntimeError(f"{display} seat unreachable ({e})") from None
|
|
1147
|
+
try:
|
|
1148
|
+
return json.loads(raw)
|
|
1149
|
+
except ValueError:
|
|
1150
|
+
raise RuntimeError(
|
|
1151
|
+
f"{display} seat returned an unreadable response") from None
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _openai_style_chat(provider_id, model, messages):
|
|
1155
|
+
"""Chat completions in the OpenAI shape (meta/openai/google/deepseek)."""
|
|
1156
|
+
spec = PROVIDERS[provider_id]
|
|
1157
|
+
key, env = _provider_key(provider_id)
|
|
1158
|
+
if not key:
|
|
1159
|
+
raise RuntimeError(
|
|
1160
|
+
f"{spec['display']} seat is not configured — {env} is not set")
|
|
1161
|
+
data = _post_json(
|
|
1162
|
+
spec["url"], {"model": model, "messages": messages},
|
|
1163
|
+
{"Authorization": f"Bearer {key}",
|
|
1164
|
+
"Content-Type": "application/json"}, provider_id)
|
|
1165
|
+
try:
|
|
1166
|
+
content = data["choices"][0]["message"]["content"]
|
|
1167
|
+
except (KeyError, IndexError, TypeError):
|
|
1168
|
+
content = None
|
|
1169
|
+
if not isinstance(content, str):
|
|
1170
|
+
raise RuntimeError(
|
|
1171
|
+
f"{spec['display']} seat returned an unexpected "
|
|
1172
|
+
"response shape")
|
|
1173
|
+
return content
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _anthropic_chat(model, messages):
|
|
1177
|
+
"""Anthropic messages API: system text travels out-of-band,
|
|
1178
|
+
and the reply's content is a list of blocks — text blocks join."""
|
|
1179
|
+
spec = PROVIDERS["anthropic"]
|
|
1180
|
+
key, env = _provider_key("anthropic")
|
|
1181
|
+
if not key:
|
|
1182
|
+
raise RuntimeError(
|
|
1183
|
+
f"{spec['display']} seat is not configured — {env} is not set")
|
|
1184
|
+
system = "\n\n".join(str(m.get("content", "")) for m in messages
|
|
1185
|
+
if m.get("role") == "system")
|
|
1186
|
+
convo = [{"role": m.get("role"), "content": m.get("content")}
|
|
1187
|
+
for m in messages if m.get("role") != "system"]
|
|
1188
|
+
payload = {"model": model, "max_tokens": 4096, "messages": convo}
|
|
1189
|
+
if system:
|
|
1190
|
+
payload["system"] = system
|
|
1191
|
+
data = _post_json(
|
|
1192
|
+
spec["url"], payload,
|
|
1193
|
+
{"x-api-key": key, "anthropic-version": "2023-06-01",
|
|
1194
|
+
"Content-Type": "application/json"}, "anthropic")
|
|
1195
|
+
blocks = data.get("content")
|
|
1196
|
+
if not isinstance(blocks, list):
|
|
1197
|
+
raise RuntimeError(
|
|
1198
|
+
"Anthropic seat returned an unexpected response shape")
|
|
1199
|
+
return "".join(b.get("text", "") for b in blocks
|
|
1200
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def call_seat_chat(seat, messages):
|
|
1204
|
+
"""One chat turn for a seat (provider, model). Returns the reply
|
|
1205
|
+
text; failures raise with a clean, honest line."""
|
|
1206
|
+
provider_id, model = seat
|
|
1207
|
+
if provider_id == "ollama":
|
|
1208
|
+
return _ollama_chat_api(messages, model)
|
|
1209
|
+
if provider_id == "anthropic":
|
|
1210
|
+
return _anthropic_chat(model, messages)
|
|
1211
|
+
if provider_id in PROVIDERS:
|
|
1212
|
+
return _openai_style_chat(provider_id, model, messages)
|
|
1213
|
+
raise RuntimeError(f"unknown seat provider: {provider_id}")
|
|
1214
|
+
|
|
1215
|
+
# ------------------------------------------------------------------
|
|
1216
|
+
# CODEGEN SUITE — devrepl's modes, rebuilt on the fleet consent wall
|
|
1217
|
+
# ------------------------------------------------------------------
|
|
1218
|
+
|
|
1219
|
+
CODEGEN_SYSTEM_PROMPTS = {
|
|
1220
|
+
"brainstorm": (
|
|
1221
|
+
"You are the brainstorm seat in godfather_wizard. Converse plainly "
|
|
1222
|
+
"and help the person think. You have no tools and no hands in "
|
|
1223
|
+
"this mode: do not claim to read, write, run, or change "
|
|
1224
|
+
"anything."
|
|
1225
|
+
),
|
|
1226
|
+
"build": (
|
|
1227
|
+
"You are the build seat in godfather_wizard. Create the complete "
|
|
1228
|
+
"content requested for one file. Reply with only the complete "
|
|
1229
|
+
"file content, with no explanation and no markdown fence."
|
|
1230
|
+
),
|
|
1231
|
+
"debug": (
|
|
1232
|
+
"You are the debug seat in godfather_wizard. Repair the file "
|
|
1233
|
+
"using the supplied file content and error context. Reply "
|
|
1234
|
+
"with only the complete repaired file content, with no "
|
|
1235
|
+
"explanation and no markdown fence."
|
|
1236
|
+
),
|
|
1237
|
+
"diff": (
|
|
1238
|
+
"You are the diff-review seat in godfather_wizard. Propose the "
|
|
1239
|
+
"complete revised content for one file so the person can "
|
|
1240
|
+
"review a diff. Reply with only the complete file content, "
|
|
1241
|
+
"with no explanation and no markdown fence."
|
|
1242
|
+
),
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _clean_codegen_reply(reply):
|
|
1247
|
+
"""Deterministic reply parsing for a complete-content seat.
|
|
1248
|
+
|
|
1249
|
+
Only a fence that surrounds the whole reply is removed. All
|
|
1250
|
+
other text — including its final newline — is the seat's exact
|
|
1251
|
+
proposed content.
|
|
1252
|
+
"""
|
|
1253
|
+
if not isinstance(reply, str) or not reply.strip():
|
|
1254
|
+
return ""
|
|
1255
|
+
lines = reply.splitlines(True)
|
|
1256
|
+
if len(lines) >= 2 and lines[0].strip().startswith("```") \
|
|
1257
|
+
and lines[-1].strip() == "```":
|
|
1258
|
+
return "".join(lines[1:-1])
|
|
1259
|
+
return reply
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def brainstorm_reply(text, memory):
|
|
1263
|
+
"""One hands-off brainstorm turn. Returns (rc, reply, memory).
|
|
1264
|
+
|
|
1265
|
+
This path deliberately never inspects TOOL: lines and never
|
|
1266
|
+
calls the oracle/tool machinery. A seat's words remain words.
|
|
1267
|
+
"""
|
|
1268
|
+
seat = seat_for("brainstorm")
|
|
1269
|
+
if seat is None:
|
|
1270
|
+
return 1, seat_unavailable_line("brainstorm"), memory
|
|
1271
|
+
work = list(memory or [])
|
|
1272
|
+
if not work:
|
|
1273
|
+
work.append({"role": "system",
|
|
1274
|
+
"content": CODEGEN_SYSTEM_PROMPTS["brainstorm"]})
|
|
1275
|
+
work.append({"role": "user", "content": text})
|
|
1276
|
+
try:
|
|
1277
|
+
reply = call_seat_chat(seat, work)
|
|
1278
|
+
except Exception as e:
|
|
1279
|
+
return 1, (f"[FAIL] brainstorm seat unreachable "
|
|
1280
|
+
f"({type(e).__name__}: {e})"), memory
|
|
1281
|
+
if not isinstance(reply, str) or not reply.strip():
|
|
1282
|
+
return 1, "[FAIL] brainstorm seat returned nothing", memory
|
|
1283
|
+
reply = reply.strip()
|
|
1284
|
+
work.append({"role": "assistant", "content": reply})
|
|
1285
|
+
return 0, reply, work
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def codegen_current_content(path):
|
|
1289
|
+
"""(content, error_line) for build/debug/diff's current truth."""
|
|
1290
|
+
if not path.exists():
|
|
1291
|
+
return "", None
|
|
1292
|
+
if not path.is_file():
|
|
1293
|
+
return None, f"[FAIL] not a file: {path}"
|
|
1294
|
+
return _read_text_guarded(path)
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def codegen_proposal(task, path, instruction, current, extra_context=""):
|
|
1298
|
+
"""Ask one codegen seat for complete content.
|
|
1299
|
+
|
|
1300
|
+
Returns (proposal, error_line). A proposal is inert data —
|
|
1301
|
+
path, instruction, exact content, task, and model — until a
|
|
1302
|
+
person approves its diff at the writewiz wall.
|
|
1303
|
+
"""
|
|
1304
|
+
if task not in ("build", "debug", "diff"):
|
|
1305
|
+
return None, f"[FAIL] unknown codegen task: {task}"
|
|
1306
|
+
seat = seat_for(task)
|
|
1307
|
+
if seat is None:
|
|
1308
|
+
return None, seat_unavailable_line(task)
|
|
1309
|
+
user = [f"Path: {path}", f"Instruction: {instruction}"]
|
|
1310
|
+
if extra_context:
|
|
1311
|
+
user += ["", "Error context:", extra_context]
|
|
1312
|
+
user += ["", "Current complete content:",
|
|
1313
|
+
current if current else "(the file does not exist yet)"]
|
|
1314
|
+
messages = [
|
|
1315
|
+
{"role": "system", "content": CODEGEN_SYSTEM_PROMPTS[task]},
|
|
1316
|
+
{"role": "user", "content": "\n".join(user)},
|
|
1317
|
+
]
|
|
1318
|
+
try:
|
|
1319
|
+
reply = call_seat_chat(seat, messages)
|
|
1320
|
+
except Exception as e:
|
|
1321
|
+
return None, (f"[FAIL] {task} seat unreachable "
|
|
1322
|
+
f"({type(e).__name__}: {e})")
|
|
1323
|
+
content = _clean_codegen_reply(reply)
|
|
1324
|
+
if not content:
|
|
1325
|
+
return None, f"[FAIL] {task} seat returned no usable content"
|
|
1326
|
+
return {
|
|
1327
|
+
"task": task,
|
|
1328
|
+
"path": str(path),
|
|
1329
|
+
"instruction": instruction,
|
|
1330
|
+
"content": content,
|
|
1331
|
+
"model": seat[1],
|
|
1332
|
+
"seat": seat,
|
|
1333
|
+
}, None
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def render_codegen_diff(path, current, proposed):
|
|
1337
|
+
"""A unified diff for display. Colour never enters staged data."""
|
|
1338
|
+
diff = difflib.unified_diff(
|
|
1339
|
+
current.splitlines(True), proposed.splitlines(True),
|
|
1340
|
+
fromfile=f"{path} (current)",
|
|
1341
|
+
tofile=f"{path} (model proposal)")
|
|
1342
|
+
lines = list(diff)
|
|
1343
|
+
if not lines:
|
|
1344
|
+
return "(no changes — the proposal matches the current file)"
|
|
1345
|
+
use_color = sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
1346
|
+
if not use_color:
|
|
1347
|
+
return "".join(lines).rstrip("\n")
|
|
1348
|
+
colored = []
|
|
1349
|
+
for line in lines:
|
|
1350
|
+
stripped = line.rstrip("\n")
|
|
1351
|
+
if stripped.startswith("+") and not stripped.startswith("+++"):
|
|
1352
|
+
colored.append(f"\033[32m{stripped}\033[0m")
|
|
1353
|
+
elif stripped.startswith("-") and not stripped.startswith("---"):
|
|
1354
|
+
colored.append(f"\033[31m{stripped}\033[0m")
|
|
1355
|
+
elif stripped.startswith("@@"):
|
|
1356
|
+
colored.append(f"\033[36m{stripped}\033[0m")
|
|
1357
|
+
else:
|
|
1358
|
+
colored.append(stripped)
|
|
1359
|
+
return "\n".join(colored)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def confirm_codegen_write(target):
|
|
1363
|
+
"""Codegen's double ask: plain yes, then name the file in type."""
|
|
1364
|
+
try:
|
|
1365
|
+
first = input(
|
|
1366
|
+
f"⚠️ write the model's proposal to {target}? [y/N] ")
|
|
1367
|
+
except (KeyboardInterrupt, EOFError):
|
|
1368
|
+
return False
|
|
1369
|
+
if first.strip().lower() != "y":
|
|
1370
|
+
return False
|
|
1371
|
+
try:
|
|
1372
|
+
typed = input(f"confirm by typing: yes, write {target}\n> ")
|
|
1373
|
+
except (KeyboardInterrupt, EOFError):
|
|
1374
|
+
return False
|
|
1375
|
+
return typed.strip() == f"yes, write {target}"
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def stage_codegen_content(target, content):
|
|
1379
|
+
"""Stage exact content through writewiz. Returns (ok, detail)."""
|
|
1380
|
+
try:
|
|
1381
|
+
from wiz_central_toolkit.writewiz.write_wizard import \
|
|
1382
|
+
propose as _propose
|
|
1383
|
+
draft, _meta, t = _propose(str(target), content)
|
|
1384
|
+
return True, f"{draft.name} -> {t} ({len(content)} chars quarantined)"
|
|
1385
|
+
except ImportError:
|
|
1386
|
+
pass
|
|
1387
|
+
except OSError as e:
|
|
1388
|
+
return False, f"write_wizard propose failed: {e}"
|
|
1389
|
+
rc, out = call_sibling(["write_wizard", "propose", str(target)],
|
|
1390
|
+
stdin=content)
|
|
1391
|
+
if rc != 0:
|
|
1392
|
+
return False, out or "write_wizard propose failed"
|
|
1393
|
+
return True, out
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def land_codegen_content(target):
|
|
1397
|
+
"""Land a staged draft through writewiz commit. Returns (ok, detail).
|
|
1398
|
+
|
|
1399
|
+
The person's live double ask has just named this file; that is
|
|
1400
|
+
the consent commit records. Writewiz still runs its own gate,
|
|
1401
|
+
backup, journal, and post-write verification inside the wall.
|
|
1402
|
+
"""
|
|
1403
|
+
try:
|
|
1404
|
+
from wiz_central_toolkit.writewiz import write_wizard as _ww
|
|
1405
|
+
except ImportError:
|
|
1406
|
+
return False, ("write_wizard not importable — the staged proposal "
|
|
1407
|
+
f"waits in quarantine; land it with: "
|
|
1408
|
+
f"write_wizard commit {target}")
|
|
1409
|
+
draft, meta = _ww._draft_path(Path(target))
|
|
1410
|
+
if not draft.exists():
|
|
1411
|
+
return False, "no staged draft found in write_wizard's quarantine"
|
|
1412
|
+
ok, msg, _t = _ww.commit(
|
|
1413
|
+
draft, meta, confirm_reader=lambda prompt="": "y")
|
|
1414
|
+
return ok, msg
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def build_tool_manifest():
|
|
1418
|
+
"""Real fleet roster from disk — no fabricated commands."""
|
|
1419
|
+
_, names = fleet_census()
|
|
1420
|
+
# names only — probe hints bloated the context; the oracle doesn't need them
|
|
1421
|
+
return ", ".join(names)
|
|
1422
|
+
|
|
1423
|
+
def oracle_tool_loop(question, model, memory, max_rounds=5):
|
|
1424
|
+
"""Chat with hands: tool calls execute REAL wizards, results feed back in.
|
|
1425
|
+
Returns (final_reply, memory). Memory is this session's conversation.
|
|
1426
|
+
`model` is a seat (provider, model); a bare name still works and
|
|
1427
|
+
means that Ollama model."""
|
|
1428
|
+
import os as _os
|
|
1429
|
+
if _os.environ.get("WIZ_AI_PROVIDER", "").strip().lower() == "none":
|
|
1430
|
+
return ("[FAIL] AI disabled (WIZ_AI_PROVIDER=none)", memory)
|
|
1431
|
+
seat = model if isinstance(model, tuple) else ("ollama", model)
|
|
1432
|
+
if not memory:
|
|
1433
|
+
memory.append({"role": "system",
|
|
1434
|
+
"content": ORACLE_SPINE + "\nAVAILABLE TOOLS:\n" + build_tool_manifest()})
|
|
1435
|
+
memory.append({"role": "user", "content": question})
|
|
1436
|
+
|
|
1437
|
+
for round_i in range(max_rounds):
|
|
1438
|
+
try:
|
|
1439
|
+
reply = call_seat_chat(seat, memory)
|
|
1440
|
+
except Exception as e:
|
|
1441
|
+
return (f"[FAIL] oracle unreachable ({type(e).__name__}: {e})", memory)
|
|
1442
|
+
memory.append({"role": "assistant", "content": reply})
|
|
1443
|
+
|
|
1444
|
+
tool_line = None
|
|
1445
|
+
for line in reply.splitlines():
|
|
1446
|
+
if line.strip().upper().startswith("TOOL:"):
|
|
1447
|
+
tool_line = line.strip()[5:].strip()
|
|
1448
|
+
break
|
|
1449
|
+
if not tool_line:
|
|
1450
|
+
return reply, memory # a plain answer — done
|
|
1451
|
+
|
|
1452
|
+
parts = [word.replace("~", __import__("os").path.expanduser("~"))
|
|
1453
|
+
if word.startswith("~") else word
|
|
1454
|
+
for word in tool_line.split()]
|
|
1455
|
+
if not parts:
|
|
1456
|
+
continue
|
|
1457
|
+
|
|
1458
|
+
# consent wall, at its true scope: staging and read lanes
|
|
1459
|
+
# run free and never prompt; a write-class lane asks twice
|
|
1460
|
+
# (the same test godfatherwiz run lives by).
|
|
1461
|
+
if pipeline_is_write_class(parts):
|
|
1462
|
+
if not _oracle_write_consent(tool_line, parts):
|
|
1463
|
+
memory.append({"role": "user", "content":
|
|
1464
|
+
"GOVERNOR DENIED this action. Proceed without it."})
|
|
1465
|
+
continue
|
|
1466
|
+
|
|
1467
|
+
print(f"🔧 {tool_line}")
|
|
1468
|
+
|
|
1469
|
+
# INTERNAL FIRST — godfatherwiz's own in-house hands (the
|
|
1470
|
+
# senses, the seats answer) execute in-process. They are
|
|
1471
|
+
# always lawful: the fleet census must never be able to
|
|
1472
|
+
# reject godfatherwiz himself.
|
|
1473
|
+
internal = call_internal(parts)
|
|
1474
|
+
|
|
1475
|
+
# FLEET WHITELIST — only real citizens may act. No shell reach-arounds.
|
|
1476
|
+
# Common shell verbs are TRANSLATED to their lawful fleet equivalents.
|
|
1477
|
+
SHELL_TRANSLATIONS = {
|
|
1478
|
+
"ls": ["godfather_wizard", "listing"],
|
|
1479
|
+
"dir": ["godfather_wizard", "listing"],
|
|
1480
|
+
"cat": ["godfather_wizard", "read"],
|
|
1481
|
+
"head": ["godfather_wizard", "head"],
|
|
1482
|
+
"pwd": ["godfather_wizard", "brief"],
|
|
1483
|
+
"find": ["godfather_wizard", "find"],
|
|
1484
|
+
"du": ["godfather_wizard", "du"],
|
|
1485
|
+
"stat": ["godfather_wizard", "stat"],
|
|
1486
|
+
"grep": ["godfather_wizard", "read"],
|
|
1487
|
+
}
|
|
1488
|
+
if internal is None:
|
|
1489
|
+
fleet_names = fleet_invocable_names()
|
|
1490
|
+
if parts[0] not in fleet_names:
|
|
1491
|
+
if parts[0] in SHELL_TRANSLATIONS:
|
|
1492
|
+
new_parts = SHELL_TRANSLATIONS[parts[0]] + parts[1:]
|
|
1493
|
+
print(f"[TRANSLATE] '{parts[0]}' → '{' '.join(new_parts[:2])}' (lawful fleet equivalent)")
|
|
1494
|
+
parts = new_parts
|
|
1495
|
+
internal = call_internal(parts)
|
|
1496
|
+
else:
|
|
1497
|
+
memory.append({"role": "user", "content":
|
|
1498
|
+
f"REJECTED: '{parts[0]}' is NOT a fleet tool. Shell commands are "
|
|
1499
|
+
f"forbidden. Your ONLY way to see files is exactly this line:\n"
|
|
1500
|
+
f"TOOL: godfather_wizard listing <path>\n"
|
|
1501
|
+
f"Emit that line now with the real path, or answer plainly."})
|
|
1502
|
+
print(f"[REJECT] '{parts[0]}' is not a citizen — bouncing back")
|
|
1503
|
+
continue
|
|
1504
|
+
if parts[0] not in fleet_names:
|
|
1505
|
+
memory.append({"role": "user", "content":
|
|
1506
|
+
f"REJECTED: '{parts[0]}' is not a fleet tool. Answer plainly instead."})
|
|
1507
|
+
print(f"[REJECT] '{parts[0]}' is not a citizen — bouncing back")
|
|
1508
|
+
continue
|
|
1509
|
+
|
|
1510
|
+
# DUPLICATE SHIELD — same command twice = answer from what we already have
|
|
1511
|
+
sig = " ".join(parts)
|
|
1512
|
+
if not hasattr(oracle_tool_loop, "_last_calls"):
|
|
1513
|
+
oracle_tool_loop._last_calls = set()
|
|
1514
|
+
if sig in oracle_tool_loop._last_calls:
|
|
1515
|
+
oracle_tool_loop._dup_count = getattr(oracle_tool_loop, "_dup_count", 0) + 1
|
|
1516
|
+
if oracle_tool_loop._dup_count >= 2:
|
|
1517
|
+
# small brains can't pivot — present the truth ourselves, honestly
|
|
1518
|
+
last_result = getattr(oracle_tool_loop, "_last_result", "(none)")
|
|
1519
|
+
print("[FORCED SUMMARY] oracle won't stop repeating — presenting raw truth")
|
|
1520
|
+
return ("[RAW TRUTH — oracle could not summarize, presented verbatim]\n"
|
|
1521
|
+
+ last_result, memory)
|
|
1522
|
+
memory.append({"role": "user", "content":
|
|
1523
|
+
f"DUPLICATE: you already ran '{sig}'. STOP calling tools. Your next "
|
|
1524
|
+
f"reply must be a PLAIN ANSWER with no TOOL: line. Summarize the "
|
|
1525
|
+
f"result you already received. Nothing else."})
|
|
1526
|
+
print("[DEDUP] identical tool call suppressed — final warning issued")
|
|
1527
|
+
continue
|
|
1528
|
+
oracle_tool_loop._last_calls.add(sig)
|
|
1529
|
+
|
|
1530
|
+
if internal is not None:
|
|
1531
|
+
rc, out = internal
|
|
1532
|
+
else:
|
|
1533
|
+
rc, out = call_sibling(parts)
|
|
1534
|
+
result = (out or "").strip() or "(no output)"
|
|
1535
|
+
oracle_tool_loop._last_result = result
|
|
1536
|
+
|
|
1537
|
+
# CONTEXT DIET — small seats have 4096-token windows; feed a digest,
|
|
1538
|
+
# not the raw firehose. Tell the oracle it was truncated.
|
|
1539
|
+
MAX_RESULT_LINES = 60
|
|
1540
|
+
lines = result.splitlines()
|
|
1541
|
+
truncated_note = ""
|
|
1542
|
+
if len(lines) > MAX_RESULT_LINES:
|
|
1543
|
+
result = "\n".join(lines[:MAX_RESULT_LINES])
|
|
1544
|
+
truncated_note = ("\n(result truncated to first "
|
|
1545
|
+
f"{MAX_RESULT_LINES} of {len(lines)} lines)")
|
|
1546
|
+
|
|
1547
|
+
memory.append({"role": "user", "content":
|
|
1548
|
+
f"TOOL RESULT ({tool_line}, exit {rc}):\n{result}"
|
|
1549
|
+
+ truncated_note})
|
|
1550
|
+
|
|
1551
|
+
return ("[FAIL] oracle exhausted tool rounds without a plain answer "
|
|
1552
|
+
"(run /model to pick a stronger seat)", memory)
|