@link-assistant/agent 0.5.3 → 0.6.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.
@@ -0,0 +1,291 @@
1
+ import makeLog, { levels, LogLevel } from 'log-lazy';
2
+ import { Flag } from '../flag/flag.ts';
3
+
4
+ /**
5
+ * JSON Lazy Logger
6
+ *
7
+ * Implements lazy logging pattern using log-lazy library.
8
+ * All log output is JSON formatted and wrapped in { log: { ... } } structure
9
+ * for easy parsing alongside regular JSON output.
10
+ *
11
+ * Key features:
12
+ * - Lazy evaluation: log arguments are only computed if logging is enabled
13
+ * - JSON output: all logs are parsable JSON in { log: { ... } } format
14
+ * - Level control: logs respect --verbose flag and LINK_ASSISTANT_AGENT_VERBOSE env
15
+ * - Type-safe: full TypeScript support
16
+ *
17
+ * Usage:
18
+ * import { lazyLog } from './util/log-lazy.ts';
19
+ *
20
+ * // Simple string message
21
+ * lazyLog.info(() => 'Starting process');
22
+ *
23
+ * // Object data (preferred - avoids expensive JSON.stringify when disabled)
24
+ * lazyLog.debug(() => ({ action: 'fetch', url: someUrl }));
25
+ *
26
+ * // Complex computed message
27
+ * lazyLog.verbose(() => `Processed ${items.length} items: ${JSON.stringify(items)}`);
28
+ */
29
+
30
+ // Custom log levels using bit flags for fine-grained control
31
+ const LEVEL_DISABLED = 0;
32
+ const LEVEL_ERROR = levels.error;
33
+ const LEVEL_WARN = levels.warn | LEVEL_ERROR;
34
+ const LEVEL_INFO = levels.info | LEVEL_WARN;
35
+ const LEVEL_DEBUG = levels.debug | LEVEL_INFO;
36
+ const LEVEL_VERBOSE = levels.verbose | LEVEL_DEBUG;
37
+ const LEVEL_TRACE = levels.trace | LEVEL_VERBOSE;
38
+
39
+ // Map of preset level configurations
40
+ const LEVEL_PRESETS = {
41
+ disabled: LEVEL_DISABLED,
42
+ error: LEVEL_ERROR,
43
+ warn: LEVEL_WARN,
44
+ info: LEVEL_INFO,
45
+ debug: LEVEL_DEBUG,
46
+ verbose: LEVEL_VERBOSE,
47
+ trace: LEVEL_TRACE,
48
+ // Convenience aliases
49
+ production: LEVEL_WARN,
50
+ development: LEVEL_DEBUG,
51
+ } as const;
52
+
53
+ type LevelPreset = keyof typeof LEVEL_PRESETS;
54
+
55
+ /**
56
+ * Format a log entry as JSON object wrapped in { log: { ... } }
57
+ */
58
+ function formatLogEntry(
59
+ level: string,
60
+ data: unknown,
61
+ tags?: Record<string, unknown>
62
+ ): string {
63
+ const timestamp = new Date().toISOString();
64
+ const logEntry: Record<string, unknown> = {
65
+ level,
66
+ timestamp,
67
+ ...tags,
68
+ };
69
+
70
+ // Handle different data types
71
+ if (typeof data === 'string') {
72
+ logEntry.message = data;
73
+ } else if (data instanceof Error) {
74
+ logEntry.message = data.message;
75
+ logEntry.error = {
76
+ name: data.name,
77
+ message: data.message,
78
+ stack: data.stack,
79
+ };
80
+ } else if (typeof data === 'object' && data !== null) {
81
+ // Spread object properties into the log entry
82
+ Object.assign(logEntry, data);
83
+ } else {
84
+ logEntry.message = String(data);
85
+ }
86
+
87
+ return JSON.stringify({ log: logEntry });
88
+ }
89
+
90
+ /**
91
+ * Create the output function that writes to stderr
92
+ */
93
+ function createOutput(
94
+ level: string,
95
+ tags?: Record<string, unknown>
96
+ ): (data: unknown) => void {
97
+ return (data: unknown) => {
98
+ const json = formatLogEntry(level, data, tags);
99
+ // Use stderr to avoid interfering with stdout JSON output
100
+ process.stderr.write(json + '\n');
101
+ };
102
+ }
103
+
104
+ /**
105
+ * LazyLogger interface extending log-lazy with JSON output
106
+ */
107
+ export interface LazyLogger {
108
+ // Log at info level (default)
109
+ (fn: () => unknown): void;
110
+
111
+ // Log levels
112
+ error(fn: () => unknown): void;
113
+ warn(fn: () => unknown): void;
114
+ info(fn: () => unknown): void;
115
+ debug(fn: () => unknown): void;
116
+ verbose(fn: () => unknown): void;
117
+ trace(fn: () => unknown): void;
118
+
119
+ // Level management
120
+ enableLevel(level: LogLevel): void;
121
+ disableLevel(level: LogLevel): void;
122
+ setLevel(level: LevelPreset | number): void;
123
+ getEnabledLevels(): LogLevel[];
124
+ shouldLog(level: LogLevel): boolean;
125
+
126
+ // Tag support
127
+ tag(key: string, value: unknown): LazyLogger;
128
+ clone(): LazyLogger;
129
+
130
+ // Configuration
131
+ readonly enabled: boolean;
132
+ }
133
+
134
+ /**
135
+ * Create a lazy logger with JSON output format
136
+ */
137
+ export function createLazyLogger(
138
+ initialTags?: Record<string, unknown>
139
+ ): LazyLogger {
140
+ // Determine initial log level based on verbose flag
141
+ const initialLevel = Flag.OPENCODE_VERBOSE ? LEVEL_VERBOSE : LEVEL_DISABLED;
142
+
143
+ // Create base log-lazy instance
144
+ const baseLog = makeLog({ level: initialLevel });
145
+ const tags = { ...initialTags };
146
+
147
+ // Custom output functions that format as JSON
148
+ const outputError = createOutput('error', tags);
149
+ const outputWarn = createOutput('warn', tags);
150
+ const outputInfo = createOutput('info', tags);
151
+ const outputDebug = createOutput('debug', tags);
152
+ const outputVerbose = createOutput('verbose', tags);
153
+ const outputTrace = createOutput('trace', tags);
154
+
155
+ // Create wrapper that uses JSON output
156
+ const wrappedLog = function (fn: () => unknown): void {
157
+ baseLog.info(() => {
158
+ const result = fn();
159
+ outputInfo(result);
160
+ return ''; // Return empty string as the base logger just needs something
161
+ });
162
+ } as LazyLogger;
163
+
164
+ wrappedLog.error = (fn: () => unknown): void => {
165
+ baseLog.error(() => {
166
+ const result = fn();
167
+ outputError(result);
168
+ return '';
169
+ });
170
+ };
171
+
172
+ wrappedLog.warn = (fn: () => unknown): void => {
173
+ baseLog.warn(() => {
174
+ const result = fn();
175
+ outputWarn(result);
176
+ return '';
177
+ });
178
+ };
179
+
180
+ wrappedLog.info = (fn: () => unknown): void => {
181
+ baseLog.info(() => {
182
+ const result = fn();
183
+ outputInfo(result);
184
+ return '';
185
+ });
186
+ };
187
+
188
+ wrappedLog.debug = (fn: () => unknown): void => {
189
+ baseLog.debug(() => {
190
+ const result = fn();
191
+ outputDebug(result);
192
+ return '';
193
+ });
194
+ };
195
+
196
+ wrappedLog.verbose = (fn: () => unknown): void => {
197
+ baseLog.verbose(() => {
198
+ const result = fn();
199
+ outputVerbose(result);
200
+ return '';
201
+ });
202
+ };
203
+
204
+ wrappedLog.trace = (fn: () => unknown): void => {
205
+ baseLog.trace(() => {
206
+ const result = fn();
207
+ outputTrace(result);
208
+ return '';
209
+ });
210
+ };
211
+
212
+ // Level management
213
+ wrappedLog.enableLevel = (level: LogLevel): void => {
214
+ baseLog.enableLevel(level);
215
+ };
216
+
217
+ wrappedLog.disableLevel = (level: LogLevel): void => {
218
+ baseLog.disableLevel(level);
219
+ };
220
+
221
+ wrappedLog.setLevel = (level: LevelPreset | number): void => {
222
+ const numericLevel =
223
+ typeof level === 'string' ? LEVEL_PRESETS[level] : level;
224
+
225
+ // Reset all levels and enable the new one
226
+ Object.values([
227
+ 'error',
228
+ 'warn',
229
+ 'info',
230
+ 'debug',
231
+ 'verbose',
232
+ 'trace',
233
+ ]).forEach((l) => baseLog.disableLevel(l as LogLevel));
234
+
235
+ // Enable appropriate levels based on the numeric level
236
+ if (numericLevel & levels.error) baseLog.enableLevel('error');
237
+ if (numericLevel & levels.warn) baseLog.enableLevel('warn');
238
+ if (numericLevel & levels.info) baseLog.enableLevel('info');
239
+ if (numericLevel & levels.debug) baseLog.enableLevel('debug');
240
+ if (numericLevel & levels.verbose) baseLog.enableLevel('verbose');
241
+ if (numericLevel & levels.trace) baseLog.enableLevel('trace');
242
+ };
243
+
244
+ wrappedLog.getEnabledLevels = (): LogLevel[] => {
245
+ return baseLog.getEnabledLevels();
246
+ };
247
+
248
+ wrappedLog.shouldLog = (level: LogLevel): boolean => {
249
+ return baseLog.shouldLog(level);
250
+ };
251
+
252
+ // Tag support
253
+ wrappedLog.tag = (key: string, value: unknown): LazyLogger => {
254
+ tags[key] = value;
255
+ return wrappedLog;
256
+ };
257
+
258
+ wrappedLog.clone = (): LazyLogger => {
259
+ return createLazyLogger({ ...tags });
260
+ };
261
+
262
+ // Configuration
263
+ Object.defineProperty(wrappedLog, 'enabled', {
264
+ get: () => Flag.OPENCODE_VERBOSE,
265
+ enumerable: true,
266
+ });
267
+
268
+ return wrappedLog;
269
+ }
270
+
271
+ /**
272
+ * Default lazy logger instance
273
+ * Enabled only when --verbose flag or LINK_ASSISTANT_AGENT_VERBOSE env is set
274
+ */
275
+ export const lazyLog = createLazyLogger({ service: 'agent' });
276
+
277
+ /**
278
+ * Utility to update the global logger level at runtime
279
+ * Call this after Flag.setVerbose() to sync the logger state
280
+ */
281
+ export function syncLoggerWithVerboseFlag(): void {
282
+ if (Flag.OPENCODE_VERBOSE) {
283
+ lazyLog.setLevel('verbose');
284
+ } else {
285
+ lazyLog.setLevel('disabled');
286
+ }
287
+ }
288
+
289
+ // Export level constants for external use
290
+ export { levels, LEVEL_PRESETS };
291
+ export type { LevelPreset };
package/src/util/log.ts CHANGED
@@ -2,7 +2,20 @@ import path from 'path';
2
2
  import fs from 'fs/promises';
