@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,323 @@
|
|
|
1
|
+
// `brain gateway login`: sign in to 100xprompt in the browser, the same way the 100xprompt CLI does (`100xprompt auth login`).
|
|
2
|
+
//
|
|
3
|
+
// 1. A one-use server listens on 127.0.0.1 on a free port, for /auth/callback only.
|
|
4
|
+
// 2. The browser opens https://100xprompt.com/auth/cli?port=<port>&state=<random>&challenge=<PKCE S256>. The person signs in
|
|
5
|
+
// there; the site sends the browser back to http://127.0.0.1:<port>/auth/callback?token=...&state=...&email=...
|
|
6
|
+
// 3. The state must be the one sent (so no other page can hand this machine a key); the token is the gateway key.
|
|
7
|
+
// 4. No browser here (SSH), or the callback cannot get through: the person pastes the one-time code or API key the
|
|
8
|
+
// site shows instead (input hidden). It is checked the way the CLI checks it.
|
|
9
|
+
//
|
|
10
|
+
// HUNDREDXPROMPT_AUTH_URL points the sign-in at another site (the CLI reads the same variable). The key is never printed.
|
|
11
|
+
//
|
|
12
|
+
// The state is the only thing that tells the real sign-in apart from a key planted by someone else on this machine, so
|
|
13
|
+
// it is kept out of other processes' view where the system allows: on macOS the address goes to the browser through
|
|
14
|
+
// AppleScript's `open location` on stdin, not on a command line that `ps` shows every user. (Linux and Windows browsers
|
|
15
|
+
// take the address on their command line; there, another user who watches `ps` during the sign-in could plant a key. The
|
|
16
|
+
// 100xprompt CLI has the same limit everywhere.)
|
|
17
|
+
import { timingSafeEqual } from "node:crypto"
|
|
18
|
+
|
|
19
|
+
export const AUTH_URL = "https://100xprompt.com"
|
|
20
|
+
export const TIMEOUT_MS = 5 * 60_000
|
|
21
|
+
|
|
22
|
+
/** PKCE (RFC 7636): a 43-character verifier and its S256 challenge, as the CLI makes them. */
|
|
23
|
+
export async function pkce(): Promise<{ verifier: string; challenge: string }> {
|
|
24
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
25
|
+
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (b) => chars[b % chars.length]).join("")
|
|
26
|
+
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")
|
|
27
|
+
return { verifier, challenge }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a pasted credential looks like a 100xprompt one, as the CLI decides it (Auth.validate): a 100x_live_ / 100x_test_ /
|
|
32
|
+
* sk- key, a sign-in token (JWT, not expired), or a 100x_auth_ one-time code wrapping either. Returns the key to store.
|
|
33
|
+
*/
|
|
34
|
+
export function checkCredential(raw: string, now = Date.now()): { ok: true; key: string; email?: string } | { ok: false; why: string } {
|
|
35
|
+
const t = raw.trim()
|
|
36
|
+
if (!t || t.length < 16) return { ok: false, why: "that is too short to be a key or code" }
|
|
37
|
+
if (/\s/.test(t)) return { ok: false, why: "a key or code has no spaces in it" }
|
|
38
|
+
// The sign-in page shows the key shortened ("100x_live_AbCdEfGhIjKl...12345678"); only its Copy key button gives all of it.
|
|
39
|
+
if (/\.\.\.|…/.test(t)) return { ok: false, why: "that is the shortened key the page shows: use its Copy key button, then paste again" }
|
|
40
|
+
if (/^(?:100x_live_|100x_test_|sk-)/.test(t)) {
|
|
41
|
+
if (!/^(?:100x_live_|100x_test_|sk-)[A-Za-z0-9_-]+$/.test(t)) return { ok: false, why: "that key has characters a 100xprompt key never has" }
|
|
42
|
+
return t.length >= 24 ? { ok: true, key: t } : { ok: false, why: "that key is too short" }
|
|
43
|
+
}
|
|
44
|
+
const parts = t.split(".")
|
|
45
|
+
if (parts.length === 3) {
|
|
46
|
+
try {
|
|
47
|
+
const head = JSON.parse(Buffer.from(parts[0]!, "base64url").toString("utf8"))
|
|
48
|
+
const body = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8"))
|
|
49
|
+
if (!head?.alg || !body?.sub) return { ok: false, why: "that sign-in token is malformed" }
|
|
50
|
+
if (typeof body.exp === "number" && body.exp < Math.floor(now / 1000)) return { ok: false, why: "that sign-in token has expired: sign in again" }
|
|
51
|
+
return { ok: true, key: t, ...(typeof body.email === "string" ? { email: body.email } : {}) }
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, why: "that sign-in token is malformed" }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (t.startsWith("100x_auth_")) {
|
|
57
|
+
try {
|
|
58
|
+
const inner = JSON.parse(Buffer.from(t.slice("100x_auth_".length), "base64url").toString("utf8"))
|
|
59
|
+
if (typeof inner?.t === "string" && !inner.t.startsWith("100x_auth_")) {
|
|
60
|
+
const r = checkCredential(inner.t, now)
|
|
61
|
+
return r.ok && typeof inner.e === "string" && !r.email ? { ...r, email: inner.e } : r
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return { ok: false, why: "that one-time code is malformed" }
|
|
65
|
+
}
|
|
66
|
+
return { ok: false, why: `that is not a 100xprompt key or one-time code (copy the code from ${AUTH_URL}/auth/cli, or a 100x_live_ key)` }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
|
|
70
|
+
const page = (title: string, body: string, status = 200) =>
|
|
71
|
+
new Response(
|
|
72
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title>` +
|
|
73
|
+
`<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#050505;color:#f1ecec}` +
|
|
74
|
+
`main{text-align:center;max-width:32rem;padding:1rem}h1{font-size:1.4rem}p{color:#a8a3a3}</style></head>` +
|
|
75
|
+
`<body><main><h1>${esc(title)}</h1><p>${esc(body)}</p></main></body></html>`,
|
|
76
|
+
// The callback URL carries the key: nothing on this page may cache it or pass it on.
|
|
77
|
+
{ status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer", "x-frame-options": "DENY" } },
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
/** Constant-time state check. Byte lengths are compared (a non-ASCII state of the right length in characters must not throw). */
|
|
81
|
+
const sameState = (got: string, want: string) => {
|
|
82
|
+
const a = Buffer.from(got)
|
|
83
|
+
const b = Buffer.from(want)
|
|
84
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The key in a callback's token: the CLI takes it as it is; brain also unwraps a one-time code and refuses junk. */
|
|
88
|
+
const callbackKey = (token: string): { ok: true; key: string; email?: string } | { ok: false; why: string } =>
|
|
89
|
+
token.startsWith("100x_auth_") ? checkCredential(token) : token.length >= 16 && !/\s/.test(token) ? { ok: true, key: token } : { ok: false, why: "no key came back from the sign-in page" }
|
|
90
|
+
|
|
91
|
+
/** The sign-in site from --auth-url / $HUNDREDXPROMPT_AUTH_URL (empty means unset, as for the CLI); undefined if not http(s). */
|
|
92
|
+
export function signInBase(given?: string): string | undefined {
|
|
93
|
+
const raw = given?.trim() || process.env.HUNDREDXPROMPT_AUTH_URL?.trim() || AUTH_URL
|
|
94
|
+
try {
|
|
95
|
+
const u = new URL(raw)
|
|
96
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") return undefined
|
|
97
|
+
return `${u.origin}${u.pathname.replace(/\/+$/, "")}`
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SignInDeps {
|
|
104
|
+
/** Opens the sign-in page; default: the system browser. */
|
|
105
|
+
open?: (url: string) => void
|
|
106
|
+
/** false: print the address only (a machine with no browser, or one the person opens elsewhere). */
|
|
107
|
+
browser?: boolean
|
|
108
|
+
/** The sign-in site; default $HUNDREDXPROMPT_AUTH_URL or https://100xprompt.com. */
|
|
109
|
+
authUrl?: string
|
|
110
|
+
timeoutMs?: number
|
|
111
|
+
/**
|
|
112
|
+
* Asks for a pasted code, input hidden; resolves undefined when the person gives up or `signal` aborts. Absent when
|
|
113
|
+
* there is no terminal to ask in (then the browser is the only way).
|
|
114
|
+
*/
|
|
115
|
+
ask?: (message: string, signal: AbortSignal) => Promise<string | undefined>
|
|
116
|
+
say: (line: string) => void
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type SignIn = { ok: true; key: string; email?: string; via: "browser" | "code" } | { ok: false; why: string }
|
|
120
|
+
|
|
121
|
+
/** Signs in through the browser (or a pasted code). Does not store anything: the caller does. */
|
|
122
|
+
export async function browserSignIn(d: SignInDeps): Promise<SignIn> {
|
|
123
|
+
const base = signInBase(d.authUrl)
|
|
124
|
+
if (!base) return { ok: false, why: "HUNDREDXPROMPT_AUTH_URL must be an http:// or https:// address" }
|
|
125
|
+
const { challenge } = await pkce()
|
|
126
|
+
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex")
|
|
127
|
+
let finish!: (r: SignIn) => void
|
|
128
|
+
const done = new Promise<SignIn>((r) => (finish = r))
|
|
129
|
+
let settled = false
|
|
130
|
+
const settle = (r: SignIn) => {
|
|
131
|
+
if (settled) return
|
|
132
|
+
settled = true
|
|
133
|
+
finish(r)
|
|
134
|
+
}
|
|
135
|
+
const server = Bun.serve({
|
|
136
|
+
hostname: "127.0.0.1",
|
|
137
|
+
port: 0,
|
|
138
|
+
development: false,
|
|
139
|
+
// Anything the handler did not expect: a plain refusal, never a stack trace on the page or in the terminal.
|
|
140
|
+
error: () => new Response("Bad request", { status: 400 }),
|
|
141
|
+
fetch(req) {
|
|
142
|
+
const url = new URL(req.url)
|
|
143
|
+
if (url.pathname !== "/auth/callback" || req.method !== "GET") return new Response("Not found", { status: 404 })
|
|
144
|
+
if (settled) return page("Already signed in", "This sign-in has finished. You can close this tab.", 410)
|
|
145
|
+
const got = url.searchParams.get("state") ?? ""
|
|
146
|
+
const error = url.searchParams.get("error")
|
|
147
|
+
// Only the page this command opened knows the state: anything else is refused and the wait goes on.
|
|
148
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return page("Sign-in refused", "This link was not started by this brain gateway login. Start again from your terminal.", 400)
|
|
149
|
+
if (error) {
|
|
150
|
+
settle({ ok: false, why: `the sign-in page said: ${error.slice(0, 200)}` })
|
|
151
|
+
return page("Sign-in failed", `${error.slice(0, 200)}. Go back to your terminal.`, 400)
|
|
152
|
+
}
|
|
153
|
+
const c = callbackKey(url.searchParams.get("token") ?? "")
|
|
154
|
+
if (!c.ok) {
|
|
155
|
+
settle({ ok: false, why: c.why })
|
|
156
|
+
return page("Sign-in failed", "No usable key came back. Go back to your terminal.", 400)
|
|
157
|
+
}
|
|
158
|
+
const email = url.searchParams.get("email") || c.email || undefined
|
|
159
|
+
settle({ ok: true, key: c.key, ...(email ? { email } : {}), via: "browser" })
|
|
160
|
+
return page("Signed in", `brain is now signed in to the 100xprompt gateway${email ? ` as ${email}` : ""}. You can close this tab and go back to your terminal.`)
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
const url = `${base}/auth/cli?port=${server.port}&state=${state}&challenge=${challenge}`
|
|
164
|
+
const stopAsking = new AbortController()
|
|
165
|
+
const timer = setTimeout(() => settle({ ok: false, why: `no sign-in came back within ${Math.round((d.timeoutMs ?? TIMEOUT_MS) / 60_000) || 1} minutes` }), d.timeoutMs ?? TIMEOUT_MS)
|
|
166
|
+
try {
|
|
167
|
+
if (d.browser === false) d.say(`Open this address in a browser to sign in to 100xprompt:\n ${url}`)
|
|
168
|
+
else {
|
|
169
|
+
d.say(`Opening your browser to sign in to 100xprompt:\n ${url}`)
|
|
170
|
+
try {
|
|
171
|
+
;(d.open ?? openInBrowser)(url)
|
|
172
|
+
} catch {}
|
|
173
|
+
}
|
|
174
|
+
if (d.ask) {
|
|
175
|
+
d.say(
|
|
176
|
+
"Waiting for the browser. Signing in on another machine (SSH)? Paste the key from the page's Copy key button, or the address the browser could not open, then press Enter (Escape cancels).",
|
|
177
|
+
)
|
|
178
|
+
// The paste runs alongside the browser: whichever comes first wins. A bad paste asks again; Escape ends the sign-in
|
|
179
|
+
// (an abandoned hidden prompt would leave the terminal echoing whatever is pasted next, and the shell reading it).
|
|
180
|
+
void (async () => {
|
|
181
|
+
for (;;) {
|
|
182
|
+
const pasted = await d.ask!("key or address: ", stopAsking.signal)
|
|
183
|
+
if (settled) return
|
|
184
|
+
if (pasted === undefined) return settle({ ok: false, why: "sign-in cancelled" })
|
|
185
|
+
if (!pasted.trim()) continue
|
|
186
|
+
const c = fromPaste(pasted.trim(), state)
|
|
187
|
+
if (c.ok) return settle({ ok: true, key: c.key, ...(c.email ? { email: c.email } : {}), via: "code" })
|
|
188
|
+
if ("fatal" in c) return settle({ ok: false, why: c.why })
|
|
189
|
+
d.say(` ${c.why}`)
|
|
190
|
+
}
|
|
191
|
+
})()
|
|
192
|
+
} else d.say("Waiting for the browser…")
|
|
193
|
+
return await done
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer)
|
|
196
|
+
stopAsking.abort()
|
|
197
|
+
// The page that just answered must reach the browser before this returns: `brain` exits right after, and an exit
|
|
198
|
+
// with the answer still unsent leaves the person looking at a browser error after a sign-in that worked. A graceful
|
|
199
|
+
// stop waits for that answer; a browser holding the connection open gets cut off after 1.5 s.
|
|
200
|
+
await Promise.race([server.stop(), Bun.sleep(1_500)])
|
|
201
|
+
void server.stop(true)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A pasted key, one-time code or sign-in token; or the callback address itself (on SSH the browser is sent to
|
|
207
|
+
* 127.0.0.1 on the wrong machine, and the address it could not open holds the key). An address must carry this sign-in's
|
|
208
|
+
* state, so one from another sign-in, or one made up, is refused.
|
|
209
|
+
*/
|
|
210
|
+
export function fromPaste(pasted: string, state: string): { ok: true; key: string; email?: string } | { ok: false; why: string } | { ok: false; why: string; fatal: true } {
|
|
211
|
+
if (/^https?:\/\//i.test(pasted)) {
|
|
212
|
+
let u: URL
|
|
213
|
+
try {
|
|
214
|
+
u = new URL(pasted)
|
|
215
|
+
} catch {
|
|
216
|
+
return { ok: false, why: "that address could not be read" }
|
|
217
|
+
}
|
|
218
|
+
if (!u.pathname.endsWith("/auth/callback")) return { ok: false, why: "that is not the sign-in's return address (it ends in /auth/callback?token=...)" }
|
|
219
|
+
const got = u.searchParams.get("state") ?? ""
|
|
220
|
+
if (!/^[0-9a-f]{64}$/.test(got) || !sameState(got, state)) return { ok: false, why: "that address is from another sign-in: paste the one from this sign-in" }
|
|
221
|
+
const error = u.searchParams.get("error")
|
|
222
|
+
if (error) return { ok: false, why: `the sign-in page said: ${error.slice(0, 200)}`, fatal: true }
|
|
223
|
+
const c = callbackKey(u.searchParams.get("token") ?? "")
|
|
224
|
+
if (!c.ok) return c
|
|
225
|
+
const email = u.searchParams.get("email") || c.email
|
|
226
|
+
return { ok: true, key: c.key, ...(email ? { email } : {}) }
|
|
227
|
+
}
|
|
228
|
+
return checkCredential(pasted)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* How to open the sign-in page on this platform: the command, and what to write to its stdin. On macOS the address goes
|
|
233
|
+
* to AppleScript's `open location` on stdin, so it (and its state) is on no command line.
|
|
234
|
+
*/
|
|
235
|
+
export function browserCommand(url: string, platform: string = process.platform): { argv: string[]; stdin?: string } {
|
|
236
|
+
if (platform === "darwin") return { argv: ["osascript", "-"], stdin: `open location "${url.replace(/[\\"]/g, (c) => `\\${c}`)}"\n` }
|
|
237
|
+
if (platform === "win32") return { argv: ["cmd", "/c", "start", "", url.replace(/&/g, "^&")] }
|
|
238
|
+
return { argv: ["xdg-open", url] }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Opens the sign-in page in the default browser (browserCommand). */
|
|
242
|
+
export function openInBrowser(url: string): void {
|
|
243
|
+
const c = browserCommand(url)
|
|
244
|
+
try {
|
|
245
|
+
const p = Bun.spawn(c.argv, { stdin: c.stdin === undefined ? "ignore" : "pipe", stdout: "ignore", stderr: "ignore" })
|
|
246
|
+
if (c.stdin !== undefined && p.stdin && typeof p.stdin !== "number") {
|
|
247
|
+
p.stdin.write(c.stdin)
|
|
248
|
+
void p.stdin.end()
|
|
249
|
+
}
|
|
250
|
+
p.unref()
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Applies typed or pasted terminal input to `buf`. Enter ends the line; Escape on its own, or Ctrl-D on an empty line,
|
|
256
|
+
* cancels; Ctrl-C interrupts. Arrow, Delete and Home keys, Alt+key and bracketed-paste markers arrive as escape sequences
|
|
257
|
+
* and are skipped, so they neither end the prompt nor land in the key.
|
|
258
|
+
*/
|
|
259
|
+
export function applyKeys(buf: string, chunk: string): { buf: string; end?: "enter" | "cancel" | "interrupt" } {
|
|
260
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
261
|
+
const ch = chunk[i]!
|
|
262
|
+
if (ch === "\r" || ch === "\n") return { buf, end: "enter" }
|
|
263
|
+
if (ch === "\u0003") return { buf, end: "interrupt" }
|
|
264
|
+
if (ch === "\u0004") {
|
|
265
|
+
if (!buf) return { buf, end: "cancel" }
|
|
266
|
+
continue
|
|
267
|
+
}
|
|
268
|
+
if (ch === "\u001b") {
|
|
269
|
+
const next = chunk[i + 1]
|
|
270
|
+
if (next === undefined) return { buf, end: "cancel" }
|
|
271
|
+
if (next === "[") {
|
|
272
|
+
// CSI: parameter bytes, then one final byte in 0x40-0x7E.
|
|
273
|
+
let j = i + 2
|
|
274
|
+
while (j < chunk.length && !(chunk.charCodeAt(j) >= 0x40 && chunk.charCodeAt(j) <= 0x7e)) j++
|
|
275
|
+
i = j
|
|
276
|
+
} else i += next === "O" ? 2 : 1 // SS3 (ESC O x) or Alt+key (ESC x)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
if (ch === "\u007f" || ch === "\b") buf = buf.slice(0, -1)
|
|
280
|
+
else if (ch >= " ") buf += ch
|
|
281
|
+
}
|
|
282
|
+
return { buf }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Reads one line from the terminal without showing it (the key must not land in the scrollback). undefined: no terminal,
|
|
287
|
+
* cancelled (Escape, Ctrl-D), or `signal` aborted. Ctrl-C ends the command, as it would anywhere else.
|
|
288
|
+
*/
|
|
289
|
+
export function askHidden(message: string, signal: AbortSignal): Promise<string | undefined> {
|
|
290
|
+
const stdin = process.stdin
|
|
291
|
+
if (!stdin.isTTY || signal.aborted) return Promise.resolve(undefined)
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
let buf = ""
|
|
294
|
+
let over = false
|
|
295
|
+
process.stderr.write(message)
|
|
296
|
+
stdin.setRawMode(true)
|
|
297
|
+
stdin.setEncoding("utf8")
|
|
298
|
+
stdin.resume()
|
|
299
|
+
const end = (v: string | undefined) => {
|
|
300
|
+
if (over) return
|
|
301
|
+
over = true
|
|
302
|
+
stdin.off("data", onData)
|
|
303
|
+
signal.removeEventListener("abort", onAbort)
|
|
304
|
+
stdin.setRawMode(false)
|
|
305
|
+
stdin.pause()
|
|
306
|
+
process.stderr.write("\n")
|
|
307
|
+
resolve(v)
|
|
308
|
+
}
|
|
309
|
+
const onAbort = () => end(undefined)
|
|
310
|
+
const onData = (chunk: string) => {
|
|
311
|
+
const r = applyKeys(buf, chunk)
|
|
312
|
+
buf = r.buf
|
|
313
|
+
if (r.end === "enter") end(buf)
|
|
314
|
+
else if (r.end === "cancel") end(undefined)
|
|
315
|
+
else if (r.end === "interrupt") {
|
|
316
|
+
end(undefined)
|
|
317
|
+
process.exit(130)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
stdin.on("data", onData)
|
|
321
|
+
signal.addEventListener("abort", onAbort)
|
|
322
|
+
})
|
|
323
|
+
}
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
// Pending handoffs: a brief waiting for the next session of a target tool in a project. `brain session
|
|
5
|
+
// continue --in <tool>` writes one; the tool's session-start hook (hooks.ts) finds it, hands the brief to
|
|
6
|
+
// the model once, and marks it used for that tool. Kept as plain files under ~/.brain/handoffs.
|
|
7
|
+
|
|
8
|
+
export const HANDOFF_HOURS = 12
|
|
9
|
+
|
|
10
|
+
export interface Handoff {
|
|
11
|
+
id: string
|
|
12
|
+
createdAt: string
|
|
13
|
+
expiresAt: string
|
|
14
|
+
/** The tool and portable id of the session being continued. */
|
|
15
|
+
fromTool: string
|
|
16
|
+
sessionId: string
|
|
17
|
+
title?: string
|
|
18
|
+
/** The project folder the brief is for. */
|
|
19
|
+
cwd: string
|
|
20
|
+
gitRemote?: string
|
|
21
|
+
/** The tool the person is moving to, or "any". */
|
|
22
|
+
target: string
|
|
23
|
+
brief: string
|
|
24
|
+
/** The brief as a Markdown file, for pasting by hand. */
|
|
25
|
+
briefPath: string
|
|
26
|
+
/** Tools whose sessions have already received it. */
|
|
27
|
+
consumedBy: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const handoffDir = (home: string) => join(home, ".brain", "handoffs")
|
|
31
|
+
|
|
32
|
+
export function saveHandoff(home: string, h: Omit<Handoff, "briefPath" | "consumedBy" | "expiresAt" | "createdAt"> & { now?: number }): Handoff {
|
|
33
|
+
const now = h.now ?? Date.now()
|
|
34
|
+
const dir = handoffDir(home)
|
|
35
|
+
mkdirSync(dir, { recursive: true })
|
|
36
|
+
const briefPath = join(dir, `${h.id}.md`)
|
|
37
|
+
const { now: _, ...rest } = h
|
|
38
|
+
const full: Handoff = { ...rest, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + HANDOFF_HOURS * 3_600_000).toISOString(), briefPath, consumedBy: [] }
|
|
39
|
+
writeFileSync(briefPath, `${h.brief}\n`)
|
|
40
|
+
writeFileSync(join(dir, `${h.id}.json`), `${JSON.stringify(full, null, 1)}\n`)
|
|
41
|
+
return full
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function listHandoffs(home: string): Handoff[] {
|
|
45
|
+
const dir = handoffDir(home)
|
|
46
|
+
let names: string[] = []
|
|
47
|
+
try {
|
|
48
|
+
names = readdirSync(dir).filter((n) => n.endsWith(".json"))
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
const out: Handoff[] = []
|
|
53
|
+
for (const n of names) {
|
|
54
|
+
try {
|
|
55
|
+
out.push(JSON.parse(readFileSync(join(dir, n), "utf8")) as Handoff)
|
|
56
|
+
} catch {
|
|
57
|
+
// A half-written or foreign file is not a handoff.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const norm = (p: string) => p.replace(/\/+$/, "")
|
|
64
|
+
/** The same project: the same folder, one inside the other, or the same git remote. */
|
|
65
|
+
function sameProject(h: Handoff, cwd: string, gitRemote?: string): boolean {
|
|
66
|
+
const a = norm(h.cwd)
|
|
67
|
+
const b = norm(cwd)
|
|
68
|
+
return a === b || b.startsWith(`${a}/`) || a.startsWith(`${b}/`) || (!!gitRemote && !!h.gitRemote && h.gitRemote === gitRemote)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A tool's claim on a handoff: created exclusively, so two sessions starting at once cannot both take it. */
|
|
72
|
+
const claimPath = (home: string, id: string, tool: string) => join(handoffDir(home), `${id}.${tool}.claimed`)
|
|
73
|
+
|
|
74
|
+
/** The newest unexpired handoff for this project that `tool` has not received yet. */
|
|
75
|
+
export function findHandoff(home: string, q: { cwd: string; tool: string; gitRemote?: string; now?: number }): Handoff | undefined {
|
|
76
|
+
const now = q.now ?? Date.now()
|
|
77
|
+
return listHandoffs(home).find(
|
|
78
|
+
(h) =>
|
|
79
|
+
Date.parse(h.expiresAt) > now &&
|
|
80
|
+
(h.target === q.tool || h.target === "any") &&
|
|
81
|
+
!h.consumedBy.includes(q.tool) &&
|
|
82
|
+
!existsSync(claimPath(home, h.id, q.tool)) &&
|
|
83
|
+
sameProject(h, q.cwd, q.gitRemote),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Claims the handoff for `tool`; false when another session of that tool already has it (deliver nothing then). */
|
|
88
|
+
export function markConsumed(home: string, id: string, tool: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(claimPath(home, id, tool), new Date().toISOString(), { flag: "wx" })
|
|
91
|
+
} catch {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
// Also recorded in the handoff itself, for listing; the claim file is what decides.
|
|
95
|
+
const path = join(handoffDir(home), `${id}.json`)
|
|
96
|
+
try {
|
|
97
|
+
const h = JSON.parse(readFileSync(path, "utf8")) as Handoff
|
|
98
|
+
if (!h.consumedBy.includes(tool)) h.consumedBy.push(tool)
|
|
99
|
+
writeFileSync(path, `${JSON.stringify(h, null, 1)}\n`)
|
|
100
|
+
} catch {
|
|
101
|
+
// Gone or unreadable: the claim file still stands.
|
|
102
|
+
}
|
|
103
|
+
return true
|
|
104
|
+
}
|