@link-assistant/agent 0.22.9 → 0.23.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.
package/README.md CHANGED
@@ -195,6 +195,9 @@ echo "hi" | agent
195
195
 
196
196
  # Other free models (in order of recommendation)
197
197
  echo "hi" | agent --model opencode/minimax-m2.5-free
198
+ echo "hi" | agent --model opencode/ling-2.6-flash-free
199
+ echo "hi" | agent --model opencode/hy3-preview-free
200
+ echo "hi" | agent --model opencode/nemotron-3-super-free
198
201
  echo "hi" | agent --model opencode/gpt-5-nano
199
202
  echo "hi" | agent --model opencode/big-pickle
200
203
 
@@ -602,13 +605,13 @@ bun run src/index.js
602
605
 
603
606
  ```bash
604
607
  # Run all tests
605
- bun test
608
+ npm test
606
609
 
607
610
  # Run specific test file
608
- bun test tests/mcp.test.js
609
- bun test tests/websearch.tools.test.js
610
- bun test tests/batch.tools.test.js
611
- bun test tests/plaintext.input.test.js
611
+ bun test ./tests/integration/mcp.js
612
+ bun test ./tests/integration/websearch.tools.js
613
+ bun test ./tests/integration/batch.tools.js
614
+ bun test ./tests/integration/plaintext.input.js
612
615
  ```
613
616
 
