@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,199 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { versionOf } from "../../fsutil.ts"
|
|
3
|
+
import type { Session, ToolKind, Turn, Usage } from "../../session/types.ts"
|
|
4
|
+
import { asText, clip, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
|
|
5
|
+
import { CONTINUED_NOTE } from "../claude-code/records.ts"
|
|
6
|
+
|
|
7
|
+
// What Goose's sessions look like, shared by the reader and the writer. Checked against goose v1.57.0's
|
|
8
|
+
// source (crates/goose/src/session/session_manager.rs, import_formats/, goose-provider-types
|
|
9
|
+
// conversation/message.rs and tool_result_serde.rs, context_mgmt/mod.rs) and against rows the 1.57.0
|
|
10
|
+
// binary wrote itself into a temporary home. Catalogue: docs/sessions/2026-11-08-session-formats.md, "Goose".
|
|
11
|
+
//
|
|
12
|
+
// Since 1.10 every session is a row of ~/.local/share/goose/sessions/sessions.db (schema version 17 in
|
|
13
|
+
// 1.57.0) and every message a row whose content_json is the message's list of typed blocks. Before that a
|
|
14
|
+
// session was <YYYYMMDD_HHMMSS>.jsonl in the same folder: a metadata line, then one message per line. Goose
|
|
15
|
+
// copies those files into the database the first time it creates it, so they are read only when there is
|
|
16
|
+
// no database yet.
|
|
17
|
+
|
|
18
|
+
export const TOOL = "goose"
|
|
19
|
+
export const LABEL = "Goose"
|
|
20
|
+
|
|
21
|
+
/** Goose versions whose session JSON and import the writer was checked against. */
|
|
22
|
+
export const TESTED_VERSIONS = ["1.57.0"]
|
|
23
|
+
|
|
24
|
+
export const sessionsDir = (home: string) => join(home, ".local", "share", "goose", "sessions")
|
|
25
|
+
export const dbPath = (home: string) => join(sessionsDir(home), "sessions.db")
|
|
26
|
+
|
|
27
|
+
/** "1.57.0" from `goose --version` (which prints " 1.57.0"); undefined when goose is missing. */
|
|
28
|
+
export const gooseVersion = (bin = "goose") => versionOf(bin)?.match(/\d+\.\d+\.\d+/)?.[0]
|
|
29
|
+
|
|
30
|
+
/** The text Goose puts after its own compaction summary (context_mgmt CONVERSATION_CONTINUATION_TEXT). */
|
|
31
|
+
export const CONTINUATION_TEXT =
|
|
32
|
+
"Your context was compacted. The previous message contains a summary of the conversation so far.\nDo not mention that you read a summary or that conversation summarization occurred.\nJust continue the conversation naturally based on the summarized context."
|
|
33
|
+
|
|
34
|
+
/** One message as Goose serialises it (Message in goose-provider-types, camelCase). */
|
|
35
|
+
export interface GooseMessage {
|
|
36
|
+
id?: string | null
|
|
37
|
+
role: string
|
|
38
|
+
created: number
|
|
39
|
+
content: Array<Record<string, any>>
|
|
40
|
+
metadata?: {
|
|
41
|
+
userVisible?: boolean
|
|
42
|
+
agentVisible?: boolean
|
|
43
|
+
turnContext?: boolean
|
|
44
|
+
inference?: { provider?: string; requestedModel?: string; resolvedModel?: string }
|
|
45
|
+
usage?: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number }
|
|
46
|
+
[k: string]: unknown
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a Goose tool does. Goose names a tool `<extension>__<tool>`; the developer extension's are shell,
|
|
52
|
+
* write, edit, tree and read_image in 1.57, and text_editor (with a `command`) in older versions.
|
|
53
|
+
*/
|
|
54
|
+
export function gooseToolKind(name: string, input: unknown): ToolKind {
|
|
55
|
+
const short = name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : name
|
|
56
|
+
if (short === "tree") return "list"
|
|
57
|
+
if (short === "read_image") return "read"
|
|
58
|
+
if (short === "text_editor") {
|
|
59
|
+
const command = (input as { command?: unknown } | null)?.command
|
|
60
|
+
return command === "view" ? "read" : command === "write" ? "write" : "edit"
|
|
61
|
+
}
|
|
62
|
+
return toolKindOf(short)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The text of a tool result's content blocks (MCP CallToolResult content), and how many images it held. */
|
|
66
|
+
function resultText(content: unknown): { text: string; images: number } {
|
|
67
|
+
if (!Array.isArray(content)) return { text: asText(content), images: 0 }
|
|
68
|
+
const parts: string[] = []
|
|
69
|
+
let images = 0
|
|
70
|
+
for (const c of content as Array<Record<string, any>>) {
|
|
71
|
+
if (c?.type === "text" && typeof c.text === "string") parts.push(c.text)
|
|
72
|
+
else if (c?.type === "image") images++
|
|
73
|
+
else if (c?.type === "resource" && typeof c.resource?.text === "string") parts.push(c.resource.text)
|
|
74
|
+
else parts.push(asText(c))
|
|
75
|
+
}
|
|
76
|
+
return { text: parts.join("\n"), images }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Goose stores a failed call as the error's Display, "-32603: <message>", sometimes twice over. */
|
|
80
|
+
const errorText = (e: unknown) => asText(e).replace(/^(?:-?\d+: )+/, "")
|
|
81
|
+
|
|
82
|
+
const visible = (m: GooseMessage) => ({ user: m.metadata?.userVisible !== false, agent: m.metadata?.agentVisible !== false })
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The portable turns of a Goose conversation. Messages the person saw are the history (including those a
|
|
86
|
+
* compaction hid from the model); an agent-only user message after them is Goose's compaction summary;
|
|
87
|
+
* Goose's own continuation text and per-turn context are not conversation. Returns the id of the session a
|
|
88
|
+
* brain-written conversation continues, when its note is there.
|
|
89
|
+
*/
|
|
90
|
+
export function turnsOf(messages: GooseMessage[], h: Session["header"]): { turns: Turn[]; continued?: string } {
|
|
91
|
+
const turns: Turn[] = []
|
|
92
|
+
const lost = lossCounter()
|
|
93
|
+
let continued: string | undefined
|
|
94
|
+
let hiddenBefore = false
|
|
95
|
+
const isContinuation = (m: GooseMessage | undefined) =>
|
|
96
|
+
m?.role === "assistant" && m.metadata?.userVisible === false && m.content?.some((b) => typeof b?.text === "string" && b.text.startsWith("Your context was compacted"))
|
|
97
|
+
for (const [idx, m] of messages.entries()) {
|
|
98
|
+
const at = iso(m.created)
|
|
99
|
+
const stamp = at ? { at } : {}
|
|
100
|
+
const v = visible(m)
|
|
101
|
+
const content = Array.isArray(m.content) ? m.content : []
|
|
102
|
+
const texts = content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text as string)
|
|
103
|
+
if (!v.user && !v.agent) continue
|
|
104
|
+
if (m.metadata?.turnContext) continue
|
|
105
|
+
if (!v.user) {
|
|
106
|
+
const text = texts.join("\n")
|
|
107
|
+
const note = text.match(CONTINUED_NOTE)
|
|
108
|
+
if (note) continued = note[1]
|
|
109
|
+
else if (m.role === "user" && (hiddenBefore || isContinuation(messages[idx + 1])) && text.trim()) turns.push({ kind: "compaction", ...stamp, summary: text })
|
|
110
|
+
else if (!text.startsWith("Your context was compacted")) lost.add("Goose context messages (not carried)")
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!v.agent) hiddenBefore = true
|
|
114
|
+
const model = m.metadata?.inference?.resolvedModel ?? m.metadata?.inference?.requestedModel
|
|
115
|
+
if (m.role === "assistant") noteModel(h, model)
|
|
116
|
+
let usage: Usage | undefined
|
|
117
|
+
const u = m.metadata?.usage
|
|
118
|
+
if (u && m.role === "assistant")
|
|
119
|
+
usage = {
|
|
120
|
+
...(u.inputTokens !== undefined ? { input: u.inputTokens } : {}),
|
|
121
|
+
...(u.outputTokens !== undefined ? { output: u.outputTokens } : {}),
|
|
122
|
+
...(u.cacheReadTokens !== undefined ? { cacheRead: u.cacheReadTokens } : {}),
|
|
123
|
+
...(u.cacheWriteTokens !== undefined ? { cacheWrite: u.cacheWriteTokens } : {}),
|
|
124
|
+
}
|
|
125
|
+
let text = ""
|
|
126
|
+
const flushText = () => {
|
|
127
|
+
if (!text.trim()) return (text = "")
|
|
128
|
+
if (m.role === "user") turns.push({ kind: "user", ...stamp, text })
|
|
129
|
+
else {
|
|
130
|
+
turns.push({ kind: "assistant", ...stamp, text, ...(model ? { model } : {}), ...(usage ? { usage } : {}) })
|
|
131
|
+
usage = undefined
|
|
132
|
+
}
|
|
133
|
+
text = ""
|
|
134
|
+
}
|
|
135
|
+
for (const b of content) {
|
|
136
|
+
switch (b?.type) {
|
|
137
|
+
case "text":
|
|
138
|
+
text += (text ? "\n" : "") + String(b.text ?? "")
|
|
139
|
+
break
|
|
140
|
+
case "thinking":
|
|
141
|
+
flushText()
|
|
142
|
+
// Goose keeps Claude's signature beside the text; the text itself is readable.
|
|
143
|
+
if (typeof b.thinking === "string" && b.thinking.trim()) turns.push({ kind: "reasoning", ...stamp, text: b.thinking })
|
|
144
|
+
break
|
|
145
|
+
case "redactedThinking":
|
|
146
|
+
lost.add("redacted reasoning")
|
|
147
|
+
break
|
|
148
|
+
case "toolRequest": {
|
|
149
|
+
flushText()
|
|
150
|
+
const call = b.toolCall ?? {}
|
|
151
|
+
if (call.status === "success" && call.value && typeof call.value.name === "string") {
|
|
152
|
+
const input = call.value.arguments ?? {}
|
|
153
|
+
turns.push({ kind: "tool_call", ...stamp, id: String(b.id), name: call.value.name, toolKind: gooseToolKind(call.value.name, input), input })
|
|
154
|
+
} else {
|
|
155
|
+
// The model asked for a call Goose could not parse; the error is all there is.
|
|
156
|
+
turns.push({ kind: "tool_call", ...stamp, id: String(b.id), name: "invalid_tool_call", toolKind: "other", input: { error: errorText(call.error) } })
|
|
157
|
+
}
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
case "toolResponse": {
|
|
161
|
+
flushText()
|
|
162
|
+
const r = b.toolResult ?? {}
|
|
163
|
+
let output: string
|
|
164
|
+
let ok: boolean
|
|
165
|
+
if (r.status === "success") {
|
|
166
|
+
const value = Array.isArray(r.value) ? { content: r.value } : (r.value ?? {})
|
|
167
|
+
const { text: out, images } = resultText(value.content)
|
|
168
|
+
if (images) lost.add("images in tool results", images)
|
|
169
|
+
output = out
|
|
170
|
+
ok = value.isError !== true
|
|
171
|
+
} else {
|
|
172
|
+
output = errorText(r.error)
|
|
173
|
+
ok = false
|
|
174
|
+
}
|
|
175
|
+
const c = clip(output)
|
|
176
|
+
turns.push({ kind: "tool_result", ...stamp, callId: String(b.id), ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
case "image":
|
|
180
|
+
lost.add("images")
|
|
181
|
+
break
|
|
182
|
+
case "document":
|
|
183
|
+
lost.add("documents")
|
|
184
|
+
break
|
|
185
|
+
case "toolConfirmationRequest":
|
|
186
|
+
case "actionRequired":
|
|
187
|
+
case "systemNotification":
|
|
188
|
+
case "error":
|
|
189
|
+
// Prompts and notices Goose shows while it works; not part of the conversation.
|
|
190
|
+
break
|
|
191
|
+
default:
|
|
192
|
+
lost.add(`unknown content types: ${String(b?.type)}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
flushText()
|
|
196
|
+
}
|
|
197
|
+
lost.flush(h)
|
|
198
|
+
return { turns, ...(continued ? { continued } : {}) }
|
|
199
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { copyFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname, extname, join, resolve } from "node:path"
|
|
3
|
+
import { toolLabel } from "../../brief.ts"
|
|
4
|
+
import { exists, isDir } from "../../fsutil.ts"
|
|
5
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
6
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
7
|
+
import { continuedNote } from "../claude-code/records.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { gooseReader } from "./reader.ts"
|
|
10
|
+
import { CONTINUATION_TEXT, type GooseMessage, LABEL, TESTED_VERSIONS, TOOL, dbPath, gooseVersion } from "./records.ts"
|
|
11
|
+
|
|
12
|
+
// Continues a portable session in Goose through Goose's own importer: the session is written as the JSON
|
|
13
|
+
// `goose session export --format json` produces (the shape import_formats::build_session_json builds), and
|
|
14
|
+
// `goose session import <file>` stores it as a new session, with an id Goose picks itself (YYYYMMDD_N),
|
|
15
|
+
// in Goose's own transaction. That is safer than inserting rows: Goose migrates its schema, allocates the
|
|
16
|
+
// id and writes with its own locking, so it works while Goose Desktop is open and before the database exists.
|
|
17
|
+
//
|
|
18
|
+
// Tool calls stay structural: toolRequest blocks in an assistant message, answered by toolResponse blocks
|
|
19
|
+
// in the next user message, as Goose writes them. Shell, write and edit calls are mapped onto Goose's
|
|
20
|
+
// developer tools (developer__shell, __write, __edit) when their arguments say what Goose needs; others keep
|
|
21
|
+
// their names. Readable reasoning stays as an unsigned thinking block, which Goose itself makes of plain
|
|
22
|
+
// reasoning. A compaction is written the way Goose compacts: the turns before it stay on screen but hidden
|
|
23
|
+
// from the model, then the summary and Goose's continuation text, visible to the model only. The note
|
|
24
|
+
// saying where the session came from is a model-only message too, so it is never shown as the person's words.
|
|
25
|
+
|
|
26
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
27
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
28
|
+
const NO_RESULT = "No result was recorded for this call; the session moved before it finished."
|
|
29
|
+
|
|
30
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
31
|
+
|
|
32
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
33
|
+
type Meta = NonNullable<GooseMessage["metadata"]>
|
|
34
|
+
const SHOWN: Meta = { userVisible: true, agentVisible: true }
|
|
35
|
+
const MODEL_ONLY: Meta = { userVisible: false, agentVisible: true }
|
|
36
|
+
const SCREEN_ONLY: Meta = { userVisible: true, agentVisible: false }
|
|
37
|
+
const NEITHER: Meta = { userVisible: false, agentVisible: false }
|
|
38
|
+
|
|
39
|
+
const str = (v: unknown) => (typeof v === "string" ? v : undefined)
|
|
40
|
+
|
|
41
|
+
/** A call as Goose's developer extension would have made it, when the arguments carry what Goose needs. */
|
|
42
|
+
export function gooseCall(c: Call): { name: string; args: Record<string, unknown> } | undefined {
|
|
43
|
+
const i = (c.input && typeof c.input === "object" && !Array.isArray(c.input) ? c.input : {}) as Record<string, unknown>
|
|
44
|
+
const path = str(i.file_path) ?? str(i.path) ?? str(i.filePath) ?? str(i.target_file) ?? str(i.absolute_path)
|
|
45
|
+
if (c.toolKind === "shell") {
|
|
46
|
+
const command = str(i.command) ?? str(i.cmd) ?? (Array.isArray(i.command) ? (i.command as unknown[]).map(String).join(" ") : undefined)
|
|
47
|
+
return command ? { name: "developer__shell", args: { command } } : undefined
|
|
48
|
+
}
|
|
49
|
+
if (c.toolKind === "write") {
|
|
50
|
+
const content = str(i.content) ?? str(i.file_text) ?? str(i.contents)
|
|
51
|
+
return path && content !== undefined ? { name: "developer__write", args: { path, content } } : undefined
|
|
52
|
+
}
|
|
53
|
+
if (c.toolKind === "edit") {
|
|
54
|
+
const before = str(i.old_string) ?? str(i.old_str) ?? str(i.oldText)
|
|
55
|
+
const after = str(i.new_string) ?? str(i.new_str) ?? str(i.newText)
|
|
56
|
+
return path && before !== undefined && after !== undefined ? { name: "developer__edit", args: { path, before, after } } : undefined
|
|
57
|
+
}
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface GoosePlan {
|
|
62
|
+
/** The session JSON `goose session import` takes. */
|
|
63
|
+
json: Record<string, unknown>
|
|
64
|
+
lost: string[]
|
|
65
|
+
/** What a read-back must find. */
|
|
66
|
+
expect: { users: number; assistants: number; calls: number }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The import JSON for `session` continued in `cwd`. Pure apart from reading attached images. */
|
|
70
|
+
export function planGooseSession(session: Session, opts: { cwd: string; now: Date }): GoosePlan {
|
|
71
|
+
const drops = new Map<string, number>()
|
|
72
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
73
|
+
const label = toolLabel(session.header.tool)
|
|
74
|
+
const turns = session.turns
|
|
75
|
+
const lastCompaction = turns.map((t) => t.kind).lastIndexOf("compaction")
|
|
76
|
+
const expect = { users: 0, assistants: 0, calls: 0 }
|
|
77
|
+
|
|
78
|
+
// Which calls are answered somewhere, so the ones that never are can be closed where they stand.
|
|
79
|
+
const answered = new Set(turns.filter((t) => t.kind === "tool_result").map((t) => (t as Extract<Turn, { kind: "tool_result" }>).callId))
|
|
80
|
+
|
|
81
|
+
// Tool call ids: providers take [A-Za-z0-9_-]; others are mapped once, the same way for call and result.
|
|
82
|
+
const ids = new Map<string, string>()
|
|
83
|
+
const used = new Set<string>()
|
|
84
|
+
let remapped = 0
|
|
85
|
+
const callId = (id: string) => {
|
|
86
|
+
let v = ids.get(id)
|
|
87
|
+
if (v) return v
|
|
88
|
+
const base = /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : `call_${id.replace(/[^A-Za-z0-9]/g, "").slice(-40) || "x"}`
|
|
89
|
+
v = base
|
|
90
|
+
for (let n = 2; used.has(v); n++) v = `${base}_${n}`
|
|
91
|
+
ids.set(id, v)
|
|
92
|
+
used.add(v)
|
|
93
|
+
if (v !== id) remapped++
|
|
94
|
+
return v
|
|
95
|
+
}
|
|
96
|
+
let renamed = 0
|
|
97
|
+
const toolName = (name: string) => {
|
|
98
|
+
const v = name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
99
|
+
if (v !== name) renamed++
|
|
100
|
+
return v
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Message times are seconds and never go backwards: Goose orders a conversation by them.
|
|
104
|
+
const known = turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
105
|
+
let clock = Math.floor((known ?? opts.now.getTime()) / 1000)
|
|
106
|
+
const created = (at?: string) => {
|
|
107
|
+
const t = at ? Math.floor(Date.parse(at) / 1000) : Number.NaN
|
|
108
|
+
if (Number.isFinite(t) && t > clock) clock = t
|
|
109
|
+
return clock
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const out: GooseMessage[] = []
|
|
113
|
+
/** Set when the next block must open a new message even if the last one has the same role. */
|
|
114
|
+
let split = false
|
|
115
|
+
/** Adds a block to the last message when it has the same role, visibility and kind, else opens a new one. */
|
|
116
|
+
const add = (role: "user" | "assistant", block: Record<string, unknown>, meta: Meta, at?: string) => {
|
|
117
|
+
const last = out[out.length - 1]
|
|
118
|
+
const results = block.type === "toolResponse"
|
|
119
|
+
const same =
|
|
120
|
+
!split &&
|
|
121
|
+
last &&
|
|
122
|
+
last.role === role &&
|
|
123
|
+
last.metadata?.userVisible === meta.userVisible &&
|
|
124
|
+
last.metadata?.agentVisible === meta.agentVisible &&
|
|
125
|
+
(role === "assistant" || last.content.every((b) => (b.type === "toolResponse") === results))
|
|
126
|
+
split = false
|
|
127
|
+
if (same) last.content.push(block)
|
|
128
|
+
else out.push({ id: null, role, created: created(at), content: [block], metadata: { ...meta } })
|
|
129
|
+
}
|
|
130
|
+
const assistantHasCalls = () => {
|
|
131
|
+
const last = out[out.length - 1]
|
|
132
|
+
return last?.role === "assistant" && last.content.some((b) => b.type === "toolRequest")
|
|
133
|
+
}
|
|
134
|
+
const response = (id: string, ok: boolean, output: string, meta: Meta, at?: string) =>
|
|
135
|
+
add("user", { type: "toolResponse", id: callId(id), toolResult: { status: "success", value: { content: [{ type: "text", text: output || "(no output)" }], isError: !ok } } }, meta, at)
|
|
136
|
+
|
|
137
|
+
add("user", { type: "text", text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }, MODEL_ONLY, turns[0]?.at)
|
|
138
|
+
const written = new Set<string>()
|
|
139
|
+
const done = new Set<string>()
|
|
140
|
+
|
|
141
|
+
for (const [i, t] of turns.entries()) {
|
|
142
|
+
const meta = i < lastCompaction ? SCREEN_ONLY : SHOWN
|
|
143
|
+
switch (t.kind) {
|
|
144
|
+
case "user": {
|
|
145
|
+
const blocks: Array<Record<string, unknown>> = []
|
|
146
|
+
let text = t.text
|
|
147
|
+
for (const a of t.attachments ?? []) {
|
|
148
|
+
if (!a.path) continue // bytes the reader could not keep; it already counted them
|
|
149
|
+
if (a.kind === "file") {
|
|
150
|
+
text += `\n\n[Attached file: ${a.path}]`
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
154
|
+
let size = Number.POSITIVE_INFINITY
|
|
155
|
+
try {
|
|
156
|
+
size = statSync(a.path).size
|
|
157
|
+
} catch {}
|
|
158
|
+
if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) blocks.push({ type: "image", data: readFileSync(a.path).toString("base64"), mimeType: mime })
|
|
159
|
+
else drop("images that could not be attached")
|
|
160
|
+
}
|
|
161
|
+
if (!text.trim() && !blocks.length) break
|
|
162
|
+
out.push({ id: null, role: "user", created: created(t.at), content: [{ type: "text", text: text.trim() ? text : "(image)" }, ...blocks], metadata: { ...meta } })
|
|
163
|
+
expect.users++
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
case "assistant":
|
|
167
|
+
if (!t.text.trim()) break
|
|
168
|
+
// Text after a tool call was written after its result came back: a new message.
|
|
169
|
+
if (assistantHasCalls()) split = true
|
|
170
|
+
add("assistant", { type: "text", text: t.text }, meta, t.at)
|
|
171
|
+
expect.assistants++
|
|
172
|
+
break
|
|
173
|
+
case "reasoning":
|
|
174
|
+
if (!t.text.trim()) break
|
|
175
|
+
if (assistantHasCalls()) split = true
|
|
176
|
+
// Goose keeps plain reasoning as thinking without a signature (it migrates `reasoning` blocks so).
|
|
177
|
+
add("assistant", { type: "thinking", thinking: t.text, signature: "" }, meta, t.at)
|
|
178
|
+
break
|
|
179
|
+
case "tool_call": {
|
|
180
|
+
if (written.has(t.id)) {
|
|
181
|
+
drop("repeated tool call ids")
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
written.add(t.id)
|
|
185
|
+
const mapped = gooseCall(t)
|
|
186
|
+
const args = mapped?.args ?? (t.input && typeof t.input === "object" && !Array.isArray(t.input) ? t.input : { input: t.input ?? null })
|
|
187
|
+
add("assistant", { type: "toolRequest", id: callId(t.id), toolCall: { status: "success", value: { name: mapped?.name ?? toolName(t.name), arguments: args } } }, meta, t.at)
|
|
188
|
+
expect.calls++
|
|
189
|
+
if (!answered.has(t.id)) {
|
|
190
|
+
response(t.id, false, NO_RESULT, meta, t.at)
|
|
191
|
+
done.add(t.id)
|
|
192
|
+
drop("tool calls without a result (closed as errors)")
|
|
193
|
+
}
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
case "tool_result": {
|
|
197
|
+
if (!written.has(t.callId)) {
|
|
198
|
+
drop("tool results without a call")
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
if (done.has(t.callId)) {
|
|
202
|
+
drop("repeated tool results")
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
done.add(t.callId)
|
|
206
|
+
response(t.callId, t.ok, t.fullPath ? `${t.output}\n\n[Full output: ${t.fullPath}]` : t.output, meta, t.at)
|
|
207
|
+
break
|
|
208
|
+
}
|
|
209
|
+
case "compaction": {
|
|
210
|
+
const summary = `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}`
|
|
211
|
+
if (i === lastCompaction) {
|
|
212
|
+
add("user", { type: "text", text: summary }, MODEL_ONLY, t.at)
|
|
213
|
+
add("assistant", { type: "text", text: CONTINUATION_TEXT }, MODEL_ONLY, t.at)
|
|
214
|
+
} else add("user", { type: "text", text: summary }, NEITHER, t.at)
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
case "file_edit":
|
|
218
|
+
if (!t.callId || !written.has(t.callId)) drop("file edits outside a tool call")
|
|
219
|
+
break
|
|
220
|
+
case "subagent":
|
|
221
|
+
drop("subagent transcripts (their results stay in the parent's tool results)")
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const conversation = out
|
|
226
|
+
|
|
227
|
+
const title = session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"
|
|
228
|
+
const first = new Date((conversation[0]?.created ?? Math.floor(opts.now.getTime() / 1000)) * 1000).toISOString()
|
|
229
|
+
const last = new Date((conversation[conversation.length - 1]?.created ?? Math.floor(opts.now.getTime() / 1000)) * 1000).toISOString()
|
|
230
|
+
const usage = { input_tokens: null, output_tokens: null, total_tokens: null, cache_read_input_tokens: null, cache_write_input_tokens: null }
|
|
231
|
+
const json = {
|
|
232
|
+
id: "imported",
|
|
233
|
+
working_dir: opts.cwd,
|
|
234
|
+
name: `${title} (from ${label})`,
|
|
235
|
+
user_set_name: true,
|
|
236
|
+
session_type: "user",
|
|
237
|
+
created_at: first,
|
|
238
|
+
updated_at: last,
|
|
239
|
+
extension_data: {},
|
|
240
|
+
usage,
|
|
241
|
+
accumulated_usage: usage,
|
|
242
|
+
accumulated_cost: null,
|
|
243
|
+
schedule_id: null,
|
|
244
|
+
recipe: null,
|
|
245
|
+
user_recipe_values: null,
|
|
246
|
+
conversation,
|
|
247
|
+
message_count: conversation.filter((m) => m.metadata?.userVisible !== false).length,
|
|
248
|
+
provider_name: null,
|
|
249
|
+
model_config: null,
|
|
250
|
+
goose_mode: "auto",
|
|
251
|
+
archived_at: null,
|
|
252
|
+
project_id: null,
|
|
253
|
+
}
|
|
254
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
255
|
+
if (remapped) lost.push(`tool call ids, remapped to ids providers accept (${remapped})`)
|
|
256
|
+
if (renamed) lost.push(`tool names changed to fit Goose (${renamed})`)
|
|
257
|
+
return { json, lost, expect }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The goose binary: the installer's ~/.local/bin/goose first, else whatever is on PATH. */
|
|
261
|
+
function resolveBin(home: string): { path: string; command: string } {
|
|
262
|
+
const own = join(home, ".local", "bin", "goose")
|
|
263
|
+
if (exists(own)) return { path: own, command: Bun.which("goose") === own ? "goose" : own }
|
|
264
|
+
return { path: "goose", command: "goose" }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, "-")
|
|
268
|
+
|
|
269
|
+
/** The Goose writer; `bin` and `version` are for tests, which must not depend on an installed Goose. */
|
|
270
|
+
export function gooseWriter(opts: { bin?: string; version?: string } = {}): Writer {
|
|
271
|
+
const binFor = (home: string) => (opts.bin ? { path: opts.bin, command: opts.bin } : resolveBin(home))
|
|
272
|
+
const installed = (home: string) => opts.version ?? gooseVersion(binFor(home).path)
|
|
273
|
+
return {
|
|
274
|
+
tool: TOOL,
|
|
275
|
+
label: LABEL,
|
|
276
|
+
way: "import",
|
|
277
|
+
|
|
278
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
279
|
+
const v = installed(w.home)
|
|
280
|
+
if (!v) return { ok: false, why: "Goose is not installed (`goose --version` did not run)" }
|
|
281
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Goose ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
282
|
+
if (!isDir(w.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${w.cwd}` }
|
|
283
|
+
return { ok: true }
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
287
|
+
const now = new Date()
|
|
288
|
+
const cwd = resolve(w.cwd)
|
|
289
|
+
const plan = planGooseSession(session, { cwd, now })
|
|
290
|
+
const file = join(w.home, ".brain", "exports", `goose-${stamp(now)}.json`)
|
|
291
|
+
const db = dbPath(w.home)
|
|
292
|
+
const bin = binFor(w.home)
|
|
293
|
+
if (w.dryRun)
|
|
294
|
+
return {
|
|
295
|
+
tool: TOOL,
|
|
296
|
+
way: "import",
|
|
297
|
+
written: [file, db],
|
|
298
|
+
open: `cd ${shellQuote(cwd)} && ${shellQuote(bin.command)} session import ${shellQuote(file)}, then ${shellQuote(bin.command)} session --resume --session-id <the id it prints> --history`,
|
|
299
|
+
lost: plan.lost,
|
|
300
|
+
dryRun: true,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
304
|
+
writeFileSync(file, `${JSON.stringify(plan.json, null, 2)}\n`, { flag: "wx" })
|
|
305
|
+
// Goose writes the rows itself; a copy of the database as it was comes first all the same.
|
|
306
|
+
if (exists(db)) {
|
|
307
|
+
const backup = join(w.home, ".brain", "backups", TOOL, stamp(now))
|
|
308
|
+
mkdirSync(backup, { recursive: true })
|
|
309
|
+
for (const side of ["", "-wal", "-shm"]) if (exists(db + side)) copyFileSync(db + side, join(backup, `sessions.db${side}`))
|
|
310
|
+
}
|
|
311
|
+
const env = { ...process.env, HOME: w.home }
|
|
312
|
+
const run = Bun.spawnSync([bin.path, "session", "import", file], { cwd, env, stdout: "pipe", stderr: "pipe", timeout: 60_000 })
|
|
313
|
+
const printed = run.stdout.toString()
|
|
314
|
+
const id = printed.split("Session imported:")[1]?.trim().match(/^(\S+) - /)?.[1]
|
|
315
|
+
if (run.exitCode !== 0 || !id) throw new Error(`goose session import failed (exit ${run.exitCode}): ${(run.stderr.toString() || printed).trim().slice(0, 500)}`)
|
|
316
|
+
|
|
317
|
+
// Read it back as the reader reads Goose; a session that does not round-trip is removed again.
|
|
318
|
+
const back = await gooseReader.read({ tool: TOOL, sourceId: id, path: db }, w.home)
|
|
319
|
+
const n = countTurns(back)
|
|
320
|
+
const pairs = unpaired(back)
|
|
321
|
+
if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
|
|
322
|
+
Bun.spawnSync([bin.path, "session", "remove", "--session-id", id], { cwd, env, stdout: "pipe", stderr: "pipe", timeout: 60_000 })
|
|
323
|
+
throw new Error(
|
|
324
|
+
`the imported Goose session did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); it was removed`,
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
tool: TOOL,
|
|
329
|
+
way: "import",
|
|
330
|
+
sessionId: id,
|
|
331
|
+
written: [file, db],
|
|
332
|
+
open: `cd ${shellQuote(cwd)} && ${shellQuote(bin.command)} session --resume --session-id ${id} --history`,
|
|
333
|
+
lost: plan.lost,
|
|
334
|
+
dryRun: false,
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export const gooseImportWriter = gooseWriter()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Grok checks every image in chat_history.jsonl when it loads a session (crates/.../session/image_normalize.rs) and moves
|
|
2
|
+
// the WHOLE file aside as chat_history.jsonl.corrupt when one fails, so a writer must not hand it an image it would reject.
|
|
3
|
+
// Measured against Grok 0.2.103 with the real binary: each side must be at least 8 px and the area at least 512 px
|
|
4
|
+
// (22x23 is rejected, 23x23 and 8x64 are accepted); 10000x10000 is accepted (its ceiling is above anything under our 5 MB limit).
|
|
5
|
+
|
|
6
|
+
export const MIN_SIDE = 8
|
|
7
|
+
export const MIN_AREA = 512
|
|
8
|
+
|
|
9
|
+
/** Width and height read from the file header of a PNG, JPEG, GIF or WebP; undefined when it cannot be read. */
|
|
10
|
+
export function imageSize(b: Buffer): { width: number; height: number } | undefined {
|
|
11
|
+
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47 && b.toString("latin1", 12, 16) === "IHDR") return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) }
|
|
12
|
+
if (b.length > 10 && (b.toString("latin1", 0, 6) === "GIF87a" || b.toString("latin1", 0, 6) === "GIF89a")) return { width: b.readUInt16LE(6), height: b.readUInt16LE(8) }
|
|
13
|
+
if (b.length > 30 && b.toString("latin1", 0, 4) === "RIFF" && b.toString("latin1", 8, 12) === "WEBP") {
|
|
14
|
+
const kind = b.toString("latin1", 12, 16)
|
|
15
|
+
if (kind === "VP8X") return { width: 1 + b.readUIntLE(24, 3), height: 1 + b.readUIntLE(27, 3) }
|
|
16
|
+
if (kind === "VP8L" && b[20] === 0x2f) {
|
|
17
|
+
const bits = b.readUInt32LE(21)
|
|
18
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }
|
|
19
|
+
}
|
|
20
|
+
if (kind === "VP8 ") return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff }
|
|
21
|
+
return undefined
|
|
22
|
+
}
|
|
23
|
+
if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
|
|
24
|
+
let i = 2
|
|
25
|
+
while (i + 9 < b.length) {
|
|
26
|
+
if (b[i] !== 0xff) return undefined
|
|
27
|
+
const m = b[i + 1]!
|
|
28
|
+
if (m === 0xff) { i++; continue }
|
|
29
|
+
if (m === 0xd8 || (m >= 0xd0 && m <= 0xd7) || m === 0x01) { i += 2; continue }
|
|
30
|
+
const len = b.readUInt16BE(i + 2)
|
|
31
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: b.readUInt16BE(i + 7), height: b.readUInt16BE(i + 5) }
|
|
32
|
+
i += 2 + len
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Why Grok would reject this image on load, or undefined when it takes it. */
|
|
39
|
+
export function grokRejects(b: Buffer): string | undefined {
|
|
40
|
+
const s = imageSize(b)
|
|
41
|
+
if (!s) return "unreadable image header"
|
|
42
|
+
if (s.width < MIN_SIDE || s.height < MIN_SIDE || s.width * s.height < MIN_AREA) return `too small (${s.width}x${s.height}; Grok needs sides of at least ${MIN_SIDE} px and ${MIN_AREA} px in all)`
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { grokReader } from "./reader.ts"
|
|
3
|
+
import { grokNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// xAI Grok CLI (grok, "Grok Build"): reads its session folders and continues any session as a new
|
|
6
|
+
// native Grok session that `grok --resume` opens.
|
|
7
|
+
export const readers: Reader[] = [grokReader]
|
|
8
|
+
export const writers: Writer[] = [grokNativeWriter]
|