@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,362 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
3
+ import { dirname, extname, join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { exists, isDir, versionOf } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import { continuedNote } from "../claude-code/records.ts"
9
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
10
+ import { parseSession } from "./reader.ts"
11
+ import { type Entry, NOTE_TYPE, type PiFlavor, SESSION_VERSION, sessionDir } from "./records.ts"
12
+
13
+ // Continues a portable session as a new Pi-format session (Pi, Auggie 1.x): one file in the project's
14
+ // session folder, the header and then entries on one parentId chain, as Pi's SessionManager writes them.
15
+ // `pi --session <id>` finds it by the header id, and /resume lists it.
16
+ //
17
+ // The first entry is a session_info (the name /resume shows, as `pi --name` writes it), then a hidden
18
+ // custom_message saying where the session came from (after the last compaction, where the model's context
19
+ // starts): Pi gives custom messages to the model as user text but `display: false` keeps it off screen,
20
+ // so it is not a fake request. Each assistant response is one
21
+ // message with its text, readable reasoning (a thinking block without a signature) and toolCall blocks,
22
+ // and each result is its own toolResult message, as Pi records them. Calls from other tools become Pi's
23
+ // own read / bash / edit / write / grep / find / ls when their arguments map; others keep their names.
24
+ //
25
+ // Assistant messages name the source tool as their provider and "brain-session" as their api, so Pi
26
+ // treats them as another model's turns: it drops their reasoning from requests and normalises their call
27
+ // ids for the model that resumes, and on resume it says it could not restore that model and uses the
28
+ // person's default. A compaction is a Pi compaction entry that keeps nothing before it, so Pi continues
29
+ // from its summary while the earlier turns stay on screen.
30
+
31
+ const API = "brain-session"
32
+ const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
33
+ const IMAGE_LIMIT = 5 * 1024 * 1024
34
+ const ZERO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
35
+
36
+ type Call = Extract<Turn, { kind: "tool_call" }>
37
+
38
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
39
+
40
+ const str = (o: Entry, ...keys: string[]): string | undefined => {
41
+ for (const k of keys) if (typeof o[k] === "string" && o[k]) return o[k] as string
42
+ return undefined
43
+ }
44
+ const num = (o: Entry, key: string, as: string) => (typeof o[key] === "number" ? { [as]: o[key] } : {})
45
+
46
+ /** A shell call's command line: a string, or an argv (`bash -lc <script>` keeps just the script). */
47
+ export function commandOf(i: Entry): string | undefined {
48
+ const c = i.command ?? i.cmd
49
+ if (typeof c === "string") return c || undefined
50
+ if (Array.isArray(c) && c.every((x) => typeof x === "string")) {
51
+ const [shell, flag, script] = c as string[]
52
+ if (c.length === 3 && /(^|\/)(ba|z)?sh$/.test(shell!) && /^-l?c$/.test(flag!)) return script
53
+ return c.map(shellQuote).join(" ") || undefined
54
+ }
55
+ return undefined
56
+ }
57
+
58
+ /** Replacements in a call's arguments, whatever the tool named them. */
59
+ function editsOf(i: Entry): Array<{ oldText: string; newText: string }> | undefined {
60
+ const one = (e: Entry) => {
61
+ const oldText = e.old_string ?? e.old_str ?? e.oldText
62
+ const newText = e.new_string ?? e.new_str ?? e.newText
63
+ return typeof oldText === "string" && typeof newText === "string" ? { oldText, newText } : undefined
64
+ }
65
+ if (i.replace_all === true || i.change_all === true) return undefined // Pi's edit replaces one occurrence
66
+ const list = Array.isArray(i.edits) ? (i.edits as Entry[]).map(one) : [one(i)]
67
+ return list.length && list.every(Boolean) ? (list as Array<{ oldText: string; newText: string }>) : undefined
68
+ }
69
+
70
+ /** A call from another tool as one of Pi's built-in tools, when its arguments say enough; undefined keeps it as it was. */
71
+ export function asPiTool(call: Call): { name: string; args: Entry } | undefined {
72
+ const i = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Entry) : undefined
73
+ if (!i) return undefined
74
+ const path = str(i, "path", "file_path", "filePath", "absolute_path", "target_file")
75
+ switch (call.toolKind) {
76
+ case "read":
77
+ return path ? { name: "read", args: { path, ...num(i, "offset", "offset"), ...num(i, "limit", "limit") } } : undefined
78
+ case "write": {
79
+ const content = i.content ?? i.contents ?? i.file_text
80
+ return path && typeof content === "string" ? { name: "write", args: { path, content } } : undefined
81
+ }
82
+ case "edit": {
83
+ const edits = editsOf(i)
84
+ return path && edits ? { name: "edit", args: { path, edits } } : undefined
85
+ }
86
+ case "shell": {
87
+ const command = commandOf(i)
88
+ return command ? { name: "bash", args: { command } } : undefined
89
+ }
90
+ case "search": {
91
+ const where = str(i, "path", "folder", "dir_path", "directory")
92
+ if (/glob|find|file_search/i.test(call.name)) {
93
+ const pattern = str(i, "pattern") ?? (Array.isArray(i.patterns) && typeof i.patterns[0] === "string" ? i.patterns[0] : str(i, "patterns"))
94
+ return pattern ? { name: "find", args: { pattern, ...(where ? { path: where } : {}) } } : undefined
95
+ }
96
+ const pattern = str(i, "pattern", "regex")
97
+ const glob = str(i, "glob", "glob_pattern", "include")
98
+ return pattern ? { name: "grep", args: { pattern, ...(where ? { path: where } : {}), ...(glob ? { glob } : {}) } } : undefined
99
+ }
100
+ case "list": {
101
+ const where = str(i, "path", "directory_path", "dir_path", "target_directory", "directory", "folder")
102
+ return { name: "ls", args: where ? { path: where } : {} }
103
+ }
104
+ default:
105
+ return undefined
106
+ }
107
+ }
108
+
109
+ export interface PiPlan {
110
+ /** The header line and every entry, in order. */
111
+ lines: Entry[]
112
+ lost: string[]
113
+ /** What a read-back must find. */
114
+ expect: { users: number; assistants: number; calls: number }
115
+ }
116
+
117
+ /** The lines of a new Pi-format session `id` continuing `session`. Pure apart from reading attached images. */
118
+ export function planPiSession(session: Session, opts: { flavor: PiFlavor; id: string; now: Date; cwd: string }): PiPlan {
119
+ const drops = new Map<string, number>()
120
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
121
+ const label = toolLabel(session.header.tool)
122
+ const expect = { users: 0, assistants: 0, calls: 0 }
123
+ const lines: Entry[] = [{ type: "session", version: SESSION_VERSION, id: opts.id, timestamp: opts.now.toISOString(), cwd: opts.cwd }]
124
+
125
+ // Entry ids: 8 hex characters, unique in the file, as Pi makes them. Timestamps never go backwards.
126
+ const used = new Set<string>()
127
+ const entryId = () => {
128
+ let v = randomUUID().slice(0, 8)
129
+ while (used.has(v)) v = randomUUID().slice(0, 8)
130
+ used.add(v)
131
+ return v
132
+ }
133
+ const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
134
+ let clock = (known ?? opts.now.getTime()) - 1
135
+ const stamp = (at?: string) => {
136
+ const t = at ? Date.parse(at) : Number.NaN
137
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
138
+ return clock
139
+ }
140
+ let parent: string | null = null
141
+ let chars = 0
142
+ const push = (fields: Entry, at?: string, id = entryId()) => {
143
+ const ms = stamp(at)
144
+ // A message carries its own time in milliseconds beside the entry's ISO time.
145
+ const body = fields.type === "message" ? { ...fields, message: { ...fields.message, timestamp: ms } } : fields
146
+ lines.push({ type: fields.type, id, parentId: parent, timestamp: new Date(ms).toISOString(), ...body })
147
+ parent = id
148
+ }
149
+ const message = (m: Entry, at?: string) => {
150
+ push({ type: "message", message: m }, at)
151
+ chars += JSON.stringify(m.content ?? "").length
152
+ }
153
+
154
+ const title = session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"
155
+ push({ type: "session_info", name: `${title} (from ${label})` })
156
+ // The note must be in the model's context, which starts at the last compaction: it goes right after that one.
157
+ const note = () => push({ type: "custom_message", customType: NOTE_TYPE, content: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)), display: false })
158
+ const lastCompaction = session.turns.map((t) => t.kind).lastIndexOf("compaction")
159
+ if (lastCompaction < 0) note()
160
+
161
+ // Which calls get a result somewhere, and the call ids as Pi will keep them.
162
+ const answered = new Set(session.turns.filter((t) => t.kind === "tool_result").map((t) => (t as Extract<Turn, { kind: "tool_result" }>).callId))
163
+ const ids = new Map<string, string>()
164
+ const takenIds = new Set<string>()
165
+ let remapped = 0
166
+ let renamed = 0
167
+ const callId = (id: string) => {
168
+ let v = ids.get(id)
169
+ if (v) return v
170
+ v = /^[A-Za-z0-9_-]{1,64}$/.test(id) && !takenIds.has(id) ? id : `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`
171
+ if (v !== id) remapped++
172
+ ids.set(id, v)
173
+ takenIds.add(v)
174
+ return v
175
+ }
176
+
177
+ let model = session.header.models[session.header.models.length - 1] ?? "unknown"
178
+ let cur: { at?: string; model: string; blocks: Entry[]; calls: string[] } | undefined
179
+ const written = new Map<string, string>() // source call id → Pi tool name
180
+ const done = new Set<string>()
181
+ const result = (sourceId: string, ok: boolean, output: string, at?: string) => {
182
+ message({ role: "toolResult", toolCallId: callId(sourceId), toolName: written.get(sourceId), content: [{ type: "text", text: output.trim() ? output : "(no output)" }], isError: !ok }, at)
183
+ done.add(sourceId)
184
+ }
185
+ const flush = () => {
186
+ if (!cur?.blocks.length) {
187
+ cur = undefined
188
+ return
189
+ }
190
+ const c = cur
191
+ cur = undefined
192
+ message({ role: "assistant", content: c.blocks, api: API, provider: session.header.tool, model: c.model, usage: ZERO_USAGE, stopReason: c.calls.length ? "toolUse" : "stop" }, c.at)
193
+ // Pi answers every call before the next request; a call that never got a result is closed as an error.
194
+ for (const id of c.calls) {
195
+ if (answered.has(id)) continue
196
+ drop("tool calls without a result (closed as errors)")
197
+ result(id, false, "No result was recorded for this call; the session moved on before it finished.")
198
+ }
199
+ }
200
+ const assistant = (at?: string) => (cur ??= { ...(at ? { at } : {}), model, blocks: [], calls: [] })
201
+
202
+ for (const [i, t] of session.turns.entries()) {
203
+ switch (t.kind) {
204
+ case "user": {
205
+ flush()
206
+ const blocks: Entry[] = []
207
+ let text = t.text
208
+ for (const a of t.attachments ?? []) {
209
+ if (!a.path) continue // bytes the reader could not keep; it already counted them
210
+ if (a.kind === "file") {
211
+ text += `\n\n[Attached file: ${a.path}]`
212
+ continue
213
+ }
214
+ const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
215
+ let size = Number.POSITIVE_INFINITY
216
+ try {
217
+ size = statSync(a.path).size
218
+ } catch {}
219
+ if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) blocks.push({ type: "image", data: readFileSync(a.path).toString("base64"), mimeType: mime })
220
+ else drop("images that could not be attached")
221
+ }
222
+ if (!text.trim() && !blocks.length) break
223
+ // Pi records a prompt as a list of content blocks even when it is only text (a real 0.80.6 session does).
224
+ message({ role: "user", content: [{ type: "text", text: text.trim() ? text : "(image)" }, ...blocks] }, t.at)
225
+ expect.users++
226
+ break
227
+ }
228
+ case "assistant":
229
+ // Text after a tool call is a new response: it was written after the call's result came back.
230
+ if (cur?.calls.length) flush()
231
+ if (t.model) model = t.model
232
+ if (!t.text.trim()) break
233
+ assistant(t.at).blocks.push({ type: "text", text: t.text })
234
+ if (t.model) cur!.model = t.model
235
+ expect.assistants++
236
+ break
237
+ case "reasoning":
238
+ if (cur?.calls.length) flush()
239
+ if (t.text.trim()) assistant(t.at).blocks.push({ type: "thinking", thinking: t.text })
240
+ break
241
+ case "tool_call": {
242
+ if (written.has(t.id)) {
243
+ drop("repeated tool call ids")
244
+ break
245
+ }
246
+ const pi = asPiTool(t)
247
+ // Unmapped names must still suit every provider's tool-name rule.
248
+ const name = pi?.name ?? (t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool")
249
+ if (name !== t.name) renamed++
250
+ const args = pi?.args ?? (t.input && typeof t.input === "object" && !Array.isArray(t.input) ? (t.input as Entry) : { input: t.input ?? null })
251
+ assistant(t.at).blocks.push({ type: "toolCall", id: callId(t.id), name, arguments: args })
252
+ cur!.calls.push(t.id)
253
+ written.set(t.id, name)
254
+ expect.calls++
255
+ break
256
+ }
257
+ case "tool_result": {
258
+ if (!written.has(t.callId)) {
259
+ drop("tool results without a call")
260
+ break
261
+ }
262
+ if (done.has(t.callId)) {
263
+ drop("repeated tool results")
264
+ break
265
+ }
266
+ // A result closes the response that made the call; the next call is a new response.
267
+ if (cur?.calls.includes(t.callId)) flush()
268
+ result(t.callId, t.ok, t.fullPath ? `${t.output}\n\n[Full output: ${t.fullPath}]` : t.output, t.at)
269
+ break
270
+ }
271
+ case "compaction": {
272
+ flush()
273
+ // Keeps nothing before it (firstKeptEntryId is its own id): Pi continues from the summary.
274
+ const id = entryId()
275
+ push({ type: "compaction", summary: t.summary, firstKeptEntryId: id, tokensBefore: Math.round(chars / 4) }, t.at, id)
276
+ chars = 0
277
+ if (i === lastCompaction) note()
278
+ break
279
+ }
280
+ case "file_edit":
281
+ if (!t.callId || !written.has(t.callId)) drop("file edits outside a tool call")
282
+ break
283
+ case "subagent":
284
+ drop("subagent transcripts (their results stay in the parent's tool results)")
285
+ break
286
+ }
287
+ }
288
+ flush()
289
+ if (!expect.users && !expect.assistants && !expect.calls) throw new Error("nothing to continue: the session has no messages or tool calls")
290
+
291
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
292
+ if (renamed) lost.push(`original tool names (${renamed} calls renamed to Pi's tools or to fit its rules)`)
293
+ if (remapped) lost.push(`tool call ids remapped (${remapped})`)
294
+ return { lines, lost, expect }
295
+ }
296
+
297
+ /** The tool's version from `<bin> --version` ("1.5.0", "1.2.0-prerelease.202611061509"), or undefined when it is missing. */
298
+ export function piFamilyVersion(bin: string): string | undefined {
299
+ return versionOf(bin)?.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)?.[1]
300
+ }
301
+
302
+ /** Pi records the folder it ran in as the process saw it: the resolved path (/private/tmp, not /tmp). */
303
+ function realCwd(cwd: string): string {
304
+ try {
305
+ return realpathSync(cwd)
306
+ } catch {
307
+ return cwd
308
+ }
309
+ }
310
+
311
+ /** A writer for one Pi-format tool; `version` is for tests, which must not depend on the installed binary. */
312
+ export function piFamilyWriter(f: PiFlavor, o: { tested: string[]; why?: (v: string) => string | undefined; version?: string }): Writer {
313
+ const installed = () => o.version ?? piFamilyVersion(f.bin)
314
+ return {
315
+ tool: f.tool,
316
+ label: f.label,
317
+ way: "native",
318
+
319
+ async ready(w: WriteOptions): Promise<Readiness> {
320
+ const v = installed()
321
+ if (!v) return { ok: false, why: `${f.label} is not installed here (\`${f.bin} --version\` failed)` }
322
+ const special = o.why?.(v)
323
+ if (special) return { ok: false, why: special }
324
+ if (!o.tested.includes(v)) return { ok: false, why: `${f.label} ${v} is not a version this writer was checked against (${o.tested.join(", ")})` }
325
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; ${f.label} keeps sessions by the folder they belong to` }
326
+ return { ok: true }
327
+ },
328
+
329
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
330
+ const cwd = realCwd(w.cwd)
331
+ const now = new Date()
332
+ const dir = sessionDir(f, w.home, cwd)
333
+ let id = f.newId()
334
+ while (exists(join(dir, f.fileName(id, now.toISOString())))) id = f.newId()
335
+ const path = join(dir, f.fileName(id, now.toISOString()))
336
+ const plan = planPiSession(session, { flavor: f, id, now, cwd })
337
+ if (!w.dryRun) {
338
+ mkdirSync(dirname(path), { recursive: true })
339
+ // "wx": a new file only; an existing session is never touched.
340
+ writeFileSync(path, `${plan.lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { flag: "wx" })
341
+ const back = parseSession(readFileSync(path, "utf8"), path, f, w.home)
342
+ const n = countTurns(back)
343
+ const pairs = unpaired(back)
344
+ if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
345
+ rmSync(path, { force: true })
346
+ throw new Error(
347
+ `the ${f.label} session did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
348
+ )
349
+ }
350
+ }
351
+ return {
352
+ tool: f.tool,
353
+ way: "native",
354
+ sessionId: id,
355
+ written: [path],
356
+ open: `cd ${shellQuote(cwd)} && ${f.bin} --session ${id}`,
357
+ lost: plan.lost,
358
+ dryRun: !!w.dryRun,
359
+ }
360
+ },
361
+ }
362
+ }
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { qoderReader } from "./reader.ts"
3
+ import { qoderNativeWriter } from "./writer.ts"
4
+
5
+ // Qoder CLI (qodercli): its transcripts are Claude Code's records in ~/.qoder/projects, so it reads them
6
+ // and continues any session as a new native Qoder session that `qodercli --resume` opens.
7
+ export const readers: Reader[] = [qoderReader]
8
+ export const writers: Writer[] = [qoderNativeWriter]
@@ -0,0 +1,83 @@
1
+ import { statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { ls } from "../../fsutil.ts"
4
+ import type { Session, Turn } from "../../session/types.ts"
5
+ import type { Reader, SessionRef } from "../types.ts"
6
+ import { claudeCodeReader } from "../claude-code/reader.ts"
7
+ import { SESSION_FILE } from "../claude-code/records.ts"
8
+ import { LABEL, TOOL, projectsDir } from "./records.ts"
9
+
10
+ // Reads Qoder CLI sessions. The records are Claude Code's, so the Claude Code parser reads them; this
11
+ // lists Qoder's own folder and gives the sessions Qoder ids.
12
+
13
+ const HEAD = 64 * 1024
14
+ const TAIL = 256 * 1024
15
+ const CWD = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/
16
+ const unjson = (raw: string | undefined) => {
17
+ if (raw === undefined) return undefined
18
+ try {
19
+ return JSON.parse(`"${raw}"`) as string
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+ const last = (text: string, re: RegExp) => {
25
+ let v: string | undefined
26
+ for (const m of text.matchAll(re)) v = m[1]
27
+ return unjson(v)
28
+ }
29
+
30
+ async function peek(path: string): Promise<Omit<SessionRef, "tool" | "sourceId" | "path"> | undefined> {
31
+ const file = Bun.file(path)
32
+ const head = await file.slice(0, Math.min(file.size, HEAD)).text()
33
+ const tail = file.size > HEAD ? await file.slice(Math.max(HEAD, file.size - TAIL)).text() : head
34
+ const cwd = unjson(head.match(CWD)?.[1])
35
+ // No cwd in the first records: only metadata, nothing to continue.
36
+ if (!cwd) return undefined
37
+ const title = last(tail, /"customTitle"\s*:\s*"((?:[^"\\]|\\.)*)"/g) ?? last(tail, /"aiTitle"\s*:\s*"((?:[^"\\]|\\.)*)"/g)
38
+ const updatedAt = last(tail, /"timestamp"\s*:\s*"((?:[^"\\]|\\.)*)"/g) ?? statSync(path).mtime.toISOString()
39
+ return { cwd, ...(title ? { title } : {}), updatedAt }
40
+ }
41
+
42
+ /** The Claude Code parser names everything claude-code:<id>; these are Qoder's. */
43
+ function retag(s: Session): Session {
44
+ const ours = (id: string) => id.replace(/^claude-code:/, `${TOOL}:`)
45
+ s.header.tool = TOOL
46
+ s.header.id = ours(s.header.id)
47
+ if (s.header.parent && s.header.parent.kind !== "continued") s.header.parent.id = ours(s.header.parent.id)
48
+ s.turns = s.turns.map((t): Turn => (t.kind === "subagent" ? { ...t, sessionId: ours(t.sessionId) } : t))
49
+ for (const c of s.children ?? []) retag(c)
50
+ return s
51
+ }
52
+
53
+ export const qoderReader: Reader = {
54
+ tool: TOOL,
55
+ label: LABEL,
56
+
57
+ async list(home: string): Promise<SessionRef[]> {
58
+ const root = projectsDir(home)
59
+ const refs: SessionRef[] = []
60
+ for (const project of ls(root)) {
61
+ for (const name of ls(join(root, project))) {
62
+ if (!SESSION_FILE.test(name)) continue
63
+ const path = join(root, project, name)
64
+ try {
65
+ if (!statSync(path).isFile()) continue
66
+ const p = await peek(path)
67
+ if (p) refs.push({ tool: TOOL, sourceId: name.slice(0, -".jsonl".length), path, ...p })
68
+ } catch {
69
+ // Gone or unreadable between listing and peeking.
70
+ }
71
+ }
72
+ }
73
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
74
+ },
75
+
76
+ async read(ref: SessionRef, home: string): Promise<Session> {
77
+ try {
78
+ return retag(await claudeCodeReader.read(ref, home))
79
+ } catch (e) {
80
+ throw new Error((e as Error).message.replace(/Claude Code/g, LABEL))
81
+ }
82
+ },
83
+ }
@@ -0,0 +1,45 @@
1
+ import { join } from "node:path"
2
+ import { exists, versionOf } from "../../fsutil.ts"
3
+
4
+ // What Qoder CLI (Alibaba's `qodercli`, npm @qoder-ai/qodercli, closed source) keeps per session, as far as
5
+ // this adapter relies on it. Checked against the 1.1.85 bundle: its session paths (vh/_m), project folder
6
+ // name (Am), lister (--list-sessions) and resume loader (_Pe → XIo), run on sessions this adapter wrote.
7
+ //
8
+ // Qoder's transcripts are Claude Code's: <config>/projects/<slug>/<uuid>.jsonl holding the same records
9
+ // (user/assistant/system chain with uuid and parentUuid, isMeta, isCompactSummary, compact_boundary,
10
+ // custom-title, last-prompt, toolUseResult, sourceToolAssistantUUID, entrypoint "cli", userType
11
+ // "external") and the same built-in tool names (Read, Edit, Write, Bash, Grep, Glob, WebFetch, ...).
12
+ // So this adapter reuses the Claude Code record planner and parser, with Qoder's folder, slug and version.
13
+
14
+ export const TOOL = "qoder-cli"
15
+ export const LABEL = "Qoder CLI"
16
+
17
+ /** Qoder CLI versions the writer's files were checked against. */
18
+ export const TESTED_VERSIONS = ["1.1.85"]
19
+
20
+ /** ~/.qoder (QODER_CONFIG_DIR moves it; the switcher always uses the default under the given home). */
21
+ export const qoderHome = (home: string) => join(home, ".qoder")
22
+ export const projectsDir = (home: string) => join(qoderHome(home), "projects")
23
+
24
+ /**
25
+ * Qoder's project folder for a cwd (Am in 1.1.85): every non-alphanumeric becomes '-'; a name over 200
26
+ * characters is cut to 200 and suffixed with the base-36 of a djb2-xor hash of the full path. Same idea
27
+ * as Claude Code's, different hash, so long paths land in different folders in the two tools.
28
+ */
29
+ export function projectSlug(cwd: string): string {
30
+ const slug = cwd.replace(/[^a-zA-Z0-9]/g, "-")
31
+ if (slug.length <= 200) return slug
32
+ let h = 5381
33
+ for (let i = 0; i < cwd.length; i++) h = (33 * h) ^ cwd.charCodeAt(i)
34
+ return `${slug.slice(0, 200)}-${Math.abs(h).toString(36)}`
35
+ }
36
+
37
+ /** The installed Qoder CLI version: on the PATH, or where Qoder's own installer puts it. */
38
+ export function qoderVersion(home: string): string | undefined {
39
+ for (const bin of ["qodercli", join(home, ".local", "bin", "qodercli"), join(qoderHome(home), "bin", "qodercli", "qodercli")]) {
40
+ if (bin !== "qodercli" && !exists(bin)) continue
41
+ const v = versionOf(bin)?.match(/(\d+\.\d+\.\d+)/)?.[1]
42
+ if (v) return v
43
+ }
44
+ return undefined
45
+ }
@@ -0,0 +1,72 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { exists, gitInfo, isDir } from "../../fsutil.ts"
5
+ import type { Session } from "../../session/types.ts"
6
+ import { countTurns, unpaired } from "../../session/util.ts"
7
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
8
+ import { planTranscript } from "../claude-code/writer.ts"
9
+ import { qoderReader } from "./reader.ts"
10
+ import { LABEL, TESTED_VERSIONS, TOOL, projectSlug, projectsDir, qoderVersion } from "./records.ts"
11
+
12
+ // Continues a portable session in Qoder CLI as a new transcript in Qoder's project folder: the records
13
+ // Claude Code's writer plans (Qoder's transcript format is Claude Code's), stamped with Qoder's version.
14
+ // Qoder lists it (--list-sessions) and its resume loader rebuilds the conversation from it; Qoder adds
15
+ // the session's state.json itself on first resume. The note saying where the session came from is an
16
+ // isMeta record, which Qoder, like Claude Code, gives the model but does not count as the person's words.
17
+
18
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
19
+
20
+ /** The Qoder writer; `version` is for tests, which must not depend on the installed CLI. */
21
+ export function qoderWriter(opts: { version?: string } = {}): Writer {
22
+ const installed = (home: string) => opts.version ?? qoderVersion(home)
23
+ return {
24
+ tool: TOOL,
25
+ label: LABEL,
26
+ way: "native",
27
+
28
+ async ready(w: WriteOptions): Promise<Readiness> {
29
+ const v = installed(w.home)
30
+ if (!v) return { ok: false, why: "Qoder CLI is not installed (`qodercli --version` did not run)" }
31
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Qoder CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
32
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Qoder lists sessions by the folder they belong to` }
33
+ return { ok: true }
34
+ },
35
+
36
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
37
+ // Qoder names the folder after process.cwd(), which is the resolved path.
38
+ const cwd = isDir(w.cwd) ? realpathSync(w.cwd) : w.cwd
39
+ const git = gitInfo(cwd, w.home)
40
+ const version = installed(w.home) ?? TESTED_VERSIONS[TESTED_VERSIONS.length - 1]!
41
+ const dir = join(projectsDir(w.home), projectSlug(cwd))
42
+ let sessionId = randomUUID()
43
+ while (exists(join(dir, `${sessionId}.jsonl`))) sessionId = randomUUID()
44
+ const path = join(dir, `${sessionId}.jsonl`)
45
+ const plan = planTranscript(session, { sessionId, cwd, version, gitBranch: git.gitBranch ?? (git.gitCommit ? "HEAD" : ""), now: new Date() })
46
+ if (!w.dryRun) {
47
+ mkdirSync(dir, { recursive: true })
48
+ // "wx": a new file only; an existing session is never touched.
49
+ writeFileSync(path, `${plan.lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { flag: "wx" })
50
+ const back = await qoderReader.read({ tool: TOOL, sourceId: sessionId, path }, w.home)
51
+ const pairs = unpaired(back)
52
+ const got = countTurns(back).tool_call
53
+ if (got !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
54
+ rmSync(path, { force: true })
55
+ throw new Error(`the new Qoder transcript did not read back intact (${got} of ${plan.expect.calls} tool calls, ${pairs.calls.length + pairs.results.length} unpaired); nothing was kept`)
56
+ }
57
+ }
58
+ return {
59
+ tool: TOOL,
60
+ way: "native",
61
+ sessionId,
62
+ written: [path],
63
+ open: `cd ${shellQuote(cwd)} && qodercli --resume ${sessionId}`,
64
+ // The planner words its reasoning loss for Claude Code; for Qoder it is simply reasoning not carried.
65
+ lost: plan.lost.map((l) => l.replace(/^reasoning \(Claude Code[^)]*\)/, "reasoning")),
66
+ dryRun: !!w.dryRun,
67
+ }
68
+ },
69
+ }
70
+ }
71
+
72
+ export const qoderNativeWriter = qoderWriter()
@@ -0,0 +1,84 @@
1
+ import { join } from "node:path"
2
+ import { isDir, ls, mtime, readJson } from "../../fsutil.ts"
3
+ import type { Session } from "../../session/types.ts"
4
+ import { header, iso, lossCounter } from "../../session/util.ts"
5
+ import type { Reader, SessionRef, Writer } from "../types.ts"
6
+ import { CONTINUED_NOTE, toParts } from "../gemini-cli/records.ts"
7
+ import { activeChain, chainToTurns, type ChatRecord, LABEL, readRecords, SESSION_FILE, TOOL } from "./records.ts"
8
+ import { qwenNativeWriter } from "./writer.ts"
9
+
10
+ // Qwen Code keeps each project's sessions in ~/.qwen/projects/<sanitized cwd>/chats/<uuid>.jsonl, with a
11
+ // <uuid>.title.json sidecar for the list's label (records.ts has the format). The list reads the first
12
+ // records for the folder and first prompt, and the last custom_title record for the title, as Qwen Code's
13
+ // SessionService does; archived sessions (chats/archive) are left out, as its own list leaves them out.
14
+
15
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
16
+
17
+ /** A session's title: its last custom_title, else the sidecar's label, else its first prompt. */
18
+ function titleOf(records: ChatRecord[], sidecar: string): string | undefined {
19
+ let title: string | undefined
20
+ for (const r of records) if (r.type === "system" && r.subtype === "custom_title" && typeof r.systemPayload?.customTitle === "string") title = r.systemPayload.customTitle
21
+ title ??= readJson<{ displayName?: string }>(sidecar)?.displayName
22
+ if (!title) {
23
+ for (const r of records) {
24
+ if (r.type !== "user" || r.subtype) continue
25
+ const shown = r.systemPayload?.displayText
26
+ const text = typeof shown === "string" ? shown : toParts(r.message?.parts).filter((p) => !(typeof p.text === "string" && CONTINUED_NOTE.test(p.text))).map((p) => p.text ?? "").join("")
27
+ if (oneLine(text)) {
28
+ title = text
29
+ break
30
+ }
31
+ }
32
+ }
33
+ return title && oneLine(title) ? oneLine(title).slice(0, 120) : undefined
34
+ }
35
+
36
+ export const reader: Reader = {
37
+ tool: TOOL,
38
+ label: LABEL,
39
+
40
+ async list(home) {
41
+ const projects = join(home, ".qwen", "projects")
42
+ if (!isDir(projects)) return []
43
+ const refs: SessionRef[] = []
44
+ for (const project of ls(projects)) {
45
+ const chats = join(projects, project, "chats")
46
+ for (const name of ls(chats)) {
47
+ if (!SESSION_FILE.test(name)) continue
48
+ const path = join(chats, name)
49
+ const { records } = readRecords(path)
50
+ const first = records[0]
51
+ if (!first || !records.some((r) => r.type === "user")) continue
52
+ const id = name.replace(/\.jsonl$/, "")
53
+ const title = titleOf(records, join(chats, `${id}.title.json`))
54
+ const updatedAt = mtime(path)
55
+ refs.push({ tool: TOOL, sourceId: first.sessionId, path, ...(title ? { title } : {}), ...(first.cwd ? { cwd: first.cwd } : {}), ...(updatedAt ? { updatedAt } : {}), extra: { project } })
56
+ }
57
+ }
58
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
59
+ },
60
+
61
+ async read(ref) {
62
+ const { records, bad } = readRecords(ref.path)
63
+ if (!records.length) throw new Error(`${ref.path} holds no Qwen Code records`)
64
+ const first = records[0]!
65
+ const lost = lossCounter()
66
+ if (bad) lost.add("unreadable lines", bad)
67
+ const { chain, offBranch } = activeChain(records)
68
+ if (offBranch) lost.add("records on rewound branches", offBranch)
69
+ const h = header(TOOL, first.sessionId, {
70
+ sourcePath: ref.path,
71
+ project: { cwd: first.cwd, ...(first.gitBranch ? { gitBranch: first.gitBranch } : {}) },
72
+ ...(iso(first.timestamp) ? { startedAt: iso(first.timestamp) } : {}),
73
+ ...(iso(records[records.length - 1]!.timestamp) ? { updatedAt: iso(records[records.length - 1]!.timestamp) } : {}),
74
+ })
75
+ const title = titleOf(records, ref.path.replace(/\.jsonl$/, ".title.json"))
76
+ if (title) h.title = title
77
+ const turns = chainToTurns(chain, h, lost)
78
+ lost.flush(h)
79
+ return { header: h, turns } satisfies Session
80
+ },
81
+ }
82
+
83
+ export const readers: Reader[] = [reader]
84
+ export const writers: Writer[] = [qwenNativeWriter]