@link-assistant/agent 0.0.8
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/EXAMPLES.md +383 -0
- package/LICENSE +24 -0
- package/MODELS.md +95 -0
- package/README.md +388 -0
- package/TOOLS.md +134 -0
- package/package.json +89 -0
- package/src/agent/agent.ts +150 -0
- package/src/agent/generate.txt +75 -0
- package/src/auth/index.ts +64 -0
- package/src/bun/index.ts +96 -0
- package/src/bus/global.ts +10 -0
- package/src/bus/index.ts +119 -0
- package/src/cli/bootstrap.js +41 -0
- package/src/cli/bootstrap.ts +17 -0
- package/src/cli/cmd/agent.ts +165 -0
- package/src/cli/cmd/cmd.ts +5 -0
- package/src/cli/cmd/export.ts +88 -0
- package/src/cli/cmd/mcp.ts +80 -0
- package/src/cli/cmd/models.ts +58 -0
- package/src/cli/cmd/run.ts +359 -0
- package/src/cli/cmd/stats.ts +276 -0
- package/src/cli/error.ts +27 -0
- package/src/command/index.ts +73 -0
- package/src/command/template/initialize.txt +10 -0
- package/src/config/config.ts +705 -0
- package/src/config/markdown.ts +41 -0
- package/src/file/ripgrep.ts +391 -0
- package/src/file/time.ts +38 -0
- package/src/file/watcher.ts +75 -0
- package/src/file.ts +6 -0
- package/src/flag/flag.ts +19 -0
- package/src/format/formatter.ts +248 -0
- package/src/format/index.ts +137 -0
- package/src/global/index.ts +52 -0
- package/src/id/id.ts +72 -0
- package/src/index.js +371 -0
- package/src/mcp/index.ts +289 -0
- package/src/patch/index.ts +622 -0
- package/src/project/bootstrap.ts +22 -0
- package/src/project/instance.ts +67 -0
- package/src/project/project.ts +105 -0
- package/src/project/state.ts +65 -0
- package/src/provider/models-macro.ts +11 -0
- package/src/provider/models.ts +98 -0
- package/src/provider/opencode.js +47 -0
- package/src/provider/provider.ts +636 -0
- package/src/provider/transform.ts +241 -0
- package/src/server/project.ts +48 -0
- package/src/server/server.ts +249 -0
- package/src/session/agent.js +204 -0
- package/src/session/compaction.ts +249 -0
- package/src/session/index.ts +380 -0
- package/src/session/message-v2.ts +758 -0
- package/src/session/message.ts +189 -0
- package/src/session/processor.ts +356 -0
- package/src/session/prompt/anthropic-20250930.txt +166 -0
- package/src/session/prompt/anthropic.txt +105 -0
- package/src/session/prompt/anthropic_spoof.txt +1 -0
- package/src/session/prompt/beast.txt +147 -0
- package/src/session/prompt/build-switch.txt +5 -0
- package/src/session/prompt/codex.txt +318 -0
- package/src/session/prompt/copilot-gpt-5.txt +143 -0
- package/src/session/prompt/gemini.txt +155 -0
- package/src/session/prompt/grok-code.txt +1 -0
- package/src/session/prompt/plan.txt +8 -0
- package/src/session/prompt/polaris.txt +107 -0
- package/src/session/prompt/qwen.txt +109 -0
- package/src/session/prompt/summarize-turn.txt +5 -0
- package/src/session/prompt/summarize.txt +10 -0
- package/src/session/prompt/title.txt +25 -0
- package/src/session/prompt.ts +1390 -0
- package/src/session/retry.ts +53 -0
- package/src/session/revert.ts +108 -0
- package/src/session/status.ts +75 -0
- package/src/session/summary.ts +179 -0
- package/src/session/system.ts +138 -0
- package/src/session/todo.ts +36 -0
- package/src/snapshot/index.ts +197 -0
- package/src/storage/storage.ts +226 -0
- package/src/tool/bash.ts +193 -0
- package/src/tool/bash.txt +121 -0
- package/src/tool/batch.ts +173 -0
- package/src/tool/batch.txt +28 -0
- package/src/tool/codesearch.ts +123 -0
- package/src/tool/codesearch.txt +12 -0
- package/src/tool/edit.ts +604 -0
- package/src/tool/edit.txt +10 -0
- package/src/tool/glob.ts +65 -0
- package/src/tool/glob.txt +6 -0
- package/src/tool/grep.ts +116 -0
- package/src/tool/grep.txt +8 -0
- package/src/tool/invalid.ts +17 -0
- package/src/tool/ls.ts +110 -0
- package/src/tool/ls.txt +1 -0
- package/src/tool/multiedit.ts +46 -0
- package/src/tool/multiedit.txt +41 -0
- package/src/tool/patch.ts +188 -0
- package/src/tool/patch.txt +1 -0
- package/src/tool/read.ts +201 -0
- package/src/tool/read.txt +12 -0
- package/src/tool/registry.ts +87 -0
- package/src/tool/task.ts +126 -0
- package/src/tool/task.txt +60 -0
- package/src/tool/todo.ts +39 -0
- package/src/tool/todoread.txt +14 -0
- package/src/tool/todowrite.txt +167 -0
- package/src/tool/tool.ts +66 -0
- package/src/tool/webfetch.ts +171 -0
- package/src/tool/webfetch.txt +14 -0
- package/src/tool/websearch.ts +133 -0
- package/src/tool/websearch.txt +11 -0
- package/src/tool/write.ts +33 -0
- package/src/tool/write.txt +8 -0
- package/src/util/binary.ts +41 -0
- package/src/util/context.ts +25 -0
- package/src/util/defer.ts +12 -0
- package/src/util/error.ts +54 -0
- package/src/util/eventloop.ts +20 -0
- package/src/util/filesystem.ts +69 -0
- package/src/util/fn.ts +11 -0
- package/src/util/iife.ts +3 -0
- package/src/util/keybind.ts +79 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/locale.ts +39 -0
- package/src/util/lock.ts +98 -0
- package/src/util/log.ts +177 -0
- package/src/util/queue.ts +19 -0
- package/src/util/rpc.ts +42 -0
- package/src/util/scrap.ts +10 -0
- package/src/util/signal.ts +12 -0
- package/src/util/timeout.ts +14 -0
- package/src/util/token.ts +7 -0
- package/src/util/wildcard.ts +54 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { Argv } from "yargs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { UI } from "../ui"
|
|
4
|
+
import { cmd } from "./cmd"
|
|
5
|
+
import { Flag } from "../../flag/flag"
|
|
6
|
+
import { bootstrap } from "../bootstrap"
|
|
7
|
+
import { Command } from "../../command"
|
|
8
|
+
import { EOL } from "os"
|
|
9
|
+
import { select } from "@clack/prompts"
|
|
10
|
+
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"
|
|
11
|
+
import { Provider } from "../../provider/provider"
|
|
12
|
+
|
|
13
|
+
const TOOL: Record<string, [string, string]> = {
|
|
14
|
+
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
|
15
|
+
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
|
16
|
+
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
|
17
|
+
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
|
18
|
+
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
|
19
|
+
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
|
20
|
+
list: ["List", UI.Style.TEXT_INFO_BOLD],
|
|
21
|
+
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
|
22
|
+
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
|
23
|
+
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const RunCommand = cmd({
|
|
27
|
+
command: "run [message..]",
|
|
28
|
+
describe: "run opencode with a message",
|
|
29
|
+
builder: (yargs: Argv) => {
|
|
30
|
+
return yargs
|
|
31
|
+
.positional("message", {
|
|
32
|
+
describe: "message to send",
|
|
33
|
+
type: "string",
|
|
34
|
+
array: true,
|
|
35
|
+
default: [],
|
|
36
|
+
})
|
|
37
|
+
.option("command", {
|
|
38
|
+
describe: "the command to run, use message for args",
|
|
39
|
+
type: "string",
|
|
40
|
+
})
|
|
41
|
+
.option("continue", {
|
|
42
|
+
alias: ["c"],
|
|
43
|
+
describe: "continue the last session",
|
|
44
|
+
type: "boolean",
|
|
45
|
+
})
|
|
46
|
+
.option("session", {
|
|
47
|
+
alias: ["s"],
|
|
48
|
+
describe: "session id to continue",
|
|
49
|
+
type: "string",
|
|
50
|
+
})
|
|
51
|
+
.option("model", {
|
|
52
|
+
type: "string",
|
|
53
|
+
alias: ["m"],
|
|
54
|
+
describe: "model to use in the format of provider/model",
|
|
55
|
+
})
|
|
56
|
+
.option("agent", {
|
|
57
|
+
type: "string",
|
|
58
|
+
describe: "agent to use",
|
|
59
|
+
})
|
|
60
|
+
.option("format", {
|
|
61
|
+
type: "string",
|
|
62
|
+
choices: ["default", "json"],
|
|
63
|
+
default: "default",
|
|
64
|
+
describe: "format: default (formatted) or json (raw JSON events)",
|
|
65
|
+
})
|
|
66
|
+
.option("file", {
|
|
67
|
+
alias: ["f"],
|
|
68
|
+
type: "string",
|
|
69
|
+
array: true,
|
|
70
|
+
describe: "file(s) to attach to message",
|
|
71
|
+
})
|
|
72
|
+
.option("title", {
|
|
73
|
+
type: "string",
|
|
74
|
+
describe: "title for the session (uses truncated prompt if no value provided)",
|
|
75
|
+
})
|
|
76
|
+
.option("attach", {
|
|
77
|
+
type: "string",
|
|
78
|
+
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
|
|
79
|
+
})
|
|
80
|
+
.option("port", {
|
|
81
|
+
type: "number",
|
|
82
|
+
describe: "port for the local server (defaults to random port if no value provided)",
|
|
83
|
+
})
|
|
84
|
+
.option("system-message", {
|
|
85
|
+
type: "string",
|
|
86
|
+
describe: "full override of the system message",
|
|
87
|
+
})
|
|
88
|
+
.option("system-message-file", {
|
|
89
|
+
type: "string",
|
|
90
|
+
describe: "full override of the system message from file",
|
|
91
|
+
})
|
|
92
|
+
.option("append-system-message", {
|
|
93
|
+
type: "string",
|
|
94
|
+
describe: "append to the default system message",
|
|
95
|
+
})
|
|
96
|
+
.option("append-system-message-file", {
|
|
97
|
+
type: "string",
|
|
98
|
+
describe: "append to the default system message from file",
|
|
99
|
+
})
|
|
100
|
+
},
|
|
101
|
+
handler: async (args) => {
|
|
102
|
+
let message = args.message.join(" ")
|
|
103
|
+
|
|
104
|
+
const fileParts: any[] = []
|
|
105
|
+
if (args.file) {
|
|
106
|
+
const files = Array.isArray(args.file) ? args.file : [args.file]
|
|
107
|
+
|
|
108
|
+
for (const filePath of files) {
|
|
109
|
+
const resolvedPath = path.resolve(process.cwd(), filePath)
|
|
110
|
+
const file = Bun.file(resolvedPath)
|
|
111
|
+
const stats = await file.stat().catch(() => {})
|
|
112
|
+
if (!stats) {
|
|
113
|
+
UI.error(`File not found: ${filePath}`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
if (!(await file.exists())) {
|
|
117
|
+
UI.error(`File not found: ${filePath}`)
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const stat = await file.stat()
|
|
122
|
+
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
|
|
123
|
+
|
|
124
|
+
fileParts.push({
|
|
125
|
+
type: "file",
|
|
126
|
+
url: `file://${resolvedPath}`,
|
|
127
|
+
filename: path.basename(resolvedPath),
|
|
128
|
+
mime,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read system message files
|
|
134
|
+
if (args["system-message-file"]) {
|
|
135
|
+
const resolvedPath = path.resolve(process.cwd(), args["system-message-file"])
|
|
136
|
+
const file = Bun.file(resolvedPath)
|
|
137
|
+
if (!(await file.exists())) {
|
|
138
|
+
UI.error(`System message file not found: ${args["system-message-file"]}`)
|
|
139
|
+
process.exit(1)
|
|
140
|
+
}
|
|
141
|
+
args["system-message"] = await file.text()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (args["append-system-message-file"]) {
|
|
145
|
+
const resolvedPath = path.resolve(process.cwd(), args["append-system-message-file"])
|
|
146
|
+
const file = Bun.file(resolvedPath)
|
|
147
|
+
if (!(await file.exists())) {
|
|
148
|
+
UI.error(`Append system message file not found: ${args["append-system-message-file"]}`)
|
|
149
|
+
process.exit(1)
|
|
150
|
+
}
|
|
151
|
+
args["append-system-message"] = await file.text()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
|
155
|
+
|
|
156
|
+
if (message.trim().length === 0 && !args.command) {
|
|
157
|
+
UI.error("You must provide a message or a command")
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const execute = async (sdk: OpencodeClient, sessionID: string) => {
|
|
162
|
+
const printEvent = (color: string, type: string, title: string) => {
|
|
163
|
+
UI.println(
|
|
164
|
+
color + `|`,
|
|
165
|
+
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
|
166
|
+
"",
|
|
167
|
+
UI.Style.TEXT_NORMAL + title,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const outputJsonEvent = (type: string, data: any) => {
|
|
172
|
+
if (args.format === "json") {
|
|
173
|
+
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const events = await sdk.event.subscribe()
|
|
180
|
+
let errorMsg: string | undefined
|
|
181
|
+
|
|
182
|
+
const eventProcessor = (async () => {
|
|
183
|
+
for await (const event of events.stream) {
|
|
184
|
+
if (event.type === "message.part.updated") {
|
|
185
|
+
const part = event.properties.part
|
|
186
|
+
if (part.sessionID !== sessionID) continue
|
|
187
|
+
|
|
188
|
+
if (part.type === "tool" && part.state.status === "completed") {
|
|
189
|
+
if (outputJsonEvent("tool_use", { part })) continue
|
|
190
|
+
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
|
191
|
+
const title =
|
|
192
|
+
part.state.title ||
|
|
193
|
+
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
|
|
194
|
+
printEvent(color, tool, title)
|
|
195
|
+
if (part.tool === "bash" && part.state.output?.trim()) {
|
|
196
|
+
UI.println()
|
|
197
|
+
UI.println(part.state.output)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (part.type === "step-start") {
|
|
202
|
+
if (outputJsonEvent("step_start", { part })) continue
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (part.type === "step-finish") {
|
|
206
|
+
if (outputJsonEvent("step_finish", { part })) continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (part.type === "text" && part.time?.end) {
|
|
210
|
+
if (outputJsonEvent("text", { part })) continue
|
|
211
|
+
const isPiped = !process.stdout.isTTY
|
|
212
|
+
if (!isPiped) UI.println()
|
|
213
|
+
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
|
|
214
|
+
if (!isPiped) UI.println()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (event.type === "session.error") {
|
|
219
|
+
const props = event.properties
|
|
220
|
+
if (props.sessionID !== sessionID || !props.error) continue
|
|
221
|
+
let err = String(props.error.name)
|
|
222
|
+
if ("data" in props.error && props.error.data && "message" in props.error.data) {
|
|
223
|
+
err = String(props.error.data.message)
|
|
224
|
+
}
|
|
225
|
+
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
|
226
|
+
if (outputJsonEvent("error", { error: props.error })) continue
|
|
227
|
+
UI.error(err)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (event.type === "permission.updated") {
|
|
235
|
+
const permission = event.properties
|
|
236
|
+
if (permission.sessionID !== sessionID) continue
|
|
237
|
+
const result = await select({
|
|
238
|
+
message: `Permission required to run: ${permission.title}`,
|
|
239
|
+
options: [
|
|
240
|
+
{ value: "once", label: "Allow once" },
|
|
241
|
+
{ value: "always", label: "Always allow" },
|
|
242
|
+
{ value: "reject", label: "Reject" },
|
|
243
|
+
],
|
|
244
|
+
initialValue: "once",
|
|
245
|
+
}).catch(() => "reject")
|
|
246
|
+
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
|
|
247
|
+
await sdk.postSessionIdPermissionsPermissionId({
|
|
248
|
+
path: { id: sessionID, permissionID: permission.id },
|
|
249
|
+
body: { response },
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
})()
|
|
254
|
+
|
|
255
|
+
if (args.command) {
|
|
256
|
+
await sdk.session.command({
|
|
257
|
+
path: { id: sessionID },
|
|
258
|
+
body: {
|
|
259
|
+
agent: args.agent || "build",
|
|
260
|
+
model: args.model,
|
|
261
|
+
system: args["system-message"],
|
|
262
|
+
appendSystem: args["append-system-message"],
|
|
263
|
+
command: args.command,
|
|
264
|
+
arguments: message,
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
} else {
|
|
268
|
+
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
|
|
269
|
+
await sdk.session.prompt({
|
|
270
|
+
path: { id: sessionID },
|
|
271
|
+
body: {
|
|
272
|
+
agent: args.agent || "build",
|
|
273
|
+
model: modelParam,
|
|
274
|
+
system: args["system-message"],
|
|
275
|
+
appendSystem: args["append-system-message"],
|
|
276
|
+
parts: [...fileParts, { type: "text", text: message }],
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await eventProcessor
|
|
282
|
+
if (errorMsg) process.exit(1)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (args.attach) {
|
|
286
|
+
const sdk = createOpencodeClient({ baseUrl: args.attach })
|
|
287
|
+
|
|
288
|
+
const sessionID = await (async () => {
|
|
289
|
+
if (args.continue) {
|
|
290
|
+
const result = await sdk.session.list()
|
|
291
|
+
return result.data?.find((s) => !s.parentID)?.id
|
|
292
|
+
}
|
|
293
|
+
if (args.session) return args.session
|
|
294
|
+
|
|
295
|
+
const title =
|
|
296
|
+
args.title !== undefined
|
|
297
|
+
? args.title === ""
|
|
298
|
+
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
|
299
|
+
: args.title
|
|
300
|
+
: undefined
|
|
301
|
+
|
|
302
|
+
const result = await sdk.session.create({ body: title ? { title } : {} })
|
|
303
|
+
return result.data?.id
|
|
304
|
+
})()
|
|
305
|
+
|
|
306
|
+
if (!sessionID) {
|
|
307
|
+
UI.error("Session not found")
|
|
308
|
+
process.exit(1)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Share not supported - removed auto-share logic
|
|
312
|
+
|
|
313
|
+
return await execute(sdk, sessionID)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await bootstrap(process.cwd(), async () => {
|
|
317
|
+
// Server not supported - this code path should not be reached
|
|
318
|
+
throw new Error("Server mode not supported in agent-cli")
|
|
319
|
+
|
|
320
|
+
if (args.command) {
|
|
321
|
+
const exists = await Command.get(args.command)
|
|
322
|
+
if (!exists) {
|
|
323
|
+
server.stop()
|
|
324
|
+
UI.error(`Command "${args.command}" not found`)
|
|
325
|
+
process.exit(1)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sessionID = await (async () => {
|
|
330
|
+
if (args.continue) {
|
|
331
|
+
const result = await sdk.session.list()
|
|
332
|
+
return result.data?.find((s) => !s.parentID)?.id
|
|
333
|
+
}
|
|
334
|
+
if (args.session) return args.session
|
|
335
|
+
|
|
336
|
+
const title =
|
|
337
|
+
args.title !== undefined
|
|
338
|
+
? args.title === ""
|
|
339
|
+
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
|
|
340
|
+
: args.title
|
|
341
|
+
: undefined
|
|
342
|
+
|
|
343
|
+
const result = await sdk.session.create({ body: title ? { title } : {} })
|
|
344
|
+
return result.data?.id
|
|
345
|
+
})()
|
|
346
|
+
|
|
347
|
+
if (!sessionID) {
|
|
348
|
+
server.stop()
|
|
349
|
+
UI.error("Session not found")
|
|
350
|
+
process.exit(1)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Share not supported - removed auto-share logic
|
|
354
|
+
|
|
355
|
+
await execute(sdk, sessionID)
|
|
356
|
+
server.stop()
|
|
357
|
+
})
|
|
358
|
+
},
|
|
359
|
+
})
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import type { Argv } from "yargs"
|
|
2
|
+
import { cmd } from "./cmd"
|
|
3
|
+
import { Session } from "../../session"
|
|
4
|
+
import { bootstrap } from "../bootstrap"
|
|
5
|
+
import { Storage } from "../../storage/storage"
|
|
6
|
+
import { Project } from "../../project/project"
|
|
7
|
+
import { Instance } from "../../project/instance"
|
|
8
|
+
|
|
9
|
+
interface SessionStats {
|
|
10
|
+
totalSessions: number
|
|
11
|
+
totalMessages: number
|
|
12
|
+
totalCost: number
|
|
13
|
+
totalTokens: {
|
|
14
|
+
input: number
|
|
15
|
+
output: number
|
|
16
|
+
reasoning: number
|
|
17
|
+
cache: {
|
|
18
|
+
read: number
|
|
19
|
+
write: number
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
toolUsage: Record<string, number>
|
|
23
|
+
dateRange: {
|
|
24
|
+
earliest: number
|
|
25
|
+
latest: number
|
|
26
|
+
}
|
|
27
|
+
days: number
|
|
28
|
+
costPerDay: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const StatsCommand = cmd({
|
|
32
|
+
command: "stats",
|
|
33
|
+
describe: "show token usage and cost statistics",
|
|
34
|
+
builder: (yargs: Argv) => {
|
|
35
|
+
return yargs
|
|
36
|
+
.option("days", {
|
|
37
|
+
describe: "show stats for the last N days (default: all time)",
|
|
38
|
+
type: "number",
|
|
39
|
+
})
|
|
40
|
+
.option("tools", {
|
|
41
|
+
describe: "number of tools to show (default: all)",
|
|
42
|
+
type: "number",
|
|
43
|
+
})
|
|
44
|
+
.option("project", {
|
|
45
|
+
describe: "filter by project (default: all projects, empty string: current project)",
|
|
46
|
+
type: "string",
|
|
47
|
+
})
|
|
48
|
+
},
|
|
49
|
+
handler: async (args) => {
|
|
50
|
+
await bootstrap(process.cwd(), async () => {
|
|
51
|
+
const stats = await aggregateSessionStats(args.days, args.project)
|
|
52
|
+
displayStats(stats, args.tools)
|
|
53
|
+
})
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
async function getCurrentProject(): Promise<Project.Info> {
|
|
58
|
+
return Instance.project
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function getAllSessions(): Promise<Session.Info[]> {
|
|
62
|
+
const sessions: Session.Info[] = []
|
|
63
|
+
|
|
64
|
+
const projectKeys = await Storage.list(["project"])
|
|
65
|
+
const projects = await Promise.all(projectKeys.map((key) => Storage.read<Project.Info>(key)))
|
|
66
|
+
|
|
67
|
+
for (const project of projects) {
|
|
68
|
+
if (!project) continue
|
|
69
|
+
|
|
70
|
+
const sessionKeys = await Storage.list(["session", project.id])
|
|
71
|
+
const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read<Session.Info>(key)))
|
|
72
|
+
|
|
73
|
+
for (const session of projectSessions) {
|
|
74
|
+
if (session) {
|
|
75
|
+
sessions.push(session)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return sessions
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
|
|
84
|
+
const sessions = await getAllSessions()
|
|
85
|
+
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
|
|
86
|
+
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
|
|
87
|
+
|
|
88
|
+
let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
|
|
89
|
+
|
|
90
|
+
if (projectFilter !== undefined) {
|
|
91
|
+
if (projectFilter === "") {
|
|
92
|
+
const currentProject = await getCurrentProject()
|
|
93
|
+
filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id)
|
|
94
|
+
} else {
|
|
95
|
+
filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stats: SessionStats = {
|
|
100
|
+
totalSessions: filteredSessions.length,
|
|
101
|
+
totalMessages: 0,
|
|
102
|
+
totalCost: 0,
|
|
103
|
+
totalTokens: {
|
|
104
|
+
input: 0,
|
|
105
|
+
output: 0,
|
|
106
|
+
reasoning: 0,
|
|
107
|
+
cache: {
|
|
108
|
+
read: 0,
|
|
109
|
+
write: 0,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
toolUsage: {},
|
|
113
|
+
dateRange: {
|
|
114
|
+
earliest: Date.now(),
|
|
115
|
+
latest: Date.now(),
|
|
116
|
+
},
|
|
117
|
+
days: 0,
|
|
118
|
+
costPerDay: 0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (filteredSessions.length > 1000) {
|
|
122
|
+
console.log(`Large dataset detected (${filteredSessions.length} sessions). This may take a while...`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (filteredSessions.length === 0) {
|
|
126
|
+
return stats
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let earliestTime = Date.now()
|
|
130
|
+
let latestTime = 0
|
|
131
|
+
|
|
132
|
+
const BATCH_SIZE = 20
|
|
133
|
+
for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) {
|
|
134
|
+
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
|
135
|
+
|
|
136
|
+
const batchPromises = batch.map(async (session) => {
|
|
137
|
+
const messages = await Session.messages({ sessionID: session.id })
|
|
138
|
+
|
|
139
|
+
let sessionCost = 0
|
|
140
|
+
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
141
|
+
let sessionToolUsage: Record<string, number> = {}
|
|
142
|
+
|
|
143
|
+
for (const message of messages) {
|
|
144
|
+
if (message.info.role === "assistant") {
|
|
145
|
+
sessionCost += message.info.cost || 0
|
|
146
|
+
|
|
147
|
+
if (message.info.tokens) {
|
|
148
|
+
sessionTokens.input += message.info.tokens.input || 0
|
|
149
|
+
sessionTokens.output += message.info.tokens.output || 0
|
|
150
|
+
sessionTokens.reasoning += message.info.tokens.reasoning || 0
|
|
151
|
+
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
|
|
152
|
+
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const part of message.parts) {
|
|
157
|
+
if (part.type === "tool" && part.tool) {
|
|
158
|
+
sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
messageCount: messages.length,
|
|
165
|
+
sessionCost,
|
|
166
|
+
sessionTokens,
|
|
167
|
+
sessionToolUsage,
|
|
168
|
+
earliestTime: session.time.created,
|
|
169
|
+
latestTime: session.time.updated,
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const batchResults = await Promise.all(batchPromises)
|
|
174
|
+
|
|
175
|
+
for (const result of batchResults) {
|
|
176
|
+
earliestTime = Math.min(earliestTime, result.earliestTime)
|
|
177
|
+
latestTime = Math.max(latestTime, result.latestTime)
|
|
178
|
+
|
|
179
|
+
stats.totalMessages += result.messageCount
|
|
180
|
+
stats.totalCost += result.sessionCost
|
|
181
|
+
stats.totalTokens.input += result.sessionTokens.input
|
|
182
|
+
stats.totalTokens.output += result.sessionTokens.output
|
|
183
|
+
stats.totalTokens.reasoning += result.sessionTokens.reasoning
|
|
184
|
+
stats.totalTokens.cache.read += result.sessionTokens.cache.read
|
|
185
|
+
stats.totalTokens.cache.write += result.sessionTokens.cache.write
|
|
186
|
+
|
|
187
|
+
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
|
|
188
|
+
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
|
|
194
|
+
stats.dateRange = {
|
|
195
|
+
earliest: earliestTime,
|
|
196
|
+
latest: latestTime,
|
|
197
|
+
}
|
|
198
|
+
stats.days = actualDays
|
|
199
|
+
stats.costPerDay = stats.totalCost / actualDays
|
|
200
|
+
|
|
201
|
+
return stats
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function displayStats(stats: SessionStats, toolLimit?: number) {
|
|
205
|
+
const width = 56
|
|
206
|
+
|
|
207
|
+
function renderRow(label: string, value: string): string {
|
|
208
|
+
const availableWidth = width - 1
|
|
209
|
+
const paddingNeeded = availableWidth - label.length - value.length
|
|
210
|
+
const padding = Math.max(0, paddingNeeded)
|
|
211
|
+
return `│${label}${" ".repeat(padding)}${value} │`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Overview section
|
|
215
|
+
console.log("┌────────────────────────────────────────────────────────┐")
|
|
216
|
+
console.log("│ OVERVIEW │")
|
|
217
|
+
console.log("├────────────────────────────────────────────────────────┤")
|
|
218
|
+
console.log(renderRow("Sessions", stats.totalSessions.toLocaleString()))
|
|
219
|
+
console.log(renderRow("Messages", stats.totalMessages.toLocaleString()))
|
|
220
|
+
console.log(renderRow("Days", stats.days.toString()))
|
|
221
|
+
console.log("└────────────────────────────────────────────────────────┘")
|
|
222
|
+
console.log()
|
|
223
|
+
|
|
224
|
+
// Cost & Tokens section
|
|
225
|
+
console.log("┌────────────────────────────────────────────────────────┐")
|
|
226
|
+
console.log("│ COST & TOKENS │")
|
|
227
|
+
console.log("├────────────────────────────────────────────────────────┤")
|
|
228
|
+
const cost = isNaN(stats.totalCost) ? 0 : stats.totalCost
|
|
229
|
+
const costPerDay = isNaN(stats.costPerDay) ? 0 : stats.costPerDay
|
|
230
|
+
console.log(renderRow("Total Cost", `$${cost.toFixed(2)}`))
|
|
231
|
+
console.log(renderRow("Cost/Day", `$${costPerDay.toFixed(2)}`))
|
|
232
|
+
console.log(renderRow("Input", formatNumber(stats.totalTokens.input)))
|
|
233
|
+
console.log(renderRow("Output", formatNumber(stats.totalTokens.output)))
|
|
234
|
+
console.log(renderRow("Cache Read", formatNumber(stats.totalTokens.cache.read)))
|
|
235
|
+
console.log(renderRow("Cache Write", formatNumber(stats.totalTokens.cache.write)))
|
|
236
|
+
console.log("└────────────────────────────────────────────────────────┘")
|
|
237
|
+
console.log()
|
|
238
|
+
|
|
239
|
+
// Tool Usage section
|
|
240
|
+
if (Object.keys(stats.toolUsage).length > 0) {
|
|
241
|
+
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
|
|
242
|
+
const toolsToDisplay = toolLimit ? sortedTools.slice(0, toolLimit) : sortedTools
|
|
243
|
+
|
|
244
|
+
console.log("┌────────────────────────────────────────────────────────┐")
|
|
245
|
+
console.log("│ TOOL USAGE │")
|
|
246
|
+
console.log("├────────────────────────────────────────────────────────┤")
|
|
247
|
+
|
|
248
|
+
const maxCount = Math.max(...toolsToDisplay.map(([, count]) => count))
|
|
249
|
+
const totalToolUsage = Object.values(stats.toolUsage).reduce((a, b) => a + b, 0)
|
|
250
|
+
|
|
251
|
+
for (const [tool, count] of toolsToDisplay) {
|
|
252
|
+
const barLength = Math.max(1, Math.floor((count / maxCount) * 20))
|
|
253
|
+
const bar = "█".repeat(barLength)
|
|
254
|
+
const percentage = ((count / totalToolUsage) * 100).toFixed(1)
|
|
255
|
+
|
|
256
|
+
const maxToolLength = 18
|
|
257
|
+
const truncatedTool = tool.length > maxToolLength ? tool.substring(0, maxToolLength - 2) + ".." : tool
|
|
258
|
+
const toolName = truncatedTool.padEnd(maxToolLength)
|
|
259
|
+
|
|
260
|
+
const content = ` ${toolName} ${bar.padEnd(20)} ${count.toString().padStart(3)} (${percentage.padStart(4)}%)`
|
|
261
|
+
const padding = Math.max(0, width - content.length - 1)
|
|
262
|
+
console.log(`│${content}${" ".repeat(padding)} │`)
|
|
263
|
+
}
|
|
264
|
+
console.log("└────────────────────────────────────────────────────────┘")
|
|
265
|
+
}
|
|
266
|
+
console.log()
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatNumber(num: number): string {
|
|
270
|
+
if (num >= 1000000) {
|
|
271
|
+
return (num / 1000000).toFixed(1) + "M"
|
|
272
|
+
} else if (num >= 1000) {
|
|
273
|
+
return (num / 1000).toFixed(1) + "K"
|
|
274
|
+
}
|
|
275
|
+
return num.toString()
|
|
276
|
+
}
|
package/src/cli/error.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ConfigMarkdown } from "../config/markdown"
|
|
2
|
+
import { Config } from "../config/config"
|
|
3
|
+
import { MCP } from "../mcp"
|
|
4
|
+
import { UI } from "./ui"
|
|
5
|
+
|
|
6
|
+
export function FormatError(input: unknown) {
|
|
7
|
+
if (MCP.Failed.isInstance(input))
|
|
8
|
+
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
|
|
9
|
+
if (Config.JsonError.isInstance(input)) {
|
|
10
|
+
return (
|
|
11
|
+
`Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "")
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
if (Config.ConfigDirectoryTypoError.isInstance(input)) {
|
|
15
|
+
return `Directory "${input.data.dir}" in ${input.data.path} is not valid. Use "${input.data.suggestion}" instead. This is a common typo.`
|
|
16
|
+
}
|
|
17
|
+
if (ConfigMarkdown.FrontmatterError.isInstance(input)) {
|
|
18
|
+
return `Failed to parse frontmatter in ${input.data.path}:\n${input.data.message}`
|
|
19
|
+
}
|
|
20
|
+
if (Config.InvalidError.isInstance(input))
|
|
21
|
+
return [
|
|
22
|
+
`Config file at ${input.data.path} is invalid` + (input.data.message ? `: ${input.data.message}` : ""),
|
|
23
|
+
...(input.data.issues?.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) ?? []),
|
|
24
|
+
].join("\n")
|
|
25
|
+
|
|
26
|
+
if (UI.CancelledError.isInstance(input)) return ""
|
|
27
|
+
}
|