@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,379 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
import { toolLabel } from "../../brief.ts"
|
|
4
|
+
import { exists, isDir, versionOf } from "../../fsutil.ts"
|
|
5
|
+
import type { Session, Turn, Usage } from "../../session/types.ts"
|
|
6
|
+
import { imageDataUrl } from "../../session/attachments.ts"
|
|
7
|
+
import { firstUserText } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import type { Bundle } from "./reader.ts"
|
|
10
|
+
import { idMinter, mintId, random62 } from "./ids.ts"
|
|
11
|
+
import { continuedNote } from "./note.ts"
|
|
12
|
+
import { HUNDREDX, OPENCODE_V1, mapCall, type Dialect } from "./tools.ts"
|
|
13
|
+
|
|
14
|
+
// Continuing a session in OpenCode 1.x, 100xprompt or Kilo through their own importer: the session is written
|
|
15
|
+
// as the JSON `export` produces, {info, messages: [{info, parts}]}, and `<tool> import <file>` stores it as a
|
|
16
|
+
// new session under the project of the folder it runs in. 100xprompt's importer writes the records as they
|
|
17
|
+
// are, unchecked; OpenCode 1.18's and Kilo's decode each against their schema and replace the projectID and
|
|
18
|
+
// directory with the folder's. So every record is complete and valid for all three.
|
|
19
|
+
//
|
|
20
|
+
// Verified live (2026-11-08, temp homes, no network): the real `import`, then `session list` and `export`,
|
|
21
|
+
// on 100xprompt 0.1.136, OpenCode 1.18.25 and Kilo 7.8.27. Shapes checked against 100xprompt's source
|
|
22
|
+
// (src/6-harness/session/message-v2.ts, src/9-surface/cli/cmd/{export,import}.ts), OpenCode v1.18.25 and
|
|
23
|
+
// Kilo's packages/opencode/src/cli/cmd/import.ts with packages/schema/src/v1/session.ts.
|
|
24
|
+
// OpenCode 2.x imports a different format (v2.ts).
|
|
25
|
+
|
|
26
|
+
export interface Target {
|
|
27
|
+
tool: string
|
|
28
|
+
label: string
|
|
29
|
+
/** The binary's name, and its install folder under home (~/.100xprompt/bin, ~/.opencode/bin, ~/.kilo/bin). */
|
|
30
|
+
bin: string
|
|
31
|
+
installDir: string
|
|
32
|
+
/** Versions whose import format was checked. */
|
|
33
|
+
tested: string[]
|
|
34
|
+
/** OpenCode's and Kilo's session schema require a slug; 100xprompt's has none. */
|
|
35
|
+
slug: boolean
|
|
36
|
+
dialect: Dialect
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const HUNDREDX_TARGET: Target = { tool: "100xprompt", label: "100xprompt", bin: "100xprompt", installDir: ".100xprompt", tested: ["0.1.136"], slug: false, dialect: HUNDREDX }
|
|
40
|
+
export const OPENCODE_TARGET: Target = { tool: "opencode", label: "OpenCode", bin: "opencode", installDir: ".opencode", tested: ["1.18.25"], slug: true, dialect: OPENCODE_V1 }
|
|
41
|
+
|
|
42
|
+
/** The binary to run: the installer's copy under home first, else whatever is on PATH. */
|
|
43
|
+
export function resolveBin(t: Pick<Target, "bin" | "installDir">, home: string): { path: string; command: string } {
|
|
44
|
+
const own = join(home, t.installDir, "bin", t.bin)
|
|
45
|
+
if (exists(own)) return { path: own, command: Bun.which(t.bin) === own ? t.bin : own }
|
|
46
|
+
return { path: t.bin, command: t.bin }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const versionNumber = (v: string | undefined) => v?.match(/\d+\.\d+\.\d+/)?.[0]
|
|
50
|
+
|
|
51
|
+
export function importWriter(t: Target): Writer {
|
|
52
|
+
return {
|
|
53
|
+
tool: t.tool,
|
|
54
|
+
label: t.label,
|
|
55
|
+
way: "import",
|
|
56
|
+
async ready(opts): Promise<Readiness> {
|
|
57
|
+
const bin = resolveBin(t, opts.home)
|
|
58
|
+
const raw = versionOf(bin.path)
|
|
59
|
+
const v = versionNumber(raw)
|
|
60
|
+
if (!raw) return { ok: false, why: `${t.label} is not installed (no ${t.bin} on PATH or in ~/${t.installDir}/bin)` }
|
|
61
|
+
if (!v || !t.tested.includes(v)) return { ok: false, why: `${t.label} ${v ?? raw} has not been tested; the import format was checked on ${t.tested.join(", ")}` }
|
|
62
|
+
if (!isDir(opts.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${opts.cwd}` }
|
|
63
|
+
return { ok: true }
|
|
64
|
+
},
|
|
65
|
+
async write(session, opts): Promise<WriteResult> {
|
|
66
|
+
const bin = resolveBin(t, opts.home)
|
|
67
|
+
const version = versionNumber(versionOf(bin.path)) ?? t.tested.at(-1)!
|
|
68
|
+
const { bundle, sessionId, lost } = buildExport(session, {
|
|
69
|
+
label: t.label,
|
|
70
|
+
dialect: t.dialect,
|
|
71
|
+
cwd: opts.cwd,
|
|
72
|
+
version,
|
|
73
|
+
projectID: projectIdOf(opts.cwd, t.bin),
|
|
74
|
+
slug: t.slug,
|
|
75
|
+
})
|
|
76
|
+
const file = join(opts.home, ".brain", "exports", `${t.tool}-${sessionId}.json`)
|
|
77
|
+
if (!opts.dryRun) {
|
|
78
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
79
|
+
writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`)
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
tool: t.tool,
|
|
83
|
+
way: "import",
|
|
84
|
+
sessionId,
|
|
85
|
+
written: [file],
|
|
86
|
+
// The importer files the session under the project of the folder it runs in, so it runs there.
|
|
87
|
+
open: `cd ${sh(opts.cwd)} && ${sh(bin.command)} import ${sh(file)} && ${sh(bin.command)} --session ${sessionId}`,
|
|
88
|
+
lost,
|
|
89
|
+
dryRun: !!opts.dryRun,
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const sh = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The project id 100xprompt and OpenCode 1.x give a folder: the repository's first root commit, cached by the
|
|
99
|
+
* tool in .git/<tool>, or "global" outside a repository. 100xprompt's importer files the session under the id
|
|
100
|
+
* it computes itself and keeps the record's own; this keeps them in agreement.
|
|
101
|
+
*/
|
|
102
|
+
export function projectIdOf(cwd: string, tool: string): string {
|
|
103
|
+
let dir = cwd
|
|
104
|
+
for (let i = 0; i < 30 && dir && dir !== "/"; i++) {
|
|
105
|
+
const git = join(dir, ".git")
|
|
106
|
+
if (exists(git)) {
|
|
107
|
+
// A worktree's .git is a file naming the real git folder, relative or absolute.
|
|
108
|
+
const gitDir = isDir(git) ? git : resolve(dir, readText(git).replace(/^gitdir:\s*/, "").trim())
|
|
109
|
+
const cached = readText(join(gitDir, tool)).trim()
|
|
110
|
+
if (cached) return cached
|
|
111
|
+
const p = Bun.spawnSync(["git", "rev-list", "--max-parents=0", "--all"], { cwd: dir, stdout: "pipe", stderr: "pipe", timeout: 10_000 })
|
|
112
|
+
const roots = p.exitCode === 0 ? p.stdout.toString().split("\n").map((x) => x.trim()).filter(Boolean).sort() : []
|
|
113
|
+
return roots[0] ?? "global"
|
|
114
|
+
}
|
|
115
|
+
dir = dirname(dir)
|
|
116
|
+
}
|
|
117
|
+
return "global"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function readText(p: string): string {
|
|
121
|
+
try {
|
|
122
|
+
return readFileSync(p, "utf8")
|
|
123
|
+
} catch {
|
|
124
|
+
return ""
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The provider a model name belongs to; "provider/model" names say so themselves. */
|
|
129
|
+
export function modelRef(model: string | undefined): { providerID: string; modelID: string } {
|
|
130
|
+
if (!model) return { providerID: "unknown", modelID: "unknown" }
|
|
131
|
+
const slash = model.indexOf("/")
|
|
132
|
+
if (slash > 0) return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
|
|
133
|
+
const providerID = /claude|opus|sonnet|haiku/i.test(model)
|
|
134
|
+
? "anthropic"
|
|
135
|
+
: /gemini|gemma/i.test(model)
|
|
136
|
+
? "google"
|
|
137
|
+
: /^(gpt|o\d|codex|chatgpt)/i.test(model)
|
|
138
|
+
? "openai"
|
|
139
|
+
: /grok/i.test(model)
|
|
140
|
+
? "xai"
|
|
141
|
+
: /deepseek/i.test(model)
|
|
142
|
+
? "deepseek"
|
|
143
|
+
: "unknown"
|
|
144
|
+
return { providerID, modelID: model }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export const zeroTokens = () => ({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })
|
|
148
|
+
|
|
149
|
+
export function addUsage(t: ReturnType<typeof zeroTokens>, u: Usage | undefined) {
|
|
150
|
+
if (!u) return
|
|
151
|
+
t.input += u.input ?? 0
|
|
152
|
+
t.output += u.output ?? 0
|
|
153
|
+
t.cache.read += u.cacheRead ?? 0
|
|
154
|
+
t.cache.write += u.cacheWrite ?? 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The short label the tool shows beside a call. */
|
|
158
|
+
export function callTitle(name: string, input: Record<string, unknown>): string {
|
|
159
|
+
const pick = input.description ?? input.filePath ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.command ?? input.query
|
|
160
|
+
const text = Array.isArray(pick) ? pick.join(" ") : typeof pick === "string" ? pick : name
|
|
161
|
+
return text.length > 120 ? `${text.slice(0, 119)}…` : text
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The new session's title: the source's, marked with where it came from. */
|
|
165
|
+
export function continuedTitle(s: Session): string {
|
|
166
|
+
const title = (s.header.title ?? firstUserText(s) ?? "").replace(/\s+/g, " ").trim().slice(0, 100) || "Continued session"
|
|
167
|
+
return `${title} (from ${toolLabel(s.header.tool)})`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ExportOptions {
|
|
171
|
+
/** The target's name, for what is lost. */
|
|
172
|
+
label: string
|
|
173
|
+
dialect: Dialect
|
|
174
|
+
cwd: string
|
|
175
|
+
version: string
|
|
176
|
+
projectID: string
|
|
177
|
+
slug: boolean
|
|
178
|
+
/** The clock for new ids and the session's update time (tests pin it). */
|
|
179
|
+
now?: number
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The portable session as an export bundle: the continuation note as the tool's own hidden (synthetic) user
|
|
184
|
+
* message, one user message per user turn, one assistant message per model step (the text, reasoning and tool
|
|
185
|
+
* calls up to the results they wait on, as the tools store them), each tool call merged with its result into
|
|
186
|
+
* one tool part under the target's own tool name, and a compaction as the tool's own compaction pair.
|
|
187
|
+
*/
|
|
188
|
+
export function buildExport(s: Session, o: ExportOptions): { bundle: Bundle; sessionId: string; lost: string[] } {
|
|
189
|
+
const now = o.now ?? Date.now()
|
|
190
|
+
// New ids from now, so the imported messages sort in order and after anything already stored.
|
|
191
|
+
const next = idMinter(now)
|
|
192
|
+
const sessionId = mintId("ses", now, 1, true)
|
|
193
|
+
const messages: Bundle["messages"] = []
|
|
194
|
+
const lost = new Map<string, number>()
|
|
195
|
+
const drop = (what: string, n = 1) => lost.set(what, (lost.get(what) ?? 0) + n)
|
|
196
|
+
const mapped = { n: 0 }
|
|
197
|
+
|
|
198
|
+
// Times must be numbers and must not go backwards, or the stores read messages out of order.
|
|
199
|
+
let clock = Date.parse(s.header.startedAt ?? "") || now - s.turns.length * 1000
|
|
200
|
+
const tick = (at?: string) => {
|
|
201
|
+
const t = at ? Date.parse(at) : NaN
|
|
202
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
203
|
+
return clock
|
|
204
|
+
}
|
|
205
|
+
const firstClock = clock
|
|
206
|
+
|
|
207
|
+
const agent = o.dialect.agent ?? "build"
|
|
208
|
+
let model = modelRef(s.header.models.at(-1))
|
|
209
|
+
let parentID: string | undefined
|
|
210
|
+
let step: { info: Record<string, any>; parts: Array<Record<string, any>>; tools: boolean; results: boolean; tokens: ReturnType<typeof zeroTokens> } | undefined
|
|
211
|
+
const calls = new Map<string, Record<string, any>>()
|
|
212
|
+
const answered = new Set<string>()
|
|
213
|
+
|
|
214
|
+
const user = (parts: (messageID: string, at: number) => Array<Record<string, any>>, at?: string) => {
|
|
215
|
+
closeStep()
|
|
216
|
+
const id = next("msg")
|
|
217
|
+
const created = tick(at)
|
|
218
|
+
messages.push({ info: { id, sessionID: sessionId, role: "user", time: { created }, agent, model, summary: { diffs: [] } }, parts: parts(id, created) })
|
|
219
|
+
parentID = id
|
|
220
|
+
return id
|
|
221
|
+
}
|
|
222
|
+
const part = (messageID: string, fields: Record<string, unknown>) => ({ id: next("prt"), sessionID: sessionId, messageID, ...fields })
|
|
223
|
+
const assistantInfo = (id: string, created: number, extra: Record<string, unknown> = {}) => ({
|
|
224
|
+
id,
|
|
225
|
+
sessionID: sessionId,
|
|
226
|
+
role: "assistant",
|
|
227
|
+
time: { created, completed: created },
|
|
228
|
+
parentID,
|
|
229
|
+
modelID: model.modelID,
|
|
230
|
+
providerID: model.providerID,
|
|
231
|
+
mode: agent,
|
|
232
|
+
agent,
|
|
233
|
+
path: { cwd: o.cwd, root: o.cwd },
|
|
234
|
+
...extra,
|
|
235
|
+
cost: 0,
|
|
236
|
+
tokens: zeroTokens(),
|
|
237
|
+
finish: "stop",
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// The note first: a synthetic part is sent to the model and hidden in the TUI, like the tools' own nudges.
|
|
241
|
+
const note = continuedNote(toolLabel(s.header.tool), s.header.id, new Date(now).toISOString().slice(0, 10))
|
|
242
|
+
user((id, created) => [part(id, { type: "text", text: note, synthetic: true, time: { start: created, end: created } })])
|
|
243
|
+
|
|
244
|
+
const openStep = (at?: string) => {
|
|
245
|
+
if (step && !step.results) return step
|
|
246
|
+
closeStep()
|
|
247
|
+
const id = next("msg")
|
|
248
|
+
step = { info: assistantInfo(id, tick(at)), parts: [], tools: false, results: false, tokens: zeroTokens() }
|
|
249
|
+
step.parts.push(part(id, { type: "step-start" }))
|
|
250
|
+
messages.push({ info: step.info, parts: step.parts })
|
|
251
|
+
return step
|
|
252
|
+
}
|
|
253
|
+
function closeStep() {
|
|
254
|
+
if (!step) return
|
|
255
|
+
const reason = step.tools ? "tool-calls" : "stop"
|
|
256
|
+
step.info.finish = reason
|
|
257
|
+
step.info.tokens = step.tokens
|
|
258
|
+
step.info.time.completed = clock
|
|
259
|
+
step.parts.push(part(step.info.id, { type: "step-finish", reason, cost: 0, tokens: step.tokens }))
|
|
260
|
+
step = undefined
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const t of s.turns as Turn[]) {
|
|
264
|
+
switch (t.kind) {
|
|
265
|
+
case "user": {
|
|
266
|
+
// Kept images are embedded as file parts with a data: URL (what OpenCode stores for a pasted image); the rest
|
|
267
|
+
// travel by path, which another machine or a later day may not have: the text says they were there.
|
|
268
|
+
const pics: Array<{ url: string; mime: string; name: string }> = []
|
|
269
|
+
let n = 0
|
|
270
|
+
for (const a of t.attachments ?? []) {
|
|
271
|
+
const url = imageDataUrl(a)
|
|
272
|
+
if (url) pics.push({ url, mime: url.slice(5, url.indexOf(";")), name: a.name ?? a.path?.split("/").pop() ?? "image" })
|
|
273
|
+
else n++
|
|
274
|
+
}
|
|
275
|
+
if (n) drop("attachments", n)
|
|
276
|
+
const text = t.text.trim() ? t.text : n ? `(${n} attachment${n > 1 ? "s" : ""} not carried over)` : ""
|
|
277
|
+
if (!text && !pics.length) break // providers reject an empty user message
|
|
278
|
+
user((id, created) => [
|
|
279
|
+
...(text ? [part(id, { type: "text", text, time: { start: created, end: created } })] : []),
|
|
280
|
+
...pics.map((p) => part(id, { type: "file", mime: p.mime, filename: p.name, url: p.url })),
|
|
281
|
+
], t.at)
|
|
282
|
+
break
|
|
283
|
+
}
|
|
284
|
+
case "assistant": {
|
|
285
|
+
if (!t.text.trim()) break
|
|
286
|
+
if (t.model) model = modelRef(t.model)
|
|
287
|
+
const st = openStep(t.at)
|
|
288
|
+
const at = tick(t.at)
|
|
289
|
+
st.parts.push(part(st.info.id, { type: "text", text: t.text, time: { start: at, end: at } }))
|
|
290
|
+
addUsage(st.tokens, t.usage)
|
|
291
|
+
break
|
|
292
|
+
}
|
|
293
|
+
case "reasoning": {
|
|
294
|
+
const st = openStep(t.at)
|
|
295
|
+
const at = tick(t.at)
|
|
296
|
+
st.parts.push(part(st.info.id, { type: "reasoning", text: t.text, time: { start: at, end: at } }))
|
|
297
|
+
// Readable reasoning has no provider signature: 100xprompt shows it but does not send it back, and
|
|
298
|
+
// OpenCode and Kilo send it to another model as plain text.
|
|
299
|
+
drop(o.dialect === HUNDREDX ? "reasoning: shown in the session but not sent back to the model" : "reasoning: kept as text, without provider signatures")
|
|
300
|
+
break
|
|
301
|
+
}
|
|
302
|
+
case "tool_call": {
|
|
303
|
+
const st = openStep(t.at)
|
|
304
|
+
const at = tick(t.at)
|
|
305
|
+
const call = mapCall(o.dialect, t.name, t.toolKind, t.input, mapped)
|
|
306
|
+
// Foreign call ids (toolu_…, call_…, Cursor's) are replaced by fresh ones every provider accepts.
|
|
307
|
+
const p = part(st.info.id, {
|
|
308
|
+
type: "tool",
|
|
309
|
+
callID: `call_${random62(24)}`,
|
|
310
|
+
tool: call.tool,
|
|
311
|
+
state: { status: "error", input: call.input, error: "No result was recorded for this call.", time: { start: at, end: at } },
|
|
312
|
+
})
|
|
313
|
+
st.parts.push(p)
|
|
314
|
+
st.tools = true
|
|
315
|
+
calls.set(t.id, p)
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
case "tool_result": {
|
|
319
|
+
const p = calls.get(t.callId)
|
|
320
|
+
if (!p) {
|
|
321
|
+
drop("tool results without a call")
|
|
322
|
+
break
|
|
323
|
+
}
|
|
324
|
+
answered.add(t.callId)
|
|
325
|
+
const start = p.state.time.start as number
|
|
326
|
+
const end = Math.max(start, tick(t.at))
|
|
327
|
+
p.state = t.ok
|
|
328
|
+
? {
|
|
329
|
+
status: "completed",
|
|
330
|
+
input: p.state.input,
|
|
331
|
+
output: t.output,
|
|
332
|
+
title: callTitle(String(p.tool), p.state.input),
|
|
333
|
+
metadata: t.truncated ? { truncated: true, ...(t.fullPath ? { outputPath: t.fullPath } : {}) } : {},
|
|
334
|
+
time: { start, end },
|
|
335
|
+
}
|
|
336
|
+
: { status: "error", input: p.state.input, error: t.output || "The call failed.", time: { start, end } }
|
|
337
|
+
if (step) step.results = true
|
|
338
|
+
break
|
|
339
|
+
}
|
|
340
|
+
case "compaction": {
|
|
341
|
+
// The tool's own pair: a user message with a compaction part, then the summary as an assistant reply.
|
|
342
|
+
// The loaders send the model only what follows the last such pair.
|
|
343
|
+
user((id) => [part(id, { type: "compaction", auto: true })], t.at)
|
|
344
|
+
const id = next("msg")
|
|
345
|
+
const created = tick(t.at)
|
|
346
|
+
messages.push({
|
|
347
|
+
info: assistantInfo(id, created, { summary: true }),
|
|
348
|
+
parts: [part(id, { type: "text", text: t.summary, time: { start: created, end: created } })],
|
|
349
|
+
})
|
|
350
|
+
break
|
|
351
|
+
}
|
|
352
|
+
case "file_edit":
|
|
353
|
+
// Both stores record edits as snapshot hashes of their own shadow repository, which cannot be made up;
|
|
354
|
+
// an edit made by a call travels as that call.
|
|
355
|
+
if (!t.callId) drop("file edits not made by a tool call")
|
|
356
|
+
break
|
|
357
|
+
case "subagent":
|
|
358
|
+
drop("subagent sessions (their call and result are kept)")
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
closeStep()
|
|
363
|
+
|
|
364
|
+
const unanswered = calls.size - answered.size
|
|
365
|
+
if (unanswered > 0) drop("calls with no result, marked as failed", unanswered)
|
|
366
|
+
if (calls.size) drop("tool-call ids replaced", calls.size)
|
|
367
|
+
if (mapped.n) drop(`tool calls renamed to ${o.label}'s own tools`, mapped.n)
|
|
368
|
+
|
|
369
|
+
const info: Record<string, unknown> = {
|
|
370
|
+
id: sessionId,
|
|
371
|
+
...(o.slug ? { slug: `brain-${sessionId.slice(-8).toLowerCase()}` } : {}),
|
|
372
|
+
projectID: o.projectID,
|
|
373
|
+
directory: o.cwd,
|
|
374
|
+
title: continuedTitle(s),
|
|
375
|
+
version: o.version,
|
|
376
|
+
time: { created: firstClock, updated: Math.max(now, clock) },
|
|
377
|
+
}
|
|
378
|
+
return { bundle: { info, messages }, sessionId, lost: [...lost].map(([what, n]) => `${what} (${n})`) }
|
|
379
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { openhandsReader } from "./reader.ts"
|
|
3
|
+
import { openhandsNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// OpenHands CLI: reads its conversation event logs, and continues any session as a new conversation that
|
|
6
|
+
// `openhands --resume <id>` opens.
|
|
7
|
+
export const readers: Reader[] = [openhandsReader]
|
|
8
|
+
export const writers: Writer[] = [openhandsNativeWriter]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { isDir, ls, mtime, readJson } from "../../fsutil.ts"
|
|
3
|
+
import type { Attachment, Session, Turn } from "../../session/types.ts"
|
|
4
|
+
import { clip, header, lossCounter, noteModel } from "../../session/util.ts"
|
|
5
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
6
|
+
import { CONTINUED_NOTE, EVENT_FILE, LABEL, TOOL, conversationsDir, eventTime, openhandsKind } from "./records.ts"
|
|
7
|
+
|
|
8
|
+
// OpenHands CLI conversations: one folder per conversation, one JSON file per event (see records.ts).
|
|
9
|
+
// Events become turns in index order: user and agent MessageEvents are messages, an ActionEvent is the
|
|
10
|
+
// agent's thought plus one tool call (parallel calls share an llm_response_id), ObservationEvent /
|
|
11
|
+
// AgentErrorEvent / UserRejectObservation are its result, and a Condensation with a summary is a compaction.
|
|
12
|
+
|
|
13
|
+
type Ev = Record<string, any>
|
|
14
|
+
|
|
15
|
+
/** Event files in index order; a gap ends the log, as EventLog._scan_and_build_index stops there. */
|
|
16
|
+
export function eventFiles(dir: string): string[] {
|
|
17
|
+
const byIndex = new Map<number, string>()
|
|
18
|
+
for (const name of ls(join(dir, "events"))) {
|
|
19
|
+
const m = name.match(EVENT_FILE)
|
|
20
|
+
if (m) byIndex.set(Number(m[1]), name)
|
|
21
|
+
}
|
|
22
|
+
const out: string[] = []
|
|
23
|
+
for (let i = 0; byIndex.has(i); i++) out.push(join(dir, "events", byIndex.get(i)!))
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const texts = (content: unknown): string =>
|
|
28
|
+
(Array.isArray(content) ? content : [])
|
|
29
|
+
.filter((c: Ev) => c?.type === "text" && typeof c.text === "string")
|
|
30
|
+
.map((c: Ev) => c.text as string)
|
|
31
|
+
.join("")
|
|
32
|
+
|
|
33
|
+
const images = (content: unknown): number => (Array.isArray(content) ? content : []).filter((c: Ev) => c?.type === "image").reduce((n: number, c: Ev) => n + (Array.isArray(c.image_urls) ? c.image_urls.length : 1), 0)
|
|
34
|
+
|
|
35
|
+
/** The folder the CLI states in every conversation's system prompt (its agent context suffix). */
|
|
36
|
+
export const workingDir = (dynamic: string | undefined) => dynamic?.match(/^Your current working directory is: (.+)$/m)?.[1]?.trim()
|
|
37
|
+
|
|
38
|
+
/** Arguments of a tool call as an object when they are JSON, else as the raw string. */
|
|
39
|
+
function args(raw: unknown): unknown {
|
|
40
|
+
if (typeof raw !== "string") return raw ?? {}
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(raw)
|
|
43
|
+
} catch {
|
|
44
|
+
return raw
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The first user message's text, which the CLI shows as the title. */
|
|
49
|
+
function firstPrompt(files: string[]): string | undefined {
|
|
50
|
+
for (const f of files.slice(0, 50)) {
|
|
51
|
+
const e = readJson<Ev>(f)
|
|
52
|
+
if (e?.kind === "MessageEvent" && e.source === "user") {
|
|
53
|
+
const t = texts(e.llm_message?.content).trim()
|
|
54
|
+
if (t) return t
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readConversation(dir: string, id: string): Session {
|
|
61
|
+
const files = eventFiles(dir)
|
|
62
|
+
if (!files.length) throw new Error(`no events in the OpenHands conversation ${dir}`)
|
|
63
|
+
const base = readJson<Ev>(join(dir, "base_state.json"))
|
|
64
|
+
const h = header(TOOL, id, { sourcePath: dir, project: { cwd: base?.workspace?.working_dir } })
|
|
65
|
+
noteModel(h, base?.agent?.llm?.model)
|
|
66
|
+
const lost = lossCounter()
|
|
67
|
+
const turns: Turn[] = []
|
|
68
|
+
const calls = new Set<string>()
|
|
69
|
+
const answered = new Set<string>()
|
|
70
|
+
let first: string | undefined
|
|
71
|
+
let last: string | undefined
|
|
72
|
+
|
|
73
|
+
const reasoning = (e: Ev, msg?: Ev) => {
|
|
74
|
+
const text = (msg?.reasoning_content ?? e.reasoning_content) as unknown
|
|
75
|
+
if (typeof text === "string" && text.trim()) turns.push({ kind: "reasoning", text })
|
|
76
|
+
const blocks = (msg?.thinking_blocks ?? e.thinking_blocks) as unknown
|
|
77
|
+
if (Array.isArray(blocks) && blocks.length) lost.add("signed reasoning", blocks.length)
|
|
78
|
+
const item = (msg?.responses_reasoning_item ?? e.responses_reasoning_item) as Ev | undefined
|
|
79
|
+
if (item) {
|
|
80
|
+
const parts = [...(item.summary ?? []), ...(item.content ?? [])].filter((s: unknown) => typeof s === "string" && s.trim())
|
|
81
|
+
if (parts.length) turns.push({ kind: "reasoning", text: parts.join("\n") })
|
|
82
|
+
if (item.encrypted_content) lost.add("encrypted reasoning")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const result = (e: Ev, ok: boolean, output: string) => {
|
|
86
|
+
const callId = String(e.tool_call_id ?? "")
|
|
87
|
+
if (!calls.has(callId)) return lost.add("results without a call")
|
|
88
|
+
if (answered.has(callId)) return lost.add("repeated results")
|
|
89
|
+
answered.add(callId)
|
|
90
|
+
const c = clip(output)
|
|
91
|
+
turns.push({ kind: "tool_result", ...(at(e) ? { at: at(e) } : {}), callId, ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
92
|
+
}
|
|
93
|
+
const at = (e: Ev) => eventTime(e.timestamp)
|
|
94
|
+
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
const e = readJson<Ev>(f)
|
|
97
|
+
if (!e) {
|
|
98
|
+
lost.add("unreadable events")
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
const when = at(e)
|
|
102
|
+
if (when) {
|
|
103
|
+
first ??= when
|
|
104
|
+
last = when
|
|
105
|
+
}
|
|
106
|
+
switch (e.kind) {
|
|
107
|
+
case "SystemPromptEvent": {
|
|
108
|
+
const dynamic = texts([e.dynamic_context])
|
|
109
|
+
const note = dynamic.match(CONTINUED_NOTE)
|
|
110
|
+
if (note) h.parent = { id: note[1]!, kind: "continued" }
|
|
111
|
+
h.project.cwd ??= workingDir(dynamic)
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
case "MessageEvent": {
|
|
115
|
+
const msg = (e.llm_message ?? {}) as Ev
|
|
116
|
+
const text = texts(msg.content)
|
|
117
|
+
if (e.source === "user") {
|
|
118
|
+
const n = images(msg.content)
|
|
119
|
+
const attachments: Attachment[] = Array.from({ length: n }, () => ({ kind: "image" }))
|
|
120
|
+
if (n) lost.add("images (kept inline by OpenHands, not as files)", n)
|
|
121
|
+
if (text.trim() || n) turns.push({ kind: "user", ...(when ? { at: when } : {}), text, ...(n ? { attachments } : {}) })
|
|
122
|
+
} else {
|
|
123
|
+
reasoning(e, msg)
|
|
124
|
+
if (text.trim()) turns.push({ kind: "assistant", ...(when ? { at: when } : {}), text, ...(h.models.length ? { model: h.models[0] } : {}) })
|
|
125
|
+
}
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
case "ActionEvent": {
|
|
129
|
+
reasoning(e)
|
|
130
|
+
const thought = texts(e.thought)
|
|
131
|
+
if (thought.trim()) turns.push({ kind: "assistant", ...(when ? { at: when } : {}), text: thought, ...(h.models.length ? { model: h.models[0] } : {}) })
|
|
132
|
+
const call = (e.tool_call ?? {}) as Ev
|
|
133
|
+
const id = String(e.tool_call_id ?? call.id ?? "")
|
|
134
|
+
if (!id || calls.has(id)) {
|
|
135
|
+
lost.add("tool calls without a usable id")
|
|
136
|
+
break
|
|
137
|
+
}
|
|
138
|
+
calls.add(id)
|
|
139
|
+
const name = String(e.tool_name ?? call.name ?? "tool")
|
|
140
|
+
const input = args(call.arguments)
|
|
141
|
+
turns.push({ kind: "tool_call", ...(when ? { at: when } : {}), id, name, toolKind: openhandsKind(name, input), input })
|
|
142
|
+
const action = (e.action ?? {}) as Ev
|
|
143
|
+
if (name === "file_editor" && typeof action.path === "string" && ["create", "str_replace", "insert"].includes(action.command))
|
|
144
|
+
turns.push({ kind: "file_edit", path: action.path, callId: id })
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
case "ObservationEvent": {
|
|
148
|
+
const obs = (e.observation ?? {}) as Ev
|
|
149
|
+
if (images(obs.content)) lost.add("images in tool results", images(obs.content))
|
|
150
|
+
const code = typeof obs.exit_code === "number" ? obs.exit_code : undefined
|
|
151
|
+
result(e, !obs.is_error && (code === undefined || code === 0 || code === -1), texts(obs.content))
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
case "AgentErrorEvent":
|
|
155
|
+
result(e, false, String(e.error ?? ""))
|
|
156
|
+
break
|
|
157
|
+
case "UserRejectObservation":
|
|
158
|
+
result(e, false, `Action rejected: ${String(e.rejection_reason ?? "")}`)
|
|
159
|
+
break
|
|
160
|
+
case "Condensation":
|
|
161
|
+
if (typeof e.summary === "string" && e.summary.trim()) turns.push({ kind: "compaction", ...(when ? { at: when } : {}), summary: e.summary })
|
|
162
|
+
break
|
|
163
|
+
case "CondensationRequest":
|
|
164
|
+
case "CondensationSummaryEvent":
|
|
165
|
+
case "ConversationStateUpdateEvent":
|
|
166
|
+
case "PauseEvent":
|
|
167
|
+
case "InterruptEvent":
|
|
168
|
+
case "TokenEvent":
|
|
169
|
+
case "StreamingDeltaEvent":
|
|
170
|
+
case "LLMCompletionLogEvent":
|
|
171
|
+
case "HookExecutionEvent":
|
|
172
|
+
case "SecurityAnalysisEvent":
|
|
173
|
+
case "ConversationErrorEvent":
|
|
174
|
+
break
|
|
175
|
+
default:
|
|
176
|
+
lost.add(`unknown event kinds: ${String(e.kind)}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
lost.flush(h)
|
|
180
|
+
h.startedAt = first
|
|
181
|
+
h.updatedAt = last ?? mtime(dir)
|
|
182
|
+
const prompt = turns.find((t) => t.kind === "user") as Extract<Turn, { kind: "user" }> | undefined
|
|
183
|
+
if (prompt) h.title = prompt.text.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
184
|
+
return { header: h, turns }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const openhandsReader: Reader = {
|
|
188
|
+
tool: TOOL,
|
|
189
|
+
label: LABEL,
|
|
190
|
+
|
|
191
|
+
async list(home: string): Promise<SessionRef[]> {
|
|
192
|
+
const root = conversationsDir(home)
|
|
193
|
+
const refs: SessionRef[] = []
|
|
194
|
+
for (const id of ls(root)) {
|
|
195
|
+
const dir = join(root, id)
|
|
196
|
+
if (!isDir(dir)) continue
|
|
197
|
+
const files = eventFiles(dir)
|
|
198
|
+
// As the CLI's LocalFileStore lists: a folder with events whose first one has a timestamp.
|
|
199
|
+
const firstEvent = files[0] ? readJson<Ev>(files[0]) : undefined
|
|
200
|
+
if (!firstEvent?.timestamp) continue
|
|
201
|
+
const base = readJson<Ev>(join(dir, "base_state.json"))
|
|
202
|
+
const cwd = (base?.workspace?.working_dir as string | undefined) ?? (firstEvent.kind === "SystemPromptEvent" ? workingDir(texts([firstEvent.dynamic_context])) : undefined)
|
|
203
|
+
const lastEvent = readJson<Ev>(files[files.length - 1]!)
|
|
204
|
+
const title = firstPrompt(files)
|
|
205
|
+
refs.push({
|
|
206
|
+
tool: TOOL,
|
|
207
|
+
sourceId: id,
|
|
208
|
+
path: dir,
|
|
209
|
+
...(title ? { title: title.replace(/\s+/g, " ").trim().slice(0, 80) } : {}),
|
|
210
|
+
...(cwd ? { cwd } : {}),
|
|
211
|
+
updatedAt: eventTime(lastEvent?.timestamp) ?? eventTime(firstEvent.timestamp),
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
async read(ref: SessionRef, home: string): Promise<Session> {
|
|
218
|
+
const dir = ref.path || join(conversationsDir(home), ref.sourceId)
|
|
219
|
+
if (!isDir(dir)) throw new Error(`no OpenHands conversation at ${dir}`)
|
|
220
|
+
return readConversation(dir, ref.sourceId)
|
|
221
|
+
},
|
|
222
|
+
}
|