@okrapdf/cli 0.4.6 → 0.5.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 (95) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +14 -2
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/auth.d.ts.map +1 -1
  5. package/dist/commands/auth.js +94 -26
  6. package/dist/commands/auth.js.map +1 -1
  7. package/dist/commands/chat.d.ts +6 -17
  8. package/dist/commands/chat.d.ts.map +1 -1
  9. package/dist/commands/chat.js +349 -318
  10. package/dist/commands/chat.js.map +1 -1
  11. package/dist/commands/chat.test.js +4 -0
  12. package/dist/commands/chat.test.js.map +1 -1
  13. package/dist/commands/elements.d.ts.map +1 -1
  14. package/dist/commands/elements.js +15 -12
  15. package/dist/commands/elements.js.map +1 -1
  16. package/dist/commands/jobs.d.ts.map +1 -1
  17. package/dist/commands/jobs.js +228 -13
  18. package/dist/commands/jobs.js.map +1 -1
  19. package/dist/commands/plugins.d.ts +3 -0
  20. package/dist/commands/plugins.d.ts.map +1 -0
  21. package/dist/commands/plugins.js +26 -0
  22. package/dist/commands/plugins.js.map +1 -0
  23. package/dist/commands/read.d.ts.map +1 -1
  24. package/dist/commands/read.js +11 -9
  25. package/dist/commands/read.js.map +1 -1
  26. package/dist/commands/search.d.ts +16 -0
  27. package/dist/commands/search.d.ts.map +1 -0
  28. package/dist/commands/search.js +246 -0
  29. package/dist/commands/search.js.map +1 -0
  30. package/dist/commands/shortcuts.js +2 -2
  31. package/dist/commands/shortcuts.js.map +1 -1
  32. package/dist/commands/task.d.ts +0 -8
  33. package/dist/commands/task.d.ts.map +1 -1
  34. package/dist/commands/task.js +9 -190
  35. package/dist/commands/task.js.map +1 -1
  36. package/dist/lib/agent-renderer.d.ts.map +1 -1
  37. package/dist/lib/agent-renderer.js +17 -1
  38. package/dist/lib/agent-renderer.js.map +1 -1
  39. package/dist/lib/backends/do-adapters.test.d.ts +6 -0
  40. package/dist/lib/backends/do-adapters.test.d.ts.map +1 -0
  41. package/dist/lib/backends/do-adapters.test.js +311 -0
  42. package/dist/lib/backends/do-adapters.test.js.map +1 -0
  43. package/dist/lib/backends/do.d.ts +23 -0
  44. package/dist/lib/backends/do.d.ts.map +1 -0
  45. package/dist/lib/backends/do.js +340 -0
  46. package/dist/lib/backends/do.js.map +1 -0
  47. package/dist/lib/backends/index.d.ts +7 -0
  48. package/dist/lib/backends/index.d.ts.map +1 -0
  49. package/dist/lib/backends/index.js +8 -0
  50. package/dist/lib/backends/index.js.map +1 -0
  51. package/dist/lib/backends/okrapdf.d.ts +9 -0
  52. package/dist/lib/backends/okrapdf.d.ts.map +1 -0
  53. package/dist/lib/backends/okrapdf.js +50 -0
  54. package/dist/lib/backends/okrapdf.js.map +1 -0
  55. package/dist/lib/backends/types.d.ts +205 -0
  56. package/dist/lib/backends/types.d.ts.map +1 -0
  57. package/dist/lib/backends/types.js +7 -0
  58. package/dist/lib/backends/types.js.map +1 -0
  59. package/dist/lib/completion-stream.d.ts +22 -0
  60. package/dist/lib/completion-stream.d.ts.map +1 -0
  61. package/dist/lib/completion-stream.js +69 -0
  62. package/dist/lib/completion-stream.js.map +1 -0
  63. package/dist/lib/config.d.ts +4 -0
  64. package/dist/lib/config.d.ts.map +1 -1
  65. package/dist/lib/config.js +6 -0
  66. package/dist/lib/config.js.map +1 -1
  67. package/dist/lib/federation.d.ts +14 -0
  68. package/dist/lib/federation.d.ts.map +1 -0
  69. package/dist/lib/federation.js +51 -0
  70. package/dist/lib/federation.js.map +1 -0
  71. package/dist/lib/general-chat.d.ts +11 -0
  72. package/dist/lib/general-chat.d.ts.map +1 -0
  73. package/dist/lib/general-chat.js +69 -0
  74. package/dist/lib/general-chat.js.map +1 -0
  75. package/dist/lib/multi-doc-chat.d.ts +20 -0
  76. package/dist/lib/multi-doc-chat.d.ts.map +1 -0
  77. package/dist/lib/multi-doc-chat.js +152 -0
  78. package/dist/lib/multi-doc-chat.js.map +1 -0
  79. package/dist/lib/plugins.d.ts +24 -0
  80. package/dist/lib/plugins.d.ts.map +1 -0
  81. package/dist/lib/plugins.js +55 -0
  82. package/dist/lib/plugins.js.map +1 -0
  83. package/dist/lib/runtime.js +1 -1
  84. package/dist/lib/runtime.js.map +1 -1
  85. package/dist/lib/single-doc-chat.d.ts +14 -0
  86. package/dist/lib/single-doc-chat.d.ts.map +1 -0
  87. package/dist/lib/single-doc-chat.js +165 -0
  88. package/dist/lib/single-doc-chat.js.map +1 -0
  89. package/dist/lib/task-agent.d.ts +11 -0
  90. package/dist/lib/task-agent.d.ts.map +1 -0
  91. package/dist/lib/task-agent.js +200 -0
  92. package/dist/lib/task-agent.js.map +1 -0
  93. package/dist/types.d.ts +32 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/package.json +3 -1
