@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.
- package/LICENSE +48 -0
- package/README.md +172 -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 +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -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,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]
|