@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.26.0",
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
  }
@@ -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, type ModelMessage } from 'ai';
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
- main();
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();
@@ -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');
@@ -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
  }
@@ -508,9 +508,20 @@ export namespace SessionProcessor {
508
508
  }
509
509
  snapshot = undefined;
510
510
  }
511
- SessionSummary.summarize({
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
 
@@ -833,9 +833,19 @@ export namespace SessionPrompt {
833
833
  };
834
834
 
835
835
  if (step === 1) {
836
- SessionSummary.summarize({
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
 
@@ -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, type ModelMessage } from 'ai';
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
- const all = await Session.messages({ sessionID: input.sessionID });
29
- await Promise.all([
30
- summarizeSession({ sessionID: input.sessionID, messages: all }),
31
- summarizeMessage({ messageID: input.messageID, messages: all }),
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
- if (isVerbose()) {
207
- log.info(() => ({
208
- message: 'title API response received',
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
- titleLength: result.text.length,
212
- usage: result.usage,
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 (
@@ -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)