@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,418 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { toolLabel } from "../../brief.ts"
|
|
4
|
+
import type { Session, ToolKind, Turn } from "../../session/types.ts"
|
|
5
|
+
import { asText, clip, firstUserText, header, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
|
|
6
|
+
import { CONTINUED_NOTE, continuedNote } from "../claude-code/records.ts"
|
|
7
|
+
import { imageData } from "../gemini-cli/exchanges.ts"
|
|
8
|
+
import { fileUri } from "../vscode-copilot/writer.ts"
|
|
9
|
+
import { ARG, type ToolMap, asArgs, mapTool, pick, safeToolName } from "../vscode-copilot/shared.ts"
|
|
10
|
+
|
|
11
|
+
// Continue's session store, which its VS Code and JetBrains extensions and its `cn` CLI share, and the
|
|
12
|
+
// older copy of it PearAI's chat still uses. Checked against continuedev/continue main (core/util/history.ts,
|
|
13
|
+
// gui/src/redux/util/constructMessages.ts, core/util/messageConversion.ts, extensions/cli/src/session.ts
|
|
14
|
+
// and compaction.ts) and trypear/pearai-submodule main (core/util/history.ts, core/llm/constructMessages.ts):
|
|
15
|
+
//
|
|
16
|
+
// - <dir>/sessions/<sessionId>.json: {sessionId, title, workspaceDirectory, history: ChatHistoryItem[], mode?}.
|
|
17
|
+
// - <dir>/sessions/sessions.json: the list both history views and `cn ls` read, in creation order, one
|
|
18
|
+
// {sessionId, title, dateCreated (epoch ms as a string), workspaceDirectory, messageCount} per session.
|
|
19
|
+
// Entries with a string `session_id` are the pre-2024 format and are skipped.
|
|
20
|
+
//
|
|
21
|
+
// A history item is {message, contextItems, ...}. In Continue an assistant message carries its tool calls
|
|
22
|
+
// (message.toolCalls) and the item their states and outputs (toolCallStates); the model is sent one tool
|
|
23
|
+
// message per state, rebuilt from it, so no separate tool items are written (the CLI would send them twice).
|
|
24
|
+
// The note that the session was carried over rides as a context item on a user message: both extensions
|
|
25
|
+
// and the CLI send a context item's content to the model ahead of the message, and the chat shows it as an
|
|
26
|
+
// attachment, not as the person's words. A compaction is an assistant item whose `conversationSummary` is
|
|
27
|
+
// set, as the CLI writes one: the GUI sends only what follows the last such item, with the summary in the
|
|
28
|
+
// system message.
|
|
29
|
+
|
|
30
|
+
type Json = Record<string, any>
|
|
31
|
+
const isObj = (v: unknown): v is Json => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
32
|
+
/** The index of the last element matching `ok`, or -1 (the project's lib predates findLastIndex). */
|
|
33
|
+
function lastIndex<T>(xs: T[], ok: (x: T) => boolean): number {
|
|
34
|
+
for (let i = xs.length - 1; i >= 0; i--) if (ok(xs[i]!)) return i
|
|
35
|
+
return -1
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What differs between Continue's store and PearAI's older copy of it. */
|
|
39
|
+
export interface Flavor {
|
|
40
|
+
tool: string
|
|
41
|
+
label: string
|
|
42
|
+
/** The folder under home: CONTINUE_GLOBAL_DIR's default. */
|
|
43
|
+
dir: string
|
|
44
|
+
/** Continue's format: tool calls, thinking and summaries as records. PearAI's messages are text only. */
|
|
45
|
+
structured: boolean
|
|
46
|
+
/** Continue's VS Code extension stores the workspace as a file:// URI; PearAI and the CLI as a path. */
|
|
47
|
+
uriWorkspace: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CONTINUE: Flavor = { tool: "continue", label: "Continue", dir: ".continue", structured: true, uriWorkspace: true }
|
|
51
|
+
export const PEARAI: Flavor = { tool: "pearai", label: "PearAI", dir: ".pearai", structured: false, uriWorkspace: false }
|
|
52
|
+
|
|
53
|
+
export const sessionsDir = (home: string, f: Flavor) => join(home, f.dir, "sessions")
|
|
54
|
+
export const listPath = (home: string, f: Flavor) => join(sessionsDir(home, f), "sessions.json")
|
|
55
|
+
|
|
56
|
+
const BRAIN_PROVIDER = "brain"
|
|
57
|
+
|
|
58
|
+
/** Continue's own tool names (core/tools/builtIn.ts) by what they do; others fall back to the shared guess. */
|
|
59
|
+
const KIND: Record<string, ToolKind> = {
|
|
60
|
+
read_file: "read",
|
|
61
|
+
read_file_range: "read",
|
|
62
|
+
read_currently_open_file: "read",
|
|
63
|
+
read_skill: "read",
|
|
64
|
+
edit_existing_file: "edit",
|
|
65
|
+
single_find_and_replace: "edit",
|
|
66
|
+
multi_edit: "edit",
|
|
67
|
+
create_new_file: "write",
|
|
68
|
+
run_terminal_command: "shell",
|
|
69
|
+
grep_search: "search",
|
|
70
|
+
file_glob_search: "search",
|
|
71
|
+
codebase: "search",
|
|
72
|
+
search_web: "web",
|
|
73
|
+
fetch_url_content: "web",
|
|
74
|
+
ls: "list",
|
|
75
|
+
view_subdirectory: "list",
|
|
76
|
+
}
|
|
77
|
+
export const continueKind = (name: string): ToolKind => KIND[name] ?? toolKindOf(name)
|
|
78
|
+
|
|
79
|
+
/** A message's text: a string, or the text parts of a part list. */
|
|
80
|
+
export function messageText(content: unknown): { text: string; images: number } {
|
|
81
|
+
if (typeof content === "string") return { text: content, images: 0 }
|
|
82
|
+
if (!Array.isArray(content)) return { text: "", images: 0 }
|
|
83
|
+
let images = 0
|
|
84
|
+
const text = content
|
|
85
|
+
.map((p) => {
|
|
86
|
+
if (isObj(p) && p.type === "text" && typeof p.text === "string") return p.text
|
|
87
|
+
if (isObj(p) && p.type === "imageUrl") images++
|
|
88
|
+
return ""
|
|
89
|
+
})
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join("\n")
|
|
92
|
+
return { text, images }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A workspace folder as a path, whether stored as a path or a file:// URI. */
|
|
96
|
+
export function workspacePath(v: unknown): string | undefined {
|
|
97
|
+
if (typeof v !== "string" || !v) return undefined
|
|
98
|
+
if (!v.startsWith("file://")) return v
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(new URL(v).pathname)
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Reading ---------------------------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** One stored session as a portable session. `raw` is the parsed <sessionId>.json. */
|
|
109
|
+
export function toPortable(raw: Json, f: Flavor, path: string, meta: { dateCreated?: string; mtime?: string } = {}): Session {
|
|
110
|
+
const lost = lossCounter()
|
|
111
|
+
const cwd = workspacePath(raw.workspaceDirectory)
|
|
112
|
+
const created = meta.dateCreated && /^\d+$/.test(meta.dateCreated) ? new Date(Number(meta.dateCreated)).toISOString() : undefined
|
|
113
|
+
const h = header(f.tool, String(raw.sessionId), {
|
|
114
|
+
...(typeof raw.title === "string" ? { title: raw.title } : {}),
|
|
115
|
+
sourcePath: path,
|
|
116
|
+
project: cwd ? { cwd } : {},
|
|
117
|
+
...(created ? { startedAt: created } : {}),
|
|
118
|
+
...(meta.mtime ? { updatedAt: meta.mtime } : {}),
|
|
119
|
+
})
|
|
120
|
+
if (typeof raw.chatModelTitle === "string") noteModel(h, raw.chatModelTitle)
|
|
121
|
+
const turns: Turn[] = []
|
|
122
|
+
const history: Json[] = Array.isArray(raw.history) ? raw.history.filter(isObj) : []
|
|
123
|
+
// Results the GUI also keeps as separate tool items, for calls whose state has no output.
|
|
124
|
+
const toolItems = new Map<string, string>()
|
|
125
|
+
for (const item of history) if (item.message?.role === "tool" && typeof item.message.toolCallId === "string") toolItems.set(item.message.toolCallId, messageText(item.message.content).text)
|
|
126
|
+
const answered = new Set<string>()
|
|
127
|
+
|
|
128
|
+
for (const item of history) {
|
|
129
|
+
const m = item.message
|
|
130
|
+
if (!isObj(m)) continue
|
|
131
|
+
const { text, images } = messageText(m.content)
|
|
132
|
+
if (images) lost.add("images", images)
|
|
133
|
+
if (m.role === "user") {
|
|
134
|
+
const ctx = Array.isArray(item.contextItems) ? item.contextItems.filter(isObj) : []
|
|
135
|
+
const attachments = ctx
|
|
136
|
+
.filter((c) => c.id?.providerTitle !== BRAIN_PROVIDER)
|
|
137
|
+
.map((c) => (c.uri?.type === "file" && typeof c.uri.value === "string" ? workspacePath(c.uri.value) : undefined))
|
|
138
|
+
const kept = attachments.filter((p): p is string => !!p)
|
|
139
|
+
if (attachments.length > kept.length) lost.add("context items that are not files", attachments.length - kept.length)
|
|
140
|
+
// brain's note, as a context item or as a request of its own, says which session this continues.
|
|
141
|
+
const from = ctx.find((c) => c.id?.providerTitle === BRAIN_PROVIDER)?.id?.itemId ?? text.trim().match(CONTINUED_NOTE)?.[1]
|
|
142
|
+
if (from && !h.parent) h.parent = { id: String(from), kind: "continued" }
|
|
143
|
+
if (CONTINUED_NOTE.test(text.trim())) continue
|
|
144
|
+
if (text.trim() || kept.length) turns.push({ kind: "user", text, ...(kept.length ? { attachments: kept.map((p) => ({ kind: "file" as const, path: p })) } : {}) })
|
|
145
|
+
} else if (m.role === "thinking") {
|
|
146
|
+
if (m.signature || m.redactedThinking) lost.add("signed reasoning")
|
|
147
|
+
else if (text.trim()) turns.push({ kind: "reasoning", text })
|
|
148
|
+
} else if (m.role === "assistant") {
|
|
149
|
+
if (isObj(item.reasoning) && typeof item.reasoning.text === "string" && item.reasoning.text.trim()) turns.push({ kind: "reasoning", text: item.reasoning.text })
|
|
150
|
+
// The CLI's compaction item holds the summary as its text too; keep it once, as the compaction.
|
|
151
|
+
const summary = typeof item.conversationSummary === "string" ? item.conversationSummary : undefined
|
|
152
|
+
if (text.trim() && text !== summary) turns.push({ kind: "assistant", text })
|
|
153
|
+
const states = new Map<string, Json>()
|
|
154
|
+
for (const s of Array.isArray(item.toolCallStates) ? item.toolCallStates.filter(isObj) : []) states.set(String(s.toolCallId), s)
|
|
155
|
+
for (const call of Array.isArray(m.toolCalls) ? m.toolCalls.filter(isObj) : []) {
|
|
156
|
+
const id = String(call.id ?? "")
|
|
157
|
+
const name = String(call.function?.name ?? "")
|
|
158
|
+
if (!id || !name) {
|
|
159
|
+
lost.add("tool calls without an id or name")
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
const state = states.get(id)
|
|
163
|
+
let input: unknown = state?.parsedArgs
|
|
164
|
+
if (input === undefined) {
|
|
165
|
+
try {
|
|
166
|
+
input = JSON.parse(String(call.function?.arguments || "{}"))
|
|
167
|
+
} catch {
|
|
168
|
+
input = { arguments: String(call.function?.arguments ?? "") }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
turns.push({ kind: "tool_call", id, name, toolKind: continueKind(name), input })
|
|
172
|
+
const outputs = Array.isArray(state?.output) ? state.output.filter(isObj) : []
|
|
173
|
+
const out = outputs.length ? outputs.map((o) => asText(o.content)).join("\n\n") : toolItems.get(id)
|
|
174
|
+
const status = state?.status
|
|
175
|
+
if (status === "canceled") turns.push({ kind: "tool_result", callId: id, ok: false, output: "The user cancelled this tool call." })
|
|
176
|
+
else if (out !== undefined || status === "errored" || status === "done") {
|
|
177
|
+
const c = clip(out ?? (status === "errored" ? "There was an error calling the tool." : "No tool output"))
|
|
178
|
+
turns.push({ kind: "tool_result", callId: id, ok: status !== "errored", output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
179
|
+
} else lost.add("tool calls still running when the session was saved")
|
|
180
|
+
answered.add(id)
|
|
181
|
+
}
|
|
182
|
+
if (summary) turns.push({ kind: "compaction", summary })
|
|
183
|
+
} else if (m.role === "tool") {
|
|
184
|
+
if (!answered.has(String(m.toolCallId))) lost.add("tool results without a call")
|
|
185
|
+
}
|
|
186
|
+
if (m.role !== "assistant" && typeof item.conversationSummary === "string") turns.push({ kind: "compaction", summary: item.conversationSummary })
|
|
187
|
+
}
|
|
188
|
+
lost.flush(h)
|
|
189
|
+
return { header: h, turns }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Writing ---------------------------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
// Continue's own tools (core/tools/definitions), where the arguments map cleanly.
|
|
195
|
+
const TOOLS: ToolMap = {
|
|
196
|
+
read: (i) => {
|
|
197
|
+
const filepath = pick(i, ARG.path)
|
|
198
|
+
return filepath ? { name: "read_file", args: { filepath } } : undefined
|
|
199
|
+
},
|
|
200
|
+
write: (i) => {
|
|
201
|
+
const filepath = pick(i, ARG.path)
|
|
202
|
+
const contents = pick(i, ARG.content)
|
|
203
|
+
return filepath && contents !== undefined ? { name: "create_new_file", args: { filepath, contents } } : undefined
|
|
204
|
+
},
|
|
205
|
+
edit: (i) => {
|
|
206
|
+
const filepath = pick(i, ARG.path)
|
|
207
|
+
const old_string = pick(i, ARG.old)
|
|
208
|
+
const new_string = pick(i, ARG.new)
|
|
209
|
+
return filepath && old_string !== undefined && new_string !== undefined ? { name: "single_find_and_replace", args: { filepath, old_string, new_string } } : undefined
|
|
210
|
+
},
|
|
211
|
+
shell: (i) => {
|
|
212
|
+
const command = pick(i, ARG.command)
|
|
213
|
+
return command ? { name: "run_terminal_command", args: { command, waitForCompletion: true } } : undefined
|
|
214
|
+
},
|
|
215
|
+
list: (i) => {
|
|
216
|
+
const dirPath = pick(i, ARG.dir)
|
|
217
|
+
return dirPath ? { name: "ls", args: { dirPath, recursive: false } } : undefined
|
|
218
|
+
},
|
|
219
|
+
search: (i) => {
|
|
220
|
+
const query = pick(i, ARG.query)
|
|
221
|
+
return query ? { name: "grep_search", args: { query } } : undefined
|
|
222
|
+
},
|
|
223
|
+
web: (i) => {
|
|
224
|
+
const url = pick(i, ARG.url)
|
|
225
|
+
return url ? { name: "fetch_url_content", args: { url } } : undefined
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
230
|
+
|
|
231
|
+
export interface ContinuePlan {
|
|
232
|
+
/** The <sessionId>.json Continue would write. */
|
|
233
|
+
file: Json
|
|
234
|
+
/** The session's entry in sessions.json. */
|
|
235
|
+
entry: Json
|
|
236
|
+
lost: string[]
|
|
237
|
+
expect: { users: number; calls: number }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The session Continue (or PearAI) would store for `session` continued as `sessionId`. Pure. */
|
|
241
|
+
export function planContinue(session: Session, f: Flavor, opts: { sessionId: string; cwd: string; now: Date }): ContinuePlan {
|
|
242
|
+
const label = toolLabel(session.header.tool)
|
|
243
|
+
const drops = new Map<string, number>()
|
|
244
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
245
|
+
const note = continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))
|
|
246
|
+
const id = () => randomUUID()
|
|
247
|
+
const results = new Map<string, Result>()
|
|
248
|
+
const callIds = new Set<string>()
|
|
249
|
+
for (const t of session.turns) {
|
|
250
|
+
if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
251
|
+
if (t.kind === "tool_call") callIds.add(t.id)
|
|
252
|
+
}
|
|
253
|
+
// Without a summary record, PearAI starts from the last compaction, with its summary in the note.
|
|
254
|
+
let turns = session.turns
|
|
255
|
+
let carried: string | undefined
|
|
256
|
+
if (!f.structured) {
|
|
257
|
+
const last = lastIndex(turns, (t) => t.kind === "compaction")
|
|
258
|
+
if (last >= 0) {
|
|
259
|
+
const before = turns.slice(0, last).filter((t) => t.kind === "user" || t.kind === "assistant" || t.kind === "tool_call").length
|
|
260
|
+
if (before) drop("messages and tool calls before the last compaction (its summary is kept)", before)
|
|
261
|
+
carried = (turns[last] as Extract<Turn, { kind: "compaction" }>).summary
|
|
262
|
+
turns = turns.slice(last + 1)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const ids = new Map<string, string>()
|
|
266
|
+
const callId = (src: string) => {
|
|
267
|
+
let v = ids.get(src)
|
|
268
|
+
if (!v) {
|
|
269
|
+
v = /^[A-Za-z0-9_-]{1,64}$/.test(src) && ![...ids.values()].includes(src) ? src : `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`
|
|
270
|
+
ids.set(src, v)
|
|
271
|
+
}
|
|
272
|
+
return v
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const history: Json[] = []
|
|
276
|
+
let assistant: Json | undefined
|
|
277
|
+
let users = 0
|
|
278
|
+
let calls = 0
|
|
279
|
+
const seen = new Set<string>()
|
|
280
|
+
let noteItem: Json | undefined
|
|
281
|
+
const pushUser = (text: string, attachments: string[] = [], images: Array<{ mimeType: string; data: string }> = []) => {
|
|
282
|
+
// Pasted images are imageUrl parts (data URLs), the shape Continue stores them in.
|
|
283
|
+
const content = images.length ? [...(text.trim() ? [{ type: "text", text }] : []), ...images.map((i) => ({ type: "imageUrl", imageUrl: { url: `data:${i.mimeType};base64,${i.data}` } }))] : text
|
|
284
|
+
history.push({ message: { role: "user", content, id: id() }, contextItems: attachments.map((p) => ({ name: p.split("/").pop(), description: p, content: `[Attached file: ${p}]`, uri: { type: "file", value: fileUri(p) }, id: { providerTitle: "file", itemId: id() } })) })
|
|
285
|
+
assistant = undefined
|
|
286
|
+
}
|
|
287
|
+
const openAssistant = (): Json => {
|
|
288
|
+
if (!assistant) {
|
|
289
|
+
// Assistant output before any request: the carried-over note stands in for the person's words.
|
|
290
|
+
if (!history.some((h) => h.message.role === "user")) {
|
|
291
|
+
pushUser(note)
|
|
292
|
+
noteItem = history.at(-1)
|
|
293
|
+
}
|
|
294
|
+
assistant = { message: { role: "assistant", content: "", id: id() }, contextItems: [] }
|
|
295
|
+
history.push(assistant)
|
|
296
|
+
}
|
|
297
|
+
return assistant
|
|
298
|
+
}
|
|
299
|
+
const textOf = (item: Json, extra: string) => {
|
|
300
|
+
if (Array.isArray(item.message.content)) {
|
|
301
|
+
const part = item.message.content.find((p: Json) => p.type === "text")
|
|
302
|
+
if (part) part.text = `${part.text}\n\n${extra}`
|
|
303
|
+
else item.message.content.unshift({ type: "text", text: extra })
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
const cur = String(item.message.content ?? "")
|
|
307
|
+
item.message.content = cur ? `${cur}\n\n${extra}` : extra
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const t of turns) {
|
|
311
|
+
switch (t.kind) {
|
|
312
|
+
case "user": {
|
|
313
|
+
const files = (t.attachments ?? []).filter((a) => a.kind === "file" && a.path).map((a) => a.path!)
|
|
314
|
+
const images = (t.attachments ?? []).flatMap((a) => (a.kind === "image" ? [imageData(a)] : [])).filter((i): i is { mimeType: string; data: string } => !!i)
|
|
315
|
+
const unusable = (t.attachments?.length ?? 0) - files.length - images.length
|
|
316
|
+
if (unusable > 0) drop("attachments that are not files or readable images", unusable)
|
|
317
|
+
if (!t.text.trim() && !files.length && !images.length) break
|
|
318
|
+
pushUser(t.text, f.structured ? files : [], images)
|
|
319
|
+
if (!f.structured && files.length) textOf(history.at(-1)!, files.map((p) => `[Attached file: ${p}]`).join("\n"))
|
|
320
|
+
users++
|
|
321
|
+
break
|
|
322
|
+
}
|
|
323
|
+
case "assistant": {
|
|
324
|
+
if (!t.text.trim()) break
|
|
325
|
+
// Text after a tool call is the model's next reply.
|
|
326
|
+
if (assistant?.message.toolCalls?.length) assistant = undefined
|
|
327
|
+
textOf(openAssistant(), t.text)
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
case "reasoning":
|
|
331
|
+
if (!t.text.trim()) break
|
|
332
|
+
if (f.structured) {
|
|
333
|
+
// Continue keeps readable thinking as its own message; providers that need a signature skip it.
|
|
334
|
+
history.push({ message: { role: "thinking", content: t.text, id: id() }, contextItems: [] })
|
|
335
|
+
assistant = undefined
|
|
336
|
+
} else drop("reasoning")
|
|
337
|
+
break
|
|
338
|
+
case "tool_call": {
|
|
339
|
+
if (seen.has(t.id)) {
|
|
340
|
+
drop("repeated tool call ids")
|
|
341
|
+
break
|
|
342
|
+
}
|
|
343
|
+
seen.add(t.id)
|
|
344
|
+
const res = results.get(t.id)
|
|
345
|
+
if (!f.structured) {
|
|
346
|
+
// PearAI's messages have no tool calls: the call and its result are kept as the reply's text.
|
|
347
|
+
let out = res ? res.output : "(no result was recorded)"
|
|
348
|
+
if (res?.fullPath) out += `\n[Full output: ${res.fullPath}]`
|
|
349
|
+
textOf(openAssistant(), `[Tool call ${t.name}: ${JSON.stringify(t.input)}]\n[Result${res && !res.ok ? " (error)" : ""}: ${out}]`)
|
|
350
|
+
drop("tool calls (kept as text)")
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
const a = openAssistant()
|
|
354
|
+
const mapped = mapTool(TOOLS, t.toolKind, t.input)
|
|
355
|
+
const name = mapped?.name ?? safeToolName(t.name)
|
|
356
|
+
const args = mapped?.args ?? asArgs(t.input)
|
|
357
|
+
const cid = callId(t.id)
|
|
358
|
+
const toolCall = { id: cid, type: "function", function: { name, arguments: JSON.stringify(args) } }
|
|
359
|
+
a.message.toolCalls = [...(a.message.toolCalls ?? []), toolCall]
|
|
360
|
+
let output = res?.output
|
|
361
|
+
if (output !== undefined && res?.fullPath) output += `\n\n[Full output: ${res.fullPath}]`
|
|
362
|
+
if (!res) drop("tool calls without a result (kept as cancelled)")
|
|
363
|
+
a.toolCallStates = [
|
|
364
|
+
...(a.toolCallStates ?? []),
|
|
365
|
+
{
|
|
366
|
+
toolCallId: cid,
|
|
367
|
+
toolCall,
|
|
368
|
+
status: !res ? "canceled" : res.ok ? "done" : "errored",
|
|
369
|
+
parsedArgs: args,
|
|
370
|
+
...(output !== undefined ? { output: [{ name: name === "run_terminal_command" ? "Terminal" : name, description: "Tool output", content: output }] } : {}),
|
|
371
|
+
},
|
|
372
|
+
]
|
|
373
|
+
calls++
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
case "tool_result":
|
|
377
|
+
if (!callIds.has(t.callId)) drop("tool results without a call")
|
|
378
|
+
break
|
|
379
|
+
case "compaction": {
|
|
380
|
+
if (!f.structured) break
|
|
381
|
+
// As the CLI stores one: an assistant item holding the summary, marked as the cut.
|
|
382
|
+
for (const h of history) delete h.conversationSummary
|
|
383
|
+
history.push({ message: { role: "assistant", content: t.summary, id: id() }, contextItems: [], conversationSummary: t.summary })
|
|
384
|
+
assistant = undefined
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
case "file_edit":
|
|
388
|
+
if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
|
|
389
|
+
break
|
|
390
|
+
case "subagent":
|
|
391
|
+
drop("subagent sessions (their call and result are kept)")
|
|
392
|
+
break
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!history.length) throw new Error("nothing to continue: the session has no messages")
|
|
396
|
+
|
|
397
|
+
// The note goes on the first user message the model still sees: after the last summary, if any.
|
|
398
|
+
const cut = lastIndex(history, (h) => h.conversationSummary !== undefined)
|
|
399
|
+
const target = history.findIndex((h, i) => i > cut && h.message.role === "user")
|
|
400
|
+
const noteText = carried ? `${note}\n\nSummary of the earlier conversation:\n${carried}` : note
|
|
401
|
+
if (target < 0) history.splice(cut + 1, 0, { message: { role: "user", content: noteText, id: id() }, contextItems: [] })
|
|
402
|
+
else if (noteItem && history[target] === noteItem) noteItem.message.content = noteText
|
|
403
|
+
else history[target]!.contextItems = [{ name: `Continued from ${label}`, description: `Continued from ${label} session ${session.header.sourceId}`, content: noteText, id: { providerTitle: BRAIN_PROVIDER, itemId: session.header.id } }, ...history[target]!.contextItems]
|
|
404
|
+
|
|
405
|
+
const title = `${(session.header.title ?? firstUserText(session) ?? "Session").replace(/\s+/g, " ").trim().slice(0, 80)} (from ${label})`
|
|
406
|
+
const workspaceDirectory = f.uriWorkspace ? fileUri(opts.cwd) : opts.cwd
|
|
407
|
+
const file: Json = { sessionId: opts.sessionId, title, workspaceDirectory, history }
|
|
408
|
+
if (f.structured) file.mode = "agent"
|
|
409
|
+
else file.perplexityHistory = []
|
|
410
|
+
const entry: Json = { sessionId: opts.sessionId, title, dateCreated: String(opts.now.getTime()), workspaceDirectory }
|
|
411
|
+
if (f.structured) entry.messageCount = history.filter((h) => h.message.role === "assistant").length
|
|
412
|
+
else entry.integrationType = "continue"
|
|
413
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
414
|
+
const remapped = [...ids].filter(([a, b]) => a !== b).length
|
|
415
|
+
if (remapped) lost.push(`tool call ids, replaced with ids every provider accepts (${remapped})`)
|
|
416
|
+
return { file, entry, lost, expect: { users, calls } }
|
|
417
|
+
}
|
|
418
|
+
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { exists, isDir, readJson } from "../../fsutil.ts"
|
|
5
|
+
import type { Session } from "../../session/types.ts"
|
|
6
|
+
import { countTurns, unpaired } from "../../session/util.ts"
|
|
7
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
8
|
+
import { backup, shellQuote } from "../vscode-copilot/shared.ts"
|
|
9
|
+
import { continueReader } from "./reader.ts"
|
|
10
|
+
import { CONTINUE, type Flavor, PEARAI, listPath, planContinue, sessionsDir } from "./records.ts"
|
|
11
|
+
|
|
12
|
+
// Continues a portable session as a new Continue (or PearAI) chat: the session file the HistoryManager
|
|
13
|
+
// loads, and its entry appended to sessions.json, which both history views and `cn ls` list from. The
|
|
14
|
+
// list is read, changed and written back the way HistoryManager.save does it, after a backup; the
|
|
15
|
+
// extensions re-read the file each time they list or save, so the editor may stay open.
|
|
16
|
+
|
|
17
|
+
/** Continue builds this writer was checked against: the VS Code/JetBrains extension and the `cn` CLI. */
|
|
18
|
+
export const TESTED_CONTINUE = { extension: ["1.2", "1.3", "2.0"], cli: ["1.5"] }
|
|
19
|
+
/** PearAI's chat extension (pearai.pearai), from trypear/pearai-submodule. */
|
|
20
|
+
export const TESTED_PEARAI = ["2.0"]
|
|
21
|
+
|
|
22
|
+
const line = (v: string | undefined) => v?.match(/\d+\.\d+/)?.[0]
|
|
23
|
+
|
|
24
|
+
/** Versions of an extension in VS Code-style extension folders under `home` (extensions.json, else folder names). */
|
|
25
|
+
function extensionVersions(home: string, folders: string[], id: string): string[] {
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
for (const folder of folders) {
|
|
28
|
+
const dir = join(home, folder, "extensions")
|
|
29
|
+
const listed = readJson<Array<{ identifier?: { id?: string }; version?: string }>>(join(dir, "extensions.json"))
|
|
30
|
+
if (Array.isArray(listed)) {
|
|
31
|
+
for (const e of listed) if (e.identifier?.id?.toLowerCase() === id && e.version) out.push(e.version)
|
|
32
|
+
} else if (isDir(dir)) {
|
|
33
|
+
for (const name of readdirSync(dir)) {
|
|
34
|
+
const m = name.toLowerCase().startsWith(`${id}-`) ? name.slice(id.length + 1).match(/^\d+\.\d+\.\d+/) : null
|
|
35
|
+
if (m) out.push(m[0])
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `cn` CLI's version, from the package it was installed from: running it would start its telemetry. */
|
|
43
|
+
function cliVersion(): string | undefined {
|
|
44
|
+
const bin = Bun.which("cn", { PATH: process.env.PATH ?? "" }) // Bun.which does not see a PATH changed after start
|
|
45
|
+
if (!bin) return undefined
|
|
46
|
+
try {
|
|
47
|
+
const pkg = readJson<{ name?: string; version?: string }>(join(dirname(realpathSync(bin)), "..", "package.json"))
|
|
48
|
+
return pkg?.name === "@continuedev/cli" ? pkg.version : undefined
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Installed {
|
|
55
|
+
/** Continue extension versions, with the editor they are in. */
|
|
56
|
+
extensions: Array<{ editor: "code" | "cursor" | "pearai"; version: string }>
|
|
57
|
+
cli?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What is installed: Continue in VS Code or Cursor and the `cn` CLI, or PearAI's chat extension. */
|
|
61
|
+
export function installed(home: string, f: Flavor): Installed {
|
|
62
|
+
if (f.tool === PEARAI.tool) {
|
|
63
|
+
const v = extensionVersions(home, [".pearai"], "pearai.pearai")
|
|
64
|
+
const app = ["/Applications", join(home, "Applications")].some((d) => exists(join(d, "PearAI.app")))
|
|
65
|
+
return { extensions: v.length ? v.map((version) => ({ editor: "pearai", version })) : app ? [{ editor: "pearai", version: "unknown" }] : [] }
|
|
66
|
+
}
|
|
67
|
+
const extensions = [
|
|
68
|
+
...extensionVersions(home, [".vscode"], "continue.continue").map((version) => ({ editor: "code" as const, version })),
|
|
69
|
+
...extensionVersions(home, [".cursor"], "continue.continue").map((version) => ({ editor: "cursor" as const, version })),
|
|
70
|
+
]
|
|
71
|
+
const cli = cliVersion()
|
|
72
|
+
return { extensions, ...(cli ? { cli } : {}) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The sessions.json list, or [] when there is none; throws on a list Continue itself would refuse. */
|
|
76
|
+
function readListStrict(path: string): unknown[] {
|
|
77
|
+
if (!exists(path)) return []
|
|
78
|
+
const text = readFileSync(path, "utf8")
|
|
79
|
+
if (!text.trim()) return []
|
|
80
|
+
let v: unknown
|
|
81
|
+
try {
|
|
82
|
+
v = JSON.parse(text)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`${path} is not valid JSON; Continue refuses to save sessions until it is fixed, so this writer does too`)
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(v)) throw new Error(`${path} is not a list of sessions`)
|
|
87
|
+
return v
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The Continue or PearAI writer; `found` is for tests, which must not depend on what is installed. */
|
|
91
|
+
export function continueWriter(f: Flavor, opts: { found?: (home: string) => Installed } = {}): Writer {
|
|
92
|
+
const find = opts.found ?? ((home: string) => installed(home, f))
|
|
93
|
+
const read = continueReader(f)
|
|
94
|
+
return {
|
|
95
|
+
tool: f.tool,
|
|
96
|
+
label: f.label,
|
|
97
|
+
way: "native",
|
|
98
|
+
|
|
99
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
100
|
+
const i = find(w.home)
|
|
101
|
+
if (f.tool === PEARAI.tool) {
|
|
102
|
+
if (!i.extensions.length) return { ok: false, why: "PearAI is not installed (no PearAI.app and no pearai.pearai extension under ~/.pearai)" }
|
|
103
|
+
if (!i.extensions.some((e) => TESTED_PEARAI.includes(line(e.version) ?? ""))) return { ok: false, why: `PearAI's chat extension ${i.extensions.map((e) => e.version).join(", ")} is not a version this writer was checked against (${TESTED_PEARAI.join(", ")})` }
|
|
104
|
+
return { ok: true }
|
|
105
|
+
}
|
|
106
|
+
if (!i.extensions.length && !i.cli) return { ok: false, why: "Continue is not installed (no continue.continue extension in VS Code or Cursor, and no cn CLI)" }
|
|
107
|
+
const tested = i.extensions.some((e) => TESTED_CONTINUE.extension.includes(line(e.version) ?? "")) || TESTED_CONTINUE.cli.includes(line(i.cli) ?? "")
|
|
108
|
+
if (!tested) {
|
|
109
|
+
const have = [...i.extensions.map((e) => `extension ${e.version}`), ...(i.cli ? [`cn ${i.cli}`] : [])].join(", ")
|
|
110
|
+
return { ok: false, why: `Continue ${have} is not a version this writer was checked against (extension ${TESTED_CONTINUE.extension.join(", ")}; cn ${TESTED_CONTINUE.cli.join(", ")})` }
|
|
111
|
+
}
|
|
112
|
+
return { ok: true }
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
116
|
+
const sessionId = randomUUID()
|
|
117
|
+
const dir = sessionsDir(w.home, f)
|
|
118
|
+
const file = join(dir, `${sessionId}.json`)
|
|
119
|
+
const list = listPath(w.home, f)
|
|
120
|
+
const entries = readListStrict(list)
|
|
121
|
+
const plan = planContinue(session, f, { sessionId, cwd: w.cwd, now: new Date() })
|
|
122
|
+
let backedUp: string | undefined
|
|
123
|
+
if (!w.dryRun) {
|
|
124
|
+
mkdirSync(dir, { recursive: true })
|
|
125
|
+
// Continue writes the session with two-space indentation, PearAI on one line.
|
|
126
|
+
writeFileSync(file, f.structured ? JSON.stringify(plan.file, undefined, 2) : JSON.stringify(plan.file), { flag: "wx" })
|
|
127
|
+
try {
|
|
128
|
+
const back = await read.read({ tool: f.tool, sourceId: sessionId, path: file }, w.home)
|
|
129
|
+
const n = countTurns(back)
|
|
130
|
+
const pairs = unpaired(back)
|
|
131
|
+
if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
132
|
+
throw new Error(`the new session did not read back intact (${n.user} of ${plan.expect.users} messages, ${n.tool_call} of ${plan.expect.calls} tool calls, ${pairs.calls.length + pairs.results.length} unpaired)`)
|
|
133
|
+
if (exists(list)) backedUp = backup(w.home, f.tool, [list])
|
|
134
|
+
const next = [...entries, plan.entry]
|
|
135
|
+
writeFileSync(list, f.structured ? JSON.stringify(next, undefined, 2) : JSON.stringify(next))
|
|
136
|
+
} catch (e) {
|
|
137
|
+
rmSync(file, { force: true })
|
|
138
|
+
throw e
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const i = find(w.home)
|
|
142
|
+
const editor = f.tool === PEARAI.tool ? "pearai" : (i?.extensions.find((e) => e.editor === "code") ?? i?.extensions[0])?.editor
|
|
143
|
+
const open =
|
|
144
|
+
f.tool === PEARAI.tool ? `open -a PearAI ${shellQuote(w.cwd)}`
|
|
145
|
+
: editor === "code" || editor === "cursor" ? `${editor} ${shellQuote(w.cwd)}`
|
|
146
|
+
: "cn ls"
|
|
147
|
+
const where = f.tool === PEARAI.tool ? "In PearAI Chat, open History" : "In Continue, open History"
|
|
148
|
+
return {
|
|
149
|
+
tool: f.tool,
|
|
150
|
+
way: "native",
|
|
151
|
+
sessionId,
|
|
152
|
+
written: [file, list],
|
|
153
|
+
open,
|
|
154
|
+
note: `${where} and pick "${plan.entry.title}".${f.tool === CONTINUE.tool ? " In a terminal, `cn ls` lists it too." : ""}${backedUp ? ` sessions.json was backed up to ${backedUp}.` : ""}`,
|
|
155
|
+
lost: plan.lost,
|
|
156
|
+
dryRun: !!w.dryRun,
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const continueNativeWriter = continueWriter(CONTINUE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { reader } from "./reader.ts"
|
|
3
|
+
import { copilotCliNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// GitHub Copilot CLI: reads ~/.copilot/session-state journals, and continues any session as a new native
|
|
6
|
+
// Copilot CLI session that `copilot --resume` opens.
|
|
7
|
+
export { reader }
|
|
8
|
+
export const readers: Reader[] = [reader]
|
|
9
|
+
export const writers: Writer[] = [copilotCliNativeWriter]
|