@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.
Files changed (133) hide show
  1. package/EXAMPLES.md +383 -0
  2. package/LICENSE +24 -0
  3. package/MODELS.md +95 -0
  4. package/README.md +388 -0
  5. package/TOOLS.md +134 -0
  6. package/package.json +89 -0
  7. package/src/agent/agent.ts +150 -0
  8. package/src/agent/generate.txt +75 -0
  9. package/src/auth/index.ts +64 -0
  10. package/src/bun/index.ts +96 -0
  11. package/src/bus/global.ts +10 -0
  12. package/src/bus/index.ts +119 -0
  13. package/src/cli/bootstrap.js +41 -0
  14. package/src/cli/bootstrap.ts +17 -0
  15. package/src/cli/cmd/agent.ts +165 -0
  16. package/src/cli/cmd/cmd.ts +5 -0
  17. package/src/cli/cmd/export.ts +88 -0
  18. package/src/cli/cmd/mcp.ts +80 -0
  19. package/src/cli/cmd/models.ts +58 -0
  20. package/src/cli/cmd/run.ts +359 -0
  21. package/src/cli/cmd/stats.ts +276 -0
  22. package/src/cli/error.ts +27 -0
  23. package/src/command/index.ts +73 -0
  24. package/src/command/template/initialize.txt +10 -0
  25. package/src/config/config.ts +705 -0
  26. package/src/config/markdown.ts +41 -0
  27. package/src/file/ripgrep.ts +391 -0
  28. package/src/file/time.ts +38 -0
  29. package/src/file/watcher.ts +75 -0
  30. package/src/file.ts +6 -0
  31. package/src/flag/flag.ts +19 -0
  32. package/src/format/formatter.ts +248 -0
  33. package/src/format/index.ts +137 -0
  34. package/src/global/index.ts +52 -0
  35. package/src/id/id.ts +72 -0
  36. package/src/index.js +371 -0
  37. package/src/mcp/index.ts +289 -0
  38. package/src/patch/index.ts +622 -0
  39. package/src/project/bootstrap.ts +22 -0
  40. package/src/project/instance.ts +67 -0
  41. package/src/project/project.ts +105 -0
  42. package/src/project/state.ts +65 -0
  43. package/src/provider/models-macro.ts +11 -0
  44. package/src/provider/models.ts +98 -0
  45. package/src/provider/opencode.js +47 -0
  46. package/src/provider/provider.ts +636 -0
  47. package/src/provider/transform.ts +241 -0
  48. package/src/server/project.ts +48 -0
  49. package/src/server/server.ts +249 -0
  50. package/src/session/agent.js +204 -0
  51. package/src/session/compaction.ts +249 -0
  52. package/src/session/index.ts +380 -0
  53. package/src/session/message-v2.ts +758 -0
  54. package/src/session/message.ts +189 -0
  55. package/src/session/processor.ts +356 -0
  56. package/src/session/prompt/anthropic-20250930.txt +166 -0
  57. package/src/session/prompt/anthropic.txt +105 -0
  58. package/src/session/prompt/anthropic_spoof.txt +1 -0
  59. package/src/session/prompt/beast.txt +147 -0
  60. package/src/session/prompt/build-switch.txt +5 -0
  61. package/src/session/prompt/codex.txt +318 -0
  62. package/src/session/prompt/copilot-gpt-5.txt +143 -0
  63. package/src/session/prompt/gemini.txt +155 -0
  64. package/src/session/prompt/grok-code.txt +1 -0
  65. package/src/session/prompt/plan.txt +8 -0
  66. package/src/session/prompt/polaris.txt +107 -0
  67. package/src/session/prompt/qwen.txt +109 -0
  68. package/src/session/prompt/summarize-turn.txt +5 -0
  69. package/src/session/prompt/summarize.txt +10 -0
  70. package/src/session/prompt/title.txt +25 -0
  71. package/src/session/prompt.ts +1390 -0
  72. package/src/session/retry.ts +53 -0
  73. package/src/session/revert.ts +108 -0
  74. package/src/session/status.ts +75 -0
  75. package/src/session/summary.ts +179 -0
  76. package/src/session/system.ts +138 -0
  77. package/src/session/todo.ts +36 -0
  78. package/src/snapshot/index.ts +197 -0
  79. package/src/storage/storage.ts +226 -0
  80. package/src/tool/bash.ts +193 -0
  81. package/src/tool/bash.txt +121 -0
  82. package/src/tool/batch.ts +173 -0
  83. package/src/tool/batch.txt +28 -0
  84. package/src/tool/codesearch.ts +123 -0
  85. package/src/tool/codesearch.txt +12 -0
  86. package/src/tool/edit.ts +604 -0
  87. package/src/tool/edit.txt +10 -0
  88. package/src/tool/glob.ts +65 -0
  89. package/src/tool/glob.txt +6 -0
  90. package/src/tool/grep.ts +116 -0
  91. package/src/tool/grep.txt +8 -0
  92. package/src/tool/invalid.ts +17 -0
  93. package/src/tool/ls.ts +110 -0
  94. package/src/tool/ls.txt +1 -0
  95. package/src/tool/multiedit.ts +46 -0
  96. package/src/tool/multiedit.txt +41 -0
  97. package/src/tool/patch.ts +188 -0
  98. package/src/tool/patch.txt +1 -0
  99. package/src/tool/read.ts +201 -0
  100. package/src/tool/read.txt +12 -0
  101. package/src/tool/registry.ts +87 -0
  102. package/src/tool/task.ts +126 -0
  103. package/src/tool/task.txt +60 -0
  104. package/src/tool/todo.ts +39 -0
  105. package/src/tool/todoread.txt +14 -0
  106. package/src/tool/todowrite.txt +167 -0
  107. package/src/tool/tool.ts +66 -0
  108. package/src/tool/webfetch.ts +171 -0
  109. package/src/tool/webfetch.txt +14 -0
  110. package/src/tool/websearch.ts +133 -0
  111. package/src/tool/websearch.txt +11 -0
  112. package/src/tool/write.ts +33 -0
  113. package/src/tool/write.txt +8 -0
  114. package/src/util/binary.ts +41 -0
  115. package/src/util/context.ts +25 -0
  116. package/src/util/defer.ts +12 -0
  117. package/src/util/error.ts +54 -0
  118. package/src/util/eventloop.ts +20 -0
  119. package/src/util/filesystem.ts +69 -0
  120. package/src/util/fn.ts +11 -0
  121. package/src/util/iife.ts +3 -0
  122. package/src/util/keybind.ts +79 -0
  123. package/src/util/lazy.ts +11 -0
  124. package/src/util/locale.ts +39 -0
  125. package/src/util/lock.ts +98 -0
  126. package/src/util/log.ts +177 -0
  127. package/src/util/queue.ts +19 -0
  128. package/src/util/rpc.ts +42 -0
  129. package/src/util/scrap.ts +10 -0
  130. package/src/util/signal.ts +12 -0
  131. package/src/util/timeout.ts +14 -0
  132. package/src/util/token.ts +7 -0
  133. 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
+ }
@@ -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
+ }