@link-assistant/agent 0.13.4 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.13.4",
3
+ "version": "0.14.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",
package/src/bun/index.ts CHANGED
@@ -141,6 +141,14 @@ export namespace BunProc {
141
141
  return new Promise((resolve) => setTimeout(resolve, ms));
142
142
  }
143
143
 
144
+ /**
145
+ * Staleness threshold for 'latest' version packages (24 hours).
146
+ * Packages installed as 'latest' will be refreshed after this period.
147
+ * This ensures users get updated packages with bug fixes and new features.
148
+ * @see https://github.com/link-assistant/agent/issues/177
149
+ */
150
+ const LATEST_VERSION_STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
151
+
144
152
  export async function install(pkg: string, version = 'latest') {
145
153
  const mod = path.join(Global.Path.cache, 'node_modules', pkg);
146
154
 
@@ -150,11 +158,41 @@ export namespace BunProc {
150
158
 
151
159
  const pkgjson = Bun.file(path.join(Global.Path.cache, 'package.json'));
152
160
  const parsed = await pkgjson.json().catch(async () => {
153
- const result = { dependencies: {} };
161
+ const result = { dependencies: {}, _installTime: {} };
154
162
  await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2));
155
163
  return result;
156
164
  });
157
- if (parsed.dependencies[pkg] === version) return mod;
165
+
166
+ // Initialize _installTime tracking if not present
167
+ if (!parsed._installTime) {
168
+ parsed._installTime = {};
169
+ }
170
+
171
+ // Check if package is already installed with the requested version
172
+ const installedVersion = parsed.dependencies[pkg];
173
+ const installTime = parsed._installTime[pkg] as number | undefined;
174
+
175
+ if (installedVersion === version) {
176
+ // For 'latest' version, check if installation is stale and needs refresh
177
+ // This ensures users get updated packages with important fixes
178
+ // @see https://github.com/link-assistant/agent/issues/177 (specificationVersion v3 support)
179
+ if (version === 'latest' && installTime) {
180
+ const age = Date.now() - installTime;
181
+ if (age < LATEST_VERSION_STALE_THRESHOLD_MS) {
182
+ return mod;
183
+ }
184
+ log.info(() => ({
185
+ message: 'refreshing stale latest package',
186
+ pkg,
187
+ version,
188
+ ageMs: age,
189
+ threshold: LATEST_VERSION_STALE_THRESHOLD_MS,
190
+ }));
191
+ } else if (version !== 'latest') {
192
+ // For explicit versions, don't reinstall
193
+ return mod;
194
+ }
195
+ }
158
196
 
159
197
  // Check for dry-run mode
160
198
  if (Flag.OPENCODE_DRY_RUN) {
@@ -205,6 +243,8 @@ export namespace BunProc {
205
243
  attempt,
206
244
  }));
207
245
  parsed.dependencies[pkg] = version;
246
+ // Track installation time for 'latest' version staleness checks
247
+ parsed._installTime[pkg] = Date.now();
208
248
  await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2));
209
249
  return mod;
