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