@100xprompt/brain 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import { toolLabel } from "../../brief.ts"
|
|
4
|
+
import { exists, gitInfo, isDir } from "../../fsutil.ts"
|
|
5
|
+
import { imageDataUrl, parseDataUrl } from "../../session/attachments.ts"
|
|
6
|
+
import type { Project, Session, Turn } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { grokRejects } from "./image.ts"
|
|
10
|
+
import { grokReader } from "./reader.ts"
|
|
11
|
+
import {
|
|
12
|
+
LABEL, MAX_FOLDER_NAME, SUMMARY_PREFIX, SYNTHETIC, TESTED_VERSIONS, TOOL,
|
|
13
|
+
asGrokCall, continuedNote, defaultModel, encodeCwd, grokHome, grokVersion, reminder, sessionsDir,
|
|
14
|
+
} from "./records.ts"
|
|
15
|
+
|
|
16
|
+
// Continues a portable session in the Grok CLI as a new session folder with the three files Grok's own
|
|
17
|
+
// sessions need: summary.json (the session list), updates.jsonl (what the screen replays) and
|
|
18
|
+
// chat_history.jsonl (what the model is given). Both logs are written because Grok reads them for
|
|
19
|
+
// different things: a session with only updates.jsonl resumes with the earlier turns on screen and an
|
|
20
|
+
// empty model context; one with only chat_history.jsonl is "not found".
|
|
21
|
+
//
|
|
22
|
+
// Tool calls stay structural in both logs: assistant tool_calls paired with tool_result messages for the
|
|
23
|
+
// model, tool_call / tool_call_update events for the screen, mapped to Grok's own tools where the
|
|
24
|
+
// arguments map (asGrokCall), with ids in Grok's call-<uuid>-<n> style. The note saying where the session
|
|
25
|
+
// came from is a synthetic system reminder, the way Grok marks context it adds itself, and it is not on
|
|
26
|
+
// screen. No system message is written: Grok puts its current system prompt in on load (checked: a
|
|
27
|
+
// stored one, short or long, does not change the context it builds, and none is inserted when missing).
|
|
28
|
+
//
|
|
29
|
+
// A compaction follows Grok's own: the screen log keeps every turn, the model's history starts from the
|
|
30
|
+
// summary. Readable reasoning is shown on screen but not given to the model, which only replays its own
|
|
31
|
+
// encrypted reasoning items.
|
|
32
|
+
|
|
33
|
+
type Row = Record<string, unknown>
|
|
34
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
35
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
36
|
+
|
|
37
|
+
/** Grok's timestamps: RFC 3339 with microseconds. */
|
|
38
|
+
const micros = (ms: number) => new Date(ms).toISOString().replace(/\.(\d{3})Z$/, ".$1000Z")
|
|
39
|
+
|
|
40
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
41
|
+
|
|
42
|
+
/** Function names the chat completions API accepts. */
|
|
43
|
+
const fnName = (name: string) => name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
44
|
+
|
|
45
|
+
/** Arguments must be a JSON object; a freeform input (an apply_patch) is wrapped. */
|
|
46
|
+
const asObject = (input: unknown): Record<string, unknown> =>
|
|
47
|
+
input && typeof input === "object" && !Array.isArray(input) ? (input as Record<string, unknown>) : { input: input ?? null }
|
|
48
|
+
|
|
49
|
+
export interface GrokPlan {
|
|
50
|
+
summary: Row
|
|
51
|
+
updates: Row[]
|
|
52
|
+
chat: Row[]
|
|
53
|
+
lost: string[]
|
|
54
|
+
expect: { users: number; assistants: number; calls: number }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The three files of a new Grok session `id` continuing `session`. Pure: writes nothing. */
|
|
58
|
+
export function planGrokSession(
|
|
59
|
+
session: Session,
|
|
60
|
+
opts: { id: string; now: Date; cwd: string; home: string; model: string; git?: Omit<Project, "cwd"> },
|
|
61
|
+
): GrokPlan {
|
|
62
|
+
const drops = new Map<string, number>()
|
|
63
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
64
|
+
const label = toolLabel(session.header.tool)
|
|
65
|
+
const { id } = opts
|
|
66
|
+
const turns = session.turns
|
|
67
|
+
const lastCompaction = turns.map((t) => t.kind).lastIndexOf("compaction")
|
|
68
|
+
|
|
69
|
+
const results = new Map<string, Result>()
|
|
70
|
+
const callIds = new Set<string>()
|
|
71
|
+
for (const t of turns) {
|
|
72
|
+
if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
73
|
+
if (t.kind === "tool_call") callIds.add(t.id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Grok's call ids: call-<uuid>-<n>, numbered through the session.
|
|
77
|
+
const callUuid = crypto.randomUUID()
|
|
78
|
+
const ids = new Map<string, string>()
|
|
79
|
+
const grokId = (callId: string) => {
|
|
80
|
+
let v = ids.get(callId)
|
|
81
|
+
if (!v) ids.set(callId, (v = `call-${callUuid}-${ids.size}`))
|
|
82
|
+
return v
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Event times never go backwards; a turn without a time takes the one before it plus a millisecond.
|
|
86
|
+
const known = turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
87
|
+
let clock = (known ?? opts.now.getTime()) - 1
|
|
88
|
+
const stamp = (at?: string) => {
|
|
89
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
90
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
91
|
+
return clock
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const updates: Row[] = []
|
|
95
|
+
let events = 0
|
|
96
|
+
let promptId: string | undefined
|
|
97
|
+
const event = (update: Row, at?: string) => {
|
|
98
|
+
const ms = stamp(at)
|
|
99
|
+
events++
|
|
100
|
+
updates.push({
|
|
101
|
+
method: "session/update",
|
|
102
|
+
params: { sessionId: id, update, _meta: { agentTimestampMs: ms, eventId: `${id}-${events}`, ...(promptId ? { promptId } : {}) } },
|
|
103
|
+
timestamp: Math.floor(ms / 1000),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
const endPrompt = () => {
|
|
107
|
+
if (promptId) event({ sessionUpdate: "turn_completed", prompt_id: promptId, stop_reason: "end_turn" })
|
|
108
|
+
promptId = undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const chat: Row[] = [
|
|
112
|
+
{ type: "user", content: [{ type: "text", text: reminder(continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))) }], [SYNTHETIC]: "system_reminder" },
|
|
113
|
+
]
|
|
114
|
+
const expect = { users: 0, assistants: 0, calls: 0 }
|
|
115
|
+
let promptIndex = 0
|
|
116
|
+
let model = session.header.models[session.header.models.length - 1] ?? opts.model
|
|
117
|
+
|
|
118
|
+
// The model's side: an assistant message gathers text and calls until a result comes back.
|
|
119
|
+
let cur: { text: string; model: string; calls: Call[] } | undefined
|
|
120
|
+
const flush = () => {
|
|
121
|
+
if (!cur) return
|
|
122
|
+
const m = cur
|
|
123
|
+
cur = undefined
|
|
124
|
+
if (!m.text.trim() && !m.calls.length) return
|
|
125
|
+
chat.push({
|
|
126
|
+
type: "assistant",
|
|
127
|
+
content: m.text,
|
|
128
|
+
model_id: m.model,
|
|
129
|
+
...(m.calls.length ? { tool_calls: m.calls.map((c) => ({ id: grokId(c.id), name: fnName(asGrokCall(c.toolKind, c.name, c.input).name), arguments: JSON.stringify(asObject(asGrokCall(c.toolKind, c.name, c.input).input)) })) } : {}),
|
|
130
|
+
})
|
|
131
|
+
if (m.text.trim()) expect.assistants++
|
|
132
|
+
for (const c of m.calls) {
|
|
133
|
+
const r = results.get(c.id)
|
|
134
|
+
if (!r) drop("tool calls without a result (closed as failed)")
|
|
135
|
+
chat.push({ type: "tool_result", tool_call_id: grokId(c.id), content: r ? r.output + (r.fullPath ? `\n\n[Full output: ${r.fullPath}]` : "") : "No result was recorded for this call." })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The screen's side, event by event; calls whose result never comes are closed as failed.
|
|
140
|
+
const screenCall = (c: Call) => {
|
|
141
|
+
const g = asGrokCall(c.toolKind, c.name, c.input)
|
|
142
|
+
event(
|
|
143
|
+
{
|
|
144
|
+
sessionUpdate: "tool_call",
|
|
145
|
+
toolCallId: grokId(c.id),
|
|
146
|
+
title: g.tool?.label ?? c.name,
|
|
147
|
+
kind: g.tool?.acp ?? "other",
|
|
148
|
+
status: "pending",
|
|
149
|
+
rawInput: asObject(g.input),
|
|
150
|
+
_meta: { "x.ai/tool": { name: g.name, kind: g.tool?.kind ?? "other", label: g.tool?.label ?? c.name, namespace: g.tool?.namespace ?? "external", read_only: g.tool?.readOnly ?? false, version: 1 } },
|
|
151
|
+
},
|
|
152
|
+
c.at,
|
|
153
|
+
)
|
|
154
|
+
if (!results.has(c.id)) screenResult(c.id, false, "No result was recorded for this call.", c.at)
|
|
155
|
+
}
|
|
156
|
+
const screenResult = (callId: string, ok: boolean, output: string, at?: string) =>
|
|
157
|
+
event({ sessionUpdate: "tool_call_update", toolCallId: grokId(callId), status: ok ? "completed" : "failed", content: [{ type: "content", content: { type: "text", text: output } }] }, at)
|
|
158
|
+
|
|
159
|
+
const seen = new Set<string>()
|
|
160
|
+
const answered = new Set<string>()
|
|
161
|
+
for (const [i, t] of turns.entries()) {
|
|
162
|
+
const toModel = i > lastCompaction
|
|
163
|
+
switch (t.kind) {
|
|
164
|
+
case "user": {
|
|
165
|
+
// Grok stores a pasted image in the user message as a content block {type:"image", url:<data URL>} (found by sending one with --prompt-json).
|
|
166
|
+
const images: Row[] = []
|
|
167
|
+
for (const a of t.attachments ?? []) {
|
|
168
|
+
const url = imageDataUrl(a)
|
|
169
|
+
const bytes = url && toModel ? Buffer.from(parseDataUrl(url)?.base64 ?? "", "base64") : undefined
|
|
170
|
+
// An image Grok would reject on load makes it move the whole history aside, so it stays behind.
|
|
171
|
+
if (url && bytes && !grokRejects(bytes)) images.push({ type: "image", url })
|
|
172
|
+
else drop(url && bytes && toModel ? "attachments Grok would reject (too small or unreadable)" : "attachments", 1)
|
|
173
|
+
}
|
|
174
|
+
if (!t.text.trim()) {
|
|
175
|
+
if (images.length) drop("attachments without text", images.length)
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
endPrompt()
|
|
179
|
+
promptId = crypto.randomUUID()
|
|
180
|
+
event({ sessionUpdate: "user_message_chunk", content: { type: "text", text: t.text }, _meta: { modelId: opts.model, promptIndex } }, t.at)
|
|
181
|
+
if (toModel) {
|
|
182
|
+
flush()
|
|
183
|
+
chat.push({ type: "user", content: [{ type: "text", text: t.text }, ...images], prompt_index: promptIndex })
|
|
184
|
+
}
|
|
185
|
+
promptIndex++
|
|
186
|
+
// The read-back reads the model's history, which starts at the last compaction.
|
|
187
|
+
if (toModel) expect.users++
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
case "assistant": {
|
|
191
|
+
if (t.model) model = t.model
|
|
192
|
+
if (!t.text.trim()) break
|
|
193
|
+
event({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: t.text } }, t.at)
|
|
194
|
+
if (!toModel) break
|
|
195
|
+
// Text after a call is a new response: it was written after the call's result came back.
|
|
196
|
+
if (cur?.calls.length) flush()
|
|
197
|
+
cur ??= { text: "", model, calls: [] }
|
|
198
|
+
cur.text = cur.text ? `${cur.text}\n\n${t.text}` : t.text
|
|
199
|
+
break
|
|
200
|
+
}
|
|
201
|
+
case "tool_call": {
|
|
202
|
+
if (seen.has(t.id)) {
|
|
203
|
+
drop("repeated tool call ids")
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
seen.add(t.id)
|
|
207
|
+
screenCall(t)
|
|
208
|
+
if (toModel) {
|
|
209
|
+
cur ??= { text: "", model, calls: [] }
|
|
210
|
+
cur.calls.push(t)
|
|
211
|
+
expect.calls++
|
|
212
|
+
}
|
|
213
|
+
break
|
|
214
|
+
}
|
|
215
|
+
case "tool_result": {
|
|
216
|
+
if (!callIds.has(t.callId)) {
|
|
217
|
+
drop("tool results without a call")
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
if (answered.has(t.callId)) break
|
|
221
|
+
answered.add(t.callId)
|
|
222
|
+
screenResult(t.callId, t.ok, t.output, t.at)
|
|
223
|
+
// A result closes the response it answers, so a call made after it starts a new one.
|
|
224
|
+
if (toModel && cur?.calls.some((c) => c.id === t.callId)) flush()
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
case "reasoning":
|
|
228
|
+
event({ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: t.text } }, t.at)
|
|
229
|
+
drop("reasoning (shown on screen, not given to the model)")
|
|
230
|
+
break
|
|
231
|
+
case "compaction":
|
|
232
|
+
if (i === lastCompaction)
|
|
233
|
+
chat.push({ type: "user", content: [{ type: "text", text: reminder(`${SUMMARY_PREFIX} in ${label}, which replaced the turns before it]\n\n${t.summary}`) }], [SYNTHETIC]: "system_reminder" })
|
|
234
|
+
break
|
|
235
|
+
case "file_edit":
|
|
236
|
+
if (!t.callId || !callIds.has(t.callId)) drop("file edits outside a tool call")
|
|
237
|
+
break
|
|
238
|
+
case "subagent":
|
|
239
|
+
drop("subagent sessions")
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
flush()
|
|
244
|
+
endPrompt()
|
|
245
|
+
if (!promptIndex && !updates.length) throw new Error("nothing Grok can show: the session has no messages or tool calls")
|
|
246
|
+
|
|
247
|
+
const title = session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"
|
|
248
|
+
const started = turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite) ?? opts.now.getTime()
|
|
249
|
+
const git = opts.git ?? {}
|
|
250
|
+
const summary: Row = {
|
|
251
|
+
info: { id, cwd: opts.cwd },
|
|
252
|
+
session_summary: `${title} (from ${label})`,
|
|
253
|
+
generated_title: `${title} (from ${label})`,
|
|
254
|
+
created_at: micros(started),
|
|
255
|
+
updated_at: micros(opts.now.getTime()),
|
|
256
|
+
last_active_at: micros(opts.now.getTime()),
|
|
257
|
+
num_messages: updates.length,
|
|
258
|
+
num_chat_messages: chat.length,
|
|
259
|
+
current_model_id: opts.model,
|
|
260
|
+
next_trace_turn: 0,
|
|
261
|
+
chat_format_version: 1,
|
|
262
|
+
grok_home: grokHome(opts.home),
|
|
263
|
+
sandbox_profile: "off",
|
|
264
|
+
...(git.gitRemote ? { git_remotes: [git.gitRemote] } : {}),
|
|
265
|
+
...(git.gitBranch ? { head_branch: git.gitBranch } : {}),
|
|
266
|
+
...(git.gitCommit ? { head_commit: git.gitCommit } : {}),
|
|
267
|
+
}
|
|
268
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
269
|
+
if (lastCompaction >= 0) lost.push("turns before the last compaction are on screen only; the model starts from its summary")
|
|
270
|
+
return { summary, updates, chat, lost, expect }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const jsonl = (rows: Row[]) => `${rows.map((r) => JSON.stringify(r)).join("\n")}\n`
|
|
274
|
+
|
|
275
|
+
/** The Grok writer; `version` is for tests, which must not depend on the installed binary. */
|
|
276
|
+
export function grokWriter(opts: { version?: string } = {}): Writer {
|
|
277
|
+
const installed = (home: string) => opts.version ?? grokVersion(home)
|
|
278
|
+
return {
|
|
279
|
+
tool: TOOL,
|
|
280
|
+
label: LABEL,
|
|
281
|
+
way: "native",
|
|
282
|
+
|
|
283
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
284
|
+
const v = installed(w.home)
|
|
285
|
+
if (!v) return { ok: false, why: "the Grok CLI is not installed (`grok --version` did not run)" }
|
|
286
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Grok ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
287
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Grok lists sessions by the folder they belong to` }
|
|
288
|
+
if (Buffer.byteLength(encodeCwd(resolve(w.cwd))) > MAX_FOLDER_NAME) return { ok: false, why: "the folder path is too long: Grok names such folders with a hash this writer does not reproduce" }
|
|
289
|
+
return { ok: true }
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
293
|
+
const cwd = resolve(w.cwd)
|
|
294
|
+
let id = Bun.randomUUIDv7()
|
|
295
|
+
const group = join(sessionsDir(w.home), encodeCwd(cwd))
|
|
296
|
+
while (exists(join(group, id))) id = Bun.randomUUIDv7()
|
|
297
|
+
const dir = join(group, id)
|
|
298
|
+
const plan = planGrokSession(session, { id, now: new Date(), cwd, home: w.home, model: defaultModel(w.home), git: gitInfo(cwd, w.home) })
|
|
299
|
+
const files = { "summary.json": `${JSON.stringify(plan.summary, null, 2)}\n`, "updates.jsonl": jsonl(plan.updates), "chat_history.jsonl": jsonl(plan.chat) }
|
|
300
|
+
if (!w.dryRun) {
|
|
301
|
+
mkdirSync(group, { recursive: true })
|
|
302
|
+
// A new folder only: an existing session is never touched.
|
|
303
|
+
mkdirSync(dir)
|
|
304
|
+
try {
|
|
305
|
+
for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text, { flag: "wx" })
|
|
306
|
+
const back = await grokReader.read({ tool: TOOL, sourceId: id, path: dir }, w.home)
|
|
307
|
+
const n = countTurns(back)
|
|
308
|
+
const pairs = unpaired(back)
|
|
309
|
+
if (n.user !== plan.expect.users || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
310
|
+
throw new Error(`the new Grok session did not read back intact (users ${n.user}/${plan.expect.users}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length})`)
|
|
311
|
+
} catch (e) {
|
|
312
|
+
rmSync(dir, { recursive: true, force: true })
|
|
313
|
+
throw new Error(`${(e as Error).message}; nothing was kept`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
tool: TOOL,
|
|
318
|
+
way: "native",
|
|
319
|
+
sessionId: id,
|
|
320
|
+
written: Object.keys(files).map((f) => join(dir, f)),
|
|
321
|
+
open: `cd ${shellQuote(cwd)} && grok --resume ${id}`,
|
|
322
|
+
lost: plan.lost,
|
|
323
|
+
dryRun: !!w.dryRun,
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export const grokNativeWriter = grokWriter()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { hermesReader } from "./reader.ts"
|
|
3
|
+
import { hermesNativeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// Hermes Agent (Nous Research): reads the sessions in ~/.hermes/state.db, and continues any session as a new
|
|
6
|
+
// native session in that store (backed up first; refused while Hermes holds it locked).
|
|
7
|
+
export const readers: Reader[] = [hermesReader]
|
|
8
|
+
export const writers: Writer[] = [hermesNativeWriter]
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { exists, gitInfo } from "../../fsutil.ts"
|
|
3
|
+
import { keepImage, parseDataUrl } from "../../session/attachments.ts"
|
|
4
|
+
import type { Attachment, Session, SessionHeader, Turn } from "../../session/types.ts"
|
|
5
|
+
import { clip, header, iso, lossCounter, noteModel } from "../../session/util.ts"
|
|
6
|
+
import { withSnapshot } from "../../sqlite.ts"
|
|
7
|
+
import { CONTINUED_NOTE } from "../opencode/note.ts"
|
|
8
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
9
|
+
import { LABEL, TOOL, columnsOf, decodeContent, hermesToolKind, imageUrlOf, partsText, storePath, summaryOf, toolOutput, type Part } from "./records.ts"
|
|
10
|
+
|
|
11
|
+
// Reads Hermes Agent sessions from ~/.hermes/state.db (records.ts), always from a copy with its write-ahead log,
|
|
12
|
+
// as Hermes keeps the store open in WAL mode. Columns are looked up per store, so the schema-19 store of 0.18.2
|
|
13
|
+
// and the schema-26 store of 0.20.6 read alike. Only Hermes's active replay context becomes turns, as
|
|
14
|
+
// session-migrate projects it: rewound rows and rows a compaction replaced are counted in `lost`, never replayed.
|
|
15
|
+
//
|
|
16
|
+
// The list shows what Hermes's own session picker shows: no delegate (subagent) runs, no archived or hidden
|
|
17
|
+
// sessions, and for a session that was compressed into a continuation only the continuation, which starts with
|
|
18
|
+
// the summary and names the session it continues as its parent.
|
|
19
|
+
|
|
20
|
+
type Row = Record<string, any>
|
|
21
|
+
|
|
22
|
+
const json = (v: unknown): any => {
|
|
23
|
+
if (typeof v !== "string" || !v) return undefined
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(v)
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const text = (v: unknown) => (typeof v === "string" && v ? v : undefined)
|
|
31
|
+
const on = (v: unknown) => v === 1 || v === "1" || v === true
|
|
32
|
+
|
|
33
|
+
/** The newest moment a session row or its messages record. */
|
|
34
|
+
const latest = (...vs: unknown[]) => {
|
|
35
|
+
const ts = vs.map((v) => (typeof v === "number" && Number.isFinite(v) ? v : 0))
|
|
36
|
+
return iso(Math.max(...ts))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function listHermes(d: Database, path: string): SessionRef[] {
|
|
40
|
+
const cols = columnsOf(d, "sessions")
|
|
41
|
+
const pick = (c: string) => (cols.has(c) ? `s.${c}` : "NULL")
|
|
42
|
+
const rows = d
|
|
43
|
+
.query(
|
|
44
|
+
`SELECT s.id, s.title, s.cwd, s.started_at, s.ended_at, s.end_reason, s.source, s.model_config,
|
|
45
|
+
${pick("archived")} AS archived, ${pick("hidden")} AS hidden, ${pick("last_activity_at")} AS last_activity_at,
|
|
46
|
+
(SELECT MAX(m.timestamp) FROM messages m WHERE m.session_id = s.id) AS last,
|
|
47
|
+
EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id AND COALESCE(m.active, 1) = 1) AS live,
|
|
48
|
+
EXISTS (SELECT 1 FROM sessions c WHERE c.parent_session_id = s.id) AS has_child
|
|
49
|
+
FROM sessions s`,
|
|
50
|
+
)
|
|
51
|
+
.all() as Row[]
|
|
52
|
+
const refs: SessionRef[] = []
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
if (!on(r.live) || on(r.archived) || on(r.hidden) || r.source === "tool") continue
|
|
55
|
+
if (json(r.model_config)?._delegate_from) continue
|
|
56
|
+
// Compressed into a continuation: the continuation is the live conversation (resolve_resume_session_id).
|
|
57
|
+
if (r.end_reason === "compression" && on(r.has_child)) continue
|
|
58
|
+
const updatedAt = latest(r.last, r.ended_at, r.last_activity_at, r.started_at)
|
|
59
|
+
refs.push({
|
|
60
|
+
tool: TOOL,
|
|
61
|
+
sourceId: String(r.id),
|
|
62
|
+
path,
|
|
63
|
+
...(text(r.title) ? { title: String(r.title) } : {}),
|
|
64
|
+
...(text(r.cwd) ? { cwd: String(r.cwd) } : {}),
|
|
65
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return refs
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What kind of child a session is of its parent: a /branch, a delegate run, or a compression continuation. */
|
|
72
|
+
function parentOf(d: Database, s: Row): SessionHeader["parent"] {
|
|
73
|
+
if (!s.parent_session_id) return undefined
|
|
74
|
+
const id = `${TOOL}:${s.parent_session_id}`
|
|
75
|
+
const config = json(s.model_config) ?? {}
|
|
76
|
+
if (config._delegate_from) return { id, kind: "subagent" }
|
|
77
|
+
if (config._branched_from) return { id, kind: "fork" }
|
|
78
|
+
const p = d.query("SELECT end_reason FROM sessions WHERE id = ?").get(s.parent_session_id) as { end_reason?: unknown } | null
|
|
79
|
+
return { id, kind: p?.end_reason === "branched" ? "fork" : "continued" }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A tool call's arguments: JSON text as Hermes stores it, parsed when it is JSON. */
|
|
83
|
+
function argsOf(v: unknown): unknown {
|
|
84
|
+
if (v && typeof v === "object") return v
|
|
85
|
+
if (typeof v !== "string") return {}
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(v)
|
|
88
|
+
} catch {
|
|
89
|
+
return { input: v }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether OpenRouter-style reasoning_details carry a signature or encrypted data that cannot cross tools. */
|
|
94
|
+
const signedDetails = (v: unknown) =>
|
|
95
|
+
Array.isArray(v) && v.some((x) => x && typeof x === "object" && (x.signature || x.data || x.encrypted_content || String(x.type ?? "").includes("encrypted")))
|
|
96
|
+
|
|
97
|
+
/** One session from an open Hermes store. */
|
|
98
|
+
export function readHermes(d: Database, id: string, where: { path: string; home: string }): Session {
|
|
99
|
+
const s = d.query("SELECT * FROM sessions WHERE id = ?").get(id) as Row | null
|
|
100
|
+
if (!s) throw new Error(`no Hermes session ${id} in ${where.path}`)
|
|
101
|
+
const cwd = text(s.cwd)
|
|
102
|
+
const branch = text(s.git_branch)
|
|
103
|
+
const h = header(TOOL, id, {
|
|
104
|
+
sourcePath: where.path,
|
|
105
|
+
...(text(s.title) ? { title: String(s.title) } : {}),
|
|
106
|
+
project: { ...(cwd ? { cwd, ...gitInfo(cwd, where.home) } : {}), ...(branch ? { gitBranch: branch } : {}) },
|
|
107
|
+
...(iso(s.started_at) ? { startedAt: iso(s.started_at)! } : {}),
|
|
108
|
+
})
|
|
109
|
+
noteModel(h, text(s.model))
|
|
110
|
+
// Models the session's API calls used (schema 2x keeps a usage row per model).
|
|
111
|
+
if (columnsOf(d, "session_model_usage").size)
|
|
112
|
+
for (const u of d.query("SELECT model FROM session_model_usage WHERE session_id = ? ORDER BY first_seen").all(id) as Row[]) noteModel(h, text(u.model))
|
|
113
|
+
const parent = parentOf(d, s)
|
|
114
|
+
if (parent) h.parent = parent
|
|
115
|
+
|
|
116
|
+
const lost = lossCounter()
|
|
117
|
+
const turns: Turn[] = []
|
|
118
|
+
const emitted = new Set<string>()
|
|
119
|
+
/** Calls still waiting for their result, by the id Hermes stored (duplicates queue up). */
|
|
120
|
+
const waiting = new Map<string, string[]>()
|
|
121
|
+
let last = 0
|
|
122
|
+
/** Whether a prompt was read yet: brain's note opens the first one. */
|
|
123
|
+
let prompted = false
|
|
124
|
+
|
|
125
|
+
for (const r of d.query("SELECT * FROM messages WHERE session_id = ? ORDER BY id").all(id) as Row[]) {
|
|
126
|
+
if (typeof r.timestamp === "number" && r.timestamp > last) last = r.timestamp
|
|
127
|
+
// Hermes heals a NULL active to 1 on startup; only 0 is out of the replay.
|
|
128
|
+
if (r.active !== null && r.active !== undefined && !on(r.active)) {
|
|
129
|
+
lost.add(on(r.compacted) ? "turns Hermes compacted away (still in its store)" : "rewound turns")
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
const role = String(r.role ?? "")
|
|
133
|
+
if (role === "session_meta") continue
|
|
134
|
+
if (on(r.observed)) {
|
|
135
|
+
lost.add("group-chat messages Hermes only observed")
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
const at = iso(r.timestamp)
|
|
139
|
+
const stamp = at ? { at } : {}
|
|
140
|
+
const content = decodeContent(r.content)
|
|
141
|
+
let body = partsText(content)
|
|
142
|
+
|
|
143
|
+
if (role === "user" || role === "assistant") {
|
|
144
|
+
const sum = summaryOf(body, on(r._compressed_summary))
|
|
145
|
+
if (sum) {
|
|
146
|
+
if (sum.summary) turns.push({ kind: "compaction", ...stamp, summary: sum.summary })
|
|
147
|
+
body = sum.prior ?? ""
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const parts = typeof content === "string" ? [] : content
|
|
151
|
+
const others = (keep: (p: Part) => boolean) => parts.filter((p) => p.type !== "text" && !keep(p))
|
|
152
|
+
|
|
153
|
+
if (role === "user") {
|
|
154
|
+
if (!prompted) {
|
|
155
|
+
const note = body.match(CONTINUED_NOTE)
|
|
156
|
+
if (note) {
|
|
157
|
+
// The note brain puts before the first message it carries over, not something the person wrote.
|
|
158
|
+
h.parent ??= { id: note[1]!, kind: "continued" }
|
|
159
|
+
body = body.replace(/^\[This session continues [^\n]*\]/, "").trimStart()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const attachments: Attachment[] = []
|
|
163
|
+
for (const p of parts) {
|
|
164
|
+
const url = imageUrlOf(p)
|
|
165
|
+
if (url === undefined) continue
|
|
166
|
+
const data = parseDataUrl(url)
|
|
167
|
+
const kept = data ? keepImage(data.base64, data.mime) : undefined
|
|
168
|
+
if (kept) attachments.push(kept)
|
|
169
|
+
else lost.add("images")
|
|
170
|
+
}
|
|
171
|
+
for (const p of others((p) => imageUrlOf(p) !== undefined)) lost.add(`unknown content parts: ${String(p.type)}`)
|
|
172
|
+
if (body.trim() || attachments.length) {
|
|
173
|
+
turns.push({ kind: "user", ...stamp, text: body, ...(attachments.length ? { attachments } : {}) })
|
|
174
|
+
prompted = true
|
|
175
|
+
}
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (role === "assistant") {
|
|
180
|
+
// Hermes keeps readable reasoning in `reasoning` (and copies it to reasoning_content for providers that
|
|
181
|
+
// want it echoed); reasoning_details and Codex reasoning items carry signed or encrypted reasoning.
|
|
182
|
+
const reasoning = [r.reasoning, r.reasoning_content].find((v) => typeof v === "string" && v.trim()) as string | undefined
|
|
183
|
+
const details = json(r.reasoning_details)
|
|
184
|
+
if (reasoning) turns.push({ kind: "reasoning", ...stamp, text: reasoning })
|
|
185
|
+
if (signedDetails(details)) lost.add(reasoning ? "reasoning signatures" : "signed reasoning")
|
|
186
|
+
if (json(r.codex_reasoning_items)) lost.add("encrypted reasoning")
|
|
187
|
+
for (const p of others(() => false)) lost.add(`unknown content parts: ${String(p.type)}`)
|
|
188
|
+
if (body.trim()) turns.push({ kind: "assistant", ...stamp, text: body })
|
|
189
|
+
const calls = r.tool_calls ? json(r.tool_calls) : []
|
|
190
|
+
if (!Array.isArray(calls)) {
|
|
191
|
+
lost.add("unreadable tool calls")
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
for (const c of calls as Row[]) {
|
|
195
|
+
const fn = (c?.function ?? {}) as Row
|
|
196
|
+
const stored = text(c?.id) ?? text(c?.call_id) ?? ""
|
|
197
|
+
const name = text(fn.name) ?? "unknown"
|
|
198
|
+
let callId = stored
|
|
199
|
+
for (let n = 2; !callId || emitted.has(callId); n++) callId = `${stored || "call"}#${n}`
|
|
200
|
+
emitted.add(callId)
|
|
201
|
+
waiting.set(stored, [...(waiting.get(stored) ?? []), callId])
|
|
202
|
+
if (c?.extra_content) lost.add("provider signatures on tool calls")
|
|
203
|
+
turns.push({ kind: "tool_call", ...stamp, id: callId, name, toolKind: hermesToolKind(name), input: argsOf(fn.arguments) })
|
|
204
|
+
}
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (role === "tool") {
|
|
209
|
+
const stored = text(r.tool_call_id) ?? ""
|
|
210
|
+
const queue = waiting.get(stored)
|
|
211
|
+
const callId = queue?.shift()
|
|
212
|
+
if (!callId) {
|
|
213
|
+
lost.add(emitted.has(stored) ? "repeated tool results" : "tool results without a call")
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const images = parts.filter((p) => imageUrlOf(p) !== undefined).length
|
|
217
|
+
if (images) lost.add("images in tool results", images)
|
|
218
|
+
const out = toolOutput(content)
|
|
219
|
+
const c = clip(out.text)
|
|
220
|
+
turns.push({ kind: "tool_result", ...stamp, callId, ok: out.ok, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lost.add(`messages with role ${role || "(none)"}`)
|
|
225
|
+
}
|
|
226
|
+
lost.flush(h)
|
|
227
|
+
const updatedAt = latest(last, s.ended_at, s.last_activity_at, s.started_at)
|
|
228
|
+
if (updatedAt) h.updatedAt = updatedAt
|
|
229
|
+
return { header: h, turns }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const hermesReader: Reader = {
|
|
233
|
+
tool: TOOL,
|
|
234
|
+
label: LABEL,
|
|
235
|
+
async list(home) {
|
|
236
|
+
const db = storePath(home)
|
|
237
|
+
if (!exists(db)) return []
|
|
238
|
+
try {
|
|
239
|
+
return withSnapshot(db, (d) => listHermes(d, db)).sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
240
|
+
} catch {
|
|
241
|
+
// A store that cannot be copied or opened lists nothing; read() says why.
|
|
242
|
+
return []
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
async read(ref, home) {
|
|
246
|
+
if (!exists(ref.path)) throw new Error(`Hermes session store not found: ${ref.path}`)
|
|
247
|
+
return withSnapshot(ref.path, (d) => readHermes(d, ref.sourceId, { path: ref.path, home }))
|
|
248
|
+
},
|
|
249
|
+
}
|