@@ -1,22 +1,11 @@
1
1
  /**
2
- * Agent-driven chat via WebSocket (same session as web UI)
2
+ * Unified chat command.
3
3
  *
4
- * okra chat <jobId> -m "what tables are in this document?"
5
- *
6
- * Both the web UI and CLI are clients to the same Cloudflare Durable Object.
7
- * On reconnect the DO replays persisted events (EVENTS_BATCH) containing old
8
- * AGENT_MESSAGE, AGENT_DONE, etc. We must ignore those and only render events
9
- * triggered by OUR new message.
10
- *
11
- * Server message order on reconnect:
12
- * Frame 1: CONNECTED
13
- * Frame 2: READY (with sandboxId if running)
14
- * Frame 3: SANDBOX_STATUS (live, not replayed)
15
- * Frame 4: EVENTS_BATCH (replayed history — arrives AFTER SANDBOX_STATUS)
16
- *
17
- * Because EVENTS_BATCH arrives after SANDBOX_STATUS resolves, a simple
18
- * messageSent flag doesn't work. Instead we use a global listener to detect
19
- * EVENTS_BATCH and wait for it before sending our message.
4
+ * okra chat "what is EBITDA?" → general (no doc)
5
+ * okra chat "how many pages?" --doc ocr-XXX → single-doc
6
+ * okra chat "compare revenue" --doc ocr-XXX,ocr-YYY → multi-doc
7
+ * okra chat "compare all" -c my-collection → multi-doc (collection)
8
+ * okra chat send ocr-XXX -m "question" → legacy compat
20
9
  */
21
10
  import '../lib/ws-polyfill.js';
22
11
  import { Command } from 'commander';
@@ -31,320 +20,114 @@ import { createSpinner } from '../lib/progress.js';
31
20
  import { OkraApiError, EXIT_CODES, patch as apiPatch } from '../lib/client.js';
32
21
  import { resolveSchema, extractJson, listBuiltinNames, } from '../lib/structured-output.js';
33
22
  import { resolveJobId } from '../lib/resolver.js';
23
+ import { runSingleDocChat } from '../lib/single-doc-chat.js';
24
+ import { runGeneralChat } from '../lib/general-chat.js';
25
+ import { runMultiDocChat } from '../lib/multi-doc-chat.js';
34
26
  const WSS_URL = getAgentSessionUrl();
35
27
  const SANDBOX_TEMPLATE = 'okra-claude-agent-sdk';
36
28
  const SANDBOX_TIMEOUT_MS = 120_000;
