atris 3.15.31 → 3.15.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -106,7 +106,7 @@ AgentXP is the proof-backed game loop for getting better with agents. Start it
106
106
  inside any project folder:
107
107
 
108
108
  ```bash
109
- npm exec --yes --package github:atrislabs/atris#v3.15.28 -- atris play --as <player>
109
+ npm exec --yes --package atris@latest -- atris play
110
110
  ```
111
111
 
112
112
  The first run creates a local starter mission if one does not exist. The loop is:
@@ -118,13 +118,13 @@ start -> proof -> accept -> login -> sync
118
118
  The player path:
119
119
 
120
120
  ```bash
121
- atris play --as justin
121
+ atris play
122
122
  atris task claim <mission-ref> --as game-manager
123
123
  atris task ready <mission-ref> --as game-manager --proof "<artifact path + verifier result>"
124
124
  atris task accept <mission-ref> --as justin --proof "<human review>"
125
125
  atris xp card --local
126
126
  atris login
127
- atris xp sync --local --as justin
127
+ atris xp sync --local
128
128
  ```
129
129
 
130
130
  The manager path:
package/ax CHANGED
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const fs = require('fs');
3
4
  const http = require('http');
5
+ const https = require('https');
6
+ const os = require('os');
7
+ const path = require('path');
4
8
  const readline = require('readline');
9
+ const { loadCredentials } = require('./utils/auth');
5
10
 
6
11
  const EXIT_WORDS = new Set(['exit', 'quit', ':q']);