210
250
  } catch (e) {
package/src/flag/flag.ts CHANGED
@@ -4,6 +4,11 @@ export namespace Flag {
4
4
  return process.env[newKey] ?? process.env[oldKey];
5
5
  }
6
6
 
7
+ function truthy(key: string) {
8
+ const value = process.env[key]?.toLowerCase();
9
+ return value === 'true' || value === '1';
10
+ }
11
+
7
12
  function truthyCompat(newKey: string, oldKey: string): boolean {
8
13
  const value = (getEnv(newKey, oldKey) ?? '').toLowerCase();
9
14
  return value === 'true' || value === '1';
@@ -77,6 +82,40 @@ export namespace Flag {
77
82
  GENERATE_TITLE = value;
78
83
  }
79
84
 
85
+ // Output response model information in step-finish parts
86
+ // Enabled by default - includes model info (providerID, requestedModelID, respondedModelID) in output
87
+ // Can be disabled with AGENT_OUTPUT_RESPONSE_MODEL=false
88
+ // See: https://github.com/link-assistant/agent/issues/179
89
+ export let OUTPUT_RESPONSE_MODEL = (() => {
90
+ const value = (
91
+ getEnv(
92
+ 'LINK_ASSISTANT_AGENT_OUTPUT_RESPONSE_MODEL',
93
+ 'AGENT_OUTPUT_RESPONSE_MODEL'
94
+ ) ?? ''
95
+ ).toLowerCase();
96
+ if (value === 'false' || value === '0') return false;
97
+ return true; // Default to true
98
+ })();
99
+
100
+ // Allow setting output-response-model mode programmatically (e.g., from CLI --output-response-model flag)
101
+ export function setOutputResponseModel(value: boolean) {
102
+ OUTPUT_RESPONSE_MODEL = value;
103
+ }
104
+
105
+ // Session summarization configuration
106
+ // When disabled, session summaries will not be generated
107
+ // This saves tokens and prevents rate limit issues with free tier models
108
+ // See: https://github.com/link-assistant/agent/issues/179
109
+ export let SUMMARIZE_SESSION = truthyCompat(
110
+ 'LINK_ASSISTANT_AGENT_SUMMARIZE_SESSION',
111
+ 'AGENT_SUMMARIZE_SESSION'
112
+ );
113
+
114
+ // Allow setting summarize-session mode programmatically (e.g., from CLI --summarize-session flag)
115
+ export function setSummarizeSession(value: boolean) {
116
+ SUMMARIZE_SESSION = value;
117
+ }
118
+
80
119
  // Retry timeout configuration
81
120
  // Maximum total time to keep retrying for the same error type (default: 7 days in seconds)
82
121
  // For different error types, the timer resets
@@ -155,9 +194,4 @@ export namespace Flag {
155
194
  export function setCompactJson(value: boolean) {
156
195
  _compactJson = value;
157
196
  }
158
-
159
- function truthy(key: string) {
160
- const value = process.env[key]?.toLowerCase();
161
- return value === 'true' || value === '1';
162
- }
163
197
  }
package/src/index.js CHANGED
@@ -1,9 +1,7 @@
1
1
  #!/usr/bin/env bun
2
-
2
+ import { Flag } from './flag/flag.ts';
3
3
  import { setProcessName } from './cli/process-name.ts';
4
-
5
4
  setProcessName('agent');
6
-
7
5
  import { Server } from './server/server.ts';
8
6
  import { Instance } from './project/instance.ts';
9
7
  import { Log } from './util/log.ts';
@@ -19,7 +17,6 @@ import {
19
17
  } from './json-standard/index.ts';
20
18
  import { McpCommand } from './cli/cmd/mcp.ts';
21
19
  import { AuthCommand } from './cli/cmd/auth.ts';
22
- import { Flag } from './flag/flag.ts';
23
20
  import { FormatError } from './cli/error.ts';
24
21
  import { UI } from './cli/ui.ts';
25
22
  import {
@@ -745,6 +742,16 @@ async function main() {
745
742
  type: 'number',
746
743
  description:
747
744
  'Maximum total retry time in seconds for rate limit errors (default: 604800 = 7 days)',
745
+ })
746
+ .option('output-response-model', {
747
+ type: 'boolean',
748
+ description: 'Include model info in step_finish output',
749
+ default: true,
750
+ })
751
+ .option('summarize-session', {
752
+ type: 'boolean',
753
+ description: 'Generate AI session summaries',
754
+ default: false,
748
755
  }),
749
756
  handler: async (argv) => {
750
757
  // Check both CLI flag and environment variable for compact JSON mode
@@ -908,37 +915,30 @@ async function main() {
908
915
  await runAgentMode(argv, request);
909
916
  },
910
917
  })
911
- // Initialize logging early for all CLI commands
912
- // This prevents debug output from appearing in CLI unless --verbose is used
918
+ // Initialize logging and flags early for all CLI commands
913
919
  .middleware(async (argv) => {
914
- // Set global compact JSON setting (CLI flag or environment variable)
915
920
  const isCompact = argv['compact-json'] === true || Flag.COMPACT_JSON();
916
921
  if (isCompact) {
917
922
  setCompactJson(true);
918
923
  }
919
-
920
- // Set verbose flag if requested
921
924
  if (argv.verbose) {
922
925
  Flag.setVerbose(true);
923
926
  }
924
-
925
- // Set dry-run flag if requested
926
927
  if (argv['dry-run']) {
927
928
  Flag.setDryRun(true);
928
929
  }
929
-
930
- // Set generate-title flag if explicitly enabled
931
- // Default is false to save tokens and prevent rate limit issues
932
- // See: https://github.com/link-assistant/agent/issues/157
933
930
  if (argv['generate-title'] === true) {
934
931
  Flag.setGenerateTitle(true);
935
932
  }
936
-
937
- // Initialize logging system
938
- // - Print logs to stdout only when verbose for clean CLI output
939
- // - Use verbose flag to enable DEBUG level logging
933
+ // output-response-model is enabled by default, only set if explicitly disabled
934
+ if (argv['output-response-model'] === false) {
935
+ Flag.setOutputResponseModel(false);
936
+ }
937
+ if (argv['summarize-session'] === true) {
938
+ Flag.setSummarizeSession(true);
939
+ }
940
940
  await Log.init({
941
- print: Flag.OPENCODE_VERBOSE, // Output logs only when verbose for clean CLI output
941
+ print: Flag.OPENCODE_VERBOSE,
942
942
  level: Flag.OPENCODE_VERBOSE ? 'DEBUG' : 'INFO',
943
943
  compactJson: isCompact,
944
944
  });
@@ -131,8 +131,11 @@ export namespace ModelsDev {
131
131
  if (result) return result as Record<string, Provider>;
132
132
 
133
133
  // Fallback to bundled data if cache read failed
134
- log.warn(() => ({
135
- message: 'cache read failed, using bundled data',
134
+ // This is expected behavior when the cache is unavailable or corrupted
135
+ // Using info level since bundled data is a valid fallback mechanism
136
+ // @see https://github.com/link-assistant/agent/issues/177
137
+ log.info(() => ({
138
+ message: 'cache unavailable, using bundled data',
136
139
  path: filepath,
137
140
  }));
138
141
  const json = await data();
@@ -1297,11 +1297,25 @@ export namespace Provider {
1297
1297
  }
1298
1298
  }
1299
1299
 
1300
+ /**
1301
+ * Get a small/cheap model for auxiliary tasks like title generation and summarization.
1302
+ * This is NOT the primary model for user requests - it's used for background tasks.
1303
+ *
1304
+ * Note: Logs from this function may show a different model than what the user specified.
1305
+ * This is by design - we use cheaper models for auxiliary tasks to save tokens/costs.
1306
+ *
1307
+ * @see https://github.com/link-assistant/agent/issues/179
1308
+ */
1300
1309
  export async function getSmallModel(providerID: string) {
1301
1310
  const cfg = await Config.get();
1302
1311
 
1303
1312
  if (cfg.small_model) {
1304
1313
  const parsed = parseModel(cfg.small_model);
1314
+ log.info(() => ({
1315
+ message: 'using configured small_model for auxiliary task',
1316
+ modelID: parsed.modelID,
1317
+ providerID: parsed.providerID,
1318
+ }));
1305
1319
  return getModel(parsed.providerID, parsed.modelID);
1306
1320
  }
1307
1321
 
@@ -1339,7 +1353,15 @@ export namespace Provider {
1339
1353
  }
1340
1354
  for (const item of priority) {
1341
1355
  for (const model of Object.keys(provider.info.models)) {
1342
- if (model.includes(item)) return getModel(providerID, model);
1356
+ if (model.includes(item)) {
1357
+ log.info(() => ({
1358
+ message: 'selected small model for auxiliary task',
1359
+ modelID: model,
1360
+ providerID,
1361
+ hint: 'This model is used for title/summary generation, not primary requests',
1362
+ }));
1363
+ return getModel(providerID, model);
1364
+ }
1343
1365
  }
1344
1366
  }
1345
1367
  }
@@ -130,8 +130,8 @@ export namespace RetryFetch {
130
130
  log.info(() => ({
131
131
  message: 'using retry-after value',
132
132
  retryAfterMs,
133
- delay,
134
- minInterval,
133
+ delayMs: delay,
134
+ minIntervalMs: minInterval,
135
135
  }));
136
136
  return addJitter(delay);
137
137
  }
@@ -145,10 +145,10 @@ export namespace RetryFetch {
145
145
  log.info(() => ({
146
146
  message: 'no retry-after header, using exponential backoff',
147
147
  attempt,
148
- backoffDelay,
149
- delay,
150
- minInterval,
151
- maxBackoffDelay,
148
+ backoffDelayMs: backoffDelay,
149
+ delayMs: delay,
150
+ minIntervalMs: minInterval,
151
+ maxBackoffDelayMs: maxBackoffDelay,
152
152
  }));
153
153
  return addJitter(delay);
154
154
  }
@@ -334,8 +334,8 @@ export namespace RetryFetch {
334
334
  message:
335
335
  'network error retry timeout exceeded, re-throwing error',
336
336
  sessionID,
337
- elapsed,
338
- maxRetryTimeout,
337
+ elapsedMs: elapsed,
338
+ maxRetryTimeoutMs: maxRetryTimeout,
339
339
  error: (error as Error).message,
340
340
  }));
341
341
  throw error;
@@ -350,7 +350,7 @@ export namespace RetryFetch {
350
350
  message: 'network error, retrying',
351
351
  sessionID,
352
352
  attempt,
353
- delay,
353
+ delayMs: delay,
354
354
  error: (error as Error).message,
355
355
  }));
356
356
  await sleep(delay, init?.signal ?? undefined);
@@ -370,8 +370,8 @@ export namespace RetryFetch {
370
370
  log.warn(() => ({
371
371
  message: 'retry timeout exceeded in fetch wrapper, returning 429',
372
372
  sessionID,
373
- elapsed,
374
- maxRetryTimeout,
373
+ elapsedMs: elapsed,
374
+ maxRetryTimeoutMs: maxRetryTimeout,
375
375
  }));
376
376
  return response; // Let higher-level handling take over
377
377
  }
@@ -390,8 +390,8 @@ export namespace RetryFetch {
390
390
  message:
391
391
  'retry-after exceeds remaining timeout, returning 429 response',
392
392
  sessionID,
393
- elapsed,
394
- remainingTimeout: maxRetryTimeout - elapsed,
393
+ elapsedMs: elapsed,
394
+ remainingTimeoutMs: maxRetryTimeout - elapsed,
395
395
  }));
