@semalt-ai/code 1.8.5 → 1.20.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 (192) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/.github/workflows/ci.yml +69 -0
  3. package/ARCHITECTURE.md +6 -95
  4. package/CLAUDE.md +196 -316
  5. package/README.md +148 -4
  6. package/docs/ARCHITECTURE.md +1321 -0
  7. package/docs/CONFIG.md +340 -0
  8. package/docs/HISTORY.md +245 -0
  9. package/examples/embed.js +74 -0
  10. package/index.js +251 -10
  11. package/lib/agent.js +856 -120
  12. package/lib/api.js +239 -50
  13. package/lib/args.js +74 -2
  14. package/lib/audit.js +23 -1
  15. package/lib/background.js +584 -0
  16. package/lib/checkpoints.js +757 -0
  17. package/lib/commands/auth.js +94 -0
  18. package/lib/commands/chat-session.js +489 -0
  19. package/lib/commands/chat-slash.js +415 -0
  20. package/lib/commands/chat-turn.js +669 -0
  21. package/lib/commands/chat.js +407 -0
  22. package/lib/commands/custom.js +157 -0
  23. package/lib/commands/history-utils.js +66 -0
  24. package/lib/commands/index.js +268 -0
  25. package/lib/commands/mcp.js +113 -0
  26. package/lib/commands/oneshot.js +193 -0
  27. package/lib/commands/registry.js +269 -0
  28. package/lib/commands/tasks.js +89 -0
  29. package/lib/compact.js +87 -0
  30. package/lib/config.js +360 -11
  31. package/lib/constants.js +401 -3
  32. package/lib/deny.js +199 -0
  33. package/lib/doctor.js +160 -0
  34. package/lib/headless.js +202 -0
  35. package/lib/hooks.js +286 -0
  36. package/lib/images.js +270 -0
  37. package/lib/internals.js +49 -0
  38. package/lib/mcp/boundary.js +131 -0
  39. package/lib/mcp/client.js +270 -0
  40. package/lib/mcp/oauth.js +134 -0
  41. package/lib/memory.js +209 -0
  42. package/lib/metrics.js +37 -2
  43. package/lib/payload.js +54 -0
  44. package/lib/permission-rules.js +401 -0
  45. package/lib/permissions.js +123 -26
  46. package/lib/pricing.js +67 -0
  47. package/lib/proc.js +62 -0
  48. package/lib/prompts.js +99 -8
  49. package/lib/sandbox.js +568 -0
  50. package/lib/sdk.js +328 -0
  51. package/lib/secrets.js +211 -0
  52. package/lib/skills.js +223 -0
  53. package/lib/subagents.js +516 -0
  54. package/lib/tool_registry.js +2862 -0
  55. package/lib/tool_specs.js +263 -9
  56. package/lib/tools.js +352 -1039
  57. package/lib/ui/anim.js +86 -0
  58. package/lib/ui/ansi.js +17 -27
  59. package/lib/ui/chat-history.js +253 -71
  60. package/lib/ui/create-ui.js +67 -24
  61. package/lib/ui/diff.js +90 -25
  62. package/lib/ui/file-activity.js +236 -0
  63. package/lib/ui/format.js +195 -29
  64. package/lib/ui/input-field.js +21 -11
  65. package/lib/ui/md-stream.js +234 -0
  66. package/lib/ui/render-operation.js +113 -0
  67. package/lib/ui/select.js +1 -4
  68. package/lib/ui/status-bar.js +146 -36
  69. package/lib/ui/stream.js +20 -13
  70. package/lib/ui/theme.js +190 -44
  71. package/lib/ui/tool-operation.js +190 -0
  72. package/lib/ui/utils.js +9 -5
  73. package/lib/ui/web-activity.js +270 -0
  74. package/lib/ui/writer.js +159 -45
  75. package/lib/ui.js +1 -1
  76. package/lib/verify.js +229 -0
  77. package/lib/web-extract.js +213 -0
  78. package/lib/web-summarize.js +68 -0
  79. package/package.json +19 -4
  80. package/scripts/lint.js +57 -0
  81. package/test/agent-loop.test.js +389 -0
  82. package/test/anim-driver.test.js +153 -0
  83. package/test/ask-user-display.test.js +226 -0
  84. package/test/ask-user-gate.test.js +231 -0
  85. package/test/background.test.js +414 -0
  86. package/test/chat-history-nocolor.test.js +155 -0
  87. package/test/chat-relogin.test.js +207 -0
  88. package/test/chat.test.js +114 -0
  89. package/test/checkpoints-agent.test.js +181 -0
  90. package/test/checkpoints.test.js +650 -0
  91. package/test/command-registry.test.js +160 -0
  92. package/test/compact.test.js +116 -0
  93. package/test/completion-lazy.test.js +52 -0
  94. package/test/config-merge.test.js +324 -0
  95. package/test/config-quarantine.test.js +128 -0
  96. package/test/config-write-guard-allow-anywhere.test.js +56 -0
  97. package/test/config-write-guard-skip.test.js +46 -0
  98. package/test/config-write-guard.test.js +153 -0
  99. package/test/context-split.test.js +215 -0
  100. package/test/cost-doctor.test.js +142 -0
  101. package/test/custom-commands-chat.test.js +106 -0
  102. package/test/custom-commands.test.js +230 -0
  103. package/test/defer-detail-band.test.js +403 -0
  104. package/test/deny-windows.test.js +120 -0
  105. package/test/deny.test.js +83 -0
  106. package/test/detail-band-tab-flatten.test.js +242 -0
  107. package/test/download-allow-anywhere.test.js +66 -0
  108. package/test/download-confine.test.js +153 -0
  109. package/test/exec-diff.test.js +268 -0
  110. package/test/executors.test.js +599 -0
  111. package/test/extract-tool-calls.test.js +349 -0
  112. package/test/fetch-url-validation.test.js +219 -0
  113. package/test/file-activity.test.js +522 -0
  114. package/test/fixtures/tool-calls.js +57 -0
  115. package/test/fixtures/web-page.js +91 -0
  116. package/test/git-tools.test.js +384 -0
  117. package/test/grep-glob-serialize.test.js +242 -0
  118. package/test/grep-glob.test.js +268 -0
  119. package/test/grep-path-target.test.js +227 -0
  120. package/test/harness/README.md +57 -0
  121. package/test/harness/chat-harness.js +143 -0
  122. package/test/harness/memwarn-headless-child.js +65 -0
  123. package/test/harness/mock-llm.js +120 -0
  124. package/test/harness/mock-mcp-server.js +142 -0
  125. package/test/harness/sse-server.js +69 -0
  126. package/test/headless.test.js +348 -0
  127. package/test/history-utils.test.js +88 -0
  128. package/test/hooks-agent.test.js +238 -0
  129. package/test/hooks-verify-sandbox.test.js +232 -0
  130. package/test/hooks.test.js +216 -0
  131. package/test/http-get-user-agent.test.js +142 -0
  132. package/test/images-api.test.js +208 -0
  133. package/test/images.test.js +238 -0
  134. package/test/input-field-ctrl-o.test.js +37 -0
  135. package/test/live-height-physical.test.js +281 -0
  136. package/test/max-iterations.test.js +218 -0
  137. package/test/mcp-boundary.test.js +57 -0
  138. package/test/mcp-client.test.js +267 -0
  139. package/test/mcp-oauth.test.js +86 -0
  140. package/test/md-stream.test.js +183 -0
  141. package/test/memory-truncation-warning.test.js +222 -0
  142. package/test/memory.test.js +198 -0
  143. package/test/native-dispatch.test.js +409 -0
  144. package/test/native-live-narration.test.js +254 -0
  145. package/test/output-chokepoint.test.js +188 -0
  146. package/test/output-heredoc-leak.test.js +195 -0
  147. package/test/output-preview.test.js +245 -0
  148. package/test/path-guards.test.js +134 -0
  149. package/test/payload.test.js +99 -0
  150. package/test/permission-rules-agent.test.js +210 -0
  151. package/test/permission-rules.test.js +297 -0
  152. package/test/permissions.test.js +362 -0
  153. package/test/plan-mode.test.js +167 -0
  154. package/test/read-paginate.test.js +275 -0
  155. package/test/readonly-tools.test.js +177 -0
  156. package/test/render-operation.test.js +317 -0
  157. package/test/replay-descriptor-xml.test.js +216 -0
  158. package/test/replay-descriptor.test.js +189 -0
  159. package/test/replay-web-aggregate.test.js +291 -0
  160. package/test/replay-web-persist.test.js +241 -0
  161. package/test/result-cap.test.js +233 -0
  162. package/test/running-glyph-anim.test.js +111 -0
  163. package/test/sandbox-agent.test.js +147 -0
  164. package/test/sandbox-integration.test.js +216 -0
  165. package/test/sandbox.test.js +408 -0
  166. package/test/sdk.test.js +234 -0
  167. package/test/shell-output-cap.test.js +181 -0
  168. package/test/skills-chat.test.js +110 -0
  169. package/test/skills.test.js +295 -0
  170. package/test/smoke.test.js +68 -0
  171. package/test/status-bar-driver.test.js +93 -0
  172. package/test/status-bar-pause.test.js +164 -0
  173. package/test/status-bar-resync.test.js +188 -0
  174. package/test/stream-parser.test.js +171 -0
  175. package/test/subagents-agent.test.js +178 -0
  176. package/test/subagents.test.js +222 -0
  177. package/test/theme-palette.test.js +166 -0
  178. package/test/tool-registry.test.js +85 -0
  179. package/test/trim-budget.test.js +101 -0
  180. package/test/truncate-visible.test.js +78 -0
  181. package/test/verify-agent.test.js +317 -0
  182. package/test/verify.test.js +141 -0
  183. package/test/view-image.test.js +199 -0
  184. package/test/web-activity-ordering.test.js +203 -0
  185. package/test/web-activity.test.js +207 -0
  186. package/test/web-data-extraction-guidance.test.js +71 -0
  187. package/test/web-extract.test.js +185 -0
  188. package/test/web-fetch-agent.test.js +291 -0
  189. package/test/web-fetch-mode.test.js +193 -0
  190. package/test/web-search.test.js +380 -0
  191. package/lib/commands.js +0 -1438
  192. package/path +0 -1
