@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.
Files changed (146) hide show
  1. package/.claude/settings.local.json +6 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/CLAUDE.md +1584 -26
  4. package/README.md +147 -3
  5. package/examples/embed.js +74 -0
  6. package/index.js +251 -10
  7. package/lib/agent.js +711 -104
  8. package/lib/api.js +213 -49
  9. package/lib/args.js +74 -2
  10. package/lib/audit.js +23 -1
  11. package/lib/background.js +584 -0
  12. package/lib/checkpoints.js +757 -0
  13. package/lib/commands/auth.js +94 -0
  14. package/lib/commands/chat-session.js +306 -0
  15. package/lib/commands/chat-slash.js +399 -0
  16. package/lib/commands/chat-turn.js +446 -0
  17. package/lib/commands/chat.js +403 -0
  18. package/lib/commands/custom.js +157 -0
  19. package/lib/commands/history-utils.js +66 -0
  20. package/lib/commands/index.js +268 -0
  21. package/lib/commands/mcp.js +113 -0
  22. package/lib/commands/oneshot.js +193 -0
  23. package/lib/commands/registry.js +269 -0
  24. package/lib/commands/tasks.js +89 -0
  25. package/lib/compact.js +87 -0
  26. package/lib/config.js +333 -11
  27. package/lib/constants.js +372 -3
  28. package/lib/deny.js +199 -0
  29. package/lib/doctor.js +160 -0
  30. package/lib/headless.js +167 -0
  31. package/lib/hooks.js +286 -0
  32. package/lib/images.js +264 -0
  33. package/lib/internals.js +49 -0
  34. package/lib/mcp/boundary.js +131 -0
  35. package/lib/mcp/client.js +270 -0
  36. package/lib/mcp/oauth.js +134 -0
  37. package/lib/memory.js +209 -0
  38. package/lib/metrics.js +37 -2
  39. package/lib/payload.js +54 -0
  40. package/lib/permission-rules.js +401 -0
  41. package/lib/permissions.js +100 -10
  42. package/lib/pricing.js +67 -0
  43. package/lib/proc.js +62 -0
  44. package/lib/prompts.js +84 -5
  45. package/lib/sandbox.js +568 -0
  46. package/lib/sdk.js +328 -0
  47. package/lib/secrets.js +211 -0
  48. package/lib/skills.js +223 -0
  49. package/lib/subagents.js +516 -0
  50. package/lib/tool_registry.js +2558 -0
  51. package/lib/tool_specs.js +222 -2
  52. package/lib/tools.js +272 -1020
  53. package/lib/ui/format.js +22 -1
  54. package/lib/ui/input-field.js +16 -7
  55. package/lib/ui/status-bar.js +79 -11
  56. package/lib/ui/theme.js +1 -0
  57. package/lib/ui/web-activity.js +218 -0
  58. package/lib/verify.js +229 -0
  59. package/lib/web-extract.js +213 -0
  60. package/lib/web-summarize.js +68 -0
  61. package/package.json +19 -4
  62. package/scripts/lint.js +57 -0
  63. package/test/agent-loop.test.js +389 -0
  64. package/test/background.test.js +414 -0
  65. package/test/chat.test.js +114 -0
  66. package/test/checkpoints-agent.test.js +181 -0
  67. package/test/checkpoints.test.js +650 -0
  68. package/test/command-registry.test.js +160 -0
  69. package/test/compact.test.js +116 -0
  70. package/test/completion-lazy.test.js +52 -0
  71. package/test/config-merge.test.js +324 -0
  72. package/test/config-quarantine.test.js +128 -0
  73. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  74. package/test/config-write-guard-skip.test.js +46 -0
  75. package/test/config-write-guard.test.js +153 -0
  76. package/test/context-split.test.js +215 -0
  77. package/test/cost-doctor.test.js +142 -0
  78. package/test/custom-commands-chat.test.js +106 -0
  79. package/test/custom-commands.test.js +230 -0
  80. package/test/deny-windows.test.js +120 -0
  81. package/test/deny.test.js +83 -0
  82. package/test/download-allow-anywhere.test.js +66 -0
  83. package/test/download-confine.test.js +153 -0
  84. package/test/executors.test.js +362 -0
  85. package/test/extract-tool-calls.test.js +315 -0
  86. package/test/fetch-url-validation.test.js +219 -0
  87. package/test/fixtures/tool-calls.js +57 -0
  88. package/test/fixtures/web-page.js +91 -0
  89. package/test/git-tools.test.js +384 -0
  90. package/test/grep-glob-serialize.test.js +242 -0
  91. package/test/grep-glob.test.js +268 -0
  92. package/test/harness/README.md +57 -0
  93. package/test/harness/chat-harness.js +142 -0
  94. package/test/harness/memwarn-headless-child.js +65 -0
  95. package/test/harness/mock-llm.js +120 -0
  96. package/test/harness/mock-mcp-server.js +142 -0
  97. package/test/harness/sse-server.js +69 -0
  98. package/test/headless.test.js +203 -0
  99. package/test/history-utils.test.js +88 -0
  100. package/test/hooks-agent.test.js +238 -0
  101. package/test/hooks-verify-sandbox.test.js +232 -0
  102. package/test/hooks.test.js +216 -0
  103. package/test/http-get-user-agent.test.js +142 -0
  104. package/test/images-api.test.js +208 -0
  105. package/test/images.test.js +238 -0
  106. package/test/max-iterations.test.js +216 -0
  107. package/test/mcp-boundary.test.js +57 -0
  108. package/test/mcp-client.test.js +267 -0
  109. package/test/mcp-oauth.test.js +86 -0
  110. package/test/memory-truncation-warning.test.js +222 -0
  111. package/test/memory.test.js +198 -0
  112. package/test/native-dispatch.test.js +356 -0
  113. package/test/output-chokepoint.test.js +188 -0
  114. package/test/path-guards.test.js +134 -0
  115. package/test/payload.test.js +99 -0
  116. package/test/permission-rules-agent.test.js +210 -0
  117. package/test/permission-rules.test.js +297 -0
  118. package/test/permissions.test.js +163 -0
  119. package/test/plan-mode.test.js +167 -0
  120. package/test/read-paginate.test.js +275 -0
  121. package/test/readonly-tools.test.js +177 -0
  122. package/test/result-cap.test.js +233 -0
  123. package/test/sandbox-agent.test.js +147 -0
  124. package/test/sandbox-integration.test.js +216 -0
  125. package/test/sandbox.test.js +408 -0
  126. package/test/sdk.test.js +234 -0
  127. package/test/shell-output-cap.test.js +181 -0
  128. package/test/skills-chat.test.js +110 -0
  129. package/test/skills.test.js +295 -0
  130. package/test/smoke.test.js +68 -0
  131. package/test/status-bar-pause.test.js +164 -0
  132. package/test/stream-parser.test.js +147 -0
  133. package/test/subagents-agent.test.js +178 -0
  134. package/test/subagents.test.js +222 -0
  135. package/test/tool-registry.test.js +85 -0
  136. package/test/trim-budget.test.js +101 -0
  137. package/test/verify-agent.test.js +317 -0
  138. package/test/verify.test.js +141 -0
  139. package/test/web-activity-ordering.test.js +194 -0
  140. package/test/web-activity.test.js +207 -0
  141. package/test/web-data-extraction-guidance.test.js +71 -0
  142. package/test/web-extract.test.js +185 -0
  143. package/test/web-fetch-agent.test.js +291 -0
  144. package/test/web-fetch-mode.test.js +193 -0
  145. package/test/web-search.test.js +380 -0
  146. package/lib/commands.js +0 -1438
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+
3
+ // Web-activity process summary (Task W.3, Part 1). The default view collapses a
4
+ // run of web ops (web_search → http_get) into ONE compact summary line; --debug
5
+ // keeps the full per-operation lines. These tests pin the pure renderer (counts:
6
+ // queries / sources read / failures), the debug-vs-default branch, that a failed
7
+ // fetch (403/timeout) is reflected (not dropped), that non-web tools are out of
8
+ // scope, and the stateful tracker's collapse-to-one-committed-line behaviour.
9
+
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert');
12
+
13
+ const { stripAnsi } = require('../lib/ui/utils');
14
+ const { formatToolLine } = require('../lib/ui/format');
15
+ const {
16
+ isWebTool,
17
+ opSucceeded,
18
+ aggregateWebOps,
19
+ webSummaryText,
20
+ formatWebSummaryLine,
21
+ renderWebActivity,
22
+ createWebActivityTracker,
23
+ } = require('../lib/ui/web-activity');
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Scope: which tools are collapsed
27
+ // ---------------------------------------------------------------------------
28
+
29
+ test('isWebTool: only web_search and http_get are in scope', () => {
30
+ assert.strictEqual(isWebTool('web_search'), true);
31
+ assert.strictEqual(isWebTool('http_get'), true);
32
+ // download writes a file, not a page read — keeps its own line.
33
+ assert.strictEqual(isWebTool('download'), false);
34
+ assert.strictEqual(isWebTool('shell'), false);
35
+ assert.strictEqual(isWebTool('read_file'), false);
36
+ assert.strictEqual(isWebTool('write_file'), false);
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Success classification (the 403/406 "blocked" rule)
41
+ // ---------------------------------------------------------------------------
42
+
43
+ test('opSucceeded: http_get >= 400 is a failure even with no transport error', () => {
44
+ assert.strictEqual(opSucceeded({ tag: 'http_get', status: 200 }), true);
45
+ assert.strictEqual(opSucceeded({ tag: 'http_get', status: 403 }), false);
46
+ assert.strictEqual(opSucceeded({ tag: 'http_get', status: 406 }), false);
47
+ // A transport error (timeout/DNS) is a failure regardless of status.
48
+ assert.strictEqual(opSucceeded({ tag: 'http_get', error: 'Request timeout' }), false);
49
+ // web_search is ok unless the backend errored.
50
+ assert.strictEqual(opSucceeded({ tag: 'web_search' }), true);
51
+ assert.strictEqual(opSucceeded({ tag: 'web_search', error: 'web search unavailable' }), false);
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Pure summary text — reflects queries, sources read, failures
56
+ // ---------------------------------------------------------------------------
57
+
58
+ test('webSummaryText: reflects query count, sources read, and blocked count', () => {
59
+ const ops = [
60
+ { tag: 'web_search', query: 'коррупционные скандалы 2024' },
61
+ { tag: 'web_search', query: 'follow-up query' },
62
+ { tag: 'http_get', url: 'https://a.example/1', status: 200 },
63
+ { tag: 'http_get', url: 'https://b.example/2', status: 200 },
64
+ { tag: 'http_get', url: 'https://ru.wikipedia.org/x', status: 403 },
65
+ ];
66
+ const text = webSummaryText(aggregateWebOps(ops));
67
+ assert.match(text, /search "коррупционные/); // leads with the query
68
+ assert.match(text, /2 queries/); // query count visible
69
+ assert.match(text, /2 sources read/); // successful reads
70
+ assert.match(text, /1 blocked/); // the 403 is surfaced, not dropped
71
+ });
72
+
73
+ test('webSummaryText: a timeout counts as blocked, not silently dropped', () => {
74
+ const ops = [
75
+ { tag: 'http_get', url: 'https://slow.example', error: 'Request timeout' },
76
+ { tag: 'http_get', url: 'https://ok.example', status: 200 },
77
+ ];
78
+ const text = webSummaryText(aggregateWebOps(ops));
79
+ assert.match(text, /1 source read/);
80
+ assert.match(text, /1 blocked/);
81
+ });
82
+
83
+ test('webSummaryText: a failed web_search is surfaced', () => {
84
+ const ops = [{ tag: 'web_search', query: 'q', error: 'web search unavailable: backend down' }];
85
+ const text = webSummaryText(aggregateWebOps(ops));
86
+ assert.match(text, /search failed/);
87
+ });
88
+
89
+ test('webSummaryText: fetch-only flow (no search) still reads cleanly', () => {
90
+ const ops = [{ tag: 'http_get', url: 'https://x', status: 200 }];
91
+ assert.match(webSummaryText(aggregateWebOps(ops)), /1 source read/);
92
+ });
93
+
94
+ test('aggregateWebOps: counts are exact', () => {
95
+ const s = aggregateWebOps([
96
+ { tag: 'web_search', query: 'a' },
97
+ { tag: 'http_get', status: 200 },
98
+ { tag: 'http_get', status: 200 },
99
+ { tag: 'http_get', status: 500 },
100
+ ]);
101
+ assert.deepStrictEqual(
102
+ { searchCount: s.searchCount, fetchCount: s.fetchCount, fetchOk: s.fetchOk, fetchFailed: s.fetchFailed },
103
+ { searchCount: 1, fetchCount: 3, fetchOk: 2, fetchFailed: 1 },
104
+ );
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // renderWebActivity — debug branch keeps full per-op detail; default collapses
109
+ // ---------------------------------------------------------------------------
110
+
111
+ const SAMPLE_OPS = [
112
+ { tag: 'web_search', query: 'how do tariffs work', durationMs: 941 },
113
+ { tag: 'http_get', url: 'https://24tv.ua/article', status: 200, bytes: 406 * 1024, durationMs: 171 },
114
+ { tag: 'http_get', url: 'https://ru.wikipedia.org/page', status: 403, bytes: 126, durationMs: 25 },
115
+ ];
116
+
117
+ test('renderWebActivity (default): a sequence of web ops → ONE compact summary line', () => {
118
+ const lines = renderWebActivity(SAMPLE_OPS, { debug: false, formatToolLine });
119
+ assert.strictEqual(lines.length, 1, 'collapsed to a single line');
120
+ const plain = stripAnsi(lines[0]);
121
+ assert.match(plain, /web/);
122
+ assert.match(plain, /search "how do tariffs work"/);
123
+ assert.match(plain, /1 source read/);
124
+ assert.match(plain, /1 blocked/);
125
+ });
126
+
127
+ test('renderWebActivity (--debug): full per-operation lines, nothing hidden', () => {
128
+ const lines = renderWebActivity(SAMPLE_OPS, { debug: true, formatToolLine });
129
+ assert.strictEqual(lines.length, SAMPLE_OPS.length, 'one line per op');
130
+ const all = lines.map(stripAnsi);
131
+ // The query and both URLs survive in the detailed view.
132
+ assert.ok(all.some((l) => /how do tariffs work/.test(l)));
133
+ assert.ok(all.some((l) => /24tv\.ua/.test(l)));
134
+ assert.ok(all.some((l) => /ru\.wikipedia\.org/.test(l)));
135
+ // The HTTP status codes (200 / 403) are present in the per-op meta.
136
+ assert.ok(all.some((l) => /\b200\b/.test(l)));
137
+ assert.ok(all.some((l) => /\b403\b/.test(l)));
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Styled line: glyph + failures coloured, plain text correct
142
+ // ---------------------------------------------------------------------------
143
+
144
+ test('formatWebSummaryLine: pending shows ●, committed shows ✓', () => {
145
+ const state = aggregateWebOps(SAMPLE_OPS);
146
+ assert.match(formatWebSummaryLine(state, { pending: true, durationMs: 500 }), /●/);
147
+ assert.match(formatWebSummaryLine(state, { pending: false }), /✓/);
148
+ });
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Stateful tracker — collapse a multi-op group into one committed line
152
+ // ---------------------------------------------------------------------------
153
+
154
+ function fakeWriter() {
155
+ const calls = { start: [], update: 0, end: [] };
156
+ return {
157
+ calls,
158
+ startActivity(id) { calls.start.push(id); },
159
+ updateActivity() { calls.update += 1; },
160
+ endActivity(id, line) { calls.end.push({ id, line }); },
161
+ };
162
+ }
163
+
164
+ test('tracker: a run of web ops commits exactly ONE summary line on flush', () => {
165
+ const w = fakeWriter();
166
+ const t = createWebActivityTracker({ writerModule: w });
167
+
168
+ t.start('web_search', 'коррупционные скандалы');
169
+ t.end('web_search', { results: [] }, 900, { attrs: { query: 'коррупционные скандалы' } });
170
+ t.start('http_get', 'https://a.example');
171
+ t.end('http_get', {}, 170, { attrs: { url: 'https://a.example' }, meta: { status_code: 200, bytes: 1000 } });
172
+ t.start('http_get', 'https://ru.wikipedia.org/x');
173
+ t.end('http_get', {}, 25, { attrs: { url: 'https://ru.wikipedia.org/x' }, meta: { status_code: 403, bytes: 126 } });
174
+
175
+ assert.strictEqual(w.calls.start.length, 1, 'one activity opened for the whole group');
176
+ assert.strictEqual(t.isOpen(), true);
177
+
178
+ t.flush();
179
+ assert.strictEqual(t.isOpen(), false);
180
+ assert.strictEqual(w.calls.end.length, 1, 'one committed summary line');
181
+ const plain = stripAnsi(w.calls.end[0].line);
182
+ assert.match(plain, /search "коррупционные скандалы"/);
183
+ assert.match(plain, /1 source read/);
184
+ assert.match(plain, /1 blocked/);
185
+ });
186
+
187
+ test('tracker: flush with no open group is a no-op', () => {
188
+ const w = fakeWriter();
189
+ const t = createWebActivityTracker({ writerModule: w });
190
+ t.flush();
191
+ assert.strictEqual(w.calls.start.length, 0);
192
+ assert.strictEqual(w.calls.end.length, 0);
193
+ });
194
+
195
+ test('tracker: a second group after flush opens a fresh activity', () => {
196
+ const w = fakeWriter();
197
+ const t = createWebActivityTracker({ writerModule: w });
198
+ t.start('web_search', 'q1');
199
+ t.end('web_search', {}, 10, { attrs: { query: 'q1' } });
200
+ t.flush();
201
+ t.start('http_get', 'https://x');
202
+ t.end('http_get', {}, 10, { attrs: { url: 'https://x' }, meta: { status_code: 200 } });
203
+ t.flush();
204
+ assert.strictEqual(w.calls.start.length, 2, 'two distinct groups');
205
+ assert.strictEqual(w.calls.end.length, 2);
206
+ assert.notStrictEqual(w.calls.start[0], w.calls.start[1], 'distinct group ids');
207
+ });
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ // Task W.4 Part 1 — guidance: teach fetch+grep for data-extraction tasks.
4
+ //
5
+ // "Extract specific values from a page" (colors/hex, versions, IDs, URLs,
6
+ // counts) is a different task class from "read a page": the right pattern is
7
+ // targeted matching (download→grep / curl|grep) so only the matches enter
8
+ // context — NOT loading page content via any http_get mode. These tests pin
9
+ // that the guidance text is present (the spec drives the model) and that the
10
+ // stale prompts.js http_get description ("byte cap body") is corrected.
11
+ //
12
+ // NOTE: Part 1 is a guidance change; its real effect is behavioral (a live
13
+ // re-run of the "what colors" task), not a unit assertion — these tests only
14
+ // pin that the guidance exists and is discoverable to the model.
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert');
18
+
19
+ const { TOOL_SPECS } = require('../lib/tool_specs');
20
+ const { getSystemPrompt } = require('../lib/prompts');
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // The http_get spec guides the agent to fetch+grep for extracting values
24
+ // ---------------------------------------------------------------------------
25
+
26
+ test('http_get spec guides fetch+grep for extracting specific values', () => {
27
+ const blob = TOOL_SPECS.http_get.description.toLowerCase();
28
+ // It points at download/grep (or curl|grep) as the extraction pattern.
29
+ assert.match(blob, /grep/, 'mentions grep as the extraction tool');
30
+ assert.match(blob, /download|curl/, 'points at download/curl to disk first');
31
+ // It frames the task class — extracting specific values.
32
+ assert.match(blob, /extract|specific value|color|version|id/, 'names the extract-values task');
33
+ // It warns that raw is expensive for simple value extraction.
34
+ assert.match(blob, /raw/, 'still describes raw');
35
+ });
36
+
37
+ test('http_get spec notes the SPA / linked-asset case', () => {
38
+ const blob = TOOL_SPECS.http_get.description.toLowerCase();
39
+ // Values may live in linked assets, not the top-level HTML.
40
+ assert.match(blob, /asset|_nuxt|stylesheet|bundle|\.css|\.js/, 'mentions linked assets / SPA case');
41
+ });
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // The system prompt carries the same guidance (covers the XML tag rail)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ test('system prompt teaches fetch+grep for value extraction (XML + native)', () => {
48
+ for (const native of [false, true]) {
49
+ const prompt = getSystemPrompt(native, '', '').toLowerCase();
50
+ assert.match(prompt, /grep/, `native=${native}: mentions grep`);
51
+ assert.match(prompt, /download|curl/, `native=${native}: mentions download/curl`);
52
+ assert.match(prompt, /extract/, `native=${native}: names the extract task`);
53
+ }
54
+ });
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The stale prompts.js http_get description is corrected
58
+ // ---------------------------------------------------------------------------
59
+
60
+ test('http_get tag purpose no longer claims a raw byte-cap response body', () => {
61
+ const { TOOL_TAG_SPECS } = require('../lib/prompts');
62
+ const purpose = (TOOL_TAG_SPECS.http_get.purpose || '').toLowerCase();
63
+ // The old text described http_get as "returns the response body (truncated to
64
+ // a byte cap …)" — pre-W.1. It now runs the extract/summarize pipeline.
65
+ assert.ok(
66
+ !/response body \(truncated to a byte cap/.test(purpose),
67
+ 'stale byte-cap-body description must be gone',
68
+ );
69
+ // The accurate description references the pipeline / modes / token cap.
70
+ assert.match(purpose, /summari|extract|mode|token|pipeline/, 'describes the W.1 pipeline');
71
+ });
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ // Unit tests for the web-fetch extraction + summary-request stages (Task W.1).
4
+ // Network-free: they run Readability + Turndown over a fixture HTML page and the
5
+ // pure summary-message builder. The pipeline integration (http_get end-to-end
6
+ // with a mock fetch + mock summarizer) lives in test/web-fetch-agent.test.js.
7
+
8
+ const { test } = require('node:test');
9
+ const assert = require('node:assert');
10
+
11
+ const {
12
+ classifyContentType,
13
+ extractContent,
14
+ capToTokens,
15
+ defaultEstimate,
16
+ } = require('../lib/web-extract');
17
+ const { buildSummaryMessages, summarizeWebContent, FENCE_OPEN } = require('../lib/web-summarize');
18
+ const { HTML, INJECTION } = require('./fixtures/web-page');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Content-type classification
22
+ // ---------------------------------------------------------------------------
23
+
24
+ test('classifyContentType: html / json / text / markdown by content-type', () => {
25
+ assert.strictEqual(classifyContentType('text/html; charset=utf-8', 'http://x', ''), 'html');
26
+ assert.strictEqual(classifyContentType('application/json', 'http://x/api', '{}'), 'json');
27
+ assert.strictEqual(classifyContentType('application/vnd.api+json', 'http://x', '{}'), 'json');
28
+ assert.strictEqual(classifyContentType('text/plain', 'http://x/readme.txt', 'hi'), 'text');
29
+ assert.strictEqual(classifyContentType('text/markdown', 'http://x/r.md', '# hi'), 'markdown');
30
+ // .md served as text/plain is still markdown
31
+ assert.strictEqual(classifyContentType('text/plain', 'http://x/README.md', '# hi'), 'markdown');
32
+ });
33
+
34
+ test('classifyContentType: sniffs HTML when content-type is absent/generic', () => {
35
+ assert.strictEqual(classifyContentType('', 'http://x', '<!doctype html><html><body>hi</body></html>'), 'html');
36
+ assert.strictEqual(classifyContentType('application/octet-stream', 'http://x', '<div>x</div>'), 'html');
37
+ assert.strictEqual(classifyContentType('', 'http://x', 'just some plain text, no tags here'), 'text');
38
+ });
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Extraction: HTML → clean Markdown of the MAIN content
42
+ // ---------------------------------------------------------------------------
43
+
44
+ test('extractContent: HTML yields clean Markdown of the article; nav/scripts/ads gone', () => {
45
+ const { kind, markdown, extracted, title } = extractContent({ body: HTML, contentType: 'text/html', url: 'http://x/docs' });
46
+ assert.strictEqual(kind, 'html');
47
+ assert.strictEqual(extracted, true);
48
+
49
+ // Title is recovered as metadata.
50
+ assert.match(title || '', /HyperWidget Layout Phases/);
51
+ // Main content survives.
52
+ assert.match(markdown, /layout phases/i);
53
+ assert.match(markdown, /ctx\.cancel\(\)/);
54
+
55
+ // Chrome is gone.
56
+ assert.ok(!/SPONSORED/.test(markdown), 'ad copy must be dropped');
57
+ assert.ok(!/Accept all/.test(markdown), 'cookie banner must be dropped');
58
+ assert.ok(!/All rights reserved/.test(markdown), 'footer must be dropped');
59
+ assert.ok(!/Sign up/.test(markdown), 'nav must be dropped');
60
+
61
+ // No executable markup leaks through (script bodies, style).
62
+ assert.ok(!/dataLayer/.test(markdown), 'inline script body must be dropped');
63
+ assert.ok(!/footer analytics beacon/.test(markdown), 'trailing script must be dropped');
64
+ assert.ok(!/<script/i.test(markdown) && !/font-family/.test(markdown), 'no script/style markup');
65
+ });
66
+
67
+ test('TOKEN VOLUME: extraction alone yields a large reduction vs raw HTML (chrome dropped)', () => {
68
+ const { markdown } = extractContent({ body: HTML, contentType: 'text/html', url: 'http://x/docs' });
69
+ const rawTokens = defaultEstimate(HTML);
70
+ const extractedTokens = defaultEstimate(markdown);
71
+ // Extraction alone (before summarization) already cuts the page substantially
72
+ // by dropping scripts/nav/ads/JSON-LD/CSS. The ORDER-OF-MAGNITUDE reduction —
73
+ // "the result entering context vs raw HTML" — is asserted on the full
74
+ // summarized pipeline in test/web-fetch-agent.test.js.
75
+ assert.ok(extractedTokens > 0, 'extraction produced content');
76
+ assert.ok(
77
+ extractedTokens * 3 < rawTokens,
78
+ `expected >=3x token reduction from extraction, got raw=${rawTokens} extracted=${extractedTokens}`,
79
+ );
80
+ });
81
+
82
+ test('extractContent: JSON passes through verbatim (no mangling)', () => {
83
+ const json = JSON.stringify({ a: 1, b: [2, 3], c: { d: '<not html>' } });
84
+ const { kind, markdown, extracted } = extractContent({ body: json, contentType: 'application/json', url: 'http://x/api' });
85
+ assert.strictEqual(kind, 'json');
86
+ assert.strictEqual(extracted, false);
87
+ assert.strictEqual(markdown, json);
88
+ });
89
+
90
+ test('extractContent: plain text passes through verbatim', () => {
91
+ const txt = 'line one\nline two\n indented three';
92
+ const { kind, markdown } = extractContent({ body: txt, contentType: 'text/plain', url: 'http://x/f.txt' });
93
+ assert.strictEqual(kind, 'text');
94
+ assert.strictEqual(markdown, txt);
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Token budget
99
+ // ---------------------------------------------------------------------------
100
+
101
+ test('capToTokens: under budget is unchanged; over budget is truncated with a notice', () => {
102
+ const small = 'hello world';
103
+ const r1 = capToTokens(small, 6000, defaultEstimate);
104
+ assert.strictEqual(r1.truncated, false);
105
+ assert.strictEqual(r1.text, small);
106
+
107
+ const big = 'x'.repeat(40000); // ~10k tokens at char/4
108
+ const r2 = capToTokens(big, 1000, defaultEstimate);
109
+ assert.strictEqual(r2.truncated, true);
110
+ assert.match(r2.text, /\[\.\.\. truncated/);
111
+ // Capped to ~ the budget in chars (+ the notice), far below the original.
112
+ assert.ok(r2.text.length < big.length / 5);
113
+ });
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Markup-aware token estimate (Task W.4 Part 2)
117
+ // ---------------------------------------------------------------------------
118
+
119
+ test('markupEstimate: denser than the prose char/4 estimate', () => {
120
+ const { markupEstimate, MARKUP_CHARS_PER_TOKEN, DEFAULT_CHARS_PER_TOKEN } = require('../lib/web-extract');
121
+ assert.ok(MARKUP_CHARS_PER_TOKEN < DEFAULT_CHARS_PER_TOKEN, 'markup divisor is smaller');
122
+ const css = '.x{color:#ffffff;margin:0}'.repeat(1000);
123
+ assert.ok(markupEstimate(css) > defaultEstimate(css), 'markup estimates MORE tokens for the same chars');
124
+ // Exactly the divisor relationship.
125
+ assert.strictEqual(markupEstimate('x'.repeat(2500)), Math.ceil(2500 / MARKUP_CHARS_PER_TOKEN));
126
+ });
127
+
128
+ test('capToTokens: markup charsPerToken trims more aggressively than prose for the SAME budget', () => {
129
+ const { markupEstimate, MARKUP_CHARS_PER_TOKEN } = require('../lib/web-extract');
130
+ const big = '.x{color:#fff;background:#000}'.repeat(5000); // dense markup, well over budget
131
+ const budget = 1000;
132
+
133
+ const prose = capToTokens(big, budget, defaultEstimate); // char/4
134
+ const markup = capToTokens(big, budget, markupEstimate, MARKUP_CHARS_PER_TOKEN); // char/2.5
135
+ assert.ok(prose.truncated && markup.truncated, 'both truncate');
136
+
137
+ // The kept char budget reflects the divisor: markup keeps ~budget*2.5 chars,
138
+ // prose keeps ~budget*4 — so markup is trimmed more aggressively.
139
+ const keptChars = (r) => r.text.split('\n\n[... truncated')[0].length;
140
+ assert.ok(keptChars(markup) < keptChars(prose), `markup ${keptChars(markup)} < prose ${keptChars(prose)}`);
141
+ assert.strictEqual(keptChars(markup), Math.floor(budget * MARKUP_CHARS_PER_TOKEN));
142
+ assert.strictEqual(keptChars(prose), Math.floor(budget * 4));
143
+ });
144
+
145
+ test('capToTokens: prose path (no charsPerToken) is byte-for-byte unchanged', () => {
146
+ // The default divisor stays 4 — the prose path must not change.
147
+ const big = 'x'.repeat(40000);
148
+ const r = capToTokens(big, 1000, defaultEstimate);
149
+ assert.strictEqual(r.text.split('\n\n[... truncated')[0].length, 4000);
150
+ });
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Summary request builder — untrusted, data-only framing
154
+ // ---------------------------------------------------------------------------
155
+
156
+ test('buildSummaryMessages: page text is fenced as data-only and never framed as instructions', () => {
157
+ const msgs = buildSummaryMessages(`some content with ${INJECTION}`, 'what are layout phases?');
158
+ assert.strictEqual(msgs.length, 2);
159
+ const sys = msgs[0].content;
160
+ const user = msgs[1].content;
161
+ // System prompt instructs the model to treat the block as data and ignore injections.
162
+ assert.match(sys, /DATA/);
163
+ assert.match(sys, /[Nn]ever (obey|follow|act)/);
164
+ // The injection text lives INSIDE the fenced untrusted block, not in the system role.
165
+ assert.ok(user.includes(FENCE_OPEN), 'content is fenced');
166
+ assert.ok(user.includes(INJECTION), 'injection carried as data');
167
+ assert.ok(!sys.includes(INJECTION), 'injection must not be in the system prompt');
168
+ // Intent threaded in.
169
+ assert.match(user, /layout phases/);
170
+ });
171
+
172
+ test('summarizeWebContent: passes data-only messages to the injected chat and returns its text', async () => {
173
+ let seen = null;
174
+ const chat = async (messages) => { seen = messages; return ' SUMMARY: phases fire in order. '; };
175
+ const out = await summarizeWebContent({ markdown: `body ${INJECTION}`, intent: 'x', chat });
176
+ assert.strictEqual(out, 'SUMMARY: phases fire in order.');
177
+ // The summarizer received the injection only as fenced data.
178
+ assert.ok(seen[1].content.includes(INJECTION));
179
+ assert.match(seen[0].content, /DATA/);
180
+ });
181
+
182
+ test('summarizeWebContent: empty result throws (so the caller can fall back)', async () => {
183
+ await assert.rejects(() => summarizeWebContent({ markdown: 'x', chat: async () => ' ' }));
184
+ await assert.rejects(() => summarizeWebContent({ markdown: 'x', chat: null }));
185
+ });