37
29
  export function createChatCommand() {
38
30
  const chat = new Command('chat')
39
- .description('Chat with a document via agent session');
40
- // Default subcommand: okra chat <document> -m "msg"
41
- const send = new Command('send')
42
- .description('Send a message to a document chat session')
43
- .argument('[document]', 'Job ID (ocr-*), source ref (arxiv/1706.03762), or URL')
44
- .requiredOption('-m, --message <message>', 'Message to send')
45
- .option('-o, --output <format>', 'Output format (text, json)', 'text')
31
+ .description('Chat with documents or ask general questions')
32
+ .argument('[message]', 'Message to send (positional)')
33
+ .option('-d, --doc <ids>', 'Comma-separated job IDs (ocr-*)')
34
+ .option('-c, --collection <nameOrId>', 'Collection name or ID')
35
+ .option('-m, --message <message>', 'Message (alternative to positional)')
46
36
  .option('-s, --schema <schema>', 'Structured output: built-in name, inline JSON, or file path')
37
+ .option('--model <model>', 'Model to use')
47
38
  .option('--share', 'Generate a public share link')
48
- .action(async (document, options) => {
49
- // Handle --schema help
50
- if (options.schema === 'help') {
51
- console.log('Built-in schemas:');
52
- for (const name of listBuiltinNames()) {
53
- console.log(` ${name}`);
54
- }
55
- console.log('\nYou can also pass inline JSON or a file path:');
56
- console.log(' --schema \'{"type":"object","properties":{"name":{"type":"string"}}}\'');
57
- console.log(' --schema ./my-schema.json');
58
- process.exit(0);
59
- }
60
- // Resolve schema (built-in name, inline JSON, or file path)
61
- let resolvedSchema;
62
- if (options.schema) {
63
- const result = resolveSchema(options.schema);
64
- if ('error' in result) {
65
- error(result.error);
66
- process.exit(EXIT_CODES.INVALID_ARGS);
67
- }
68
- resolvedSchema = result.schema;
69
- }
70
- if (!document) {
71
- error('Provide a document argument, or use `okra task` for multi-doc queries');
39
+ .option('--socket', 'Use legacy WebSocket + E2B sandbox path')
40
+ .option('--tools <type>', 'Tool mode: sandbox | code_interpreter (stub)')
41
+ .action(async (positionalMessage, options) => {
42
+ const message = positionalMessage || options.message;
43
+ if (!message) {
44
+ error('Provide a message: okra chat "your question" or okra chat -m "your question"');
72
45
  process.exit(EXIT_CODES.INVALID_ARGS);
73
- return; // unreachable, helps TS narrow
74
46
  }
75
- // Resolve document reference to jobId
76
- const resolved = await resolveJobId(document).catch((err) => {
77
- error(err instanceof Error ? err.message : String(err));
78
- process.exit(EXIT_CODES.GENERAL_ERROR);
79
- });
80
- const jobId = resolved.jobId;
81
- const apiKey = getApiKey();
82
- if (!apiKey) {
83
- error('Not authenticated. Run `okra auth login` first.');
84
- process.exit(EXIT_CODES.AUTH_ERROR);
85
- }
86
- const quiet = isQuietMode();
87
- // In schema mode, all human-friendly output goes to stderr (gh CLI pattern).
88
- // stdout is reserved exclusively for the JSON result.
89
- const spinnerStream = resolvedSchema ? process.stderr : undefined;
90
- const useSpinners = !quiet && (resolvedSchema ? process.stderr.isTTY : process.stdout.isTTY);
91
- // Suppress library console.log that pollutes stdout
92
- // (AgentSessionClient logs "[AgentSession] ..." to console.log)
93
- // Must happen before client.connect() so connect/connected logs are caught.
94
- const origConsoleLog = console.log;
95
- if (resolvedSchema) {
96
- console.log = (...args) => {
97
- const first = typeof args[0] === 'string' ? args[0] : '';
98
- if (first.startsWith('[AgentSession]'))
99
- return;
100
- origConsoleLog(...args);
101
- };
102
- }
103
- // 1. Fetch bootstrap (archive URL + metadata)
104
- const bootSpinner = useSpinners
105
- ? createSpinner('Preparing document...', { stream: spinnerStream })
106
- : null;
107
- bootSpinner?.start();
108
- let archiveUrl;
109
- let totalPages;
110
- try {
111
- const bootstrap = await fetchBootstrap(jobId);
112
- archiveUrl = bootstrap.archiveUrl;
113
- totalPages = bootstrap.metadata.totalPages;
114
- bootSpinner?.succeed(`Document ready (${totalPages} pages)`);
47
+ // --tools stub
48
+ if (options.tools) {
49
+ const chalk = (await import('chalk')).default;
50
+ process.stderr.write(chalk.yellow(`Warning: --tools ${options.tools} is not yet implemented. Proceeding without tools.\n`));
115
51
  }
116
- catch (err) {
117
- if (err instanceof OkraApiError) {
118
- bootSpinner?.fail(`Failed to prepare document (${err.statusCode})`);
119
- error(err.message);
120
- if (err.details)
121
- error(JSON.stringify(err.details));
122
- }
123
- else {
124
- bootSpinner?.fail('Failed to prepare document');
125
- error(err instanceof Error ? err.message : String(err));
126
- }
127
- process.exit(EXIT_CODES.GENERAL_ERROR);
128
- }
129
- // 2. Connect WebSocket
130
- const renderer = resolvedSchema
131
- ? new SilentCollectorRenderer()
132
- : new AgentRenderer();
133
- const client = new AgentSessionClient({
134
- url: WSS_URL,
135
- sessionId: jobId,
136
- autoConnect: false,
137
- autoReconnect: false,
138
- });
139
- // Gate: ignore replayed agent events until we've sent our message.
140
- // After SANDBOX_STATUS resolves we wait 100ms for EVENTS_BATCH to drain,
141
- // then flip this flag and send. Events arriving before the flip are replay.
142
- let messageSent = false;
143
- // 3. Register event handlers
144
- const sandboxSpinner = useSpinners
145
- ? createSpinner('Booting sandbox...', { stream: spinnerStream })
146
- : null;
147
- // Promise that resolves when sandbox is ready.
148
- // Two paths to resolution (matching web client Redux slice):
149
- // 1. READY { sandboxId } — sandbox already running (reconnect)
150
- // 2. SANDBOX_STATUS { status: 'ready' } — fresh boot completed
151
- let resolveSandboxReady;
152
- const sandboxReady = new Promise((resolve, reject) => {
153
- resolveSandboxReady = resolve;
154
- const timeout = setTimeout(() => {
155
- reject(new Error('Sandbox boot timed out'));
156
- }, SANDBOX_TIMEOUT_MS);
157
- client.on('SANDBOX_STATUS', (event) => {
158
- if (event.status === 'ready') {
159
- clearTimeout(timeout);
160
- resolve();
161
- }
162
- else if (event.status === 'error') {
163
- clearTimeout(timeout);
164
- reject(new Error(event.error || 'Sandbox boot failed'));
165
- }
166
- });
167
- });
168
- client.on('READY', (event) => {
169
- if (event.sandboxId) {
170
- // Sandbox already running on reconnect.
171
- // Match web client behavior: READY { sandboxId } means sandbox is alive.
172
- // (Redux slice setReady sets sandboxStatus = 'ready' when sandboxId present)
173
- // Server validated sandbox health before including sandboxId — trust it.
174
- resolveSandboxReady();
175
- }
176
- else {
177
- sandboxSpinner?.start();
178
- client.send({
179
- type: 'START_SANDBOX',
180
- template: SANDBOX_TEMPLATE,
181
- bootstrap: { archiveUrl },
182
- });
183
- }
184
- });
185
- // Wait for EVENTS_BATCH replay to finish (or timeout if no replay).
186
- const replayDone = new Promise((resolve) => {
187
- sandboxReady.then(() => {
188
- setTimeout(resolve, 100);
189
- }).catch(() => resolve());
190
- });
191
- // Promise that resolves when agent is done
192
- let resolveAgent;
193
- let rejectAgent;
194
- const agentDone = new Promise((resolve, reject) => {
195
- resolveAgent = resolve;
196
- rejectAgent = reject;
197
- });
198
- client.on('LIFECYCLE', (event) => {
199
- if (sandboxSpinner?.isSpinning) {
200
- sandboxSpinner.text = event.message || event.phase;
201
- }
202
- });
203
- // Agent events — gated on messageSent to skip replay
204
- client.on('AGENT_STARTED', (_event) => {
205
- if (!messageSent)
206
- return;
207
- renderer.onAgentStarted();
208
- });
209
- // Capture structured_output from SDK result event
210
- let structuredOutput = undefined;
211
- client.on('AGENT_MESSAGE', (event) => {
212
- if (!messageSent)
213
- return;
214
- // Runner sends subtype:"result" with is_error:true on SDK failure
215
- if (event.subtype === 'result' && event.is_error) {
216
- const errMsg = event.result || 'Agent failed (no details)';
217
- renderer.onAgentError(String(errMsg));
218
- rejectAgent(new Error(String(errMsg)));
219
- return;
220
- }
221
- // Capture SDK structured_output from result event
222
- if (event.subtype === 'result' && event.structured_output != null) {
223
- structuredOutput = event.structured_output;
224
- }
225
- renderer.onAgentMessage(event.message?.content);
226
- });
227
- client.on('AGENT_DONE', (event) => {
228
- if (!messageSent)
229
- return;
230
- renderer.onAgentDone(event.total_cost_usd, event.duration_ms);
231
- resolveAgent();
232
- });
233
- client.on('AGENT_ERROR', (event) => {
234
- if (!messageSent)
52
+ // Route based on --doc / -c presence
53
+ const docIds = options.doc?.split(',').map(s => s.trim()).filter(Boolean) || [];
54
+ const hasCollection = Boolean(options.collection);
55
+ const docCount = docIds.length;
56
+ if (!options.doc && !hasCollection) {
57
+ // General chat — no documents
58
+ await runGeneralChat({ message, model: options.model });
59
+ return;
60
+ }
61
+ if (docCount === 1 && !hasCollection) {
62
+ // Single-doc chat
63
+ if (options.socket) {
64
+ // Legacy WS + E2B sandbox path
65
+ await runLegacySocketChat(docIds[0], message, options);
235
66
  return;
236
- renderer.onAgentError(event.error);
237
- rejectAgent(new Error(event.error));
238
- });
239
- client.on('ERROR', (event) => {
240
- error(`Server error: ${event.msg}`);
241
- rejectAgent(new Error(event.msg));
242
- });
243
- // 4. Connect
244
- client.connect();
245
- // 5. Wait for sandbox
246
- try {
247
- await sandboxReady;
248
- if (sandboxSpinner?.isSpinning) {
249
- sandboxSpinner.succeed('Sandbox ready');
250
67
  }
68
+ await runSingleDocChat({
69
+ jobId: docIds[0],
70
+ message,
71
+ schema: options.schema,
72
+ share: options.share,
73
+ model: options.model,
74
+ });
75
+ return;
251
76
  }
252
- catch (err) {
253
- sandboxSpinner?.fail('Sandbox boot failed');
254
- error(err instanceof Error ? err.message : String(err));
255
- client.disconnect();
256
- process.exit(EXIT_CODES.GENERAL_ERROR);
77
+ // Multi-doc (2+ docs or collection)
78
+ if (options.schema) {
79
+ error('--schema is not supported for multi-document chat');
80
+ process.exit(EXIT_CODES.INVALID_ARGS);
257
81
  }
258
- // 6. Wait for replay to drain before sending
259
- await replayDone;
260
- // 6b. --share: set is_public and print share URL
261
- const shareUrl = `https://okrapdf.com/share/${jobId}`;
262
- if (options.share) {
263
- try {
264
- await apiPatch(`api/share/${jobId}`, { is_public: true });
265
- info(`Share URL: ${shareUrl}`);
266
- }
267
- catch (err) {
268
- error(`Failed to enable sharing: ${err instanceof Error ? err.message : String(err)}`);
269
- }
82
+ await runMultiDocChat({
83
+ message,
84
+ documents: docIds.length > 0 ? docIds.join(',') : undefined,
85
+ collection: options.collection,
86
+ model: options.model,
87
+ share: options.share,
88
+ });
89
+ });
90
+ // ── Legacy `send` subcommand ──────────────────────────────────────────
91
+ // okra chat send ocr-XXX -m "question"
92
+ const send = new Command('send')
93
+ .description('Send a message (legacy — use `okra chat "msg" --doc <id>` instead)')
94
+ .argument('[document]', 'Job ID (ocr-*), source ref, or URL')
95
+ .requiredOption('-m, --message <message>', 'Message to send')
96
+ .option('-c, --collection <nameOrId>', 'Collection name or ID')
97
+ .option('-s, --schema <schema>', 'Structured output schema')
98
+ .option('--model <model>', 'Model to use', 'claude-sonnet-4-20250514')
99
+ .option('--share', 'Generate a public share link')
100
+ .option('--socket', 'Use legacy WebSocket + E2B sandbox path')
101
+ .action(async (document, options) => {
102
+ const hasMultipleDocs = document?.includes(',');
103
+ const hasCollection = Boolean(options.collection);
104
+ if (hasCollection || hasMultipleDocs) {
105
+ // Multi-doc via orchestrator
106
+ await runMultiDocChat({
107
+ message: options.message,
108
+ documents: document,
109
+ collection: options.collection,
110
+ model: options.model,
111
+ share: options.share,
112
+ });
113
+ return;
270
114
  }
271
- // 7. Send message — flip gate so subsequent events are rendered
272
- messageSent = true;
273
- if (!resolvedSchema)
274
- info(`Sending: ${options.message}`);
275
- const systemPrompt = buildSystemPrompt({ schema: resolvedSchema });
276
- // Send raw AGENT_MESSAGE so author metadata is preserved across
277
- // older @steventsao/agent-session client versions used by the CLI.
278
- const wsMessage = {
279
- type: 'AGENT_MESSAGE',
280
- content: options.message,
281
- model: { id: 'claude-sonnet-4-20250514', provider: 'anthropic' },
282
- systemPrompt,
283
- agentType: 'claude-code',
284
- author: { name: 'okra-cli', clientType: 'cli' },
285
- };
286
- // Send JSON Schema for SDK-native structured output
287
- if (resolvedSchema) {
288
- wsMessage.outputFormat = {
289
- type: 'json_schema',
290
- schema: resolvedSchema.jsonSchema,
291
- };
292
- }
293
- client.send(wsMessage);
294
- // 8. Wait for agent completion
295
- try {
296
- await agentDone;
297
- if (options.share) {
298
- info(`Share URL: ${shareUrl}`);
299
- }
115
+ if (!document) {
116
+ error('Provide a document argument (or use -c/--collection for multi-doc mode)');
117
+ process.exit(EXIT_CODES.INVALID_ARGS);
118
+ return;
300
119
  }
301
- catch (err) {
302
- error(err instanceof Error ? err.message : String(err));
303
- process.exitCode = EXIT_CODES.GENERAL_ERROR;
304
- }
305
- // Restore console.log before outputting results
306
- if (resolvedSchema)
307
- console.log = origConsoleLog;
308
- // 8b. Structured output: prefer SDK structured_output, fall back to text extraction
309
- // Write JSON to stdout via process.stdout.write (not console.log) to keep
310
- // a clean machine-readable stream — gh CLI pattern.
311
- if (resolvedSchema && !process.exitCode) {
312
- let parsed = structuredOutput ?? null;
313
- // Fallback: extract JSON from collected text if SDK didn't return structured_output
314
- if (parsed === null) {
315
- const raw = renderer.getCollectedText();
316
- parsed = extractJson(raw);
317
- if (parsed === null) {
318
- process.stderr.write('Failed to extract JSON from agent response.\n');
319
- process.stderr.write('Raw output:\n' + raw + '\n');
320
- process.exitCode = EXIT_CODES.VALIDATION_ERROR;
321
- }
322
- }
323
- if (parsed !== null && !process.exitCode) {
324
- // For built-in schemas, validate with Zod; for custom schemas, output as-is
325
- if (resolvedSchema.zodSchema) {
326
- const result = resolvedSchema.zodSchema.safeParse(parsed);
327
- if (result.success) {
328
- process.stdout.write(JSON.stringify(result.data, null, 2) + '\n');
329
- }
330
- else {
331
- process.stderr.write('Schema validation failed:\n');
332
- for (const issue of result.error.issues) {
333
- process.stderr.write(` - ${issue.path.join('.')}: ${issue.message}\n`);
334
- }
335
- process.stderr.write('\nRaw JSON:\n' + JSON.stringify(parsed, null, 2) + '\n');
336
- process.exitCode = EXIT_CODES.VALIDATION_ERROR;
337
- }
338
- }
339
- else {
340
- // Custom schema — SDK already validated via constrained decoding
341
- process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
342
- }
343
- }
120
+ if (options.socket) {
121
+ await runLegacySocketChat(document, options.message, options);
122
+ return;
344
123
  }
345
- // 9. Disconnect and exit
346
- client.disconnect();
347
- process.exit(process.exitCode ?? 0);
124
+ await runSingleDocChat({
125
+ jobId: document,
126
+ message: options.message,
127
+ schema: options.schema,
128
+ share: options.share,
129
+ model: options.model,
130
+ });
348
131
  });
349
132
  // ── view subcommand ──────────────────────────────────────────────────
350
133
  const view = new Command('view')
@@ -361,7 +144,6 @@ export function createChatCommand() {
361
144
  const chalk = (await import('chalk')).default;
362
145
  const got = (await import('got')).default;
363
146
  const { openInBrowser } = await import('../lib/browser.js');
364
- // Resolve events source
365
147
  let events;
366
148
  if (opts.file) {
367
149
  try {
@@ -405,7 +187,6 @@ export function createChatCommand() {
405
187
  info('No events found');
406
188
  process.exit(0);
407
189
  }
408
- // Output modes
409
190
  if (opts.jsonl) {
410
191
  for (const evt of events) {
411
192
  console.log(JSON.stringify(evt));
@@ -429,10 +210,261 @@ export function createChatCommand() {
429
210
  }
430
211
  process.exit(0);
431
212
  });
432
- chat.addCommand(send, { isDefault: true });
213
+ chat.addCommand(send);
433
214
  chat.addCommand(view);
434
215
  return chat;
435
216
  }
217
+ // ── Legacy WebSocket + E2B sandbox path ─────────────────────────────────
218
+ async function runLegacySocketChat(document, message, options) {
219
+ // Resolve schema
220
+ let resolvedSchema;
221
+ if (options.schema) {
222
+ if (options.schema === 'help') {
223
+ console.log('Built-in schemas:');
224
+ for (const name of listBuiltinNames()) {
225
+ console.log(` ${name}`);
226
+ }
227
+ process.exit(0);
228
+ }
229
+ const result = resolveSchema(options.schema);
230
+ if ('error' in result) {
231
+ error(result.error);
232
+ process.exit(EXIT_CODES.INVALID_ARGS);
233
+ }
234
+ resolvedSchema = result.schema;
235
+ }
236
+ const apiKey = getApiKey();
237
+ if (!apiKey) {
238
+ error('Not authenticated. Run `okra auth login` first.');
239
+ process.exit(EXIT_CODES.AUTH_ERROR);
240
+ }
241
+ const resolved = await resolveJobId(document).catch((err) => {
242
+ error(err instanceof Error ? err.message : String(err));
243
+ process.exit(EXIT_CODES.GENERAL_ERROR);
244
+ });
245
+ const jobId = resolved.jobId;
246
+ const model = options.model || 'claude-sonnet-4-20250514';
247
+ const quiet = isQuietMode();
248
+ const spinnerStream = resolvedSchema ? process.stderr : undefined;
249
+ const useSpinners = !quiet && (resolvedSchema ? true : process.stdout.isTTY);
250
+ const origConsoleLog = console.log;
251
+ if (resolvedSchema) {
252
+ console.log = (...args) => {
253
+ const first = typeof args[0] === 'string' ? args[0] : '';
254
+ if (first.startsWith('[AgentSession]'))
255
+ return;
256
+ origConsoleLog(...args);
257
+ };
258
+ }
259
+ const bootSpinner = useSpinners
260
+ ? createSpinner('Preparing document...', { stream: spinnerStream })
261
+ : null;
262
+ bootSpinner?.start();
263
+ let archiveUrl;
264
+ let totalPages;
265
+ try {
266
+ const bootstrap = await fetchBootstrap(jobId);
267
+ archiveUrl = bootstrap.archiveUrl;
268
+ totalPages = bootstrap.metadata.totalPages;
269
+ bootSpinner?.succeed(`Document ready (${totalPages} pages)`);
270
+ }
271
+ catch (err) {
272
+ if (err instanceof OkraApiError) {
273
+ bootSpinner?.fail(`Failed to prepare document (${err.statusCode})`);
274
+ error(err.message);
275
+ if (err.details)
276
+ error(JSON.stringify(err.details));
277
+ }
278
+ else {
279
+ bootSpinner?.fail('Failed to prepare document');
280
+ error(err instanceof Error ? err.message : String(err));
281
+ }
282
+ process.exit(EXIT_CODES.GENERAL_ERROR);
283
+ }
284
+ const renderer = resolvedSchema
285
+ ? new SilentCollectorRenderer()
286
+ : new AgentRenderer();
287
+ const client = new AgentSessionClient({
288
+ url: WSS_URL,
289
+ sessionId: jobId,
290
+ autoConnect: false,
291
+ autoReconnect: false,
292
+ });
293
+ let messageSent = false;
294
+ const sandboxSpinner = useSpinners
295
+ ? createSpinner('Booting sandbox...', { stream: spinnerStream })
296
+ : null;
297
+ let resolveSandboxReady;
298
+ const sandboxReady = new Promise((resolve, reject) => {
299
+ resolveSandboxReady = resolve;
300
+ const timeout = setTimeout(() => {
301
+ reject(new Error('Sandbox boot timed out'));
302
+ }, SANDBOX_TIMEOUT_MS);
303
+ client.on('SANDBOX_STATUS', (event) => {
304
+ if (event.status === 'ready') {
305
+ clearTimeout(timeout);
306
+ resolve();
307
+ }
308
+ else if (event.status === 'error') {
309
+ clearTimeout(timeout);
310
+ reject(new Error(event.error || 'Sandbox boot failed'));
311
+ }
312
+ });
313
+ });
314
+ client.on('READY', (event) => {
315
+ if (event.sandboxId) {
316
+ resolveSandboxReady();
317
+ }
318
+ else {
319
+ sandboxSpinner?.start();
320
+ client.send({
321
+ type: 'START_SANDBOX',
322
+ template: SANDBOX_TEMPLATE,
323
+ bootstrap: { archiveUrl },
324
+ });
325
+ }
326
+ });
327
+ const replayDone = new Promise((resolve) => {
328
+ sandboxReady.then(() => {
329
+ setTimeout(resolve, 100);
330
+ }).catch(() => resolve());
331
+ });
332
+ let resolveAgent;
333
+ let rejectAgent;
334
+ const agentDone = new Promise((resolve, reject) => {
335
+ resolveAgent = resolve;
336
+ rejectAgent = reject;
337
+ });
338
+ client.on('LIFECYCLE', (event) => {
339
+ if (sandboxSpinner?.isSpinning) {
340
+ sandboxSpinner.text = event.message || event.phase;
341
+ }
342
+ });
343
+ client.on('AGENT_STARTED', (_event) => {
344
+ if (!messageSent)
345
+ return;
346
+ renderer.onAgentStarted();
347
+ });
348
+ let structuredOutput = undefined;
349
+ client.on('AGENT_MESSAGE', (event) => {
350
+ if (!messageSent)
351
+ return;
352
+ if (event.subtype === 'result' && event.is_error) {
353
+ const errMsg = event.result || 'Agent failed (no details)';
354
+ renderer.onAgentError(String(errMsg));
355
+ rejectAgent(new Error(String(errMsg)));
356
+ return;
357
+ }
358
+ if (event.subtype === 'result' && event.structured_output != null) {
359
+ structuredOutput = event.structured_output;
360
+ }
361
+ renderer.onAgentMessage(event.message?.content);
362
+ });
363
+ client.on('AGENT_DONE', (event) => {
364
+ if (!messageSent)
365
+ return;
366
+ renderer.onAgentDone(event.total_cost_usd, event.duration_ms);
367
+ resolveAgent();
368
+ });
369
+ client.on('AGENT_ERROR', (event) => {
370
+ if (!messageSent)
371
+ return;
372
+ renderer.onAgentError(event.error);
373
+ rejectAgent(new Error(event.error));
374
+ });
375
+ client.on('ERROR', (event) => {
376
+ error(`Server error: ${event.msg}`);
377
+ rejectAgent(new Error(event.msg));
378
+ });
379
+ client.connect();
380
+ try {
381
+ await sandboxReady;
382
+ if (sandboxSpinner?.isSpinning) {
383
+ sandboxSpinner.succeed('Sandbox ready');
384
+ }
385
+ }
386
+ catch (err) {
387
+ sandboxSpinner?.fail('Sandbox boot failed');
388
+ error(err instanceof Error ? err.message : String(err));
389
+ client.disconnect();
390
+ process.exit(EXIT_CODES.GENERAL_ERROR);
391
+ }
392
+ await replayDone;
393
+ const shareUrl = `https://okrapdf.com/share/${jobId}`;
394
+ if (options.share) {
395
+ try {
396
+ await apiPatch(`api/share/${jobId}`, { is_public: true });
397
+ info(`Share URL: ${shareUrl}`);
398
+ }
399
+ catch (err) {
400
+ error(`Failed to enable sharing: ${err instanceof Error ? err.message : String(err)}`);
401
+ }
402
+ }
403
+ messageSent = true;
404
+ if (!resolvedSchema)
405
+ info(`Sending: ${message}`);
406
+ const systemPrompt = buildSystemPrompt({ schema: resolvedSchema });
407
+ const wsMessage = {
408
+ type: 'AGENT_MESSAGE',
409
+ content: message,
410
+ model: { id: model, provider: 'anthropic' },
411
+ systemPrompt,
412
+ agentType: 'claude-code',
413
+ author: { name: 'okra-cli', clientType: 'cli' },
414
+ };
415
+ if (resolvedSchema) {
416
+ wsMessage.outputFormat = {
417
+ type: 'json_schema',
418
+ schema: resolvedSchema.jsonSchema,
419
+ };
420
+ }
421
+ client.send(wsMessage);
422
+ try {
423
+ await agentDone;
424
+ if (options.share) {
425
+ info(`Share URL: ${shareUrl}`);
426
+ }
427
+ }
428
+ catch (err) {
429
+ error(err instanceof Error ? err.message : String(err));
430
+ process.exitCode = EXIT_CODES.GENERAL_ERROR;
431
+ }
432
+ if (resolvedSchema)
433
+ console.log = origConsoleLog;
434
+ if (resolvedSchema && !process.exitCode) {
435
+ let parsed = structuredOutput ?? null;
436
+ if (parsed === null) {
437
+ const raw = renderer.getCollectedText();
438
+ parsed = extractJson(raw);
439
+ if (parsed === null) {
440
+ process.stderr.write('Failed to extract JSON from agent response.\n');
441
+ process.stderr.write('Raw output:\n' + raw + '\n');
442
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
443
+ }
444
+ }
445
+ if (parsed !== null && !process.exitCode) {
446
+ if (resolvedSchema.zodSchema) {
447
+ const result = resolvedSchema.zodSchema.safeParse(parsed);
448
+ if (result.success) {
449
+ process.stdout.write(JSON.stringify(result.data, null, 2) + '\n');
450
+ }
451
+ else {
452
+ process.stderr.write('Schema validation failed:\n');
453
+ for (const issue of result.error.issues) {
454
+ process.stderr.write(` - ${issue.path.join('.')}: ${issue.message}\n`);
455
+ }
456
+ process.stderr.write('\nRaw JSON:\n' + JSON.stringify(parsed, null, 2) + '\n');
457
+ process.exitCode = EXIT_CODES.VALIDATION_ERROR;
458
+ }
459
+ }
460
+ else {
461
+ process.stdout.write(JSON.stringify(parsed, null, 2) + '\n');
462
+ }
463
+ }
464
+ }
465
+ client.disconnect();
466
+ process.exit(process.exitCode ?? 0);
467
+ }
436
468
  function parseJsonl(raw) {
437
469
  return raw
438
470
  .split('\n')
@@ -447,7 +479,6 @@ function parseJsonl(raw) {
447
479
  })
448
480
  .filter((e) => e !== null);
449
481
  }
450
- /* eslint-disable @typescript-eslint/no-explicit-any */
451
482
  function renderTerminalTranscript(events, chalk) {
452
483
  for (const evt of events) {
453
484
  const ts = evt.timestamp ? chalk.dim(new Date(evt.timestamp).toLocaleTimeString() + ' ') : '';