@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,314 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { basename, join } from "node:path"
|
|
3
|
+
import { exists, gitInfo, ls, mtime } from "../../fsutil.ts"
|
|
4
|
+
import { keepImage } from "../../session/attachments.ts"
|
|
5
|
+
import type { Attachment, Session, Turn, Usage } from "../../session/types.ts"
|
|
6
|
+
import { clip, header, iso, lossCounter, noteModel } from "../../session/util.ts"
|
|
7
|
+
import { CONTINUED_NOTE } from "../claude-code/records.ts"
|
|
8
|
+
import { type Entry, NOTE_TYPE, activeBranch, bashExecutionText, kindOf, migrate, parseEntries } from "../pi/records.ts"
|
|
9
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
10
|
+
import { SETTING_TYPES, agentDirOf, agentDirs, blobRef, checkedBlob, splitTitleSlot } from "./records.ts"
|
|
11
|
+
|
|
12
|
+
// Reads Oh My Pi sessions: <agent>/sessions/<bucket>/<file>.jsonl for the default agent folder and each
|
|
13
|
+
// profile's. Ported from session-migrate's omp.py (MIT, xhluca) onto our Pi reader's rules: the session is the
|
|
14
|
+
// active branch OMP resumes, compactions sit where the model's context starts over with the earlier turns
|
|
15
|
+
// kept in front, context edits apply as Pi applies them. On top of Pi:
|
|
16
|
+
// - the title is the slot's (else the last title_change, else the header's);
|
|
17
|
+
// - a reset_boundary hides everything before it from the model, so those turns are left out and counted;
|
|
18
|
+
// - blob-backed media become attachments pointing at the blob file (after checking its hash): images as
|
|
19
|
+
// images, PDFs, audio and video as files with their type;
|
|
20
|
+
// - model_change names "<provider>/<model>"; the model part is kept.
|
|
21
|
+
|
|
22
|
+
const PEEK = 64 * 1024
|
|
23
|
+
const LABEL = "Oh My Pi"
|
|
24
|
+
|
|
25
|
+
type Lost = ReturnType<typeof lossCounter>
|
|
26
|
+
|
|
27
|
+
const textOf = (content: unknown): string => {
|
|
28
|
+
if (typeof content === "string") return content
|
|
29
|
+
if (!Array.isArray(content)) return ""
|
|
30
|
+
return (content as Entry[])
|
|
31
|
+
.filter((b) => b?.type === "text" && typeof b.text === "string")
|
|
32
|
+
.map((b) => b.text as string)
|
|
33
|
+
.join("\n")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A user message's media as attachments: blob files where OMP keeps them, inline images saved by the brain. */
|
|
37
|
+
function attachmentsOf(content: unknown, agentDir: string | undefined, lost: Lost): Attachment[] {
|
|
38
|
+
const out: Attachment[] = []
|
|
39
|
+
for (const b of Array.isArray(content) ? (content as Entry[]) : []) {
|
|
40
|
+
if (b?.type !== "image") continue
|
|
41
|
+
const mime = typeof b.mimeType === "string" ? b.mimeType : undefined
|
|
42
|
+
const hex = blobRef(b.data)
|
|
43
|
+
if (hex) {
|
|
44
|
+
const path = agentDir ? checkedBlob(agentDir, hex) : undefined
|
|
45
|
+
if (path) out.push({ kind: mime?.startsWith("image/") ? "image" : "file", path, ...(mime ? { mime } : {}) })
|
|
46
|
+
else lost.add("attachments whose blob is missing or altered")
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (mime?.startsWith("image/") && typeof b.data === "string") {
|
|
50
|
+
const kept = keepImage(b.data, mime)
|
|
51
|
+
if (kept) out.push(kept)
|
|
52
|
+
else lost.add("images")
|
|
53
|
+
} else lost.add(`inline attachments (${mime ?? "unknown type"})`)
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const usageOf = (u: Entry | undefined): Usage | undefined => {
|
|
59
|
+
if (!u || typeof u !== "object") return undefined
|
|
60
|
+
const out: Usage = {}
|
|
61
|
+
if (u.input) out.input = u.input
|
|
62
|
+
if (u.output) out.output = u.output
|
|
63
|
+
if (u.cacheRead) out.cacheRead = u.cacheRead
|
|
64
|
+
if (u.cacheWrite) out.cacheWrite = u.cacheWrite
|
|
65
|
+
return Object.keys(out).length ? out : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The model part of a model_change: OMP writes "<provider>/<model>", Pi writes modelId. */
|
|
69
|
+
const changedModel = (e: Entry): string | undefined => {
|
|
70
|
+
if (typeof e.modelId === "string" && e.modelId) return e.modelId
|
|
71
|
+
if (typeof e.model !== "string" || !e.model) return undefined
|
|
72
|
+
const slash = e.model.indexOf("/")
|
|
73
|
+
return slash >= 0 ? e.model.slice(slash + 1) || undefined : e.model
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The name the picker shows: the slot's, else the last rename, else the header's. */
|
|
77
|
+
function titleOf(slot: Entry | undefined, entries: Entry[]): string | undefined {
|
|
78
|
+
const clean = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined)
|
|
79
|
+
if (clean(slot?.title)) return clean(slot!.title)
|
|
80
|
+
for (let i = entries.length - 1; i > 0; i--) {
|
|
81
|
+
const e = entries[i]!
|
|
82
|
+
if (e.type === "title_change" && clean(e.title)) return clean(e.title)
|
|
83
|
+
if (e.type === "session_info" && clean(e.name)) return clean(e.name)
|
|
84
|
+
}
|
|
85
|
+
return clean(entries[0]?.title)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One OMP session file as the portable session; `home` is for git details only. */
|
|
89
|
+
export function parseOmpSession(text: string, path: string, home?: string): Session {
|
|
90
|
+
const { slot, rest } = splitTitleSlot(text)
|
|
91
|
+
const entries = parseEntries(rest)
|
|
92
|
+
if (!entries.length) throw new Error(`${path} is not an ${LABEL} session (no session header)`)
|
|
93
|
+
migrate(entries)
|
|
94
|
+
const head = entries[0]!
|
|
95
|
+
const id = String(head.id)
|
|
96
|
+
const cwd = typeof head.cwd === "string" && head.cwd ? head.cwd : undefined
|
|
97
|
+
const agentDir = agentDirOf(path)
|
|
98
|
+
const lost = lossCounter()
|
|
99
|
+
const turns: Turn[] = []
|
|
100
|
+
const h = header("omp", id, { sourcePath: path, project: { ...(cwd ? { cwd, ...gitInfo(cwd, home) } : {}) }, ...(iso(head.timestamp) ? { startedAt: iso(head.timestamp) } : {}) })
|
|
101
|
+
|
|
102
|
+
// A reset boundary starts the model's context afresh: what came before it is not part of the session OMP resumes.
|
|
103
|
+
let branch = activeBranch(entries)
|
|
104
|
+
const reset = branch.map((e) => e.type).lastIndexOf("reset_boundary")
|
|
105
|
+
if (reset >= 0) {
|
|
106
|
+
const hidden = branch.slice(0, reset).filter((e) => e.type === "message" || e.type === "compaction").length
|
|
107
|
+
if (hidden) lost.add("messages hidden by a context reset", hidden)
|
|
108
|
+
branch = branch.slice(reset + 1)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Where each compaction's context starts over: before the first entry it kept, when that comes before it.
|
|
112
|
+
const indexOf = new Map(branch.map((e, i) => [e.id as string, i]))
|
|
113
|
+
const placedBefore = new Map<number, Entry[]>()
|
|
114
|
+
const moved = new Set<Entry>()
|
|
115
|
+
for (const [i, e] of branch.entries()) {
|
|
116
|
+
if (e.type !== "compaction") continue
|
|
117
|
+
const k = indexOf.get(e.firstKeptEntryId)
|
|
118
|
+
if (k !== undefined && k < i) {
|
|
119
|
+
placedBefore.set(k, [...(placedBefore.get(k) ?? []), e])
|
|
120
|
+
moved.add(e)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const edits = new Map<string, Entry>()
|
|
124
|
+
for (const e of branch) if (e.type === "context_edit" && typeof e.targetId === "string") edits.set(e.targetId, e)
|
|
125
|
+
|
|
126
|
+
const calls = new Map<string, Extract<Turn, { kind: "tool_call" }>>()
|
|
127
|
+
const compaction = (e: Entry) => {
|
|
128
|
+
if (typeof e.summary === "string" && e.summary.trim()) turns.push({ kind: "compaction", ...at(e.timestamp), summary: e.summary })
|
|
129
|
+
}
|
|
130
|
+
let continued: string | undefined
|
|
131
|
+
let last: string | undefined
|
|
132
|
+
|
|
133
|
+
for (const [i, e] of branch.entries()) {
|
|
134
|
+
for (const c of placedBefore.get(i) ?? []) compaction(c)
|
|
135
|
+
if (typeof e.timestamp === "string") last = e.timestamp
|
|
136
|
+
switch (e.type) {
|
|
137
|
+
case "message": {
|
|
138
|
+
const edit = edits.get(e.id)
|
|
139
|
+
if (edit && edit.replacement === null) break
|
|
140
|
+
message(edit ? { ...e.message, content: edit.replacement?.content } : e.message, e.timestamp)
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
case "compaction":
|
|
144
|
+
if (!moved.has(e)) compaction(e)
|
|
145
|
+
break
|
|
146
|
+
case "custom_message": {
|
|
147
|
+
const note = typeof e.content === "string" ? e.content.match(CONTINUED_NOTE) : null
|
|
148
|
+
if (e.customType === NOTE_TYPE) {
|
|
149
|
+
if (note) continued = note[1]
|
|
150
|
+
} else lost.add("extension messages")
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
case "model_change":
|
|
154
|
+
noteModel(h, changedModel(e))
|
|
155
|
+
break
|
|
156
|
+
case "branch_summary":
|
|
157
|
+
lost.add("summaries of abandoned branches")
|
|
158
|
+
break
|
|
159
|
+
case "session_init":
|
|
160
|
+
lost.add(`${LABEL} session setup records`)
|
|
161
|
+
break
|
|
162
|
+
case "ttsr_injection":
|
|
163
|
+
lost.add(`${LABEL} rule injections`)
|
|
164
|
+
break
|
|
165
|
+
case "title_change":
|
|
166
|
+
case "session_info":
|
|
167
|
+
break
|
|
168
|
+
default:
|
|
169
|
+
if (!SETTING_TYPES.has(String(e.type))) lost.add(`unknown entry types: ${String(e.type)}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function message(m: Entry, when: string | undefined) {
|
|
174
|
+
if (!m || typeof m !== "object") return
|
|
175
|
+
const stamp = at(typeof m.timestamp === "number" ? m.timestamp : when)
|
|
176
|
+
switch (m.role) {
|
|
177
|
+
case "user": {
|
|
178
|
+
const text = textOf(m.content)
|
|
179
|
+
const attachments = attachmentsOf(m.content, agentDir, lost)
|
|
180
|
+
if (text.trim() || attachments.length) turns.push({ kind: "user", ...stamp, text, ...(attachments.length ? { attachments } : {}) })
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
case "assistant": {
|
|
184
|
+
const model = typeof m.model === "string" ? m.model : undefined
|
|
185
|
+
noteModel(h, model)
|
|
186
|
+
let usage = usageOf(m.usage)
|
|
187
|
+
for (const b of Array.isArray(m.content) ? (m.content as Entry[]) : []) {
|
|
188
|
+
if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) {
|
|
189
|
+
turns.push({ kind: "assistant", ...stamp, text: b.text, ...(model ? { model } : {}), ...(usage ? { usage } : {}) })
|
|
190
|
+
usage = undefined
|
|
191
|
+
} else if (b?.type === "thinking") {
|
|
192
|
+
if (b.redacted) lost.add("redacted reasoning")
|
|
193
|
+
else if (typeof b.thinking === "string" && b.thinking.trim()) {
|
|
194
|
+
turns.push({ kind: "reasoning", ...stamp, text: b.thinking })
|
|
195
|
+
if (b.thinkingSignature) lost.add("reasoning signatures, text kept")
|
|
196
|
+
} else if (b.thinkingSignature) lost.add("signed reasoning")
|
|
197
|
+
} else if (b?.type === "toolCall" && typeof b.id === "string") {
|
|
198
|
+
const name = typeof b.name === "string" ? b.name : "tool"
|
|
199
|
+
const call: Extract<Turn, { kind: "tool_call" }> = { kind: "tool_call", ...stamp, id: b.id, name, toolKind: kindOf(name), input: b.arguments ?? {} }
|
|
200
|
+
calls.set(b.id, call)
|
|
201
|
+
turns.push(call)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
case "toolResult": {
|
|
207
|
+
if (typeof m.toolCallId !== "string") break
|
|
208
|
+
if (Array.isArray(m.content) && (m.content as Entry[]).some((b) => b?.type === "image")) lost.add("tool result images")
|
|
209
|
+
const full = clip(textOf(m.content))
|
|
210
|
+
const d = m.details && typeof m.details === "object" ? (m.details as Entry) : {}
|
|
211
|
+
const fullPath = typeof d.fullOutputPath === "string" ? d.fullOutputPath : undefined
|
|
212
|
+
const ok = m.isError !== true
|
|
213
|
+
turns.push({ kind: "tool_result", ...stamp, callId: m.toolCallId, ok, output: full.text, ...(full.truncated || d.truncation?.truncated ? { truncated: true } : {}), ...(fullPath ? { fullPath } : {}) })
|
|
214
|
+
const call = calls.get(m.toolCallId)
|
|
215
|
+
const input = call?.input as Entry | undefined
|
|
216
|
+
const target = input?.path ?? input?.file_path
|
|
217
|
+
if (ok && call && (call.name === "edit" || call.name === "write") && typeof target === "string")
|
|
218
|
+
turns.push({ kind: "file_edit", ...stamp, path: target, ...(typeof d.diff === "string" ? { patch: d.diff } : {}), callId: call.id })
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
case "bashExecution":
|
|
222
|
+
if (!m.excludeFromContext) turns.push({ kind: "user", ...stamp, text: bashExecutionText(m) })
|
|
223
|
+
break
|
|
224
|
+
case "compactionSummary":
|
|
225
|
+
if (typeof m.summary === "string") turns.push({ kind: "compaction", ...stamp, summary: m.summary })
|
|
226
|
+
break
|
|
227
|
+
case "branchSummary":
|
|
228
|
+
lost.add("summaries of abandoned branches")
|
|
229
|
+
break
|
|
230
|
+
case "custom":
|
|
231
|
+
lost.add("extension messages")
|
|
232
|
+
break
|
|
233
|
+
case "developer":
|
|
234
|
+
case "system":
|
|
235
|
+
lost.add(`${LABEL} system and developer messages`)
|
|
236
|
+
break
|
|
237
|
+
default:
|
|
238
|
+
lost.add(`unknown message roles: ${String(m.role)}`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const title = titleOf(slot, entries) ?? (turns.find((t) => t.kind === "user") as Extract<Turn, { kind: "user" }> | undefined)?.text.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
243
|
+
if (title) h.title = title
|
|
244
|
+
const updated = iso(last)
|
|
245
|
+
if (updated) h.updatedAt = updated
|
|
246
|
+
if (continued) h.parent = { id: continued, kind: "continued" }
|
|
247
|
+
else if (typeof head.parentSession === "string" && head.parentSession) {
|
|
248
|
+
const parent = basename(head.parentSession, ".jsonl").replace(/^.*_/, "")
|
|
249
|
+
if (parent) h.parent = { id: `omp:${parent}`, kind: "fork" }
|
|
250
|
+
}
|
|
251
|
+
lost.flush(h)
|
|
252
|
+
return { header: h, turns }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function at(v: unknown): { at?: string } {
|
|
256
|
+
const t = iso(v)
|
|
257
|
+
return t ? { at: t } : {}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Id, folder and title from the start and end of a file: the title slot makes the name the first line. */
|
|
261
|
+
async function peek(path: string): Promise<Omit<SessionRef, "tool" | "path"> | undefined> {
|
|
262
|
+
const file = Bun.file(path)
|
|
263
|
+
const text = file.size <= 4 * PEEK ? await file.text() : `${await file.slice(0, PEEK).text()}\n${await file.slice(file.size - PEEK).text()}`
|
|
264
|
+
const { slot, rest } = splitTitleSlot(text)
|
|
265
|
+
const entries = parseEntries(rest)
|
|
266
|
+
const head = entries[0]
|
|
267
|
+
if (!head) return undefined
|
|
268
|
+
let first: string | undefined
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
if (e.type === "message" && e.message?.role === "user") {
|
|
271
|
+
first = textOf(e.message.content).replace(/\s+/g, " ").trim().slice(0, 80) || undefined
|
|
272
|
+
if (first) break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const title = titleOf(slot, entries) ?? first
|
|
276
|
+
return {
|
|
277
|
+
sourceId: String(head.id),
|
|
278
|
+
...(title ? { title } : {}),
|
|
279
|
+
...(typeof head.cwd === "string" && head.cwd ? { cwd: head.cwd } : {}),
|
|
280
|
+
...(mtime(path) ? { updatedAt: mtime(path) } : {}),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const ompReader: Reader = {
|
|
285
|
+
tool: "omp",
|
|
286
|
+
label: LABEL,
|
|
287
|
+
|
|
288
|
+
async list(home: string): Promise<SessionRef[]> {
|
|
289
|
+
const refs: SessionRef[] = []
|
|
290
|
+
for (const agent of agentDirs(home)) {
|
|
291
|
+
const root = join(agent, "sessions")
|
|
292
|
+
for (const bucket of ls(root)) {
|
|
293
|
+
const dir = join(root, bucket)
|
|
294
|
+
for (const name of ls(dir)) {
|
|
295
|
+
if (!name.endsWith(".jsonl")) continue
|
|
296
|
+
const path = join(dir, name)
|
|
297
|
+
try {
|
|
298
|
+
if (!statSync(path).isFile()) continue
|
|
299
|
+
const p = await peek(path)
|
|
300
|
+
if (p) refs.push({ tool: "omp", path, ...p })
|
|
301
|
+
} catch {
|
|
302
|
+
// Deleted or unreadable between listing and reading: not a session to offer.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async read(ref: SessionRef, home: string): Promise<Session> {
|
|
311
|
+
if (!exists(ref.path)) throw new Error(`no ${LABEL} session at ${ref.path}`)
|
|
312
|
+
return parseOmpSession(readFileSync(ref.path, "utf8"), ref.path, home)
|
|
313
|
+
},
|
|
314
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"
|
|
5
|
+
import { isDir, ls } from "../../fsutil.ts"
|
|
6
|
+
import type { PiFlavor } from "../pi/records.ts"
|
|
7
|
+
|
|
8
|
+
// Facts about Oh My Pi's session files (can1357/oh-my-pi, a fork of Pi), checked against OMP 18.0.5 (tag v18.0.5,
|
|
9
|
+
// commit eab72e88e447a4be45bea2bc302995844c0c51a2) through session-migrate's notes, its adapter and a real 18.0.5
|
|
10
|
+
// capture: xhluca/session-migrate (MIT, copyright (c) 2026 xhluca), docs/omp-format.md and
|
|
11
|
+
// src/session_migrate/formats/omp.py. The semantics are ported from there; the code is ours.
|
|
12
|
+
//
|
|
13
|
+
// The store is the agent folder ~/.omp/agent (PI_CODING_AGENT_DIR moves it, which brain does not follow) and
|
|
14
|
+
// one per named profile, ~/.omp/profiles/<name>/agent. Sessions live at
|
|
15
|
+
// <agent>/sessions/<cwd bucket>/<start time>_<uuid>.jsonl and pasted media at <agent>/blobs/<sha256>.
|
|
16
|
+
//
|
|
17
|
+
// A file is Pi's version 3 session (header line, then entries on an id/parentId tree, see ../pi/records.ts)
|
|
18
|
+
// with three differences:
|
|
19
|
+
// - It starts with a fixed-width title slot: one JSON line {type:"title", v:1, title, source, updatedAt, pad}
|
|
20
|
+
// padded with spaces to exactly 256 bytes, newline included, so OMP can rename a session in place. The slot
|
|
21
|
+
// is the name the picker shows; the header repeats it (title, titleSource) and renames append title_change.
|
|
22
|
+
// - Media leave the file: an image block's data is "blob:sha256:<hex>", the bytes in <agent>/blobs/<hex>.
|
|
23
|
+
// OMP takes PDFs, audio and video through the same image field and stores them the same way.
|
|
24
|
+
// - The project folder ("cwd bucket") is relative to home or to the temp folder when the cwd is under one.
|
|
25
|
+
// OMP also adds entry types (title_change, reset_boundary, credential_pin, mode_change, service_tier_change,
|
|
26
|
+
// session_init, ttsr_injection), writes model_change as {model: "<provider>/<model>"}, and marks prompts with
|
|
27
|
+
// attribution: "user".
|
|
28
|
+
|
|
29
|
+
/** OMP versions whose session format this adapter was checked against. */
|
|
30
|
+
export const OMP_TESTED_VERSIONS = ["18.0.5"]
|
|
31
|
+
|
|
32
|
+
export const TITLE_SLOT_BYTES = 256
|
|
33
|
+
const BLOB_PREFIX = "blob:sha256:"
|
|
34
|
+
/** Blobs larger than this are not read (session-migrate's bound). */
|
|
35
|
+
export const BLOB_LIMIT = 64 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
/** The default agent folder and each named profile's, whether or not they exist. */
|
|
38
|
+
export function agentDirs(home: string): string[] {
|
|
39
|
+
const profiles = join(home, ".omp", "profiles")
|
|
40
|
+
return [join(home, ".omp", "agent"), ...ls(profiles).map((p) => join(profiles, p, "agent")).filter(isDir)]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The agent folder a session file belongs to: the parent of its `sessions` folder. */
|
|
44
|
+
export function agentDirOf(sessionPath: string): string | undefined {
|
|
45
|
+
for (let d = dirname(sessionPath); d && d !== dirname(d); d = dirname(d)) if (basename(d) === "sessions") return dirname(d)
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const blobPath = (agentDir: string, hex: string) => join(agentDir, "blobs", hex)
|
|
50
|
+
|
|
51
|
+
/** The sha256 an image block's data points at, when it is a blob reference. */
|
|
52
|
+
export function blobRef(data: unknown): string | undefined {
|
|
53
|
+
if (typeof data !== "string" || !data.startsWith(BLOB_PREFIX)) return undefined
|
|
54
|
+
const hex = data.slice(BLOB_PREFIX.length)
|
|
55
|
+
return /^[0-9a-f]{64}$/.test(hex) ? hex : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const blobData = (hex: string) => `${BLOB_PREFIX}${hex}`
|
|
59
|
+
|
|
60
|
+
export const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex")
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A blob's path when it is a plain file (not a link), within the size bound, and its bytes hash to its name,
|
|
64
|
+
* as OMP checks it; undefined otherwise.
|
|
65
|
+
*/
|
|
66
|
+
export function checkedBlob(agentDir: string, hex: string): string | undefined {
|
|
67
|
+
const path = blobPath(agentDir, hex)
|
|
68
|
+
try {
|
|
69
|
+
const st = lstatSync(path)
|
|
70
|
+
if (!st.isFile() || st.size > BLOB_LIMIT) return undefined
|
|
71
|
+
return sha256(readFileSync(path)) === hex ? path : undefined
|
|
72
|
+
} catch {
|
|
73
|
+
return undefined
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const resolved = (p: string) => {
|
|
78
|
+
try {
|
|
79
|
+
return realpathSync(p)
|
|
80
|
+
} catch {
|
|
81
|
+
return p
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A path inside `root` as OMP spells it in a bucket name, or undefined when it is not inside. */
|
|
86
|
+
function inside(path: string, root: string): string | undefined {
|
|
87
|
+
const rel = relative(root, path)
|
|
88
|
+
if (rel === "") return ""
|
|
89
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined
|
|
90
|
+
return rel.replace(/[/\\:]/g, "-")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* OMP 18.0.5's folder name for a project (session-migrate's session_directory_name): under home it is "-" and
|
|
95
|
+
* the home-relative path ("-" for home itself), under the temp folder "-tmp" and the relative path, and
|
|
96
|
+
* anywhere else the absolute path between "--", leading separators dropped. / \ and : become -. Paths are
|
|
97
|
+
* compared resolved, as OMP sees the process's folder.
|
|
98
|
+
*/
|
|
99
|
+
export function bucketName(cwd: string, home: string, temp = tmpdir()): string {
|
|
100
|
+
const path = resolved(cwd)
|
|
101
|
+
const inHome = inside(path, resolved(home))
|
|
102
|
+
if (inHome !== undefined) return inHome ? `-${inHome}` : "-"
|
|
103
|
+
const inTemp = inside(path, resolved(temp))
|
|
104
|
+
if (inTemp !== undefined) return inTemp ? `-tmp-${inTemp}` : "-tmp"
|
|
105
|
+
return `--${path.replace(/^[/\\]+/, "").replace(/[/\\:]/g, "-")}--`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const slotText = (title: string, updatedAt: string, pad: string) =>
|
|
109
|
+
`${JSON.stringify({ type: "title", v: 1, title, ...(title ? { source: "user" } : {}), updatedAt, pad })}\n`
|
|
110
|
+
|
|
111
|
+
/** The 256-byte title line and the title as it fits there (cut at a character, never mid-character). */
|
|
112
|
+
export function titleSlot(title: string, updatedAt: string): { line: string; title: string } {
|
|
113
|
+
const chars = Array.from(title)
|
|
114
|
+
let lo = 0
|
|
115
|
+
let hi = chars.length
|
|
116
|
+
let fit = ""
|
|
117
|
+
while (lo <= hi) {
|
|
118
|
+
const mid = (lo + hi) >> 1
|
|
119
|
+
const candidate = chars.slice(0, mid).join("")
|
|
120
|
+
if (Buffer.byteLength(slotText(candidate, updatedAt, "")) <= TITLE_SLOT_BYTES) {
|
|
121
|
+
fit = candidate
|
|
122
|
+
lo = mid + 1
|
|
123
|
+
} else hi = mid - 1
|
|
124
|
+
}
|
|
125
|
+
const line = slotText(fit, updatedAt, " ".repeat(TITLE_SLOT_BYTES - Buffer.byteLength(slotText(fit, updatedAt, ""))))
|
|
126
|
+
return { line, title: fit }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The title slot and the rest of the file; files from before the slot (legacy v3) have none. */
|
|
130
|
+
export function splitTitleSlot(text: string): { slot?: Record<string, any>; rest: string } {
|
|
131
|
+
const nl = text.indexOf("\n")
|
|
132
|
+
const first = nl < 0 ? text : text.slice(0, nl)
|
|
133
|
+
try {
|
|
134
|
+
const v = JSON.parse(first)
|
|
135
|
+
if (v && typeof v === "object" && v.type === "title") return { slot: v, rest: nl < 0 ? "" : text.slice(nl + 1) }
|
|
136
|
+
} catch {
|
|
137
|
+
// not a slot: the file starts with its header
|
|
138
|
+
}
|
|
139
|
+
return { rest: text }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Entry types that are OMP settings, not conversation: skipped without counting. */
|
|
143
|
+
export const SETTING_TYPES = new Set(["credential_pin", "mode_change", "service_tier_change", "thinking_level_change", "usage", "label", "custom", "context_edit"])
|
|
144
|
+
|
|
145
|
+
/** OMP's flavor of the Pi family, for Pi's planner: OMP prefixes the start time like Pi and uses v4 UUIDs. */
|
|
146
|
+
export const ompFlavor: PiFlavor = {
|
|
147
|
+
tool: "omp",
|
|
148
|
+
label: "Oh My Pi",
|
|
149
|
+
bin: "omp",
|
|
150
|
+
sessionsRoot: (home) => join(home, ".omp", "agent", "sessions"),
|
|
151
|
+
fileName: (id, startedAt) => `${startedAt.replace(/[:.]/g, "-")}_${id}.jsonl`,
|
|
152
|
+
newId: () => randomUUID(),
|
|
153
|
+
}
|