package/lib/memory.js ADDED
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Project memory — AGENTS.md / CLAUDE.md hierarchy (Task 2.3)
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // On session start the agent loads project-local instruction files and appends
8
+ // them to the system prompt, marked as distinct, trusted project guidance. The
9
+ // hierarchy, concatenated in this order (all that exist):
10
+ //
11
+ // 1. global ~/.semalt-ai/AGENTS.md
12
+ // 2. project root <repo root>/AGENTS.md (repo root = nearest .git ancestor)
13
+ // 3. cwd <cwd>/AGENTS.md (only when CWD is nested below root)
14
+ //
15
+ // At each level CLAUDE.md is an alias for AGENTS.md: AGENTS.md is preferred when
16
+ // both exist, and the choice (plus the ignored CLAUDE.md) is reported. The total
17
+ // size is bounded — oversized memory is truncated with a visible notice rather
18
+ // than blowing the context. API-key/secret files are never involved here; these
19
+ // are plain project docs.
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ // Keep memory from dominating the context. 32 KB is comfortably above a typical
26
+ // AGENTS.md yet far below any model window.
27
+ const DEFAULT_MEMORY_MAX_BYTES = 32 * 1024;
28
+
29
+ function _isFile(p) {
30
+ try { return fs.statSync(p).isFile(); } catch { return false; }
31
+ }
32
+
33
+ // Nearest ancestor (inclusive) containing a .git entry, or null.
34
+ function findRepoRoot(startDir) {
35
+ let dir = path.resolve(startDir);
36
+ while (true) {
37
+ try { if (fs.existsSync(path.join(dir, '.git'))) return dir; } catch {}
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) return null;
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ // Pick the memory file for a directory: AGENTS.md preferred, CLAUDE.md alias.
45
+ // Returns { path, name, alsoPresent } or null. `alsoPresent` is true when both
46
+ // files exist (CLAUDE.md was present but ignored in favor of AGENTS.md).
47
+ function _pickMemoryFile(dir) {
48
+ const agents = path.join(dir, 'AGENTS.md');
49
+ const claude = path.join(dir, 'CLAUDE.md');
50
+ const hasAgents = _isFile(agents);
51
+ const hasClaude = _isFile(claude);
52
+ if (hasAgents) return { path: agents, name: 'AGENTS.md', alsoPresent: hasClaude };
53
+ if (hasClaude) return { path: claude, name: 'CLAUDE.md', alsoPresent: false };
54
+ return null;
55
+ }
56
+
57
+ // Resolve the ordered set of memory files for a (cwd, home), de-duplicated by
58
+ // resolved path so a level that coincides with another is not loaded twice.
59
+ function discoverMemoryFiles(cwd = process.cwd(), home = os.homedir()) {
60
+ const out = [];
61
+ const seen = new Set();
62
+ const add = (dir, source) => {
63
+ const picked = _pickMemoryFile(dir);
64
+ if (!picked) return;
65
+ const real = path.resolve(picked.path);
66
+ if (seen.has(real)) return;
67
+ seen.add(real);
68
+ out.push({ ...picked, source });
69
+ };
70
+
71
+ add(path.join(home, '.semalt-ai'), 'global');
72
+ const repoRoot = findRepoRoot(cwd);
73
+ const projectRoot = repoRoot || cwd;
74
+ add(projectRoot, 'project-root');
75
+ if (path.resolve(cwd) !== path.resolve(projectRoot)) add(cwd, 'cwd');
76
+ return out;
77
+ }
78
+
79
+ // Per-file truncation accounting. The block joins all loaded files (with a
80
+ // `# path (source)\n` header each, separated by '\n\n') and then slices the
81
+ // whole body at the cap, so a file may be fully kept, partially cut, or wholly
82
+ // dropped depending on where it falls. This mirrors the exact char-based slice
83
+ // in _buildBlock (NOT changed — see the comment there) to report which files
84
+ // lost content and by how much. Returns one entry per file that was truncated:
85
+ // { path, source, originalBytes, loadedBytes }
86
+ function _truncatedFileDetails(loadedFiles, cutChars) {
87
+ const out = [];
88
+ let offset = 0; // char offset of the current section within the joined body
89
+ for (let i = 0; i < loadedFiles.length; i++) {
90
+ const f = loadedFiles[i];
91
+ if (i > 0) offset += 2; // the '\n\n' separator between sections
92
+ const header = `# ${f.path} (${f.source})\n`;
93
+ const contentStart = offset + header.length;
94
+ const survivedChars = Math.max(0, Math.min(f.content.length, cutChars - contentStart));
95
+ const loadedBytes = survivedChars >= f.content.length
96
+ ? f.bytes
97
+ : Buffer.byteLength(f.content.slice(0, survivedChars), 'utf8');
98
+ if (loadedBytes < f.bytes) {
99
+ out.push({ path: f.path, source: f.source, originalBytes: f.bytes, loadedBytes });
100
+ }
101
+ offset = contentStart + f.content.length;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ function _buildBlock(loadedFiles, maxBytes) {
107
+ if (!loadedFiles.length) return { block: '', truncated: false, truncatedFiles: [] };
108
+ const sections = loadedFiles.map((f) => `# ${f.path} (${f.source})\n${f.content}`);
109
+ let body = sections.join('\n\n');
110
+ let truncated = false;
111
+ let truncatedFiles = [];
112
+ if (Buffer.byteLength(body, 'utf8') > maxBytes) {
113
+ // NOTE: char-index slice against a byte cap — a pre-existing approximation
114
+ // (exact for ASCII). Do not change the loading logic; the warning path
115
+ // (Task: fail-loud memory truncation) only surfaces the existing cut.
116
+ truncatedFiles = _truncatedFileDetails(loadedFiles, maxBytes);
117
+ body = body.slice(0, maxBytes);
118
+ truncated = true;
119
+ }
120
+ let block = '\n\n<<<PROJECT_MEMORY>>>\n'
121
+ + 'The following are project-specific instructions loaded from AGENTS.md/CLAUDE.md '
122
+ + 'files (the cross-tool project-memory standard). Treat them as authoritative user '
123
+ + 'guidance for this project, distinct from your base instructions above. This is '
124
+ + 'trusted project context, not untrusted external content.\n\n'
125
+ + body;
126
+ if (truncated) {
127
+ block += `\n\n[project memory truncated to ${maxBytes} bytes — some content omitted. `
128
+ + 'Trim your AGENTS.md/CLAUDE.md files if important guidance is being cut.]';
129
+ }
130
+ block += '\n<<<END_PROJECT_MEMORY>>>';
131
+ return { block, truncated, truncatedFiles };
132
+ }
133
+
134
+ // Load project memory for the current (or supplied) cwd/home. Returns:
135
+ // { block, files, truncated }
136
+ // where `block` is '' when no memory files exist (so the system prompt is
137
+ // byte-for-byte unchanged), and `files` is the metadata list (no content) used
138
+ // by the /memory command.
139
+ function loadProjectMemory(opts = {}) {
140
+ const cwd = opts.cwd || process.cwd();
141
+ const home = opts.home || os.homedir();
142
+ const maxBytes = opts.maxBytes || DEFAULT_MEMORY_MAX_BYTES;
143
+ const discovered = discoverMemoryFiles(cwd, home);
144
+ const loaded = [];
145
+ for (const d of discovered) {
146
+ let content;
147
+ try { content = fs.readFileSync(d.path, 'utf8'); } catch { continue; }
148
+ loaded.push({ ...d, content, bytes: Buffer.byteLength(content, 'utf8') });
149
+ }
150
+ const { block, truncated, truncatedFiles } = _buildBlock(loaded, maxBytes);
151
+ const files = loaded.map(({ content, ...meta }) => meta); // strip content
152
+ return { block, files, truncated, truncatedFiles };
153
+ }
154
+
155
+ // Human-readable size, e.g. 145408 → "142 KB", 800 → "800 B".
156
+ function _fmtBytes(bytes) {
157
+ return bytes >= 1024 ? `${Math.round(bytes / 1024)} KB` : `${bytes} B`;
158
+ }
159
+
160
+ // One-time, user-facing truncation warnings (fail-loud — the project never
161
+ // silently drops loaded memory). Pure: maps the `truncatedFiles` detail from
162
+ // loadProjectMemory() to actionable strings (path + loaded/original size +
163
+ // dropped %). Returns [] when nothing was truncated, so callers warn only when
164
+ // content was actually dropped. This text is for the USER channel (stderr /
165
+ // chat system line / SDK 'warning' event) — never the model/system prompt.
166
+ function memoryTruncationWarnings(result) {
167
+ const files = (result && result.truncatedFiles) || [];
168
+ return files.map((t) => {
169
+ const dropped = Math.max(0, t.originalBytes - t.loadedBytes);
170
+ const pct = t.originalBytes > 0 ? Math.round((dropped / t.originalBytes) * 100) : 0;
171
+ return `⚠ Memory file ${t.path} truncated: loaded ${_fmtBytes(t.loadedBytes)} of `
172
+ + `${_fmtBytes(t.originalBytes)} (${pct}% dropped). `
173
+ + 'Consider trimming it to the most relevant guidance.';
174
+ });
175
+ }
176
+
177
+ // Human-readable status lines for the /memory command: which files loaded, their
178
+ // resolved paths, the alias choice, truncation, and where to edit.
179
+ function memoryStatusLines(result) {
180
+ const lines = [];
181
+ if (!result.files.length) {
182
+ lines.push('No project memory files found.');
183
+ lines.push('Create an AGENTS.md (or CLAUDE.md) in your repo root to add project instructions.');
184
+ return lines;
185
+ }
186
+ lines.push(`Loaded ${result.files.length} project memory file(s):`);
187
+ for (const f of result.files) {
188
+ let line = ` • ${f.path} [${f.source}]`;
189
+ if (f.alsoPresent) line += ' (chose AGENTS.md; CLAUDE.md also present, ignored)';
190
+ lines.push(line);
191
+ }
192
+ if (result.truncated) {
193
+ lines.push('⚠ Project memory was truncated (too large). Trim your memory files.');
194
+ }
195
+ const editTarget = result.files.find((f) => f.source === 'cwd')
196
+ || result.files.find((f) => f.source === 'project-root')
197
+ || result.files[0];
198
+ lines.push(`Edit project memory: ${editTarget.path}`);
199
+ return lines;
200
+ }
201
+
202
+ module.exports = {
203
+ DEFAULT_MEMORY_MAX_BYTES,
204
+ findRepoRoot,
205
+ discoverMemoryFiles,
206
+ loadProjectMemory,
207
+ memoryStatusLines,
208
+ memoryTruncationWarnings,
209
+ };
package/lib/metrics.js CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  const { THEME } = require('./ui');
4
4
 
5
+ // Compact token count for the estimated-split summary row (Variant B): the
6
+ // base/working estimates are abbreviated (12k, 5.6k) so the row fits the fixed
7
+ // summary-box width. They're estimates, so sub-thousand precision is noise.
8
+ function abbrevTokens(n) {
9
+ const v = Math.max(0, Math.round(Number(n) || 0));
10
+ if (v < 1000) return String(v);
11
+ const k = v / 1000;
12
+ return (k < 10 ? k.toFixed(1) : String(Math.round(k))) + 'k';
13
+ }
14
+
5
15
  class Metrics {
6
16
  constructor(modelTokenLimit = null) {
7
17
  this.sessionStart = Date.now();
@@ -10,16 +20,21 @@ class Metrics {
10
20
  }
11
21
 
12
22
  startTurn() {
13
- this.turns.push({ start: Date.now(), promptTokens: 0, completionTokens: 0 });
23
+ this.turns.push({ start: Date.now(), promptTokens: 0, completionTokens: 0, baseEst: 0, workingEst: 0 });
14
24
  }
15
25
 
16
- endTurn(usage, model) {
26
+ endTurn(usage, model, contextEstimate) {
17
27
  const last = this.turns[this.turns.length - 1];
18
28
  if (!last) return;
19
29
  last.end = Date.now();
20
30
  last.promptTokens = (usage && usage.prompt_tokens) || 0;
21
31
  last.completionTokens = (usage && usage.completion_tokens) || 0;
22
32
  last.model = model;
33
+ // Estimated base/working split (Variant B, display-only). The real
34
+ // promptTokens above stays the truth anchor; these are char/4 estimates of
35
+ // the same prompt's parts, recomputed per request by the api client.
36
+ last.baseEst = (contextEstimate && contextEstimate.base) || 0;
37
+ last.workingEst = (contextEstimate && contextEstimate.working) || 0;
23
38
  }
24
39
 
25
40
  totalTokens() {
@@ -31,6 +46,19 @@ class Metrics {
31
46
  return this.turns[this.turns.length - 1].promptTokens;
32
47
  }
33
48
 
49
+ // Estimated split of the current (last turn's) context — display-only
50
+ // (Variant B). Both are char/4 estimates that sum consistently; the real
51
+ // contextTokens() above is the measured anchor shown alongside them.
52
+ contextBaseEst() {
53
+ if (!this.turns.length) return 0;
54
+ return this.turns[this.turns.length - 1].baseEst || 0;
55
+ }
56
+
57
+ contextWorkingEst() {
58
+ if (!this.turns.length) return 0;
59
+ return this.turns[this.turns.length - 1].workingEst || 0;
60
+ }
61
+
34
62
  tokenLimitStatus() {
35
63
  const used = this.contextTokens();
36
64
  if (this.modelTokenLimit == null) {
@@ -94,6 +122,13 @@ class Metrics {
94
122
  lines.push(row(` Context used: ${this.contextTokens()}`));
95
123
  lines.push(row(` Token limit: ${status.used}/${status.limit} (${status.pct}%)`));
96
124
  }
125
+ // Estimated breakdown of the measured context above (Variant B). The ~
126
+ // marks these as estimates; the measured total is the line above (no ~).
127
+ const baseEst = this.contextBaseEst();
128
+ const workingEst = this.contextWorkingEst();
129
+ if (baseEst > 0 || workingEst > 0) {
130
+ lines.push(row(` Est. split: ~${abbrevTokens(workingEst)} work · ~${abbrevTokens(baseEst)} base`));
131
+ }
97
132
  }
98
133
 
99
134
  lines.push(row(` Duration: ${durationStr}`));
package/lib/payload.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Optional chat/completions payload augmentations (Task 2.7).
5
+ // ---------------------------------------------------------------------------
6
+ //
7
+ // Pure functions, gated by config + model support, applied to the request body
8
+ // just before it is serialized. Kept separate from api.js so the exact field
9
+ // presence/absence is unit-testable without a live endpoint.
10
+
11
+ // Prompt caching: mark the STABLE PREFIX — the last system message and the end
12
+ // of the tools array — with Anthropic-style cache_control:{type:'ephemeral'} so
13
+ // providers that honor it reuse the prefix across turns. Only mutates when
14
+ // enabled; absent otherwise. Gated upstream by config.prompt_caching, so it is
15
+ // never sent to endpoints the user hasn't opted in for.
16
+ function applyPromptCaching(payload, enabled) {
17
+ if (!enabled || !payload) return payload;
18
+ if (Array.isArray(payload.messages)) {
19
+ for (let i = payload.messages.length - 1; i >= 0; i--) {
20
+ const m = payload.messages[i];
21
+ if (m && m.role === 'system') {
22
+ payload.messages[i] = { ...m, cache_control: { type: 'ephemeral' } };
23
+ break;
24
+ }
25
+ }
26
+ }
27
+ if (Array.isArray(payload.tools) && payload.tools.length) {
28
+ const last = payload.tools.length - 1;
29
+ payload.tools[last] = { ...payload.tools[last], cache_control: { type: 'ephemeral' } };
30
+ }
31
+ return payload;
32
+ }
33
+
34
+ // Heuristic for OpenAI-style `reasoning_effort` support: reasoning model
35
+ // families (o1–o4, gpt-5, *-reasoning/-thinking, deepseek-r1, qwq).
36
+ function supportsReasoningEffort(model) {
37
+ if (typeof model !== 'string' || !model) return false;
38
+ return /(^|[/\-])o[1-4]([-/]|$|mini|preview)|gpt-5|reason|deepseek-r1|(^|[/\-])r1([-/]|$)|thinking|qwq/i.test(model);
39
+ }
40
+
41
+ const VALID_EFFORTS = new Set(['minimal', 'low', 'medium', 'high']);
42
+
43
+ // Add reasoning_effort when configured and the model supports it (or support is
44
+ // forced for a model the heuristic misses). No-op otherwise.
45
+ function applyReasoningEffort(payload, effort, model, { force = false } = {}) {
46
+ if (!payload || !effort) return payload;
47
+ const e = String(effort).toLowerCase();
48
+ if (!VALID_EFFORTS.has(e)) return payload;
49
+ if (!force && !supportsReasoningEffort(model)) return payload;
50
+ payload.reasoning_effort = e;
51
+ return payload;
52
+ }
53
+
54
+ module.exports = { applyPromptCaching, supportsReasoningEffort, applyReasoningEffort, VALID_EFFORTS };