614
617
  For detailed testing information including how to run tests manually and trigger CI tests, see [TESTING.md](../TESTING.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.22.9",
3
+ "version": "0.23.0",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "scripts": {
11
11
  "dev": "bun run src/index.js",
12
- "test": "bun test tests/*.test.js tests/*.test.ts",
13
- "test:integration": "bun test tests/integration/basic.test.js",
12
+ "test": "bun test ./tests/*.js ./tests/*.ts",
13
+ "test:integration": "bun test ./tests/integration/basic.js",
14
14
  "lint": "eslint .",
15
15
  "lint:fix": "eslint . --fix",
16
16
  "format": "prettier --write .",
package/src/cli/argv.ts CHANGED
@@ -105,7 +105,7 @@ export function getCompactionSafetyMarginFromProcessArgv(): string | null {
105
105
  /**
106
106
  * Extract --compaction-models argument directly from process.argv
107
107
  * The value is a links notation references sequence, e.g.:
108
- * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
108
+ * "(big-pickle minimax-m2.5-free nemotron-3-super-free hy3-preview-free ling-2.6-flash-free gpt-5-nano same)"
109
109
  * @returns The compaction models argument from CLI or null if not found
110
110
  * @see https://github.com/link-assistant/agent/issues/232
111
111
  */
@@ -8,7 +8,7 @@ import { Instance } from '../project/instance.ts';
8
8
  import { Bus } from '../bus/index.ts';
9
9
  import { Session } from '../session/index.ts';
10
10
  import { SessionPrompt } from '../session/prompt.ts';
11
- import { createEventHandler } from '../json-standard/index.ts';
11
+ import { createEventHandler, serializeOutput } from '../json-standard/index.ts';
12
12
  import { createContinuousStdinReader } from './input-queue.js';
13
13
  import { Log } from '../util/log.ts';
14
14
  import { config } from '../config/config.ts';
@@ -40,6 +40,64 @@ export function getHasError() {
40
40
  // Logger for resume operations
41
41
  const log = Log.create({ service: 'resume' });
42
42
 
43
+ function getInputFormat(argv) {
44
+ return argv['input-format'] || argv.inputFormat || 'text';
45
+ }
46
+
47
+ function outputConsumedInput({
48
+ message,
49
+ jsonStandard,
50
+ sessionID,
51
+ compactJson,
52
+ }) {
53
+ const raw = message.raw || message.message || message.system || '';
54
+ if (
55
+ jsonStandard === 'claude' &&
56
+ message.kind === 'message' &&
57
+ message.format === 'stream-json'
58
+ ) {
59
+ process.stdout.write(
60
+ serializeOutput(
61
+ {
62
+ type: 'message',
63
+ timestamp: new Date().toISOString(),
64
+ session_id: sessionID,
65
+ role: 'user',
66
+ content: [{ type: 'text', text: message.message ?? '' }],
67
+ },
68
+ 'claude'
69
+ )
70
+ );
71
+ return;
72
+ }
73
+
74
+ if (jsonStandard === 'claude') {
75
+ return;
76
+ }
77
+
78
+ outputInput(
79
+ {
80
+ raw,
81
+ parsed: message.parsed || message,
82
+ format: message.format || 'text',
83
+ kind: message.kind || 'message',
84
+ },
85
+ compactJson
86
+ );
87
+ }
88
+
89
+ function outputInputParseError(error, line, compactJson) {
90
+ hasError = true;
91
+ outputError(
92
+ {
93
+ errorType: 'ValidationError',
94
+ message: error instanceof Error ? error.message : String(error),
95
+ raw: line,
96
+ },
97
+ compactJson
98
+ );
99
+ }
100
+
43
101
  /**
44
102
  * Resolve the session to use based on --resume, --continue, and --no-fork options.
45
103
  * Returns the session ID to use, handling forking as needed.
@@ -51,7 +109,8 @@ const log = Log.create({ service: 'resume' });
51
109
  export async function resolveResumeSession(argv, compactJson) {
52
110
  const resumeSessionID = argv.resume;
53
111
  const shouldContinue = argv.continue === true;
54
- const noFork = argv['no-fork'] === true;
112
+ const noFork =
113
+ argv['no-fork'] === true || argv.noFork === true || argv.fork === false;
55
114
 
56
115
  // If neither --resume nor --continue is specified, return null to create new session
57
116
  if (!resumeSessionID && !shouldContinue) {
@@ -199,8 +258,12 @@ export async function runContinuousServerMode(
199
258
  ) {
200
259
  // Check both CLI flag and environment variable for compact JSON mode
201
260
  const compactJson = argv['compact-json'] === true || config.compactJson;
261
+ const inputFormat = getInputFormat(argv);
202
262
  const isInteractive = argv.interactive !== false;
203
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
263
+ const autoMerge =
264
+ inputFormat === 'stream-json'
265
+ ? false
266
+ : argv['auto-merge-queued-messages'] !== false;
204
267
 
205
268
  // Start server like OpenCode does
206
269
  const server = Server.listen({ port: 0, hostname: '127.0.0.1' });
@@ -243,9 +306,23 @@ export async function runContinuousServerMode(
243
306
  // Track if we're currently processing a message
244
307
  let isProcessing = false;
245
308
  const pendingMessages = [];
309
+ let currentSystemMessage = systemMessage;
310
+ const currentAppendSystemMessage = appendSystemMessage;
246
311
 
247
312
  // Process messages from the queue
248
313
  const processMessage = async (message) => {
314
+ if (message.kind === 'interrupt') {
315
+ SessionPrompt.cancel(sessionID);
316
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
317
+ return;
318
+ }
319
+
320
+ if (message.kind === 'system') {
321
+ currentSystemMessage = message.system;
322
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
323
+ return;
324
+ }
325
+
249
326
  if (isProcessing) {
250
327
  pendingMessages.push(message);
251
328
  return;
@@ -254,16 +331,9 @@ export async function runContinuousServerMode(
254
331
  isProcessing = true;
255
332
 
256
333
  // Output input confirmation in JSON format
257
- outputInput(
258
- {
259
- raw: message.raw || message.message,
260
- parsed: message,
261
- format: message.format || 'text',
262
- },
263
- compactJson
264
- );
334
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
265
335
 
266
- const messageText = message.message || 'hi';
336
+ const messageText = message.message ?? 'hi';
267
337
  const parts = [{ type: 'text', text: messageText }];
268
338
 
269
339
  // Create a promise to wait for this message to complete
@@ -289,8 +359,8 @@ export async function runContinuousServerMode(
289
359
  parts,
290
360
  model: { providerID, modelID },
291
361
  compactionModel,
292
- system: systemMessage,
293
- appendSystem: appendSystemMessage,
362
+ system: currentSystemMessage,
363
+ appendSystem: currentAppendSystemMessage,
294
364
  temperature,
295
365
  }),
296
366
  }
@@ -376,11 +446,15 @@ export async function runContinuousServerMode(
376
446
 
377
447
  // Create continuous stdin reader
378
448
  stdinReader = createContinuousStdinReader({
379
- interactive: isInteractive,
449
+ interactive: inputFormat === 'stream-json' ? false : isInteractive,
380
450
  autoMerge,
451
+ inputFormat,
381
452
  onMessage: (message) => {
382
453
  processMessage(message);
383
454
  },
455
+ onError: (error, line) => {
456
+ outputInputParseError(error, line, compactJson);
457
+ },
384
458
  });
385
459
 
386
460
  // Wait for stdin to end (EOF or close)
@@ -453,8 +527,12 @@ export async function runContinuousDirectMode(
453
527
  ) {
454
528
  // Check both CLI flag and environment variable for compact JSON mode
455
529
  const compactJson = argv['compact-json'] === true || config.compactJson;
530
+ const inputFormat = getInputFormat(argv);
456
531
  const isInteractive = argv.interactive !== false;
457
- const autoMerge = argv['auto-merge-queued-messages'] !== false;
532
+ const autoMerge =
533
+ inputFormat === 'stream-json'
534
+ ? false
535
+ : argv['auto-merge-queued-messages'] !== false;
458
536
 
459
537
  let unsub = null;
460
538
  let stdinReader = null;
@@ -482,9 +560,23 @@ export async function runContinuousDirectMode(
482
560
  // Track if we're currently processing a message
483
561
  let isProcessing = false;
484
562
  const pendingMessages = [];
563
+ let currentSystemMessage = systemMessage;
564
+ const currentAppendSystemMessage = appendSystemMessage;
485
565
 
486
566
  // Process messages from the queue
487
567
  const processMessage = async (message) => {
568
+ if (message.kind === 'interrupt') {
569
+ SessionPrompt.cancel(sessionID);
570
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
571
+ return;
572
+ }
573
+
574
+ if (message.kind === 'system') {
575
+ currentSystemMessage = message.system;
576
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
577
+ return;
578
+ }
579
+
488
580
  if (isProcessing) {
489
581
  pendingMessages.push(message);
490
582
  return;
@@ -493,16 +585,9 @@ export async function runContinuousDirectMode(
493
585
  isProcessing = true;
494
586
 
495
587
  // Output input confirmation in JSON format
496
- outputInput(
497
- {
498
- raw: message.raw || message.message,
499
- parsed: message,
500
- format: message.format || 'text',
501
- },
502
- compactJson
503
- );
588
+ outputConsumedInput({ message, jsonStandard, sessionID, compactJson });
504
589
 
505
- const messageText = message.message || 'hi';
590
+ const messageText = message.message ?? 'hi';
506
591
  const parts = [{ type: 'text', text: messageText }];
507
592
 
508
593
  // Create a promise to wait for this message to complete
@@ -524,8 +609,8 @@ export async function runContinuousDirectMode(
524
609
  parts,
525
610
  model: { providerID, modelID },
526
611
  compactionModel,
527
- system: systemMessage,
528
- appendSystem: appendSystemMessage,
612
+ system: currentSystemMessage,
613
+ appendSystem: currentAppendSystemMessage,
529
614
  temperature,
530
615
  }).catch((error) => {
531
616
  hasError = true;
@@ -609,11 +694,15 @@ export async function runContinuousDirectMode(
609
694
 
610
695
  // Create continuous stdin reader
611
696
  stdinReader = createContinuousStdinReader({
612
- interactive: isInteractive,
697
+ interactive: inputFormat === 'stream-json' ? false : isInteractive,
613
698
  autoMerge,
699
+ inputFormat,
614
700
  onMessage: (message) => {
615
701
  processMessage(message);
616
702
  },
703
+ onError: (error, line) => {
704
+ outputInputParseError(error, line, compactJson);
705
+ },
617
706
  });
618
707
 
619
708
  // Wait for stdin to end (EOF or close)
@@ -1,62 +1 @@
1
- /**
2
- * Default CLI configuration values.
3
- *
4
- * Centralizing defaults here ensures all code references the same value (#208).
5
- * When the default model changes, update this file only.
6
- */
7
-
8
- /** Default model used when no `--model` CLI argument is provided. */
9
- export const DEFAULT_MODEL = 'opencode/nemotron-3-super-free';
10
-
11
- /** Default provider ID extracted from DEFAULT_MODEL. */
12
- export const DEFAULT_PROVIDER_ID = DEFAULT_MODEL.split('/')[0];
13
-
14
- /** Default model ID extracted from DEFAULT_MODEL. */
15
- export const DEFAULT_MODEL_ID = DEFAULT_MODEL.split('/').slice(1).join('/');
16
-
17
- /**
18
- * Default compaction model used when no `--compaction-model` CLI argument is provided.
19
- * gpt-5-nano has a 400K context window, larger than most free base models (~200K),
20
- * which allows compacting 100% of the base model's context without a safety margin.
21
- * The special value "same" means use the same model as `--model`.
22
- * @see https://github.com/link-assistant/agent/issues/219
23
- */
24
- export const DEFAULT_COMPACTION_MODEL = 'opencode/gpt-5-nano';
25
-
26
- /**
27
- * Default compaction models cascade, ordered from smallest/cheapest context to largest.
28
- * During compaction, the system tries each model in order. If the used context exceeds
29
- * a model's context limit, it skips to the next larger model. If a model's rate limit
30
- * is reached, it also skips to the next model.
31
- * The special value "same" means use the same model as `--model`.
32
- *
33
- * Parsed as links notation references sequence (single anonymous link):
34
- * "(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)"
35
- *
36
- * Context limits (approximate):
37
- * big-pickle: ~200K
38
- * minimax-m2.5-free: ~200K
39
- * nemotron-3-super-free: ~262K (default model)
40
- * gpt-5-nano: ~400K
41
- * same: (base model's context)
42
- *
43
- * Note: qwen3.6-plus-free was removed — free promotion ended April 2026.
44
- * @see https://github.com/link-assistant/agent/issues/242
45
- * @see https://github.com/link-assistant/agent/issues/232
46
- */
47
- export const DEFAULT_COMPACTION_MODELS =
48
- '(big-pickle minimax-m2.5-free nemotron-3-super-free gpt-5-nano same)';
49
-
50
- /**
51
- * Default compaction safety margin as a percentage of usable context window.
52
- * Applied only when the compaction model has a context window equal to or smaller
53
- * than the base model. When the compaction model has a larger context, the margin
54
- * is automatically set to 0 (allowing 100% context usage).
55
- *
56
- * Increased from 15% to 25% to reduce probability of context overflow errors,
57
- * especially when providers return inaccurate or zero token counts.
58
- * Matches OpenCode upstream's 75% threshold (25% margin).
59
- * @see https://github.com/link-assistant/agent/issues/219
60
- * @see https://github.com/link-assistant/agent/issues/249
61
- */
62
- export const DEFAULT_COMPACTION_SAFETY_MARGIN_PERCENT = 25;
1
+ export * from '../config/defaults.ts';
@@ -5,11 +5,13 @@
5
5
  export class InputQueue {
6
6
  constructor(options = {}) {
7
7
  this.queue = [];
8
+ this.inputFormat = options.inputFormat || 'text';
8
9
  this.autoMerge = options.autoMerge !== false; // enabled by default
9
10
  this.mergeDelayMs = options.mergeDelayMs || 50; // delay to wait for more lines
10
11
  this.pendingLines = [];
11
12
  this.mergeTimer = null;
12
13
  this.onMessage = options.onMessage || (() => {});
14
+ this.onError = options.onError || (() => {});
13
15
  this.interactive = options.interactive !== false; // enabled by default
14
16
  }
15
17
 
@@ -24,6 +26,10 @@ export class InputQueue {
24
26
  return null;
25
27
  }
26
28
 
29
+ if (this.inputFormat === 'stream-json') {
30
+ return parseStreamJsonInput(trimmed);
31
+ }
32
+
27
33
  try {
28
34
  const parsed = JSON.parse(trimmed);
29
35
  // If it has a message field, use it directly
@@ -43,6 +49,11 @@ export class InputQueue {
43
49
  * @param {string} line - Input line
44
50
  */
45
51
  addLine(line) {
52
+ if (this.inputFormat === 'stream-json') {
53
+ this.queueLine(line);
54
+ return;
55
+ }
56
+
46
57
  if (!this.interactive && !line.trim().startsWith('{')) {
47
58
  // In non-interactive mode, only accept JSON
48
59
  return;
@@ -54,11 +65,7 @@ export class InputQueue {
54
65
  this.scheduleMerge();
55
66
  } else {
56
67
  // No merging, queue immediately
57
- const parsed = this.parseInput(line);
58
- if (parsed) {
59
- this.queue.push(parsed);
60
- this.notifyMessage(parsed);
61
- }
68
+ this.queueLine(line);
62
69
  }
63
70
  }
64
71
 
@@ -130,6 +137,33 @@ export class InputQueue {
130
137
  }
131
138
  }
132
139
 
140
+ /**
141
+ * Notify listener of an input parse error
142
+ * @param {Error} error - Parse error
143
+ * @param {string} line - Raw input line
144
+ */
145
+ notifyError(error, line) {
146
+ if (this.onError) {
147
+ this.onError(error, line);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Parse and enqueue one complete input line
153
+ * @param {string} line - Raw input line
154
+ */
155
+ queueLine(line) {
156
+ try {
157
+ const parsed = this.parseInput(line);
158
+ if (parsed) {
159
+ this.queue.push(parsed);
160
+ this.notifyMessage(parsed);
161
+ }
162
+ } catch (error) {
163
+ this.notifyError(error, line);
164
+ }
165
+ }
166
+
133
167
  /**
134
168
  * Get queue size
135
169
  * @returns {number}
@@ -139,6 +173,136 @@ export class InputQueue {
139
173
  }
140
174
  }
141
175
 
176
+ function extractContentText(content) {
177
+ if (typeof content === 'string') {
178
+ return content;
179
+ }
180
+
181
+ if (Array.isArray(content)) {
182
+ const parts = [];
183
+ for (const part of content) {
184
+ if (typeof part === 'string') {
185
+ parts.push(part);
186
+ } else if (part && typeof part === 'object') {
187
+ if (typeof part.text === 'string') {
188
+ parts.push(part.text);
189
+ } else if (typeof part.content === 'string') {
190
+ parts.push(part.content);
191
+ }
192
+ }
193
+ }
194
+ return parts.length > 0 ? parts.join('\n') : null;
195
+ }
196
+
197
+ if (content && typeof content === 'object') {
198
+ if (typeof content.text === 'string') {
199
+ return content.text;
200
+ }
201
+ if ('content' in content) {
202
+ return extractContentText(content.content);
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ function extractFrameText(frame) {
210
+ if (typeof frame.message === 'string') {
211
+ return frame.message;
212
+ }
213
+
214
+ if (frame.message && typeof frame.message === 'object') {
215
+ const messageText = extractContentText(frame.message.content);
216
+ if (messageText !== null) {
217
+ return messageText;
218
+ }
219
+ if (typeof frame.message.text === 'string') {
220
+ return frame.message.text;
221
+ }
222
+ }
223
+
224
+ const contentText = extractContentText(frame.content);
225
+ if (contentText !== null) {
226
+ return contentText;
227
+ }
228
+
229
+ if (typeof frame.text === 'string') {
230
+ return frame.text;
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * Parse one Claude-compatible stream-json input frame.
238
+ * @param {string} input - One JSONL frame
239
+ * @returns {object} Normalized queue message
240
+ */
241
+ export function parseStreamJsonInput(input) {
242
+ let frame;
243
+ try {
244
+ frame = JSON.parse(input);
245
+ } catch (error) {
246
+ throw new Error(
247
+ `Invalid stream-json input frame: ${
248
+ error instanceof Error ? error.message : String(error)
249
+ }`
250
+ );
251
+ }
252
+
253
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame)) {
254
+ throw new Error('Invalid stream-json input frame: expected JSON object');
255
+ }
256
+
257
+ const type = frame.type;
258
+
259
+ if (type === 'interrupt') {
260
+ return {
261
+ kind: 'interrupt',
262
+ raw: input,
263
+ parsed: frame,
264
+ format: 'stream-json',
265
+ inputType: type,
266
+ };
267
+ }
268
+
269
+ if (type === 'system') {
270
+ const system = extractFrameText(frame);
271
+ if (system === null) {
272
+ throw new Error(
273
+ 'Invalid stream-json system frame: expected content text'
274
+ );
275
+ }
276
+ return {
277
+ kind: 'system',
278
+ system,
279
+ raw: input,
280
+ parsed: frame,
281
+ format: 'stream-json',
282
+ inputType: type,
283
+ };
284
+ }
285
+
286
+ if (type === 'user' || type === 'user_prompt' || type === undefined) {
287
+ const message = extractFrameText(frame);
288
+ if (message === null) {
289
+ throw new Error(
290
+ 'Invalid stream-json user frame: expected message content text'
291
+ );
292
+ }
293
+ return {
294
+ kind: 'message',
295
+ message,
296
+ raw: input,
297
+ parsed: frame,
298
+ format: 'stream-json',
299
+ inputType: type || 'message',
300
+ };
301
+ }
302
+
303
+ throw new Error(`Unsupported stream-json input frame type: ${String(type)}`);
304
+ }
305
+
142
306
  /**
143
307
  * Create a continuous stdin reader that queues input lines
144
308
  * @param {object} options - Options for the reader