@100xprompt/brain 0.0.0-stage → 0.1.0

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 (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -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 +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,194 @@
1
+ import { readFileSync } from "node:fs"
2
+ import type { SessionHeader, Turn, Usage } from "../../session/types.ts"
3
+ import { iso, noteModel, type lossCounter } from "../../session/util.ts"
4
+ import { closeOpenCalls, CONTINUED_NOTE, type Ctx, newCtx, type Part, partsToTurns, snapshotText, toParts } from "../gemini-cli/records.ts"
5
+
6
+ // Facts about Qwen Code's session records, shared by its reader and writer. Checked against Qwen Code 0.27.0
7
+ // (packages/core/src/services/chatRecordingService.ts, sessionService.ts, session-api-history.ts and
8
+ // utils/transcript-records.ts; packages/cli/src/ui/utils/resumeHistoryUtils.ts).
9
+ //
10
+ // A session is ~/.qwen/projects/<cwd with every non-alphanumeric as '-'>/chats/<uuid>.jsonl, one ChatRecord
11
+ // per line: Claude-style uuid/parentUuid links around Gemini Content ({role, parts}). type is user, assistant
12
+ // (the model's parts: thoughts, text, functionCall), tool_result (one per call: a functionResponse part, plus
13
+ // toolCallResult for the screen) or system (titles, compression and other session state, by subtype).
14
+ // Loading walks parentUuid back from the last conversation record, so only the active branch counts, and
15
+ // records sharing a uuid are fragments of one message.
16
+
17
+ export const TOOL = "qwen-code"
18
+ export const LABEL = "Qwen Code"
19
+
20
+ export interface ChatRecord {
21
+ uuid: string
22
+ parentUuid: string | null
23
+ sessionId: string
24
+ timestamp: string
25
+ type: "user" | "assistant" | "tool_result" | "system"
26
+ subtype?: string
27
+ provenance?: string
28
+ cwd: string
29
+ version: string
30
+ gitBranch?: string
31
+ message?: { role?: string; parts?: Part[] }
32
+ model?: string
33
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number; thoughtsTokenCount?: number; totalTokenCount?: number }
34
+ toolCallResult?: { callId?: string; status?: string; resultDisplay?: unknown; error?: unknown }
35
+ systemPayload?: Record<string, any>
36
+ isSidechain?: boolean
37
+ }
38
+
39
+ /** Session files are named by a UUID; the list skips anything else in chats/. */
40
+ export const SESSION_FILE = /^[0-9a-fA-F-]{32,36}\.jsonl$/
41
+
42
+ /** Qwen Code's folder name for a project (sanitizeCwd; lower-cased on Windows only). */
43
+ export const projectId = (cwd: string) => cwd.replace(/[^a-zA-Z0-9]/g, "-")
44
+
45
+ /** System records that are never part of the conversation chain. */
46
+ const OUTSIDE_CHAIN = new Set(["session_sources_snapshot", "managed_session_header_v1", "managed_session_event_v1", "managed_session_commit_v1", "managed_session_snapshot_base_v1", "session_artifact_event", "session_artifact_snapshot"])
47
+
48
+ const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
49
+
50
+ /** The records of a session file, in file order; lines that are not records are counted. */
51
+ export function readRecords(path: string): { records: ChatRecord[]; bad: number } {
52
+ let text: string
53
+ try {
54
+ text = readFileSync(path, "utf8")
55
+ } catch {
56
+ return { records: [], bad: 0 }
57
+ }
58
+ const records: ChatRecord[] = []
59
+ let bad = 0
60
+ for (const line of text.split("\n")) {
61
+ if (!line.trim()) continue
62
+ try {
63
+ const r = JSON.parse(line)
64
+ if (isObj(r) && typeof r.uuid === "string" && typeof r.sessionId === "string" && typeof r.type === "string" && (typeof r.parentUuid === "string" || r.parentUuid === null)) records.push(r as ChatRecord)
65
+ else bad++
66
+ } catch {
67
+ bad++
68
+ }
69
+ }
70
+ return { records, bad }
71
+ }
72
+
73
+ /**
74
+ * The active conversation, as prepareTranscriptRecords builds it: from the last record that is part of the
75
+ * conversation, back along parentUuid to the root, with the fragments of each uuid merged (parts appended,
76
+ * the latest timestamp). Records on other branches (a rewind) are left out.
77
+ */
78
+ export function activeChain(records: ChatRecord[]): { chain: ChatRecord[]; offBranch: number } {
79
+ const inChain = (r: ChatRecord) => !(r.type === "system" && r.subtype && OUTSIDE_CHAIN.has(r.subtype))
80
+ const byUuid = new Map<string, ChatRecord[]>()
81
+ for (const r of records) if (inChain(r)) byUuid.set(r.uuid, [...(byUuid.get(r.uuid) ?? []), r])
82
+ const leaf = [...records].reverse().find(inChain)
83
+ const uuids: string[] = []
84
+ const seen = new Set<string>()
85
+ for (let u: string | null | undefined = leaf?.uuid; u && !seen.has(u) && byUuid.has(u); u = byUuid.get(u)![0]!.parentUuid) {
86
+ seen.add(u)
87
+ uuids.push(u)
88
+ }
89
+ const chain = uuids.reverse().map((u) => {
90
+ const [first, ...rest] = byUuid.get(u)!
91
+ const out: ChatRecord = { ...first!, ...(first!.message ? { message: { ...first!.message, parts: [...(first!.message.parts ?? [])] } } : {}) }
92
+ for (const r of rest) {
93
+ if (r.message) out.message = { role: out.message?.role ?? r.message.role, parts: [...(out.message?.parts ?? []), ...(r.message.parts ?? [])] }
94
+ if (r.usageMetadata) out.usageMetadata = r.usageMetadata
95
+ if (r.toolCallResult && !out.toolCallResult) out.toolCallResult = r.toolCallResult
96
+ if (r.model && !out.model) out.model = r.model
97
+ if (r.timestamp > out.timestamp) out.timestamp = r.timestamp
98
+ }
99
+ return out
100
+ })
101
+ const kept = new Set(uuids)
102
+ return { chain, offBranch: records.filter((r) => inChain(r) && !kept.has(r.uuid)).length }
103
+ }
104
+
105
+ /** Qwen's token counts in the portable form: cached input apart, thinking counted as output. */
106
+ function usageOf(u: ChatRecord["usageMetadata"]): Usage | undefined {
107
+ if (!u) return undefined
108
+ const cached = u.cachedContentTokenCount ?? 0
109
+ const out: Usage = {}
110
+ if (typeof u.promptTokenCount === "number") out.input = Math.max(0, u.promptTokenCount - cached)
111
+ if (typeof u.candidatesTokenCount === "number") out.output = u.candidatesTokenCount + (u.thoughtsTokenCount ?? 0)
112
+ if (cached) out.cacheRead = cached
113
+ return Object.keys(out).length ? out : undefined
114
+ }
115
+
116
+ /** The text of a user record's parts, without brain's note on a continued session. */
117
+ const textOf = (parts: Part[]) => parts.map((p) => (typeof p.text === "string" && !p.thought ? p.text : "")).join("")
118
+
119
+ /** User subtypes that are something other than the person typing (background notices, agents, goals). */
120
+ const INJECTED = new Set(["notification", "cron", "goal_runtime", "agent_mention", "agent_message", "agent_launch_prompt", "agent_bootstrap", "realtime_message"])
121
+
122
+ /** A file edit from a tool result's display (FileDiff), named by the call's own path where it has one. */
123
+ function editOf(r: ChatRecord, callId: string, turns: Turn[], at: string | undefined): Turn | undefined {
124
+ const d = r.toolCallResult?.resultDisplay
125
+ if (!isObj(d) || typeof d.fileDiff !== "string") return undefined
126
+ const call = turns.find((t) => t.kind === "tool_call" && t.id === callId) as Extract<Turn, { kind: "tool_call" }> | undefined
127
+ const input = isObj(call?.input) ? call!.input : {}
128
+ const path = [input.file_path, input.path, d.filePath, d.fileName].find((p): p is string => typeof p === "string" && p.length > 0)
129
+ return path ? { kind: "file_edit", ...(at ? { at } : {}), path, patch: d.fileDiff, callId } : undefined
130
+ }
131
+
132
+ /** The portable turns of a session's active chain; `h` gets its models, version and source note. */
133
+ export function chainToTurns(chain: ChatRecord[], h: SessionHeader, lost: ReturnType<typeof lossCounter>): Turn[] {
134
+ const ctx: Ctx = newCtx(h, lost)
135
+ const turns: Turn[] = []
136
+ for (const r of chain) {
137
+ const at = iso(r.timestamp)
138
+ const parts = toParts(r.message?.parts)
139
+ if (r.version && !h.toolVersion) h.toolVersion = r.version
140
+ if (r.isSidechain) {
141
+ lost.add("subagent records")
142
+ continue
143
+ }
144
+ switch (r.type) {
145
+ case "user": {
146
+ if (r.subtype && r.subtype !== "mid_turn_user_message") {
147
+ lost.add(INJECTED.has(r.subtype) ? "notifications and injected messages" : `user records of kind ${r.subtype}`)
148
+ break
149
+ }
150
+ let ps = parts
151
+ const note = ps[0]?.text?.match(CONTINUED_NOTE)
152
+ if (note) {
153
+ h.parent = { id: note[1]!, kind: "continued" }
154
+ ps = ps.slice(1)
155
+ }
156
+ // What the person typed, when the record keeps it apart from what the model got (hook context, @-files).
157
+ const shown = r.systemPayload?.displayText
158
+ if (typeof shown === "string" && typeof r.systemPayload?.hookContext === "string") ps = [...(shown ? [{ text: shown }] : []), ...ps.filter((p) => typeof p.text !== "string")]
159
+ turns.push(...partsToTurns(ps, "user", at, ctx))
160
+ break
161
+ }
162
+ case "assistant":
163
+ noteModel(h, r.model)
164
+ turns.push(...partsToTurns(parts, "model", at, ctx, r.model, usageOf(r.usageMetadata)))
165
+ break
166
+ case "tool_result": {
167
+ if (r.subtype === "code_mode_tool_result") {
168
+ lost.add("internal tool results")
169
+ break
170
+ }
171
+ const got = partsToTurns(parts, "user", at, ctx)
172
+ const status = r.toolCallResult?.status
173
+ for (const t of got) {
174
+ if (t.kind === "tool_result" && (status === "error" || status === "cancelled")) t.ok = false
175
+ turns.push(t)
176
+ if (t.kind === "tool_result") {
177
+ const edit = editOf(r, t.callId, turns, at)
178
+ if (edit) turns.push(edit)
179
+ }
180
+ }
181
+ break
182
+ }
183
+ case "system":
184
+ if (r.subtype === "chat_compression" && Array.isArray(r.systemPayload?.compressedHistory)) {
185
+ const first = (r.systemPayload.compressedHistory as Array<{ role?: string; parts?: Part[] }>).find((c) => c.role === "user")
186
+ const ps = toParts(first?.parts).filter((p) => !(typeof p.text === "string" && CONTINUED_NOTE.test(p.text)))
187
+ const text = textOf(ps)
188
+ turns.push({ kind: "compaction", ...(at ? { at } : {}), summary: snapshotText(text) ?? text.trim() })
189
+ } else if (r.subtype === "slash_command") lost.add("slash commands")
190
+ break
191
+ }
192
+ }
193
+ return closeOpenCalls(turns, ctx.pair.open(), ctx, () => "the session ended before it finished")
194
+ }
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, gitInfo, isDir, versionOf } from "../../fsutil.ts"
6
+ import type { Session } from "../../session/types.ts"
7
+ import { countTurns, header, lossCounter, unpaired } from "../../session/util.ts"
8
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
9
+ import { ALIASES, type Call, clock, drops, imageData, mapCall, realCwd, regroup, resultOutput, shellQuote, titleOf, type ToolSpec } from "../gemini-cli/exchanges.ts"
10
+ import { continuedNote, type Part, SNAPSHOT_ACK, snapshotOf } from "../gemini-cli/records.ts"
11
+ import { activeChain, chainToTurns, type ChatRecord, LABEL, projectId, readRecords, TOOL } from "./records.ts"
12
+
13
+ // Continues a portable session in Qwen Code as a new session of its own: chats/<uuid>.jsonl in the project's
14
+ // folder, written the way ChatRecordingService records a live session, so SessionService lists it and
15
+ // `qwen --resume <id>` loads it (checked with Qwen Code 0.27.0's own loader, run from source).
16
+ //
17
+ // One linked chain of records: the person's messages; one assistant record per model response (readable
18
+ // thoughts as thought parts, its text, its functionCall parts); one tool_result record per call, holding the
19
+ // functionResponse the model gets back and toolCallResult for the screen. A compaction is written the way
20
+ // Qwen Code compresses: a chat_compression system record whose compressedHistory (the summary and its
21
+ // acknowledgement) replaces what the model sees, while the screen keeps the earlier turns. The note on where
22
+ // the session came from is the first part of the first user message; the record's displayText (with an empty
23
+ // hookContext, as a recorded prompt has) shows the person's own words without it. The title is a
24
+ // custom_title record plus the title sidecar the list falls back on, and having one stops Qwen Code asking a
25
+ // model for a title.
26
+
27
+ /** Versions this writer was checked against; others get the brief. */
28
+ export const TESTED_VERSIONS = ["0.27.0"]
29
+
30
+ /** The installed Qwen Code version ("0.27.0"), or undefined when it is not installed. */
31
+ export const qwenVersion = (bin = "qwen") => versionOf(bin)?.match(/(\d+\.\d+\.\d+)/)?.[1]
32
+
33
+ /** Qwen Code's own tool for a portable call, by what the call does. */
34
+ function qwenTool(call: Call): ToolSpec | undefined {
35
+ const input = (call.input ?? {}) as Record<string, unknown>
36
+ switch (call.toolKind) {
37
+ case "read":
38
+ return { name: "read_file", args: { file_path: ALIASES.file } }
39
+ case "edit":
40
+ return { name: "edit", args: { file_path: ALIASES.file, old_string: ALIASES.oldText, new_string: ALIASES.newText } }
41
+ case "write":
42
+ return { name: "write_file", args: { file_path: ALIASES.file, content: ALIASES.content } }
43
+ case "shell":
44
+ return { name: "run_shell_command", args: { command: ALIASES.command } }
45
+ case "search":
46
+ return { name: /glob/i.test(call.name) ? "glob" : "grep_search", args: { pattern: ALIASES.pattern, path: ALIASES.dir } }
47
+ case "list":
48
+ return { name: "list_directory", args: { path: ALIASES.dir } }
49
+ case "web":
50
+ return ALIASES.url.some((k) => input[k] !== undefined) ? { name: "web_fetch", args: { url: ALIASES.url, prompt: ["prompt"] } } : { name: "web_search", args: { query: ALIASES.query } }
51
+ case "todo":
52
+ return { name: "todo_write", args: { todos: ["todos"] } }
53
+ default:
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ export interface RecordPlan {
59
+ records: ChatRecord[]
60
+ title: string
61
+ lost: string[]
62
+ /** What a read-back must find. */
63
+ expect: { users: number; assistants: number; calls: number }
64
+ }
65
+
66
+ /** The records of the new session `sessionId` continuing `session`. Pure apart from reading attached images. */
67
+ export function planRecords(session: Session, opts: { sessionId: string; cwd: string; version: string; gitBranch?: string; now: Date }): RecordPlan {
68
+ const { drop, list } = drops()
69
+ const exchanges = regroup(session, { fromLastCompaction: false, keepReasoning: true, drop })
70
+ const label = toolLabel(session.header.tool)
71
+ const stamp = clock(session, opts.now)
72
+ const note: Part = { text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }
73
+ const expect = { users: 0, assistants: 0, calls: 0 }
74
+ const records: ChatRecord[] = []
75
+ let parent: string | null = null
76
+ let lastModel = session.header.models[session.header.models.length - 1] ?? "unknown"
77
+ let noted = false
78
+ const add = (type: ChatRecord["type"], at: string | undefined, fields: Partial<ChatRecord>) => {
79
+ const provenance = { user: "real_user", assistant: "assistant_output", tool_result: "tool_result", system: "system" }[type]
80
+ const r: ChatRecord = {
81
+ uuid: randomUUID(),
82
+ parentUuid: parent,
83
+ sessionId: opts.sessionId,
84
+ timestamp: stamp(at),
85
+ type,
86
+ provenance,
87
+ cwd: opts.cwd,
88
+ version: opts.version,
89
+ ...(opts.gitBranch ? { gitBranch: opts.gitBranch } : {}),
90
+ ...fields,
91
+ }
92
+ records.push(r)
93
+ parent = r.uuid
94
+ return r
95
+ }
96
+ const user = (at: string | undefined, parts: Part[], shown: string) => {
97
+ if (noted) return add("user", at, { message: { role: "user", parts } })
98
+ noted = true
99
+ return add("user", at, { message: { role: "user", parts: [note, ...parts] }, systemPayload: { displayText: shown, hookContext: "" } })
100
+ }
101
+
102
+ // A session that opens with the model gets the note on its own first.
103
+ if (exchanges[0]?.kind === "model") user(exchanges[0].at, [], "")
104
+ exchanges.forEach((x, i) => {
105
+ if (x.kind === "user") {
106
+ let text = x.text
107
+ const images: Part[] = []
108
+ for (const a of x.attachments) {
109
+ if (a.kind === "file") {
110
+ if (a.path) text += `\n\n[Attached file: ${a.path}]`
111
+ continue
112
+ }
113
+ const img = imageData(a)
114
+ if (img) images.push({ inlineData: img })
115
+ else if (a.path) drop("images that could not be attached")
116
+ }
117
+ const parts: Part[] = [...(text.trim() ? [{ text }] : []), ...images]
118
+ if (!parts.length) return
119
+ user(x.at, parts, text)
120
+ if (text.trim()) expect.users++
121
+ } else if (x.kind === "compaction") {
122
+ // The model resumes from compressedHistory, so the note goes there too. Qwen Code never puts two model
123
+ // turns side by side, so a summary followed by a model response goes without the acknowledgement.
124
+ const ack = exchanges[i + 1]?.kind === "model" ? [] : [{ role: "model", parts: [{ text: SNAPSHOT_ACK }] }]
125
+ add("system", x.at, {
126
+ subtype: "chat_compression",
127
+ systemPayload: {
128
+ info: { originalTokenCount: 0, newTokenCount: 0, compressionStatus: 1 },
129
+ compressedHistory: [{ role: "user", parts: [note, { text: snapshotOf(label, x.summary) }] }, ...ack],
130
+ },
131
+ })
132
+ } else {
133
+ if (x.model) lastModel = x.model
134
+ const calls = x.calls.map((a) => ({ a, id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`, ...mapCall(a.call, qwenTool(a.call)) }))
135
+ const parts: Part[] = [
136
+ ...x.thoughts.map((t) => ({ text: t.text, thought: true })),
137
+ ...(x.text.trim() ? [{ text: x.text }] : []),
138
+ ...calls.map((c) => ({ functionCall: { id: c.id, name: c.name, args: c.args } })),
139
+ ]
140
+ const usage = x.usage
141
+ ? (() => {
142
+ const prompt = (x.usage.input ?? 0) + (x.usage.cacheRead ?? 0)
143
+ const out = x.usage.output ?? 0
144
+ return { promptTokenCount: prompt, candidatesTokenCount: out, totalTokenCount: prompt + out, ...(x.usage.cacheRead ? { cachedContentTokenCount: x.usage.cacheRead } : {}) }
145
+ })()
146
+ : undefined
147
+ add("assistant", x.at, { model: lastModel, message: { role: "model", parts }, ...(usage ? { usageMetadata: usage } : {}) })
148
+ if (x.text.trim()) expect.assistants++
149
+ for (const c of calls) {
150
+ if (!c.a.result) drop("tool calls without a result (closed as errors)")
151
+ if (c.a.edits.length) drop("file edit diffs (Qwen Code keeps one only with the whole file before and after)", c.a.edits.length)
152
+ const ok = c.a.result?.ok ?? false
153
+ const output = resultOutput(c.a.result)
154
+ add("tool_result", c.a.result?.at ?? c.a.call.at, {
155
+ message: { role: "user", parts: [{ functionResponse: { id: c.id, name: c.name, response: ok ? { output } : { error: output } } }] },
156
+ toolCallResult: { callId: c.id, status: ok ? "success" : "error", resultDisplay: output },
157
+ })
158
+ expect.calls++
159
+ }
160
+ }
161
+ })
162
+ if (!records.some((r) => r.type !== "system")) throw new Error("nothing Qwen Code can resume: the session has no messages")
163
+ const title = `${titleOf(session)} (from ${label})`
164
+ add("system", undefined, { subtype: "custom_title", systemPayload: { customTitle: title, titleSource: "auto" } })
165
+ return { records, title, lost: list(), expect }
166
+ }
167
+
168
+ /** The Qwen Code writer; `version` and `bin` are for tests, which must not depend on the installed binary. */
169
+ export function qwenWriter(opts: { bin?: string; version?: string } = {}): Writer {
170
+ const installed = () => opts.version ?? qwenVersion(opts.bin)
171
+ return {
172
+ tool: TOOL,
173
+ label: LABEL,
174
+ way: "native",
175
+
176
+ async ready(w: WriteOptions): Promise<Readiness> {
177
+ const v = installed()
178
+ if (!v) return { ok: false, why: "Qwen Code is not installed (`qwen --version` did not run)" }
179
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Qwen Code ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
180
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Qwen Code lists and resumes sessions per project folder` }
181
+ return { ok: true }
182
+ },
183
+
184
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
185
+ const version = installed() ?? TESTED_VERSIONS[TESTED_VERSIONS.length - 1]!
186
+ // Qwen Code files a session under the folder it runs in, and lists only those whose cwd hashes the same.
187
+ const cwd = realCwd(w.cwd)
188
+ const chats = join(w.home, ".qwen", "projects", projectId(cwd), "chats")
189
+ let sessionId = randomUUID()
190
+ while (exists(join(chats, `${sessionId}.jsonl`))) sessionId = randomUUID()
191
+ const path = join(chats, `${sessionId}.jsonl`)
192
+ const sidecar = join(chats, `${sessionId}.title.json`)
193
+ const now = new Date()
194
+ const branch = gitInfo(cwd, w.home).gitBranch
195
+ const plan = planRecords(session, { sessionId, cwd, version, now, ...(branch ? { gitBranch: branch } : {}) })
196
+ if (!w.dryRun) {
197
+ mkdirSync(chats, { recursive: true })
198
+ // "wx": a new file only; an existing session is never touched.
199
+ writeFileSync(path, `${plan.records.map((r) => JSON.stringify(r)).join("\n")}\n`, { flag: "wx" })
200
+ writeFileSync(sidecar, `${JSON.stringify({ version: 1, displayName: plan.title, source: "auto", updatedAt: now.toISOString() }, null, 2)}\n`, { flag: "wx", mode: 0o600 })
201
+ const h = header(TOOL, sessionId)
202
+ const turns = chainToTurns(activeChain(readRecords(path).records).chain, h, lossCounter())
203
+ const got = countTurns({ header: h, turns })
204
+ const pairs = unpaired({ header: h, turns })
205
+ if (got.user !== plan.expect.users || got.assistant !== plan.expect.assistants || got.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
206
+ rmSync(path, { force: true })
207
+ rmSync(sidecar, { force: true })
208
+ throw new Error(`the new Qwen Code session did not read back as written (users ${got.user}/${plan.expect.users}, replies ${got.assistant}/${plan.expect.assistants}, tool calls ${got.tool_call}/${plan.expect.calls}); nothing was kept`)
209
+ }
210
+ }
211
+ return {
212
+ tool: TOOL,
213
+ way: "native",
214
+ sessionId,
215
+ written: [path, sidecar],
216
+ open: `cd ${shellQuote(cwd)} && qwen --resume ${sessionId}`,
217
+ lost: plan.lost,
218
+ dryRun: !!w.dryRun,
219
+ }
220
+ },
221
+ }
222
+ }
223
+
224
+ export const qwenNativeWriter = qwenWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { tabnineReader } from "./reader.ts"
3
+ import { tabnineNativeWriter } from "./writer.ts"
4
+
5
+ // Tabnine CLI (the Gemini CLI fork, `tabnine` / `tabnine-legacy`): its sessions are Gemini CLI's files under
6
+ // ~/.tabnine/agent, so it reads them and continues any session as a new native Tabnine session.
7
+ export const readers: Reader[] = [tabnineReader]
8
+ export const writers: Writer[] = [tabnineNativeWriter]
@@ -0,0 +1,106 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import { isDir, ls, readJson } from "../../fsutil.ts"
4
+ import type { Session, Turn } from "../../session/types.ts"
5
+ import type { Reader, SessionRef } from "../types.ts"
6
+ import { reader as geminiReader } from "../gemini-cli/index.ts"
7
+ import { LABEL, TOOL, agentDir } from "./records.ts"
8
+
9
+ // Reads Tabnine CLI sessions. The files are Gemini CLI's, so the Gemini CLI parser reads them; this lists
10
+ // Tabnine's own folder, finds each project's path there (Gemini's parser would look in ~/.gemini) and gives
11
+ // the sessions Tabnine ids.
12
+
13
+ /** Project folder name → project path, from Tabnine's registry and its .project_root markers. */
14
+ function projectPaths(home: string): Map<string, string> {
15
+ const root = agentDir(home)
16
+ const out = new Map<string, string>()
17
+ const registry = readJson<{ projects?: Record<string, string> }>(join(root, "projects.json"))?.projects ?? {}
18
+ for (const [path, slug] of Object.entries(registry)) out.set(slug, path)
19
+ for (const slug of ls(join(root, "tmp"))) {
20
+ if (out.has(slug)) continue
21
+ try {
22
+ out.set(slug, resolve(readFileSync(join(root, "tmp", slug, ".project_root"), "utf8").trim()))
23
+ } catch {
24
+ // A legacy hash-named folder without a marker: its path is not recorded.
25
+ }
26
+ }
27
+ return out
28
+ }
29
+
30
+ /** The session id, start and title from the file's first record and its last summary update. */
31
+ function summarize(path: string): { sessionId: string; kind?: string; title?: string } | undefined {
32
+ const text = readFileSync(path, "utf8")
33
+ const first = text.slice(0, text.indexOf("\n") >>> 0)
34
+ let meta: { sessionId?: unknown; kind?: unknown }
35
+ try {
36
+ meta = JSON.parse(path.endsWith(".jsonl") ? first : text)
37
+ } catch {
38
+ return undefined
39
+ }
40
+ if (typeof meta.sessionId !== "string") return undefined
41
+ let title: string | undefined
42
+ for (const m of text.matchAll(/"summary"\s*:\s*("(?:[^"\\]|\\.)*")/g)) title = JSON.parse(m[1]!) as string
43
+ return { sessionId: meta.sessionId, ...(typeof meta.kind === "string" ? { kind: meta.kind } : {}), ...(title ? { title } : {}) }
44
+ }
45
+
46
+ /** The Gemini CLI parser names everything gemini-cli:<id>; these are Tabnine's. */
47
+ function retag(s: Session): Session {
48
+ const ours = (id: string) => id.replace(/^gemini-cli:/, `${TOOL}:`)
49
+ s.header.tool = TOOL
50
+ s.header.id = ours(s.header.id)
51
+ if (s.header.parent && s.header.parent.kind !== "continued") s.header.parent.id = ours(s.header.parent.id)
52
+ s.turns = s.turns.map((t): Turn => {
53
+ if (t.kind === "subagent") return { ...t, sessionId: ours(t.sessionId) }
54
+ // The shared name patterns do not know Gemini's edit tool, `replace`.
55
+ if (t.kind === "tool_call" && t.name === "replace" && t.toolKind === "other") return { ...t, toolKind: "edit" }
56
+ return t
57
+ })
58
+ for (const c of s.children ?? []) retag(c)
59
+ return s
60
+ }
61
+
62
+ export const tabnineReader: Reader = {
63
+ tool: TOOL,
64
+ label: LABEL,
65
+
66
+ async list(home: string): Promise<SessionRef[]> {
67
+ const tmp = join(agentDir(home), "tmp")
68
+ if (!isDir(tmp)) return []
69
+ const paths = projectPaths(home)
70
+ const refs: SessionRef[] = []
71
+ for (const folder of ls(tmp)) {
72
+ const chats = join(tmp, folder, "chats")
73
+ for (const name of ls(chats)) {
74
+ if (!/^session-.*\.jsonl?$/.test(name)) continue
75
+ const path = join(chats, name)
76
+ try {
77
+ const s = summarize(path)
78
+ if (!s || s.kind === "subagent") continue
79
+ const cwd = paths.get(folder)
80
+ refs.push({
81
+ tool: TOOL,
82
+ sourceId: s.sessionId,
83
+ path,
84
+ ...(s.title ? { title: s.title } : {}),
85
+ ...(cwd ? { cwd } : {}),
86
+ updatedAt: statSync(path).mtime.toISOString(),
87
+ extra: { folder },
88
+ })
89
+ } catch {
90
+ // Gone or unreadable between listing and reading.
91
+ }
92
+ }
93
+ }
94
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
95
+ },
96
+
97
+ async read(ref: SessionRef, home: string): Promise<Session> {
98
+ const folder = (ref.extra?.folder as string | undefined) ?? ref.path.split("/").slice(-3, -2)[0]
99
+ const cwd = ref.cwd ?? (folder ? projectPaths(home).get(folder) : undefined)
100
+ try {
101
+ return retag(await geminiReader.read({ ...ref, ...(cwd ? { cwd } : {}) }, home))
102
+ } catch (e) {
103
+ throw new Error((e as Error).message.replace(/Gemini CLI/g, LABEL))
104
+ }
105
+ },
106
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ // What the Tabnine CLI (the Gemini CLI fork Tabnine ships as `tabnine`, renamed `tabnine-legacy` once its
5
+ // OpenCode-based successor is installed) keeps per session, as far as this adapter relies on it. Checked
6
+ // against the public 0.36.1 bundle (console.tabnine.com/update/cli/0.36.1/tabnine.tar): Storage,
7
+ // ProjectRegistry, ChatRecordingService and loadConversationRecord, run on sessions this adapter wrote.
8
+ //
9
+ // It is Gemini CLI's layout under ~/.tabnine/agent instead of ~/.gemini: projects.json names each project's
10
+ // folder, tmp/<project>/chats/session-<time>-<id8>.jsonl holds a session (a metadata record, then message
11
+ // records, then `$set` updates), and `.project_root` markers in tmp/<project> and history/<project> claim the
12
+ // name. So the Gemini CLI planner writes the records; only the root and the version differ.
13
+
14
+ export const TOOL = "tabnine-cli"
15
+ export const LABEL = "Tabnine CLI"
16
+
17
+ /** Tabnine CLI versions the writer's files were checked against. */
18
+ export const TESTED_VERSIONS = ["0.36.1"]
19
+
20
+ /** The Gemini CLI release whose session format 0.36.1 writes: JSONL, which upstream adopted in 0.39.0. */
21
+ export const GEMINI_FORMAT_VERSION = "0.39.0"
22
+
23
+ /** ~/.tabnine/agent (TABNINE_DEV_CONFIG_DIR only applies to Tabnine's development builds). */
24
+ export const agentDir = (home: string) => join(home, ".tabnine", "agent")
25
+
26
+ /** The active Tabnine CLI version: the installer records it in .bundles/.active, so nothing is run to learn it. */
27
+ export function tabnineVersion(home: string): string | undefined {
28
+ try {
29
+ return readFileSync(join(agentDir(home), ".bundles", ".active"), "utf8").trim().match(/^\d+\.\d+\.\d+$/)?.[0]
30
+ } catch {
31
+ return undefined
32
+ }
33
+ }