396
396
  return response;
397
397
  }
@@ -401,9 +401,9 @@ export namespace RetryFetch {
401
401
  log.warn(() => ({
402
402
  message: 'delay would exceed retry timeout, returning 429 response',
403
403
  sessionID,
404
- elapsed,
405
- delay,
406
- maxRetryTimeout,
404
+ elapsedMs: elapsed,
405
+ delayMs: delay,
406
+ maxRetryTimeoutMs: maxRetryTimeout,
407
407
  }));
408
408
  return response;
409
409
  }
@@ -414,11 +414,11 @@ export namespace RetryFetch {
414
414
  message: 'rate limited, will retry',
415
415
  sessionID,
416
416
  attempt,
417
- delay,
417
+ delayMs: delay,
418
418
  delayMinutes: (delay / 1000 / 60).toFixed(2),
419
419
  delayHours: (delay / 1000 / 3600).toFixed(2),
420
- elapsed,
421
- remainingTimeout,
420
+ elapsedMs: elapsed,
421
+ remainingTimeoutMs: remainingTimeout,
422
422
  remainingTimeoutHours: (remainingTimeout / 1000 / 3600).toFixed(2),
423
423
  isolatedSignal: true, // Indicates we're using isolated signal for this wait
424
424
  }));
@@ -224,6 +224,22 @@ export namespace MessageV2 {
224
224
  });
