@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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|