3
3
  import { Global } from '../global';
4
4
  import z from 'zod';
5
+ import makeLog, { levels } from 'log-lazy';
6
+ import { Flag } from '../flag/flag.ts';
5
7
 
8
+ /**
9
+ * Logging module with JSON output and lazy evaluation support.
10
+ *
11
+ * Features:
12
+ * - JSON formatted output: All logs are wrapped in { log: { ... } } structure
13
+ * - Lazy evaluation: Use lazy() methods to defer expensive computations
14
+ * - Level control: Respects --verbose flag and log level settings
15
+ * - File logging: Writes to file when not in verbose/print mode
16
+ *
17
+ * The JSON format ensures all output is parsable, separating logs from regular output.
18
+ */
6
19
  export namespace Log {
7
20
  export const Level = z
8
21
  .enum(['DEBUG', 'INFO', 'WARN', 'ERROR'])
@@ -17,16 +30,41 @@ export namespace Log {
17
30
  };
18
31
 
19
32
  let level: Level = 'INFO';
33
+ let jsonOutput = false; // Whether to output JSON format (enabled in verbose mode)
20
34
 
21
35
  function shouldLog(input: Level): boolean {
22
36
  return levelPriority[input] >= levelPriority[level];
23
37
  }
24
38
 
39
+ /**
40
+ * Logger interface with support for both immediate and lazy logging.
41
+ *
42
+ * All logging methods accept either:
43
+ * - A message string/object/Error with optional extra data
44
+ * - A function that returns the data to log (lazy evaluation)
45
+ *
46
+ * Lazy logging: The function is only called if logging is enabled for that level,
47
+ * avoiding expensive computations when logs are disabled.
48
+ */
25
49
  export type Logger = {
26
- debug(message?: any, extra?: Record<string, any>): void;
27
- info(message?: any, extra?: Record<string, any>): void;
28
- error(message?: any, extra?: Record<string, any>): void;
29
- warn(message?: any, extra?: Record<string, any>): void;
50
+ // Unified logging - supports both immediate and lazy (callback) styles
51
+ debug(
52
+ message?: any | (() => { message?: string; [key: string]: any }),
53
+ extra?: Record<string, any>
54
+ ): void;
55
+ info(
56
+ message?: any | (() => { message?: string; [key: string]: any }),
57
+ extra?: Record<string, any>
58
+ ): void;
59
+ error(
60
+ message?: any | (() => { message?: string; [key: string]: any }),
61
+ extra?: Record<string, any>
62
+ ): void;
63
+ warn(
64
+ message?: any | (() => { message?: string; [key: string]: any }),
65
+ extra?: Record<string, any>
66
+ ): void;
67
+
30
68
  tag(key: string, value: string): Logger;
31
69
  clone(): Logger;
32
70
  time(
@@ -54,24 +92,47 @@ export namespace Log {
54
92
  }
55
93
  let write = (msg: any) => Bun.stderr.write(msg);
56
94
 
95
+ // Initialize log-lazy for controlling lazy log execution
96
+ let lazyLogInstance = makeLog({ level: 0 }); // Start disabled
97
+
57
98
  export async function init(options: Options) {
58
99
  if (options.level) level = options.level;
59
100
  cleanup(Global.Path.log);
60
- if (options.print) return;
61
- logpath = path.join(
62
- Global.Path.log,
63
- options.dev
64
- ? 'dev.log'
65
- : new Date().toISOString().split('.')[0].replace(/:/g, '') + '.log'
66
- );
67
- const logfile = Bun.file(logpath);
68
- await fs.truncate(logpath).catch(() => {});
69
- const writer = logfile.writer();
70
- write = async (msg: any) => {
71
- const num = writer.write(msg);
72
- writer.flush();
73
- return num;
74
- };
101
+
102
+ // Always use JSON output format for logs
103
+ jsonOutput = true;
104
+
105
+ // Configure lazy logging level based on verbose flag
106
+ if (Flag.OPENCODE_VERBOSE || options.print) {
107
+ // Enable all levels for lazy logging when verbose
108
+ lazyLogInstance = makeLog({
109
+ level: levels.debug | levels.info | levels.warn | levels.error,
110
+ });
111
+ } else {
112
+ // Disable lazy logging when not verbose
113
+ lazyLogInstance = makeLog({ level: 0 });
114
+ }
115
+
116
+ if (options.print) {
117
+ // In print mode, output to stderr
118
+ // No file logging needed
119
+ } else {
120
+ // In normal mode, write to file
121
+ logpath = path.join(
122
+ Global.Path.log,
123
+ options.dev
124
+ ? 'dev.log'
125
+ : new Date().toISOString().split('.')[0].replace(/:/g, '') + '.log'
126
+ );
127
+ const logfile = Bun.file(logpath);
128
+ await fs.truncate(logpath).catch(() => {});
129
+ const writer = logfile.writer();
130
+ write = async (msg: any) => {
131
+ const num = writer.write(msg);
132
+ writer.flush();
133
+ return num;
134
+ };
135
+ }
75
136
  }
76
137
 
77
138
  async function cleanup(dir: string) {
@@ -97,6 +158,43 @@ export namespace Log {
97
158
  : result;
98
159
  }
99
160
 
161
+ /**
162
+ * Format log entry as JSON object wrapped in { log: { ... } }
163
+ */
164
+ function formatJson(
165
+ logLevel: Level,
166
+ message: any,
167
+ tags: Record<string, any>,
168
+ extra?: Record<string, any>
169
+ ): string {
170
+ const timestamp = new Date().toISOString();
171
+ const logEntry: Record<string, any> = {
172
+ level: logLevel.toLowerCase(),
173
+ timestamp,
174
+ ...tags,
175
+ ...extra,
176
+ };
177
+
178
+ if (message !== undefined && message !== null) {
179
+ if (typeof message === 'string') {
180
+ logEntry.message = message;
181
+ } else if (message instanceof Error) {
182
+ logEntry.message = message.message;
183
+ logEntry.error = {
184
+ name: message.name,
185
+ message: message.message,
186
+ stack: message.stack,
187
+ };
188
+ } else if (typeof message === 'object') {
189
+ Object.assign(logEntry, message);
190
+ } else {
191
+ logEntry.message = String(message);
192
+ }
193
+ }
194
+
195
+ return JSON.stringify({ log: logEntry });
196
+ }
197
+
100
198
  let last = Date.now();
101
199
  export function create(tags?: Record<string, any>) {
102
200
  tags = tags || {};
@@ -109,7 +207,8 @@ export namespace Log {
109
207
  }
110
208
  }
111
209
 
112
- function build(message: any, extra?: Record<string, any>) {
210
+ // Legacy format for file logging (backward compatibility)
211
+ function buildLegacy(message: any, extra?: Record<string, any>) {
113
212
  const prefix = Object.entries({
114
213
  ...tags,
115
214
  ...extra,
@@ -131,27 +230,83 @@ export namespace Log {
131
230
  .join(' ') + '\n'
132
231
  );
133
232
  }
233
+
234
+ // Choose output format based on jsonOutput flag
235
+ function output(
236
+ logLevel: Level,
237
+ message: any,
238
+ extra?: Record<string, any>
239
+ ) {
240
+ if (jsonOutput) {
241
+ // Use our custom JSON formatting for { log: { ... } } format
242
+ write(formatJson(logLevel, message, tags || {}, extra) + '\n');
243
+ } else {
244
+ write(logLevel.padEnd(5) + ' ' + buildLegacy(message, extra));
245
+ }
246
+ }
247
+
134
248
  const result: Logger = {
135
249
  debug(message?: any, extra?: Record<string, any>) {
136
- if (shouldLog('DEBUG')) {
137
- write('DEBUG ' + build(message, extra));
250
+ if (!shouldLog('DEBUG')) return;
251
+
252
+ // Check if message is a function (lazy logging)
253
+ if (typeof message === 'function') {
254
+ lazyLogInstance.debug(() => {
255
+ const data = message();
256
+ const { message: msg, ...extraData } = data;
257
+ output('DEBUG', msg, extraData);
258
+ return '';
259
+ });
260
+ } else {
261
+ output('DEBUG', message, extra);
138
262
  }
139
263
  },
140
264
  info(message?: any, extra?: Record<string, any>) {
141
- if (shouldLog('INFO')) {
142
- write('INFO ' + build(message, extra));
265
+ if (!shouldLog('INFO')) return;
266
+
267
+ // Check if message is a function (lazy logging)
268
+ if (typeof message === 'function') {
269
+ lazyLogInstance.info(() => {
270
+ const data = message();
271
+ const { message: msg, ...extraData } = data;
272
+ output('INFO', msg, extraData);
273
+ return '';
274
+ });
275
+ } else {
276
+ output('INFO', message, extra);
143
277
  }
144
278
  },
145
279
  error(message?: any, extra?: Record<string, any>) {
146
- if (shouldLog('ERROR')) {
147
- write('ERROR ' + build(message, extra));
280
+ if (!shouldLog('ERROR')) return;
281
+
282
+ // Check if message is a function (lazy logging)
283
+ if (typeof message === 'function') {
284
+ lazyLogInstance.error(() => {
285
+ const data = message();
286
+ const { message: msg, ...extraData } = data;
287
+ output('ERROR', msg, extraData);
288
+ return '';
289
+ });
290
+ } else {
291
+ output('ERROR', message, extra);
148
292
  }
149
293
  },
150
294
  warn(message?: any, extra?: Record<string, any>) {
151
- if (shouldLog('WARN')) {
152
- write('WARN ' + build(message, extra));
295
+ if (!shouldLog('WARN')) return;
296
+
297
+ // Check if message is a function (lazy logging)
298
+ if (typeof message === 'function') {
299
+ lazyLogInstance.warn(() => {
300
+ const data = message();
301
+ const { message: msg, ...extraData } = data;
302
+ output('WARN', msg, extraData);
303
+ return '';
304
+ });
305
+ } else {
306
+ output('WARN', message, extra);
153
307
  }
154
308
  },
309
+
155
310
  tag(key: string, value: string) {
156
311
  if (tags) tags[key] = value;
157
312
  return result;
@@ -184,4 +339,26 @@ export namespace Log {
184
339
 
185
340
  return result;
186
341
  }
342
+
343
+ /**
344
+ * Check if JSON output mode is enabled
345
+ */
346
+ export function isJsonOutput(): boolean {
347
+ return jsonOutput;
348
+ }
349
+
350
+ /**
351
+ * Sync lazy logging with verbose flag at runtime
352
+ * Call after Flag.setVerbose() to update lazy logging state
353
+ */
354
+ export function syncWithVerboseFlag(): void {
355
+ if (Flag.OPENCODE_VERBOSE) {
356
+ jsonOutput = true;
357
+ lazyLogInstance = makeLog({
358
+ level: levels.debug | levels.info | levels.warn | levels.error,
359
+ });
360
+ } else {
361
+ lazyLogInstance = makeLog({ level: 0 });
362
+ }
363
+ }
187
364
  }