@100xprompt/brain 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -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 +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,296 @@
1
+ // `brain mcp serve`: one small stdio MCP server the brain owns, so every tool gets the same memory.
2
+ // Hand-written JSON-RPC 2.0 over newline-delimited JSON (no SDK). Read-only: it never writes to Chitta, the blob store
3
+ // or the project. Everything it returns goes through redact().
4
+ //
5
+ // Tools (kept tiny on purpose: their definitions are sent in every request, so they are a token cost; a test holds
6
+ // the three under 700 tokens):
7
+ // brain_recall the best memories for a question in the current project, at most 1500 tokens
8
+ // brain_expand a stored tool output by blob id or by its stub text: grep for matching lines, or page with offset/limit (8,000 characters by default)
9
+ // brain_status whether memory is available, the store, item counts, the project
10
+ // brain_map compact map of the folder's code (layout, ranked exports), cached until the code changes
11
+
12
+ import { gitInfo } from "../fsutil.ts"
13
+ import { expandStub } from "./externalize.ts"
14
+ import { openBlobStore } from "./blobs.ts"
15
+ import { projectKey } from "./project.ts"
16
+ import { redact } from "./redact.ts"
17
+ import { repoMap } from "./repomap.ts"
18
+ import { windowOf } from "./relevance.ts"
19
+ import { logLookup } from "./lookups.ts"
20
+ import { openMemory } from "./store.ts"
21
+ import { approxTokens, type BlobStore, type Memory, type ProjectKey } from "./types.ts"
22
+
23
+ export interface McpServerOptions {
24
+ home: string
25
+ /** Overrides the project (default: the one of the folder the process was started in). */
26
+ project?: ProjectKey
27
+ input?: ReadableStream<Uint8Array>
28
+ output?: WritableStream<Uint8Array>
29
+ /** Folder to take the project from (default process.cwd()). */
30
+ cwd?: string
31
+ /** Test seams: use these instead of opening the real stores. */
32
+ memory?: Memory
33
+ blobs?: BlobStore
34
+ }
35
+
36
+ /** Protocol versions this server can speak; it answers with the client's when it is one of them. */
37
+ const VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"]
38
+ const RECALL_TOKENS = 1500
39
+ /** What one brain_expand call returns by default and at most. Tools truncate long MCP results on their own (Codex drops the middle of one), so the default is small and the model pages or greps for the rest. */
40
+ const EXPAND_DEFAULT = 8_000
41
+ const EXPAND_MAX = 60_000
42
+ const SERVER_INFO = { name: "brain", version: "0.1.0" }
43
+
44
+ type Json = Record<string, any>
45
+
46
+ export const TOOLS = [
47
+ {
48
+ name: "brain_recall",
49
+ description: "Look up what earlier sessions of this project established (decisions, constraints, failed attempts, commands). Use at the start of a task or before repeating work. Returns up to 1500 tokens.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ query: { type: "string", description: "What you need to know" },
54
+ project: { type: "string", description: "Project label or id, when it is not the folder you are in" },
55
+ limit: { type: "number", description: "Max items (default 8)" },
56
+ },
57
+ required: ["query"],
58
+ },
59
+ },
60
+ {
61
+ name: "brain_expand",
62
+ description: "Get the text of a tool output that was replaced by a '[brain: output stored ... id=...]' stub. Pass the id (or the stub). Best: grep to get only the matching lines with context; else offset/limit to page. Use only when the digest is not enough.",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ id: { type: "string", description: "Blob id or the stub text" },
67
+ grep: { type: "string", description: "Regex: return only matching lines (and context lines around them)" },
68
+ context: { type: "number", description: "Lines around each match (default 2)" },
69
+ offset: { type: "number", description: "First character to return (default 0)" },
70
+ limit: { type: "number", description: "Characters to return (default 8000, max 60000)" },
71
+ query: { type: "string", description: "Words to look for: with no grep/offset/limit, adds the best-matching parts of a long output to its head and tail" },
72
+ },
73
+ required: ["id"],
74
+ },
75
+ },
76
+ {
77
+ name: "brain_status",
78
+ description: "Whether brain memory is available, its store, item counts and the current project.",
79
+ inputSchema: { type: "object", properties: {} },
80
+ },
81
+ {
82
+ name: "brain_map",
83
+ description: "Compact map of this folder's code: layout and exported symbols, ranked. Orientation only: read files narrowly after it.",
84
+ inputSchema: { type: "object", properties: {} },
85
+ },
86
+ ]
87
+
88
+ const ok = (text: string) => ({ content: [{ type: "text", text }] })
89
+ const fail = (text: string) => ({ content: [{ type: "text", text }], isError: true })
90
+
91
+ const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
92
+
93
+ export async function runMcpServer(opts: McpServerOptions): Promise<void> {
94
+ const cwd = opts.cwd ?? process.cwd()
95
+ const project: ProjectKey = opts.project ?? projectKey({ cwd, ...gitInfo(cwd, opts.home) })
96
+ let memory = opts.memory
97
+ let blobs = opts.blobs
98
+ // Opened on first use: a tool that only lists its tools should not touch the disk.
99
+ const mem = () => (memory ??= openMemory(opts.home))
100
+ const blob = () => (blobs ??= openBlobStore(opts.home))
101
+
102
+ async function recall(args: Json) {
103
+ const query = typeof args.query === "string" ? args.query.trim() : ""
104
+ if (!query) return fail("query is required")
105
+ const st = await mem().status()
106
+ if (!st.available) return ok(`memory is not available: ${st.why ?? "unknown reason"}`)
107
+ const limit = Math.max(1, Math.min(50, Math.floor(Number(args.limit)) || 8))
108
+ // Tools start the server in different folders (a desktop app may start it in the home folder), so the project can be named.
109
+ const known = (await (mem() as { projects?: () => Promise<Array<{ id: string; label: string; items: number }>> }).projects?.().catch(() => undefined)) ?? []
110
+ let target = project
111
+ if (typeof args.project === "string" && args.project.trim()) {
112
+ const want = args.project.trim().toLowerCase()
113
+ const hit = known.filter((p) => p.id.toLowerCase() === want || p.label.toLowerCase() === want)
114
+ const near = hit.length ? hit : known.filter((p) => p.label.toLowerCase().startsWith(want) || p.id.toLowerCase().endsWith(want))
115
+ if (near.length !== 1) return ok(near.length ? `"${clip(redact(args.project), 60)}" matches several projects: ${near.map((p) => p.label).join(", ")}` : `no project "${clip(redact(args.project), 60)}" has memory${known.length ? `; projects with memory: ${known.map((p) => `${p.label} (${p.items})`).join(", ")}` : ""}`)
116
+ target = { id: near[0]!.id, label: near[0]!.label }
117
+ }
118
+ const found = await mem().recall(target, query, { limit, maxTokens: RECALL_TOKENS })
119
+ if (!found.length) {
120
+ const others = known.filter((p) => p.id !== target.id && p.items > 0).slice(0, 8)
121
+ const hint = others.length ? ` Projects with memory: ${others.map((p) => `${p.label} (${p.items})`).join(", ")}; pass project=<label> to search one.` : ""
122
+ return ok(`no memories for ${target.label} match "${clip(redact(query), 80)}".${hint}`)
123
+ }
124
+ const lines: string[] = []
125
+ let used = 0
126
+ for (const r of found) {
127
+ const line = redact(`[${r.kind ?? "note"}] ${clip(r.text.replace(/\s+/g, " ").trim(), 1500)}${r.source ? ` (from ${clip(r.source, 60)})` : ""}`)
128
+ const cost = approxTokens(line) + 1
129
+ // The total is hard-capped here whatever the store returned; the first line always fits (it is clipped to 1500 chars).
130
+ if (used + cost > RECALL_TOKENS && lines.length) break
131
+ lines.push(line)
132
+ used += cost
133
+ }
134
+ return ok(lines.join("\n"))
135
+ }
136
+
137
+ function expand(args: Json) {
138
+ const raw = typeof args.id === "string" ? args.id.trim() : ""
139
+ if (!raw) return fail("id is required")
140
+ // A bare blob id, or a whole stub (or text containing one).
141
+ const id = /^[0-9a-f]{8,64}$/i.test(raw) ? raw.toLowerCase() : expandStub(raw)
142
+ if (!id) return fail("that is not a blob id or a brain stub")
143
+ const text = blob().get(id)
144
+ if (text === undefined) return fail(`no stored output with id ${id} (never stored, or pruned)`)
145
+ const how = typeof args.grep === "string" && args.grep ? "grep" : args.offset === undefined && args.limit === undefined ? "window" : "page"
146
+ logLookup(opts.home, { event: "expand", at: new Date().toISOString(), id, kind: blob().kindOf?.(id) ?? "unknown", how })
147
+ const safe = redact(text)
148
+ const limit = Math.min(EXPAND_MAX, Math.max(1, Math.floor(Number(args.limit) || EXPAND_DEFAULT)))
149
+ if (typeof args.grep === "string" && args.grep) {
150
+ let re: RegExp
151
+ try {
152
+ re = new RegExp(args.grep, "i")
153
+ } catch {
154
+ re = new RegExp(args.grep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") // not a valid regex: match it as plain text
155
+ }
156
+ const lines = safe.split("\n")
157
+ const hit = lines.map((l, i) => (re.test(l) ? i : -1)).filter((i) => i >= 0)
158
+ if (!hit.length) return ok(`[brain: no line of the ${lines.length}-line output matches "${clip(args.grep, 60)}"]`)
159
+ const ctx = Math.min(10, Math.max(0, Math.floor(Number(args.context ?? 2)) || 0))
160
+ const keep = new Set<number>()
161
+ for (const i of hit) for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) keep.add(j)
162
+ const out: string[] = []
163
+ let used = 0
164
+ let prev = -2
165
+ let shown = 0
166
+ for (const i of [...keep].sort((a, b) => a - b)) {
167
+ const line = `${i + 1}: ${clip(lines[i]!, 400)}`
168
+ if (used + line.length + 1 > limit) break
169
+ if (i !== prev + 1 && out.length) out.push("--")
170
+ out.push(line)
171
+ used += line.length + 1
172
+ prev = i
173
+ shown++
174
+ }
175
+ const more = keep.size > shown ? ` (showing ${shown} of ${keep.size} lines; narrow the grep or raise limit)` : ""
176
+ return ok(`[brain: ${hit.length} matching line${hit.length === 1 ? "" : "s"} of ${lines.length}${more}]\n${out.join("\n")}`)
177
+ }
178
+ // Neither grep, offset nor limit: head + tail (+ the parts matching `query`), not the first page only: agents rarely page.
179
+ if (args.offset === undefined && args.limit === undefined) return ok(windowOf(safe, EXPAND_DEFAULT, { query: typeof args.query === "string" ? args.query : undefined }))
180
+ const offset = Math.max(0, Math.floor(Number(args.offset) || 0))
181
+ const end = Math.min(safe.length, offset + limit)
182
+ const body = safe.slice(offset, end)
183
+ return ok(end < safe.length || offset > 0 ? `${body}\n[brain: characters ${offset}-${end} of ${safe.length}${end < safe.length ? `; more: call again with offset=${end}, or pass grep to find a line` : ""}]` : body)
184
+ }
185
+
186
+ async function status() {
187
+ const st = await mem().status()
188
+ const lines = [`project: ${project.label} (${project.id})`, `memory: ${st.available ? "available" : `not available: ${st.why ?? "unknown reason"}`}`]
189
+ if (st.store) lines.push(`store: ${st.store}`)
190
+ if (st.items !== undefined) lines.push(`items: ${st.items}`)
191
+ lines.push(`blobs: ${blob().dir}`)
192
+ return ok(redact(lines.join("\n")))
193
+ }
194
+
195
+ async function callTool(params: Json) {
196
+ const args = params?.arguments && typeof params.arguments === "object" ? params.arguments : {}
197
+ try {
198
+ switch (params?.name) {
199
+ case "brain_recall":
200
+ return await recall(args)
201
+ case "brain_expand":
202
+ return expand(args)
203
+ case "brain_status":
204
+ return await status()
205
+ case "brain_map": {
206
+ const m = repoMap(cwd, opts.home)
207
+ return ok(redact(m.text || "no files found in this folder"))
208
+ }
209
+ default:
210
+ return undefined
211
+ }
212
+ } catch (e) {
213
+ return fail(`${params.name} failed: ${redact(e instanceof Error ? e.message : String(e))}`)
214
+ }
215
+ }
216
+
217
+ /** One request: a response object, or undefined for a notification. */
218
+ async function handle(msg: Json): Promise<Json | undefined> {
219
+ if (!msg || typeof msg !== "object" || Array.isArray(msg) || typeof msg.method !== "string") {
220
+ return { jsonrpc: "2.0", id: msg?.id ?? null, error: { code: -32600, message: "invalid request" } }
221
+ }
222
+ const hasId = msg.id !== undefined && msg.id !== null
223
+ const reply = (result: unknown) => (hasId ? { jsonrpc: "2.0", id: msg.id, result } : undefined)
224
+ const error = (code: number, message: string) => (hasId ? { jsonrpc: "2.0", id: msg.id, error: { code, message } } : undefined)
225
+ switch (msg.method) {
226
+ case "initialize": {
227
+ const want = msg.params?.protocolVersion
228
+ return reply({
229
+ protocolVersion: VERSIONS.includes(want) ? want : VERSIONS[0],
230
+ capabilities: { tools: { listChanged: false } },
231
+ serverInfo: SERVER_INFO,
232
+ })
233
+ }
234
+ case "ping":
235
+ return reply({})
236
+ case "tools/list":
237
+ return reply({ tools: TOOLS })
238
+ case "tools/call": {
239
+ const result = await callTool(msg.params)
240
+ return result ? reply(result) : error(-32602, `unknown tool: ${String(msg.params?.name)}`)
241
+ }
242
+ default:
243
+ // Notifications (notifications/initialized, cancelled, ...) get no answer; unknown requests get an error.
244
+ return msg.method.startsWith("notifications/") ? undefined : error(-32601, `method not found: ${msg.method}`)
245
+ }
246
+ }
247
+
248
+ const input = opts.input ?? (Bun.stdin.stream() as ReadableStream<Uint8Array>)
249
+ const output = opts.output ?? new WritableStream<Uint8Array>({ write: (chunk) => void process.stdout.write(chunk) })
250
+ const writer = output.getWriter()
251
+ const enc = new TextEncoder()
252
+ const send = (v: unknown) => writer.write(enc.encode(`${JSON.stringify(v)}\n`))
253
+
254
+ async function line(text: string) {
255
+ if (!text.trim()) return
256
+ let msg: unknown
257
+ try {
258
+ msg = JSON.parse(text)
259
+ } catch {
260
+ await send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })
261
+ return
262
+ }
263
+ if (Array.isArray(msg)) {
264
+ const out = (await Promise.all(msg.map((m) => handle(m)))).filter((r) => r !== undefined)
265
+ if (out.length) await send(out)
266
+ return
267
+ }
268
+ const res = await handle(msg as Json)
269
+ if (res) await send(res)
270
+ }
271
+
272
+ const reader = input.getReader()
273
+ const dec = new TextDecoder()
274
+ let buf = ""
275
+ try {
276
+ for (;;) {
277
+ const { done, value } = await reader.read()
278
+ if (done) break
279
+ buf += dec.decode(value, { stream: true })
280
+ let nl: number
281
+ while ((nl = buf.indexOf("\n")) >= 0) {
282
+ const l = buf.slice(0, nl)
283
+ buf = buf.slice(nl + 1)
284
+ await line(l)
285
+ }
286
+ }
287
+ buf += dec.decode()
288
+ await line(buf)
289
+ } finally {
290
+ try {
291
+ await writer.close()
292
+ } catch {
293
+ // The output may already be closed by the other side.
294
+ }
295
+ }
296
+ }
@@ -0,0 +1,179 @@
1
+ // What `brain session continue` does with a session before a writer sees it, to spend fewer tokens:
2
+ // - lean: on a long session (or when asked) big tool output is replaced by digests, the full text kept on disk;
3
+ // - memory: what the session established is distilled into the project's memory (the brain's own store), and what the
4
+ // project already knows about the new request is recalled for the brief.
5
+ // Both are optional and degrade quietly: no Chitta means no memory; a short session stays as it is.
6
+ import { gitInfo, isDir } from "../fsutil.ts"
7
+ import type { WriteOptions } from "../adapters/types.ts"
8
+ import type { Session } from "../session/types.ts"
9
+ import { withoutInjected } from "../session/util.ts"
10
+ import { openBlobStore } from "./blobs.ts"
11
+ import { distill } from "./distill.ts"
12
+ import { externalize, externalizeToBudget } from "./externalize.ts"
13
+ import { tidySession } from "./tidy.ts"
14
+ import { projectKey } from "./project.ts"
15
+ import { repoMap } from "./repomap.ts"
16
+ import { logLookup } from "./lookups.ts"
17
+ import { openMemory } from "./store.ts"
18
+ import { approxTokens, type BlobRef, type BlobStore, type Memory, type ProjectKey } from "./types.ts"
19
+
20
+ /** Sessions with at least this many tokens are made lean when nothing says otherwise: below it, full fidelity costs little. */
21
+ export const LEAN_AUTO_TOKENS = 50_000
22
+ /** Above this many tokens a native continuation cannot fit the context of most models. */
23
+ export const WINDOW_WARN_TOKENS = 400_000
24
+ /** What a lean brief costs, roughly (its budget in characters / 4, plus recalled memory). */
25
+ const BRIEF_TOKENS_HINT = 2_500
26
+ /** What the brief may take from memory. */
27
+ const RECALL_LIMIT = 8
28
+ const RECALL_TOKENS = 1_200
29
+ /** The project map's size in the brief: the brief takes at most 1,200 characters of it (MAP_CHARS). */
30
+ const MAP_TOKENS = 300
31
+
32
+ export interface PrepareOptions {
33
+ home: string
34
+ /** The folder the session continues in. */
35
+ cwd: string
36
+ dryRun: boolean
37
+ /** true: lean; false: full; undefined: lean when the session is long. */
38
+ lean?: boolean
39
+ /** Neither ingest into nor recall from memory. */
40
+ noMemory?: boolean
41
+ /** Aim for a session of at most this many tokens: big outputs are replaced in graduated levels until it fits (implies lean when the session is bigger). */
42
+ budget?: number
43
+ /** Tidy tool results that stay (lossless in meaning: ANSI, progress lines, repeated lines, JSON whitespace). Default true. */
44
+ tidy?: boolean
45
+ }
46
+
47
+ export interface Prepared {
48
+ session: Session
49
+ brief: NonNullable<WriteOptions["brief"]>
50
+ lean: boolean
51
+ /** One line per thing done, for the person ("memory: recalled 3 items", ...). */
52
+ notes: string[]
53
+ }
54
+
55
+ /** The tokens in a session's text-bearing parts (chars / 4). */
56
+ export function sessionTokens(s: Session): number {
57
+ let chars = 0
58
+ for (const t of s.turns) {
59
+ switch (t.kind) {
60
+ case "user":
61
+ case "assistant":
62
+ case "reasoning":
63
+ chars += t.text.length
64
+ break
65
+ case "tool_call":
66
+ chars += JSON.stringify(t.input ?? "").length
67
+ break
68
+ case "tool_result":
69
+ chars += t.output.length
70
+ break
71
+ case "file_edit":
72
+ chars += t.patch?.length ?? 0
73
+ break
74
+ case "compaction":
75
+ chars += t.summary.length
76
+ break
77
+ }
78
+ }
79
+ return approxTokens("x".repeat(chars))
80
+ }
81
+
82
+ /** What to ask memory: the latest real request, else the first. */
83
+ export function queryOf(s: Session): string {
84
+ const users = s.turns.filter((t) => t.kind === "user") as Array<Extract<Session["turns"][number], { kind: "user" }>>
85
+ const real = users.map((u) => withoutInjected(u.text).trim()).filter(Boolean)
86
+ return (real[real.length - 1] ?? real[0] ?? "").slice(0, 500)
87
+ }
88
+
89
+ /** Blobs held in memory only: a dry run counts what would be replaced without writing anything. */
90
+ function scratchBlobs(): BlobStore {
91
+ const m = new Map<string, string>()
92
+ return {
93
+ dir: "(dry run)",
94
+ put(text: string): BlobRef {
95
+ const id = Bun.hash(text).toString(16).padStart(16, "0").slice(0, 16)
96
+ m.set(id, text)
97
+ return { id, chars: text.length }
98
+ },
99
+ get: (id) => m.get(id),
100
+ prune: () => 0,
101
+ }
102
+ }
103
+
104
+ export async function prepare(session: Session, o: PrepareOptions, deps: { memory?: Memory; blobs?: BlobStore } = {}): Promise<Prepared> {
105
+ const notes: string[] = []
106
+ const tokens = sessionTokens(session)
107
+ const budget = o.budget !== undefined && Number.isFinite(o.budget) && o.budget > 0 ? Math.floor(o.budget) : undefined
108
+ const lean = o.lean ?? (tokens >= LEAN_AUTO_TOKENS || (budget !== undefined && tokens > budget))
109
+ const brief: Prepared["brief"] = { lean }
110
+ let out = session
111
+ const tidy = o.tidy !== false
112
+
113
+ if (lean) {
114
+ const blobs = deps.blobs ?? (o.dryRun ? scratchBlobs() : openBlobStore(o.home))
115
+ const r = budget !== undefined ? externalizeToBudget(session, blobs, { targetTokens: budget, tidy }) : externalize(session, blobs, { tidy })
116
+ out = r.session
117
+ if (!o.dryRun && r.replaced) logLookup(o.home, { event: "stubs", at: new Date().toISOString(), session: session.header.id, byKind: r.byKind as Record<string, number> })
118
+ const digested = Math.round((r.before - r.after - r.tidied) / 4)
119
+ if (r.replaced) notes.push(`lean: ${r.replaced} big tool outputs replaced by digests, about ${digested.toLocaleString()} of ${Math.round(r.before / 4).toLocaleString()} tokens saved${o.dryRun ? " (dry run: nothing stored)" : "; the full text is kept; install the brain tools (`brain mcp install`) so the model can ask for it"}`)
120
+ else notes.push(`lean: nothing to replace (the session is ${tokens.toLocaleString()} tokens, and its big outputs are all recent)`)
121
+ if (budget !== undefined && "level" in r) {
122
+ const b = r as ReturnType<typeof externalizeToBudget>
123
+ notes.push(`budget: target ${budget.toLocaleString()} tokens, ${b.reached ? `reached at level ${b.level} (${b.tokens.toLocaleString()} tokens)` : `not reached: level ${b.level} of 4 leaves ${b.tokens.toLocaleString()} tokens`}`)
124
+ }
125
+ if (r.tidied) notes.push(`tidy: ${Math.round(r.tidied / 4).toLocaleString()} tokens saved`)
126
+ } else {
127
+ if (budget !== undefined) notes.push("budget: ignored (the session is kept full)")
128
+ if (tidy) {
129
+ const t = tidySession(session)
130
+ out = t.session
131
+ if (t.tidied) notes.push(`tidy: ${Math.round(t.tidied / 4).toLocaleString()} tokens saved`)
132
+ }
133
+ }
134
+
135
+ // A session still bigger than a model's window cannot be carried over whole, lean or not: say so, and say what does fit.
136
+ const left = sessionTokens(out)
137
+ if (left > WINDOW_WARN_TOKENS) notes.push(`size: still about ${left.toLocaleString()} tokens, more than most models take at once; \`--way brief\` carries the essentials and what the project remembers in about ${(BRIEF_TOKENS_HINT).toLocaleString()} tokens`)
138
+
139
+ if (!o.noMemory) {
140
+ const memory = deps.memory ?? openMemory(o.home)
141
+ const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
142
+ if (!status.available) notes.push(`memory: not used (${status.why ?? "unavailable"})`)
143
+ else {
144
+ const own = projectKey({ ...session.header.project, ...(session.header.project.cwd ? gitInfo(session.header.project.cwd) : {}) })
145
+ const here: ProjectKey = projectKey({ cwd: o.cwd, ...gitInfo(o.cwd) })
146
+ if (!o.dryRun) {
147
+ const items = distill(session)
148
+ if (items.length) {
149
+ const r = await memory.ingest(own, items).catch(() => undefined)
150
+ notes.push(r ? `memory: ${r.stored} item${r.stored === 1 ? "" : "s"} from this session kept for ${own.label}` : "memory: could not store this session's items")
151
+ }
152
+ }
153
+ const q = queryOf(session)
154
+ if (q) {
155
+ const recalled = await memory.recall(here, q, { limit: RECALL_LIMIT, maxTokens: RECALL_TOKENS }).catch(() => [])
156
+ // Items from this very session add nothing the brief does not already say.
157
+ const fresh = recalled.filter((r) => r.source !== session.header.id)
158
+ if (fresh.length) {
159
+ brief.memory = fresh.map((r) => ({ ...(r.kind ? { kind: r.kind } : {}), text: r.text, ...(r.source ? { source: r.source } : {}) }))
160
+ notes.push(`memory: ${fresh.length} earlier item${fresh.length === 1 ? "" : "s"} about this project added to the brief`)
161
+ }
162
+ }
163
+ }
164
+ }
165
+ // A lean continuation starts with a map of the code (cached while the code is unchanged), so the model does not rediscover the files.
166
+ if (lean && isDir(o.cwd)) {
167
+ try {
168
+ const m = repoMap(o.cwd, o.home, { maxTokens: MAP_TOKENS, write: !o.dryRun })
169
+ if (m.text) {
170
+ ;(brief as { map?: string }).map = m.text
171
+ notes.push(`map: about ${m.tokens.toLocaleString()} tokens of project map added to the brief (${m.cached ? "cached, code unchanged" : "regenerated from the code"})`)
172
+ }
173
+ } catch {
174
+ // The map is an extra; the brief is complete without it.
175
+ }
176
+ }
177
+ return { session: out, brief, lean, notes }
178
+ }
179
+
@@ -0,0 +1,154 @@
1
+ // What a lean stub shows of the output it replaced, so the model can often answer without calling brain_expand.
2
+ // Every expansion costs a whole extra request (measured live, docs/live-savings/), and the industry's stubs carry previews
3
+ // for the same reason (Gemini CLI head/tail, OpenCode 2,000 chars, Codex counts). Deterministic and model-free, so the
4
+ // stub is byte-identical every time (the prompt cache depends on it), and capped so a stub stays small next to the output.
5
+ //
6
+ // code read an outline of top-level declarations, with short constant values ("EXPAND_MAX = 60_000; runMcpServer()")
7
+ // shell first lines, last lines (where results and failures are), and error lines in between
8
+ // search the first matches
9
+ // list the first entries
10
+ // other first and last lines
11
+ import type { ToolKind } from "../session/types.ts"
12
+ import { hasSecret, redact } from "./redact.ts"
13
+
14
+ /** The whole preview, at most this many characters. */
15
+ export const PREVIEW_MAX = 900
16
+ const LINE = 160
17
+
18
+ const CODE_EXT = /\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|kts|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|zsh|lua|dart|ex|exs)$/i
19
+ /** Line-number prefixes that read tools add: " 12→" (Claude Code), "12\t", "12: " (OpenCode), "12| " and "00012| " (100xprompt, older OpenCode), " 12|" (Devin). */
20
+ const NUMBERED = /^\s*\d{1,6}(?:→|\t|: |\s?\|\s?)/
21
+ const TRIPLE = ['"""', "'''"]
22
+
23
+ const clip = (s: string, n = LINE): string => {
24
+ const t = s.replace(/\s+/g, " ").trim()
25
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t
26
+ }
27
+ const safe = (l: string): string => {
28
+ const r = clip(redact(l))
29
+ return hasSecret(r) ? "[line withheld: secret]" : r
30
+ }
31
+
32
+ /** Joins items with `sep` while the result stays within `max` characters; ends with "…" when some were left out. */
33
+ export function joinWithin(items: string[], sep: string, max: number): string {
34
+ let out = ""
35
+ for (let i = 0; i < items.length; i++) {
36
+ const next = out ? `${out}${sep}${items[i]}` : items[i]!
37
+ const room = i < items.length - 1 ? max - sep.length - 1 : max
38
+ if (next.length > room) return out ? `${out}${sep}…` : `${items[i]!.slice(0, Math.max(0, max - 1))}…`
39
+ out = next
40
+ }
41
+ return out
42
+ }
43
+
44
+ const count = (s: string, needle: string) => s.split(needle).length - 1
45
+
46
+ /** Top-level declarations of a source file, in order: names, with the value of short constants. */
47
+ export function outline(code: string, max = 40): string[] {
48
+ const out: string[] = []
49
+ const seen = new Set<string>()
50
+ const add = (s: string, key: string) => {
51
+ if (seen.has(key) || out.length >= max) return
52
+ seen.add(key)
53
+ out.push(s)
54
+ }
55
+ const value = (v: string | undefined) => {
56
+ const t = (v ?? "").trim().replace(/[;,]\s*$/, "")
57
+ const open = (t.match(/[{([]/g) ?? []).length
58
+ const close = (t.match(/[})\]]/g) ?? []).length
59
+ // An object or call cut at the line end, or a string that only opens on this line, is not a value.
60
+ const opensString = count(t, "`") % 2 === 1 || TRIPLE.some((q) => count(t, q) % 2 === 1)
61
+ return t && t.length <= 40 && open === close && !opensString ? ` = ${t}` : ""
62
+ }
63
+ // Lines inside a multi-line string (a JS/TS template literal, a Python triple-quoted string) are prose, not declarations.
64
+ let inTemplate = false
65
+ let inTriple: string | undefined
66
+ for (const raw of code.split("\n")) {
67
+ const l = raw.replace(/\r$/, "").replace(NUMBERED, "")
68
+ const inside = inTemplate || inTriple !== undefined
69
+ if (count(l.replace(/\\`/g, ""), "`") % 2 === 1) inTemplate = !inTemplate
70
+ for (const q of TRIPLE) if (count(l, q) % 2 === 1 && (inTriple === undefined || inTriple === q)) inTriple = inTriple === undefined ? q : undefined
71
+ if (inside) continue
72
+ if (/^\s/.test(l) && !/^\s*export\s/.test(l)) continue // nested: only top level
73
+ let m: RegExpExecArray | null
74
+ if ((m = /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/.exec(l))) add(`${m[1]}()`, m[1]!)
75
+ else if ((m = /^(?:export\s+)?(?:declare\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::\s*[^=]+?)?=\s*(.*)$/.exec(l))) {
76
+ const isFn = /^(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=]+)?=>/.test(m[2]!.trim())
77
+ add(isFn ? `${m[1]}()` : `${m[1]}${value(m[2])}`, m[1]!)
78
+ } else if ((m = /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/.exec(l))) add(`${m[1]} ${m[2]}`, m[2]!)
79
+ else if ((m = /^(?:async\s+)?def\s+(\w+)\s*\(/.exec(l))) add(`${m[1]}()`, m[1]!)
80
+ else if ((m = /^class\s+(\w+)/.exec(l))) add(`class ${m[1]}`, m[1]!)
81
+ else if ((m = /^([A-Z][A-Z0-9_]+)\s*(?::\s*[^=]+)?=\s*(.*)$/.exec(l))) add(`${m[1]}${value(m[2])}`, m[1]!)
82
+ else if ((m = /^func\s+(?:\([^)]*\)\s*)?(\w+)/.exec(l))) add(`${m[1]}()`, m[1]!)
83
+ else if ((m = /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(fn|struct|enum|trait|mod)\s+(\w+)/.exec(l))) add(m[1] === "fn" ? `${m[2]}()` : `${m[1]} ${m[2]}`, m[2]!)
84
+ else if ((m = /^(?:pub(?:\([^)]*\))?\s+)?(?:const|static)\s+(\w+)\s*:[^=]*=\s*(.*)$/.exec(l))) add(`${m[1]}${value(m[2])}`, m[1]!)
85
+ }
86
+ return out
87
+ }
88
+
89
+ const ERROR_LINE = /\b(?:error|errors|fail(?:ed|ure|ing)?|exception|traceback|panic(?:ked)?|fatal|denied|not found|cannot|undefined is not)\b/i
90
+
91
+ /** Joins preview lines under the cap; never cuts a line in the middle except the last that fits. */
92
+ function fit(parts: Array<[string, string[]]>): string {
93
+ const out: string[] = []
94
+ let used = 0
95
+ for (const [label, lines] of parts) {
96
+ if (!lines.length) continue
97
+ const head = `${label}:`
98
+ if (used + head.length + 1 > PREVIEW_MAX) break
99
+ out.push(head)
100
+ used += head.length + 1
101
+ for (const l of lines) {
102
+ const s = ` ${l}`
103
+ if (used + s.length + 1 > PREVIEW_MAX) {
104
+ out.push(" …")
105
+ return out.join("\n")
106
+ }
107
+ out.push(s)
108
+ used += s.length + 1
109
+ }
110
+ }
111
+ return out.join("\n")
112
+ }
113
+
114
+ /**
115
+ * The preview block for a replaced tool result ("" when there is nothing useful to add). `path` is the file a read
116
+ * call named, when there is one.
117
+ */
118
+ export function previewOf(raw: string, kind: ToolKind, opts: { ok: boolean; path?: string } = { ok: true }): string {
119
+ // Secrets are found in the whole text first: a private key spans many lines, and one line of it looks like plain base64.
120
+ const output = redact(raw)
121
+ const lines = output
122
+ .split("\n")
123
+ .map((l) => l.replace(/\r$/, "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""))
124
+ .filter((l) => l.trim())
125
+ if (!lines.length) return ""
126
+ const headTail = (h: number, t: number): Array<[string, string[]]> =>
127
+ lines.length <= h + t ? [["lines", lines.map(safe)]] : [["first lines", lines.slice(0, h).map(safe)], ["last lines", lines.slice(-t).map(safe)]]
128
+ const errors = (skip: Set<number>) =>
129
+ lines
130
+ .map((l, i) => [l, i] as const)
131
+ .filter(([l, i]) => !skip.has(i) && ERROR_LINE.test(l))
132
+ .slice(0, 4)
133
+ .map(([l]) => safe(l))
134
+
135
+ if (kind === "read") {
136
+ const code = (opts.path && CODE_EXT.test(opts.path)) || /^\s*(?:\d+(?:→|\t|: |\s?\|)\s*)?(?:import|export|package|use|from|#include|def|func|fn)\b/m.test(output)
137
+ if (code) {
138
+ const o = outline(output).map((d) => clip(d, 80)).filter((d) => !hasSecret(d))
139
+ if (o.length) return `outline: ${joinWithin(o, "; ", PREVIEW_MAX - 9)}`
140
+ }
141
+ return fit(headTail(3, 2))
142
+ }
143
+ if (kind === "shell") {
144
+ const h = 2
145
+ const t = 4
146
+ const skip = new Set<number>([...Array(Math.min(h, lines.length)).keys(), ...Array.from({ length: Math.min(t, lines.length) }, (_, i) => lines.length - 1 - i)])
147
+ const parts = headTail(h, t)
148
+ const e = lines.length > h + t ? errors(skip) : []
149
+ return fit(opts.ok ? [...parts, ["error lines", e]] : [["error lines", e], ...parts])
150
+ }
151
+ if (kind === "search") return fit([["first matches", lines.slice(0, 6).map(safe)]])
152
+ if (kind === "list") return `first entries:\n ${joinWithin(lines.slice(0, 15).map((l) => safe(l).slice(0, 60)), ", ", PREVIEW_MAX - 18)}`
153
+ return fit(headTail(2, 2))
154
+ }
@@ -0,0 +1,35 @@
1
+ // The key a project's memory is filed under: its git remote when it has one (so two clones, or the same repo opened from
2
+ // two tools or two machines, share memory), else the real path of its folder.
3
+ import { realpathSync } from "node:fs"
4
+ import { basename, resolve } from "node:path"
5
+ import type { Project } from "../session/types.ts"
6
+ import type { ProjectKey } from "./types.ts"
7
+
8
+ /** `git@github.com:Owner/Repo.git`, `https://user:tok@GitHub.com/owner/repo/`, `ssh://git@host:22/o/r` -> `github.com/owner/repo`. */
9
+ export function normalizeRemote(remote: string): string | undefined {
10
+ let r = remote.trim()
11
+ if (!r) return undefined
12
+ r = r.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "") // protocol
13
+ r = r.replace(/^[^@/]*@/, "") // user[:password]@
14
+ r = r.replace(/^([^/:]+):(\d+)(?=\/)/, "$1") // host:port/path
15
+ r = r.replace(/^([^/:]+):(?!\/)/, "$1/") // scp-style host:owner/repo
16
+ r = r.replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "")
17
+ r = r.toLowerCase()
18
+ return r.includes("/") ? r : undefined
19
+ }
20
+
21
+ export function projectKey(project: Project): ProjectKey {
22
+ const remote = project.gitRemote ? normalizeRemote(project.gitRemote) : undefined
23
+ if (remote) return { id: remote, label: remote.split("/").pop() || remote }
24
+ if (project.cwd) {
25
+ let path = project.cwd
26
+ try {
27
+ path = realpathSync(project.cwd)
28
+ } catch {
29
+ path = resolve(project.cwd) // a folder from another machine: keep it as written
30
+ }
31
+ path = path.replace(/\/+$/, "") || "/"
32
+ return { id: path, label: basename(path) || path }
33
+ }
34
+ return { id: "unknown", label: "unknown" }
35
+ }