@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.
- package/LICENSE +48 -0
- package/README.md +235 -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 +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -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,71 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "./adapters/types.ts"
|
|
4
|
+
import { BRIEF_BUDGET, LEAN_BUDGET, buildBrief, toolLabel } from "./brief.ts"
|
|
5
|
+
import { gitInfo } from "./fsutil.ts"
|
|
6
|
+
import { handoffDir, markConsumed, saveHandoff } from "./handoff.ts"
|
|
7
|
+
import { hookInstalled } from "./hooks.ts"
|
|
8
|
+
import type { Session } from "./session/types.ts"
|
|
9
|
+
|
|
10
|
+
// Continuing a session in any tool by a brief: the universal fallback, and the only way into tools that
|
|
11
|
+
// keep sessions on their servers or in formats nothing else can write. The brief is saved as a pending
|
|
12
|
+
// handoff (for the target's session-start hook, when installed) and as a Markdown file to paste.
|
|
13
|
+
// Cursor gets its own route: its prompt deeplink opens the editor with the brief in the chat box,
|
|
14
|
+
// ready to send, without writing to Cursor's database.
|
|
15
|
+
|
|
16
|
+
const CURSOR_DEEPLINK_LIMIT = 10_000
|
|
17
|
+
|
|
18
|
+
export const BRIEF_TARGETS = ["claude-code", "codex", "cursor", "cursor-cli", "gemini-cli", "vscode-copilot", "copilot-cli", "cline", "opencode", "100xprompt", "antigravity", "any"]
|
|
19
|
+
|
|
20
|
+
/** `cursor://` deeplink that opens Cursor's chat with `text` filled in (Cursor asks before sending). */
|
|
21
|
+
export function cursorPromptLink(text: string): string {
|
|
22
|
+
return `cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(text.slice(0, CURSOR_DEEPLINK_LIMIT))}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function briefWriter(target: string, named?: string): Writer {
|
|
26
|
+
const label = named ?? (target === "any" ? "any tool" : target === "cursor" ? "Cursor" : toolLabel(target))
|
|
27
|
+
return {
|
|
28
|
+
tool: target,
|
|
29
|
+
label,
|
|
30
|
+
way: "brief",
|
|
31
|
+
async ready(): Promise<Readiness> {
|
|
32
|
+
return { ok: true }
|
|
33
|
+
},
|
|
34
|
+
async write(session: Session, opts: WriteOptions): Promise<WriteResult> {
|
|
35
|
+
const lean = !!opts.brief?.lean
|
|
36
|
+
const budget = lean ? LEAN_BUDGET : BRIEF_BUDGET[target] ?? BRIEF_BUDGET.default!
|
|
37
|
+
const brief = buildBrief(session, { budget, lean, ...(opts.brief?.memory?.length ? { memory: opts.brief.memory } : {}), ...(opts.brief?.map ? { map: opts.brief.map } : {}), fullRecord: `brain session show ${session.header.id}` })
|
|
38
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`
|
|
39
|
+
const briefPath = join(handoffDir(opts.home), `${id}.md`)
|
|
40
|
+
const written = [briefPath, join(handoffDir(opts.home), `${id}.json`)]
|
|
41
|
+
if (!opts.dryRun) {
|
|
42
|
+
saveHandoff(opts.home, {
|
|
43
|
+
id,
|
|
44
|
+
fromTool: session.header.tool,
|
|
45
|
+
sessionId: session.header.id,
|
|
46
|
+
...(session.header.title ? { title: session.header.title } : {}),
|
|
47
|
+
cwd: opts.cwd,
|
|
48
|
+
...(gitInfo(opts.cwd).gitRemote ? { gitRemote: gitInfo(opts.cwd).gitRemote } : {}),
|
|
49
|
+
target: target === "cursor" ? "cursor-ide" : target,
|
|
50
|
+
brief,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
let open: string
|
|
54
|
+
if (target === "cursor" && hookInstalled("cursor-ide", opts.home)) {
|
|
55
|
+
// Cursor's session-start hook delivers it; the deeplink as well would give the next chat the brief twice.
|
|
56
|
+
open = `open -a Cursor ${JSON.stringify(opts.cwd)}, then start a new chat: Cursor's session-start hook gives the model the brief.`
|
|
57
|
+
} else if (target === "cursor") {
|
|
58
|
+
open = `open -a Cursor ${JSON.stringify(opts.cwd)} && open ${JSON.stringify(cursorPromptLink(`Continue this work.\n\n${brief}`))}`
|
|
59
|
+
// Delivered by the deeplink: claimed now, so a hook installed later does not deliver it again.
|
|
60
|
+
if (!opts.dryRun) markConsumed(opts.home, id, "cursor-ide")
|
|
61
|
+
} else if (hookInstalled(target, opts.home)) {
|
|
62
|
+
open = `Start ${label} in ${opts.cwd}: its session-start hook gives the model the brief.`
|
|
63
|
+
} else {
|
|
64
|
+
open = `Start ${label} in ${opts.cwd} and paste the brief as your first message: ${briefPath}`
|
|
65
|
+
}
|
|
66
|
+
return { tool: target, way: "brief", written, open, lost: [lean ? "everything but the brief: the model gets the essentials and what the project remembers; the rest is stored and reachable on demand" : "everything but the brief: the model gets a summary and the latest turns, not the full history"], dryRun: !!opts.dryRun }
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const briefWriters: Writer[] = BRIEF_TARGETS.map((t) => briefWriter(t))
|
package/src/brief.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Session, Turn } from "./session/types.ts"
|
|
2
|
+
import { asText, firstUserText } from "./session/util.ts"
|
|
3
|
+
|
|
4
|
+
// The continuation brief: what a model needs to carry on a session it never saw, in a fixed budget.
|
|
5
|
+
// Built without a model call, from the portable session, essentials first: where it came from, the goal,
|
|
6
|
+
// where it stands, files changed, commands run, the latest requests, then as many recent turns as fit.
|
|
7
|
+
// Design: docs/sessions/2026-11-08-session-switcher.md, section 3.2.
|
|
8
|
+
|
|
9
|
+
/** Characters each target takes at session start (Codex hook output ~2,500 tokens; Claude Code SessionStart 10,000 characters; Cursor's prompt deeplink 10,000). */
|
|
10
|
+
export const BRIEF_BUDGET: Record<string, number> = {
|
|
11
|
+
"claude-code": 9_500,
|
|
12
|
+
codex: 8_000,
|
|
13
|
+
cursor: 8_000,
|
|
14
|
+
default: 8_000,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const LABEL: Record<string, string> = {
|
|
18
|
+
"claude-code": "Claude Code",
|
|
19
|
+
codex: "Codex",
|
|
20
|
+
"cursor-ide": "Cursor",
|
|
21
|
+
"cursor-cli": "the Cursor CLI",
|
|
22
|
+
"gemini-cli": "Gemini CLI",
|
|
23
|
+
"vscode-copilot": "Copilot Chat in VS Code",
|
|
24
|
+
"copilot-cli": "the Copilot CLI",
|
|
25
|
+
cline: "Cline",
|
|
26
|
+
roo: "Roo Code",
|
|
27
|
+
opencode: "OpenCode",
|
|
28
|
+
"100xprompt": "100xprompt",
|
|
29
|
+
cursor: "Cursor",
|
|
30
|
+
aider: "Aider",
|
|
31
|
+
antigravity: "Antigravity",
|
|
32
|
+
devin: "Devin CLI",
|
|
33
|
+
hermes: "Hermes Agent",
|
|
34
|
+
mastracode: "Mastra Code",
|
|
35
|
+
muse: "Muse Code",
|
|
36
|
+
omp: "Oh My Pi",
|
|
37
|
+
"amazon-q-cli": "the Amazon Q CLI",
|
|
38
|
+
"amazon-q-ide": "Amazon Q in the IDE",
|
|
39
|
+
auggie: "Auggie",
|
|
40
|
+
"cline-sdk": "Cline",
|
|
41
|
+
"cline-cursor": "Cline in Cursor",
|
|
42
|
+
"cline-windsurf": "Cline in Windsurf",
|
|
43
|
+
"roo-cursor": "Roo Code in Cursor",
|
|
44
|
+
"roo-windsurf": "Roo Code in Windsurf",
|
|
45
|
+
codebuff: "Codebuff",
|
|
46
|
+
continue: "Continue",
|
|
47
|
+
crush: "Crush",
|
|
48
|
+
"factory-droid": "Factory Droid",
|
|
49
|
+
goose: "Goose",
|
|
50
|
+
"grok-cli": "Grok CLI",
|
|
51
|
+
"iflow-cli": "iFlow CLI",
|
|
52
|
+
"kilo-cli": "Kilo CLI",
|
|
53
|
+
"kilo-code": "Kilo Code",
|
|
54
|
+
"kilo-code-cursor": "Kilo Code in Cursor",
|
|
55
|
+
"kilo-code-windsurf": "Kilo Code in Windsurf",
|
|
56
|
+
"kimi-code": "Kimi Code",
|
|
57
|
+
"kiro-cli": "Kiro CLI",
|
|
58
|
+
"kiro-ide": "Kiro IDE",
|
|
59
|
+
"letta-code": "Letta Code",
|
|
60
|
+
"mistral-vibe": "Mistral Vibe",
|
|
61
|
+
openhands: "OpenHands",
|
|
62
|
+
pearai: "PearAI",
|
|
63
|
+
pi: "Pi",
|
|
64
|
+
"qoder-cli": "Qoder CLI",
|
|
65
|
+
"qwen-code": "Qwen Code",
|
|
66
|
+
"tabnine-cli": "Tabnine CLI",
|
|
67
|
+
void: "Void",
|
|
68
|
+
zed: "Zed",
|
|
69
|
+
any: "any tool",
|
|
70
|
+
}
|
|
71
|
+
export const toolLabel = (tool: string) => LABEL[tool] ?? tool
|
|
72
|
+
|
|
73
|
+
const cut = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1).trimEnd()}…`)
|
|
74
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim()
|
|
75
|
+
|
|
76
|
+
function ago(when: string | undefined, now: number): string {
|
|
77
|
+
if (!when) return "at an unknown time"
|
|
78
|
+
const m = Math.round((now - Date.parse(when)) / 60_000)
|
|
79
|
+
if (!Number.isFinite(m) || m < 0) return `at ${when}`
|
|
80
|
+
if (m < 2) return "just now"
|
|
81
|
+
if (m < 90) return `${m} minutes ago`
|
|
82
|
+
if (m < 48 * 60) return `${Math.round(m / 60)} hours ago`
|
|
83
|
+
return `${Math.round(m / 1440)} days ago`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Files the session changed, from file_edit turns and edit/write tool calls, in order of first change. */
|
|
87
|
+
export function changedFiles(s: Session): string[] {
|
|
88
|
+
const out: string[] = []
|
|
89
|
+
const add = (p: unknown) => {
|
|
90
|
+
if (typeof p === "string" && p && !out.includes(p)) out.push(p)
|
|
91
|
+
}
|
|
92
|
+
for (const t of s.turns) {
|
|
93
|
+
if (t.kind === "file_edit") add(t.path)
|
|
94
|
+
if (t.kind === "tool_call" && (t.toolKind === "edit" || t.toolKind === "write")) {
|
|
95
|
+
const i = (t.input ?? {}) as Record<string, unknown>
|
|
96
|
+
add(i.file_path ?? i.path ?? i.target_file ?? i.filePath ?? i.relativeWorkspacePath ?? i.file)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Shell commands the session ran, in order. */
|
|
103
|
+
export function commandsRun(s: Session): string[] {
|
|
104
|
+
const out: string[] = []
|
|
105
|
+
for (const t of s.turns) {
|
|
106
|
+
if (t.kind !== "tool_call" || t.toolKind !== "shell") continue
|
|
107
|
+
const i = (t.input ?? {}) as Record<string, unknown>
|
|
108
|
+
const c = i.command ?? i.cmd ?? i.commandLine
|
|
109
|
+
const text = Array.isArray(c) ? c.join(" ") : typeof c === "string" ? c : undefined
|
|
110
|
+
if (text) out.push(oneLine(text))
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderTurn(t: Turn, calls: Map<string, string>): string | undefined {
|
|
116
|
+
switch (t.kind) {
|
|
117
|
+
case "user":
|
|
118
|
+
return `USER: ${cut(t.text.trim(), 1_200)}`
|
|
119
|
+
case "assistant":
|
|
120
|
+
return t.text.trim() ? `ASSISTANT: ${cut(t.text.trim(), 1_200)}` : undefined
|
|
121
|
+
case "tool_call":
|
|
122
|
+
return `TOOL ${t.name}: ${cut(oneLine(asText(t.input)), 240)}`
|
|
123
|
+
case "tool_result":
|
|
124
|
+
return `RESULT${t.ok ? "" : " (error)"} of ${calls.get(t.callId) ?? "tool"}: ${cut(oneLine(t.output), 300)}`
|
|
125
|
+
case "compaction":
|
|
126
|
+
return `EARLIER (summarised): ${cut(t.summary.trim(), 1_500)}`
|
|
127
|
+
case "reasoning":
|
|
128
|
+
return undefined
|
|
129
|
+
case "file_edit":
|
|
130
|
+
return `EDITED ${t.path}`
|
|
131
|
+
case "subagent":
|
|
132
|
+
return `SUBAGENT${t.description ? `: ${cut(t.description, 200)}` : ""}`
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The remembered items as a section, at most MEMORY_CHARS characters, one line each. */
|
|
137
|
+
function memorySection(items: BriefOptions["memory"]): string | undefined {
|
|
138
|
+
if (!items?.length) return undefined
|
|
139
|
+
const lines: string[] = []
|
|
140
|
+
let used = 0
|
|
141
|
+
for (const m of items) {
|
|
142
|
+
const line = `- ${m.kind ? `[${m.kind}] ` : ""}${cut(oneLine(m.text), 400)}`
|
|
143
|
+
if (used + line.length + 1 > MEMORY_CHARS) break
|
|
144
|
+
lines.push(line)
|
|
145
|
+
used += line.length + 1
|
|
146
|
+
}
|
|
147
|
+
return lines.length ? `\nREMEMBERED FOR THIS PROJECT (from earlier sessions, any tool):\n${lines.join("\n")}` : undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface BriefOptions {
|
|
151
|
+
budget?: number
|
|
152
|
+
now?: number
|
|
153
|
+
/** How the person can see the whole session, shown at the end. */
|
|
154
|
+
fullRecord?: string
|
|
155
|
+
/**
|
|
156
|
+
* What the project's memory holds that matters for the first request (brain memory, already limited to a token
|
|
157
|
+
* budget and redacted). Shown as its own section after the goal and state.
|
|
158
|
+
*/
|
|
159
|
+
memory?: Array<{ kind?: string; text: string; source?: string }>
|
|
160
|
+
/**
|
|
161
|
+
* Lean: the essentials plus memory and only the last few turns, in a smaller budget. The rest stays reachable through
|
|
162
|
+
* the brain tools (`brain_recall`, `brain_expand`) or the CLI, so it costs tokens only when the model asks.
|
|
163
|
+
*/
|
|
164
|
+
lean?: boolean
|
|
165
|
+
/** Name the on-demand tools in the closing line. Default true when lean. */
|
|
166
|
+
mention?: boolean
|
|
167
|
+
/** A compact repo map (see memory/repomap.ts), shown in its own section in lean briefs only, at most MAP_CHARS characters. */
|
|
168
|
+
map?: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Characters of a lean brief, and how many recent turns it keeps. */
|
|
172
|
+
export const LEAN_BUDGET = 4_500
|
|
173
|
+
const LEAN_TURNS = 6
|
|
174
|
+
/** The most characters the memory section may take. */
|
|
175
|
+
const MEMORY_CHARS = 1_800
|
|
176
|
+
/** The most characters the project map may take of a lean brief. */
|
|
177
|
+
export const MAP_CHARS = 1_200
|
|
178
|
+
|
|
179
|
+
/** The map as a section: whole lines only, at most MAP_CHARS characters; shorter text is used as it is. */
|
|
180
|
+
export function mapSection(map: string | undefined): string | undefined {
|
|
181
|
+
const t = map?.trim()
|
|
182
|
+
if (!t) return undefined
|
|
183
|
+
const head = "\nPROJECT MAP (regenerated from the code):\n"
|
|
184
|
+
const room = MAP_CHARS - head.length + 1
|
|
185
|
+
let body = t
|
|
186
|
+
if (body.length > room) {
|
|
187
|
+
const lines = body.slice(0, room).split("\n")
|
|
188
|
+
lines.pop() // the last line may be cut mid-way
|
|
189
|
+
body = lines.join("\n")
|
|
190
|
+
}
|
|
191
|
+
return body ? `${head}${body}` : undefined
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A continuation brief for `s`, at most `budget` characters. */
|
|
195
|
+
export function buildBrief(s: Session, opts: BriefOptions = {}): string {
|
|
196
|
+
const budget = opts.budget ?? (opts.lean ? LEAN_BUDGET : BRIEF_BUDGET.default!)
|
|
197
|
+
const now = opts.now ?? Date.now()
|
|
198
|
+
const h = s.header
|
|
199
|
+
const where = [h.project.cwd, h.project.gitBranch && `branch ${h.project.gitBranch}`].filter(Boolean).join(", ")
|
|
200
|
+
const head: string[] = [
|
|
201
|
+
`You are continuing a session that started in ${toolLabel(h.tool)}${h.title ? ` ("${cut(oneLine(h.title), 120)}")` : ""}, last active ${ago(h.updatedAt, now)}${where ? `, in ${where}` : ""}. The person switched tools and wants to carry on where it left off. Treat what follows as the history of this work; check files before relying on details, since they may have changed.`,
|
|
202
|
+
]
|
|
203
|
+
const goal = firstUserText(s)
|
|
204
|
+
if (goal) head.push(`\nGOAL (the first request):\n${cut(goal.trim(), 900)}`)
|
|
205
|
+
const lastAssistant = [...s.turns].reverse().find((t) => t.kind === "assistant" && t.text.trim()) as Extract<Turn, { kind: "assistant" }> | undefined
|
|
206
|
+
if (lastAssistant) head.push(`\nWHERE IT STANDS (the last reply):\n${cut(lastAssistant.text.trim(), 1_200)}`)
|
|
207
|
+
const memory = memorySection(opts.memory)
|
|
208
|
+
if (memory) head.push(memory)
|
|
209
|
+
const mapText = opts.lean ? mapSection(opts.map) : undefined
|
|
210
|
+
if (mapText) head.push(mapText)
|
|
211
|
+
const files = changedFiles(s)
|
|
212
|
+
if (files.length) head.push(`\nFILES CHANGED (${files.length}):\n${files.slice(0, 30).map((f) => `- ${f}`).join("\n")}${files.length > 30 ? `\n- and ${files.length - 30} more` : ""}`)
|
|
213
|
+
const cmds = commandsRun(s)
|
|
214
|
+
if (cmds.length) head.push(`\nLAST COMMANDS RUN:\n${cmds.slice(-8).map((c) => `- ${cut(c, 200)}`).join("\n")}`)
|
|
215
|
+
const users = s.turns.filter((t) => t.kind === "user" && t.text.trim()) as Array<Extract<Turn, { kind: "user" }>>
|
|
216
|
+
if (users.length > 1) head.push(`\nLATEST REQUESTS:\n${users.slice(-3).map((u) => `- ${cut(oneLine(u.text), 400)}`).join("\n")}`)
|
|
217
|
+
const mention = opts.mention ?? !!opts.lean
|
|
218
|
+
const tail = `\n${opts.fullRecord ? `The whole session: ${opts.fullRecord}. ` : ""}${mention ? "More is stored than shown here: ask with the brain_recall tool (what the project knows) or brain_expand (a full tool output shown as \"output stored\"). " : ""}Pick up from the latest request; ask if the next step is unclear.`
|
|
219
|
+
|
|
220
|
+
let text = head.join("\n")
|
|
221
|
+
const room = budget - text.length - tail.length - 40
|
|
222
|
+
if (room > 200) {
|
|
223
|
+
// The most recent turns, newest last, as many as fit.
|
|
224
|
+
const calls = new Map(s.turns.filter((t) => t.kind === "tool_call").map((t) => [(t as Extract<Turn, { kind: "tool_call" }>).id, (t as Extract<Turn, { kind: "tool_call" }>).name]))
|
|
225
|
+
const lines: string[] = []
|
|
226
|
+
let used = 0
|
|
227
|
+
for (const t of [...s.turns].reverse()) {
|
|
228
|
+
const line = renderTurn(t, calls)
|
|
229
|
+
if (!line) continue
|
|
230
|
+
if (used + line.length + 1 > room || (opts.lean && lines.length >= LEAN_TURNS)) break
|
|
231
|
+
lines.unshift(line)
|
|
232
|
+
used += line.length + 1
|
|
233
|
+
}
|
|
234
|
+
if (lines.length) text += `\n\nRECENT TURNS:\n${lines.join("\n")}`
|
|
235
|
+
}
|
|
236
|
+
text += tail
|
|
237
|
+
return text.length <= budget ? text : cut(text, budget)
|
|
238
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { writeFileSync } from "node:fs"
|
|
3
|
+
import { resolve } from "node:path"
|
|
4
|
+
import { parseArgs } from "node:util"
|
|
5
|
+
import type { SessionRef, Way, WriteResult } from "./adapters/types.ts"
|
|
6
|
+
import { changedFiles, toolLabel } from "./brief.ts"
|
|
7
|
+
import { gitInfo, homeDir } from "./fsutil.ts"
|
|
8
|
+
import { secureSelf } from "./self.ts"
|
|
9
|
+
import { HOOK_TOOLS, installHooks, runHook, uninstallHooks } from "./hooks.ts"
|
|
10
|
+
import type { Session } from "./session/types.ts"
|
|
11
|
+
import { prepare, sessionTokens } from "./memory/prepare.ts"
|
|
12
|
+
import { countTurns, firstUserText, toJsonl, unpaired } from "./session/util.ts"
|
|
13
|
+
|
|
14
|
+
// brain session: one working session, any AI coding tool.
|
|
15
|
+
// Design: docs/sessions/2026-11-08-session-switcher.md.
|
|
16
|
+
|
|
17
|
+
// Adapters load only for commands that use them: every tool's session-start hook runs this file, and
|
|
18
|
+
// one adapter failing to load (or loading slowly) must not hold up or break a session in another tool.
|
|
19
|
+
let loaded: typeof import("./adapters/index.ts") | undefined
|
|
20
|
+
const adapters = async () => (loaded ??= await import("./adapters/index.ts"))
|
|
21
|
+
|
|
22
|
+
const USAGE = `brain session <command>
|
|
23
|
+
|
|
24
|
+
list [--tool <id>] [--here] [--limit 30] [--json] every session on this machine, newest first
|
|
25
|
+
show <id> [--json] a session's header, size and what would be lost
|
|
26
|
+
export <id> [-o file] the portable session (brain-session/v1 JSONL)
|
|
27
|
+
continue <id> --in <tool> [--way native|import|brief] [--cwd dir] [--dry-run] [--no-open]
|
|
28
|
+
handoff --in <tool> [--from <tool>] [--dry-run] [--no-open] the newest session in this folder, to <tool>
|
|
29
|
+
continue/handoff also take [--lean | --full] [--budget <tokens>] [--no-memory] [--no-tidy] lean: big tool output becomes digests (automatic on long sessions); budget: mask in levels until the session fits
|
|
30
|
+
targets where a session can go, and how
|
|
31
|
+
hooks install|uninstall|status [--tool <id>] [--dry-run] session-start hooks that deliver briefs
|
|
32
|
+
hook <event> --tool <id> (run by a tool's hook; reads the hook's JSON on stdin)
|
|
33
|
+
|
|
34
|
+
<id> is <tool>:<session id> as \`list\` prints it, or a unique start of the session id.
|
|
35
|
+
|
|
36
|
+
brain memory ingest <id> | search <query> [--project p] | status | forget [--project p] [--source id] | sync [--here] [--limit n] what projects remember, across tools
|
|
37
|
+
brain tokens audit [--dir <folder>] static, read-only check of what a project reloads into every request (instruction files, MCP servers, ignore files)
|
|
38
|
+
brain tokens lookups how often each kind of stubbed output is asked back (brain_expand); a high rate means its preview is too thin
|
|
39
|
+
brain org login --server <url> --token <t> | logout | whoami | status | promote|demote (--ids a,b | --source <id>) --to <scope> | forget --mine
|
|
40
|
+
brain org admin init|serve|user|token|team|project|policy|audit|retention shared memory for an organisation (roles, scopes, audit)
|
|
41
|
+
brain mcp serve | install | uninstall | status [--tool <id>] the brain_recall / brain_expand tools for your AI tools
|
|
42
|
+
brain gateway login [--no-browser] | logout | connect --tool <id> | disconnect --tool <id> | status | tools sign in to 100xprompt and route a coding tool through its gateway (and back)
|
|
43
|
+
brain dashboard [push|on|off|status] [--quiet] [--no-open] send this machine's report (tools, gateway, conversions; never chat text) to your 100xprompt account and open its Brain view
|
|
44
|
+
brain --version the version, and where this brain runs from
|
|
45
|
+
brain expand <blob id> the full text of a tool output that was replaced by a digest`
|
|
46
|
+
|
|
47
|
+
const home = homeDir()
|
|
48
|
+
const out = (s: string) => process.stdout.write(`${s}\n`)
|
|
49
|
+
// Output piped into something that stops reading early (`brain session list | head`) ends quietly, not with a trace.
|
|
50
|
+
process.stdout.on("error", (e: NodeJS.ErrnoException) => {
|
|
51
|
+
if (e.code === "EPIPE") process.exit(0)
|
|
52
|
+
throw e
|
|
53
|
+
})
|
|
54
|
+
secureSelf()
|
|
55
|
+
const fail = (s: string): never => {
|
|
56
|
+
process.stderr.write(`brain: ${s}\n`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
/** After a command that changes what the dashboard report says: a report in a detached process (src/dashboard/send.ts). */
|
|
60
|
+
async function reportLater() {
|
|
61
|
+
try {
|
|
62
|
+
const { reportInBackground } = await import("./dashboard/send.ts")
|
|
63
|
+
reportInBackground(home)
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function allRefs(tool?: string): Promise<{ refs: SessionRef[]; errors: string[] }> {
|
|
68
|
+
const { READERS } = await adapters()
|
|
69
|
+
const readers = tool ? READERS.filter((r) => r.tool === tool) : READERS
|
|
70
|
+
if (tool && !readers.length) fail(`no reader for "${tool}"; readers: ${READERS.map((r) => r.tool).join(", ")}`)
|
|
71
|
+
const errors: string[] = []
|
|
72
|
+
const lists = await Promise.all(readers.map(async (r) => {
|
|
73
|
+
try {
|
|
74
|
+
return await r.list(home)
|
|
75
|
+
} catch (e) {
|
|
76
|
+
errors.push(`${r.tool}: ${(e as Error).message}`)
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}))
|
|
80
|
+
return { refs: lists.flat().sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "")), errors }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function resolveRef(given: string): Promise<SessionRef> {
|
|
84
|
+
// An id copied from an older, shortened list ends in "…": what comes before it is still a unique start.
|
|
85
|
+
const id = given.replace(/…$/, "")
|
|
86
|
+
const { readerFor } = await adapters()
|
|
87
|
+
const colon = id.indexOf(":")
|
|
88
|
+
const tool = colon > 0 && readerFor(id.slice(0, colon)) ? id.slice(0, colon) : undefined
|
|
89
|
+
const want = tool ? id.slice(colon + 1) : id
|
|
90
|
+
const { refs } = await allRefs(tool)
|
|
91
|
+
const exact = refs.filter((r) => r.sourceId === want)
|
|
92
|
+
const matches = exact.length ? exact : refs.filter((r) => r.sourceId.startsWith(want))
|
|
93
|
+
if (!matches.length) fail(`no session "${id}"; see \`brain session list\``)
|
|
94
|
+
if (matches.length > 1) fail(`"${id}" matches ${matches.length} sessions (${matches.slice(0, 4).map((r) => `${r.tool}:${r.sourceId}`).join(", ")}…); give more of the id`)
|
|
95
|
+
return matches[0]!
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function readRef(ref: SessionRef): Promise<Session> {
|
|
99
|
+
const { readerFor } = await adapters()
|
|
100
|
+
return readerFor(ref.tool)!.read(ref, home)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether a session that ran in `cwd` belongs to the folder `here`: the same folder or one inside it, or a
|
|
105
|
+
* folder above it in the same git repository. A folder above that is not the same repository (/tmp, home)
|
|
106
|
+
* is a different project, so a session there is never "here" and never handed off.
|
|
107
|
+
*/
|
|
108
|
+
function inProject(cwd: string | undefined, here: string): boolean {
|
|
109
|
+
if (!cwd) return false
|
|
110
|
+
if (cwd === here || cwd.startsWith(`${here}/`)) return true
|
|
111
|
+
if (!here.startsWith(`${cwd}/`) || cwd === home || home.startsWith(`${cwd}/`)) return false
|
|
112
|
+
const a = gitInfo(cwd).gitRemote
|
|
113
|
+
return !!a && a === gitInfo(here).gitRemote
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ago = (when?: string) => {
|
|
117
|
+
if (!when) return "?"
|
|
118
|
+
const m = (Date.now() - Date.parse(when)) / 60_000
|
|
119
|
+
return m < 60 ? `${Math.max(0, Math.round(m))}m ago` : m < 48 * 60 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`
|
|
120
|
+
}
|
|
121
|
+
const short = (p?: string) => (p ? p.replace(home, "~") : "")
|
|
122
|
+
const cut = (s: string, n: number) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
|
|
123
|
+
|
|
124
|
+
function describe(s: Session): string {
|
|
125
|
+
const c = countTurns(s)
|
|
126
|
+
const u = unpaired(s)
|
|
127
|
+
const h = s.header
|
|
128
|
+
const lines = [
|
|
129
|
+
`${h.id} (${toolLabel(h.tool)}${h.toolVersion ? ` ${h.toolVersion}` : ""})`,
|
|
130
|
+
h.title ? `title ${h.title}` : "",
|
|
131
|
+
`project ${short(h.project.cwd)}${h.project.gitBranch ? ` @ ${h.project.gitBranch}` : ""}`,
|
|
132
|
+
`when ${h.startedAt ?? "?"} → ${h.updatedAt ?? "?"}`,
|
|
133
|
+
`models ${h.models.join(", ") || "?"}`,
|
|
134
|
+
`turns ${c.user} user, ${c.assistant} assistant, ${c.tool_call} tool calls, ${c.tool_result} results, ${c.reasoning} reasoning, ${c.compaction} compactions, ${c.subagent} subagents${s.children?.length ? ` (${s.children.length} read)` : ""}`,
|
|
135
|
+
`files ${changedFiles(s).length} changed`,
|
|
136
|
+
u.calls.length || u.results.length ? `unpaired ${u.calls.length} calls without a result, ${u.results.length} results without a call` : "",
|
|
137
|
+
`lost ${h.lost.length ? h.lost.join("; ") : "nothing"}`,
|
|
138
|
+
`source ${short(h.sourcePath)}`,
|
|
139
|
+
firstUserText(s) ? `first ask ${cut(firstUserText(s)!.replace(/\s+/g, " "), 160)}` : "",
|
|
140
|
+
]
|
|
141
|
+
return lines.filter(Boolean).join("\n")
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function report(r: WriteResult) {
|
|
145
|
+
out(`${r.dryRun ? "Would continue" : "Continued"} in ${r.tool} by ${r.way === "native" ? "a native session" : r.way === "import" ? "its own import" : "a brief"}${r.sessionId ? ` (session ${r.sessionId})` : ""}.`)
|
|
146
|
+
for (const w of r.written) out(` ${r.dryRun ? "would write" : "wrote"} ${short(w)}`)
|
|
147
|
+
if (r.lost.length) out(` not carried over: ${r.lost.join("; ")}`)
|
|
148
|
+
out(`Next: ${r.open}`)
|
|
149
|
+
if (r.note) out(` then: ${r.note}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function continueIn(source: Session, target: string, opts: { way?: Way; cwd?: string; dryRun: boolean; open: boolean; lean?: boolean; noMemory?: boolean; budget?: number; tidy?: false }) {
|
|
153
|
+
const { writersFor } = await adapters()
|
|
154
|
+
const candidates = writersFor(target).filter((w) => !opts.way || w.way === opts.way)
|
|
155
|
+
if (!candidates.length) fail(`can't continue in "${target}"${opts.way ? ` by ${opts.way}` : ""}; see \`brain session targets\``)
|
|
156
|
+
const cwd = resolve(opts.cwd ?? source.header.project.cwd ?? process.cwd())
|
|
157
|
+
const prepared = await prepare(source, { home, cwd, dryRun: opts.dryRun, ...(opts.lean !== undefined ? { lean: opts.lean } : {}), ...(opts.noMemory ? { noMemory: true } : {}), ...(opts.budget ? { budget: opts.budget } : {}), ...(opts.tidy === false ? { tidy: false } : {}) })
|
|
158
|
+
const session = prepared.session
|
|
159
|
+
for (const n of prepared.notes) out(n)
|
|
160
|
+
const wopts = { home, cwd, dryRun: opts.dryRun, brief: prepared.brief }
|
|
161
|
+
const skipped: string[] = []
|
|
162
|
+
for (const w of candidates) {
|
|
163
|
+
const ready = await w.ready(wopts)
|
|
164
|
+
if (!ready.ok) {
|
|
165
|
+
skipped.push(`${w.way}: ${ready.why}`)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
for (const s of skipped) out(`(${s}; using the next way)`)
|
|
169
|
+
const r = await w.write(session, wopts)
|
|
170
|
+
report(r)
|
|
171
|
+
if (!r.dryRun) {
|
|
172
|
+
// The dashboard's conversion log: tools, way, title, project folder name and sizes; never the chat (src/dashboard/conversions.ts).
|
|
173
|
+
const { logConversion } = await import("./dashboard/conversions.ts")
|
|
174
|
+
const { readerFor } = await adapters()
|
|
175
|
+
const to = r.tool === "cursor" ? "cursor-ide" : r.tool
|
|
176
|
+
logConversion(home, { from: source.header.tool, fromLabel: readerFor(source.header.tool)?.label ?? toolLabel(source.header.tool), to: r.tool, toLabel: readerFor(to)?.label ?? w.label, way: r.way, title: source.header.title, cwd: source.header.project.cwd ?? cwd, turns: source.turns.length, tokens: sessionTokens(session), lean: prepared.lean })
|
|
177
|
+
}
|
|
178
|
+
if (!r.dryRun && r.way === "brief") {
|
|
179
|
+
const brief = Bun.file(r.written[0]!)
|
|
180
|
+
// The clipboard where the system has one (pbcopy on macOS, wl-copy or xclip on Linux, clip on Windows); a missing tool is fine.
|
|
181
|
+
const copy = process.platform === "darwin" ? ["pbcopy"] : process.platform === "win32" ? ["clip"] : Bun.which("wl-copy") ? ["wl-copy"] : ["xclip", "-selection", "clipboard"]
|
|
182
|
+
let copied = false
|
|
183
|
+
try {
|
|
184
|
+
if (await brief.exists()) copied = Bun.spawnSync(copy, { stdin: Buffer.from(await brief.text()) }).exitCode === 0
|
|
185
|
+
} catch {}
|
|
186
|
+
if (copied) out(" (the brief is also on the clipboard)")
|
|
187
|
+
}
|
|
188
|
+
if (!r.dryRun && opts.open && target === "cursor") Bun.spawnSync(["/bin/sh", "-c", r.open])
|
|
189
|
+
if (!r.dryRun) await reportLater()
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
fail(`no way to continue in "${target}" right now: ${skipped.join("; ")}`)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** --lean / --full into the option (neither: decided by the session's size). */
|
|
196
|
+
const leanOf = (v: { lean?: boolean; full?: boolean }): { lean?: boolean } => (v.lean && v.full ? fail("--lean and --full together?") : v.lean ? { lean: true } : v.full ? { lean: false } : {})
|
|
197
|
+
|
|
198
|
+
/** --budget <tokens> into a positive integer (or a clear failure). */
|
|
199
|
+
const budgetOf = (v: { budget?: string }): { budget?: number } => {
|
|
200
|
+
if (v.budget === undefined) return {}
|
|
201
|
+
const n = Number(v.budget)
|
|
202
|
+
if (!Number.isInteger(n) || n < 1_000) fail("--budget needs a whole number of tokens, at least 1000 (for example --budget 80000)")
|
|
203
|
+
return { budget: n }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function main() {
|
|
207
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
208
|
+
if (cmd === "--help" || cmd === "-h" || cmd === "help") return out(USAGE)
|
|
209
|
+
if (cmd === "--version" || cmd === "-v" || cmd === "version") {
|
|
210
|
+
const { CLI_PATH, PACKAGE, VERSION, isEphemeral } = await import("./self.ts")
|
|
211
|
+
out(`${PACKAGE} ${VERSION}`)
|
|
212
|
+
out(`running from ${CLI_PATH}${isEphemeral() ? " (a temporary bunx copy: hooks and MCP entries it installs run the pinned package)" : ""}`)
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
// Commands with their own flags: `mcp serve` must keep stdout clean for the protocol, so they run before option parsing.
|
|
216
|
+
if (cmd === "gateway") {
|
|
217
|
+
const { gatewayCommand } = await import("./gateway/connect.ts")
|
|
218
|
+
const code = await gatewayCommand(rest, home)
|
|
219
|
+
if (code === 0 && ["login", "logout", "connect", "disconnect"].includes(rest[0] ?? "") && !rest.includes("--dry-run")) await reportLater()
|
|
220
|
+
process.exit(code)
|
|
221
|
+
}
|
|
222
|
+
if (cmd === "mcp") {
|
|
223
|
+
const { mcpCommand } = await import("./memory/mcp-install.ts")
|
|
224
|
+
const code = await mcpCommand(rest, home)
|
|
225
|
+
if (code === 0 && ["install", "uninstall"].includes(rest[0] ?? "") && !rest.includes("--dry-run")) await reportLater()
|
|
226
|
+
process.exit(code)
|
|
227
|
+
}
|
|
228
|
+
if (cmd === "dashboard") {
|
|
229
|
+
const { dashboardCommand } = await import("./dashboard/cli.ts")
|
|
230
|
+
process.exit(await dashboardCommand(rest, home))
|
|
231
|
+
}
|
|
232
|
+
if (cmd === "memory" && rest[0] === "sync") {
|
|
233
|
+
const { syncMemory } = await import("./memory/sync.ts")
|
|
234
|
+
const here = rest.includes("--here")
|
|
235
|
+
const limitAt = rest.indexOf("--limit")
|
|
236
|
+
const r = await syncMemory({ home, ...(here ? { cwd: process.cwd() } : {}), ...(limitAt >= 0 ? { limit: Number(rest[limitAt + 1]) || 8 } : {}) })
|
|
237
|
+
if (!rest.includes("--quiet")) {
|
|
238
|
+
if (r.unavailable) out(`memory sync: memory is not available (${r.unavailable})`)
|
|
239
|
+
else if (r.busy) out("memory sync: another sync is running")
|
|
240
|
+
else out(`memory sync: ${r.ingested} session${r.ingested === 1 ? "" : "s"} (${r.items} items) added, ${r.upToDate} already up to date${r.deferred ? `, ${r.deferred} left for the next run` : ""}${r.errors.length ? `; could not read ${r.errors.length}` : ""} (${(r.ms / 1000).toFixed(1)}s)`)
|
|
241
|
+
}
|
|
242
|
+
process.exit(0)
|
|
243
|
+
}
|
|
244
|
+
if (cmd === "memory") {
|
|
245
|
+
const { memoryCommand } = await import("./memory/cli.ts")
|
|
246
|
+
process.exit(await memoryCommand(rest, home, async (id) => readRef(await resolveRef(id)), out))
|
|
247
|
+
}
|
|
248
|
+
if (cmd === "tokens") {
|
|
249
|
+
const { tokensCommand } = await import("./memory/audit.ts")
|
|
250
|
+
process.exit(await tokensCommand(rest, home, { out }))
|
|
251
|
+
}
|
|
252
|
+
if (cmd === "org") {
|
|
253
|
+
const { orgCommand } = await import("./org/cli.ts")
|
|
254
|
+
process.exit(await orgCommand(rest, home, { out }))
|
|
255
|
+
}
|
|
256
|
+
if (cmd === "expand") {
|
|
257
|
+
const id = rest[0] ?? fail("expand needs a blob id (from a digest: \"id=...\")")
|
|
258
|
+
const { openBlobStore } = await import("./memory/blobs.ts")
|
|
259
|
+
const { expandStub } = await import("./memory/externalize.ts")
|
|
260
|
+
const text = openBlobStore(home).get(expandStub(id) ?? id)
|
|
261
|
+
if (text === undefined) fail(`no stored output "${id}" (it may have been pruned)`)
|
|
262
|
+
process.stdout.write(text!)
|
|
263
|
+
process.exit(0)
|
|
264
|
+
}
|
|
265
|
+
const sub = cmd === "session" ? rest.shift() : cmd
|
|
266
|
+
const args = cmd === "session" ? rest : rest
|
|
267
|
+
const { values, positionals } = parseArgs({
|
|
268
|
+
args,
|
|
269
|
+
allowPositionals: true,
|
|
270
|
+
options: {
|
|
271
|
+
tool: { type: "string" }, here: { type: "boolean" }, limit: { type: "string" }, json: { type: "boolean" },
|
|
272
|
+
in: { type: "string" }, way: { type: "string" }, cwd: { type: "string" }, from: { type: "string" },
|
|
273
|
+
"dry-run": { type: "boolean" }, "no-open": { type: "boolean" }, lean: { type: "boolean" }, full: { type: "boolean" }, "no-memory": { type: "boolean" }, budget: { type: "string" }, "no-tidy": { type: "boolean" }, o: { type: "string" }, help: { type: "boolean", short: "h" },
|
|
274
|
+
},
|
|
275
|
+
})
|
|
276
|
+
if (!sub || values.help) return out(USAGE)
|
|
277
|
+
|
|
278
|
+
if (sub === "list") {
|
|
279
|
+
const { refs, errors } = await allRefs(values.tool)
|
|
280
|
+
const here = values.here ? process.cwd() : undefined
|
|
281
|
+
const shown = refs.filter((r) => !here || inProject(r.cwd, here)).slice(0, Number(values.limit ?? 30))
|
|
282
|
+
if (values.json) return out(JSON.stringify(shown, null, 1))
|
|
283
|
+
// The id column is never cut: it is what `show` and `continue` take, so it must be copyable whole.
|
|
284
|
+
const idw = Math.max(48, ...shown.map((r) => r.tool.length + 1 + r.sourceId.length))
|
|
285
|
+
for (const r of shown) out(`${`${r.tool}:${r.sourceId}`.padEnd(idw)} ${ago(r.updatedAt).padStart(8)} ${cut(short(r.cwd), 34).padEnd(34)} ${cut((r.title ?? "").replace(/\s+/g, " "), 60)}`)
|
|
286
|
+
out(`${shown.length} of ${refs.length} sessions${errors.length ? `; could not list: ${errors.join("; ")}` : ""}`)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if (sub === "show" || sub === "export") {
|
|
290
|
+
const id = positionals[0] ?? fail(`${sub} needs a session id`)
|
|
291
|
+
const s = await readRef(await resolveRef(id))
|
|
292
|
+
if (sub === "export") {
|
|
293
|
+
if (values.o) {
|
|
294
|
+
writeFileSync(values.o, toJsonl(s))
|
|
295
|
+
return out(`wrote ${values.o}`)
|
|
296
|
+
}
|
|
297
|
+
return process.stdout.write(toJsonl(s))
|
|
298
|
+
}
|
|
299
|
+
return out(values.json ? JSON.stringify(s.header, null, 1) : describe(s))
|
|
300
|
+
}
|
|
301
|
+
if (sub === "continue") {
|
|
302
|
+
const id = positionals[0] ?? fail("continue needs a session id")
|
|
303
|
+
const target = values.in ?? fail("continue needs --in <tool>")
|
|
304
|
+
const s = await readRef(await resolveRef(id))
|
|
305
|
+
return continueIn(s, target, { ...(values.way ? { way: values.way as Way } : {}), ...(values.cwd ? { cwd: values.cwd } : {}), dryRun: !!values["dry-run"], open: !values["no-open"], ...leanOf(values), ...budgetOf(values), ...(values["no-tidy"] ? { tidy: false as const } : {}), ...(values["no-memory"] ? { noMemory: true } : {}) })
|
|
306
|
+
}
|
|
307
|
+
if (sub === "handoff") {
|
|
308
|
+
const target = values.in ?? fail("handoff needs --in <tool>")
|
|
309
|
+
const here = process.cwd()
|
|
310
|
+
const { refs } = await allRefs(values.from)
|
|
311
|
+
const ref = refs.find((r) => r.tool !== target && inProject(r.cwd, here))
|
|
312
|
+
if (!ref) fail(`no session from another tool in ${short(here)}`)
|
|
313
|
+
out(`Handing off ${ref!.tool}:${ref!.sourceId}${ref!.title ? ` ("${cut(ref!.title, 60)}")` : ""}, ${ago(ref!.updatedAt)}.`)
|
|
314
|
+
return continueIn(await readRef(ref!), target, { ...(values.way ? { way: values.way as Way } : {}), cwd: here, dryRun: !!values["dry-run"], open: !values["no-open"], ...leanOf(values), ...budgetOf(values), ...(values["no-tidy"] ? { tidy: false as const } : {}), ...(values["no-memory"] ? { noMemory: true } : {}) })
|
|
315
|
+
}
|
|
316
|
+
if (sub === "targets") {
|
|
317
|
+
const { READERS, WRITERS, writersFor } = await adapters()
|
|
318
|
+
const targets = [...new Set(WRITERS.map((w) => w.tool))]
|
|
319
|
+
for (const t of targets) {
|
|
320
|
+
const ways = await Promise.all(writersFor(t).map(async (w) => {
|
|
321
|
+
const r = await w.ready({ home, cwd: process.cwd(), dryRun: true })
|
|
322
|
+
return `${w.way}${r.ok ? "" : ` (not now: ${r.why})`}`
|
|
323
|
+
}))
|
|
324
|
+
out(`${t.padEnd(16)} ${ways.join(", ")}`)
|
|
325
|
+
}
|
|
326
|
+
out(`\nreaders: ${READERS.map((r) => r.tool).join(", ")}`)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
if (sub === "hooks") {
|
|
330
|
+
const action = positionals[0] ?? "status"
|
|
331
|
+
const tools = values.tool ? [values.tool] : HOOK_TOOLS
|
|
332
|
+
const changes = action === "install" ? await installHooks(tools, home, { dryRun: !!values["dry-run"] })
|
|
333
|
+
: action === "uninstall" ? await uninstallHooks(tools, home, { dryRun: !!values["dry-run"] })
|
|
334
|
+
: action === "status" ? await installHooks(tools, home, { dryRun: true })
|
|
335
|
+
: fail(`hooks ${action}? use install, uninstall or status`)
|
|
336
|
+
for (const c of changes) out(`${c.tool.padEnd(14)} ${c.action}${c.why ? ` (${c.why})` : ""} ${short(c.path)}`)
|
|
337
|
+
if (!changes.length) out("no tools with session-start hooks found")
|
|
338
|
+
if ((action === "install" || action === "uninstall") && !values["dry-run"]) await reportLater()
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (sub === "hook") {
|
|
342
|
+
const event = positionals[0] ?? fail("hook needs an event")
|
|
343
|
+
const tool = values.tool ?? fail("hook needs --tool")
|
|
344
|
+
const text = await runHook(event, tool, await Bun.stdin.text(), home)
|
|
345
|
+
if (text) process.stdout.write(text)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
fail(`unknown command "${sub}"\n\n${USAGE}`)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await main()
|