@100xprompt/brain 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +172 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, realpathSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { versionOf } from "../../fsutil.ts"
|
|
4
|
+
import type { ToolKind } from "../../session/types.ts"
|
|
5
|
+
import { toolKindOf } from "../../session/util.ts"
|
|
6
|
+
|
|
7
|
+
// What OpenHands CLI conversations look like, as far as this adapter relies on it. Checked against the
|
|
8
|
+
// released CLI, openhands 1.16.0 on PyPI with openhands-sdk 1.21.0 (installed from PyPI; source read in
|
|
9
|
+
// site-packages: openhands_cli/conversations/store/local.py, openhands/sdk/conversation/{state,event_store,
|
|
10
|
+
// persistence_const}.py, openhands/sdk/event/**, openhands/sdk/agent/agent.py), and against events the SDK
|
|
11
|
+
// itself serialised (EventLog.append, model_dump_json(exclude_none=True)). Catalogue entry 15.
|
|
12
|
+
//
|
|
13
|
+
// A conversation is a folder ~/.openhands/conversations/<uuid hex>/ holding base_state.json (the
|
|
14
|
+
// ConversationState) and events/event-<5-digit index>-<event uuid>.json, one pydantic event per file with a
|
|
15
|
+
// "kind" discriminator. The CLI lists every folder whose events/ holds at least one event, dated by the first
|
|
16
|
+
// event's timestamp and titled by the first user MessageEvent; `openhands --resume <id>` builds the
|
|
17
|
+
// conversation from those files (ConversationState.create), and the model is sent the events in order:
|
|
18
|
+
// the SystemPromptEvent first, then messages, actions and observations, with Condensation events applied.
|
|
19
|
+
|
|
20
|
+
export const TOOL = "openhands"
|
|
21
|
+
export const LABEL = "OpenHands CLI"
|
|
22
|
+
|
|
23
|
+
/** OpenHands CLI versions whose conversation format the writer was checked against (and their SDK). */
|
|
24
|
+
export const TESTED_VERSIONS = ["1.16.0"]
|
|
25
|
+
|
|
26
|
+
export const openhandsDir = (home: string) => join(home, ".openhands")
|
|
27
|
+
export const conversationsDir = (home: string) => join(openhandsDir(home), "conversations")
|
|
28
|
+
export const agentSettingsPath = (home: string) => join(openhandsDir(home), "agent_settings.json")
|
|
29
|
+
|
|
30
|
+
/** persistence_const.EVENT_NAME_RE: exactly five digits of index, then the event id. */
|
|
31
|
+
export const EVENT_FILE = /^event-(\d{5})-([0-9a-fA-F-]{8,})\.json$/
|
|
32
|
+
export const eventFile = (index: number, id: string) => `event-${String(index).padStart(5, "0")}-${id}.json`
|
|
33
|
+
|
|
34
|
+
/** The note a continued conversation carries in its system prompt's dynamic context; the reader looks for it. */
|
|
35
|
+
export const continuedNote = (label: string, id: string, when: string) =>
|
|
36
|
+
`[This conversation continues ${label} session ${id}, carried over by brain session on ${when}. The events after this system prompt are its history; anything listed as lost did not carry over. Check files before relying on details, since they may have changed.]`
|
|
37
|
+
export const CONTINUED_NOTE = /\[This conversation continues .+? session (\S+), carried over by brain session/
|
|
38
|
+
|
|
39
|
+
/** "OpenHands CLI 1.16.0" → "1.16.0". */
|
|
40
|
+
export const openhandsVersion = (bin = "openhands") => versionOf(bin)?.match(/(\d+\.\d+\.\d+)/)?.[1]
|
|
41
|
+
|
|
42
|
+
const pad = (n: number, w = 2) => String(n).padStart(w, "0")
|
|
43
|
+
/**
|
|
44
|
+
* A timestamp as OpenHands writes it: datetime.now().isoformat(), local time without a zone. The CLI sorts
|
|
45
|
+
* conversations by it, and a zoned value next to its own naive ones would fail that comparison.
|
|
46
|
+
*/
|
|
47
|
+
export function naiveLocal(ms: number): string {
|
|
48
|
+
const d = new Date(Math.floor(ms))
|
|
49
|
+
const micros = pad(d.getMilliseconds(), 3) + pad(Math.floor((ms % 1) * 1000), 3)
|
|
50
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${micros}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A naive local or zoned OpenHands timestamp → ISO. */
|
|
54
|
+
export function eventTime(v: unknown): string | undefined {
|
|
55
|
+
if (typeof v !== "string" || !v) return undefined
|
|
56
|
+
const t = new Date(/[zZ]|[+-]\d\d:?\d\d$/.test(v) ? v : v.slice(0, 23))
|
|
57
|
+
return Number.isFinite(t.getTime()) ? t.toISOString() : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** OpenHands' own tools, with what each does in portable terms. file_editor's kind depends on its command. */
|
|
61
|
+
export function openhandsKind(name: string, input: unknown): ToolKind {
|
|
62
|
+
if (name === "terminal" || name === "execute_bash") return "shell"
|
|
63
|
+
if (name === "task_tracker") return "todo"
|
|
64
|
+
if (name === "task_tool_set" || name === "task" || name === "delegate") return "task"
|
|
65
|
+
if (name === "think" || name === "finish") return "other"
|
|
66
|
+
if (name === "file_editor" || name === "str_replace_editor") {
|
|
67
|
+
const command = (input as { command?: unknown } | undefined)?.command
|
|
68
|
+
return command === "view" ? "read" : command === "create" ? "write" : "edit"
|
|
69
|
+
}
|
|
70
|
+
return toolKindOf(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Python of a pip or uv install of the CLI: the interpreter named by the `openhands` script's shebang,
|
|
75
|
+
* either directly (#!/…/bin/python) or through pip's long-path form (#!/bin/sh then '''exec' '/…/python').
|
|
76
|
+
* A standalone binary has none.
|
|
77
|
+
*/
|
|
78
|
+
export function openhandsPython(bin: string): string | undefined {
|
|
79
|
+
const path = bin.includes("/") ? bin : Bun.which(bin, { PATH: process.env.PATH ?? "" })
|
|
80
|
+
if (!path) return undefined
|
|
81
|
+
let head = ""
|
|
82
|
+
try {
|
|
83
|
+
const fd = openSync(realpathSync(path), "r")
|
|
84
|
+
const buf = Buffer.alloc(600)
|
|
85
|
+
const n = readSync(fd, buf, 0, buf.length, 0)
|
|
86
|
+
closeSync(fd)
|
|
87
|
+
head = buf.subarray(0, n).toString("utf8")
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
if (!head.startsWith("#!")) return undefined
|
|
92
|
+
const direct = head.match(/^#!\s*(\S*python[\d.]*)\s*\n/)?.[1]
|
|
93
|
+
if (direct) return direct
|
|
94
|
+
return head.match(/^'''exec' ['"]([^'"]*python[\d.]*)['"]/m)?.[1]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run with the CLI's own Python: the system prompt a new conversation in `cwd` would start with, made the way
|
|
99
|
+
* the CLI makes it (AgentStore.load_from_disk for the person's agent settings, the CLI's agent context of
|
|
100
|
+
* project skills plus "Your current working directory is ..." / "User operating system ..."), without
|
|
101
|
+
* starting tools, MCP servers or a model. Public skills are left out: loading them fetches from the network.
|
|
102
|
+
*/
|
|
103
|
+
export const RENDER_PROMPT_PY = `
|
|
104
|
+
import json, sys
|
|
105
|
+
from openhands.sdk import AgentContext
|
|
106
|
+
from openhands.sdk.context import load_project_skills
|
|
107
|
+
from openhands_cli.stores import AgentStore
|
|
108
|
+
from openhands_cli.utils import get_os_description
|
|
109
|
+
cwd = sys.argv[1]
|
|
110
|
+
agent = AgentStore().load_from_disk()
|
|
111
|
+
if agent is None:
|
|
112
|
+
print("BRAIN-RENDER " + json.dumps({"error": "no agent settings"}))
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
suffix = "\\n".join([f"Your current working directory is: {cwd}", f"User operating system: {get_os_description()}"])
|
|
115
|
+
ctx = AgentContext(skills=load_project_skills(cwd), system_message_suffix=suffix, load_user_skills=True, load_public_skills=False)
|
|
116
|
+
agent = agent.model_copy(update={"agent_context": ctx})
|
|
117
|
+
dynamic = ctx.get_system_message_suffix(llm_model=agent.llm.model, llm_model_canonical=agent.llm.model_canonical_name)
|
|
118
|
+
print("BRAIN-RENDER " + json.dumps({"system_prompt": agent.static_system_message, "dynamic_context": dynamic, "model": agent.llm.model}))
|
|
119
|
+
`
|
|
120
|
+
|
|
121
|
+
export interface RenderedPrompt {
|
|
122
|
+
system_prompt: string
|
|
123
|
+
dynamic_context?: string | null
|
|
124
|
+
model?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The system prompt from the installed CLI, run against `home` (its agent settings) and `cwd` (its skills). */
|
|
128
|
+
export function renderPrompt(python: string, home: string, cwd: string): RenderedPrompt {
|
|
129
|
+
const env: Record<string, string> = { HOME: home, PATH: process.env.PATH ?? "/usr/bin:/bin", OPENHANDS_SUPPRESS_BANNER: "1", OPENHANDS_WORK_DIR: cwd, LANG: process.env.LANG ?? "en_US.UTF-8" }
|
|
130
|
+
const p = Bun.spawnSync([python, "-I", "-c", RENDER_PROMPT_PY, cwd], { cwd, env, stdout: "pipe", stderr: "pipe", timeout: 120_000 })
|
|
131
|
+
const line = p.stdout.toString().split("\n").find((l) => l.startsWith("BRAIN-RENDER "))
|
|
132
|
+
if (!line) throw new Error(`OpenHands could not build its system prompt (exit ${p.exitCode}): ${p.stderr.toString().trim().split("\n").slice(-3).join(" ")}`)
|
|
133
|
+
const out = JSON.parse(line.slice("BRAIN-RENDER ".length)) as RenderedPrompt & { error?: string }
|
|
134
|
+
if (out.error || !out.system_prompt) throw new Error(`OpenHands could not build its system prompt: ${out.error ?? "empty prompt"}`)
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { extname, join } from "node:path"
|
|
4
|
+
import { toolLabel } from "../../brief.ts"
|
|
5
|
+
import { exists, isDir } from "../../fsutil.ts"
|
|
6
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { readConversation } from "./reader.ts"
|
|
10
|
+
import {
|
|
11
|
+
LABEL,
|
|
12
|
+
type RenderedPrompt,
|
|
13
|
+
TESTED_VERSIONS,
|
|
14
|
+
TOOL,
|
|
15
|
+
agentSettingsPath,
|
|
16
|
+
continuedNote,
|
|
17
|
+
conversationsDir,
|
|
18
|
+
eventFile,
|
|
19
|
+
naiveLocal,
|
|
20
|
+
openhandsPython,
|
|
21
|
+
openhandsVersion,
|
|
22
|
+
renderPrompt,
|
|
23
|
+
} from "./records.ts"
|
|
24
|
+
|
|
25
|
+
// Continues a portable session in the OpenHands CLI as a new conversation: a folder under
|
|
26
|
+
// ~/.openhands/conversations/<uuid hex>/events/ holding the events the SDK itself would have stored, in its
|
|
27
|
+
// serialisation (kind discriminators, naive local timestamps, five-digit indexes). `openhands --resume <id>`
|
|
28
|
+
// lists and opens it: with no base_state.json yet, ConversationState.create takes its fresh path, loads the
|
|
29
|
+
// events through EventLog and writes base_state.json from the CLI's current agent, as for any new
|
|
30
|
+
// conversation; nothing has to agree with an agent snapshot written here.
|
|
31
|
+
//
|
|
32
|
+
// The first event is the SystemPromptEvent, because the agent refuses a log whose first events hold a user
|
|
33
|
+
// message without one (Agent.init_state), and the model is sent the events in order. Its text is made by the
|
|
34
|
+
// installed CLI from the person's own agent settings (records.ts RENDER_PROMPT_PY), so the model gets the
|
|
35
|
+
// OpenHands prompt it would get in any conversation. The note saying where the session came from goes in
|
|
36
|
+
// that event's dynamic context, OpenHands' per-conversation part of the system prompt, not in a user message.
|
|
37
|
+
//
|
|
38
|
+
// Tool calls stay structural: ActionEvent + ObservationEvent paired by tool_call_id, parallel calls sharing
|
|
39
|
+
// an llm_response_id. Shell, read, write and edit calls become OpenHands' own terminal and file_editor calls
|
|
40
|
+
// (TerminalAction/TerminalObservation, FileEditorAction/FileEditorObservation); others keep their name with
|
|
41
|
+
// no executable action and a generic MCPToolObservation, the SDK's record for a named tool's text result.
|
|
42
|
+
// A failed call is an AgentErrorEvent. Readable reasoning rides on the next agent event's reasoning_content,
|
|
43
|
+
// a plain-text field OpenHands keeps and sends back only to models that take it. A compaction is a native
|
|
44
|
+
// Condensation: the turns before it stay on screen, the model gets the system prompt, the summary and what
|
|
45
|
+
// follows.
|
|
46
|
+
|
|
47
|
+
type Ev = Record<string, unknown>
|
|
48
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
49
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
50
|
+
|
|
51
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
52
|
+
const text = (t: string) => ({ cache_prompt: false, type: "text", text: t })
|
|
53
|
+
|
|
54
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
55
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
const pick = (o: Record<string, unknown>, keys: string[]) => {
|
|
58
|
+
for (const k of keys) if (typeof o[k] === "string") return o[k] as string
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The exit code a failed command's output states ("Exit code 2", "exited with code 2"), if it states one. */
|
|
63
|
+
export const exitCodeOf = (out: string) => {
|
|
64
|
+
const m = out.match(/exit(?:ed with)?(?: status| code)?:?\s*(-?\d+)\b/i)
|
|
65
|
+
return m && Number(m[1]) !== 0 ? Number(m[1]) : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The call as OpenHands' own tool where one fits: its name, arguments and executable action, and the
|
|
70
|
+
* observation kind its result is stored as. Anything else keeps its own name with no action.
|
|
71
|
+
*/
|
|
72
|
+
export function mapCall(call: Call, cwd?: string): { name: string; args: Record<string, unknown> | string; action?: Ev; observe: (ok: boolean, out: string) => Ev; native: boolean } {
|
|
73
|
+
const input = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Record<string, unknown>) : undefined
|
|
74
|
+
if (input) {
|
|
75
|
+
const command = pick(input, ["command", "cmd"]) ?? (Array.isArray(input.command) ? (input.command as unknown[]).map(String).join(" ") : undefined)
|
|
76
|
+
if (call.toolKind === "shell" && command) {
|
|
77
|
+
return {
|
|
78
|
+
name: "terminal",
|
|
79
|
+
args: { command },
|
|
80
|
+
action: { command, is_input: false, reset: false, kind: "TerminalAction" },
|
|
81
|
+
// A success is exit code 0. A failure takes the code its output states, else 1: OpenHands reads -1 as a
|
|
82
|
+
// command still running, which would misstate a failed one (the writer lists how many it guessed).
|
|
83
|
+
observe: (ok, out) => {
|
|
84
|
+
const code = ok ? 0 : (exitCodeOf(out) ?? 1)
|
|
85
|
+
return { content: [text(out)], is_error: !ok, command, exit_code: code, timeout: false, metadata: { exit_code: code, pid: -1, ...(cwd ? { working_dir: cwd } : {}), prefix: "", suffix: `\n[The command completed with exit code ${code}.]` }, kind: "TerminalObservation" }
|
|
86
|
+
},
|
|
87
|
+
native: true,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const path = pick(input, ["file_path", "path", "filePath", "absolute_path", "target_file", "file"])
|
|
91
|
+
const editor = (command: string, extra: Record<string, unknown>) => ({
|
|
92
|
+
name: "file_editor",
|
|
93
|
+
args: { command, path, ...extra },
|
|
94
|
+
action: { command, path, ...extra, kind: "FileEditorAction" },
|
|
95
|
+
observe: (ok: boolean, out: string) => ({ content: [text(out)], is_error: !ok, command, path, prev_exist: true, kind: "FileEditorObservation" }),
|
|
96
|
+
native: true,
|
|
97
|
+
})
|
|
98
|
+
if (path && call.toolKind === "read") return editor("view", {})
|
|
99
|
+
const content = pick(input, ["content", "file_text", "contents"])
|
|
100
|
+
if (path && call.toolKind === "write" && content !== undefined) return editor("create", { file_text: content })
|
|
101
|
+
const oldStr = pick(input, ["old_string", "old_str", "oldText", "old_text"])
|
|
102
|
+
const newStr = pick(input, ["new_string", "new_str", "newText", "new_text"])
|
|
103
|
+
if (path && call.toolKind === "edit" && oldStr !== undefined && newStr !== undefined) return editor("str_replace", { old_str: oldStr, new_str: newStr })
|
|
104
|
+
}
|
|
105
|
+
// Names the model APIs accept: letters, digits, _ and -, at most 64.
|
|
106
|
+
const name = call.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
args: typeof call.input === "string" ? call.input : (input ?? { input: call.input ?? null }),
|
|
110
|
+
observe: (ok, out) => ({ content: [text(out)], is_error: !ok, tool_name: name, kind: "MCPToolObservation" }),
|
|
111
|
+
native: false,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The conversation regrouped: messages, agent responses (thought plus the calls made together), compactions. */
|
|
116
|
+
type Step =
|
|
117
|
+
| { kind: "user"; at?: string; content: Ev[] }
|
|
118
|
+
| { kind: "agent"; at?: string; texts: string[]; reasoning?: string; calls: Call[] }
|
|
119
|
+
| { kind: "compaction"; at?: string; summary: string }
|
|
120
|
+
|
|
121
|
+
export interface EventPlan {
|
|
122
|
+
/** Every event of the new conversation, in index order. */
|
|
123
|
+
events: Ev[]
|
|
124
|
+
lost: string[]
|
|
125
|
+
/** What a read-back must find. */
|
|
126
|
+
expect: { users: number; calls: number }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The events of a new OpenHands conversation continuing `session`. Pure apart from reading attached images. */
|
|
130
|
+
export function planEvents(session: Session, opts: { now: Date; prompt: RenderedPrompt; cwd?: string }): EventPlan {
|
|
131
|
+
const drops = new Map<string, number>()
|
|
132
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
133
|
+
const label = toolLabel(session.header.tool)
|
|
134
|
+
const results = new Map<string, Result>()
|
|
135
|
+
const callIds = new Set<string>()
|
|
136
|
+
for (const t of session.turns) {
|
|
137
|
+
if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
138
|
+
if (t.kind === "tool_call") callIds.add(t.id)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const steps: Step[] = []
|
|
142
|
+
let cur: Extract<Step, { kind: "agent" }> | undefined
|
|
143
|
+
let reasoning: string[] = []
|
|
144
|
+
const flush = () => {
|
|
145
|
+
if (cur && (cur.texts.length || cur.calls.length)) steps.push(cur)
|
|
146
|
+
cur = undefined
|
|
147
|
+
}
|
|
148
|
+
const agent = (at?: string) => {
|
|
149
|
+
if (!cur) {
|
|
150
|
+
cur = { kind: "agent", ...(at ? { at } : {}), texts: [], calls: [] }
|
|
151
|
+
if (reasoning.length) cur.reasoning = reasoning.join("\n\n")
|
|
152
|
+
reasoning = []
|
|
153
|
+
}
|
|
154
|
+
return cur
|
|
155
|
+
}
|
|
156
|
+
const dropReasoning = () => {
|
|
157
|
+
if (reasoning.length) drop("reasoning with no reply after it", reasoning.length)
|
|
158
|
+
reasoning = []
|
|
159
|
+
}
|
|
160
|
+
const seen = new Set<string>()
|
|
161
|
+
for (const t of session.turns) {
|
|
162
|
+
switch (t.kind) {
|
|
163
|
+
case "user": {
|
|
164
|
+
flush()
|
|
165
|
+
dropReasoning()
|
|
166
|
+
const content: Ev[] = []
|
|
167
|
+
let body = t.text
|
|
168
|
+
for (const a of t.attachments ?? []) {
|
|
169
|
+
if (!a.path) continue
|
|
170
|
+
if (a.kind === "file") {
|
|
171
|
+
body += `\n\n[Attached file: ${a.path}]`
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
175
|
+
let size = Number.POSITIVE_INFINITY
|
|
176
|
+
try {
|
|
177
|
+
size = statSync(a.path).size
|
|
178
|
+
} catch {}
|
|
179
|
+
if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT)
|
|
180
|
+
content.push({ cache_prompt: false, type: "image", image_urls: [`data:${mime};base64,${readFileSync(a.path).toString("base64")}`] })
|
|
181
|
+
else drop("images that could not be attached")
|
|
182
|
+
}
|
|
183
|
+
if (!body.trim() && !content.length) break
|
|
184
|
+
steps.push({ kind: "user", ...(t.at ? { at: t.at } : {}), content: [text(body.trim() ? body : "(image)"), ...content] })
|
|
185
|
+
break
|
|
186
|
+
}
|
|
187
|
+
case "assistant":
|
|
188
|
+
// Text after a tool call is a new response: it was written after the call's result came back.
|
|
189
|
+
if (cur?.calls.length) flush()
|
|
190
|
+
if (t.text.trim()) agent(t.at).texts.push(t.text)
|
|
191
|
+
break
|
|
192
|
+
case "tool_call":
|
|
193
|
+
if (seen.has(t.id)) break
|
|
194
|
+
seen.add(t.id)
|
|
195
|
+
agent(t.at).calls.push(t)
|
|
196
|
+
break
|
|
197
|
+
case "tool_result":
|
|
198
|
+
// A result closes the response it answers; a call after it is a new response, not a parallel one.
|
|
199
|
+
if (!callIds.has(t.callId)) drop("tool results without a call")
|
|
200
|
+
else if (cur?.calls.some((c) => c.id === t.callId)) flush()
|
|
201
|
+
break
|
|
202
|
+
case "reasoning":
|
|
203
|
+
if (cur?.calls.length || cur?.texts.length) flush()
|
|
204
|
+
if (t.text.trim()) reasoning.push(t.text)
|
|
205
|
+
break
|
|
206
|
+
case "compaction":
|
|
207
|
+
flush()
|
|
208
|
+
dropReasoning()
|
|
209
|
+
steps.push({ kind: "compaction", ...(t.at ? { at: t.at } : {}), summary: `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}` })
|
|
210
|
+
break
|
|
211
|
+
case "file_edit":
|
|
212
|
+
if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
|
|
213
|
+
break
|
|
214
|
+
case "subagent":
|
|
215
|
+
drop("subagent sessions (their results stay in the parent's tool results)")
|
|
216
|
+
break
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
flush()
|
|
220
|
+
dropReasoning()
|
|
221
|
+
|
|
222
|
+
// Events, with timestamps that never go backwards and call ids every model API accepts.
|
|
223
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
224
|
+
let clock = (known ?? opts.now.getTime()) - 1
|
|
225
|
+
const stamp = (at?: string) => {
|
|
226
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
227
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 0.001
|
|
228
|
+
return naiveLocal(clock)
|
|
229
|
+
}
|
|
230
|
+
const ids = new Map<string, string>()
|
|
231
|
+
const usedIds = new Set<string>()
|
|
232
|
+
let remapped = 0
|
|
233
|
+
const toolCallId = (id: string) => {
|
|
234
|
+
let v = ids.get(id)
|
|
235
|
+
if (v) return v
|
|
236
|
+
const base = /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${id.replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
|
|
237
|
+
v = base
|
|
238
|
+
for (let n = 2; usedIds.has(v); n++) v = `${base}_${n}`
|
|
239
|
+
ids.set(id, v)
|
|
240
|
+
usedIds.add(v)
|
|
241
|
+
if (v !== id) remapped++
|
|
242
|
+
return v
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const events: Ev[] = []
|
|
246
|
+
const push = (fields: Ev, at?: string) => {
|
|
247
|
+
const e = { id: randomUUID(), timestamp: stamp(at), ...fields }
|
|
248
|
+
events.push(e)
|
|
249
|
+
return e
|
|
250
|
+
}
|
|
251
|
+
const dynamic = [opts.prompt.dynamic_context, continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))].filter(Boolean).join("\n\n")
|
|
252
|
+
push({ source: "agent", system_prompt: text(opts.prompt.system_prompt), tools: [], dynamic_context: text(dynamic), kind: "SystemPromptEvent" }, undefined)
|
|
253
|
+
// Ids of what the model has been shown since the system prompt, for a Condensation to forget.
|
|
254
|
+
let shown: string[] = []
|
|
255
|
+
const expect = { users: 0, calls: 0 }
|
|
256
|
+
let renamed = 0
|
|
257
|
+
for (const s of steps) {
|
|
258
|
+
if (s.kind === "user") {
|
|
259
|
+
shown.push(push({ source: "user", llm_message: { role: "user", content: s.content, thinking_blocks: [] }, activated_skills: [], extended_content: [], kind: "MessageEvent" }, s.at).id as string)
|
|
260
|
+
expect.users++
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (s.kind === "compaction") {
|
|
264
|
+
const e = push({ source: "environment", forgotten_event_ids: shown, summary: s.summary, summary_offset: 1, llm_response_id: randomUUID(), kind: "Condensation" }, s.at)
|
|
265
|
+
shown = [`${e.id}-summary`]
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
const response = randomUUID()
|
|
269
|
+
const thought = s.texts.map(text)
|
|
270
|
+
if (!s.calls.length) {
|
|
271
|
+
shown.push(
|
|
272
|
+
push(
|
|
273
|
+
{ source: "agent", llm_message: { role: "assistant", content: thought, ...(s.reasoning ? { reasoning_content: s.reasoning } : {}), thinking_blocks: [] }, llm_response_id: response, activated_skills: [], extended_content: [], kind: "MessageEvent" },
|
|
274
|
+
s.at,
|
|
275
|
+
).id as string,
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
const actions: Array<{ call: Call; event: Ev; mapped: ReturnType<typeof mapCall>; id: string }> = []
|
|
280
|
+
s.calls.forEach((call, k) => {
|
|
281
|
+
const mapped = mapCall(call, opts.cwd)
|
|
282
|
+
if (!mapped.native && mapped.name !== call.name) renamed++
|
|
283
|
+
const id = toolCallId(call.id)
|
|
284
|
+
const args = typeof mapped.args === "string" ? mapped.args : JSON.stringify(mapped.args)
|
|
285
|
+
// Only the first action of a response carries its thought and reasoning (_combine_action_events).
|
|
286
|
+
const event = push(
|
|
287
|
+
{
|
|
288
|
+
source: "agent",
|
|
289
|
+
thought: k === 0 ? thought : [],
|
|
290
|
+
...(k === 0 && s.reasoning ? { reasoning_content: s.reasoning } : {}),
|
|
291
|
+
thinking_blocks: [],
|
|
292
|
+
...(mapped.action ? { action: mapped.action } : {}),
|
|
293
|
+
tool_name: mapped.name,
|
|
294
|
+
tool_call_id: id,
|
|
295
|
+
tool_call: { id, name: mapped.name, arguments: args, origin: "completion" },
|
|
296
|
+
llm_response_id: response,
|
|
297
|
+
security_risk: "UNKNOWN",
|
|
298
|
+
kind: "ActionEvent",
|
|
299
|
+
},
|
|
300
|
+
call.at ?? s.at,
|
|
301
|
+
)
|
|
302
|
+
shown.push(event.id as string)
|
|
303
|
+
actions.push({ call, event, mapped, id })
|
|
304
|
+
expect.calls++
|
|
305
|
+
})
|
|
306
|
+
for (const a of actions) {
|
|
307
|
+
const r = results.get(a.call.id)
|
|
308
|
+
let out = r?.output ?? ""
|
|
309
|
+
if (r?.fullPath) out += `\n\n[Full output: ${r.fullPath}]`
|
|
310
|
+
if (r && !out.trim()) out = "(no output)"
|
|
311
|
+
const fields: Ev = !r
|
|
312
|
+
? { source: "agent", tool_name: a.mapped.name, tool_call_id: a.id, error: "No result was recorded for this call; the session moved on before it finished.", kind: "AgentErrorEvent" }
|
|
313
|
+
: !r.ok && !a.mapped.native
|
|
314
|
+
? { source: "agent", tool_name: a.mapped.name, tool_call_id: a.id, error: out, kind: "AgentErrorEvent" }
|
|
315
|
+
: { source: "environment", tool_name: a.mapped.name, tool_call_id: a.id, observation: a.mapped.observe(r.ok, out), action_id: a.event.id, kind: "ObservationEvent" }
|
|
316
|
+
if (!r) drop("tool calls without a result (closed as errors)")
|
|
317
|
+
else if (!r.ok && a.mapped.name === "terminal" && exitCodeOf(out) === undefined) drop("exit codes of failed shell commands, recorded as 1")
|
|
318
|
+
shown.push(push(fields, r?.at ?? s.at).id as string)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (!expect.users && !expect.calls && !steps.some((s) => s.kind === "agent")) throw new Error("nothing OpenHands can replay: the session has no messages or tool calls")
|
|
322
|
+
|
|
323
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
324
|
+
if (remapped) lost.push(`tool call ids, remapped to ids model APIs accept (${remapped})`)
|
|
325
|
+
if (renamed) lost.push(`tool names changed to fit model APIs (${renamed})`)
|
|
326
|
+
lost.push("OpenHands public skills (not listed in this conversation's system prompt)")
|
|
327
|
+
return { events, lost, expect }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The resolved folder (/private/tmp, not /tmp): the path OpenHands itself would record for the workspace. */
|
|
331
|
+
function realCwd(cwd: string): string {
|
|
332
|
+
try {
|
|
333
|
+
return realpathSync(cwd)
|
|
334
|
+
} catch {
|
|
335
|
+
return cwd
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The OpenHands writer. `bin`, `version` and `render` are for tests and harnesses: the CLI to run, its
|
|
341
|
+
* version, and what builds the system prompt (the installed CLI's own code by default).
|
|
342
|
+
*/
|
|
343
|
+
export function openhandsWriter(opts: { bin?: string; version?: string; render?: (home: string, cwd: string) => RenderedPrompt } = {}): Writer {
|
|
344
|
+
const bin = opts.bin ?? "openhands"
|
|
345
|
+
const installed = () => opts.version ?? openhandsVersion(bin)
|
|
346
|
+
const render =
|
|
347
|
+
opts.render ??
|
|
348
|
+
((home: string, cwd: string) => {
|
|
349
|
+
const python = openhandsPython(bin)
|
|
350
|
+
if (!python) throw new Error("the OpenHands CLI's Python was not found")
|
|
351
|
+
return renderPrompt(python, home, cwd)
|
|
352
|
+
})
|
|
353
|
+
return {
|
|
354
|
+
tool: TOOL,
|
|
355
|
+
label: LABEL,
|
|
356
|
+
way: "native",
|
|
357
|
+
|
|
358
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
359
|
+
const v = installed()
|
|
360
|
+
if (!v) return { ok: false, why: "the OpenHands CLI is not installed (`openhands --version` did not run)" }
|
|
361
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `OpenHands CLI ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
362
|
+
if (!opts.render && !openhandsPython(bin))
|
|
363
|
+
return { ok: false, why: "the OpenHands CLI is a standalone binary here; the writer needs the Python install (`uv tool install openhands`) to build OpenHands' system prompt" }
|
|
364
|
+
if (!exists(agentSettingsPath(w.home))) return { ok: false, why: "OpenHands has no agent settings yet (~/.openhands/agent_settings.json); run `openhands` once to choose a model" }
|
|
365
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; OpenHands works in the folder a conversation belongs to` }
|
|
366
|
+
return { ok: true }
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
370
|
+
const cwd = realCwd(w.cwd)
|
|
371
|
+
let id = randomUUID().replace(/-/g, "")
|
|
372
|
+
while (exists(join(conversationsDir(w.home), id))) id = randomUUID().replace(/-/g, "")
|
|
373
|
+
const dir = join(conversationsDir(w.home), id)
|
|
374
|
+
// A dry run never runs OpenHands: the prompt does not change which files are written.
|
|
375
|
+
const prompt = w.dryRun ? { system_prompt: "(made by OpenHands when written)" } : render(w.home, cwd)
|
|
376
|
+
const plan = planEvents(session, { now: new Date(), prompt, cwd })
|
|
377
|
+
const files = plan.events.map((e, i) => join(dir, "events", eventFile(i, e.id as string)))
|
|
378
|
+
if (!w.dryRun) {
|
|
379
|
+
mkdirSync(join(dir, "events"), { recursive: true })
|
|
380
|
+
plan.events.forEach((e, i) => writeFileSync(files[i]!, JSON.stringify(e), { flag: "wx" }))
|
|
381
|
+
const back = readConversation(dir, id)
|
|
382
|
+
const n = countTurns(back)
|
|
383
|
+
const pairs = unpaired(back)
|
|
384
|
+
if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
|
|
385
|
+
rmSync(dir, { recursive: true, force: true })
|
|
386
|
+
throw new Error(
|
|
387
|
+
`the OpenHands conversation did not read back as written (users ${n.user}/${plan.expect.users}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
tool: TOOL,
|
|
393
|
+
way: "native",
|
|
394
|
+
sessionId: id,
|
|
395
|
+
written: files,
|
|
396
|
+
open: `cd ${shellQuote(cwd)} && openhands --resume ${id}`,
|
|
397
|
+
lost: plan.lost,
|
|
398
|
+
dryRun: !!w.dryRun,
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export const openhandsNativeWriter = openhandsWriter()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { continueReader } from "../continue/reader.ts"
|
|
2
|
+
import { PEARAI } from "../continue/records.ts"
|
|
3
|
+
import { continueWriter } from "../continue/writer.ts"
|
|
4
|
+
import type { Reader, Writer } from "../types.ts"
|
|
5
|
+
|
|
6
|
+
// PearAI's chat, a fork of an older Continue: the same session store under ~/.pearai/sessions, with text-only
|
|
7
|
+
// messages, so the Continue adapter does the work with PearAI's settings (records.ts, PEARAI).
|
|
8
|
+
export const reader = continueReader(PEARAI)
|
|
9
|
+
export const readers: Reader[] = [reader]
|
|
10
|
+
export const writers: Writer[] = [continueWriter(PEARAI)]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import type { Reader, Writer } from "../types.ts"
|
|
3
|
+
import { piFamilyReader } from "./reader.ts"
|
|
4
|
+
import type { PiFlavor } from "./records.ts"
|
|
5
|
+
import { piFamilyWriter } from "./writer.ts"
|
|
6
|
+
|
|
7
|
+
// Pi coding agent (earendil-works/pi, formerly badlogic/pi-mono): reads its sessions and continues any
|
|
8
|
+
// session as a new native Pi session, `pi --session <id>`.
|
|
9
|
+
|
|
10
|
+
/** Pi versions whose session format this writer was checked against (format version 3). */
|
|
11
|
+
export const PI_TESTED_VERSIONS = ["1.5.0"]
|
|
12
|
+
|
|
13
|
+
export const piFlavor: PiFlavor = {
|
|
14
|
+
tool: "pi",
|
|
15
|
+
label: "Pi",
|
|
16
|
+
bin: "pi",
|
|
17
|
+
// ~/.pi/agent/sessions; PI_CODING_AGENT_DIR and --session-dir move it, which brain does not follow.
|
|
18
|
+
sessionsRoot: (home) => join(home, ".pi", "agent", "sessions"),
|
|
19
|
+
// <start time with : and . as ->_<id>.jsonl, as SessionManager.newSession names it.
|
|
20
|
+
fileName: (id, startedAt) => `${startedAt.replace(/[:.]/g, "-")}_${id}.jsonl`,
|
|
21
|
+
newId: () => Bun.randomUUIDv7(),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const piReader = piFamilyReader(piFlavor)
|
|
25
|
+
export const piWriter = (version?: string) => piFamilyWriter(piFlavor, { tested: PI_TESTED_VERSIONS, ...(version ? { version } : {}) })
|
|
26
|
+
|
|
27
|
+
export const readers: Reader[] = [piReader]
|
|
28
|
+
export const writers: Writer[] = [piWriter()]
|