7
12
  const BACKEND = {
@@ -9,6 +14,39 @@ const BACKEND = {
9
14
  port: 8000,
10
15
  path: '/api/atris2/turn'
11
16
  };
17
+ const DEFAULT_BACKEND_BASE = `http://${BACKEND.host}:${BACKEND.port}`;
18
+ const CONNECTION_STATUS_PATH = '/api/integrations/status';
19
+ const CONNECTION_CAPABILITIES_PATH = '/api/atris2/connection-capabilities';
20
+ const ATRIS2_CONNECTION_STATUS_PATH = '/api/atris2/connection-status';
21
+ const CONNECTOR_NAMES = {
22
+ gmail: 'Gmail',
23
+ google_calendar: 'Google Calendar',
24
+ google_drive: 'Google Drive',
25
+ google_docs: 'Google Docs',
26
+ github: 'GitHub',
27
+ slack: 'Slack',
28
+ linear: 'Linear',
29
+ notion: 'Notion',
30
+ hubspot: 'HubSpot',
31
+ twitter: 'Twitter',
32
+ microsoft: 'Microsoft',
33
+ discord: 'Discord',
34
+ jira: 'Jira',
35
+ linkedin: 'LinkedIn',
36
+ whatsapp: 'WhatsApp',
37
+ salesforce: 'Salesforce'
38
+ };
39
+ const CONNECTOR_SCOPES = {
40
+ gmail: ['mail'],
41
+ google_calendar: ['calendar'],
42
+ google_drive: ['drive'],
43
+ google_docs: ['docs'],
44
+ github: ['repos', 'issues', 'pull_requests'],
45
+ slack: ['messages'],
46
+ linear: ['issues'],
47
+ notion: ['pages', 'databases'],
48
+ hubspot: ['crm']
49
+ };
12
50
  const ANSI = {
13
51
  reset: '\x1b[0m',
14
52
  bold: '\x1b[1m',
@@ -50,37 +88,54 @@ function formatUsage() {
50
88
  'ax - Atris 2 local coding agent',
51
89
  '',
52
90
  'Usage:',
53
- ' ax [--pro|--fast] <message>',
54
- ' ax [--pro|--fast] --chat',
91
+ ' ax [--pro|--fast] [--local|--cloud] <message>',
92
+ ' ax [--pro|--fast] [--local|--cloud] --chat',
55
93
  ' ax [--pro|--fast] --doctor',
94
+ ' ax [--fast] --benchmark',
56
95
  '',
57
96
  'Modes:',
58
97
  ' --pro local workspace agent, deeper tool loop',
59
98
  ' --fast local workspace agent, faster low-latency turns',
99
+ ' --local force local workspace tools',
100
+ ' --cloud force authenticated cloud connectors/chat',
60
101
  '',
61
102
  'Examples:',
62
103
  ' ax --pro find the config file and explain it',
63
104
  ' ax --fast what files are here',
105
+ ' ax --fast what is on my calendar today',
64
106
  ' ax --pro --chat',
65
107
  ].join('\n');
66
108
  }
67
109
 
110
+ function backendBaseUrl() {
111
+ return (process.env.AX_BACKEND_URL
112
+ || process.env.OBELISK_LOCAL_ATRIS2_BACKEND_URL
113
+ || process.env.OBELISK_ATRIS2_BACKEND_URL
114
+ || DEFAULT_BACKEND_BASE).replace(/\/$/, '');
115
+ }
116
+
68
117
  function backendUrl() {
69
- return `http://${BACKEND.host}:${BACKEND.port}${BACKEND.path}`;
118
+ return new URL(BACKEND.path, backendBaseUrl()).toString();
119
+ }
120
+
121
+ function backendPathUrl(pathname) {
122
+ return new URL(pathname, backendBaseUrl()).toString();
70
123
  }
71
124
 
72
125
  function buildRunProfile(options = {}) {
73
126
  const mode = options.mode === 'fast' ? 'fast' : 'pro';
74
127
  const cwd = options.cwd || process.cwd();
75
- const payload = buildPayload(options.message || 'doctor', { mode, cwd });
128
+ const route = resolveRoute(options.message || 'doctor', options);
129
+ const payload = buildPayload(options.message || 'doctor', { mode, cwd, route });
76
130
  return {
77
131
  endpoint: backendUrl(),
78
132
  mode,
133
+ route,
79
134
  model: payload.model,
80
- workspace_path: payload.workspace_path,
135
+ workspace_path: payload.workspace_path || 'cloud',
81
136
  max_turns: payload.max_turns,
82
137
  streaming: true,
83
- runtime: 'local workspace',
138
+ runtime: route === 'cloud' ? 'authenticated cloud connectors/chat' : 'local workspace',
84
139
  reasoning: mode === 'pro'
85
140
  ? 'backend reports run row; Pro workspace tool loop uses API default medium'
86
141
  : 'backend reports run row; Fast workspace tool loop uses provider default'
@@ -91,6 +146,7 @@ function formatRunProfile(profile, options = {}) {
91
146
  const rows = [
92
147
  ['mode', `${profile.mode} (${profile.model})`],
93
148
  ['endpoint', profile.endpoint],
149
+ ['route', profile.route || 'auto'],
94
150
  ['workspace', formatPathSubject(profile.workspace_path, options)],
95
151
  ['turns', String(profile.max_turns)],
96
152
  ['streaming', profile.streaming ? 'yes' : 'no'],
@@ -100,6 +156,196 @@ function formatRunProfile(profile, options = {}) {
100
156
  return rows.map(([label, value]) => formatAuxRow(label, value, options)).join('\n');
101
157
  }
102
158
 
159
+ function canonicalConnectorId(value) {
160
+ return String(value || '').trim().toLowerCase().replace(/-/g, '_');
161
+ }
162
+
163
+ function connectorDisplayName(id) {
164
+ const key = canonicalConnectorId(id);
165
+ if (CONNECTOR_NAMES[key]) return CONNECTOR_NAMES[key];
166
+ return key.split('_').map(part => part ? part[0].toUpperCase() + part.slice(1) : '').join(' ');
167
+ }
168
+
169
+ function authToken() {
170
+ if (process.env.OBELISK_AUTH_TOKEN) return process.env.OBELISK_AUTH_TOKEN;
171
+ if (process.env.ATRIS_TOKEN) return process.env.ATRIS_TOKEN;
172
+ try {
173
+ const creds = loadCredentials();
174
+ return creds && creds.token ? creds.token : '';
175
+ } catch (_) {
176
+ return '';
177
+ }
178
+ }
179
+
180
+ function authUserId() {
181
+ if (process.env.OBELISK_USER_ID) return process.env.OBELISK_USER_ID;
182
+ if (process.env.ATRIS_USER_ID) return process.env.ATRIS_USER_ID;
183
+ try {
184
+ const creds = loadCredentials();
185
+ return creds && creds.user_id ? creds.user_id : '';
186
+ } catch (_) {
187
+ return '';
188
+ }
189
+ }
190
+
191
+ function isLoopbackBackend() {
192
+ try {
193
+ const parsed = new URL(backendBaseUrl());
194
+ return ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
195
+ } catch (_) {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ function requestJson(pathname, { token = authToken(), timeoutMs = 6000 } = {}) {
201
+ const url = backendPathUrl(pathname);
202
+ const parsed = new URL(url);
203
+ const transport = parsed.protocol === 'https:' ? https : http;
204
+ return new Promise((resolve) => {
205
+ const req = transport.request({
206
+ method: 'GET',
207
+ hostname: parsed.hostname,
208
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
209
+ path: `${parsed.pathname}${parsed.search}`,
210
+ headers: {
211
+ Accept: 'application/json',
212
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
213
+ }
214
+ }, (res) => {
215
+ const chunks = [];
216
+ res.on('data', chunk => chunks.push(chunk));
217
+ res.on('end', () => {
218
+ const text = Buffer.concat(chunks).toString('utf8');
219
+ try {
220
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data: text ? JSON.parse(text) : null, text });
221
+ } catch (_) {
222
+ resolve({ ok: false, status: res.statusCode, data: null, text });
223
+ }
224
+ });
225
+ });
226
+ req.on('error', error => resolve({ ok: false, status: 0, data: null, text: '', error: error.message }));
227
+ req.setTimeout(timeoutMs, () => {
228
+ req.destroy();
229
+ resolve({ ok: false, status: 0, data: null, text: '', error: `timeout after ${timeoutMs}ms` });
230
+ });
231
+ req.end();
232
+ });
233
+ }
234
+
235
+ function defaultAuthority(id) {
236
+ const key = canonicalConnectorId(id);
237
+ if (key === 'gmail') return { list_messages: 'read_only', get_message: 'read_only', send_message: 'approval_required' };
238
+ if (key === 'google_calendar') return { list_events: 'read_only', get_event: 'read_only', create_event: 'approval_required' };
239
+ if (key === 'google_drive') return { list_files: 'read_only', search_files: 'read_only', download_file: 'read_only', upload_file: 'approval_required' };
240
+ if (key === 'google_docs') return { list_documents: 'read_only', get_document: 'read_only', create_document: 'approval_required' };
241
+ if (key === 'github') return { list_repos: 'read_only', get_repo: 'read_only', list_issues: 'read_only', create_issue: 'approval_required' };
242
+ if (key === 'slack') return { list_channels: 'read_only', search_messages: 'read_only', post_message: 'approval_required' };
243
+ if (key === 'linear') return { list_issues: 'read_only', get_issue: 'read_only', create_issue: 'approval_required' };
244
+ if (key === 'notion') return { search: 'read_only', get_page: 'read_only', create_page: 'approval_required' };
245
+ if (key === 'hubspot') return { list_contacts: 'read_only', search_contacts: 'read_only', create_contact: 'approval_required' };
246
+ return { inspect_status: 'read_only' };
247
+ }
248
+
249
+ function connectionFromStatus(id, connected, contractConnector = null) {
250
+ const key = canonicalConnectorId(id);
251
+ const authority = contractConnector && contractConnector.authority && typeof contractConnector.authority === 'object'
252
+ ? contractConnector.authority
253
+ : defaultAuthority(key);
254
+ return {
255
+ id: key,
256
+ name: connectorDisplayName(key),
257
+ connected: connected === true,
258
+ local: false,
259
+ actions: Object.keys(authority),
260
+ scopes: CONNECTOR_SCOPES[key] || [],
261
+ authority,
262
+ source: 'ax_integrations_status'
263
+ };
264
+ }
265
+
266
+ function statusConnected(statuses, id) {
267
+ const keys = [id, id.replace(/_/g, '-'), canonicalConnectorId(id)];
268
+ for (const key of keys) {
269
+ const value = statuses[key];
270
+ if (value === true) return true;
271
+ if (value && typeof value === 'object') {
272
+ if (value.connected === true) return true;
273
+ if (String(value.status || '').toLowerCase() === 'connected') return true;
274
+ }
275
+ }
276
+ return false;
277
+ }
278
+
279
+ async function buildConnectionContext(options = {}) {
280
+ const token = options.token || authToken();
281
+ const localStatusUserId = isLoopbackBackend() ? authUserId() : '';
282
+ const statusPath = localStatusUserId
283
+ ? `${ATRIS2_CONNECTION_STATUS_PATH}?connection_user_id=${encodeURIComponent(localStatusUserId)}`
284
+ : CONNECTION_STATUS_PATH;
285
+ const [statusRes, contractRes] = await Promise.all([
286
+ localStatusUserId || token ? requestJson(statusPath, { token: localStatusUserId ? '' : token }) : Promise.resolve({ ok: false, data: null }),
287
+ requestJson(CONNECTION_CAPABILITIES_PATH, { token: '' })
288
+ ]);
289
+ const statusData = statusRes.ok && statusRes.data && typeof statusRes.data === 'object' ? statusRes.data : {};
290
+ const statuses = statusData.statuses && typeof statusData.statuses === 'object' ? statusData.statuses : statusData;
291
+ const contractConnectors = new Map();
292
+ if (contractRes.ok && contractRes.data && Array.isArray(contractRes.data.connectors)) {
293
+ for (const connector of contractRes.data.connectors) {
294
+ const key = canonicalConnectorId(connector.id);
295
+ if (key) contractConnectors.set(key, connector);
296
+ }
297
+ }
298
+ const ids = new Set([
299
+ ...Object.keys(CONNECTOR_NAMES),
300
+ ...Object.keys(statuses).map(canonicalConnectorId),
301
+ ...contractConnectors.keys(),
302
+ ]);
303
+ const connections = [...ids].sort().map(id => connectionFromStatus(
304
+ id,
305
+ statusConnected(statuses, id),
306
+ contractConnectors.get(id)
307
+ ));
308
+ connections.push({
309
+ id: 'computer.local',
310
+ name: 'This Mac workspace',
311
+ connected: options.localWorkspace === true,
312
+ local: true,
313
+ status: options.localWorkspace === true ? 'available' : 'cloud_only',
314
+ actions: ['inspect_status', 'use_local_workspace'],
315
+ scopes: ['computer', 'workspace'],
316
+ authority: { inspect_status: 'read_only', use_local_workspace: 'read_only' },
317
+ source: 'ax'
318
+ });
319
+ return {
320
+ schema: 'atris.connection_capabilities.v1',
321
+ source: 'ax',
322
+ stale: false,
323
+ capability_contract_source: contractRes.ok ? 'backend' : 'fallback',
324
+ capability_contract_schema: contractRes.data && contractRes.data.schema,
325
+ capability_contract_connectors: contractConnectors.size || undefined,
326
+ connections
327
+ };
328
+ }
329
+
330
+ function mentionsConnector(message) {
331
+ return /\b(gmail|email|mail|inbox|calendar|events?|meetings?|schedule|google drive|drive|docs?|sheets?|github|slack|linear|notion|hubspot|integrations?|connectors?|connections?|connected apps?|connected tools?)\b/i.test(message || '');
332
+ }
333
+
334
+ function connectorWriteIntent(message) {
335
+ return mentionsConnector(message) && /\b(send|post|dm|message|reply|draft|compose|schedule|book|create|update|delete|archive|move|share|comment|invite)\b/i.test(message || '');
336
+ }
337
+
338
+ function workspaceIntent(message) {
339
+ return /\b(files?|folders?|repo|workspace|project|directory|tree|read|open|inspect|search|grep|find|locate|where|edit|write|change|modify|patch|fix|test|tests?|build|src|source|code|diff|git|backend|frontend|atris task|atris xp|xp game|career xp|agentxp|todo|map)\b/i.test(message || '');
340
+ }
341
+
342
+ function resolveRoute(message, options = {}) {
343
+ if (options.route === 'local' || options.forceLocal) return 'local';
344
+ if (options.route === 'cloud' || options.forceCloud) return 'cloud';
345
+ if (mentionsConnector(message) && !workspaceIntent(message)) return 'cloud';
346
+ return 'local';
347
+ }
348
+
103
349
  function formatPrompt() {
104
350
  return '› ';
105
351
  }
@@ -342,13 +588,29 @@ function buildMessage(message, history = []) {
342
588
 
343
589
  function buildPayload(message, options = {}) {
344
590
  const mode = options.mode === 'fast' ? 'fast' : 'pro';
345
- return {
591
+ const route = resolveRoute(message, options);
592
+ const local = route !== 'cloud';
593
+ const payload = {
346
594
  message: buildMessage(message, options.history || []),
347
- workspace_path: options.cwd || process.cwd(),
348
595
  model: modelForMode(mode),
349
- max_turns: mode === 'pro' ? 14 : 6,
596
+ max_turns: local ? (mode === 'pro' ? 14 : 8) : 1,
350
597
  verify_command: 'true'
351
598
  };
599
+ if (local) {
600
+ payload.workspace_path = options.cwd || process.cwd();
601
+ }
602
+ if (options.connectionContext) {
603
+ payload.connection_context = options.connectionContext;
604
+ }
605
+ if (!local && options.connectionUserId) {
606
+ payload.connection_user_id = options.connectionUserId;
607
+ }
608
+ if (!local && connectorWriteIntent(message)) {
609
+ payload.allow_external_actions = true;
610
+ payload.cleanup_external_actions = true;
611
+ payload.max_turns = mode === 'pro' ? 4 : 2;
612
+ }
613
+ return payload;
352
614
  }
353
615
 
354
616
  function handleEvent(event, state, output) {
@@ -401,11 +663,24 @@ function handleEvent(event, state, output) {
401
663
  }
402
664
  }
403
665
 
404
- function postTurn(message, options = {}) {
405
- const payload = buildPayload(message, options);
666
+ async function postTurn(message, options = {}) {
667
+ const route = resolveRoute(message, options);
668
+ const local = route !== 'cloud';
669
+ const token = authToken();
670
+ const shouldSendConnectionContext = options.connectionContext
671
+ || route === 'cloud'
672
+ || mentionsConnector(message)
673
+ || /\b(can you use|can you access|connected|connections?|integrations?|tools?|capabilities)\b/i.test(message || '');
674
+ const connectionContext = options.connectionContext || (shouldSendConnectionContext
675
+ ? await buildConnectionContext({ token, localWorkspace: local })
676
+ : null);
677
+ const connectionUserId = !local && isLoopbackBackend() ? authUserId() : '';
678
+ const payload = buildPayload(message, { ...options, route, connectionContext, connectionUserId });
406
679
  const postData = JSON.stringify(payload);
407
680
  const output = options.output || process.stdout;
408
681
  const timeoutMs = payload.model === 'atris:pro' ? 180000 : 60000;
682
+ const turnUrl = new URL(backendUrl());
683
+ const transport = turnUrl.protocol === 'https:' ? https : http;
409
684
  const state = {
410
685
  events: [],
411
686
  errors: [],
@@ -435,15 +710,16 @@ function postTurn(message, options = {}) {
435
710
  else resolve(value);
436
711
  };
437
712
 
438
- const req = http.request({
439
- hostname: BACKEND.host,
440
- port: BACKEND.port,
441
- path: BACKEND.path,
713
+ const req = transport.request({
714
+ hostname: turnUrl.hostname,
715
+ port: turnUrl.port || (turnUrl.protocol === 'https:' ? 443 : 80),
716
+ path: `${turnUrl.pathname}${turnUrl.search}`,
442
717
  method: 'POST',
443
718
  headers: {
444
719
  'Content-Type': 'application/json',
445
720
  'Content-Length': Buffer.byteLength(postData),
446
- Accept: 'text/event-stream'
721
+ Accept: 'text/event-stream',
722
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
447
723
  }
448
724
  }, (res) => {
449
725
  res.setEncoding('utf8');
@@ -554,7 +830,174 @@ async function chat(options = {}) {
554
830
  function printBackendHint() {
555
831
  console.log('');
556
832
  console.log('Start backend:');
557
- console.log(`cd /Users/keshavrao/arena/atrisos-backend/backend && uvicorn main:app --host ${BACKEND.host} --port ${BACKEND.port}`);
833
+ console.log(`cd /Users/keshavrao/arena/atrisos-backend/backend && ATRIS2_ALLOW_LOCAL_WORKSPACE=1 ENVIRONMENT=development ENV=development ../venv/bin/uvicorn main:app --host ${BACKEND.host} --port ${BACKEND.port}`);
834
+ }
835
+
836
+ function bufferedOutput() {
837
+ let text = '';
838
+ return {
839
+ isTTY: false,
840
+ write(chunk) {
841
+ text += String(chunk || '');
842
+ return true;
843
+ },
844
+ text() {
845
+ return text;
846
+ }
847
+ };
848
+ }
849
+
850
+ function receiptFromState(state) {
851
+ const event = [...(state.events || [])].reverse().find(item => item && item.type === 'receipt' && item.receipt);
852
+ return event ? event.receipt : null;
853
+ }
854
+
855
+ function toolEventsFromState(state) {
856
+ const receipt = receiptFromState(state);
857
+ if (receipt && Array.isArray(receipt.tool_events)) return receipt.tool_events;
858
+ return [];
859
+ }
860
+
861
+ function assertBenchmark(condition, message) {
862
+ if (!condition) throw new Error(message);
863
+ }
864
+
865
+ function toolEventText(events) {
866
+ return JSON.stringify(events || []);
867
+ }
868
+
869
+ async function runBenchmarkCase(label, fn, results, output) {
870
+ const startedAt = Date.now();
871
+ output.write(`- ${label} ... `);
872
+ try {
873
+ const detail = await fn();
874
+ const duration = formatDuration(Date.now() - startedAt);
875
+ output.write(`ok (${duration})${detail ? ` - ${detail}` : ''}\n`);
876
+ results.push({ label, ok: true, duration_ms: Date.now() - startedAt, detail });
877
+ } catch (error) {
878
+ const duration = formatDuration(Date.now() - startedAt);
879
+ output.write(`fail (${duration}) - ${error.message}\n`);
880
+ results.push({ label, ok: false, duration_ms: Date.now() - startedAt, error: error.message });
881
+ }
882
+ }
883
+
884
+ async function runBenchmark(options = {}) {
885
+ const mode = options.mode === 'pro' ? 'pro' : 'fast';
886
+ const output = options.output || process.stdout;
887
+ const results = [];
888
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ax-atris2-benchmark-'));
889
+ const localOut = () => bufferedOutput();
890
+
891
+ output.write(`Atris 2 ${mode === 'fast' ? 'Fast' : 'Pro'} benchmark\n`);
892
+ output.write(`${backendUrl()}\n\n`);
893
+
894
+ try {
895
+ await runBenchmarkCase('search through files', async () => {
896
+ const workspace = path.join(tempRoot, 'search');
897
+ fs.mkdirSync(path.join(workspace, 'src', 'deep'), { recursive: true });
898
+ fs.writeFileSync(path.join(workspace, 'package.json'), '{"name":"ax-search-bench"}\n');
899
+ fs.writeFileSync(path.join(workspace, 'src', 'deep', 'needle.txt'), 'AX_SEARCH_SENTINEL=found-here\n');
900
+ const sink = localOut();
901
+ const state = await postTurn(
902
+ 'Use local tools to search for AX_SEARCH_SENTINEL and answer with the exact file path. Do not edit.',
903
+ { mode, cwd: workspace, route: 'local', output: sink, showProgress: false }
904
+ );
905
+ const combined = `${state.output}\n${toolEventText(toolEventsFromState(state))}`;
906
+ assertBenchmark(/src\/deep\/needle\.txt/.test(combined), 'missing proof path src/deep/needle.txt');
907
+ assertBenchmark(/AX_SEARCH_SENTINEL/.test(combined), 'missing sentinel proof');
908
+ return 'found src/deep/needle.txt';
909
+ }, results, output);
910
+
911
+ await runBenchmarkCase('modify files', async () => {
912
+ const workspace = path.join(tempRoot, 'edit');
913
+ fs.mkdirSync(path.join(workspace, 'src'), { recursive: true });
914
+ fs.writeFileSync(path.join(workspace, 'package.json'), '{"name":"ax-edit-bench"}\n');
915
+ const target = path.join(workspace, 'src', 'bench-target.txt');
916
+ fs.writeFileSync(target, 'status=AX_BEFORE\n');
917
+ const sink = localOut();
918
+ const state = await postTurn(
919
+ 'Use local tools to edit src/bench-target.txt. Replace AX_BEFORE with AX_AFTER, then report the changed file. Do not create any other files.',
920
+ { mode, cwd: workspace, route: 'local', output: sink, showProgress: false }
921
+ );
922
+ const text = fs.readFileSync(target, 'utf8');
923
+ assertBenchmark(text.includes('AX_AFTER'), 'src/bench-target.txt was not modified');
924
+ const combined = `${state.output}\n${toolEventText(toolEventsFromState(state))}`;
925
+ assertBenchmark(/bench-target\.txt/.test(combined), 'missing changed-file proof in response or receipt');
926
+ return 'edited src/bench-target.txt';
927
+ }, results, output);
928
+
929
+ await runBenchmarkCase('use Atris workspace state', async () => {
930
+ const cwd = options.cwd || process.cwd();
931
+ assertBenchmark(fs.existsSync(path.join(cwd, 'atris')), 'current workspace has no atris/ directory');
932
+ const sink = localOut();
933
+ const state = await postTurn(
934
+ 'Use Atris local tools to inspect current task or map state. Report one concrete Atris file or task source you used. Do not edit files.',
935
+ { mode, cwd, route: 'local', output: sink, showProgress: false }
936
+ );
937
+ const combined = `${state.output}\n${toolEventText(toolEventsFromState(state))}`;
938
+ assertBenchmark(/atris\/(TODO|MAP|atris)\.md|local_task_op|local_map_op|Task|Map/.test(combined), 'missing Atris task/map proof');
939
+ return 'read Atris task/map surface';
940
+ }, results, output);
941
+
942
+ await runBenchmarkCase('know XP system', async () => {
943
+ const cwd = options.cwd || process.cwd();
944
+ assertBenchmark(fs.existsSync(path.join(cwd, 'atris')), 'current workspace has no atris/ directory');
945
+ const sink = localOut();
946
+ const state = await postTurn(
947
+ 'Use the local RewardRubric/Atris tools or workspace files to explain the Career XP reward system in this workspace. Mention proof, review/acceptance, and reward/XP. Cite the exact tool or file you used. Do not edit files.',
948
+ { mode, cwd, route: 'local', output: sink, showProgress: false }
949
+ );
950
+ const combined = `${state.output}\n${toolEventText(toolEventsFromState(state))}`;
951
+ assertBenchmark(/xp|reward|rubric/i.test(combined), 'missing XP/reward/rubric proof');
952
+ assertBenchmark(/proof|review|accept|approval/i.test(combined), 'missing proof/review/acceptance explanation');
953
+ assertBenchmark(/RewardRubric|local_reward_rubric_op|atris\/|main\.js|taskBoard/i.test(combined), 'missing cited XP source or tool');
954
+ return 'explained XP reward loop';
955
+ }, results, output);
956
+
957
+ await runBenchmarkCase('list connections', async () => {
958
+ const context = await buildConnectionContext({ token: authToken(), localWorkspace: false });
959
+ const connected = (context.connections || []).filter(row => row.connected);
960
+ assertBenchmark((context.connections || []).length >= 5, 'connection capability context is missing');
961
+ const sink = localOut();
962
+ const state = await postTurn(
963
+ 'Which Atris integrations are connected for me? Include Google Calendar status. Answer from live connection context.',
964
+ { mode, route: 'cloud', output: sink, showProgress: false, connectionContext: context }
965
+ );
966
+ const receipt = receiptFromState(state);
967
+ assertBenchmark(receipt && receipt.connection_context, 'receipt missing connection_context');
968
+ const answer = `${state.output}\n${receipt && receipt.final ? receipt.final : ''}`;
969
+ assertBenchmark(/calendar|gmail|github|slack|drive|integration|connected/i.test(answer), 'answer did not discuss integrations');
970
+ return `${connected.length} connected reported`;
971
+ }, results, output);
972
+
973
+ await runBenchmarkCase('read calendar', async () => {
974
+ const sink = localOut();
975
+ const state = await postTurn(
976
+ 'What is on my calendar today? Use the connected Google Calendar read path and be concise.',
977
+ { mode, route: 'cloud', output: sink, showProgress: false }
978
+ );
979
+ const events = toolEventsFromState(state);
980
+ const text = toolEventText(events);
981
+ assertBenchmark(/google_calendar/.test(text), 'calendar tool event missing');
982
+ assertBenchmark(!/"status":"not_connected"/.test(text), 'Google Calendar is not connected for this account');
983
+ assertBenchmark(/Calendar|event|No calendar events|meeting/i.test(state.output), 'calendar answer missing');
984
+ return 'calendar read path responded';
985
+ }, results, output);
986
+ } finally {
987
+ try {
988
+ fs.rmSync(tempRoot, { recursive: true, force: true });
989
+ } catch (_) {}
990
+ }
991
+
992
+ const failed = results.filter(result => !result.ok);
993
+ output.write('\n');
994
+ output.write(failed.length ? `Benchmark failed: ${failed.length}/${results.length} failed\n` : `Benchmark passed: ${results.length}/${results.length}\n`);
995
+ if (failed.length) {
996
+ const error = new Error(failed.map(result => `${result.label}: ${result.error}`).join('; '));
997
+ error.results = results;
998
+ throw error;
999
+ }
1000
+ return results;
558
1001
  }
559
1002
 
560
1003
  async function main() {
@@ -566,27 +1009,36 @@ async function main() {
566
1009
 
567
1010
  const mode = args.includes('--fast') ? 'fast' : 'pro';
568
1011
  const doctor = args.includes('--doctor');
1012
+ const benchmark = args.includes('--benchmark');
1013
+ const forceCloud = args.includes('--cloud');
1014
+ const forceLocal = args.includes('--local');
1015
+ const route = forceCloud ? 'cloud' : forceLocal ? 'local' : 'auto';
569
1016
  const prompt = args
570
- .filter(arg => !['--fast', '--pro', '--chat', '--doctor', '--help', '-h'].includes(arg))
1017
+ .filter(arg => !['--fast', '--pro', '--chat', '--doctor', '--benchmark', '--local', '--cloud', '--help', '-h'].includes(arg))
571
1018
  .join(' ')
572
1019
  .trim();
573
1020
 
574
1021
  try {
1022
+ if (benchmark) {
1023
+ await runBenchmark({ mode, cwd: process.cwd(), output: process.stdout });
1024
+ return;
1025
+ }
1026
+
575
1027
  if (doctor) {
576
1028
  console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }));
577
1029
  console.log('');
578
- console.log(formatRunProfile(buildRunProfile({ mode, cwd: process.cwd() }), process.stdout));
1030
+ console.log(formatRunProfile(buildRunProfile({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route }), process.stdout));
579
1031
  return;
580
1032
  }
581
1033
 
582
1034
  if (!prompt || args.includes('--chat')) {
583
- await chat({ mode, cwd: process.cwd() });
1035
+ await chat({ mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route });
584
1036
  return;
585
1037
  }
586
1038
 
587
1039
  console.log(formatHeader({ mode, cwd: process.cwd(), chat: false }));
588
1040
  console.log('');
589
- const result = await postTurn(prompt, { mode, cwd: process.cwd() });
1041
+ const result = await postTurn(prompt, { mode, cwd: process.cwd(), route: route === 'auto' ? undefined : route });
590
1042
  console.log('');
591
1043
  console.log(formatDoneLine(result.durationMs));
592
1044
  } catch (error) {
@@ -601,8 +1053,12 @@ if (require.main === module) {
601
1053
  }
602
1054
 
603
1055
  module.exports = {
1056
+ authToken,
1057
+ authUserId,
1058
+ backendBaseUrl,
604
1059
  backendUrl,
605
1060
  buildPayload,
1061
+ buildConnectionContext,
606
1062
  buildRunProfile,
607
1063
  chat,
608
1064
  createProgressReporter,
@@ -620,6 +1076,8 @@ module.exports = {
620
1076
  modelForMode,
621
1077
  parseSseBlock,
622
1078
  postTurn,
1079
+ resolveRoute,
1080
+ runBenchmark,
623
1081
  summarizeToolInput,
624
1082
  summarizeToolResult
625
1083
  };
package/bin/atris.js CHANGED
@@ -1666,7 +1666,7 @@ if (command === 'init') {
1666
1666
  require('../commands/workflow').executeAgentSDKFast(userInput);
1667
1667
  } else if (command === 'computer') {
1668
1668
  require('../commands/computer').runComputer()
1669
- .then(() => process.exit(0))
1669
+ .then(() => process.exit(process.exitCode || 0))
1670
1670
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
1671
1671
  } else if (command === 'diff') {
1672
1672
  let diffSlug = process.argv[3];