@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,656 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto"
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, extname, join } from "node:path"
|
|
4
|
+
import { toolLabel } from "../../brief.ts"
|
|
5
|
+
import { appSupport, exists, isDir, isRunning, readJson } from "../../fsutil.ts"
|
|
6
|
+
import type { Session, ToolKind, Turn, Usage } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { taskReader } from "./reader.ts"
|
|
10
|
+
import {
|
|
11
|
+
CLINE_TOOLS,
|
|
12
|
+
type Host,
|
|
13
|
+
type Mapping,
|
|
14
|
+
ROO_TOOLS,
|
|
15
|
+
SHOWN,
|
|
16
|
+
compareVersions,
|
|
17
|
+
continuedNote,
|
|
18
|
+
extensionVersion,
|
|
19
|
+
hostByApp,
|
|
20
|
+
stateDb,
|
|
21
|
+
storageDir,
|
|
22
|
+
summaryText,
|
|
23
|
+
} from "./records.ts"
|
|
24
|
+
import { backupDb, updateState } from "./vscdb.ts"
|
|
25
|
+
|
|
26
|
+
// Continues a portable session as a new task in Cline or Roo Code, written where the extension keeps its own
|
|
27
|
+
// (<app>/User/globalStorage/<extension>/tasks/<id>/) and listed in the extension's task history, so it opens
|
|
28
|
+
// from the History view like any task:
|
|
29
|
+
// api_conversation_history.json what the model sees: Anthropic-style messages with native tool_use and
|
|
30
|
+
// tool_result blocks, the protocol Cline 3.56 (next-gen models), Cline 4.x
|
|
31
|
+
// and Roo Code (3.38 default, 3.54 only) use
|
|
32
|
+
// ui_messages.json what the task panel shows: the task, replies, tool rows, command output
|
|
33
|
+
// Cline: task_metadata.json, and an entry in state/taskHistory.json (Cline 3.56 watches the file and picks up
|
|
34
|
+
// external changes; Cline 4.x reads it as its legacy task list and moves the task into its SDK store
|
|
35
|
+
// on resume, src/sdk/sdk-task-history.ts)
|
|
36
|
+
// Roo 3.49+: tasks/<id>/history_item.json, the source of truth its TaskHistoryStore reconciles from disk,
|
|
37
|
+
// and tasks/_index.json when it exists
|
|
38
|
+
// Roo before 3.49: an entry in the taskHistory of its globalState row in the app's state.vscdb, written
|
|
39
|
+
// only with the app closed and after a backup
|
|
40
|
+
//
|
|
41
|
+
// The task panel and the model read different files, so the note saying where the task came from is a text
|
|
42
|
+
// block in the first API message only: the model reads it, the panel does not show it. Tool calls are mapped
|
|
43
|
+
// to the extension's own tools where the shapes translate (records.ts) and keep their names otherwise. Every
|
|
44
|
+
// call gets a result (a missing one is closed as an error), and the folder is read back before it is listed.
|
|
45
|
+
|
|
46
|
+
interface Flavor {
|
|
47
|
+
/** Reader id. */
|
|
48
|
+
tool: string
|
|
49
|
+
label: string
|
|
50
|
+
/** The globalStorage folder name, which is the extension id in lower case. */
|
|
51
|
+
extension: string
|
|
52
|
+
/** The extension id as its publisher writes it: the key of its globalState row. */
|
|
53
|
+
stateKey: string
|
|
54
|
+
/** Versions whose task store and loader the writer was checked against. */
|
|
55
|
+
tested: string[]
|
|
56
|
+
tools: Partial<Record<ToolKind, Mapping>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const CLINE: Flavor = {
|
|
60
|
+
tool: "cline",
|
|
61
|
+
label: "Cline",
|
|
62
|
+
extension: "saoudrizwan.claude-dev",
|
|
63
|
+
stateKey: "saoudrizwan.claude-dev",
|
|
64
|
+
tested: ["3.56.2", "4.1.26"],
|
|
65
|
+
tools: CLINE_TOOLS,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const ROO: Flavor = {
|
|
69
|
+
tool: "roo",
|
|
70
|
+
label: "Roo Code",
|
|
71
|
+
extension: "rooveterinaryinc.roo-cline",
|
|
72
|
+
stateKey: "RooVeterinaryInc.roo-cline",
|
|
73
|
+
tested: ["3.38.1", "3.54.0"],
|
|
74
|
+
tools: ROO_TOOLS,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Roo's roo-cline.customStoragePath setting moves its whole task store elsewhere (src/utils/storage.ts); the
|
|
79
|
+
* writer only knows the default place, so it refuses rather than write where Roo does not look.
|
|
80
|
+
*/
|
|
81
|
+
function rooCustomStorage(home: string, app: string): string | undefined {
|
|
82
|
+
let text = ""
|
|
83
|
+
try {
|
|
84
|
+
text = readFileSync(join(appSupport(home), app, "User", "settings.json"), "utf8")
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
return text.match(/"roo-cline\.customStoragePath"\s*:\s*"([^"]+)"/)?.[1]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Roo moved its task list out of globalState into per-task files in 3.49.0 ("per-task file-based history store"). */
|
|
92
|
+
const ROO_FILE_INDEX = "3.49.0"
|
|
93
|
+
const usesStateDb = (f: Flavor, version: string) => f === ROO && compareVersions(version, ROO_FILE_INDEX) < 0
|
|
94
|
+
|
|
95
|
+
const ALNUM = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
96
|
+
const randomId = (prefix: string, n: number) => prefix + [...randomBytes(n)].map((b) => ALNUM[b % ALNUM.length]).join("")
|
|
97
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
98
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
99
|
+
|
|
100
|
+
/** Cline's ulid: 10 Crockford base32 characters of time, then 16 random ones. */
|
|
101
|
+
function ulid(ms: number): string {
|
|
102
|
+
const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
103
|
+
let t = ms
|
|
104
|
+
let time = ""
|
|
105
|
+
for (let i = 0; i < 10; i++) {
|
|
106
|
+
time = B32[t % 32]! + time
|
|
107
|
+
t = Math.floor(t / 32)
|
|
108
|
+
}
|
|
109
|
+
return time + [...randomBytes(16)].map((b) => B32[b % 32]).join("")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Roo's task ids are uuid v7: 48 bits of time, then random bits. */
|
|
113
|
+
function uuidv7(ms: number): string {
|
|
114
|
+
const b = randomBytes(16)
|
|
115
|
+
let t = BigInt(ms)
|
|
116
|
+
for (let i = 5; i >= 0; i--) {
|
|
117
|
+
b[i] = Number(t & 0xffn)
|
|
118
|
+
t >>= 8n
|
|
119
|
+
}
|
|
120
|
+
b[6] = (b[6]! & 0x0f) | 0x70
|
|
121
|
+
b[8] = (b[8]! & 0x3f) | 0x80
|
|
122
|
+
const h = b.toString("hex")
|
|
123
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Tool names providers accept: letters, digits, _ and -, at most 64. */
|
|
127
|
+
const apiName = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
128
|
+
|
|
129
|
+
const asRecord = (v: unknown): Record<string, unknown> =>
|
|
130
|
+
v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : { input: v ?? null }
|
|
131
|
+
|
|
132
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
133
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
134
|
+
|
|
135
|
+
type Block = Record<string, unknown>
|
|
136
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
137
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
138
|
+
|
|
139
|
+
interface ApiMsg {
|
|
140
|
+
role: "user" | "assistant"
|
|
141
|
+
content: Block[]
|
|
142
|
+
ts: number
|
|
143
|
+
/** Roo's own compaction: the summary, and the messages it replaced. */
|
|
144
|
+
isSummary?: true
|
|
145
|
+
condenseId?: string
|
|
146
|
+
condenseParent?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface UiMsg {
|
|
150
|
+
ts: number
|
|
151
|
+
type: "say" | "ask"
|
|
152
|
+
say?: string
|
|
153
|
+
ask?: string
|
|
154
|
+
text?: string
|
|
155
|
+
images?: string[]
|
|
156
|
+
conversationHistoryIndex?: number
|
|
157
|
+
contextCondense?: Record<string, unknown>
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface TaskPlan {
|
|
161
|
+
/** The two task files' records, in the order the extension stores them. */
|
|
162
|
+
api: Array<Record<string, unknown>>
|
|
163
|
+
ui: Array<Record<string, unknown>>
|
|
164
|
+
/** The task's history entry, less its size. */
|
|
165
|
+
item: Record<string, unknown>
|
|
166
|
+
/** Cline's task_metadata.json. */
|
|
167
|
+
metadata?: Record<string, unknown>
|
|
168
|
+
lost: string[]
|
|
169
|
+
/** What a read-back must find. */
|
|
170
|
+
expect: { calls: number }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The files of a new task `taskId` continuing `session`. Pure apart from reading attached images. */
|
|
174
|
+
export function planTask(
|
|
175
|
+
session: Session,
|
|
176
|
+
flavor: Flavor,
|
|
177
|
+
opts: { taskId: string; cwd: string; now: Date; version?: string },
|
|
178
|
+
): TaskPlan {
|
|
179
|
+
const roo = flavor === ROO
|
|
180
|
+
// The first request is wrapped in <task> by Cline and by Roo 3.38; Roo 3.54 uses <user_message> throughout.
|
|
181
|
+
const taskTag = !roo || (opts.version !== undefined && usesStateDb(flavor, opts.version)) ? "task" : "user_message"
|
|
182
|
+
const drops = new Map<string, number>()
|
|
183
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
184
|
+
const label = toolLabel(session.header.tool)
|
|
185
|
+
const title = oneLine(session.header.title ?? firstUserText(session) ?? "") || `${label} session`
|
|
186
|
+
const results = new Map<string, Result>()
|
|
187
|
+
const callIds = new Set<string>()
|
|
188
|
+
for (const t of session.turns) {
|
|
189
|
+
if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
190
|
+
if (t.kind === "tool_call") callIds.add(t.id)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Timestamps that never go backwards, from the session's own times where it has them.
|
|
194
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
195
|
+
let clock = (known ?? opts.now.getTime() - session.turns.length * 1000) - 1
|
|
196
|
+
const stamp = (at?: string) => {
|
|
197
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
198
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
199
|
+
return clock
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const msgs: ApiMsg[] = []
|
|
203
|
+
const ui: UiMsg[] = []
|
|
204
|
+
// Cline dates each panel row by the API history's length when it appeared: the index of the last message.
|
|
205
|
+
const row = (fields: Omit<UiMsg, "ts">, ts: number, index: number) => {
|
|
206
|
+
const r: UiMsg = { ts, ...fields }
|
|
207
|
+
if (!roo) r.conversationHistoryIndex = index
|
|
208
|
+
ui.push(r)
|
|
209
|
+
return r
|
|
210
|
+
}
|
|
211
|
+
const note = { type: "text", text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }
|
|
212
|
+
const first: ApiMsg = { role: "user", content: [note], ts: stamp() }
|
|
213
|
+
msgs.push(first)
|
|
214
|
+
/** The user message new text joins, so roles alternate: the first message, or a message of tool results. */
|
|
215
|
+
let open: ApiMsg | undefined = first
|
|
216
|
+
let hasTask = false
|
|
217
|
+
const firstReal = session.turns.find((t) => t.kind === "user" || t.kind === "assistant" || t.kind === "tool_call" || t.kind === "compaction")
|
|
218
|
+
if (firstReal?.kind !== "user") {
|
|
219
|
+
// The panel's first row is the task; a session that starts mid-way gets its title there.
|
|
220
|
+
row({ type: "say", say: "text", text: title }, first.ts, -1)
|
|
221
|
+
hasTask = true
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let cur: { msg: ApiMsg; index: number; req: UiMsg; usage: Required<Usage>; calls: Array<{ call: Call; id: string; name: string; input: Block }> } | undefined
|
|
225
|
+
let reasoning: Array<{ text: string; at?: string }> = []
|
|
226
|
+
let calls = 0
|
|
227
|
+
let tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
228
|
+
|
|
229
|
+
const flushReasoning = (index: number) => {
|
|
230
|
+
for (const r of reasoning) row({ type: "say", say: "reasoning", text: r.text }, stamp(r.at), index)
|
|
231
|
+
reasoning = []
|
|
232
|
+
}
|
|
233
|
+
const openAssistant = (at?: string) => {
|
|
234
|
+
if (cur) return cur
|
|
235
|
+
const index = msgs.length - 1
|
|
236
|
+
const ts = stamp(at)
|
|
237
|
+
const req = row({ type: "say", say: "api_req_started", text: "" }, ts, index)
|
|
238
|
+
flushReasoning(index)
|
|
239
|
+
cur = { msg: { role: "assistant", content: [], ts }, index, req, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, calls: [] }
|
|
240
|
+
return cur
|
|
241
|
+
}
|
|
242
|
+
const closeAssistant = () => {
|
|
243
|
+
if (!cur) return
|
|
244
|
+
const c = cur
|
|
245
|
+
cur = undefined
|
|
246
|
+
msgs.push(c.msg)
|
|
247
|
+
// Cline's api_req_started row carries the request's usage as JSON; the totals in the task header add these up.
|
|
248
|
+
c.req.text = JSON.stringify({
|
|
249
|
+
request: `Carried over from ${label}`,
|
|
250
|
+
tokensIn: c.usage.input,
|
|
251
|
+
tokensOut: c.usage.output,
|
|
252
|
+
cacheWrites: c.usage.cacheWrite,
|
|
253
|
+
cacheReads: c.usage.cacheRead,
|
|
254
|
+
cost: 0,
|
|
255
|
+
})
|
|
256
|
+
open = undefined
|
|
257
|
+
if (!c.calls.length) return
|
|
258
|
+
// Every tool_use is answered in the next user message, results first and in call order.
|
|
259
|
+
const res: ApiMsg = { role: "user", content: [], ts: stamp() }
|
|
260
|
+
for (const k of c.calls) {
|
|
261
|
+
const r = results.get(k.call.id)
|
|
262
|
+
if (!r) drop("tool calls without a result (closed as errors)")
|
|
263
|
+
const ok = r?.ok ?? false
|
|
264
|
+
let output = r?.output ?? "No result was recorded for this call; the session moved on before it finished."
|
|
265
|
+
if (r?.fullPath) output += `\n\n[Full output: ${r.fullPath}]`
|
|
266
|
+
if (!output.trim()) output = ok ? "(no output)" : "The call failed."
|
|
267
|
+
res.content.push({ type: "tool_result", tool_use_id: k.id, content: output, ...(ok ? {} : { is_error: true }) })
|
|
268
|
+
toolRows(k.name, k.input, output, ok, k.call.name, c.index, r?.at)
|
|
269
|
+
}
|
|
270
|
+
msgs.push(res)
|
|
271
|
+
open = res
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The panel rows for one call, in the vocabulary each extension's chat view renders. */
|
|
275
|
+
const toolRows = (name: string, input: Block, output: string, ok: boolean, original: string, index: number, at?: string) => {
|
|
276
|
+
const s = (v: unknown) => (typeof v === "string" ? v : "")
|
|
277
|
+
const tool = (fields: Record<string, unknown>) =>
|
|
278
|
+
row(roo ? { type: "ask", ask: "tool", text: JSON.stringify(fields) } : { type: "say", say: "tool", text: JSON.stringify(fields) }, stamp(at), index)
|
|
279
|
+
switch (name) {
|
|
280
|
+
case "execute_command":
|
|
281
|
+
row(roo ? { type: "ask", ask: "command", text: s(input.command) } : { type: "say", say: "command", text: s(input.command) }, stamp(at), index)
|
|
282
|
+
row({ type: "say", say: "command_output", text: output }, stamp(at), index)
|
|
283
|
+
return
|
|
284
|
+
case "read_file":
|
|
285
|
+
tool({ tool: "readFile", path: s(input.path), content: s(input.path) })
|
|
286
|
+
return
|
|
287
|
+
case "write_to_file":
|
|
288
|
+
tool({ tool: "newFileCreated", path: s(input.path), content: s(input.content) })
|
|
289
|
+
return
|
|
290
|
+
case "replace_in_file":
|
|
291
|
+
tool({ tool: "editedExistingFile", path: s(input.path), diff: s(input.diff) })
|
|
292
|
+
return
|
|
293
|
+
case "search_replace":
|
|
294
|
+
tool({ tool: "editedExistingFile", path: s(input.file_path), diff: unifiedDiff(s(input.old_string), s(input.new_string)) })
|
|
295
|
+
return
|
|
296
|
+
case "search_files":
|
|
297
|
+
tool({ tool: "searchFiles", path: s(input.path), regex: s(input.regex), ...(input.file_pattern ? { filePattern: input.file_pattern } : {}), content: output })
|
|
298
|
+
return
|
|
299
|
+
case "list_files":
|
|
300
|
+
tool({ tool: input.recursive ? "listFilesRecursive" : "listFilesTopLevel", path: s(input.path), content: output })
|
|
301
|
+
return
|
|
302
|
+
case "web_fetch":
|
|
303
|
+
tool({ tool: "webFetch", path: s(input.url), content: output })
|
|
304
|
+
return
|
|
305
|
+
case "ask_followup_question":
|
|
306
|
+
row({ type: "ask", ask: "followup", text: JSON.stringify(roo ? { question: s(input.question), suggest: [] } : { question: s(input.question), options: [] }) }, stamp(at), index)
|
|
307
|
+
if (ok) row({ type: "say", say: "user_feedback", text: output }, stamp(at), index)
|
|
308
|
+
return
|
|
309
|
+
case "attempt_completion":
|
|
310
|
+
row({ type: "say", say: "completion_result", text: s(input.result) }, stamp(at), index)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const mcp = original.match(/^mcp__(.+?)__(.+)$/) ?? original.match(/^mcp--(.+?)--(.+)$/)
|
|
314
|
+
if (mcp) {
|
|
315
|
+
const server = { type: "use_mcp_tool", serverName: mcp[1], toolName: mcp[2], arguments: JSON.stringify(input) }
|
|
316
|
+
row(roo ? { type: "ask", ask: "use_mcp_server", text: JSON.stringify(server) } : { type: "say", say: "use_mcp_server", text: JSON.stringify(server) }, stamp(at), index)
|
|
317
|
+
row({ type: "say", say: "mcp_server_response", text: output }, stamp(at), index)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
// A tool the panel has no row for is shown as a short note, so the person still sees it ran.
|
|
321
|
+
const cut = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)}…` : t)
|
|
322
|
+
const text = `\`${original}\` (carried over from ${label})\n\n\`\`\`\n${cut(JSON.stringify(input), 600)}\n\`\`\`\n\n${ok ? "" : "Failed: "}${cut(output, 600)}`
|
|
323
|
+
row({ type: "say", say: "info", text }, stamp(at), index)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const seen = new Set<string>()
|
|
327
|
+
let lastSummary = -1
|
|
328
|
+
for (const t of session.turns) {
|
|
329
|
+
switch (t.kind) {
|
|
330
|
+
case "user": {
|
|
331
|
+
closeAssistant()
|
|
332
|
+
flushReasoning(msgs.length - 1)
|
|
333
|
+
const blocks: Block[] = []
|
|
334
|
+
const images: string[] = []
|
|
335
|
+
let text = t.text
|
|
336
|
+
for (const a of t.attachments ?? []) {
|
|
337
|
+
if (!a.path) continue // bytes the reader could not keep; it already counted them
|
|
338
|
+
if (a.kind === "file") {
|
|
339
|
+
text += `\n\n[Attached file: ${a.path}]`
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
343
|
+
let size = Number.POSITIVE_INFINITY
|
|
344
|
+
try {
|
|
345
|
+
size = statSync(a.path).size
|
|
346
|
+
} catch {}
|
|
347
|
+
if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) {
|
|
348
|
+
const data = readFileSync(a.path).toString("base64")
|
|
349
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: mime, data } })
|
|
350
|
+
images.push(`data:${mime};base64,${data}`)
|
|
351
|
+
} else drop("images that could not be attached")
|
|
352
|
+
}
|
|
353
|
+
if (!text.trim() && !blocks.length) break
|
|
354
|
+
const tag = hasTask ? "user_message" : taskTag
|
|
355
|
+
const wrapped = { type: "text", text: `<${tag}>\n${text}\n</${tag}>` }
|
|
356
|
+
const index = (open ? msgs.indexOf(open) : msgs.length) - 1
|
|
357
|
+
row({ type: "say", say: hasTask ? "user_feedback" : "text", text, ...(images.length ? { images } : {}) }, stamp(t.at), index)
|
|
358
|
+
hasTask = true
|
|
359
|
+
if (open) open.content.push(wrapped, ...blocks)
|
|
360
|
+
else {
|
|
361
|
+
open = { role: "user", content: [wrapped, ...blocks], ts: ui.at(-1)!.ts }
|
|
362
|
+
msgs.push(open)
|
|
363
|
+
}
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
case "assistant": {
|
|
367
|
+
if (!t.text.trim()) break
|
|
368
|
+
// Text after a tool call is a new response: it was written after the call's result came back.
|
|
369
|
+
if (cur?.calls.length) closeAssistant()
|
|
370
|
+
const c = openAssistant(t.at)
|
|
371
|
+
c.msg.content.push({ type: "text", text: t.text })
|
|
372
|
+
row({ type: "say", say: "text", text: t.text }, stamp(t.at), c.index)
|
|
373
|
+
if (t.usage) {
|
|
374
|
+
c.usage.input += t.usage.input ?? 0
|
|
375
|
+
c.usage.output += t.usage.output ?? 0
|
|
376
|
+
c.usage.cacheRead += t.usage.cacheRead ?? 0
|
|
377
|
+
c.usage.cacheWrite += t.usage.cacheWrite ?? 0
|
|
378
|
+
tokens = { input: tokens.input + (t.usage.input ?? 0), output: tokens.output + (t.usage.output ?? 0), cacheRead: tokens.cacheRead + (t.usage.cacheRead ?? 0), cacheWrite: tokens.cacheWrite + (t.usage.cacheWrite ?? 0) }
|
|
379
|
+
}
|
|
380
|
+
break
|
|
381
|
+
}
|
|
382
|
+
case "tool_call": {
|
|
383
|
+
if (seen.has(t.id)) break
|
|
384
|
+
seen.add(t.id)
|
|
385
|
+
const raw = asRecord(t.input)
|
|
386
|
+
const mapped = flavor.tools[t.toolKind]?.(raw, t.name)
|
|
387
|
+
if (mapped && mapped.name !== t.name) drop(`tool calls mapped to ${flavor.label}'s own tools`)
|
|
388
|
+
const name = mapped?.name ?? apiName(t.name)
|
|
389
|
+
const input = mapped?.input ?? raw
|
|
390
|
+
const c = openAssistant(t.at)
|
|
391
|
+
// A final answer or plan is both the text the person saw and the call's input; the call carries it.
|
|
392
|
+
const shown = SHOWN[name]
|
|
393
|
+
const last = c.msg.content.at(-1)
|
|
394
|
+
if (shown && last?.type === "text" && String(last.text).trim() === String(input[shown] ?? "").trim()) {
|
|
395
|
+
c.msg.content.pop()
|
|
396
|
+
for (let i = ui.length - 1; i >= 0; i--)
|
|
397
|
+
if (ui[i]!.say === "text" && ui[i]!.text === last.text) {
|
|
398
|
+
ui.splice(i, 1)
|
|
399
|
+
break
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const id = randomId("toolu_01", 22)
|
|
403
|
+
c.msg.content.push({ type: "tool_use", id, name, input })
|
|
404
|
+
c.calls.push({ call: t, id, name, input })
|
|
405
|
+
calls++
|
|
406
|
+
break
|
|
407
|
+
}
|
|
408
|
+
case "tool_result":
|
|
409
|
+
// Written right after its call's response; a result closes the response it answers, so a call made
|
|
410
|
+
// after it is a new response. A result whose call never came is dropped.
|
|
411
|
+
if (!callIds.has(t.callId)) drop("tool results without a call")
|
|
412
|
+
else if (cur?.calls.some((k) => k.call.id === t.callId)) closeAssistant()
|
|
413
|
+
break
|
|
414
|
+
case "reasoning":
|
|
415
|
+
// Shown in the panel as the extensions show their own; the API history keeps only signed reasoning.
|
|
416
|
+
reasoning.push({ text: t.text, ...(t.at ? { at: t.at } : {}) })
|
|
417
|
+
drop("reasoning (shown in the task, not sent to the model)")
|
|
418
|
+
break
|
|
419
|
+
case "compaction": {
|
|
420
|
+
closeAssistant()
|
|
421
|
+
flushReasoning(msgs.length - 1)
|
|
422
|
+
const ts = stamp(t.at)
|
|
423
|
+
if (roo) {
|
|
424
|
+
// Roo's own non-destructive condense: every earlier message points at the summary, which the model
|
|
425
|
+
// reads from on (getEffectiveApiHistory); the note goes with it, since the first message is hidden.
|
|
426
|
+
// What the person says next joins the summary's message, as Roo 3.54 merges it before each request.
|
|
427
|
+
const condenseId = randomUUID()
|
|
428
|
+
for (const m of msgs) m.condenseParent ??= condenseId
|
|
429
|
+
open = { role: "user", content: [note, { type: "text", text: `## Conversation Summary\n${t.summary}` }], ts, isSummary: true, condenseId }
|
|
430
|
+
msgs.push(open)
|
|
431
|
+
row({ type: "say", say: "condense_context", contextCondense: { cost: 0, prevContextTokens: 0, newContextTokens: 0, summary: t.summary, condenseId } }, ts, msgs.length - 1)
|
|
432
|
+
} else {
|
|
433
|
+
// Cline's own truncation: conversationHistoryDeletedRange hides the messages between the first
|
|
434
|
+
// exchange and the summary from every request (ContextManager.getTruncatedMessages).
|
|
435
|
+
const index = (open ? msgs.indexOf(open) : msgs.length) - 1
|
|
436
|
+
const block = { type: "text", text: summaryText(label, t.summary) }
|
|
437
|
+
if (open) open.content.push(block)
|
|
438
|
+
else {
|
|
439
|
+
open = { role: "user", content: [block], ts }
|
|
440
|
+
msgs.push(open)
|
|
441
|
+
}
|
|
442
|
+
lastSummary = msgs.indexOf(open)
|
|
443
|
+
row({ type: "say", say: "tool", text: JSON.stringify({ tool: "summarizeTask", content: t.summary }) }, ts, index)
|
|
444
|
+
}
|
|
445
|
+
break
|
|
446
|
+
}
|
|
447
|
+
case "file_edit":
|
|
448
|
+
if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
|
|
449
|
+
break
|
|
450
|
+
case "subagent":
|
|
451
|
+
drop("subagent transcripts (their results stay in the parent's tool results)")
|
|
452
|
+
break
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
closeAssistant()
|
|
456
|
+
flushReasoning(msgs.length - 1)
|
|
457
|
+
|
|
458
|
+
const task = ui[0]?.text ?? title
|
|
459
|
+
const lastTs = ui.at(-1)?.ts ?? first.ts
|
|
460
|
+
const totals = { tokensIn: tokens.input, tokensOut: tokens.output, cacheWrites: tokens.cacheWrite, cacheReads: tokens.cacheRead, totalCost: 0 }
|
|
461
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
462
|
+
const api = msgs.map((m) =>
|
|
463
|
+
roo
|
|
464
|
+
? { role: m.role, content: m.content, ts: m.ts, ...(m.isSummary ? { isSummary: true, condenseId: m.condenseId } : {}), ...(m.condenseParent ? { condenseParent: m.condenseParent } : {}) }
|
|
465
|
+
: { role: m.role, content: m.content },
|
|
466
|
+
)
|
|
467
|
+
const uiOut = ui.map((r) => ({ ...r }) as Record<string, unknown>)
|
|
468
|
+
if (roo) {
|
|
469
|
+
return {
|
|
470
|
+
api,
|
|
471
|
+
ui: uiOut,
|
|
472
|
+
// Listed by the time it was continued, so it sorts with today's tasks.
|
|
473
|
+
item: { id: opts.taskId, number: 1, ts: Math.max(lastTs, opts.now.getTime()), task, ...totals, workspace: opts.cwd, mode: "code", toolProtocol: "native" },
|
|
474
|
+
lost,
|
|
475
|
+
expect: { calls },
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// The deleted range keeps the first exchange and starts again at the summary; with no exchange before it, nothing is hidden.
|
|
479
|
+
const range: [number, number] | undefined = lastSummary >= 3 ? [2, lastSummary - 1] : undefined
|
|
480
|
+
const model = session.header.models.at(-1)
|
|
481
|
+
return {
|
|
482
|
+
api,
|
|
483
|
+
ui: uiOut,
|
|
484
|
+
item: {
|
|
485
|
+
id: opts.taskId,
|
|
486
|
+
ulid: ulid(opts.now.getTime()),
|
|
487
|
+
ts: Math.max(lastTs, opts.now.getTime()),
|
|
488
|
+
task,
|
|
489
|
+
...totals,
|
|
490
|
+
cwdOnTaskInitialization: opts.cwd,
|
|
491
|
+
...(range ? { conversationHistoryDeletedRange: range } : {}),
|
|
492
|
+
isFavorited: false,
|
|
493
|
+
...(model ? { modelId: model } : {}),
|
|
494
|
+
},
|
|
495
|
+
metadata: {
|
|
496
|
+
files_in_context: [],
|
|
497
|
+
model_usage: session.header.models.map((m) => ({ ts: first.ts, model_id: m, model_provider_id: providerOf(m), mode: "act" })),
|
|
498
|
+
environment_history: [],
|
|
499
|
+
},
|
|
500
|
+
lost,
|
|
501
|
+
expect: { calls },
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The provider a model belongs to, for task_metadata's model_usage. */
|
|
506
|
+
function providerOf(model: string): string {
|
|
507
|
+
const slash = model.indexOf("/")
|
|
508
|
+
if (slash > 0) return model.slice(0, slash)
|
|
509
|
+
return /claude|opus|sonnet|haiku/i.test(model) ? "anthropic" : /gemini/i.test(model) ? "gemini" : /^(gpt|o\d|codex)/i.test(model) ? "openai" : "unknown"
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** A small unified diff of one replacement, which Roo's edit rows render. */
|
|
513
|
+
function unifiedDiff(before: string, after: string): string {
|
|
514
|
+
const lines = (s: string, p: string) => (s ? s.split("\n").map((l) => p + l) : [])
|
|
515
|
+
return ["@@", ...lines(before, "-"), ...lines(after, "+")].join("\n")
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Writes a file through a temporary name, as the extensions do, so a watcher never sees half of it. */
|
|
519
|
+
function writeAtomic(path: string, data: string) {
|
|
520
|
+
const tmp = `${path}.tmp.${Date.now()}.${randomBytes(4).toString("hex")}`
|
|
521
|
+
writeFileSync(tmp, data)
|
|
522
|
+
renameSync(tmp, path)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, "-")
|
|
526
|
+
|
|
527
|
+
export interface TaskWriterOptions {
|
|
528
|
+
flavor: Flavor
|
|
529
|
+
/** The editor whose global storage holds the task: "Code" (VS Code), "Cursor" or "Windsurf". */
|
|
530
|
+
app: string
|
|
531
|
+
/** For tests: the installed extension version, and whether a process is running. */
|
|
532
|
+
version?: string
|
|
533
|
+
running?: (process: string) => boolean
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function taskWriter(o: TaskWriterOptions): Writer {
|
|
537
|
+
const host = hostByApp(o.app) as Host
|
|
538
|
+
const f = o.flavor
|
|
539
|
+
const tool = o.app === "Code" ? f.tool : `${f.tool}-${o.app.toLowerCase()}`
|
|
540
|
+
const label = o.app === "Code" ? f.label : `${f.label} (${o.app})`
|
|
541
|
+
const app = o.app === "Code" ? "VS Code" : o.app
|
|
542
|
+
const installed = (home: string) => o.version ?? extensionVersion(home, host.app, f.extension)
|
|
543
|
+
const running = () => (o.running ?? isRunning)(host.process)
|
|
544
|
+
// Cursor's global storage also holds Cursor's own chats, and Windsurf's its own state: never written while they run.
|
|
545
|
+
const mustClose = (version: string) => o.app !== "Code" || usesStateDb(f, version)
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
tool,
|
|
549
|
+
label,
|
|
550
|
+
way: "native",
|
|
551
|
+
|
|
552
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
553
|
+
const v = installed(w.home)
|
|
554
|
+
if (!v) return { ok: false, why: `${f.label} is not installed in ${app}` }
|
|
555
|
+
if (!f.tested.includes(v)) return { ok: false, why: `${f.label} ${v} is not a version this writer was checked against (${f.tested.join(", ")})` }
|
|
556
|
+
if (mustClose(v) && running()) return { ok: false, why: `${app} is running; close it first, since ${usesStateDb(f, v) ? `${f.label} ${v} keeps its task list in ${app}'s state database` : `${app} keeps its own state beside ${f.label}'s tasks`}` }
|
|
557
|
+
if (usesStateDb(f, v) && !exists(stateDb(w.home, host.app))) return { ok: false, why: `${app} has no state database yet (${stateDb(w.home, host.app)}); open ${f.label} in ${app} once first` }
|
|
558
|
+
const custom = f === ROO ? rooCustomStorage(w.home, host.app) : undefined
|
|
559
|
+
if (custom) return { ok: false, why: `${f.label} keeps its tasks in ${custom} (roo-cline.customStoragePath); this writer only writes to ${app}'s own storage` }
|
|
560
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; ${f.label} lists a task under the folder it ran in` }
|
|
561
|
+
return { ok: true }
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
565
|
+
const version = installed(w.home) ?? f.tested.at(-1)!
|
|
566
|
+
const now = new Date()
|
|
567
|
+
const root = storageDir(w.home, host.app, f.extension)
|
|
568
|
+
const tasks = join(root, "tasks")
|
|
569
|
+
let taskId: string
|
|
570
|
+
if (f === ROO) taskId = uuidv7(now.getTime())
|
|
571
|
+
else {
|
|
572
|
+
let n = now.getTime()
|
|
573
|
+
while (exists(join(tasks, String(n)))) n++
|
|
574
|
+
taskId = String(n)
|
|
575
|
+
}
|
|
576
|
+
const dir = join(tasks, taskId)
|
|
577
|
+
const plan = planTask(session, f, { taskId, cwd: w.cwd, now, version })
|
|
578
|
+
|
|
579
|
+
const files: Array<[string, string]> = [
|
|
580
|
+
[join(dir, "api_conversation_history.json"), JSON.stringify(plan.api)],
|
|
581
|
+
[join(dir, "ui_messages.json"), JSON.stringify(plan.ui)],
|
|
582
|
+
]
|
|
583
|
+
if (plan.metadata) files.push([join(dir, "task_metadata.json"), JSON.stringify(plan.metadata, null, 2)])
|
|
584
|
+
const item: Record<string, unknown> = { ...plan.item, size: files.reduce((n, [, data]) => n + Buffer.byteLength(data), 0) }
|
|
585
|
+
const db = usesStateDb(f, version)
|
|
586
|
+
const fileIndex = f === ROO && !db
|
|
587
|
+
if (fileIndex) files.push([join(dir, "history_item.json"), JSON.stringify(item, null, "\t")])
|
|
588
|
+
const index = db ? stateDb(w.home, host.app) : fileIndex ? join(tasks, "_index.json") : join(root, "state", "taskHistory.json")
|
|
589
|
+
const written = [...files.map(([p]) => p), index]
|
|
590
|
+
const result = (): WriteResult => ({
|
|
591
|
+
tool,
|
|
592
|
+
way: "native",
|
|
593
|
+
sessionId: taskId,
|
|
594
|
+
written,
|
|
595
|
+
open: `${host.cli} ${shellQuote(w.cwd)}`,
|
|
596
|
+
note: `In ${app}, open ${f.label}, click History, and pick "${oneLine(String(item.task)).slice(0, 80)}".`,
|
|
597
|
+
lost: plan.lost,
|
|
598
|
+
dryRun: !!w.dryRun,
|
|
599
|
+
})
|
|
600
|
+
if (w.dryRun) return result()
|
|
601
|
+
|
|
602
|
+
if (mustClose(version) && running()) throw new Error(`${app} is running; close it before continuing a session in ${f.label}`)
|
|
603
|
+
if (exists(dir)) throw new Error(`task folder ${dir} already exists`)
|
|
604
|
+
if (db && !exists(index)) throw new Error(`${app} has no state database yet (${index}); open ${f.label} in ${app} once first`)
|
|
605
|
+
mkdirSync(dir, { recursive: true })
|
|
606
|
+
try {
|
|
607
|
+
// "wx": new files only; an existing task is never touched.
|
|
608
|
+
for (const [p, data] of files) writeFileSync(p, data, { flag: "wx" })
|
|
609
|
+
const back = await taskReader({ tool: f.tool, label: f.label, extension: f.extension }).read({ tool: f.tool, sourceId: taskId, path: dir, extra: { host: host.app } }, w.home)
|
|
610
|
+
const pairs = unpaired(back)
|
|
611
|
+
const got = countTurns(back).tool_call
|
|
612
|
+
if (got !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
613
|
+
throw new Error(`the new ${f.label} task did not read back intact (${got} of ${plan.expect.calls} tool calls, ${pairs.calls.length + pairs.results.length} unpaired)`)
|
|
614
|
+
|
|
615
|
+
const backups = join(w.home, ".brain", "backups", f.tool, stamp(now))
|
|
616
|
+
if (db) {
|
|
617
|
+
// VS Code holds this table in memory while it runs and writes it back; it is closed (checked above).
|
|
618
|
+
written.push(...backupDb(index, backups))
|
|
619
|
+
updateState(index, f.stateKey, (state) => {
|
|
620
|
+
const history = Array.isArray(state.taskHistory) ? (state.taskHistory as Array<Record<string, unknown>>) : []
|
|
621
|
+
if (history.some((h) => h?.id === taskId)) throw new Error(`task ${taskId} is already in ${f.label}'s history`)
|
|
622
|
+
state.taskHistory = [...history, item]
|
|
623
|
+
})
|
|
624
|
+
} else if (fileIndex) {
|
|
625
|
+
// history_item.json is what Roo's store reconciles from; its index is a cache, kept in step when there is one.
|
|
626
|
+
const cache = readJson<{ version?: number; entries?: Array<Record<string, unknown>> }>(index)
|
|
627
|
+
if (cache?.version === 1 && Array.isArray(cache.entries)) {
|
|
628
|
+
written.push(backupFile(index, backups))
|
|
629
|
+
const entries = [...cache.entries.filter((e) => e?.id !== taskId), item].sort((a, b) => Number(b.ts ?? 0) - Number(a.ts ?? 0))
|
|
630
|
+
writeAtomic(index, JSON.stringify({ version: 1, updatedAt: now.getTime(), entries }))
|
|
631
|
+
} else written.pop()
|
|
632
|
+
} else {
|
|
633
|
+
const history = readJson<Array<Record<string, unknown>>>(index)
|
|
634
|
+
if (exists(index) && !Array.isArray(history)) throw new Error(`${index} is not a task list; it was left as it is`)
|
|
635
|
+
if (exists(index)) written.push(backupFile(index, backups))
|
|
636
|
+
mkdirSync(dirname(index), { recursive: true })
|
|
637
|
+
writeAtomic(index, JSON.stringify([...(history ?? []), item]))
|
|
638
|
+
}
|
|
639
|
+
} catch (e) {
|
|
640
|
+
rmSync(dir, { recursive: true, force: true })
|
|
641
|
+
throw e
|
|
642
|
+
}
|
|
643
|
+
return result()
|
|
644
|
+
},
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function backupFile(path: string, dir: string): string {
|
|
649
|
+
mkdirSync(dir, { recursive: true })
|
|
650
|
+
const copy = join(dir, path.split("/").at(-1)!)
|
|
651
|
+
copyFileSync(path, copy)
|
|
652
|
+
return copy
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** One writer per extension and editor: VS Code under the extension's own id, the others suffixed. */
|
|
656
|
+
export const taskWriters: Writer[] = [CLINE, ROO].flatMap((flavor) => ["Code", "Cursor", "Windsurf"].map((app) => taskWriter({ flavor, app })))
|