@100xprompt/brain 0.0.0-stage → 0.1.1

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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,452 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { dirname, join } from "node:path"
3
+ import { exists, gitInfo, ls, readJson } from "../../fsutil.ts"
4
+ import { withSnapshot } from "../../sqlite.ts"
5
+ import type { Attachment, Session, Turn, Usage } from "../../session/types.ts"
6
+ import { asText, clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
7
+ import type { Reader, SessionRef } from "../types.ts"
8
+ import { compareIds } from "./ids.ts"
9
+ import { CONTINUED_NOTE } from "./note.ts"
10
+ import { listV2Db, readV2Db } from "./v2.ts"
11
+
12
+ // OpenCode 1.x and 100xprompt, its fork (catalogue entries 8, 9 and 71). Both keep a session as an info
13
+ // record, its messages, and each message's parts; a tool call and its result are one `tool` part keyed by
14
+ // callID. Two stores hold that same shape:
15
+ // <data>/storage/session/<projectID>/<sessionID>.json, message/<sessionID>/<messageID>.json,
16
+ // part/<messageID>/<partID>.json: 100xprompt today, OpenCode before 2026
17
+ // <data>/opencode.db (opencode-<channel>.db on other channels): OpenCode 1.x since 2026, tables session,
18
+ // message and part, with each message's and part's JSON in a `data` column, less its ids
19
+ // 100xprompt's own 100xprompt.db and store.db are abandoned stores the runtime never opens, so they are not read.
20
+ // OpenCode 2.x keeps the same file with a different schema (session_v2, session_message), read by v2.ts.
21
+ // Kilo CLI is a fork with the 1.x tables in <data>/kilo.db (kilo-cli/).
22
+
23
+ /** A session as `export` writes it: the info record and every message with its parts, oldest first. */
24
+ export interface Bundle {
25
+ info: Record<string, any>
26
+ messages: Array<{ info: Record<string, any>; parts: Array<Record<string, any>> }>
27
+ }
28
+
29
+ interface Family {
30
+ tool: string
31
+ label: string
32
+ /** Folder name under ~/.local/share. */
33
+ app: string
34
+ /** Whether the tool also keeps sessions in SQLite. */
35
+ sqlite: boolean
36
+ }
37
+
38
+ /** One store's sessions by id. */
39
+ interface Store {
40
+ bundle(id: string): { bundle: Bundle; path: string; bad: number } | undefined
41
+ children(id: string): string[]
42
+ }
43
+
44
+ const dataDir = (home: string, app: string) => join(home, ".local", "share", app)
45
+
46
+ /** A real session record: test leftovers and half-written files under session/ have no `ses` id or no times. */
47
+ const isSession = (v: any): v is Record<string, any> =>
48
+ !!v && typeof v === "object" && typeof v.id === "string" && v.id.startsWith("ses") && typeof v.time?.created === "number"
49
+
50
+ export function familyReader(f: Family): Reader {
51
+ return {
52
+ tool: f.tool,
53
+ label: f.label,
54
+ async list(home) {
55
+ const refs: SessionRef[] = []
56
+ const seen = new Set<string>()
57
+ if (f.sqlite)
58
+ for (const db of dbFiles(home, f.app)) {
59
+ try {
60
+ for (const r of withSnapshot(db, (d) => [...listDb(d, db, f.tool), ...listV2Db(d, db, f.tool)])) {
61
+ if (seen.has(r.sourceId)) continue
62
+ seen.add(r.sourceId)
63
+ refs.push(r)
64
+ }
65
+ } catch {
66
+ // A database that cannot be copied or opened lists nothing; read() reports why for a session in it.
67
+ }
68
+ }
69
+ const sessions = join(dataDir(home, f.app), "storage", "session")
70
+ for (const project of ls(sessions))
71
+ for (const name of ls(join(sessions, project))) {
72
+ if (!name.endsWith(".json")) continue
73
+ const path = join(sessions, project, name)
74
+ const info = readJson(path)
75
+ // Subagent sessions are read as children of the session that started them.
76
+ if (!isSession(info) || info.parentID || seen.has(info.id)) continue
77
+ seen.add(info.id)
78
+ refs.push({
79
+ tool: f.tool,
80
+ sourceId: info.id,
81
+ path,
82
+ ...(typeof info.title === "string" ? { title: info.title } : {}),
83
+ ...(typeof info.directory === "string" ? { cwd: info.directory } : {}),
84
+ ...(iso(info.time.updated) ? { updatedAt: iso(info.time.updated)! } : {}),
85
+ extra: { source: "json", projectID: project },
86
+ })
87
+ }
88
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
89
+ },
90
+ async read(ref) {
91
+ if (ref.extra?.source === "db2") {
92
+ if (!exists(ref.path)) throw new Error(`database not found: ${ref.path}`)
93
+ return withSnapshot(ref.path, (d) => readV2Db(d, ref.path, f.tool, ref.sourceId))
94
+ }
95
+ if (ref.extra?.source === "db" || ref.path.endsWith(".db")) {
96
+ if (!exists(ref.path)) throw new Error(`database not found: ${ref.path}`)
97
+ return withSnapshot(ref.path, (d) => readDb(d, ref.path, f.tool, ref.sourceId))
98
+ }
99
+ if (!exists(ref.path)) throw new Error(`session file not found: ${ref.path}`)
100
+ return toSession(f.tool, jsonStore(ref.path), ref.sourceId)
101
+ },
102
+ }
103
+ }
104
+
105
+ function dbFiles(home: string, app: string): string[] {
106
+ return ls(dataDir(home, app))
107
+ .filter((n) => new RegExp(`^${app}(-[\\w.-]+)?\\.db$`).test(n))
108
+ .map((n) => join(dataDir(home, app), n))
109
+ }
110
+
111
+ /** Top-level sessions in an open v1 database (the reader opens a snapshot of it); none in a 2.x database. */
112
+ export function listDb(d: Database, path: string, tool: string): SessionRef[] {
113
+ if (!hasTable(d, "session")) return []
114
+ const rows = d.query("SELECT id, title, directory, time_updated, project_id FROM session WHERE parent_id IS NULL").all() as Array<Record<string, any>>
115
+ return rows.map((r) => ({
116
+ tool,
117
+ sourceId: String(r.id),
118
+ path,
119
+ ...(r.title ? { title: String(r.title) } : {}),
120
+ ...(r.directory ? { cwd: String(r.directory) } : {}),
121
+ ...(iso(r.time_updated) ? { updatedAt: iso(r.time_updated)! } : {}),
122
+ extra: { source: "db", projectID: r.project_id },
123
+ }))
124
+ }
125
+
126
+ /** One session from an open v1 database, with its subagent sessions. */
127
+ export function readDb(d: Database, path: string, tool: string, id: string): Session {
128
+ if (!hasTable(d, "session")) throw new Error(`no v1 session table in ${path} (an OpenCode 2.x database is read by readV2Db)`)
129
+ return toSession(tool, dbStore(d, path), id)
130
+ }
131
+
132
+ function hasTable(d: Database, name: string): boolean {
133
+ return !!d.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
134
+ }
135
+
136
+ function dbStore(d: Database, path: string): Store {
137
+ const json = (s: unknown) => {
138
+ try {
139
+ return JSON.parse(String(s)) as Record<string, any>
140
+ } catch {
141
+ return undefined
142
+ }
143
+ }
144
+ return {
145
+ bundle(id) {
146
+ const s = d.query("SELECT * FROM session WHERE id = ?").get(id) as Record<string, any> | null
147
+ if (!s) return undefined
148
+ const info = {
149
+ id: s.id,
150
+ projectID: s.project_id,
151
+ directory: s.directory,
152
+ title: s.title,
153
+ version: s.version,
154
+ ...(s.parent_id ? { parentID: s.parent_id } : {}),
155
+ time: { created: s.time_created, updated: s.time_updated },
156
+ }
157
+ let bad = 0
158
+ const parts = new Map<string, Array<Record<string, any>>>()
159
+ for (const p of d.query("SELECT id, message_id, data FROM part WHERE session_id = ?").all(id) as Array<Record<string, any>>) {
160
+ const data = json(p.data)
161
+ if (!data) {
162
+ bad++
163
+ continue
164
+ }
165
+ const list = parts.get(p.message_id) ?? []
166
+ list.push({ ...data, id: p.id, sessionID: id, messageID: p.message_id })
167
+ parts.set(p.message_id, list)
168
+ }
169
+ const messages: Bundle["messages"] = []
170
+ for (const m of d.query("SELECT id, time_created, data FROM message WHERE session_id = ?").all(id) as Array<Record<string, any>>) {
171
+ const data = json(m.data)
172
+ if (!data) {
173
+ bad++
174
+ continue
175
+ }
176
+ messages.push({ info: { time: { created: m.time_created }, ...data, id: m.id, sessionID: id }, parts: parts.get(m.id) ?? [] })
177
+ }
178
+ return { bundle: { info, messages }, path, bad }
179
+ },
180
+ children(id) {
181
+ return (d.query("SELECT id FROM session WHERE parent_id = ?").all(id) as Array<{ id: string }>).map((r) => r.id)
182
+ },
183
+ }
184
+ }
185
+
186
+ /** The JSON store a session file belongs to: <root>/session/<projectID>/<id>.json. */
187
+ function jsonStore(sessionFile: string): Store {
188
+ const projectDir = dirname(sessionFile)
189
+ const root = dirname(dirname(projectDir))
190
+ let infos: Map<string, Record<string, any>> | undefined
191
+ // Every session record in the project, read once: children are found by their parentID.
192
+ const project = () => {
193
+ if (!infos) {
194
+ infos = new Map()
195
+ for (const n of ls(projectDir)) {
196
+ const v = n.endsWith(".json") ? readJson(join(projectDir, n)) : undefined
197
+ if (isSession(v)) infos.set(v.id, v)
198
+ }
199
+ }
200
+ return infos
201
+ }
202
+ return {
203
+ bundle(id) {
204
+ const path = join(projectDir, `${id}.json`)
205
+ const info = readJson(path) ?? project().get(id)
206
+ if (!isSession(info)) return undefined
207
+ let bad = 0
208
+ const messages: Bundle["messages"] = []
209
+ for (const n of ls(join(root, "message", id))) {
210
+ if (!n.endsWith(".json")) continue
211
+ const m = readJson<Record<string, any>>(join(root, "message", id, n))
212
+ if (!m || typeof m.id !== "string") {
213
+ bad++
214
+ continue
215
+ }
216
+ const parts: Array<Record<string, any>> = []
217
+ for (const pn of ls(join(root, "part", m.id))) {
218
+ if (!pn.endsWith(".json")) continue
219
+ const p = readJson<Record<string, any>>(join(root, "part", m.id, pn))
220
+ if (p && typeof p.type === "string") parts.push(p)
221
+ else bad++
222
+ }
223
+ messages.push({ info: m, parts })
224
+ }
225
+ return { bundle: { info, messages }, path, bad }
226
+ },
227
+ children(id) {
228
+ return [...project().values()].filter((s) => s.parentID === id).map((s) => s.id as string)
229
+ },
230
+ }
231
+ }
232
+
233
+ /** Parts that carry nothing a conversation needs: step bounds, snapshot ids, @agent mentions, retry records. */
234
+ const SKIPPED_PARTS = new Set(["step-start", "step-finish", "snapshot", "agent", "retry"])
235
+
236
+ function toSession(tool: string, store: Store, id: string, depth = 0): Session {
237
+ const got = store.bundle(id)
238
+ if (!got) throw new Error(`session ${id} not found`)
239
+ const s = bundleToSession(tool, got.bundle, got.path)
240
+ if (got.bad) s.header.lost.push(`unreadable message or part records (${got.bad})`)
241
+ // Subagent sessions, two levels down at most: a child's own children are rarely worth the reading time.
242
+ if (depth < 2) {
243
+ const children: Session[] = []
244
+ for (const childId of store.children(id)) {
245
+ try {
246
+ children.push(toSession(tool, store, childId, depth + 1))
247
+ } catch {
248
+ s.header.lost.push(`unreadable subagent session (${childId})`)
249
+ }
250
+ }
251
+ if (children.length) s.children = children
252
+ }
253
+ return s
254
+ }
255
+
256
+ /** The portable session for an export-shaped bundle. Exported for tests and for reading `export` files. */
257
+ export function bundleToSession(tool: string, b: Bundle, sourcePath?: string): Session {
258
+ const info = b.info
259
+ const cwd = typeof info.directory === "string" ? info.directory : undefined
260
+ const h = header(tool, String(info.id), {
261
+ ...(typeof info.version === "string" ? { toolVersion: info.version } : {}),
262
+ ...(sourcePath ? { sourcePath } : {}),
263
+ ...(typeof info.title === "string" ? { title: info.title } : {}),
264
+ project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
265
+ ...(iso(info.time?.created) ? { startedAt: iso(info.time.created)! } : {}),
266
+ ...(iso(info.time?.updated) ? { updatedAt: iso(info.time.updated)! } : {}),
267
+ ...(info.parentID ? { parent: { id: `${tool}:${info.parentID}`, kind: "subagent" as const } } : {}),
268
+ })
269
+ const lost = lossCounter()
270
+ const turns: Turn[] = []
271
+ const callIds = new Set<string>()
272
+ const now = Date.now()
273
+ const messages = [...b.messages].sort((x, y) => (x.info.time?.created ?? 0) - (y.info.time?.created ?? 0) || compareIds(String(x.info.id), String(y.info.id), now))
274
+
275
+ for (const { info: m, parts: unsorted } of messages) {
276
+ const parts = [...unsorted].sort((x, y) => compareIds(String(x.id), String(y.id), now))
277
+ const msgAt = iso(m.time?.created)
278
+ if (m.role === "user") {
279
+ // The user message that asks for a compaction holds only a `compaction` part; its summary follows.
280
+ if (parts.some((p) => p.type === "compaction")) continue
281
+ // A user-role message the tool injected (a "continue" nudge, the verification gate, a background
282
+ // notification) is not the person's: 100xprompt tells them apart the same way (MessageV2.userAuthored).
283
+ if (!userAuthored(parts)) {
284
+ const texts = parts.filter((p) => p.type === "text" && typeof p.text === "string" && p.text.trim())
285
+ // brain's note on a continued session says where it came from; it is not a turn.
286
+ const continued = texts.length === 1 ? String(texts[0]!.text).match(CONTINUED_NOTE)?.[1] : undefined
287
+ if (continued) h.parent ??= { id: continued, kind: "continued" }
288
+ else if (texts.length) lost.add("messages the tool injected as the user")
289
+ continue
290
+ }
291
+ const own: string[] = []
292
+ const injected: string[] = []
293
+ const attachments: Attachment[] = []
294
+ for (const p of parts) {
295
+ if (p.type === "text") {
296
+ if (p.ignored || typeof p.text !== "string" || !p.text.trim()) continue
297
+ // Synthetic text is what the tool added (a mentioned file's contents, a reminder): kept after the person's words.
298
+ ;(p.synthetic ? injected : own).push(p.text)
299
+ } else if (p.type === "file") attachments.push(attachmentOf(p, lost))
300
+ else if (p.type === "compaction" || SKIPPED_PARTS.has(p.type)) continue
301
+ else if (p.type === "subtask") lost.add("subtask parts")
302
+ else if (p.type === "tombstone") lost.add("parts the tool had removed")
303
+ else lost.add(`unknown part types: ${String(p.type)}`)
304
+ }
305
+ const text = [...own, ...injected].join("\n\n")
306
+ if (text || attachments.length) turns.push({ kind: "user", ...(msgAt ? { at: msgAt } : {}), text, ...(attachments.length ? { attachments } : {}) })
307
+ continue
308
+ }
309
+ if (m.role !== "assistant") {
310
+ lost.add(`unknown message roles: ${String(m.role)}`)
311
+ continue
312
+ }
313
+ if (m.error && typeof m.error === "object") lost.add("error notices on replies")
314
+ if (m.summary === true) {
315
+ // 100xprompt's summary message also carries synthetic parts sent only to the model: files re-read after
316
+ // the compaction, the last messages verbatim (already turns here) and an instruction to carry on.
317
+ const texts = parts.filter((p) => p.type === "text" && typeof p.text === "string" && p.text.trim())
318
+ const shown = texts.filter((p) => !p.synthetic)
319
+ if (shown.length && shown.length < texts.length) lost.add("context the tool re-attached after a compaction")
320
+ const summary = (shown.length ? shown : texts).map((p) => p.text).join("\n\n")
321
+ if (summary.trim()) turns.push({ kind: "compaction", ...(msgAt ? { at: msgAt } : {}), summary })
322
+ continue
323
+ }
324
+ const model = typeof m.modelID === "string" ? m.modelID : undefined
325
+ if (model && parts.some((p) => (p.type === "text" && !p.synthetic) || p.type === "tool")) noteModel(h, model)
326
+ let usage = usageOf(m.tokens)
327
+ const edited = new Set<string>()
328
+ for (const p of parts) {
329
+ switch (p.type) {
330
+ case "text": {
331
+ if (typeof p.text !== "string" || !p.text.trim()) break
332
+ // Synthetic text on a reply is the tool's note (the verification gate's verdict), not the model's answer.
333
+ if (p.synthetic) {
334
+ lost.add("notes the tool added to replies")
335
+ break
336
+ }
337
+ const at = iso(p.time?.start) ?? msgAt
338
+ turns.push({ kind: "assistant", ...(at ? { at } : {}), text: p.text, ...(model ? { model } : {}), ...(usage ? { usage } : {}) })
339
+ usage = undefined // the message's usage goes on its first text turn
340
+ break
341
+ }
342
+ case "reasoning": {
343
+ const signed = hasProviderData(p.metadata)
344
+ if (typeof p.text === "string" && p.text.trim()) {
345
+ const at = iso(p.time?.start) ?? msgAt
346
+ turns.push({ kind: "reasoning", ...(at ? { at } : {}), text: p.text })
347
+ if (signed) lost.add("reasoning signatures")
348
+ } else if (signed) lost.add("signed reasoning")
349
+ break
350
+ }
351
+ case "tool":
352
+ toolTurns(tool, p, turns, callIds, edited, lost)
353
+ break
354
+ case "patch":
355
+ // The files a step changed, from the tool's snapshots: those not already shown by an edit tool's diff.
356
+ for (const file of Array.isArray(p.files) ? p.files : [])
357
+ if (typeof file === "string" && !edited.has(file)) {
358
+ edited.add(file)
359
+ turns.push({ kind: "file_edit", ...(msgAt ? { at: msgAt } : {}), path: file })
360
+ }
361
+ break
362
+ case "file":
363
+ lost.add("files attached to assistant messages")
364
+ break
365
+ case "tombstone":
366
+ lost.add("parts the tool had removed")
367
+ break
368
+ default:
369
+ if (!SKIPPED_PARTS.has(p.type) && p.type !== "compaction") lost.add(`unknown part types: ${String(p.type)}`)
370
+ }
371
+ }
372
+ }
373
+ lost.flush(h)
374
+ return { header: h, turns }
375
+ }
376
+
377
+ function toolTurns(tool: string, p: Record<string, any>, turns: Turn[], callIds: Set<string>, edited: Set<string>, lost: ReturnType<typeof lossCounter>) {
378
+ const st = (p.state ?? {}) as Record<string, any>
379
+ const md = (st.metadata ?? {}) as Record<string, any>
380
+ const name = String(p.tool ?? "unknown")
381
+ // Call ids are the provider's; some providers repeat them across a session, and turns need them unique.
382
+ let id = String(p.callID ?? p.id)
383
+ for (let n = 2; callIds.has(id); n++) id = `${String(p.callID ?? p.id)}#${n}`
384
+ callIds.add(id)
385
+ const input = st.input ?? {}
386
+ const start = iso(st.time?.start)
387
+ const end = iso(st.time?.end) ?? start
388
+ if (hasProviderData(p.metadata)) lost.add("tool-call signatures")
389
+ turns.push({ kind: "tool_call", ...(start ? { at: start } : {}), id, name, toolKind: toolKindOf(name), input })
390
+ if (st.status === "completed" || st.status === "error") {
391
+ if (Array.isArray(st.attachments) && st.attachments.length) lost.add("attachments in tool results", st.attachments.length)
392
+ const raw = st.status === "completed" ? asText(st.output) : asText(st.error)
393
+ const c = clip(raw)
394
+ // Long outputs were spilled to a file by the tool itself; point at it when it is still there.
395
+ const full = typeof md.outputPath === "string" && exists(md.outputPath) ? md.outputPath : undefined
396
+ const truncated = c.truncated || md.truncated === true
397
+ turns.push({
398
+ kind: "tool_result",
399
+ ...(end ? { at: end } : {}),
400
+ callId: id,
401
+ ok: st.status === "completed",
402
+ output: c.text,
403
+ ...(truncated ? { truncated: true } : {}),
404
+ ...(full && truncated ? { fullPath: full } : {}),
405
+ })
406
+ }
407
+ // Edit tools record the diff they applied.
408
+ const file = md.filediff?.file ?? input.filePath ?? input.file_path ?? input.path
409
+ if (typeof md.diff === "string" && md.diff && typeof file === "string") {
410
+ edited.add(file)
411
+ turns.push({ kind: "file_edit", ...(end ? { at: end } : {}), path: file, patch: clip(md.diff).text, callId: id })
412
+ }
413
+ // The task tool names the subagent session it ran.
414
+ if (typeof md.sessionId === "string" && md.sessionId.startsWith("ses"))
415
+ turns.push({
416
+ kind: "subagent",
417
+ ...(end ? { at: end } : {}),
418
+ sessionId: `${tool}:${md.sessionId}`,
419
+ callId: id,
420
+ ...(typeof input.description === "string" ? { description: input.description } : {}),
421
+ })
422
+ }
423
+
424
+ /** Whether the person wrote a user message: their own text, a file, an @agent mention or a subtask (100xprompt's MessageV2.userAuthored). */
425
+ function userAuthored(parts: Array<Record<string, any>>): boolean {
426
+ return parts.some(
427
+ (p) => (p.type === "text" && !p.synthetic && typeof p.text === "string" && p.text.trim()) || p.type === "file" || p.type === "agent" || p.type === "subtask",
428
+ )
429
+ }
430
+
431
+ function usageOf(t: any): Usage | undefined {
432
+ if (!t || typeof t !== "object") return undefined
433
+ const u: Usage = {}
434
+ if (typeof t.input === "number") u.input = t.input
435
+ if (typeof t.output === "number") u.output = t.output
436
+ if (typeof t.cache?.read === "number") u.cacheRead = t.cache.read
437
+ if (typeof t.cache?.write === "number") u.cacheWrite = t.cache.write
438
+ return Object.values(u).some((v) => v) ? u : undefined
439
+ }
440
+
441
+ /** Provider replay data on a part: Anthropic signatures, OpenRouter reasoning_details, Gemini thought signatures, OpenAI item ids. */
442
+ function hasProviderData(md: unknown): boolean {
443
+ return !!md && typeof md === "object" && Object.values(md as Record<string, unknown>).some((v) => v && typeof v === "object" && Object.keys(v).length > 0)
444
+ }
445
+
446
+ function attachmentOf(p: Record<string, any>, lost: ReturnType<typeof lossCounter>): Attachment {
447
+ const url = typeof p.url === "string" ? p.url : ""
448
+ const path = url.startsWith("file://") ? decodeURIComponent(url.slice(7)) : undefined
449
+ if (!path) lost.add("attachment data")
450
+ const kind = typeof p.mime === "string" && p.mime.startsWith("image/") ? "image" : "file"
451
+ return { kind, ...(path ? { path } : {}), ...(typeof p.mime === "string" ? { mime: p.mime } : {}), ...(typeof p.filename === "string" ? { name: p.filename } : {}) }
452
+ }
@@ -0,0 +1,174 @@
1
+ import type { ToolKind } from "../../session/types.ts"
2
+
3
+ // A foreign tool call written as the target's own tool: the same tool name and the argument names that tool
4
+ // takes, so the TUI draws it as one of its own and the model sees calls shaped like the tools it has. A call
5
+ // whose arguments do not fit (apply_patch, MCP tools, anything unknown) keeps its own name and input.
6
+ // Shared by OpenCode 1.x, 100xprompt, Kilo, OpenCode 2.x and Crush, whose tools differ in name and key style.
7
+
8
+ export interface Dialect {
9
+ read: string
10
+ write: string
11
+ edit: string
12
+ shell: string
13
+ glob: string
14
+ grep: string
15
+ /** The folder-listing tool; without one, read is given the folder (OpenCode's read lists directories). */
16
+ list?: string
17
+ fetch: string
18
+ search: string
19
+ task: string
20
+ todo: string
21
+ /** The agent a session runs as: OpenCode's `build`, Kilo's `code` (corpus captures of OpenCode 1.17.20 and Kilo 7.5.0). */
22
+ agent?: string
23
+ keys: {
24
+ file: string
25
+ oldString: string
26
+ newString: string
27
+ replaceAll: string
28
+ workdir: string
29
+ /** The subagent tool's key for the agent to run; Crush's agent tool takes only a prompt. */
30
+ agent?: string
31
+ }
32
+ }
33
+
34
+ const V1_KEYS = { file: "filePath", oldString: "oldString", newString: "newString", replaceAll: "replaceAll", workdir: "workdir", agent: "subagent_type" }
35
+
36
+ /** OpenCode 1.18 and Kilo 7.8 (packages/opencode/src/tool). */
37
+ export const OPENCODE_V1: Dialect = { read: "read", write: "write", edit: "edit", shell: "bash", glob: "glob", grep: "grep", fetch: "webfetch", search: "websearch", task: "task", todo: "todowrite", keys: V1_KEYS }
38
+
39
+ /** Kilo 7.x: OpenCode 1.x's tools under Kilo's own default agent, `code`. */
40
+ export const KILO_V1: Dialect = { ...OPENCODE_V1, agent: "code" }
41
+
42
+ /** 100xprompt kept OpenCode's tools and its list tool (src/6-harness/tool). */
43
+ export const HUNDREDX: Dialect = { ...OPENCODE_V1, list: "list" }
44
+
45
+ /** OpenCode 2.x (packages/core/src/tool/plugin): shell, subagent, and `path` for files. */
46
+ export const OPENCODE_V2: Dialect = {
47
+ ...OPENCODE_V1,
48
+ shell: "shell",
49
+ task: "subagent",
50
+ keys: { ...V1_KEYS, file: "path", agent: "agent" },
51
+ }
52
+
53
+ /** Crush (internal/agent/tools): Claude-style snake_case arguments. */
54
+ export const CRUSH: Dialect = {
55
+ read: "view",
56
+ write: "write",
57
+ edit: "edit",
58
+ shell: "bash",
59
+ glob: "glob",
60
+ grep: "grep",
61
+ list: "ls",
62
+ fetch: "fetch",
63
+ search: "web_search",
64
+ task: "agent",
65
+ todo: "todos",
66
+ keys: { file: "file_path", oldString: "old_string", newString: "new_string", replaceAll: "replace_all", workdir: "working_dir" },
67
+ }
68
+
69
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
70
+ const firstOf = (input: Record<string, unknown>, keys: string[]) => keys.map((k) => str(input[k])).find(Boolean)
71
+
72
+ const FILE_KEYS = ["filePath", "file_path", "path", "target_file", "absolute_path", "file"]
73
+ const DIR_KEYS = ["path", "dir", "directory", "target_directory", "relative_workspace_path"]
74
+
75
+ /** A shell command as one line: Codex passes ["bash", "-lc", "…"]. */
76
+ function commandOf(v: unknown): string | undefined {
77
+ if (typeof v === "string") return v || undefined
78
+ if (!Array.isArray(v) || !v.every((x) => typeof x === "string") || !v.length) return undefined
79
+ const [shell, flag, script] = v as string[]
80
+ if (v.length === 3 && /^(ba|z)?sh$/.test(shell!.split("/").pop()!) && /^-l?c$/.test(flag!)) return script
81
+ return v.join(" ")
82
+ }
83
+
84
+ /** The target's own tool for a call, or undefined when its arguments do not fit one. */
85
+ export function nativeCall(d: Dialect, name: string, kind: ToolKind, input: Record<string, unknown>): { tool: string; input: Record<string, unknown> } | undefined {
86
+ const k = d.keys
87
+ switch (kind) {
88
+ case "read": {
89
+ const file = firstOf(input, FILE_KEYS)
90
+ if (!file) return undefined
91
+ const range = Object.fromEntries(["offset", "limit"].filter((x) => typeof input[x] === "number").map((x) => [x, input[x]]))
92
+ return { tool: d.read, input: { [k.file]: file, ...range } }
93
+ }
94
+ case "write": {
95
+ const file = firstOf(input, FILE_KEYS)
96
+ const content = [input.content, input.contents, input.file_text].find((v) => typeof v === "string")
97
+ if (!file || content === undefined) return undefined
98
+ return { tool: d.write, input: { [k.file]: file, content } }
99
+ }
100
+ case "edit": {
101
+ const file = firstOf(input, FILE_KEYS)
102
+ const oldString = [input.oldString, input.old_string, input.old_str].find((v) => typeof v === "string")
103
+ const newString = [input.newString, input.new_string, input.new_str].find((v) => typeof v === "string")
104
+ if (!file || oldString === undefined || newString === undefined) return undefined
105
+ const all = [input.replaceAll, input.replace_all].find((v) => typeof v === "boolean")
106
+ return { tool: d.edit, input: { [k.file]: file, [k.oldString]: oldString, [k.newString]: newString, ...(all !== undefined ? { [k.replaceAll]: all } : {}) } }
107
+ }
108
+ case "shell": {
109
+ const command = commandOf(input.command ?? input.cmd)
110
+ if (!command) return undefined
111
+ const workdir = firstOf(input, ["workdir", "cwd", "working_dir", "workingDirectory"])
112
+ const description = str(input.description)
113
+ return { tool: d.shell, input: { command, ...(description ? { description } : {}), ...(workdir ? { [k.workdir]: workdir } : {}) } }
114
+ }
115
+ case "search": {
116
+ const pattern = firstOf(input, ["pattern", "query", "regex", "glob_pattern"])
117
+ if (!pattern) return undefined
118
+ const path = firstOf(input, DIR_KEYS)
119
+ if (/glob|find_?files|file_search/i.test(name)) return { tool: d.glob, input: { pattern, ...(path ? { path } : {}) } }
120
+ if (!/grep|ripgrep|search_files|grep_search|^rg$/i.test(name)) return undefined
121
+ const include = firstOf(input, ["include", "glob", "includePattern"])
122
+ return { tool: d.grep, input: { pattern, ...(path ? { path } : {}), ...(include ? { include } : {}) } }
123
+ }
124
+ case "list": {
125
+ const path = firstOf(input, DIR_KEYS)
126
+ if (d.list) return { tool: d.list, input: path ? { path } : {} }
127
+ return path ? { tool: d.read, input: { [k.file]: path } } : undefined
128
+ }
129
+ case "web": {
130
+ const url = str(input.url)
131
+ if (url) return { tool: d.fetch, input: { url, format: "markdown" } }
132
+ const query = firstOf(input, ["query", "search_term", "q"])
133
+ return query ? { tool: d.search, input: { query } } : undefined
134
+ }
135
+ case "task": {
136
+ const prompt = firstOf(input, ["prompt", "message", "task", "instructions"])
137
+ if (!prompt) return undefined
138
+ if (!k.agent) return { tool: d.task, input: { prompt } }
139
+ const description = str(input.description) ?? prompt.split("\n")[0]!.slice(0, 60)
140
+ const agent = firstOf(input, ["subagent_type", "agent", "agent_type"]) ?? "general"
141
+ return { tool: d.task, input: { description, prompt, [k.agent]: agent } }
142
+ }
143
+ case "todo":
144
+ return Array.isArray(input.todos) ? { tool: d.todo, input: { todos: input.todos } } : undefined
145
+ default:
146
+ return undefined
147
+ }
148
+ }
149
+
150
+ /** A tool name every provider accepts, for a call kept under its own name. */
151
+ export const safeToolName = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool"
152
+
153
+ /** A tool's input as the record every one of these formats requires. */
154
+ export function asRecord(v: unknown): Record<string, unknown> {
155
+ if (v && typeof v === "object" && !Array.isArray(v)) return v as Record<string, unknown>
156
+ if (typeof v === "string") {
157
+ try {
158
+ const parsed = JSON.parse(v)
159
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
160
+ } catch {}
161
+ }
162
+ return v === undefined || v === null ? {} : { input: v }
163
+ }
164
+
165
+ /** The call as the target writes it, counting the ones moved onto its own tools. */
166
+ export function mapCall(d: Dialect, name: string, kind: ToolKind, raw: unknown, mapped: { n: number }): { tool: string; input: Record<string, unknown> } {
167
+ const input = asRecord(raw)
168
+ const native = nativeCall(d, name, kind, input)
169
+ if (native) {
170
+ if (native.tool !== name || JSON.stringify(native.input) !== JSON.stringify(input)) mapped.n++
171
+ return native
172
+ }
173
+ return { tool: safeToolName(name), input }
174
+ }