@link-assistant/agent 0.5.2 → 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.
- package/package.json +5 -3
- package/src/auth/claude-oauth.ts +50 -24
- package/src/auth/plugins.ts +28 -16
- package/src/bun/index.ts +33 -27
- package/src/bus/index.ts +3 -5
- package/src/config/config.ts +39 -22
- package/src/file/ripgrep.ts +1 -1
- package/src/file/time.ts +1 -1
- package/src/file/watcher.ts +10 -5
- package/src/format/index.ts +12 -10
- package/src/index.js +30 -35
- package/src/mcp/index.ts +32 -15
- package/src/patch/index.ts +8 -4
- package/src/project/project.ts +1 -1
- package/src/project/state.ts +15 -7
- package/src/provider/cache.ts +259 -0
- package/src/provider/echo.ts +174 -0
- package/src/provider/models.ts +4 -5
- package/src/provider/provider.ts +164 -29
- package/src/server/server.ts +4 -5
- package/src/session/agent.js +16 -2
- package/src/session/compaction.ts +4 -6
- package/src/session/index.ts +2 -2
- package/src/session/processor.ts +3 -7
- package/src/session/prompt.ts +95 -60
- package/src/session/revert.ts +1 -1
- package/src/session/summary.ts +2 -2
- package/src/snapshot/index.ts +27 -12
- package/src/storage/storage.ts +18 -18
- package/src/util/log-lazy.ts +291 -0
- package/src/util/log.ts +205 -28
package/src/file/watcher.ts
CHANGED
|
@@ -36,7 +36,7 @@ export namespace FileWatcher {
|
|
|
36
36
|
const state = Instance.state(
|
|
37
37
|
async () => {
|
|
38
38
|
if (Instance.project.vcs !== 'git') return {};
|
|
39
|
-
log.info('init');
|
|
39
|
+
log.info(() => ({ message: 'init' }));
|
|
40
40
|
const cfg = await Config.get();
|
|
41
41
|
const backend = (() => {
|
|
42
42
|
if (process.platform === 'win32') return 'windows';
|
|
@@ -44,18 +44,23 @@ export namespace FileWatcher {
|
|
|
44
44
|
if (process.platform === 'linux') return 'inotify';
|
|
45
45
|
})();
|
|
46
46
|
if (!backend) {
|
|
47
|
-
log.error(
|
|
47
|
+
log.error(() => ({
|
|
48
|
+
message: 'watcher backend not supported',
|
|
48
49
|
platform: process.platform,
|
|
49
|
-
});
|
|
50
|
+
}));
|
|
50
51
|
return {};
|
|
51
52
|
}
|
|
52
|
-
log.info(
|
|
53
|
+
log.info(() => ({
|
|
54
|
+
message: 'watcher backend',
|
|
55
|
+
platform: process.platform,
|
|
56
|
+
backend,
|
|
57
|
+
}));
|
|
53
58
|
const sub = await watcher().subscribe(
|
|
54
59
|
Instance.directory,
|
|
55
60
|
(err, evts) => {
|
|
56
61
|
if (err) return;
|
|
57
62
|
for (const evt of evts) {
|
|
58
|
-
log.info('event', evt);
|
|
63
|
+
log.info(() => ({ message: 'event', ...evt }));
|
|
59
64
|
if (evt.type === 'create')
|
|
60
65
|
Bus.publish(Event.Updated, { file: evt.path, event: 'add' });
|
|
61
66
|
if (evt.type === 'update')
|
package/src/format/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ export namespace Format {
|
|
|
29
29
|
|
|
30
30
|
const formatters: Record<string, Formatter.Info> = {};
|
|
31
31
|
if (cfg.formatter === false) {
|
|
32
|
-
log.info('all formatters are disabled');
|
|
32
|
+
log.info(() => ({ message: 'all formatters are disabled' }));
|
|
33
33
|
return {
|
|
34
34
|
enabled,
|
|
35
35
|
formatters,
|
|
@@ -77,10 +77,10 @@ export namespace Format {
|
|
|
77
77
|
const formatters = await state().then((x) => x.formatters);
|
|
78
78
|
const result = [];
|
|
79
79
|
for (const item of Object.values(formatters)) {
|
|
80
|
-
log.info('checking',
|
|
80
|
+
log.info(() => ({ message: 'checking', name: item.name, ext }));
|
|
81
81
|
if (!item.extensions.includes(ext)) continue;
|
|
82
82
|
if (!(await isEnabled(item))) continue;
|
|
83
|
-
log.info('enabled',
|
|
83
|
+
log.info(() => ({ message: 'enabled', name: item.name, ext }));
|
|
84
84
|
result.push(item);
|
|
85
85
|
}
|
|
86
86
|
return result;
|
|
@@ -101,14 +101,14 @@ export namespace Format {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export function init() {
|
|
104
|
-
log.info('init');
|
|
104
|
+
log.info(() => ({ message: 'init' }));
|
|
105
105
|
Bus.subscribe(File.Event.Edited, async (payload) => {
|
|
106
106
|
const file = payload.properties.file;
|
|
107
|
-
log.info('formatting',
|
|
107
|
+
log.info(() => ({ message: 'formatting', file }));
|
|
108
108
|
const ext = path.extname(file);
|
|
109
109
|
|
|
110
110
|
for (const item of await getFormatter(ext)) {
|
|
111
|
-
log.info('running',
|
|
111
|
+
log.info(() => ({ message: 'running', command: item.command }));
|
|
112
112
|
try {
|
|
113
113
|
const proc = Bun.spawn({
|
|
114
114
|
cmd: item.command.map((x) => x.replace('$FILE', file)),
|
|
@@ -119,17 +119,19 @@ export namespace Format {
|
|
|
119
119
|
});
|
|
120
120
|
const exit = await proc.exited;
|
|
121
121
|
if (exit !== 0)
|
|
122
|
-
log.error(
|
|
122
|
+
log.error(() => ({
|
|
123
|
+
message: 'failed',
|
|
123
124
|
command: item.command,
|
|
124
125
|
...item.environment,
|
|
125
|
-
});
|
|
126
|
+
}));
|
|
126
127
|
} catch (error) {
|
|
127
|
-
log.error(
|
|
128
|
+
log.error(() => ({
|
|
129
|
+
message: 'failed to format file',
|
|
128
130
|
error,
|
|
129
131
|
command: item.command,
|
|
130
132
|
...item.environment,
|
|
131
133
|
file,
|
|
132
|
-
});
|
|
134
|
+
}));
|
|
133
135
|
}
|
|
134
136
|
}
|
|
135
137
|
});
|
package/src/index.js
CHANGED
|
@@ -246,22 +246,19 @@ async function readSystemMessages(argv) {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
async function runAgentMode(argv, request) {
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
console.error(`Script path: ${import.meta.path}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Log dry-run mode if enabled
|
|
249
|
+
// Log version and command info in verbose mode using lazy logging
|
|
250
|
+
Log.Default.lazy.info(() => ({
|
|
251
|
+
message: 'Agent started',
|
|
252
|
+
version: pkg.version,
|
|
253
|
+
command: process.argv.join(' '),
|
|
254
|
+
workingDirectory: process.cwd(),
|
|
255
|
+
scriptPath: import.meta.path,
|
|
256
|
+
}));
|
|
261
257
|
if (Flag.OPENCODE_DRY_RUN) {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
258
|
+
Log.Default.lazy.info(() => ({
|
|
259
|
+
message: 'Dry run mode enabled',
|
|
260
|
+
mode: 'dry-run',
|
|
261
|
+
}));
|
|
265
262
|
}
|
|
266
263
|
|
|
267
264
|
const { providerID, modelID } = await parseModelConfig(argv);
|
|
@@ -269,9 +266,11 @@ async function runAgentMode(argv, request) {
|
|
|
269
266
|
// Validate and get JSON standard
|
|
270
267
|
const jsonStandard = argv['json-standard'];
|
|
271
268
|
if (!isValidJsonStandard(jsonStandard)) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
269
|
+
outputStatus({
|
|
270
|
+
type: 'error',
|
|
271
|
+
errorType: 'ValidationError',
|
|
272
|
+
message: `Invalid JSON standard: ${jsonStandard}. Use "opencode" or "claude".`,
|
|
273
|
+
});
|
|
275
274
|
process.exit(1);
|
|
276
275
|
}
|
|
277
276
|
|
|
@@ -317,24 +316,20 @@ async function runAgentMode(argv, request) {
|
|
|
317
316
|
* @param {object} argv - Command line arguments
|
|
318
317
|
*/
|
|
319
318
|
async function runContinuousAgentMode(argv) {
|
|
320
|
-
// Note: verbose flag and logging are now initialized in middleware
|
|
321
|
-
// See main() function for the middleware that sets up Flag and Log.init()
|
|
322
|
-
|
|
323
319
|
const compactJson = argv['compact-json'] === true;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Log dry-run mode if enabled
|
|
320
|
+
// Log version and command info in verbose mode using lazy logging
|
|
321
|
+
Log.Default.lazy.info(() => ({
|
|
322
|
+
message: 'Agent started (continuous mode)',
|
|
323
|
+
version: pkg.version,
|
|
324
|
+
command: process.argv.join(' '),
|
|
325
|
+
workingDirectory: process.cwd(),
|
|
326
|
+
scriptPath: import.meta.path,
|
|
327
|
+
}));
|
|
334
328
|
if (Flag.OPENCODE_DRY_RUN) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
329
|
+
Log.Default.lazy.info(() => ({
|
|
330
|
+
message: 'Dry run mode enabled',
|
|
331
|
+
mode: 'dry-run',
|
|
332
|
+
}));
|
|
338
333
|
}
|
|
339
334
|
|
|
340
335
|
const { providerID, modelID } = await parseModelConfig(argv);
|
|
@@ -935,7 +930,7 @@ async function main() {
|
|
|
935
930
|
}
|
|
936
931
|
|
|
937
932
|
// Initialize logging system
|
|
938
|
-
// - If verbose: print logs to stderr for debugging
|
|
933
|
+
// - If verbose: print logs to stderr for debugging in JSON format
|
|
939
934
|
// - Otherwise: write logs to file to keep CLI output clean
|
|
940
935
|
await Log.init({
|
|
941
936
|
print: Flag.OPENCODE_VERBOSE,
|
package/src/mcp/index.ts
CHANGED
|
@@ -81,9 +81,10 @@ export namespace MCP {
|
|
|
81
81
|
await Promise.all(
|
|
82
82
|
Object.values(state.clients).map((client) =>
|
|
83
83
|
client.close().catch((error) => {
|
|
84
|
-
log.error(
|
|
84
|
+
log.error(() => ({
|
|
85
|
+
message: 'Failed to close MCP client',
|
|
85
86
|
error,
|
|
86
|
-
});
|
|
87
|
+
}));
|
|
87
88
|
})
|
|
88
89
|
)
|
|
89
90
|
);
|
|
@@ -119,10 +120,10 @@ export namespace MCP {
|
|
|
119
120
|
|
|
120
121
|
async function create(key: string, mcp: Config.Mcp) {
|
|
121
122
|
if (mcp.enabled === false) {
|
|
122
|
-
log.info('mcp server disabled',
|
|
123
|
+
log.info(() => ({ message: 'mcp server disabled', key }));
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
|
-
log.info('found',
|
|
126
|
+
log.info(() => ({ message: 'found', key, type: mcp.type }));
|
|
126
127
|
let mcpClient: MCPClient | undefined;
|
|
127
128
|
let status: Status | undefined = undefined;
|
|
128
129
|
|
|
@@ -152,7 +153,11 @@ export namespace MCP {
|
|
|
152
153
|
transport,
|
|
153
154
|
})
|
|
154
155
|
.then((client) => {
|
|
155
|
-
log.info(
|
|
156
|
+
log.info(() => ({
|
|
157
|
+
message: 'connected',
|
|
158
|
+
key,
|
|
159
|
+
transport: name,
|
|
160
|
+
}));
|
|
156
161
|
mcpClient = client;
|
|
157
162
|
status = { status: 'connected' };
|
|
158
163
|
return true;
|
|
@@ -160,12 +165,13 @@ export namespace MCP {
|
|
|
160
165
|
.catch((error) => {
|
|
161
166
|
lastError =
|
|
162
167
|
error instanceof Error ? error : new Error(String(error));
|
|
163
|
-
log.debug(
|
|
168
|
+
log.debug(() => ({
|
|
169
|
+
message: 'transport connection failed',
|
|
164
170
|
key,
|
|
165
171
|
transport: name,
|
|
166
172
|
url: mcp.url,
|
|
167
173
|
error: lastError.message,
|
|
168
|
-
});
|
|
174
|
+
}));
|
|
169
175
|
status = {
|
|
170
176
|
status: 'failed' as const,
|
|
171
177
|
error: lastError.message,
|
|
@@ -198,11 +204,12 @@ export namespace MCP {
|
|
|
198
204
|
};
|
|
199
205
|
})
|
|
200
206
|
.catch((error) => {
|
|
201
|
-
log.error(
|
|
207
|
+
log.error(() => ({
|
|
208
|
+
message: 'local mcp startup failed',
|
|
202
209
|
key,
|
|
203
210
|
command: mcp.command,
|
|
204
211
|
error: error instanceof Error ? error.message : String(error),
|
|
205
|
-
});
|
|
212
|
+
}));
|
|
206
213
|
status = {
|
|
207
214
|
status: 'failed' as const,
|
|
208
215
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -228,14 +235,19 @@ export namespace MCP {
|
|
|
228
235
|
mcpClient.tools(),
|
|
229
236
|
mcp.timeout ?? 5000
|
|
230
237
|
).catch((err) => {
|
|
231
|
-
log.error(
|
|
238
|
+
log.error(() => ({
|
|
239
|
+
message: 'failed to get tools from client',
|
|
240
|
+
key,
|
|
241
|
+
error: err,
|
|
242
|
+
}));
|
|
232
243
|
return undefined;
|
|
233
244
|
});
|
|
234
245
|
if (!result) {
|
|
235
246
|
await mcpClient.close().catch((error) => {
|
|
236
|
-
log.error(
|
|
247
|
+
log.error(() => ({
|
|
248
|
+
message: 'Failed to close MCP client',
|
|
237
249
|
error,
|
|
238
|
-
});
|
|
250
|
+
}));
|
|
239
251
|
});
|
|
240
252
|
status = {
|
|
241
253
|
status: 'failed',
|
|
@@ -250,10 +262,11 @@ export namespace MCP {
|
|
|
250
262
|
};
|
|
251
263
|
}
|
|
252
264
|
|
|
253
|
-
log.info(
|
|
265
|
+
log.info(() => ({
|
|
266
|
+
message: 'create() successfully created client',
|
|
254
267
|
key,
|
|
255
268
|
toolCount: Object.keys(result).length,
|
|
256
|
-
});
|
|
269
|
+
}));
|
|
257
270
|
return {
|
|
258
271
|
mcpClient,
|
|
259
272
|
status,
|
|
@@ -274,7 +287,11 @@ export namespace MCP {
|
|
|
274
287
|
const clientsSnapshot = await clients();
|
|
275
288
|
for (const [clientName, client] of Object.entries(clientsSnapshot)) {
|
|
276
289
|
const tools = await client.tools().catch((e) => {
|
|
277
|
-
log.error(
|
|
290
|
+
log.error(() => ({
|
|
291
|
+
message: 'failed to get tools',
|
|
292
|
+
clientName,
|
|
293
|
+
error: e.message,
|
|
294
|
+
}));
|
|
278
295
|
const failedStatus = {
|
|
279
296
|
status: 'failed' as const,
|
|
280
297
|
error: e instanceof Error ? e.message : String(e),
|
package/src/patch/index.ts
CHANGED
|
@@ -532,13 +532,13 @@ export namespace Patch {
|
|
|
532
532
|
|
|
533
533
|
await fs.writeFile(hunk.path, hunk.contents, 'utf-8');
|
|
534
534
|
added.push(hunk.path);
|
|
535
|
-
log.info(
|
|
535
|
+
log.info(() => ({ message: 'Added file', path: hunk.path }));
|
|
536
536
|
break;
|
|
537
537
|
|
|
538
538
|
case 'delete':
|
|
539
539
|
await fs.unlink(hunk.path);
|
|
540
540
|
deleted.push(hunk.path);
|
|
541
|
-
log.info(
|
|
541
|
+
log.info(() => ({ message: 'Deleted file', path: hunk.path }));
|
|
542
542
|
break;
|
|
543
543
|
|
|
544
544
|
case 'update':
|
|
@@ -557,12 +557,16 @@ export namespace Patch {
|
|
|
557
557
|
await fs.writeFile(hunk.move_path, fileUpdate.content, 'utf-8');
|
|
558
558
|
await fs.unlink(hunk.path);
|
|
559
559
|
modified.push(hunk.move_path);
|
|
560
|
-
log.info(
|
|
560
|
+
log.info(() => ({
|
|
561
|
+
message: 'Moved file',
|
|
562
|
+
from: hunk.path,
|
|
563
|
+
to: hunk.move_path,
|
|
564
|
+
}));
|
|
561
565
|
} else {
|
|
562
566
|
// Regular update
|
|
563
567
|
await fs.writeFile(hunk.path, fileUpdate.content, 'utf-8');
|
|
564
568
|
modified.push(hunk.path);
|
|
565
|
-
log.info(
|
|
569
|
+
log.info(() => ({ message: 'Updated file', path: hunk.path }));
|
|
566
570
|
}
|
|
567
571
|
break;
|
|
568
572
|
}
|
package/src/project/project.ts
CHANGED
|
@@ -24,7 +24,7 @@ export namespace Project {
|
|
|
24
24
|
export type Info = z.infer<typeof Info>;
|
|
25
25
|
|
|
26
26
|
export async function fromDirectory(directory: string) {
|
|
27
|
-
log.info('fromDirectory',
|
|
27
|
+
log.info(() => ({ message: 'fromDirectory', directory }));
|
|
28
28
|
const matches = Filesystem.up({ targets: ['.git'], start: directory });
|
|
29
29
|
const git = await matches.next().then((x) => x.value);
|
|
30
30
|
await matches.return();
|
package/src/project/state.ts
CHANGED
|
@@ -36,16 +36,20 @@ export namespace State {
|
|
|
36
36
|
const entries = recordsByKey.get(key);
|
|
37
37
|
if (!entries) return;
|
|
38
38
|
|
|
39
|
-
log.info(
|
|
39
|
+
log.info(() => ({
|
|
40
|
+
message: 'waiting for state disposal to complete',
|
|
41
|
+
key,
|
|
42
|
+
}));
|
|
40
43
|
|
|
41
44
|
let disposalFinished = false;
|
|
42
45
|
|
|
43
46
|
setTimeout(() => {
|
|
44
47
|
if (!disposalFinished) {
|
|
45
|
-
log.warn(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
log.warn(() => ({
|
|
49
|
+
message:
|
|
50
|
+
'state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug',
|
|
51
|
+
key,
|
|
52
|
+
}));
|
|
49
53
|
}
|
|
50
54
|
}, 10000).unref();
|
|
51
55
|
|
|
@@ -56,7 +60,11 @@ export namespace State {
|
|
|
56
60
|
const task = Promise.resolve(entry.state)
|
|
57
61
|
.then((state) => entry.dispose!(state))
|
|
58
62
|
.catch((error) => {
|
|
59
|
-
log.error(
|
|
63
|
+
log.error(() => ({
|
|
64
|
+
message: 'Error while disposing state',
|
|
65
|
+
error,
|
|
66
|
+
key,
|
|
67
|
+
}));
|
|
60
68
|
});
|
|
61
69
|
|
|
62
70
|
tasks.push(task);
|
|
@@ -64,6 +72,6 @@ export namespace State {
|
|
|
64
72
|
await Promise.all(tasks);
|
|
65
73
|
recordsByKey.delete(key);
|
|
66
74
|
disposalFinished = true;
|
|
67
|
-
log.info('state disposal completed',
|
|
75
|
+
log.info(() => ({ message: 'state disposal completed', key }));
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache Provider - A synthetic provider for caching API responses
|
|
3
|
+
*
|
|
4
|
+
* This provider caches API responses to enable deterministic testing.
|
|
5
|
+
* When a response is not cached, it falls back to the echo provider behavior.
|
|
6
|
+
* Cached responses are stored using Links Notation format (.lino files).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* agent --model link-assistant/cache/opencode -p "hello" # Uses cached responses
|
|
10
|
+
*
|
|
11
|
+
* Cache location: ./data/api-cache/{provider}/{model}/
|
|
12
|
+
* Format: Links Notation files with .lino extension
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/agent/issues/89
|
|
15
|
+
* @see https://github.com/link-foundation/lino-objects-codec
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { LanguageModelV2, LanguageModelV2CallOptions } from 'ai';
|
|
19
|
+
import { Log } from '../util/log';
|
|
20
|
+
import { createEchoModel } from './echo';
|
|
21
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
22
|
+
import { dirname, join } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
// @ts-ignore - lino-objects-codec is a JavaScript library
|
|
25
|
+
import { encode, decode } from 'lino-objects-codec';
|
|
26
|
+
|
|
27
|
+
const log = Log.create({ service: 'provider.cache' });
|
|
28
|
+
|
|
29
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const CACHE_ROOT = join(__dirname, '../../data/api-cache');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a cache key from the prompt
|
|
34
|
+
*/
|
|
35
|
+
function generateCacheKey(
|
|
36
|
+
prompt: LanguageModelV2CallOptions['prompt']
|
|
37
|
+
): string {
|
|
38
|
+
// Simple hash of the prompt content
|
|
39
|
+
const content = JSON.stringify(prompt);
|
|
40
|
+
let hash = 0;
|
|
41
|
+
for (let i = 0; i < content.length; i++) {
|
|
42
|
+
const char = content.charCodeAt(i);
|
|
43
|
+
hash = (hash << 5) - hash + char;
|
|
44
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
45
|
+
}
|
|
46
|
+
return Math.abs(hash).toString(36);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get cache file path for a provider/model combination
|
|
51
|
+
* Uses .lino extension for Links Notation format
|
|
52
|
+
*/
|
|
53
|
+
function getCachePath(provider: string, model: string, key: string): string {
|
|
54
|
+
return join(CACHE_ROOT, provider, model, `${key}.lino`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generate a unique ID for streaming parts
|
|
59
|
+
*/
|
|
60
|
+
function generatePartId(): string {
|
|
61
|
+
return `cache_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load cached response from file using Links Notation format
|
|
66
|
+
*/
|
|
67
|
+
function loadCachedResponse(filePath: string): any | null {
|
|
68
|
+
try {
|
|
69
|
+
if (!existsSync(filePath)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const content = readFileSync(filePath, 'utf8');
|
|
73
|
+
// Decode from Links Notation format
|
|
74
|
+
return decode({ notation: content });
|
|
75
|
+
} catch (error: any) {
|
|
76
|
+
log.warn('Failed to load cached response', {
|
|
77
|
+
filePath,
|
|
78
|
+
error: error.message,
|
|
79
|
+
});
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Save response to cache file using Links Notation format
|
|
86
|
+
*/
|
|
87
|
+
function saveCachedResponse(filePath: string, response: any): void {
|
|
88
|
+
try {
|
|
89
|
+
const dir = dirname(filePath);
|
|
90
|
+
if (!existsSync(dir)) {
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
// Encode to Links Notation format
|
|
94
|
+
const encoded = encode({ obj: response });
|
|
95
|
+
writeFileSync(filePath, encoded, 'utf8');
|
|
96
|
+
log.info('Saved cached response', { filePath });
|
|
97
|
+
} catch (error: any) {
|
|
98
|
+
log.warn('Failed to save cached response', {
|
|
99
|
+
filePath,
|
|
100
|
+
error: error.message,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Creates a cache language model that stores/retrieves responses
|
|
107
|
+
* Implements LanguageModelV2 interface for AI SDK 6.x compatibility
|
|
108
|
+
*/
|
|
109
|
+
export function createCacheModel(
|
|
110
|
+
providerId: string,
|
|
111
|
+
modelId: string
|
|
112
|
+
): LanguageModelV2 {
|
|
113
|
+
const model: LanguageModelV2 = {
|
|
114
|
+
specificationVersion: 'v2',
|
|
115
|
+
provider: 'link-assistant',
|
|
116
|
+
modelId: `${providerId}/${modelId}`,
|
|
117
|
+
|
|
118
|
+
// No external URLs are supported by this synthetic provider
|
|
119
|
+
supportedUrls: {},
|
|
120
|
+
|
|
121
|
+
async doGenerate(options: LanguageModelV2CallOptions) {
|
|
122
|
+
const cacheKey = generateCacheKey(options.prompt);
|
|
123
|
+
const cachePath = getCachePath(providerId, modelId, cacheKey);
|
|
124
|
+
|
|
125
|
+
// Try to load from cache first
|
|
126
|
+
const cached = loadCachedResponse(cachePath);
|
|
127
|
+
if (cached) {
|
|
128
|
+
log.info('Using cached response', { providerId, modelId, cacheKey });
|
|
129
|
+
return cached;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fall back to echo behavior
|
|
133
|
+
log.info('No cached response, using echo fallback', {
|
|
134
|
+
providerId,
|
|
135
|
+
modelId,
|
|
136
|
+
cacheKey,
|
|
137
|
+
});
|
|
138
|
+
const echoModel = createEchoModel(`${providerId}/${modelId}`);
|
|
139
|
+
const response = await echoModel.doGenerate(options);
|
|
140
|
+
|
|
141
|
+
// Save to cache for future use
|
|
142
|
+
saveCachedResponse(cachePath, response);
|
|
143
|
+
|
|
144
|
+
return response;
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async doStream(options: LanguageModelV2CallOptions) {
|
|
148
|
+
const cacheKey = generateCacheKey(options.prompt);
|
|
149
|
+
const cachePath = getCachePath(providerId, modelId, cacheKey);
|
|
150
|
+
|
|
151
|
+
// Try to load from cache first
|
|
152
|
+
const cached = loadCachedResponse(cachePath);
|
|
153
|
+
if (cached) {
|
|
154
|
+
log.info('Using cached streaming response', {
|
|
155
|
+
providerId,
|
|
156
|
+
modelId,
|
|
157
|
+
cacheKey,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// For cached responses, we need to simulate streaming
|
|
161
|
+
// Extract the text from the cached response
|
|
162
|
+
const echoText =
|
|
163
|
+
cached.content?.[0]?.text || cached.text || 'Cached response';
|
|
164
|
+
const textPartId = generatePartId();
|
|
165
|
+
|
|
166
|
+
// Create a ReadableStream with LanguageModelV2StreamPart format
|
|
167
|
+
const stream = new ReadableStream({
|
|
168
|
+
async start(controller) {
|
|
169
|
+
// Emit text-start
|
|
170
|
+
controller.enqueue({
|
|
171
|
+
type: 'text-start',
|
|
172
|
+
id: textPartId,
|
|
173
|
+
providerMetadata: undefined,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Emit the text in chunks for realistic streaming behavior
|
|
177
|
+
const chunkSize = 10;
|
|
178
|
+
for (let i = 0; i < echoText.length; i += chunkSize) {
|
|
179
|
+
const chunk = echoText.slice(i, i + chunkSize);
|
|
180
|
+
controller.enqueue({
|
|
181
|
+
type: 'text-delta',
|
|
182
|
+
id: textPartId,
|
|
183
|
+
delta: chunk,
|
|
184
|
+
providerMetadata: undefined,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Emit text-end
|
|
189
|
+
controller.enqueue({
|
|
190
|
+
type: 'text-end',
|
|
191
|
+
id: textPartId,
|
|
192
|
+
providerMetadata: undefined,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Emit finish event
|
|
196
|
+
controller.enqueue({
|
|
197
|
+
type: 'finish',
|
|
198
|
+
finishReason: 'stop',
|
|
199
|
+
usage: cached.usage || {
|
|
200
|
+
promptTokens: Math.ceil(echoText.length / 4),
|
|
201
|
+
completionTokens: Math.ceil(echoText.length / 4),
|
|
202
|
+
},
|
|
203
|
+
providerMetadata: undefined,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
controller.close();
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
stream,
|
|
212
|
+
request: undefined,
|
|
213
|
+
response: undefined,
|
|
214
|
+
warnings: [],
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Fall back to echo streaming behavior
|
|
219
|
+
log.info('No cached streaming response, using echo fallback', {
|
|
220
|
+
providerId,
|
|
221
|
+
modelId,
|
|
222
|
+
cacheKey,
|
|
223
|
+
});
|
|
224
|
+
const echoModel = createEchoModel(`${providerId}/${modelId}`);
|
|
225
|
+
const response = await echoModel.doStream(options);
|
|
226
|
+
|
|
227
|
+
// Note: We don't cache streaming responses as they're consumed immediately
|
|
228
|
+
return response;
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return model;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Cache provider factory function
|
|
237
|
+
*/
|
|
238
|
+
export function createCacheProvider(options?: { name?: string }) {
|
|
239
|
+
return {
|
|
240
|
+
languageModel(modelId: string): LanguageModelV2 {
|
|
241
|
+
// Parse provider/model from modelId like "opencode/grok-code"
|
|
242
|
+
const parts = modelId.split('/');
|
|
243
|
+
if (parts.length < 2) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Invalid cache model ID: ${modelId}. Expected format: provider/model`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
const [providerId, ...modelParts] = parts;
|
|
249
|
+
const actualModelId = modelParts.join('/');
|
|
250
|
+
|
|
251
|
+
return createCacheModel(providerId, actualModelId);
|
|
252
|
+
},
|
|
253
|
+
textEmbeddingModel() {
|
|
254
|
+
throw new Error('Cache provider does not support text embeddings');
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export const cacheProvider = createCacheProvider();
|