225
225
  export type StepStartPart = z.infer<typeof StepStartPart>;
226
226
 
227
+ /**
228
+ * Model information for output parts.
229
+ * Included when --output-response-model flag is enabled.
230
+ * @see https://github.com/link-assistant/agent/issues/179
231
+ */
232
+ export const ModelInfo = z
233
+ .object({
234
+ providerID: z.string(),
235
+ requestedModelID: z.string(),
236
+ respondedModelID: z.string().optional(),
237
+ })
238
+ .meta({
239
+ ref: 'ModelInfo',
240
+ });
241
+ export type ModelInfo = z.infer<typeof ModelInfo>;
242
+
227
243
  export const StepFinishPart = PartBase.extend({
228
244
  type: z.literal('step-finish'),
229
245
  reason: z.string(),
@@ -238,6 +254,9 @@ export namespace MessageV2 {
238
254
  write: z.number(),
239
255
  }),
240
256
  }),
257
+ // Model info included when --output-response-model is enabled
258
+ // @see https://github.com/link-assistant/agent/issues/179
259
+ model: ModelInfo.optional(),
241
260
  }).meta({
242
261
  ref: 'StepFinishPart',
243
262
  });
@@ -16,6 +16,7 @@ import { SessionSummary } from './summary';
16
16
  import { Bus } from '../bus';
