@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,452 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { mkdirSync } from "node:fs"
|
|
3
|
+
import { basename, join, resolve } from "node:path"
|
|
4
|
+
import { toolLabel } from "../../brief.ts"
|
|
5
|
+
import { exists, gitInfo, isDir } from "../../fsutil.ts"
|
|
6
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
8
|
+
import { withSnapshot } from "../../sqlite.ts"
|
|
9
|
+
import { continuedNote } from "../opencode/note.ts"
|
|
10
|
+
import { random62 } from "../opencode/ids.ts"
|
|
11
|
+
import { sh } from "../opencode/writer.ts"
|
|
12
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
13
|
+
import { readHermes } from "./reader.ts"
|
|
14
|
+
import {
|
|
15
|
+
LABEL,
|
|
16
|
+
MAX_SCHEMA,
|
|
17
|
+
MIN_SCHEMA,
|
|
18
|
+
SESSION_ID,
|
|
19
|
+
TESTED_VERSIONS,
|
|
20
|
+
TOOL,
|
|
21
|
+
columnsOf,
|
|
22
|
+
hermesBin,
|
|
23
|
+
hermesCall,
|
|
24
|
+
hermesVersion,
|
|
25
|
+
newSessionId,
|
|
26
|
+
pyJson,
|
|
27
|
+
responseItemId,
|
|
28
|
+
schemaVersion,
|
|
29
|
+
storePath,
|
|
30
|
+
summaryText,
|
|
31
|
+
toolContent,
|
|
32
|
+
} from "./records.ts"
|
|
33
|
+
|
|
34
|
+
// Continues a portable session in Hermes Agent as a new session of its own store, ~/.hermes/state.db, with the
|
|
35
|
+
// rows Hermes itself writes (SessionDB.append_message / _insert_message_rows in hermes_state.py): a user row per
|
|
36
|
+
// prompt, an assistant row per model response (its text, readable reasoning in `reasoning`, its calls as an
|
|
37
|
+
// OpenAI `tool_calls` list), and a `tool` row per result right after the call that asked for it. The note saying
|
|
38
|
+
// where the session came from opens the first prompt the model sees, as Hermes opens a prompt with its own
|
|
39
|
+
// image-fallback notes. A compaction is written as Hermes compacts (archive_and_compact): the turns before the
|
|
40
|
+
// last summary are kept but out of the replay (active = 0, compacted = 1), then the summary as a user message
|
|
41
|
+
// with Hermes's legacy summary prefix (flagged _compressed_summary where the store has that column).
|
|
42
|
+
//
|
|
43
|
+
// session-migrate installs through Hermes's own SessionDB.import_sessions; this writer inserts the same rows
|
|
44
|
+
// itself, in one immediate transaction, setting only columns the target store has, so it writes the schema-19
|
|
45
|
+
// store of Hermes 0.18.2 and the schema-26 store of 0.20.6 alike; the store's own triggers keep its FTS5 indexes
|
|
46
|
+
// in sync. The session is read back inside the transaction and rolled back unless it reads as written. Hermes
|
|
47
|
+
// shares the store between its CLI, gateway and cron processes (WAL, short write transactions with retries), so
|
|
48
|
+
// a new session can go in while Hermes runs, as its own processes do; when the store stays locked the write is
|
|
49
|
+
// refused. The store is backed up first with SQLite's VACUUM INTO, a consistent copy even with Hermes running.
|
|
50
|
+
// A missing store is not created: Hermes creates and migrates its own schema the first time it runs.
|
|
51
|
+
|
|
52
|
+
const NO_RESULT = "No result was recorded for this call; the session moved before it finished."
|
|
53
|
+
|
|
54
|
+
/** The columns the writer cannot do without; anything else is written only where the store has it. */
|
|
55
|
+
const NEEDED = {
|
|
56
|
+
sessions: ["id", "source", "model", "parent_session_id", "started_at", "ended_at", "end_reason", "message_count", "tool_call_count", "cwd", "title"],
|
|
57
|
+
messages: ["id", "session_id", "role", "content", "tool_call_id", "tool_calls", "tool_name", "timestamp", "finish_reason", "reasoning", "active", "compacted"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One messages row before it is inserted (columns the store lacks are left out then). */
|
|
61
|
+
export interface MessageRow {
|
|
62
|
+
role: "user" | "assistant" | "tool"
|
|
63
|
+
content: string
|
|
64
|
+
tool_call_id: string | null
|
|
65
|
+
tool_calls: string | null
|
|
66
|
+
tool_name: string | null
|
|
67
|
+
timestamp: number
|
|
68
|
+
finish_reason: string | null
|
|
69
|
+
reasoning: string | null
|
|
70
|
+
observed: 0
|
|
71
|
+
active: 0 | 1
|
|
72
|
+
compacted: 0 | 1
|
|
73
|
+
_compressed_summary: 0 | 1
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HermesPlan {
|
|
77
|
+
id: string
|
|
78
|
+
/** The title before it is made unique in the store (Hermes keeps titles unique: "title #2"). */
|
|
79
|
+
title: string
|
|
80
|
+
model: string | null
|
|
81
|
+
/** When the session is created (seconds): its start, as its id says, though its messages keep their own times. */
|
|
82
|
+
createdAt: number
|
|
83
|
+
messages: MessageRow[]
|
|
84
|
+
lost: string[]
|
|
85
|
+
/** What a read-back must find among the replayed (active) rows. */
|
|
86
|
+
expect: { users: number; assistants: number; calls: number; reasoning: number; compactions: number }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clean = (s: string) =>
|
|
90
|
+
s
|
|
91
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff\ufffc\ufff9-\ufffb]/g, "")
|
|
92
|
+
.replace(/\s+/g, " ")
|
|
93
|
+
.trim()
|
|
94
|
+
|
|
95
|
+
/** The session's title marked with where it came from, within Hermes's 100 characters (with room for " #N"). */
|
|
96
|
+
export function hermesTitle(s: Session, label: string): string {
|
|
97
|
+
const suffix = ` (from ${label})`
|
|
98
|
+
const room = 100 - suffix.length - 5
|
|
99
|
+
const base = clean(s.header.title ?? firstUserText(s) ?? "") || "Continued session"
|
|
100
|
+
return `${base.length > room ? `${base.slice(0, room - 1).trimEnd()}…` : base}${suffix}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The rows of a new Hermes session continuing `session`. Pure: reads and writes nothing. */
|
|
104
|
+
export function planHermes(session: Session, opts: { id: string; now: Date }): HermesPlan {
|
|
105
|
+
const drops = new Map<string, number>()
|
|
106
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
107
|
+
const turns = session.turns as Turn[]
|
|
108
|
+
const label = session.header.tool === TOOL ? LABEL : toolLabel(session.header.tool)
|
|
109
|
+
const lastCompaction = turns.map((t) => t.kind).lastIndexOf("compaction")
|
|
110
|
+
const expect = { users: 0, assistants: 0, calls: 0, reasoning: 0, compactions: 0 }
|
|
111
|
+
const messages: MessageRow[] = []
|
|
112
|
+
|
|
113
|
+
// Times are seconds and never go backwards (Hermes replays in id order, but lists and searches by time).
|
|
114
|
+
const known = turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
115
|
+
let clock = (Date.parse(session.header.startedAt ?? "") || known || opts.now.getTime() - turns.length * 1000) / 1000 - 0.001
|
|
116
|
+
const tick = (at?: string) => {
|
|
117
|
+
const t = at ? Date.parse(at) / 1000 : Number.NaN
|
|
118
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 0.001
|
|
119
|
+
return clock
|
|
120
|
+
}
|
|
121
|
+
const row = (r: Pick<MessageRow, "role" | "content"> & Partial<MessageRow>, at: number, archived: boolean): MessageRow => ({
|
|
122
|
+
tool_call_id: null,
|
|
123
|
+
tool_calls: null,
|
|
124
|
+
tool_name: null,
|
|
125
|
+
finish_reason: null,
|
|
126
|
+
reasoning: null,
|
|
127
|
+
observed: 0,
|
|
128
|
+
_compressed_summary: 0,
|
|
129
|
+
...r,
|
|
130
|
+
timestamp: at,
|
|
131
|
+
active: archived ? 0 : 1,
|
|
132
|
+
compacted: archived ? 1 : 0,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// The first result of every call, written right after the call (OpenAI order), wherever it sat in the source.
|
|
136
|
+
const callIds = new Set(turns.filter((t) => t.kind === "tool_call").map((t) => (t as Extract<Turn, { kind: "tool_call" }>).id))
|
|
137
|
+
const results = new Map<string, Extract<Turn, { kind: "tool_result" }>>()
|
|
138
|
+
for (const t of turns) if (t.kind === "tool_result" && callIds.has(t.callId) && !results.has(t.callId)) results.set(t.callId, t)
|
|
139
|
+
|
|
140
|
+
// Tool-call ids go to the provider on every replay: ids providers reject are replaced (results follow their call).
|
|
141
|
+
const used = new Set<string>()
|
|
142
|
+
let replaced = 0
|
|
143
|
+
const idFor = (src: string) => {
|
|
144
|
+
let id = /^[A-Za-z0-9_-]{1,64}$/.test(src) && !used.has(src) ? src : ""
|
|
145
|
+
if (!id) {
|
|
146
|
+
replaced++
|
|
147
|
+
do id = `call_${random62(24)}`
|
|
148
|
+
while (used.has(id))
|
|
149
|
+
}
|
|
150
|
+
used.add(id)
|
|
151
|
+
return id
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const note = continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10))
|
|
155
|
+
let noted = false
|
|
156
|
+
const noteRow = () => {
|
|
157
|
+
messages.push(row({ role: "user", content: note }, tick(), false))
|
|
158
|
+
noted = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type Pending = { src: string; id: string; name: string; terminal: boolean }
|
|
162
|
+
let step: { row: MessageRow; text: string[]; reasoning: string[]; calls: Array<Record<string, unknown>>; pending: Pending[]; archived: boolean } | undefined
|
|
163
|
+
const written = new Set<string>()
|
|
164
|
+
let renamed = 0
|
|
165
|
+
|
|
166
|
+
const open = (archived: boolean, at?: string) => {
|
|
167
|
+
if (step && step.archived !== archived) close()
|
|
168
|
+
if (step) return step
|
|
169
|
+
if (!archived && !noted) noteRow()
|
|
170
|
+
step = { row: row({ role: "assistant", content: "" }, tick(at), archived), text: [], reasoning: [], calls: [], pending: [], archived }
|
|
171
|
+
return step
|
|
172
|
+
}
|
|
173
|
+
function close() {
|
|
174
|
+
if (!step) return
|
|
175
|
+
const { row: r, text, reasoning, calls, pending, archived } = step
|
|
176
|
+
step = undefined
|
|
177
|
+
if (!text.length && !calls.length) {
|
|
178
|
+
drop("reasoning with no reply to attach it to")
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
r.content = text.join("\n\n")
|
|
182
|
+
r.reasoning = reasoning.length ? reasoning.join("\n\n") : null
|
|
183
|
+
r.tool_calls = calls.length ? pyJson(calls) : null
|
|
184
|
+
r.finish_reason = calls.length ? "tool_calls" : "stop"
|
|
185
|
+
messages.push(r)
|
|
186
|
+
if (!archived) {
|
|
187
|
+
if (text.length) expect.assistants++
|
|
188
|
+
if (reasoning.length) expect.reasoning++
|
|
189
|
+
expect.calls += calls.length
|
|
190
|
+
}
|
|
191
|
+
for (const p of pending) {
|
|
192
|
+
const res = results.get(p.src)
|
|
193
|
+
if (!res) drop("tool calls with no result, closed as failed")
|
|
194
|
+
const output = res ? (res.fullPath ? `${res.output}\n\n[Full output: ${res.fullPath}]` : res.output) : NO_RESULT
|
|
195
|
+
messages.push(
|
|
196
|
+
row({ role: "tool", content: toolContent(output, res?.ok ?? false, p.terminal), tool_call_id: p.id, tool_name: p.name }, tick(res?.at), archived),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const [i, t] of turns.entries()) {
|
|
202
|
+
const archived = i < lastCompaction
|
|
203
|
+
switch (t.kind) {
|
|
204
|
+
case "user": {
|
|
205
|
+
// Images travel as their path, which Hermes's vision_analyze tool can open (as its own fallback note does).
|
|
206
|
+
const extra: string[] = []
|
|
207
|
+
for (const a of t.attachments ?? []) {
|
|
208
|
+
if (a.path) {
|
|
209
|
+
extra.push(a.kind === "image" ? `[The user attached an image: ${a.path}]` : `[The user attached a file: ${a.path}]`)
|
|
210
|
+
if (a.kind === "image") drop("images (passed as a file path)")
|
|
211
|
+
} else drop("attachments without their bytes")
|
|
212
|
+
}
|
|
213
|
+
let text = [t.text.trim() ? t.text : "", ...extra].filter(Boolean).join("\n\n")
|
|
214
|
+
if (!text) break
|
|
215
|
+
close()
|
|
216
|
+
if (!archived && !noted) {
|
|
217
|
+
text = `${note}\n\n${text}`
|
|
218
|
+
noted = true
|
|
219
|
+
}
|
|
220
|
+
messages.push(row({ role: "user", content: text }, tick(t.at), archived))
|
|
221
|
+
if (!archived) expect.users++
|
|
222
|
+
break
|
|
223
|
+
}
|
|
224
|
+
case "assistant": {
|
|
225
|
+
if (!t.text.trim()) break
|
|
226
|
+
if (step?.calls.length) close()
|
|
227
|
+
open(archived, t.at).text.push(t.text)
|
|
228
|
+
break
|
|
229
|
+
}
|
|
230
|
+
case "reasoning": {
|
|
231
|
+
if (!t.text.trim()) break
|
|
232
|
+
if (step?.calls.length) close()
|
|
233
|
+
open(archived, t.at).reasoning.push(t.text)
|
|
234
|
+
break
|
|
235
|
+
}
|
|
236
|
+
case "tool_call": {
|
|
237
|
+
if (written.has(t.id)) {
|
|
238
|
+
drop("repeated tool call ids")
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
written.add(t.id)
|
|
242
|
+
const st = open(archived, t.at)
|
|
243
|
+
const call = hermesCall(t)
|
|
244
|
+
if (call.mapped) renamed++
|
|
245
|
+
const id = idFor(t.id)
|
|
246
|
+
st.calls.push({ id, call_id: id, response_item_id: responseItemId(id), type: "function", function: { name: call.name, arguments: pyJson(call.args) } })
|
|
247
|
+
st.pending.push({ src: t.id, id, name: call.name, terminal: call.name === "terminal" })
|
|
248
|
+
break
|
|
249
|
+
}
|
|
250
|
+
case "tool_result": {
|
|
251
|
+
if (!callIds.has(t.callId)) drop("tool results without a call")
|
|
252
|
+
else if (results.get(t.callId) !== t) drop("repeated tool results")
|
|
253
|
+
// Its call's response ends here; the result itself is written with the call.
|
|
254
|
+
else if (step?.pending.some((p) => p.src === t.callId)) close()
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
case "compaction": {
|
|
258
|
+
close()
|
|
259
|
+
messages.push(row({ role: "user", content: summaryText(t.summary), _compressed_summary: 1 }, tick(t.at), archived))
|
|
260
|
+
if (!archived) expect.compactions++
|
|
261
|
+
break
|
|
262
|
+
}
|
|
263
|
+
case "file_edit":
|
|
264
|
+
if (!t.callId) drop("file edits not made by a tool call")
|
|
265
|
+
break
|
|
266
|
+
case "subagent":
|
|
267
|
+
drop("subagent sessions (their call and result are kept)")
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
close()
|
|
272
|
+
if (!noted && messages.length) noteRow()
|
|
273
|
+
if (lastCompaction > 0) drop("turns before the last compaction, kept as Hermes's compacted history (not replayed)", lastCompaction)
|
|
274
|
+
|
|
275
|
+
// Nothing may sort after the next message Hermes writes, which is stamped now.
|
|
276
|
+
const late = (messages.at(-1)?.timestamp ?? 0) - opts.now.getTime() / 1000
|
|
277
|
+
if (late > 0) for (const m of messages) m.timestamp -= late + 0.001
|
|
278
|
+
|
|
279
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
280
|
+
if (replaced) lost.push(`tool-call ids replaced with ids providers accept (${replaced})`)
|
|
281
|
+
if (renamed) lost.push(`tool calls renamed to Hermes's own tools (${renamed})`)
|
|
282
|
+
if (session.header.models.length > 1) lost.push(`which model wrote each reply: Hermes keeps one model per session (${session.header.models.length} models)`)
|
|
283
|
+
const usage = turns.filter((t) => t.kind === "assistant" && t.usage).length
|
|
284
|
+
if (usage) lost.push(`token usage (${usage} replies)`)
|
|
285
|
+
return { id: opts.id, title: hermesTitle(session, label), model: session.header.models.at(-1) ?? null, createdAt: opts.now.getTime() / 1000, messages, lost, expect }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The folder of the git repository `cwd` is in, below the home folder, as Hermes records git_repo_root. */
|
|
289
|
+
function repoRoot(cwd: string, home: string): string | undefined {
|
|
290
|
+
for (let dir = cwd; dir && dir !== "/" && dir !== home; dir = dir.slice(0, dir.lastIndexOf("/"))) if (exists(join(dir, ".git"))) return dir
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Hermes's title rule (get_next_title_in_lineage): a title in use becomes "title #N", one past the highest N. */
|
|
295
|
+
function uniqueTitle(db: Database, title: string): string {
|
|
296
|
+
const esc = title.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")
|
|
297
|
+
const taken = (db.query("SELECT title FROM sessions WHERE title = ? OR title LIKE ? ESCAPE '\\'").all(title, `${esc} #%`) as Array<{ title: string }>).map((r) => r.title)
|
|
298
|
+
if (!taken.length) return title
|
|
299
|
+
const n = Math.max(1, ...taken.map((t) => Number(t.match(/ #(\d+)$/)?.[1] ?? 1)))
|
|
300
|
+
return `${title} #${n + 1}`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Why a store cannot take a new session, or nothing when it can. */
|
|
304
|
+
function storeProblem(d: Database, path: string): string | undefined {
|
|
305
|
+
const v = schemaVersion(d)
|
|
306
|
+
if (v === undefined) return `the Hermes store at ${path} has no schema version; it is not a store this writer knows`
|
|
307
|
+
if (v > MAX_SCHEMA) return `the Hermes store at ${path} was migrated by a newer Hermes (schema ${v}; this writer knows ${MIN_SCHEMA} to ${MAX_SCHEMA})`
|
|
308
|
+
if (v < MIN_SCHEMA) return `the Hermes store at ${path} has an older schema (${v}); run hermes once so it migrates its store`
|
|
309
|
+
for (const [table, cols] of Object.entries(NEEDED)) {
|
|
310
|
+
const have = columnsOf(d, table)
|
|
311
|
+
const missing = cols.filter((c) => !have.has(c))
|
|
312
|
+
if (missing.length) return `the Hermes store at ${path} has no ${table}.${missing.join(", ")}; it is not a store this writer knows`
|
|
313
|
+
}
|
|
314
|
+
return undefined
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const noStore = (path: string) => `Hermes has no session store at ${path} yet; run hermes once so it creates and migrates its own, then try again`
|
|
318
|
+
|
|
319
|
+
const stamp = (d: Date) => d.toISOString().replace(/[:.]/g, "-")
|
|
320
|
+
|
|
321
|
+
/** The Hermes writer; `newId` and `busyMs` are for tests. */
|
|
322
|
+
export function hermesWriter(opts: { newId?: (now: Date) => string; busyMs?: number } = {}): Writer {
|
|
323
|
+
return {
|
|
324
|
+
tool: TOOL,
|
|
325
|
+
label: LABEL,
|
|
326
|
+
way: "native",
|
|
327
|
+
|
|
328
|
+
async ready(o: WriteOptions): Promise<Readiness> {
|
|
329
|
+
const bin = hermesBin(o.home)
|
|
330
|
+
if (!bin) return { ok: false, why: "Hermes Agent is not installed (no hermes on PATH or in ~/.local/bin)" }
|
|
331
|
+
const v = hermesVersion(bin.path)
|
|
332
|
+
if (!v) return { ok: false, why: `\`${bin.path} --version\` did not say which Hermes Agent it is` }
|
|
333
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Hermes Agent ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
334
|
+
if (!isDir(o.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${o.cwd}` }
|
|
335
|
+
const db = storePath(o.home)
|
|
336
|
+
if (!exists(db)) return { ok: false, why: noStore(db) }
|
|
337
|
+
try {
|
|
338
|
+
const problem = withSnapshot(db, (d) => storeProblem(d, db))
|
|
339
|
+
if (problem) return { ok: false, why: problem }
|
|
340
|
+
} catch (e) {
|
|
341
|
+
return { ok: false, why: `the Hermes store at ${db} cannot be read: ${(e as Error).message}` }
|
|
342
|
+
}
|
|
343
|
+
return { ok: true }
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async write(session: Session, o: WriteOptions): Promise<WriteResult> {
|
|
347
|
+
const cwd = resolve(o.cwd)
|
|
348
|
+
const now = new Date()
|
|
349
|
+
const db = storePath(o.home)
|
|
350
|
+
const plan = planHermes(session, { id: (opts.newId ?? newSessionId)(now), now })
|
|
351
|
+
if (!SESSION_ID.test(plan.id)) throw new Error(`not a Hermes session id: ${plan.id}`)
|
|
352
|
+
if (!plan.expect.users && !plan.expect.assistants && !plan.expect.calls && !plan.expect.compactions)
|
|
353
|
+
throw new Error("the session has no messages to carry over to Hermes")
|
|
354
|
+
if (!exists(db)) throw new Error(noStore(db))
|
|
355
|
+
const backupDir = join(o.home, ".brain", "backups", TOOL, stamp(now))
|
|
356
|
+
const command = hermesBin(o.home)?.command ?? "hermes"
|
|
357
|
+
const result = (title: string): WriteResult => ({
|
|
358
|
+
tool: TOOL,
|
|
359
|
+
way: "native",
|
|
360
|
+
sessionId: plan.id,
|
|
361
|
+
written: [db, backupDir],
|
|
362
|
+
open: `cd ${sh(cwd)} && ${sh(command)} chat --resume ${plan.id}`,
|
|
363
|
+
note: `Hermes lists it as "${title}".`,
|
|
364
|
+
lost: plan.lost,
|
|
365
|
+
dryRun: !!o.dryRun,
|
|
366
|
+
})
|
|
367
|
+
if (o.dryRun) {
|
|
368
|
+
const problem = withSnapshot(db, (d) => storeProblem(d, db))
|
|
369
|
+
if (problem) throw new Error(problem)
|
|
370
|
+
return result(plan.title)
|
|
371
|
+
}
|
|
372
|
+
return result(insert(db, plan, { cwd, home: o.home, backupDir, busyMs: opts.busyMs ?? 5000, source: session.header.id }))
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export const hermesNativeWriter = hermesWriter()
|
|
378
|
+
|
|
379
|
+
const isBusy = (e: unknown) => /SQLITE_BUSY|database is locked/i.test(`${(e as { code?: string }).code ?? ""} ${(e as Error).message ?? ""}`)
|
|
380
|
+
|
|
381
|
+
/** Backs the store up, writes the session in one transaction and reads it back before committing. Returns the title. */
|
|
382
|
+
function insert(dbPath: string, plan: HermesPlan, o: { cwd: string; home: string; backupDir: string; busyMs: number; source: string }): string {
|
|
383
|
+
const db = new Database(dbPath)
|
|
384
|
+
try {
|
|
385
|
+
db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(o.busyMs))}`)
|
|
386
|
+
db.exec("PRAGMA foreign_keys = ON")
|
|
387
|
+
const problem = storeProblem(db, dbPath)
|
|
388
|
+
if (problem) throw new Error(problem)
|
|
389
|
+
mkdirSync(o.backupDir, { recursive: true, mode: 0o700 })
|
|
390
|
+
db.exec(`VACUUM INTO '${join(o.backupDir, basename(dbPath)).replace(/'/g, "''")}'`)
|
|
391
|
+
|
|
392
|
+
const sCols = columnsOf(db, "sessions")
|
|
393
|
+
const mCols = columnsOf(db, "messages")
|
|
394
|
+
const git = gitInfo(o.cwd, o.home)
|
|
395
|
+
const root = git.gitBranch ? repoRoot(o.cwd, o.home) : undefined
|
|
396
|
+
const live = plan.messages.filter((m) => m.active)
|
|
397
|
+
let title = plan.title
|
|
398
|
+
|
|
399
|
+
db.transaction(() => {
|
|
400
|
+
if (db.query("SELECT 1 FROM sessions WHERE id = ?").get(plan.id)) throw new Error(`Hermes already has a session ${plan.id}; nothing was written`)
|
|
401
|
+
title = uniqueTitle(db, plan.title)
|
|
402
|
+
const session: Record<string, unknown> = {
|
|
403
|
+
id: plan.id,
|
|
404
|
+
source: "cli",
|
|
405
|
+
model: plan.model,
|
|
406
|
+
started_at: plan.createdAt,
|
|
407
|
+
ended_at: plan.createdAt,
|
|
408
|
+
end_reason: "brain_import",
|
|
409
|
+
message_count: live.length,
|
|
410
|
+
tool_call_count: live.reduce((n, m) => n + (m.tool_calls ? (JSON.parse(m.tool_calls) as unknown[]).length : 0), 0),
|
|
411
|
+
cwd: o.cwd,
|
|
412
|
+
title,
|
|
413
|
+
...(git.gitBranch ? { git_branch: git.gitBranch } : {}),
|
|
414
|
+
...(root ? { git_repo_root: root } : {}),
|
|
415
|
+
}
|
|
416
|
+
const keys = Object.keys(session).filter((k) => sCols.has(k))
|
|
417
|
+
db.prepare(`INSERT INTO sessions (${keys.map((k) => `"${k}"`).join(", ")}) VALUES (${keys.map(() => "?").join(", ")})`).run(...(keys.map((k) => session[k] ?? null) as any[]))
|
|
418
|
+
const mKeys = ["session_id", ...Object.keys(plan.messages[0]!)].filter((k) => mCols.has(k))
|
|
419
|
+
const add = db.prepare(`INSERT INTO messages (${mKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${mKeys.map(() => "?").join(", ")})`)
|
|
420
|
+
for (const m of plan.messages) {
|
|
421
|
+
const values: Record<string, unknown> = { ...m, session_id: plan.id }
|
|
422
|
+
add.run(...(mKeys.map((k) => values[k] ?? null) as any[]))
|
|
423
|
+
}
|
|
424
|
+
// Read back as the reader reads Hermes; throwing here rolls everything back.
|
|
425
|
+
const back = readHermes(db, plan.id, { path: dbPath, home: o.home })
|
|
426
|
+
const n = countTurns(back)
|
|
427
|
+
const pairs = unpaired(back)
|
|
428
|
+
const e = plan.expect
|
|
429
|
+
if (
|
|
430
|
+
n.user !== e.users ||
|
|
431
|
+
n.assistant !== e.assistants ||
|
|
432
|
+
n.tool_call !== e.calls ||
|
|
433
|
+
n.tool_result !== e.calls ||
|
|
434
|
+
n.reasoning !== e.reasoning ||
|
|
435
|
+
n.compaction !== e.compactions ||
|
|
436
|
+
pairs.calls.length ||
|
|
437
|
+
pairs.results.length ||
|
|
438
|
+
back.header.parent?.id !== o.source
|
|
439
|
+
)
|
|
440
|
+
throw new Error(
|
|
441
|
+
`the Hermes session did not read back as written (users ${n.user}/${e.users}, replies ${n.assistant}/${e.assistants}, tool calls ${n.tool_call}/${e.calls}, results ${n.tool_result}/${e.calls}, reasoning ${n.reasoning}/${e.reasoning}, summaries ${n.compaction}/${e.compactions}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
|
|
442
|
+
)
|
|
443
|
+
}).immediate()
|
|
444
|
+
db.exec("PRAGMA wal_checkpoint(PASSIVE)")
|
|
445
|
+
return title
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (isBusy(e)) throw new Error(`Hermes is holding its session store (${dbPath} is locked); try again when it is idle. Nothing was written.`)
|
|
448
|
+
throw e
|
|
449
|
+
} finally {
|
|
450
|
+
db.close()
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { isDir, ls, mtime } from "../../fsutil.ts"
|
|
3
|
+
import type { Session } from "../../session/types.ts"
|
|
4
|
+
import { header, iso, lossCounter } from "../../session/util.ts"
|
|
5
|
+
import type { Reader, SessionRef, Writer } from "../types.ts"
|
|
6
|
+
import { type IflowRecord, LABEL, readRecords, recordsToTurns, stripNote, TOOL } from "./records.ts"
|
|
7
|
+
import { iflowNativeWriter } from "./writer.ts"
|
|
8
|
+
|
|
9
|
+
// iFlow CLI keeps each project's sessions in ~/.iflow/projects/<project name>/<session id>.jsonl, where the
|
|
10
|
+
// session id is session-<uuid> (records.ts has the format). Its list is every .jsonl in the project's folder,
|
|
11
|
+
// newest activity first; a session has no title, so the list shows its first request.
|
|
12
|
+
|
|
13
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
14
|
+
|
|
15
|
+
function firstRequest(records: IflowRecord[]): string | undefined {
|
|
16
|
+
for (const r of records) {
|
|
17
|
+
if (r.type !== "user" || r.isMeta || r.toolUseResult || r.isCompactSummary) continue
|
|
18
|
+
const c = r.message.content
|
|
19
|
+
const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((b) => (b.type === "text" ? b.text : "")).join("") : ""
|
|
20
|
+
const t = oneLine(stripNote(text).text)
|
|
21
|
+
if (t) return t.slice(0, 120)
|
|
22
|
+
}
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const reader: Reader = {
|
|
27
|
+
tool: TOOL,
|
|
28
|
+
label: LABEL,
|
|
29
|
+
|
|
30
|
+
async list(home) {
|
|
31
|
+
const projects = join(home, ".iflow", "projects")
|
|
32
|
+
if (!isDir(projects)) return []
|
|
33
|
+
const refs: SessionRef[] = []
|
|
34
|
+
for (const project of ls(projects)) {
|
|
35
|
+
for (const name of ls(join(projects, project))) {
|
|
36
|
+
if (!name.endsWith(".jsonl")) continue
|
|
37
|
+
const path = join(projects, project, name)
|
|
38
|
+
const { records } = readRecords(path)
|
|
39
|
+
if (!records.length) continue
|
|
40
|
+
const cwd = records.find((r) => r.cwd)?.cwd
|
|
41
|
+
const title = firstRequest(records)
|
|
42
|
+
const updatedAt = iso(records[records.length - 1]!.timestamp) ?? mtime(path)
|
|
43
|
+
refs.push({ tool: TOOL, sourceId: name.replace(/\.jsonl$/, ""), path, ...(title ? { title } : {}), ...(cwd ? { cwd } : {}), ...(updatedAt ? { updatedAt } : {}), extra: { project } })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
async read(ref) {
|
|
50
|
+
const { records, bad } = readRecords(ref.path)
|
|
51
|
+
if (!records.length) throw new Error(`${ref.path} holds no iFlow CLI records`)
|
|
52
|
+
const lost = lossCounter()
|
|
53
|
+
if (bad) lost.add("unreadable lines", bad)
|
|
54
|
+
const first = records[0]!
|
|
55
|
+
const cwd = records.find((r) => r.cwd)?.cwd
|
|
56
|
+
const branch = records.find((r) => r.gitBranch)?.gitBranch
|
|
57
|
+
const title = firstRequest(records)
|
|
58
|
+
const h = header(TOOL, ref.sourceId, {
|
|
59
|
+
sourcePath: ref.path,
|
|
60
|
+
project: { ...(cwd ? { cwd } : {}), ...(branch ? { gitBranch: branch } : {}) },
|
|
61
|
+
...(title ? { title } : {}),
|
|
62
|
+
...(iso(first.timestamp) ? { startedAt: iso(first.timestamp) } : {}),
|
|
63
|
+
...(iso(records[records.length - 1]!.timestamp) ? { updatedAt: iso(records[records.length - 1]!.timestamp) } : {}),
|
|
64
|
+
})
|
|
65
|
+
const turns = recordsToTurns(records, h, lost)
|
|
66
|
+
lost.flush(h)
|
|
67
|
+
return { header: h, turns } satisfies Session
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const readers: Reader[] = [reader]
|
|
72
|
+
export const writers: Writer[] = [iflowNativeWriter]
|