@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.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. 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
+ }