@allansantos-dev/smart-tool 0.9.29 → 0.9.32
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.
- package/CHANGELOG.md +62 -0
- package/README.md +16 -4
- package/autostart.py +4 -0
- package/config.py +1 -0
- package/model_client.py +27 -2
- package/model_defaults.py +9 -5
- package/model_health.py +164 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- package/scout.py +307 -0
- package/setup_ui.py +125 -2
- package/smart_tool_daemon.py +211 -11
- package/toast_notice.py +99 -0
- package/tray.py +39 -0
- package/usage_meter.py +1 -1
- package/version.py +1 -1
package/scout.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""smart_scout: answers a question about a project's code with the literal lines that answer it, read from disk.
|
|
2
|
+
|
|
3
|
+
The heavy model of the session passes the question; the daemon finds the candidates (the index when it is ready, the
|
|
4
|
+
keyword scan of the disk when it is not), reads the current lines of each candidate, keeps the window with most query
|
|
5
|
+
terms and returns `path:lines` plus those lines verbatim. With a `scout_model` configured, a light model of the Smart
|
|
6
|
+
Tool's own gateway also writes a short answer and picks which lines to show; what it writes never replaces the literal
|
|
7
|
+
lines, which always come from the file on disk. Without an index the model gets two fixed calls (terms and folders to
|
|
8
|
+
scan, then the answer); with an index, one.
|
|
9
|
+
|
|
10
|
+
Market pattern copied (docs/research/2026-10-24-scout-modelo-leve-no-smart-tool.md): read-only, step limit, return
|
|
11
|
+
`path:lines` with the excerpt (Windsurf Fast Context, Relace, Claude Code Explore); the light model is fed only the
|
|
12
|
+
excerpts, never an agent prompt, so no agent context is reopened per call.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
import document_text
|
|
18
|
+
import index_scope
|
|
19
|
+
import indexer
|
|
20
|
+
|
|
21
|
+
MAX_QUESTION_CHARS = 2000
|
|
22
|
+
MAX_TERMS_CHARS = 400
|
|
23
|
+
DEFAULT_MAX_FILES = 5
|
|
24
|
+
MAX_FILES = 10
|
|
25
|
+
WINDOWS_PER_FILE = 2
|
|
26
|
+
LEXICAL_WINDOW_LINES = 24
|
|
27
|
+
MODEL_WINDOW_LINES = 60
|
|
28
|
+
CITED_MAX_LINES = 40
|
|
29
|
+
UNCITED_WINDOWS = 2
|
|
30
|
+
LITERAL_BUDGET_CHARS = 9000
|
|
31
|
+
LINE_MAX_CHARS = 240
|
|
32
|
+
ANSWER_MAX_CHARS = 1200
|
|
33
|
+
PLAN_MAX_TERMS = 12
|
|
34
|
+
PLAN_MAX_FOLDERS = 6
|
|
35
|
+
OUTLINE_MAX_DIRS = 300
|
|
36
|
+
OUTLINE_MAX_ROOT_FILES = 200
|
|
37
|
+
_FIELDS = {"project_root", "question", "terms", "max_files"}
|
|
38
|
+
_WORD = re.compile(r"[\w]{3,}", re.UNICODE)
|
|
39
|
+
_STOPWORDS = {
|
|
40
|
+
"the", "and", "for", "how", "what", "where", "which", "does", "with", "from", "that", "this", "are", "when", "who",
|
|
41
|
+
"why", "into", "used", "use", "uses", "its", "has", "have", "code", "project", "file", "files",
|
|
42
|
+
"como", "onde", "qual", "quais", "que", "quem", "por", "para", "com", "uma", "dos", "das", "nos", "nas", "pelo",
|
|
43
|
+
"pela", "ele", "ela", "isso", "esse", "essa", "este", "esta", "fica", "ficam", "usa", "usado", "usada", "sobre",
|
|
44
|
+
"entre", "depois", "antes", "quando", "cada", "seu", "sua", "seus", "suas", "não", "sem", "mais", "código",
|
|
45
|
+
"projeto", "arquivo", "arquivos", "decide", "funciona",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
PLAN_SYSTEM = (
|
|
49
|
+
"You plan a keyword scan of a code project that has no search index yet. Given a question and the project's "
|
|
50
|
+
"folders and root files, return the identifier-style terms most likely written in the code that answers it "
|
|
51
|
+
"(function, class, variable, config names; split camelCase is fine; both the code's language and the question's) "
|
|
52
|
+
"and the folders worth scanning (only names from the list; the root files are always scanned). Call submit_scan_plan."
|
|
53
|
+
)
|
|
54
|
+
PLAN_TOOL = {"type": "function", "function": {
|
|
55
|
+
"name": "submit_scan_plan",
|
|
56
|
+
"description": "Terms to look for in file contents and the folders to scan.",
|
|
57
|
+
"parameters": {"type": "object", "additionalProperties": False, "required": ["terms", "folders"], "properties": {
|
|
58
|
+
"terms": {"type": "array", "items": {"type": "string"}, "maxItems": PLAN_MAX_TERMS},
|
|
59
|
+
"folders": {"type": "array", "items": {"type": "string"}, "maxItems": PLAN_MAX_FOLDERS}}}}}
|
|
60
|
+
ANSWER_SYSTEM = (
|
|
61
|
+
"You answer a question about a code project using ONLY the numbered excerpts provided (each line starts with its "
|
|
62
|
+
"line number). Write at most 5 short lines, citing excerpts as [E1], [E2]. Then cite the excerpts that answer the "
|
|
63
|
+
"question with the narrowest line range inside each one (at most 40 lines). If no excerpt answers it, set found to "
|
|
64
|
+
"false and say what is missing; never guess beyond the excerpts. Answer in the language of the question. "
|
|
65
|
+
"Call submit_scout_answer."
|
|
66
|
+
)
|
|
67
|
+
ANSWER_TOOL = {"type": "function", "function": {
|
|
68
|
+
"name": "submit_scout_answer",
|
|
69
|
+
"description": "Short answer and the excerpt line ranges that support it.",
|
|
70
|
+
"parameters": {"type": "object", "additionalProperties": False, "required": ["found", "answer", "citations"],
|
|
71
|
+
"properties": {
|
|
72
|
+
"found": {"type": "boolean"},
|
|
73
|
+
"answer": {"type": "string"},
|
|
74
|
+
"citations": {"type": "array", "maxItems": MAX_FILES * WINDOWS_PER_FILE, "items": {
|
|
75
|
+
"type": "object", "additionalProperties": False, "required": ["id", "start_line", "end_line"],
|
|
76
|
+
"properties": {"id": {"type": "string"}, "start_line": {"type": "integer"},
|
|
77
|
+
"end_line": {"type": "integer"}}}}}}}}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def arguments(raw):
|
|
81
|
+
"""Validated `{"project_root", "question", "terms", "max_files"}` from the tool call; raises ValueError naming the
|
|
82
|
+
field. `terms` (identifier words the caller already knows) is optional and joins the question in the search."""
|
|
83
|
+
if not isinstance(raw, dict):
|
|
84
|
+
raise ValueError("smart_scout requires its arguments as a JSON object")
|
|
85
|
+
unknown = set(raw) - _FIELDS
|
|
86
|
+
if unknown:
|
|
87
|
+
raise ValueError(f"Unknown fields in smart_scout: {', '.join(sorted(unknown))}. Accepted: {', '.join(sorted(_FIELDS))}.")
|
|
88
|
+
root = raw.get("project_root")
|
|
89
|
+
if not isinstance(root, str) or not os.path.isdir(root):
|
|
90
|
+
raise ValueError("smart_scout requires 'project_root' pointing to an existing folder")
|
|
91
|
+
question = raw.get("question")
|
|
92
|
+
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
93
|
+
raise ValueError(f"smart_scout requires 'question' (non-empty text of at most {MAX_QUESTION_CHARS} characters)")
|
|
94
|
+
terms = raw.get("terms")
|
|
95
|
+
if terms is not None and (not isinstance(terms, str) or len(terms) > MAX_TERMS_CHARS):
|
|
96
|
+
raise ValueError(f"terms must be text of at most {MAX_TERMS_CHARS} characters")
|
|
97
|
+
max_files = raw.get("max_files", DEFAULT_MAX_FILES)
|
|
98
|
+
if type(max_files) is not int or not 1 <= max_files <= MAX_FILES:
|
|
99
|
+
raise ValueError(f"max_files must be an integer from 1 to {MAX_FILES}")
|
|
100
|
+
return {"project_root": root, "question": question.strip(), "terms": (terms or "").strip(), "max_files": max_files}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def focus_terms(*texts):
|
|
104
|
+
"""Casefolded content words of the texts (3+ characters, common question words dropped), camelCase split, in
|
|
105
|
+
order: what a window is scored by."""
|
|
106
|
+
found = []
|
|
107
|
+
for text in texts:
|
|
108
|
+
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", text or "")
|
|
109
|
+
for word in _WORD.findall(spaced):
|
|
110
|
+
folded = word.casefold()
|
|
111
|
+
if folded not in _STOPWORDS and folded not in found:
|
|
112
|
+
found.append(folded)
|
|
113
|
+
return found[:24]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def disk_lines(root, path, start, end):
|
|
117
|
+
"""`[(line_number, text)]` of lines `start..end` of the file as it is on disk now, or None when the file cannot be
|
|
118
|
+
read by the index rules (secret, dotfile, too large, binary, gone) or holds no line text (docx)."""
|
|
119
|
+
if document_text.location_kind(path) != "source_line":
|
|
120
|
+
return None
|
|
121
|
+
data, encoding = indexer.read_indexable(root, path)
|
|
122
|
+
if data is None:
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
lines = data.decode(encoding or "utf-8", "replace").splitlines()
|
|
126
|
+
except LookupError:
|
|
127
|
+
return None
|
|
128
|
+
start = max(1, start)
|
|
129
|
+
end = min(len(lines), end)
|
|
130
|
+
return [(number, lines[number - 1]) for number in range(start, end + 1)] if start <= end else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def best_window(lines, terms, size):
|
|
134
|
+
"""The run of at most `size` consecutive `(number, text)` lines holding most occurrences of `terms` (first run on a
|
|
135
|
+
tie), trimmed of blank edges."""
|
|
136
|
+
if len(lines) <= size:
|
|
137
|
+
window = lines
|
|
138
|
+
else:
|
|
139
|
+
hits = [sum(text.casefold().count(term) for term in terms) for _number, text in lines]
|
|
140
|
+
best, best_at, current = -1, 0, sum(hits[:size])
|
|
141
|
+
for at in range(len(lines) - size + 1):
|
|
142
|
+
if at:
|
|
143
|
+
current += hits[at + size - 1] - hits[at - 1]
|
|
144
|
+
if current > best:
|
|
145
|
+
best, best_at = current, at
|
|
146
|
+
window = lines[best_at:best_at + size]
|
|
147
|
+
while window and not window[0][1].strip():
|
|
148
|
+
window = window[1:]
|
|
149
|
+
while window and not window[-1][1].strip():
|
|
150
|
+
window = window[:-1]
|
|
151
|
+
return window
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def order_candidates(blocks, max_files):
|
|
155
|
+
"""Search rows of the code, doc and test blocks in reading order: code first, with the best doc and test rows kept
|
|
156
|
+
inside the first `max_files` files when the code rows would crowd them out."""
|
|
157
|
+
code, doc, test = (list(blocks.get(name) or []) for name in ("code", "doc", "test"))
|
|
158
|
+
reserved = [rows[0] for rows in (doc, test) if rows]
|
|
159
|
+
head = code[:max(1, max_files - len(reserved))]
|
|
160
|
+
rest = code[len(head):] + doc[1:] + test[1:]
|
|
161
|
+
return head + reserved + rest
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def evidence(root, rows, terms, max_files, window_lines):
|
|
165
|
+
"""Evidence items `{id, path, start, end, lines, symbols}` for the first `max_files` files of `rows` (search rows
|
|
166
|
+
`{path, lines: "a-b", snippet, symbols}`), at most WINDOWS_PER_FILE non-overlapping windows per file, each the
|
|
167
|
+
`window_lines` lines of the chunk with most `terms`, read from disk now. A file that cannot be read from disk keeps
|
|
168
|
+
the indexed snippet with its chunk range."""
|
|
169
|
+
items, files = [], []
|
|
170
|
+
for row in rows:
|
|
171
|
+
path = row["path"]
|
|
172
|
+
try:
|
|
173
|
+
start, end = (int(part) for part in str(row["lines"]).split("-", 1))
|
|
174
|
+
except ValueError:
|
|
175
|
+
continue
|
|
176
|
+
if path not in files:
|
|
177
|
+
if len(files) >= max_files:
|
|
178
|
+
continue
|
|
179
|
+
files.append(path)
|
|
180
|
+
mine = [item for item in items if item["path"] == path]
|
|
181
|
+
if len(mine) >= WINDOWS_PER_FILE or any(item["start"] <= end and start <= item["end"] for item in mine):
|
|
182
|
+
continue
|
|
183
|
+
lines = disk_lines(root, path, start, end)
|
|
184
|
+
if lines:
|
|
185
|
+
window = best_window(lines, terms, window_lines)
|
|
186
|
+
if not window:
|
|
187
|
+
continue
|
|
188
|
+
start, end = window[0][0], window[-1][0]
|
|
189
|
+
else:
|
|
190
|
+
window = [(None, text) for text in str(row.get("snippet") or "").splitlines()[:window_lines]]
|
|
191
|
+
items.append({"id": f"E{len(items) + 1}", "path": path, "start": start, "end": end, "lines": window,
|
|
192
|
+
"symbols": row.get("symbols") or ""})
|
|
193
|
+
return items
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def numbered(lines):
|
|
197
|
+
"""Lines as ` 123| text`, long lines cut at LINE_MAX_CHARS."""
|
|
198
|
+
out = []
|
|
199
|
+
for number, text in lines:
|
|
200
|
+
text = text if len(text) <= LINE_MAX_CHARS else text[:LINE_MAX_CHARS] + " …"
|
|
201
|
+
out.append(f"{number:>6}| {text}" if number is not None else f" | {text}")
|
|
202
|
+
return "\n".join(out)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def outline(root):
|
|
206
|
+
"""`(folders, root_files)` shown to the plan call: folders up to two levels deep and loose files at the root, with
|
|
207
|
+
the index's mechanical exclusions (dependency caches, hidden folders, generated output) already applied."""
|
|
208
|
+
skipped = set(index_scope.mechanical_excludes(root))
|
|
209
|
+
folders = index_scope._structure(root, max_dirs=OUTLINE_MAX_DIRS,
|
|
210
|
+
keep=lambda rel: rel.count("/") < 2 and rel.split("/")[0] not in skipped)
|
|
211
|
+
try:
|
|
212
|
+
names = sorted(os.listdir(root))
|
|
213
|
+
except OSError:
|
|
214
|
+
names = []
|
|
215
|
+
files = [name for name in names if not name.startswith(".") and os.path.isfile(os.path.join(root, name))]
|
|
216
|
+
return folders, files[:OUTLINE_MAX_ROOT_FILES]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def plan_prompt(question, folders, root_files):
|
|
220
|
+
"""User message of the plan call."""
|
|
221
|
+
return ("Question: " + question + "\n\nFolders:\n" + "\n".join(folders or ["(none)"]) +
|
|
222
|
+
"\n\nRoot files:\n" + "\n".join(root_files or ["(none)"]))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def valid_plan(raw, folders):
|
|
226
|
+
"""`(terms, folders)` from the plan call: terms as non-empty words, folders only those listed (none means only the
|
|
227
|
+
root files, which `index_scope.resolve_included_files` always adds); raises ValueError when no usable term
|
|
228
|
+
remains."""
|
|
229
|
+
if not isinstance(raw, dict) or not isinstance(raw.get("terms"), list) or not isinstance(raw.get("folders"), list):
|
|
230
|
+
raise ValueError("the scan plan came back without terms and folders")
|
|
231
|
+
terms = [term.strip() for term in raw["terms"] if isinstance(term, str) and term.strip()][:PLAN_MAX_TERMS]
|
|
232
|
+
if not terms:
|
|
233
|
+
raise ValueError("the scan plan came back without terms")
|
|
234
|
+
allowed = set(folders)
|
|
235
|
+
picked = [folder.strip().strip("/") for folder in raw["folders"] if isinstance(folder, str)]
|
|
236
|
+
return terms, [folder for folder in dict.fromkeys(picked) if folder in allowed][:PLAN_MAX_FOLDERS]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def answer_prompt(question, items):
|
|
240
|
+
"""User message of the answer call: the question and each excerpt with its id, path and numbered lines."""
|
|
241
|
+
parts = [f"Question: {question}"]
|
|
242
|
+
for item in items:
|
|
243
|
+
parts.append(f"[{item['id']}] {item['path']}:{item['start']}-{item['end']}"
|
|
244
|
+
+ (f" ({item['symbols']})" if item["symbols"] else "") + "\n" + numbered(item["lines"]))
|
|
245
|
+
return "\n\n".join(parts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def valid_answer(raw, items):
|
|
249
|
+
"""`(found, answer, citations)` from the answer call. Citations name an excerpt id and a range clipped to that
|
|
250
|
+
excerpt and to CITED_MAX_LINES; unknown ids and empty ranges are dropped. Raises ValueError on a malformed reply."""
|
|
251
|
+
if (not isinstance(raw, dict) or type(raw.get("found")) is not bool or not isinstance(raw.get("answer"), str)
|
|
252
|
+
or not isinstance(raw.get("citations"), list)):
|
|
253
|
+
raise ValueError("the answer came back outside the expected fields")
|
|
254
|
+
by_id = {item["id"]: item for item in items}
|
|
255
|
+
citations = []
|
|
256
|
+
for cite in raw["citations"]:
|
|
257
|
+
if not isinstance(cite, dict) or cite.get("id") not in by_id:
|
|
258
|
+
continue
|
|
259
|
+
item = by_id[cite["id"]]
|
|
260
|
+
try:
|
|
261
|
+
start, end = int(cite.get("start_line")), int(cite.get("end_line"))
|
|
262
|
+
except (TypeError, ValueError):
|
|
263
|
+
continue
|
|
264
|
+
if item["lines"][0][0] is None:
|
|
265
|
+
start, end = item["start"], item["end"]
|
|
266
|
+
start, end = max(start, item["start"]), min(end, item["end"], max(start, item["start"]) + CITED_MAX_LINES - 1)
|
|
267
|
+
if start <= end and not any(c["id"] == item["id"] and c["start"] <= end and start <= c["end"] for c in citations):
|
|
268
|
+
citations.append({"id": item["id"], "start": start, "end": end})
|
|
269
|
+
answer = raw["answer"].strip()[:ANSWER_MAX_CHARS]
|
|
270
|
+
if not answer:
|
|
271
|
+
raise ValueError("the answer came back empty")
|
|
272
|
+
return raw["found"], answer, citations
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def render(question, source, items, citations=None, answer=None, found=None, model_note="", warnings=(), others=()):
|
|
276
|
+
"""The text returned to the agent. Lexical mode (`citations` None): every evidence window, within
|
|
277
|
+
LITERAL_BUDGET_CHARS, after the model's answer when there is one (a model that cited nothing never leaves the
|
|
278
|
+
answer without lines). Model mode: the model's answer, then only the cited ranges (sliced from the windows read
|
|
279
|
+
from disk), and the uncited excerpts as `path:lines` pointers. `others` are candidates not shown, listed as
|
|
280
|
+
pointers."""
|
|
281
|
+
out = [f"question: {question}", f"source: {source}", f"model: {model_note or 'none (literal lines only)'}"]
|
|
282
|
+
if answer is not None:
|
|
283
|
+
out.append(("answer: " if found else "answer (not found in the excerpts): ") + answer)
|
|
284
|
+
shown, budget = [], LITERAL_BUDGET_CHARS
|
|
285
|
+
if citations is None:
|
|
286
|
+
picks = [(item, item["start"], item["end"]) for item in items]
|
|
287
|
+
else:
|
|
288
|
+
by_id = {item["id"]: item for item in items}
|
|
289
|
+
picks = [(by_id[c["id"]], c["start"], c["end"]) for c in citations]
|
|
290
|
+
out.append("evidence:" if picks else "evidence: none")
|
|
291
|
+
for item, start, end in picks:
|
|
292
|
+
lines = [(n, t) for n, t in item["lines"] if n is None or start <= n <= end]
|
|
293
|
+
block = numbered(lines)
|
|
294
|
+
head = f"- [{item['id']}] {item['path']}:{start}-{end}" + (f" ({item['symbols']})" if item["symbols"] else "")
|
|
295
|
+
if len(block) > budget:
|
|
296
|
+
out.append(head + " (lines not shown: output budget reached)")
|
|
297
|
+
continue
|
|
298
|
+
budget -= len(block)
|
|
299
|
+
out += [head, block]
|
|
300
|
+
shown.append(item["id"])
|
|
301
|
+
pointers = [f"{item['path']}:{item['start']}-{item['end']}" for item in items if item["id"] not in shown
|
|
302
|
+
and citations is not None] + list(others)
|
|
303
|
+
if pointers:
|
|
304
|
+
out.append("other candidates (not shown): " + ", ".join(pointers))
|
|
305
|
+
for warning in warnings:
|
|
306
|
+
out.append(f"warning: {warning}")
|
|
307
|
+
return "\n".join(out) + "\n"
|
package/setup_ui.py
CHANGED
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
20
|
import secrets
|
|
21
|
+
import time
|
|
21
22
|
|
|
22
23
|
import autostart
|
|
23
24
|
import block_patterns
|
|
@@ -29,6 +30,8 @@ import endpoint_sync
|
|
|
29
30
|
import gateway
|
|
30
31
|
import model_catalog
|
|
31
32
|
import model_defaults
|
|
33
|
+
import model_health
|
|
34
|
+
import model_upgrade
|
|
32
35
|
import local_embedder
|
|
33
36
|
import project_store
|
|
34
37
|
import secret_store
|
|
@@ -79,6 +82,7 @@ def status_payload():
|
|
|
79
82
|
"missing_models": models["missing"],
|
|
80
83
|
"router_model": cfg.get("router_model", ""),
|
|
81
84
|
"scope_model": cfg.get("scope_model", ""),
|
|
85
|
+
"scout_model": cfg.get("scout_model", ""),
|
|
82
86
|
"research_model": cfg.get("research_model", ""),
|
|
83
87
|
"research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
|
|
84
88
|
"quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
|
|
@@ -132,6 +136,7 @@ def handle_models_get():
|
|
|
132
136
|
"current": {
|
|
133
137
|
"router_model": cfg.get("router_model", ""),
|
|
134
138
|
"scope_model": cfg.get("scope_model", ""),
|
|
139
|
+
"scout_model": cfg.get("scout_model", ""),
|
|
135
140
|
"research_model": cfg.get("research_model", ""),
|
|
136
141
|
"research_max_iterations": cfg.get("research_max_iterations", config.DEFAULT_CONFIG["research_max_iterations"]),
|
|
137
142
|
"quick_search_timeout_s": cfg.get("quick_search_timeout_s", config.DEFAULT_CONFIG["quick_search_timeout_s"]),
|
|
@@ -153,6 +158,56 @@ def handle_models_get():
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
|
|
161
|
+
_UPGRADE_CHECK_EVERY_S = 3600
|
|
162
|
+
_upgrade_checked = {"at": 0.0, "error": ""}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _checked_upgrade(force=False):
|
|
166
|
+
"""The model offer, rechecked against the catalog at most once an hour (or when forced). A catalog that cannot be
|
|
167
|
+
read is reported in `check_error`, with the last stored offer, never as 'nothing to offer'."""
|
|
168
|
+
now = time.time()
|
|
169
|
+
if force or now - _upgrade_checked["at"] >= _UPGRADE_CHECK_EVERY_S:
|
|
170
|
+
try:
|
|
171
|
+
model_upgrade.check()
|
|
172
|
+
_upgrade_checked["error"] = ""
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
_upgrade_checked["error"] = str(exc)[:300]
|
|
175
|
+
_upgrade_checked["at"] = now
|
|
176
|
+
return {**model_upgrade.status(), "check_error": _upgrade_checked["error"]}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def handle_upgrade_get():
|
|
180
|
+
return _checked_upgrade(force=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def handle_upgrade_action(body):
|
|
184
|
+
action = body.get("action") if isinstance(body, dict) else None
|
|
185
|
+
try:
|
|
186
|
+
if action == "apply":
|
|
187
|
+
text = model_upgrade.apply()
|
|
188
|
+
elif action == "decline":
|
|
189
|
+
text = model_upgrade.decline()
|
|
190
|
+
elif action == "revert":
|
|
191
|
+
text = model_upgrade.revert()
|
|
192
|
+
else:
|
|
193
|
+
raise SetupError("Invalid action: use apply, decline or revert.")
|
|
194
|
+
except (ValueError, config.ConfigCorruptedError, OSError) as exc:
|
|
195
|
+
raise SetupError(str(exc))
|
|
196
|
+
return {"ok": True, "message": text, "upgrade": model_upgrade.status()}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def handle_health_get():
|
|
200
|
+
return model_health.summary()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_notices():
|
|
204
|
+
"""What the tray should show now: the model offer (once a day until answered) and models over the failure limit
|
|
205
|
+
(once a day each). Fetching marks them announced, so each toast is shown once per reminder period."""
|
|
206
|
+
upgrade = _checked_upgrade()
|
|
207
|
+
offer = model_upgrade.due() if upgrade["state"] == "pending" else None
|
|
208
|
+
return {"upgrade": offer, "failing": model_health.alerts_due(), "check_error": upgrade["check_error"]}
|
|
209
|
+
|
|
210
|
+
|
|
156
211
|
def handle_local_models_get():
|
|
157
212
|
available = local_embedder.info() if local_embedder.configured() else None
|
|
158
213
|
return {"mode": config.local_models_mode(), "modes": list(config.LOCAL_MODEL_MODES),
|
|
@@ -369,6 +424,7 @@ def handle_autostart_action(body):
|
|
|
369
424
|
_MODEL_FIELDS = {
|
|
370
425
|
"router_model": "chat",
|
|
371
426
|
"scope_model": "chat",
|
|
427
|
+
"scout_model": "chat",
|
|
372
428
|
"research_model": "chat",
|
|
373
429
|
"embedding_model": "embedding",
|
|
374
430
|
"rerank_model": "rerank",
|
|
@@ -668,6 +724,24 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
668
724
|
<div class="msg" id="gateway-msg" role="status"></div>
|
|
669
725
|
</div>
|
|
670
726
|
|
|
727
|
+
<div class="card" id="upgrade-card" hidden>
|
|
728
|
+
<div class="card-head"><h3>New recommended models</h3><span class="pill" id="upgrade-pill" data-tone="warn">Available</span></div>
|
|
729
|
+
<p class="lede">This version recommends other models than the ones you use. Nothing changes until you apply; the models you have now are kept so you can switch back.</p>
|
|
730
|
+
<ul id="upgrade-list"></ul>
|
|
731
|
+
<div class="actions">
|
|
732
|
+
<button type="button" class="primary" id="btn-upgrade-apply">Apply recommended models</button>
|
|
733
|
+
<button type="button" id="btn-upgrade-decline">Keep mine, don't ask again</button>
|
|
734
|
+
<button type="button" id="btn-upgrade-revert" hidden>Switch back to my previous models</button>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="msg" id="upgrade-msg" role="status"></div>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<div class="card" id="health-card">
|
|
740
|
+
<div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
|
|
741
|
+
<p class="lede">Every failed call to a gateway model, by role and tool. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
|
|
742
|
+
<div id="health-table"></div>
|
|
743
|
+
</div>
|
|
744
|
+
|
|
671
745
|
<div class="card" id="models-card">
|
|
672
746
|
<div class="card-head"><h3>2. Models</h3><span class="pill" id="models-pill" data-tone="busy">Checking</span></div>
|
|
673
747
|
<p class="lede">Any model id your gateway accepts. The gateway's catalog shows up as suggestions while you type.</p>
|
|
@@ -681,6 +755,7 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
681
755
|
<div class="field"><label for="sel-scope">Index classification</label><span class="explain">Decides scope, code or documentation, and languages while indexing. Runs once per project.</span><input id="sel-scope" list="dl-scope" autocomplete="off" spellcheck="false"><datalist id="dl-scope"></datalist></div>
|
|
682
756
|
<div class="field"><label for="sel-router">Router</label><span class="explain">Decides before each agent Grep, Read or Bash. Use a small model without reasoning: above 10 s routing turns off.</span><input id="sel-router" list="dl-router" autocomplete="off" spellcheck="false"><datalist id="dl-router"></datalist></div>
|
|
683
757
|
</div>
|
|
758
|
+
<div class="field"><label for="sel-scout">Code scout (optional)</label><span class="explain">smart_scout: writes a short answer over the lines it read and keeps only the ones that answer. Empty returns the literal lines without a model.</span><input id="sel-scout" list="dl-scout" autocomplete="off" spellcheck="false"><datalist id="dl-scout"></datalist></div>
|
|
684
759
|
<div class="field"><label for="sel-research">Web research</label><span class="explain">Used by web_search with depth=research. Empty uses the router model.</span><input id="sel-research" list="dl-research" autocomplete="off" spellcheck="false"><datalist id="dl-research"></datalist></div>
|
|
685
760
|
<details class="more">
|
|
686
761
|
<summary>Advanced web research limits</summary>
|
|
@@ -1031,6 +1106,7 @@ async function loadModels() {
|
|
|
1031
1106
|
const data = await getJSON("/setup/models");
|
|
1032
1107
|
fillSuggestions($("sel-router"), data.chat_lightweight.length ? data.chat_lightweight : data.chat, data.current.router_model);
|
|
1033
1108
|
fillSuggestions($("sel-scope"), data.chat, data.current.scope_model);
|
|
1109
|
+
fillSuggestions($("sel-scout"), data.chat_lightweight.length ? data.chat_lightweight : data.chat, data.current.scout_model);
|
|
1034
1110
|
fillSuggestions($("sel-research"), data.chat, data.current.research_model);
|
|
1035
1111
|
fillSuggestions($("sel-embedding"), data.embedding, data.current.embedding_model);
|
|
1036
1112
|
fillSuggestions($("sel-rerank"), data.rerank, data.current.rerank_model);
|
|
@@ -1043,12 +1119,58 @@ async function loadModels() {
|
|
|
1043
1119
|
setMsg("models-msg", "error", "Could not read the gateway catalog: " + error.message);
|
|
1044
1120
|
}
|
|
1045
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
function esc(text) { const el = document.createElement("span"); el.textContent = text; return el.innerHTML; }
|
|
1124
|
+
async function loadUpgrade() {
|
|
1125
|
+
try {
|
|
1126
|
+
const data = await getJSON("/setup/models/upgrade");
|
|
1127
|
+
const rows = Object.entries(data.changes || {}).map(([role, change]) => `<li><b>${esc(role)}</b>: ${esc(change.current)} → ${esc(change.new)}</li>`);
|
|
1128
|
+
const offer = data.state === "pending" && rows.length;
|
|
1129
|
+
const applied = data.state === "applied" && Object.keys(data.previous || {}).length;
|
|
1130
|
+
$("upgrade-card").hidden = !(offer || applied);
|
|
1131
|
+
$("upgrade-list").innerHTML = offer ? rows.join("") : Object.entries(data.previous).map(([role, model]) => `<li><b>${esc(role)}</b> was ${esc(model)}</li>`).join("");
|
|
1132
|
+
setPill("upgrade-pill", offer ? "warn" : "ok", offer ? "Available" : "Applied");
|
|
1133
|
+
$("btn-upgrade-apply").hidden = $("btn-upgrade-decline").hidden = !offer;
|
|
1134
|
+
$("btn-upgrade-revert").hidden = !applied;
|
|
1135
|
+
if (data.check_error) setMsg("upgrade-msg", "error", "Could not check the catalog: " + data.check_error);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
$("upgrade-card").hidden = false;
|
|
1138
|
+
setMsg("upgrade-msg", "error", "Could not read the model offer: " + error.message);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-decline", "decline"], ["btn-upgrade-revert", "revert"]]) {
|
|
1142
|
+
$(id).addEventListener("click", async () => {
|
|
1143
|
+
setMsg("upgrade-msg", "", "Working…");
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await post("/setup/models/upgrade", { action });
|
|
1146
|
+
await loadUpgrade();
|
|
1147
|
+
await loadModels();
|
|
1148
|
+
setMsg("upgrade-msg", "ok", result.message);
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
setMsg("upgrade-msg", "error", error.message);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
async function loadHealth() {
|
|
1155
|
+
try {
|
|
1156
|
+
const data = await getJSON("/setup/models/health");
|
|
1157
|
+
const failing = data.models.filter((row) => row.failing).length;
|
|
1158
|
+
setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
|
|
1159
|
+
const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
|
|
1160
|
+
const failures = data.failures.map((row) => `<tr><td>${esc(new Date(row.at * 1000).toLocaleString())}</td><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
|
|
1161
|
+
$("health-table").innerHTML = (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
|
|
1162
|
+
+ (failures ? `<table><tr><th>When</th><th>Role</th><th>Model</th><th>Tool</th><th>Error</th></tr>${failures}</table>` : "");
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
setPill("health-pill", "bad", "Unavailable");
|
|
1165
|
+
$("health-table").textContent = "Could not read the failure log: " + error.message;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1046
1168
|
$("btn-save-models").addEventListener("click", async () => {
|
|
1047
1169
|
if (!modelsLoaded) { setMsg("models-msg", "error", "The catalog has not loaded yet; nothing was saved."); return; }
|
|
1048
1170
|
setMsg("models-msg", "", "Saving…");
|
|
1049
1171
|
try {
|
|
1050
1172
|
await post("/setup/models", {
|
|
1051
|
-
router_model: $("sel-router").value, scope_model: $("sel-scope").value, research_model: $("sel-research").value,
|
|
1173
|
+
router_model: $("sel-router").value, scope_model: $("sel-scope").value, scout_model: $("sel-scout").value, research_model: $("sel-research").value,
|
|
1052
1174
|
research_max_iterations: Number($("num-research-iterations").value), quick_search_timeout_s: Number($("num-quick-timeout").value),
|
|
1053
1175
|
site_memory_similarity_threshold: Number($("num-site-memory-threshold").value),
|
|
1054
1176
|
embedding_model: $("sel-embedding").value, rerank_model: $("sel-rerank").value,
|
|
@@ -1804,7 +1926,8 @@ $("btn-audit-run").addEventListener("click", async () => {
|
|
|
1804
1926
|
|
|
1805
1927
|
renderGateway();
|
|
1806
1928
|
renderOverview();
|
|
1807
|
-
if (status.gateway.status === "ready") loadModels();
|
|
1929
|
+
if (status.gateway.status === "ready") { loadModels(); loadUpgrade(); }
|
|
1930
|
+
loadHealth();
|
|
1808
1931
|
loadLocalModels();
|
|
1809
1932
|
loadIntegration();
|
|
1810
1933
|
loadAutostart();
|