@link-assistant/agent 0.5.3 → 0.6.1

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.
@@ -28,7 +28,7 @@ export namespace Snapshot {
28
28
  await $`git --git-dir ${git} config core.autocrlf false`
29
29
  .quiet()
30
30
  .nothrow();
31
- log.info('initialized');
31
+ log.info(() => ({ message: 'initialized' }));
32
32
  }
33
33
  await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
34
34
  .quiet()
@@ -40,7 +40,12 @@ export namespace Snapshot {
40
40
  .cwd(Instance.directory)
41
41
  .nothrow()
42
42
  .text();
43
- log.info('tracking', { hash, cwd: Instance.directory, git });
43
+ log.info(() => ({
44
+ message: 'tracking',
45
+ hash,
46
+ cwd: Instance.directory,
47
+ git,
48
+ }));
44
49
  return hash.trim();
45
50
  }
46
51
 
@@ -64,7 +69,11 @@ export namespace Snapshot {
64
69
 
65
70
  // If git diff fails, return empty patch
66
71
  if (result.exitCode !== 0) {
67
- log.warn('failed to get diff', { hash, exitCode: result.exitCode });
72
+ log.warn(() => ({
73
+ message: 'failed to get diff',
74
+ hash,
75
+ exitCode: result.exitCode,
76
+ }));
68
77
  return { hash, files: [] };
69
78
  }
70
79
 
@@ -81,7 +90,7 @@ export namespace Snapshot {
81
90
  }
82
91
 
83
92
  export async function restore(snapshot: string) {
84
- log.info('restore', { commit: snapshot });
93
+ log.info(() => ({ message: 'restore', commit: snapshot }));
85
94
  const git = gitdir();
86
95
  const result =
87
96
  await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
@@ -90,12 +99,13 @@ export namespace Snapshot {
90
99
  .nothrow();
91
100
 
92
101
  if (result.exitCode !== 0) {
93
- log.error('failed to restore snapshot', {
102
+ log.error(() => ({
103
+ message: 'failed to restore snapshot',
94
104
  snapshot,
95
105
  exitCode: result.exitCode,
96
106
  stderr: result.stderr.toString(),
97
107
  stdout: result.stdout.toString(),
98
- });
108
+ }));
99
109
  }
100
110
  }
101
111
 
@@ -105,7 +115,7 @@ export namespace Snapshot {
105
115
  for (const item of patches) {
106
116
  for (const file of item.files) {
107
117
  if (files.has(file)) continue;
108
- log.info('reverting', { file, hash: item.hash });
118
+ log.info(() => ({ message: 'reverting', file, hash: item.hash }));
109
119
  const result =
110
120
  await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
111
121
  .quiet()
@@ -119,11 +129,15 @@ export namespace Snapshot {
119
129
  .cwd(Instance.worktree)
120
130
  .nothrow();
121
131
  if (checkTree.exitCode === 0 && checkTree.text().trim()) {
122
- log.info('file existed in snapshot but checkout failed, keeping', {
132
+ log.info(() => ({
133
+ message: 'file existed in snapshot but checkout failed, keeping',
123
134
  file,
124
- });
135
+ }));
125
136
  } else {
126
- log.info('file did not exist in snapshot, deleting', { file });
137
+ log.info(() => ({
138
+ message: 'file did not exist in snapshot, deleting',
139
+ file,
140
+ }));
127
141
  await fs.unlink(file).catch(() => {});
128
142
  }
129
143
  }
@@ -145,12 +159,13 @@ export namespace Snapshot {
145
159
  .nothrow();
146
160
 
147
161
  if (result.exitCode !== 0) {
148
- log.warn('failed to get diff', {
162
+ log.warn(() => ({
163
+ message: 'failed to get diff',
149
164
  hash,
150
165
  exitCode: result.exitCode,
151
166
  stderr: result.stderr.toString(),
152
167
  stdout: result.stdout.toString(),
153
- });
168
+ }));
154
169
  return '';
155
170
  }
156
171
 
@@ -28,7 +28,7 @@ export namespace Storage {
28
28
  cwd: project,
29
29
  onlyFiles: false,
30
30
  })) {
31
- log.info(`migrating project ${projectDir}`);
31
+ log.info(() => ({ message: 'migrating project', projectDir }));
32
32
  let projectID = projectDir;
33
33
  const fullProjectDir = path.join(project, projectDir);
34
34
  let worktree = '/';
@@ -74,7 +74,10 @@ export namespace Storage {
74
74
  })
75
75
  );
76
76
 
77
- log.info(`migrating sessions for project ${projectID}`);
77
+ log.info(() => ({
78
+ message: 'migrating sessions for project',
79
+ projectID,
80
+ }));
78
81
  for await (const sessionFile of new Bun.Glob(
79
82
  'storage/session/info/*.json'
80
83
  ).scan({
@@ -87,13 +90,13 @@ export namespace Storage {
87
90
  projectID,
88
91
  path.basename(sessionFile)
89
92
  );
90
- log.info('copying', {
91
- sessionFile,
92
- dest,
93
- });
93
+ log.info(() => ({ message: 'copying', sessionFile, dest }));
94
94
  const session = await Bun.file(sessionFile).json();
95
95
  await Bun.write(dest, JSON.stringify(session));
96
- log.info(`migrating messages for session ${session.id}`);
96
+ log.info(() => ({
97
+ message: 'migrating messages for session',
98
+ sessionID: session.id,
99
+ }));
97
100
  for await (const msgFile of new Bun.Glob(
98
101
  `storage/session/message/${session.id}/*.json`
99
102
  ).scan({
@@ -106,14 +109,14 @@ export namespace Storage {
106
109
  session.id,
107
110
  path.basename(msgFile)
108
111
  );
109
- log.info('copying', {
110
- msgFile,
111
- dest,
112
- });
112
+ log.info(() => ({ message: 'copying', msgFile, dest }));
113
113
  const message = await Bun.file(msgFile).json();
114
114
  await Bun.write(dest, JSON.stringify(message));
115
115
 
116
- log.info(`migrating parts for message ${message.id}`);
116
+ log.info(() => ({
117
+ message: 'migrating parts for message',
118
+ messageID: message.id,
119
+ }));
117
120
  for await (const partFile of new Bun.Glob(
118
121
  `storage/session/part/${session.id}/${message.id}/*.json`
119
122
  ).scan({
@@ -127,10 +130,7 @@ export namespace Storage {
127
130
  path.basename(partFile)
128
131
  );
129
132
  const part = await Bun.file(partFile).json();
130
- log.info('copying', {
131
- partFile,
132
- dest,
133
- });
133
+ log.info(() => ({ message: 'copying', partFile, dest }));
134
134
  await Bun.write(dest, JSON.stringify(part));
135
135
  }
136
136
  }
@@ -178,10 +178,10 @@ export namespace Storage {
178
178
  .then((x) => parseInt(x))
179
179
  .catch(() => 0);
180
180
  for (let index = migration; index < MIGRATIONS.length; index++) {
181
- log.info('running migration', { index });
181
+ log.info(() => ({ message: 'running migration', index }));
182
182
  const migration = MIGRATIONS[index];
183
183
  await migration(dir).catch(() =>
184
- log.error('failed to run migration', { index })
184
+ log.error(() => ({ message: 'failed to run migration', index }))
185
185
  );
186
186
  await Bun.write(path.join(dir, 'migration'), (index + 1).toString());
187
187
  }
@@ -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 };