@semalt-ai/code 1.8.5 → 1.19.0
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/.claude/settings.local.json +6 -1
- package/.github/workflows/ci.yml +69 -0
- package/CLAUDE.md +1584 -26
- package/README.md +147 -3
- package/examples/embed.js +74 -0
- package/index.js +251 -10
- package/lib/agent.js +711 -104
- package/lib/api.js +213 -49
- package/lib/args.js +74 -2
- package/lib/audit.js +23 -1
- package/lib/background.js +584 -0
- package/lib/checkpoints.js +757 -0
- package/lib/commands/auth.js +94 -0
- package/lib/commands/chat-session.js +306 -0
- package/lib/commands/chat-slash.js +399 -0
- package/lib/commands/chat-turn.js +446 -0
- package/lib/commands/chat.js +403 -0
- package/lib/commands/custom.js +157 -0
- package/lib/commands/history-utils.js +66 -0
- package/lib/commands/index.js +268 -0
- package/lib/commands/mcp.js +113 -0
- package/lib/commands/oneshot.js +193 -0
- package/lib/commands/registry.js +269 -0
- package/lib/commands/tasks.js +89 -0
- package/lib/compact.js +87 -0
- package/lib/config.js +333 -11
- package/lib/constants.js +372 -3
- package/lib/deny.js +199 -0
- package/lib/doctor.js +160 -0
- package/lib/headless.js +167 -0
- package/lib/hooks.js +286 -0
- package/lib/images.js +264 -0
- package/lib/internals.js +49 -0
- package/lib/mcp/boundary.js +131 -0
- package/lib/mcp/client.js +270 -0
- package/lib/mcp/oauth.js +134 -0
- package/lib/memory.js +209 -0
- package/lib/metrics.js +37 -2
- package/lib/payload.js +54 -0
- package/lib/permission-rules.js +401 -0
- package/lib/permissions.js +100 -10
- package/lib/pricing.js +67 -0
- package/lib/proc.js +62 -0
- package/lib/prompts.js +84 -5
- package/lib/sandbox.js +568 -0
- package/lib/sdk.js +328 -0
- package/lib/secrets.js +211 -0
- package/lib/skills.js +223 -0
- package/lib/subagents.js +516 -0
- package/lib/tool_registry.js +2558 -0
- package/lib/tool_specs.js +222 -2
- package/lib/tools.js +272 -1020
- package/lib/ui/format.js +22 -1
- package/lib/ui/input-field.js +16 -7
- package/lib/ui/status-bar.js +79 -11
- package/lib/ui/theme.js +1 -0
- package/lib/ui/web-activity.js +218 -0
- package/lib/verify.js +229 -0
- package/lib/web-extract.js +213 -0
- package/lib/web-summarize.js +68 -0
- package/package.json +19 -4
- package/scripts/lint.js +57 -0
- package/test/agent-loop.test.js +389 -0
- package/test/background.test.js +414 -0
- package/test/chat.test.js +114 -0
- package/test/checkpoints-agent.test.js +181 -0
- package/test/checkpoints.test.js +650 -0
- package/test/command-registry.test.js +160 -0
- package/test/compact.test.js +116 -0
- package/test/completion-lazy.test.js +52 -0
- package/test/config-merge.test.js +324 -0
- package/test/config-quarantine.test.js +128 -0
- package/test/config-write-guard-allow-anywhere.test.js +56 -0
- package/test/config-write-guard-skip.test.js +46 -0
- package/test/config-write-guard.test.js +153 -0
- package/test/context-split.test.js +215 -0
- package/test/cost-doctor.test.js +142 -0
- package/test/custom-commands-chat.test.js +106 -0
- package/test/custom-commands.test.js +230 -0
- package/test/deny-windows.test.js +120 -0
- package/test/deny.test.js +83 -0
- package/test/download-allow-anywhere.test.js +66 -0
- package/test/download-confine.test.js +153 -0
- package/test/executors.test.js +362 -0
- package/test/extract-tool-calls.test.js +315 -0
- package/test/fetch-url-validation.test.js +219 -0
- package/test/fixtures/tool-calls.js +57 -0
- package/test/fixtures/web-page.js +91 -0
- package/test/git-tools.test.js +384 -0
- package/test/grep-glob-serialize.test.js +242 -0
- package/test/grep-glob.test.js +268 -0
- package/test/harness/README.md +57 -0
- package/test/harness/chat-harness.js +142 -0
- package/test/harness/memwarn-headless-child.js +65 -0
- package/test/harness/mock-llm.js +120 -0
- package/test/harness/mock-mcp-server.js +142 -0
- package/test/harness/sse-server.js +69 -0
- package/test/headless.test.js +203 -0
- package/test/history-utils.test.js +88 -0
- package/test/hooks-agent.test.js +238 -0
- package/test/hooks-verify-sandbox.test.js +232 -0
- package/test/hooks.test.js +216 -0
- package/test/http-get-user-agent.test.js +142 -0
- package/test/images-api.test.js +208 -0
- package/test/images.test.js +238 -0
- package/test/max-iterations.test.js +216 -0
- package/test/mcp-boundary.test.js +57 -0
- package/test/mcp-client.test.js +267 -0
- package/test/mcp-oauth.test.js +86 -0
- package/test/memory-truncation-warning.test.js +222 -0
- package/test/memory.test.js +198 -0
- package/test/native-dispatch.test.js +356 -0
- package/test/output-chokepoint.test.js +188 -0
- package/test/path-guards.test.js +134 -0
- package/test/payload.test.js +99 -0
- package/test/permission-rules-agent.test.js +210 -0
- package/test/permission-rules.test.js +297 -0
- package/test/permissions.test.js +163 -0
- package/test/plan-mode.test.js +167 -0
- package/test/read-paginate.test.js +275 -0
- package/test/readonly-tools.test.js +177 -0
- package/test/result-cap.test.js +233 -0
- package/test/sandbox-agent.test.js +147 -0
- package/test/sandbox-integration.test.js +216 -0
- package/test/sandbox.test.js +408 -0
- package/test/sdk.test.js +234 -0
- package/test/shell-output-cap.test.js +181 -0
- package/test/skills-chat.test.js +110 -0
- package/test/skills.test.js +295 -0
- package/test/smoke.test.js +68 -0
- package/test/status-bar-pause.test.js +164 -0
- package/test/stream-parser.test.js +147 -0
- package/test/subagents-agent.test.js +178 -0
- package/test/subagents.test.js +222 -0
- package/test/tool-registry.test.js +85 -0
- package/test/trim-budget.test.js +101 -0
- package/test/verify-agent.test.js +317 -0
- package/test/verify.test.js +141 -0
- package/test/web-activity-ordering.test.js +194 -0
- package/test/web-activity.test.js +207 -0
- package/test/web-data-extraction-guidance.test.js +71 -0
- package/test/web-extract.test.js +185 -0
- package/test/web-fetch-agent.test.js +291 -0
- package/test/web-fetch-mode.test.js +193 -0
- package/test/web-search.test.js +380 -0
- package/lib/commands.js +0 -1438
package/lib/ui/format.js
CHANGED
|
@@ -153,7 +153,28 @@ function _operation(tag, arg, attrs) {
|
|
|
153
153
|
case 'recall_memory': return _truncate(`recall ${a.key || arg || ''}`, max);
|
|
154
154
|
case 'list_memories': return 'list memories';
|
|
155
155
|
case 'system_info': return 'system info';
|
|
156
|
-
default:
|
|
156
|
+
default:
|
|
157
|
+
if (_normalizeTag(tag).startsWith('git_')) return _truncate(_gitOperation(_normalizeTag(tag), a), max);
|
|
158
|
+
// arg may be a structured options object for newer tools — avoid rendering
|
|
159
|
+
// "[object Object]" by only appending string/number args.
|
|
160
|
+
return _truncate((arg && typeof arg !== 'object') ? `${tag} ${arg}` : tag, max);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Human-readable one-liner for the native git tools (Task 5.1). `a` is the
|
|
165
|
+
// options object (attrs) carried by the call.
|
|
166
|
+
function _gitOperation(tag, a) {
|
|
167
|
+
const paths = Array.isArray(a.paths) ? a.paths.join(' ') : (a.paths || '');
|
|
168
|
+
switch (tag) {
|
|
169
|
+
case 'git_status': return 'git status';
|
|
170
|
+
case 'git_diff': return `git diff${a.staged ? ' --staged' : ''}${a.path ? ' ' + a.path : ''}`;
|
|
171
|
+
case 'git_log': return `git log${a.count ? ' -n ' + a.count : ''}${a.path ? ' ' + a.path : ''}`;
|
|
172
|
+
case 'git_add': return `git add ${a.all ? '-A' : paths}`.trim();
|
|
173
|
+
case 'git_commit': return `git commit${a.all ? ' -a' : ''} -m "${normalizeCmdForDisplay(a.message || '')}"`;
|
|
174
|
+
case 'git_branch': return a.name ? `git branch ${a.delete ? '-d ' : ''}${a.name}` : 'git branch';
|
|
175
|
+
case 'git_checkout': return `git checkout ${a.create ? '-b ' : ''}${a.name || ''}`.trim();
|
|
176
|
+
case 'git_worktree': return `git worktree ${a.op || 'list'}${a.path ? ' ' + a.path : ''}`;
|
|
177
|
+
default: return tag;
|
|
157
178
|
}
|
|
158
179
|
}
|
|
159
180
|
|
package/lib/ui/input-field.js
CHANGED
|
@@ -7,10 +7,13 @@ const { RST, DIM, FG_CYAN, FG_GREEN, FG_YELLOW } = require('./ansi');
|
|
|
7
7
|
const { stripAnsi, termWidth } = require('./utils');
|
|
8
8
|
const writer = require('./writer');
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// Tab-completion command list is generated from the slash-command registry —
|
|
11
|
+
// the single source of truth — so it can never drift from the dispatcher.
|
|
12
|
+
// Resolved LIVE (not snapshotted at load) so commands registered after this
|
|
13
|
+
// module loads — notably the Markdown custom commands discovered at chat
|
|
14
|
+
// startup (Task 3.1) — appear in completion. The exported `SLASH_CMDS` is kept
|
|
15
|
+
// for back-compat (since 1.3) but backed by the same live getter.
|
|
16
|
+
const { completionNames } = require('../commands/registry');
|
|
14
17
|
|
|
15
18
|
// ─── Key sequence parser ──────────────────────────────────────────────────────
|
|
16
19
|
|
|
@@ -571,7 +574,7 @@ class InputField extends EventEmitter {
|
|
|
571
574
|
sources.push({ type: 'history', text });
|
|
572
575
|
for (const item of this._searchExtraItems)
|
|
573
576
|
sources.push(item);
|
|
574
|
-
for (const cmd of
|
|
577
|
+
for (const cmd of completionNames())
|
|
575
578
|
sources.push({ type: 'command', text: cmd });
|
|
576
579
|
return sources;
|
|
577
580
|
}
|
|
@@ -635,7 +638,7 @@ class InputField extends EventEmitter {
|
|
|
635
638
|
}
|
|
636
639
|
|
|
637
640
|
if (!val.startsWith('/')) return;
|
|
638
|
-
const matches =
|
|
641
|
+
const matches = completionNames().filter(c => c.startsWith(val));
|
|
639
642
|
if (!matches.length) return;
|
|
640
643
|
if (matches.length === 1) {
|
|
641
644
|
this._setValue(matches[0] + ' ');
|
|
@@ -1266,4 +1269,10 @@ class InputField extends EventEmitter {
|
|
|
1266
1269
|
}
|
|
1267
1270
|
}
|
|
1268
1271
|
|
|
1269
|
-
module.exports = { InputField, parseKeySequence
|
|
1272
|
+
module.exports = { InputField, parseKeySequence };
|
|
1273
|
+
// SLASH_CMDS kept for back-compat (since 1.3), but now a live getter so callers
|
|
1274
|
+
// reading it after custom-command registration see the current set.
|
|
1275
|
+
Object.defineProperty(module.exports, 'SLASH_CMDS', {
|
|
1276
|
+
enumerable: true,
|
|
1277
|
+
get() { return completionNames(); },
|
|
1278
|
+
});
|
package/lib/ui/status-bar.js
CHANGED
|
@@ -4,6 +4,17 @@ const { RST, DIM, FG_RED, FG_DARK, SPINNER_DEFS } = require('./ansi');
|
|
|
4
4
|
const { UI_THEME } = require('./theme');
|
|
5
5
|
const { stripAnsi, termWidth } = require('./utils');
|
|
6
6
|
|
|
7
|
+
// Compact token count for the estimated split (Variant B): the base/working
|
|
8
|
+
// estimates are shown abbreviated (e.g. 12k, 5.6k, 200k) to keep the row short
|
|
9
|
+
// — they are estimates, so sub-thousand precision is noise. The real measured
|
|
10
|
+
// total is rendered separately with full toLocaleString precision (no ~).
|
|
11
|
+
function abbrevTokens(n) {
|
|
12
|
+
const v = Math.max(0, Math.round(Number(n) || 0));
|
|
13
|
+
if (v < 1000) return String(v);
|
|
14
|
+
const k = v / 1000;
|
|
15
|
+
return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
// Status bar is a *content producer* only. It builds a single line string
|
|
8
19
|
// and hands it to the UI orchestrator via the onChange callback; the
|
|
9
20
|
// orchestrator composes the full live region (status + input + hints) and
|
|
@@ -27,6 +38,11 @@ class FullStatusBar {
|
|
|
27
38
|
this._reportedContext = 0;
|
|
28
39
|
this._pendingDelta = 0;
|
|
29
40
|
this._contextLimit = null;
|
|
41
|
+
// Estimated base/working split (Variant B, display-only). Both are char/4
|
|
42
|
+
// estimates of the same measured prompt; rendered ~-prefixed alongside the
|
|
43
|
+
// real total. 0 until the agent loop reports the first estimate.
|
|
44
|
+
this._baseEst = 0;
|
|
45
|
+
this._workingEst = 0;
|
|
30
46
|
this._speed = 0;
|
|
31
47
|
this._streamStart = null;
|
|
32
48
|
this._streamTokens = 0;
|
|
@@ -37,22 +53,53 @@ class FullStatusBar {
|
|
|
37
53
|
// Clock tick drives the `HH:MM:SS` part of the right-hand side. Every
|
|
38
54
|
// tick just notifies the orchestrator to re-push the live region — the
|
|
39
55
|
// compound erase+redraw goes through the writer's queue so a tick
|
|
40
|
-
// falling mid-bubble can't produce a torn frame.
|
|
56
|
+
// falling mid-bubble can't produce a torn frame. The tick is what fights
|
|
57
|
+
// user scrollback while idle, so pause()/resume() START/STOP this timer
|
|
58
|
+
// (see _startClock/_stopClock) rather than gating _notify with a flag.
|
|
59
|
+
this._clockTimer = null;
|
|
60
|
+
this._startClock();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Idempotent clock control. pause() stops the once-per-second redraw so the
|
|
64
|
+
// terminal viewport can scroll freely while idle; resume() restarts it.
|
|
65
|
+
// Guarded so repeated pause()/resume() cycles never stack a second timer.
|
|
66
|
+
_startClock() {
|
|
67
|
+
if (this._clockTimer) return;
|
|
41
68
|
this._clockTimer = setInterval(() => this._notify(), 1000);
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
|
|
45
|
-
|
|
71
|
+
_stopClock() {
|
|
72
|
+
if (this._clockTimer) { clearInterval(this._clockTimer); this._clockTimer = null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// pause() genuinely halts the periodic redraw (clears the clock timer) so a
|
|
76
|
+
// forced redraw no longer snaps the viewport to the bottom while the user
|
|
77
|
+
// scrolls up. Event-driven redraws (update/updateMetrics/setCost/spinner)
|
|
78
|
+
// are unaffected — they call _notify directly. resume() restarts the clock
|
|
79
|
+
// and forces one repaint so the viewport returns to the input prompt.
|
|
80
|
+
pause() { this._paused = true; this._stopClock(); }
|
|
81
|
+
resume() { this._paused = false; this._startClock(); this._notify(); }
|
|
46
82
|
|
|
47
83
|
setModel(name) {
|
|
48
84
|
this._model = name || '';
|
|
49
85
|
this._notify();
|
|
50
86
|
}
|
|
51
87
|
|
|
88
|
+
// Session cost string (e.g. "$0.0123" or "unknown"), shown when show_cost is
|
|
89
|
+
// on (Task 2.6). null/'' hides the field. The caller computes the value from
|
|
90
|
+
// the price table × usage; the bar just renders it.
|
|
91
|
+
setCost(str) {
|
|
92
|
+
this._cost = str || null;
|
|
93
|
+
this._notify();
|
|
94
|
+
}
|
|
95
|
+
|
|
52
96
|
update(state, label) {
|
|
53
97
|
this._state = state || 'idle';
|
|
54
98
|
if (label !== undefined) this._label = label;
|
|
99
|
+
// A state change means work is happening — keep the not-paused ⇒
|
|
100
|
+
// clock-running invariant (idempotent, so no stacked timer).
|
|
55
101
|
this._paused = false;
|
|
102
|
+
this._startClock();
|
|
56
103
|
|
|
57
104
|
if (state === 'streaming') {
|
|
58
105
|
if (!this._streamStart) { this._streamStart = Date.now(); this._streamTokens = 0; }
|
|
@@ -92,6 +139,10 @@ class FullStatusBar {
|
|
|
92
139
|
this._reportedContext = data.contextTokens;
|
|
93
140
|
this._pendingDelta = 0;
|
|
94
141
|
}
|
|
142
|
+
// Estimated split (Variant B) — recomputed per request by the api client and
|
|
143
|
+
// threaded through here, so it tracks MCP-connect / plan-mode / native-vs-XML.
|
|
144
|
+
if (typeof data.baseEst === 'number') this._baseEst = data.baseEst;
|
|
145
|
+
if (typeof data.workingEst === 'number') this._workingEst = data.workingEst;
|
|
95
146
|
if (data.tokenLimit && typeof data.tokenLimit.limit === 'number') {
|
|
96
147
|
this._contextLimit = data.tokenLimit.limit;
|
|
97
148
|
}
|
|
@@ -122,9 +173,9 @@ class FullStatusBar {
|
|
|
122
173
|
// to build the live region. ALWAYS returns a string — the status row is a
|
|
123
174
|
// permanent fixture of the live region, never omitted. Missing data
|
|
124
175
|
// renders as a short placeholder ("—") so the row width is stable.
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
176
|
+
// Pausing (idle scroll) stops the periodic *tick* (the clock timer is
|
|
177
|
+
// cleared) but the row itself is still produced whenever the composer asks
|
|
178
|
+
// for it — an event-driven _notify still repaints normally.
|
|
128
179
|
renderLine() {
|
|
129
180
|
const layout = this._layout;
|
|
130
181
|
const cols = layout.cols;
|
|
@@ -168,6 +219,10 @@ class FullStatusBar {
|
|
|
168
219
|
{ visible: this._model || '—', ansi: this._model || '—', priority: 4 },
|
|
169
220
|
{ visible: tokenField.visible, ansi: tokenField.ansi, priority: 3 },
|
|
170
221
|
];
|
|
222
|
+
if (this._cost) {
|
|
223
|
+
const c = `${this._cost}`;
|
|
224
|
+
fields.push({ visible: c, ansi: c, priority: 2 });
|
|
225
|
+
}
|
|
171
226
|
if (state === 'streaming' && this._speed > 0) {
|
|
172
227
|
const s = `${this._speed} t/s`;
|
|
173
228
|
fields.push({ visible: s, ansi: s, priority: 1 });
|
|
@@ -221,21 +276,30 @@ class FullStatusBar {
|
|
|
221
276
|
_buildTokenField() {
|
|
222
277
|
const used = Math.max(0, (this._reportedContext | 0) + (this._pendingDelta | 0));
|
|
223
278
|
const usedStr = used.toLocaleString();
|
|
279
|
+
// Estimated split prefix (Variant B): "~Nk working · ~Nk base · ". Working
|
|
280
|
+
// first (it's the part that grows and matters), base second (fixed-ish
|
|
281
|
+
// reference). Both carry ~ — they're char/4 estimates. The measured
|
|
282
|
+
// total/limit/percent that follows carries NO ~ (it's the truth anchor).
|
|
283
|
+
// Shown only once an estimate has arrived, so a fresh bar has no ~ noise.
|
|
284
|
+
let estPrefix = '';
|
|
285
|
+
if (this._workingEst > 0 || this._baseEst > 0) {
|
|
286
|
+
estPrefix = `~${abbrevTokens(this._workingEst)} working · ~${abbrevTokens(this._baseEst)} base · `;
|
|
287
|
+
}
|
|
224
288
|
if (!this._contextLimit) {
|
|
225
|
-
const s = `${usedStr} tok`;
|
|
289
|
+
const s = `${estPrefix}${usedStr} tok`;
|
|
226
290
|
return { visible: s, ansi: s };
|
|
227
291
|
}
|
|
228
292
|
const limit = this._contextLimit;
|
|
229
293
|
const pct = limit > 0 ? Math.round((used / limit) * 100) : 0;
|
|
230
294
|
const limitStr = limit.toLocaleString();
|
|
231
|
-
const visible = `${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
295
|
+
const visible = `${estPrefix}${usedStr} / ${limitStr} tok (${pct}%)`;
|
|
232
296
|
let pctAnsi = `${pct}%`;
|
|
233
297
|
if (pct >= 90) {
|
|
234
298
|
pctAnsi = `${RST}${UI_THEME.error}${pct}%${RST}${DIM}`;
|
|
235
299
|
} else if (pct >= 70) {
|
|
236
300
|
pctAnsi = `${RST}${UI_THEME.warning}${pct}%${RST}${DIM}`;
|
|
237
301
|
}
|
|
238
|
-
const ansi = `${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
302
|
+
const ansi = `${estPrefix}${usedStr} / ${limitStr} tok (${pctAnsi})`;
|
|
239
303
|
return { visible, ansi };
|
|
240
304
|
}
|
|
241
305
|
|
|
@@ -255,13 +319,17 @@ class FullStatusBar {
|
|
|
255
319
|
_renderBar() { this._notify(); }
|
|
256
320
|
|
|
257
321
|
_notify() {
|
|
258
|
-
|
|
322
|
+
// Every redraw — periodic tick AND event-driven (update/updateMetrics/
|
|
323
|
+
// setCost/spinner) — flows through here. Pausing is done by stopping the
|
|
324
|
+
// clock timer (see pause/_stopClock), NOT by gating here: a guard would
|
|
325
|
+
// also suppress the event-driven repaints that must keep working while
|
|
326
|
+
// idle scroll is paused.
|
|
259
327
|
this._onChange();
|
|
260
328
|
}
|
|
261
329
|
|
|
262
330
|
destroy() {
|
|
263
331
|
if (this._animTimer) { clearInterval(this._animTimer); this._animTimer = null; }
|
|
264
|
-
|
|
332
|
+
this._stopClock();
|
|
265
333
|
}
|
|
266
334
|
}
|
|
267
335
|
|
package/lib/ui/theme.js
CHANGED
|
@@ -21,6 +21,7 @@ const UI_THEME = {
|
|
|
21
21
|
// values produced by TOOL_CATEGORIES so a lookup is categories[cat].
|
|
22
22
|
categories: {
|
|
23
23
|
net: fg256(110), // dusty blue — http, download, upload
|
|
24
|
+
web: fg256(80), // aqua — collapsed web-activity summary (W.3)
|
|
24
25
|
file: fg256(151), // soft sage — file ops
|
|
25
26
|
cmd: fg256(180), // warm tan — shell / exec
|
|
26
27
|
user: fg256(217), // pale rose — ask_user
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Web-activity process summary (Task W.3, Part 1).
|
|
4
|
+
//
|
|
5
|
+
// A web task runs `web_search` (find candidate pages) then targeted `http_get`
|
|
6
|
+
// (read the relevant ones). By default each operation printed its own tool line
|
|
7
|
+
// (one "tool · web_search" / "net · GET …" row per call), which reads as a noisy
|
|
8
|
+
// list rather than one coherent process. This module collapses a run of
|
|
9
|
+
// consecutive web operations into a SINGLE compact process-summary line —
|
|
10
|
+
//
|
|
11
|
+
// ✓ web · search "коррупционные скандалы…" · 2 queries · 3 sources read · 1 blocked
|
|
12
|
+
//
|
|
13
|
+
// — while `--debug` keeps the full per-operation lines (in debug mode chat-turn.js
|
|
14
|
+
// bypasses this collapser and renders each op the normal way).
|
|
15
|
+
//
|
|
16
|
+
// Display only: the audit log still records every individual operation (that
|
|
17
|
+
// happens in the executors, untouched here), and NON-web tools render exactly as
|
|
18
|
+
// before. Scope: `web_search` + `http_get`. `download` is a file-save, not a page
|
|
19
|
+
// read for the search→fetch flow, so it keeps its own line.
|
|
20
|
+
|
|
21
|
+
const { UI_THEME, UI_ICONS } = require('./theme');
|
|
22
|
+
const { RST, DIM } = require('./ansi');
|
|
23
|
+
const { formatDuration } = require('./format');
|
|
24
|
+
|
|
25
|
+
// The tools collapsed into the web-activity summary.
|
|
26
|
+
const WEB_TOOLS = new Set(['web_search', 'http_get']);
|
|
27
|
+
|
|
28
|
+
function isWebTool(tag) { return WEB_TOOLS.has(tag); }
|
|
29
|
+
|
|
30
|
+
function _truncate(text, max) {
|
|
31
|
+
const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
|
|
32
|
+
if (s.length <= max) return s;
|
|
33
|
+
return s.slice(0, Math.max(0, max - 1)) + '…';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Whether a finished web op counts as a success. A `web_search` is ok unless the
|
|
37
|
+
// executor flagged an error (backend down). An `http_get` is ok only when it both
|
|
38
|
+
// avoided a transport error (timeout/DNS — surfaced as `op.error`) AND the server
|
|
39
|
+
// answered < 400: a 403/406 is a real "blocked" even though the fetch itself
|
|
40
|
+
// completed and returned a status code.
|
|
41
|
+
function opSucceeded(op) {
|
|
42
|
+
if (!op) return false;
|
|
43
|
+
if (op.error) return false;
|
|
44
|
+
if (op.tag === 'http_get' && typeof op.status === 'number' && op.status >= 400) return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Pure: fold the recorded op list into the counts the summary needs.
|
|
49
|
+
function aggregateWebOps(ops) {
|
|
50
|
+
const state = {
|
|
51
|
+
searchCount: 0, searchFailed: 0, queries: [],
|
|
52
|
+
fetchCount: 0, fetchOk: 0, fetchFailed: 0,
|
|
53
|
+
};
|
|
54
|
+
for (const op of (ops || [])) {
|
|
55
|
+
if (!op) continue;
|
|
56
|
+
const ok = opSucceeded(op);
|
|
57
|
+
if (op.tag === 'web_search') {
|
|
58
|
+
state.searchCount += 1;
|
|
59
|
+
if (!ok) state.searchFailed += 1;
|
|
60
|
+
if (op.query) state.queries.push(op.query);
|
|
61
|
+
} else if (op.tag === 'http_get') {
|
|
62
|
+
state.fetchCount += 1;
|
|
63
|
+
if (ok) state.fetchOk += 1; else state.fetchFailed += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pure: the plain-text segments of the summary (no ANSI). Each segment is tagged
|
|
70
|
+
// with a `kind` so the styled renderer can colour failures distinctly. Exposed
|
|
71
|
+
// for tests and reused by the styled renderer. Failures (a blocked source / a
|
|
72
|
+
// failed search) are ALWAYS represented so the compact view never silently drops
|
|
73
|
+
// a source that didn't load.
|
|
74
|
+
function webSummarySegments(state) {
|
|
75
|
+
const segs = [];
|
|
76
|
+
if (state.searchCount > 0) {
|
|
77
|
+
const q = state.queries[0] ? `"${_truncate(state.queries[0], 48)}"` : '';
|
|
78
|
+
segs.push({ kind: 'lead', text: `search ${q}`.trim() });
|
|
79
|
+
if (state.searchCount > 1) segs.push({ kind: 'count', text: `${state.searchCount} queries` });
|
|
80
|
+
if (state.searchFailed > 0) {
|
|
81
|
+
segs.push({ kind: 'fail', text: `${state.searchFailed} search${state.searchFailed === 1 ? '' : 'es'} failed` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (state.fetchCount > 0) {
|
|
85
|
+
segs.push({ kind: state.searchCount > 0 ? 'count' : 'lead', text: `${state.fetchOk} ${state.fetchOk === 1 ? 'source' : 'sources'} read` });
|
|
86
|
+
if (state.fetchFailed > 0) segs.push({ kind: 'fail', text: `${state.fetchFailed} blocked` });
|
|
87
|
+
}
|
|
88
|
+
if (segs.length === 0) segs.push({ kind: 'lead', text: 'web' });
|
|
89
|
+
return segs;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Plain-text one-liner (no ANSI). The text the tests assert on.
|
|
93
|
+
function webSummaryText(state) {
|
|
94
|
+
return webSummarySegments(state).map((s) => s.text).join(' · ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Styled, chrome-consistent line for the writer's activity region / scrollback.
|
|
98
|
+
// Mirrors formatToolLine's "<glyph> <category> · <segments…>" layout so the
|
|
99
|
+
// summary reads as a peer of the other tool lines, not a foreign widget.
|
|
100
|
+
function formatWebSummaryLine(state, opts) {
|
|
101
|
+
const { pending = false, durationMs = 0 } = opts || {};
|
|
102
|
+
const glyph = pending ? UI_ICONS.pending : UI_ICONS.success;
|
|
103
|
+
const glyphColor = pending ? UI_THEME.muted : UI_THEME.success;
|
|
104
|
+
const cat = 'web'.padEnd(5);
|
|
105
|
+
const catColor = (UI_THEME.categories && UI_THEME.categories.web) || UI_THEME.accent;
|
|
106
|
+
const sep = ` ${DIM}·${RST} `;
|
|
107
|
+
|
|
108
|
+
const out = [` ${glyphColor}${glyph}${RST} ${catColor}${cat}${RST}`];
|
|
109
|
+
for (const seg of webSummarySegments(state)) {
|
|
110
|
+
let color = UI_THEME.subtle;
|
|
111
|
+
if (seg.kind === 'lead') color = UI_THEME.default;
|
|
112
|
+
else if (seg.kind === 'fail') color = UI_THEME.warning;
|
|
113
|
+
out.push(`${color}${seg.text}${RST}`);
|
|
114
|
+
}
|
|
115
|
+
if (pending) out.push(`${UI_THEME.muted}${formatDuration(durationMs)}…${RST}`);
|
|
116
|
+
return out.join(sep);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Batch renderer / policy seam (used by tests, and documents the runtime split):
|
|
120
|
+
// debug → one full per-operation tool line per op (nothing hidden), built
|
|
121
|
+
// with the SAME formatToolLine the runtime uses.
|
|
122
|
+
// default → a single collapsed summary line.
|
|
123
|
+
function renderWebActivity(ops, opts) {
|
|
124
|
+
const { debug = false, formatToolLine } = opts || {};
|
|
125
|
+
if (debug) {
|
|
126
|
+
return (ops || []).map((op) => formatToolLine({
|
|
127
|
+
status: opSucceeded(op) ? 'success' : 'failure',
|
|
128
|
+
tag: op.tag,
|
|
129
|
+
arg: op.query || op.url || '',
|
|
130
|
+
attrs: op.tag === 'web_search' ? { query: op.query } : { url: op.url },
|
|
131
|
+
durationMs: op.durationMs,
|
|
132
|
+
meta: op.tag === 'http_get' ? { status_code: op.status, bytes: op.bytes } : null,
|
|
133
|
+
error: op.error ? { message: String(op.error) } : null,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
return [formatWebSummaryLine(aggregateWebOps(ops), { pending: false })];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Stateful runtime collapser. Owns one writer "activity" entry per group of
|
|
140
|
+
// consecutive web ops, updating it in place as ops complete and committing a
|
|
141
|
+
// single final summary line to scrollback on flush(). Tools run sequentially in
|
|
142
|
+
// the agent loop, so at most one group is ever open and there is no concurrency.
|
|
143
|
+
function createWebActivityTracker(deps) {
|
|
144
|
+
const { writerModule } = deps || {};
|
|
145
|
+
let groupId = null;
|
|
146
|
+
let seq = 0;
|
|
147
|
+
let ended = [];
|
|
148
|
+
let current = null; // the in-flight op, shown in the pending line before it ends
|
|
149
|
+
|
|
150
|
+
function _render(durationMs) {
|
|
151
|
+
const state = aggregateWebOps(current ? ended.concat([current]) : ended);
|
|
152
|
+
return formatWebSummaryLine(state, { pending: true, durationMs });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _refresh() {
|
|
156
|
+
if (groupId === null) return;
|
|
157
|
+
writerModule.updateActivity(groupId, (elapsedMs) => _render(elapsedMs));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
isWeb: isWebTool,
|
|
162
|
+
isOpen() { return groupId !== null; },
|
|
163
|
+
|
|
164
|
+
start(tag, input) {
|
|
165
|
+
current = {
|
|
166
|
+
tag,
|
|
167
|
+
query: tag === 'web_search' ? input : undefined,
|
|
168
|
+
url: tag === 'http_get' ? input : undefined,
|
|
169
|
+
};
|
|
170
|
+
if (groupId === null) {
|
|
171
|
+
groupId = `web-${seq++}`;
|
|
172
|
+
writerModule.startActivity(groupId, (elapsedMs) => _render(elapsedMs));
|
|
173
|
+
} else {
|
|
174
|
+
_refresh();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
end(tag, result, durationMs, toolCtx) {
|
|
179
|
+
const meta = toolCtx && toolCtx.meta;
|
|
180
|
+
const attrs = toolCtx && toolCtx.attrs;
|
|
181
|
+
ended.push({
|
|
182
|
+
tag,
|
|
183
|
+
durationMs,
|
|
184
|
+
query: (attrs && attrs.query) || (current && current.query),
|
|
185
|
+
url: (attrs && attrs.url) || (current && current.url),
|
|
186
|
+
status: meta && typeof meta.status_code === 'number' ? meta.status_code : undefined,
|
|
187
|
+
bytes: meta && typeof meta.bytes === 'number' ? meta.bytes : undefined,
|
|
188
|
+
error: toolCtx && toolCtx.error ? (toolCtx.error.message || String(toolCtx.error)) : undefined,
|
|
189
|
+
});
|
|
190
|
+
current = null;
|
|
191
|
+
_refresh();
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
// Commit the collapsed summary for the current group to scrollback and reset.
|
|
195
|
+
// A no-op when no group is open.
|
|
196
|
+
flush() {
|
|
197
|
+
if (groupId === null) return;
|
|
198
|
+
const id = groupId;
|
|
199
|
+
const line = formatWebSummaryLine(aggregateWebOps(ended), { pending: false });
|
|
200
|
+
groupId = null;
|
|
201
|
+
ended = [];
|
|
202
|
+
current = null;
|
|
203
|
+
writerModule.endActivity(id, line);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
module.exports = {
|
|
209
|
+
WEB_TOOLS,
|
|
210
|
+
isWebTool,
|
|
211
|
+
opSucceeded,
|
|
212
|
+
aggregateWebOps,
|
|
213
|
+
webSummarySegments,
|
|
214
|
+
webSummaryText,
|
|
215
|
+
formatWebSummaryLine,
|
|
216
|
+
renderWebActivity,
|
|
217
|
+
createWebActivityTracker,
|
|
218
|
+
};
|