@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,270 @@
1
+ 'use strict';
2
+
3
+ // MCP client manager (Task 3.3).
4
+ // ----------------------------------------------------------------------------
5
+ // Connects to the MCP servers configured under `config.mcp.servers`, discovers
6
+ // each server's tools, and registers them into the runtime tool registry
7
+ // (lib/tool_registry.js dynamic API) under the namespace `mcp__<server>__<tool>`
8
+ // so they dispatch through the SAME agent loop as built-ins.
9
+ //
10
+ // Security posture (required by the task):
11
+ // * MCP tool RESULTS are untrusted external content — the execute() here
12
+ // returns `{ mcp:true, content, isError }`, and the agent loop wraps that
13
+ // payload in the UNTRUSTED_EXTERNAL_CONTENT delimiter (lib/agent.js),
14
+ // identical to http_get.
15
+ // * MCP tools are arbitrary/external, so they REQUIRE APPROVAL by default —
16
+ // never auto-allowed by the `--allow-*` tiers. Per-server/per-tool opt-in
17
+ // comes from config (`allow: [...]` or `allowAll: true`).
18
+ //
19
+ // Robustness: a server that fails to launch/connect degrades gracefully (the
20
+ // failure is recorded in status, a warning is logged, and the CLI continues) —
21
+ // it never crashes the process. The SDK itself is reached only through
22
+ // lib/mcp/boundary.js (the single CJS↔ESM bridge); this module never imports it.
23
+
24
+ const realBoundary = require('./boundary');
25
+ const {
26
+ registerDynamicTool, unregisterDynamicTool,
27
+ } = require('../tool_registry');
28
+ const { logToolCall } = require('../audit');
29
+ const { createKeychainOAuthProvider } = require('./oauth');
30
+
31
+ const realRegistry = { registerDynamicTool, unregisterDynamicTool };
32
+
33
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15000;
34
+
35
+ // `mcp__<server>__<tool>` with non-identifier chars folded to `_` so the result
36
+ // is a valid native function name (the LLM echoes it back verbatim in tool_calls).
37
+ function mcpToolName(server, tool) {
38
+ const s = String(server).replace(/[^a-zA-Z0-9_-]/g, '_');
39
+ const t = String(tool).replace(/[^a-zA-Z0-9_-]/g, '_');
40
+ return `mcp__${s}__${t}`;
41
+ }
42
+
43
+ // Flatten an MCP CallToolResult's content blocks into a single text string.
44
+ // Text blocks pass through; non-text blocks (image/resource/…) are summarized
45
+ // as a JSON line so nothing is silently dropped from the model's view.
46
+ function mcpResultToText(result) {
47
+ if (!result) return '';
48
+ const blocks = Array.isArray(result.content) ? result.content : [];
49
+ const parts = [];
50
+ for (const b of blocks) {
51
+ if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
52
+ else if (b && typeof b === 'object') parts.push(`[${b.type || 'content'}] ${JSON.stringify(b)}`);
53
+ }
54
+ if (!parts.length && result.structuredContent !== undefined) {
55
+ parts.push(JSON.stringify(result.structuredContent));
56
+ }
57
+ return parts.join('\n');
58
+ }
59
+
60
+ // Decide whether a discovered tool is pre-approved (opt-in) for this server.
61
+ // Matches either the bare tool name or its full namespaced form in `allow`.
62
+ function isToolAllowed(spec, toolName, namespacedName) {
63
+ if (!spec) return false;
64
+ if (spec.allowAll === true) return true;
65
+ const allow = Array.isArray(spec.allow) ? spec.allow : [];
66
+ return allow.includes(toolName) || allow.includes(namespacedName);
67
+ }
68
+
69
+ // Build the dynamic tool-registry entry for one discovered MCP tool. Shape is
70
+ // identical to a static entry so the agent loop dispatches it the same way.
71
+ function buildMcpToolEntry({ server, spec, tool, client }) {
72
+ const name = mcpToolName(server, tool.name);
73
+ const allowed = isToolAllowed(spec, tool.name, name);
74
+ const description = `[MCP:${server}] ${tool.description || tool.name}`;
75
+
76
+ return {
77
+ tool: name,
78
+ mcp: true,
79
+ server,
80
+ origName: tool.name,
81
+ spec: {
82
+ description,
83
+ parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
84
+ ? tool.inputSchema
85
+ : { type: 'object', properties: {} },
86
+ },
87
+ // Native function-calling path: the model emits { name, arguments } → the
88
+ // whole arguments object becomes the single positional arg of the tuple.
89
+ fromParams: (p) => [name, p || {}],
90
+ // XML path (non-native models): <mcp__server__tool>{json args}</mcp__server__tool>.
91
+ parseXml: (text) => {
92
+ const out = [];
93
+ const re = new RegExp(`<${name}\\s*>([\\s\\S]*?)<\\/${name}>`, 'g');
94
+ for (const m of text.matchAll(re)) {
95
+ const body = (m[1] || '').trim();
96
+ let args = {};
97
+ if (body) { try { args = JSON.parse(body); } catch { args = {}; } }
98
+ out.push([name, args]);
99
+ }
100
+ // Self-closing form with no args: <mcp__server__tool/>
101
+ const selfRe = new RegExp(`<${name}\\s*/>`, 'g');
102
+ for (const _m of text.matchAll(selfRe)) out.push([name, {}]);
103
+ return out;
104
+ },
105
+ // Approval gate: MCP tools require approval by DEFAULT. Opt-in via config
106
+ // (allow/allowAll) returns null → no gate (treated like a read-only tool).
107
+ permission: () => {
108
+ if (allowed) return null;
109
+ return { actionType: 'mcp', description: `MCP ${server}/${tool.name}`, tag: name };
110
+ },
111
+ execute: async (_ctx, args, options) => {
112
+ const params = (args && args[0]) || {};
113
+ const signal = (options && options.signal) || undefined;
114
+ try {
115
+ const res = await client.callTool(
116
+ { name: tool.name, arguments: params },
117
+ undefined,
118
+ signal ? { signal } : undefined,
119
+ );
120
+ logToolCall(name, { server, tool: tool.name }, true, res && res.isError ? 'error' : 'ok');
121
+ return { mcp: true, content: mcpResultToText(res), isError: !!(res && res.isError) };
122
+ } catch (err) {
123
+ logToolCall(name, { server, tool: tool.name }, true, 'error');
124
+ return { error: err && err.message ? err.message : String(err) };
125
+ }
126
+ },
127
+ };
128
+ }
129
+
130
+ function withTimeout(promise, ms, onTimeoutMessage) {
131
+ let timer;
132
+ const timeout = new Promise((_, reject) => {
133
+ timer = setTimeout(() => reject(new Error(onTimeoutMessage)), ms);
134
+ });
135
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
136
+ }
137
+
138
+ function createMcpManager({
139
+ getConfig,
140
+ boundary = realBoundary,
141
+ registry = realRegistry,
142
+ oauthFactory = createKeychainOAuthProvider,
143
+ logger = null,
144
+ connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
145
+ } = {}) {
146
+ const _clients = new Map(); // server name → connected Client
147
+ const _toolNames = []; // registered dynamic tool names
148
+ let _status = []; // per-server status records
149
+
150
+ function warn(msg) {
151
+ if (typeof logger === 'function') logger(msg);
152
+ }
153
+
154
+ // Resolve the transport for a server spec. Throws on an invalid/missing spec.
155
+ async function buildTransport(name, spec) {
156
+ const transport = (spec.transport || (spec.url ? 'http' : 'stdio')).toLowerCase();
157
+ if (transport === 'stdio') {
158
+ if (!spec.command) throw new Error(`stdio server "${name}" requires a "command"`);
159
+ return boundary.createStdioTransport({
160
+ command: spec.command,
161
+ args: Array.isArray(spec.args) ? spec.args : [],
162
+ env: spec.env && typeof spec.env === 'object' ? spec.env : undefined,
163
+ cwd: spec.cwd || undefined,
164
+ });
165
+ }
166
+ if (transport === 'http' || transport === 'streamable-http' || transport === 'sse') {
167
+ if (!spec.url) throw new Error(`remote server "${name}" requires a "url"`);
168
+ const opts = {};
169
+ if (spec.headers && typeof spec.headers === 'object') {
170
+ opts.requestInit = { headers: spec.headers };
171
+ }
172
+ // OAuth: opt-in via spec.oauth (or spec.auth === 'oauth'). Tokens are
173
+ // persisted in the OS keychain by the provider, never in config.
174
+ if (spec.oauth === true || spec.auth === 'oauth') {
175
+ opts.authProvider = oauthFactory(name, { url: spec.url });
176
+ }
177
+ return transport === 'sse'
178
+ ? boundary.createSseTransport(spec.url, opts)
179
+ : boundary.createStreamableHttpTransport(spec.url, opts);
180
+ }
181
+ throw new Error(`server "${name}" has unknown transport "${transport}"`);
182
+ }
183
+
184
+ async function connectServer(name, spec) {
185
+ const transportKind = (spec.transport || (spec.url ? 'http' : 'stdio')).toLowerCase();
186
+ const record = { name, transport: transportKind, state: 'connecting', tools: [], error: null };
187
+ if (spec.disabled) {
188
+ record.state = 'disabled';
189
+ return record;
190
+ }
191
+ let client = null;
192
+ try {
193
+ const transport = await buildTransport(name, spec);
194
+ client = await boundary.createClient();
195
+ await withTimeout(
196
+ client.connect(transport),
197
+ connectTimeoutMs,
198
+ `connect timed out after ${connectTimeoutMs}ms`,
199
+ );
200
+ const listed = await client.listTools();
201
+ const tools = Array.isArray(listed && listed.tools) ? listed.tools : [];
202
+ for (const tool of tools) {
203
+ if (!tool || typeof tool.name !== 'string') continue;
204
+ const entry = buildMcpToolEntry({ server: name, spec, tool, client });
205
+ registry.registerDynamicTool(entry);
206
+ record.tools.push(entry.tool);
207
+ _toolNames.push(entry.tool);
208
+ }
209
+ record.state = 'connected';
210
+ _clients.set(name, client);
211
+ } catch (err) {
212
+ record.state = 'failed';
213
+ record.error = err && err.message ? err.message : String(err);
214
+ warn(`MCP server "${name}" failed: ${record.error}`);
215
+ // Best-effort cleanup of a half-open client so a failed server leaks nothing.
216
+ if (client) { try { await client.close(); } catch { /* ignore */ } }
217
+ }
218
+ return record;
219
+ }
220
+
221
+ // Connect to every configured server. Failures are isolated per-server (one
222
+ // bad server never blocks the others, and never throws out of here).
223
+ async function connectAll() {
224
+ const cfg = getConfig ? getConfig() : {};
225
+ const servers = (cfg && cfg.mcp && cfg.mcp.servers) || {};
226
+ const names = Object.keys(servers);
227
+ const records = [];
228
+ for (const name of names) {
229
+ records.push(await connectServer(name, servers[name] || {}));
230
+ }
231
+ _status = records;
232
+ return records;
233
+ }
234
+
235
+ function status() {
236
+ return _status.map((r) => ({ ...r, tools: r.tools.slice() }));
237
+ }
238
+
239
+ function registeredToolNames() {
240
+ return _toolNames.slice();
241
+ }
242
+
243
+ async function shutdown() {
244
+ for (const name of _toolNames) {
245
+ try { registry.unregisterDynamicTool(name); } catch { /* ignore */ }
246
+ }
247
+ _toolNames.length = 0;
248
+ for (const client of _clients.values()) {
249
+ try { await client.close(); } catch { /* ignore */ }
250
+ }
251
+ _clients.clear();
252
+ _status = [];
253
+ }
254
+
255
+ return {
256
+ connectAll,
257
+ connectServer,
258
+ status,
259
+ registeredToolNames,
260
+ shutdown,
261
+ };
262
+ }
263
+
264
+ module.exports = {
265
+ createMcpManager,
266
+ buildMcpToolEntry,
267
+ mcpToolName,
268
+ mcpResultToText,
269
+ isToolAllowed,
270
+ };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ // MCP OAuth — keychain-backed token store (Task 3.3).
4
+ // ----------------------------------------------------------------------------
5
+ // Remote MCP servers (HTTP/SSE) may require OAuth. The SDK drives the OAuth 2.1
6
+ // + PKCE flow through an `OAuthClientProvider` interface; this module implements
7
+ // that interface and persists EVERYTHING sensitive — tokens, the dynamically
8
+ // registered client credentials, and the PKCE code verifier — in the OS
9
+ // keychain, never in plaintext config. That mirrors the Phase 0 secret path
10
+ // (lib/secrets.js): secrets live in the keychain, config holds only references.
11
+ //
12
+ // The keychain access is injected (`store`) so it can be unit-tested with an
13
+ // in-memory fake; in production it defaults to the generic keychain helpers in
14
+ // lib/secrets.js under the service `semalt-code-mcp`, keyed per server.
15
+ //
16
+ // Records are JSON blobs stored under three accounts per server:
17
+ // <server>:tokens — the OAuthTokens (access/refresh/expiry)
18
+ // <server>:client — the registered OAuthClientInformation
19
+ // <server>:verifier — the in-flight PKCE code verifier
20
+ //
21
+ // `redirectToAuthorization` opens the user's browser (best-effort) and prints
22
+ // the URL so headless/remote sessions can complete the flow manually.
23
+
24
+ const { spawn } = require('child_process');
25
+ const {
26
+ keychainGetItem, keychainSetItem, keychainDeleteItem,
27
+ } = require('../secrets');
28
+
29
+ const MCP_KEYCHAIN_SERVICE = 'semalt-code-mcp';
30
+
31
+ // Default production store: the OS keychain via lib/secrets.js generic helpers.
32
+ function keychainStore(service = MCP_KEYCHAIN_SERVICE) {
33
+ return {
34
+ get(account) { return keychainGetItem(service, account); },
35
+ set(account, value) { return keychainSetItem(service, account, value, `MCP OAuth ${account}`); },
36
+ delete(account) { return keychainDeleteItem(service, account); },
37
+ };
38
+ }
39
+
40
+ // Best-effort browser opener — same idea the device-login flow uses. Never
41
+ // throws; if no opener is available the URL is just printed for manual use.
42
+ function _openBrowser(url) {
43
+ const platform = process.platform;
44
+ let cmd; let args;
45
+ if (platform === 'darwin') { cmd = 'open'; args = [url]; }
46
+ else if (platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', url]; }
47
+ else { cmd = 'xdg-open'; args = [url]; }
48
+ try {
49
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
50
+ child.on('error', () => {});
51
+ child.unref();
52
+ } catch { /* ignore — URL is printed below */ }
53
+ }
54
+
55
+ function _parse(raw) {
56
+ if (!raw) return undefined;
57
+ try { return JSON.parse(raw); } catch { return undefined; }
58
+ }
59
+
60
+ // Build a keychain-backed OAuthClientProvider for one server.
61
+ // server — config key, used to namespace keychain accounts.
62
+ // url — the server URL (origin used as redirect base).
63
+ // store — injectable { get, set, delete } (defaults to OS keychain).
64
+ // onRedirect — optional callback(url) instead of opening a browser (tests).
65
+ function createKeychainOAuthProvider(server, {
66
+ url = '',
67
+ store = keychainStore(),
68
+ redirectUrl = 'http://127.0.0.1:8976/callback',
69
+ clientName = '@semalt-ai/code',
70
+ onRedirect = null,
71
+ } = {}) {
72
+ const acct = (kind) => `${server}:${kind}`;
73
+
74
+ return {
75
+ get redirectUrl() { return redirectUrl; },
76
+
77
+ get clientMetadata() {
78
+ return {
79
+ client_name: clientName,
80
+ redirect_uris: [redirectUrl],
81
+ grant_types: ['authorization_code', 'refresh_token'],
82
+ response_types: ['code'],
83
+ token_endpoint_auth_method: 'none',
84
+ };
85
+ },
86
+
87
+ clientInformation() {
88
+ return _parse(store.get(acct('client')));
89
+ },
90
+ saveClientInformation(info) {
91
+ store.set(acct('client'), JSON.stringify(info));
92
+ },
93
+
94
+ tokens() {
95
+ return _parse(store.get(acct('tokens')));
96
+ },
97
+ saveTokens(tokens) {
98
+ store.set(acct('tokens'), JSON.stringify(tokens));
99
+ },
100
+
101
+ saveCodeVerifier(verifier) {
102
+ store.set(acct('verifier'), String(verifier));
103
+ },
104
+ codeVerifier() {
105
+ const v = store.get(acct('verifier'));
106
+ if (!v) throw new Error('No PKCE code verifier saved for this MCP server');
107
+ return v;
108
+ },
109
+
110
+ redirectToAuthorization(authorizationUrl) {
111
+ const href = authorizationUrl instanceof URL ? authorizationUrl.href : String(authorizationUrl);
112
+ if (typeof onRedirect === 'function') { onRedirect(href); return; }
113
+ // audit: allowed — pre/non-UI OAuth flow prompt; the user must visit this URL.
114
+ process.stderr.write(`\nOpen this URL to authorize the MCP server "${server}":\n${href}\n\n`);
115
+ _openBrowser(href);
116
+ },
117
+ };
118
+ }
119
+
120
+ // Forget all stored OAuth material for a server (used by `mcp remove`/re-auth).
121
+ function clearOAuth(server, store = keychainStore()) {
122
+ let ok = true;
123
+ for (const kind of ['tokens', 'client', 'verifier']) {
124
+ if (!store.delete(`${server}:${kind}`)) ok = false;
125
+ }
126
+ return ok;
127
+ }
128
+
129
+ module.exports = {
130
+ MCP_KEYCHAIN_SERVICE,
131
+ keychainStore,
132
+ createKeychainOAuthProvider,
133
+ clearOAuth,
134
+ };
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}`));