@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,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
|
+
}
|