@link-assistant/agent 0.26.0 → 0.26.2
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 +9 -4
- package/src/agent/agent.ts +8 -7
- package/src/index.js +6 -1
- package/src/project/instance.ts +1 -0
- package/src/project/project.ts +66 -0
- package/src/session/processor.ts +12 -1
- package/src/session/prompt.ts +11 -1
- package/src/session/summary.ts +55 -22
- package/src/snapshot/index.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/agent",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
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",
|
|
@@ -50,8 +50,6 @@
|
|
|
50
50
|
"LICENSE"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@actions/core": "^1.11.1",
|
|
54
|
-
"@actions/github": "^6.0.1",
|
|
55
53
|
"@agentclientprotocol/sdk": "^0.5.1",
|
|
56
54
|
"@ai-sdk/amazon-bedrock": "^3.0.74",
|
|
57
55
|
"@ai-sdk/anthropic": "^2.0.58",
|
|
@@ -134,5 +132,12 @@
|
|
|
134
132
|
"prettier --check"
|
|
135
133
|
]
|
|
136
134
|
},
|
|
137
|
-
"license": "Unlicense"
|
|
135
|
+
"license": "Unlicense",
|
|
136
|
+
"allowScripts": {
|
|
137
|
+
"@parcel/watcher@2.5.1": true,
|
|
138
|
+
"tree-sitter-bash@0.25.1": true
|
|
139
|
+
},
|
|
140
|
+
"overrides": {
|
|
141
|
+
"undici": "^7.16.0"
|
|
142
|
+
}
|
|
138
143
|
}
|
package/src/agent/agent.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Config } from '../config/file-config';
|
|
2
2
|
import z from 'zod';
|
|
3
3
|
import { Provider } from '../provider/provider';
|
|
4
|
-
import { generateObject
|
|
4
|
+
import { generateObject } from 'ai';
|
|
5
5
|
import PROMPT_GENERATE from './generate.txt';
|
|
6
6
|
import { SystemPrompt } from '../session/system';
|
|
7
7
|
import { Instance } from '../project/instance';
|
|
@@ -138,13 +138,14 @@ export namespace Agent {
|
|
|
138
138
|
const existing = await list();
|
|
139
139
|
const result = await generateObject({
|
|
140
140
|
temperature: 0.3,
|
|
141
|
+
// The system prompt belongs in `system`, not in `prompt`: the AI SDK
|
|
142
|
+
// warns that a system message inside the prompt enables prompt
|
|
143
|
+
// injection, and providers are free to treat it as ordinary content.
|
|
144
|
+
system: system.map((content) => ({
|
|
145
|
+
role: 'system' as const,
|
|
146
|
+
content,
|
|
147
|
+
})),
|
|
141
148
|
prompt: [
|
|
142
|
-
...system.map(
|
|
143
|
-
(item): ModelMessage => ({
|
|
144
|
-
role: 'system',
|
|
145
|
-
content: item,
|
|
146
|
-
})
|
|
147
|
-
),
|
|
148
149
|
{
|
|
149
150
|
role: 'user',
|
|
150
151
|
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(', ')}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
package/src/index.js
CHANGED
|
@@ -971,4 +971,9 @@ async function main() {
|
|
|
971
971
|
}
|
|
972
972
|
}
|
|
973
973
|
|
|
974
|
-
|
|
974
|
+
// Awaited on purpose: everything the CLI does happens inside this promise, and
|
|
975
|
+
// on Windows nothing else holds the process open while startup waits on async
|
|
976
|
+
// filesystem I/O — the event loop drains and Bun exits 0 mid-run, so the CLI
|
|
977
|
+
// prints its startup logs, never contacts the provider and reports success.
|
|
978
|
+
// See: https://github.com/link-assistant/agent/issues/306
|
|
979
|
+
await main();
|
package/src/project/instance.ts
CHANGED
|
@@ -58,6 +58,7 @@ export const Instance = {
|
|
|
58
58
|
async dispose() {
|
|
59
59
|
Log.Default.info('disposing instance', { directory: Instance.directory });
|
|
60
60
|
await State.dispose(Instance.directory);
|
|
61
|
+
await Project.prune();
|
|
61
62
|
},
|
|
62
63
|
async disposeAll() {
|
|
63
64
|
Log.Default.info('disposing all instances');
|
package/src/project/project.ts
CHANGED
|
@@ -4,9 +4,12 @@ import path from 'path';
|
|
|
4
4
|
import { $ } from 'bun';
|
|
5
5
|
import { Storage } from '../storage/storage';
|
|
6
6
|
import { Log } from '../util/log';
|
|
7
|
+
import fs from 'node:fs/promises';
|
|
8
|
+
import { Global } from '../global';
|
|
7
9
|
|
|
8
10
|
export namespace Project {
|
|
9
11
|
const log = Log.create({ service: 'project' });
|
|
12
|
+
const recentSnapshotAge = 15 * 60_000;
|
|
10
13
|
export const Info = z
|
|
11
14
|
.object({
|
|
12
15
|
id: z.string(),
|
|
@@ -24,6 +27,7 @@ export namespace Project {
|
|
|
24
27
|
|
|
25
28
|
export async function fromDirectory(directory: string) {
|
|
26
29
|
log.info(() => ({ message: 'fromDirectory', directory }));
|
|
30
|
+
await prune();
|
|
27
31
|
const matches = Filesystem.up({ targets: ['.git'], start: directory });
|
|
28
32
|
const git = await matches.next().then((x) => x.value);
|
|
29
33
|
await matches.return();
|
|
@@ -101,4 +105,66 @@ export namespace Project {
|
|
|
101
105
|
const keys = await Storage.list(['project']);
|
|
102
106
|
return await Promise.all(keys.map((x) => Storage.read<Info>(x)));
|
|
103
107
|
}
|
|
108
|
+
|
|
109
|
+
export async function prune() {
|
|
110
|
+
const root = path.join(Global.Path.data, 'snapshot');
|
|
111
|
+
const entries = await fs
|
|
112
|
+
.readdir(root, { withFileTypes: true })
|
|
113
|
+
.catch(() => []);
|
|
114
|
+
const snapshots: { id: string; path: string; modified: number }[] = [];
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
if (!entry.isDirectory()) continue;
|
|
117
|
+
const snapshot = path.join(root, entry.name);
|
|
118
|
+
const modified = await fs
|
|
119
|
+
.stat(snapshot)
|
|
120
|
+
.then((stat) => stat.mtimeMs)
|
|
121
|
+
.catch(() => undefined);
|
|
122
|
+
if (modified === undefined) continue;
|
|
123
|
+
snapshots.push({ id: entry.name, path: snapshot, modified });
|
|
124
|
+
}
|
|
125
|
+
snapshots.sort((a, b) => b.modified - a.modified);
|
|
126
|
+
|
|
127
|
+
const newest = snapshots[0]?.id;
|
|
128
|
+
const recent = Date.now() - recentSnapshotAge;
|
|
129
|
+
for (const snapshot of snapshots) {
|
|
130
|
+
if (snapshot.id === newest || snapshot.modified >= recent) continue;
|
|
131
|
+
const project = await Storage.read<Info>(['project', snapshot.id]).catch(
|
|
132
|
+
() => undefined
|
|
133
|
+
);
|
|
134
|
+
const live = project
|
|
135
|
+
? await fs
|
|
136
|
+
.stat(project.worktree)
|
|
137
|
+
.then((stat) => stat.isDirectory())
|
|
138
|
+
.catch(() => false)
|
|
139
|
+
: false;
|
|
140
|
+
if (live) continue;
|
|
141
|
+
|
|
142
|
+
await remove(snapshot).catch((error) =>
|
|
143
|
+
log.warn(() => ({
|
|
144
|
+
message: 'failed to prune snapshot',
|
|
145
|
+
projectID: snapshot.id,
|
|
146
|
+
error: error instanceof Error ? error.message : String(error),
|
|
147
|
+
}))
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function remove(snapshot: { id: string; path: string }) {
|
|
153
|
+
await fs.rm(snapshot.path, { recursive: true, force: true });
|
|
154
|
+
for (const session of await Storage.list(['session', snapshot.id])) {
|
|
155
|
+
const sessionID = session.at(-1)!;
|
|
156
|
+
for (const message of await Storage.list(['message', sessionID])) {
|
|
157
|
+
const messageID = message.at(-1)!;
|
|
158
|
+
for (const part of await Storage.list(['part', messageID])) {
|
|
159
|
+
await Storage.remove(part);
|
|
160
|
+
}
|
|
161
|
+
await Storage.remove(message);
|
|
162
|
+
}
|
|
163
|
+
await Storage.remove(['session_diff', sessionID]);
|
|
164
|
+
await Storage.remove(['todo', sessionID]);
|
|
165
|
+
await Storage.remove(session);
|
|
166
|
+
}
|
|
167
|
+
await Storage.remove(['project', snapshot.id]);
|
|
168
|
+
log.info(() => ({ message: 'pruned snapshot', projectID: snapshot.id }));
|
|
169
|
+
}
|
|
104
170
|
}
|
package/src/session/processor.ts
CHANGED
|
@@ -508,9 +508,20 @@ export namespace SessionProcessor {
|
|
|
508
508
|
}
|
|
509
509
|
snapshot = undefined;
|
|
510
510
|
}
|
|
511
|
-
|
|
511
|
+
// Fire-and-forget on purpose, and guarded: a summarization
|
|
512
|
+
// failure must not become an unhandled rejection that exits
|
|
513
|
+
// the process while this stream is still being processed.
|
|
514
|
+
// See: https://github.com/link-assistant/agent/issues/304
|
|
515
|
+
void SessionSummary.summarize({
|
|
512
516
|
sessionID: input.sessionID,
|
|
513
517
|
messageID: input.assistantMessage.parentID,
|
|
518
|
+
}).catch((error) => {
|
|
519
|
+
log.warn(() => ({
|
|
520
|
+
message: 'session summarization failed',
|
|
521
|
+
sessionID: input.sessionID,
|
|
522
|
+
error:
|
|
523
|
+
error instanceof Error ? error.message : String(error),
|
|
524
|
+
}));
|
|
514
525
|
});
|
|
515
526
|
break;
|
|
516
527
|
|
package/src/session/prompt.ts
CHANGED
|
@@ -833,9 +833,19 @@ export namespace SessionPrompt {
|
|
|
833
833
|
};
|
|
834
834
|
|
|
835
835
|
if (step === 1) {
|
|
836
|
-
|
|
836
|
+
// Fire-and-forget on purpose: the summary runs alongside the turn. The
|
|
837
|
+
// `void` marks that, and the `.catch` keeps a summarization failure
|
|
838
|
+
// from becoming an unhandled rejection that would exit the process and
|
|
839
|
+
// abort this turn. See: https://github.com/link-assistant/agent/issues/304
|
|
840
|
+
void SessionSummary.summarize({
|
|
837
841
|
sessionID: sessionID,
|
|
838
842
|
messageID: lastUser.id,
|
|
843
|
+
}).catch((error) => {
|
|
844
|
+
log.warn(() => ({
|
|
845
|
+
message: 'session summarization failed',
|
|
846
|
+
sessionID,
|
|
847
|
+
error: error instanceof Error ? error.message : String(error),
|
|
848
|
+
}));
|
|
839
849
|
});
|
|
840
850
|
}
|
|
841
851
|
|
package/src/session/summary.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Provider } from '../provider/provider';
|
|
|
2
2
|
import { fn } from '../util/fn';
|
|
3
3
|
import z from 'zod';
|
|
4
4
|
import { Session } from '.';
|
|
5
|
-
import { generateText
|
|
5
|
+
import { generateText } from 'ai';
|
|
6
6
|
import { MessageV2 } from './message-v2';
|
|
7
7
|
import { Identifier } from '../id/id';
|
|
8
8
|
import { Snapshot } from '../snapshot';
|
|
@@ -25,11 +25,29 @@ export namespace SessionSummary {
|
|
|
25
25
|
messageID: z.string(),
|
|
26
26
|
}),
|
|
27
27
|
async (input) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
// Summarization is a side quest: its whole product is a session title, a
|
|
29
|
+
// body and a diff stat, and it runs concurrently with the turn it
|
|
30
|
+
// describes. Neither caller awaits it, so a rejection escaping this
|
|
31
|
+
// function has no handler anywhere and becomes an `unhandledRejection`,
|
|
32
|
+
// which exits the process with status 1 and aborts the streaming turn.
|
|
33
|
+
// A failed title must never be a failed run.
|
|
34
|
+
// See: https://github.com/link-assistant/agent/issues/304
|
|
35
|
+
try {
|
|
36
|
+
const all = await Session.messages({ sessionID: input.sessionID });
|
|
37
|
+
await Promise.all([
|
|
38
|
+
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
|
39
|
+
summarizeMessage({ messageID: input.messageID, messages: all }),
|
|
40
|
+
]);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
log.warn(() => ({
|
|
43
|
+
message: 'session summarization failed',
|
|
44
|
+
hint: 'The turn is unaffected; only the session summary is missing',
|
|
45
|
+
sessionID: input.sessionID,
|
|
46
|
+
messageID: input.messageID,
|
|
47
|
+
error: error instanceof Error ? error.message : String(error),
|
|
48
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
33
51
|
}
|
|
34
52
|
);
|
|
35
53
|
|
|
@@ -187,13 +205,14 @@ export namespace SessionSummary {
|
|
|
187
205
|
model.providerID,
|
|
188
206
|
{}
|
|
189
207
|
),
|
|
208
|
+
// The system prompt belongs in `system`, not in `messages`: the AI SDK
|
|
209
|
+
// warns that a system message inside `messages` enables prompt
|
|
210
|
+
// injection, and providers are free to treat it as ordinary content.
|
|
211
|
+
system: systemPrompts.map((content) => ({
|
|
212
|
+
role: 'system' as const,
|
|
213
|
+
content,
|
|
214
|
+
})),
|
|
190
215
|
messages: [
|
|
191
|
-
...systemPrompts.map(
|
|
192
|
-
(x): ModelMessage => ({
|
|
193
|
-
role: 'system',
|
|
194
|
-
content: x,
|
|
195
|
-
})
|
|
196
|
-
),
|
|
197
216
|
{
|
|
198
217
|
role: 'user' as const,
|
|
199
218
|
content: userContent,
|
|
@@ -201,20 +220,34 @@ export namespace SessionSummary {
|
|
|
201
220
|
],
|
|
202
221
|
headers: model.info.headers,
|
|
203
222
|
model: model.language,
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
223
|
+
}).catch((err) => {
|
|
224
|
+
// A provider that resolves a model but errors on the request must cost
|
|
225
|
+
// the run its title, not its exit status.
|
|
226
|
+
// See: https://github.com/link-assistant/agent/issues/304
|
|
227
|
+
log.warn(() => ({
|
|
228
|
+
message: 'title API call failed',
|
|
209
229
|
providerID: model.providerID,
|
|
210
230
|
modelID: model.modelID,
|
|
211
|
-
|
|
212
|
-
|
|
231
|
+
error: err instanceof Error ? err.message : String(err),
|
|
232
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
213
233
|
}));
|
|
234
|
+
return undefined;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (result) {
|
|
238
|
+
if (isVerbose()) {
|
|
239
|
+
log.info(() => ({
|
|
240
|
+
message: 'title API response received',
|
|
241
|
+
providerID: model.providerID,
|
|
242
|
+
modelID: model.modelID,
|
|
243
|
+
titleLength: result.text.length,
|
|
244
|
+
usage: result.usage,
|
|
245
|
+
}));
|
|
246
|
+
}
|
|
247
|
+
log.info(() => ({ message: 'title', title: result.text }));
|
|
248
|
+
userMsg.summary.title = result.text;
|
|
249
|
+
await Session.updateMessage(userMsg);
|
|
214
250
|
}
|
|
215
|
-
log.info(() => ({ message: 'title', title: result.text }));
|
|
216
|
-
userMsg.summary.title = result.text;
|
|
217
|
-
await Session.updateMessage(userMsg);
|
|
218
251
|
}
|
|
219
252
|
|
|
220
253
|
if (
|
package/src/snapshot/index.ts
CHANGED
|
@@ -30,6 +30,25 @@ export namespace Snapshot {
|
|
|
30
30
|
.nothrow();
|
|
31
31
|
log.info(() => ({ message: 'initialized' }));
|
|
32
32
|
}
|
|
33
|
+
const objects =
|
|
34
|
+
await $`git rev-parse --path-format=absolute --git-path objects`
|
|
35
|
+
.quiet()
|
|
36
|
+
.cwd(Instance.worktree)
|
|
37
|
+
.nothrow();
|
|
38
|
+
if (objects.exitCode === 0) {
|
|
39
|
+
const info = path.join(git, 'objects', 'info');
|
|
40
|
+
await fs.mkdir(info, { recursive: true });
|
|
41
|
+
await fs.writeFile(
|
|
42
|
+
path.join(info, 'alternates'),
|
|
43
|
+
`${objects.text().trim()}\n`
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
log.warn(() => ({
|
|
47
|
+
message: 'failed to resolve repository objects',
|
|
48
|
+
exitCode: objects.exitCode,
|
|
49
|
+
stderr: objects.stderr.toString(),
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
33
52
|
await $`git --git-dir ${git} --work-tree ${Instance.worktree} add .`
|
|
34
53
|
.quiet()
|
|
35
54
|
.cwd(Instance.directory)
|