17
17
  import { SessionRetry } from './retry';
18
18
  import { SessionStatus } from './status';
19
+ import { Flag } from '../flag/flag';
19
20
 
20
21
  export namespace SessionProcessor {
21
22
  const DOOM_LOOP_THRESHOLD = 3;
@@ -261,6 +262,21 @@ export namespace SessionProcessor {
261
262
  input.assistantMessage.finish = finishReason;
262
263
  input.assistantMessage.cost += usage.cost;
263
264
  input.assistantMessage.tokens = usage.tokens;
265
+
266
+ // Build model info if --output-response-model flag is enabled
267
+ // @see https://github.com/link-assistant/agent/issues/179
268
+ const modelInfo: MessageV2.ModelInfo | undefined =
269
+ Flag.OUTPUT_RESPONSE_MODEL
270
+ ? {
271
+ providerID: input.providerID,
272
+ requestedModelID: input.model.id,
273
+ // Get respondedModelID from finish-step response if available
274
+ // AI SDK includes response.modelId when available from provider
275
+ respondedModelID:
276
+ (value as any).response?.modelId ?? undefined,
277
+ }
278
+ : undefined;
279
+
264
280
  await Session.updatePart({
265
281
  id: Identifier.ascending('part'),
266
282
  reason: finishReason,
@@ -270,6 +286,7 @@ export namespace SessionProcessor {
270
286
  type: 'step-finish',
271
287
  tokens: usage.tokens,
272
288
  cost: usage.cost,
289
+ model: modelInfo,
273
290
  });
274
291
  await Session.updateMessage(input.assistantMessage);
275
292
  if (snapshot) {
@@ -94,8 +94,8 @@ export namespace SessionRetry {
94
94
  message: 'retry timeout exceeded',
95
95
  sessionID,
96
96
  errorType,
97
- elapsedTime,
98
- maxTime,
97
+ elapsedTimeMs: elapsedTime,
98
+ maxTimeMs: maxTime,
99
99
  }));
100
100
  return { shouldRetry: false, elapsedTime, maxTime };
101
101
  }
@@ -245,8 +245,8 @@ export namespace SessionRetry {
245
245
  log.info(() => ({
246
246
  message: 'no retry-after header, using exponential backoff',
247
247
  attempt,
248
- backoffDelay,
249
- maxBackoffDelay,
248
+ backoffDelayMs: backoffDelay,
249
+ maxBackoffDelayMs: maxBackoffDelay,
250
250
  }));
251
251
  return addJitter(backoffDelay);
252
252
  }
@@ -260,8 +260,8 @@ export namespace SessionRetry {
260
260
  message:
261
261
  'no response headers, using exponential backoff with conservative cap',
262
262
  attempt,
263
- backoffDelay,
264
- maxCap: RETRY_MAX_DELAY_NO_HEADERS,
263
+ backoffDelayMs: backoffDelay,
264
+ maxCapMs: RETRY_MAX_DELAY_NO_HEADERS,
265
265
  }));
266
266
  return addJitter(backoffDelay);
267
267
  }
@@ -13,6 +13,7 @@ import path from 'path';
13
13
  import { Instance } from '../project/instance';
14
14
  import { Storage } from '../storage/storage';
15
15
  import { Bus } from '../bus';
16
+ import { Flag } from '../flag/flag';
16
17
 
17
18
  export namespace SessionSummary {
18
19
  const log = Log.create({ service: 'session.summary' });
@@ -79,6 +80,16 @@ export namespace SessionSummary {
79
80
  };
80
81
  await Session.updateMessage(userMsg);
81
82
 
83
+ // Skip AI-powered summarization if disabled (default)
84
+ // See: https://github.com/link-assistant/agent/issues/179
85
+ if (!Flag.SUMMARIZE_SESSION) {
86
+ log.info(() => ({
87
+ message: 'session summarization disabled',
88
+ hint: 'Enable with --summarize-session flag or AGENT_SUMMARIZE_SESSION=true',
89
+ }));
90
+ return;
91
+ }
92
+
82
93
  const assistantMsg = messages.find((m) => m.info.role === 'assistant')!
83
94
  .info as MessageV2.Assistant;
84
95
  const small = await Provider.getSmallModel(assistantMsg.providerID);