@100xprompt/brain 0.1.5 → 0.1.6

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.
@@ -0,0 +1,169 @@
1
+ // Gateway connector for Claude Desktop (macOS): its own third-party mode, "Claude (third-party)", pointed at the gateway.
2
+ //
3
+ // Checked 2026-11-11 in Claude 4.23118.0 (app.asar, read only), and with the app's bundled Claude Code (2.1.317) against
4
+ // the gateway's code:
5
+ // - Signed in with a Claude account the app talks to Anthropic only, and sets ANTHROPIC_BASE_URL for its Claude Code
6
+ // itself (so Claude Code's own settings do not move it).
7
+ // - Its third-party mode takes a gateway ("Configure Third-Party Inference…", or an admin's managed preferences): a
8
+ // config library in ~/Library/Application Support/Claude-3p/configLibrary, `_meta.json` ({appliedId, entries:
9
+ // [{id, name}]}) naming the applied `<id>.json`, whose flat keys are the settings: inferenceProvider "gateway",
10
+ // inferenceGatewayBaseUrl, inferenceCredentialKind "helper-script" + inferenceCredentialHelper (a program that prints
11
+ // the key; the app caches it for inferenceCredentialHelperTtlSec), inferenceGatewayAuthScheme "bearer",
12
+ // modelDiscoveryEnabled, inferenceModels ([{name, labelOverride, supports1m, prefer1m, anthropicFamilyTier}]),
13
+ // inferenceCustomHeaders ({name: value}).
14
+ // - The app gives its Claude Code ANTHROPIC_BASE_URL = the gateway, ANTHROPIC_AUTH_TOKEN = the helper's output and
15
+ // the model's name; Claude Code posts the Messages API to <base>/v1/messages, which the gateway's routes serve.
16
+ // - A model's name must read as an Anthropic model (contain claude, opus, sonnet, haiku or anthropic; never gemini and
17
+ // the like): so `anthropic/100x-pro` and `anthropic/100x-flash` (the gateway serves a name with "pro" as Pro and any
18
+ // other as Flash; the auto route serves any name), shown in the menu by their 100xprompt names (labelOverride). With
19
+ // "[1m]" (supports1m) Claude Code takes the 1M window the models have.
20
+ // - Third-party mode is a separate profile (its own sessions and settings, in Claude-3p). The app starts in it when a
21
+ // third-party configuration is applied and the person has not chosen their Claude account there; signing in with the
22
+ // Claude account again goes back.
23
+ // brain writes its own entry (a fixed id) and applies it; the key never goes into the app's files: the helper reads it
24
+ // from brain's key file each time the app asks.
25
+ import { join } from "node:path"
26
+ import { savedMode } from "../../adapters/claude-desktop/records.ts"
27
+ import { AUTO_MODEL, brainDir, EDIT_HEADER, isAuto, keyPath, routeUrl, type Codec, type Connector } from "../common.ts"
28
+
29
+ /** brain's entry in the app's config library (a UUID, as the app requires). */
30
+ export const ENTRY_ID = "b7a1e0c0-0100-4000-8000-000000000100"
31
+ const ENTRY_NAME = "100xprompt"
32
+ const MARK = "brain-claude-desktop-key"
33
+
34
+ export const thirdPartyDir = (home: string) => join(home, "Library", "Application Support", "Claude-3p")
35
+ const libraryDir = (home: string) => join(thirdPartyDir(home), "configLibrary")
36
+ export const metaPath = (home: string) => join(libraryDir(home), "_meta.json")
37
+ export const entryPath = (home: string) => join(libraryDir(home), `${ENTRY_ID}.json`)
38
+ export const helperPath = (home: string) => join(brainDir(home), "bin", "claude-desktop-key")
39
+
40
+ /** The names the app sends; the gateway serves a name with "pro" as Pro, any other as Flash, and the auto route any. */
41
+ const PRO = { name: "anthropic/100x-pro", labelOverride: "100xprompt Pro", supports1m: true, prefer1m: true, anthropicFamilyTier: "sonnet" }
42
+ const FLASH = { name: "anthropic/100x-flash", labelOverride: "100xprompt Flash", supports1m: true, prefer1m: true, anthropicFamilyTier: "haiku" }
43
+ const AUTO = { name: `anthropic/${AUTO_MODEL}`, labelOverride: "100xprompt Auto", supports1m: true, prefer1m: true, anthropicFamilyTier: "sonnet" }
44
+
45
+ /** The helper the app runs for the key: it prints the first line of brain's key file. No key in it. */
46
+ export function helperText(v: { keyFile: string }): string {
47
+ const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
48
+ return [
49
+ "#!/bin/sh",
50
+ `# ${MARK} ${JSON.stringify(v)}`,
51
+ "# Prints the 100xprompt gateway key for Claude Desktop's third-party mode (its credential helper). Written by",
52
+ "# `brain gateway connect --tool claude-desktop`; undo with `brain gateway disconnect --tool claude-desktop`.",
53
+ `exec head -n 1 ${sq(v.keyFile)}`,
54
+ "",
55
+ ].join("\n")
56
+ }
57
+
58
+ /** The helper script, which brain writes whole; anyone else's file there is left alone. */
59
+ const helperCodec: Codec = {
60
+ check(text) {
61
+ return text.trim() && !text.includes(MARK) ? "it is not brain's key helper; left as it is" : undefined
62
+ },
63
+ get(text, key) {
64
+ if (key !== "helper") return undefined
65
+ const m = new RegExp(`${MARK} (\\{.*\\})`).exec(text)
66
+ try {
67
+ return m ? JSON.parse(m[1]!) : undefined
68
+ } catch {
69
+ return undefined
70
+ }
71
+ },
72
+ set(_text, key, value) {
73
+ if (key !== "helper") throw new Error(`unknown helper key ${key}`)
74
+ return value === undefined ? "" : helperText(value as { keyFile: string })
75
+ },
76
+ }
77
+
78
+ type Meta = { appliedId: string; entries: Array<{ id: string; name: string; [k: string]: unknown }>; [k: string]: unknown }
79
+ const parseMeta = (text: string): Meta => {
80
+ if (!text.trim()) return { appliedId: "", entries: [] }
81
+ const j = JSON.parse(text)
82
+ return { ...j, appliedId: typeof j.appliedId === "string" ? j.appliedId : "", entries: Array.isArray(j.entries) ? j.entries : [] }
83
+ }
84
+
85
+ /**
86
+ * The config library's list (`_meta.json`): brain's entry in it ("entry") and which entry is applied ("appliedId"). The
87
+ * person's own entries and anything else the app keeps there are left as they are. A list left with nothing in it is
88
+ * written empty, so a disconnect takes away a file only connect made.
89
+ */
90
+ const metaCodec: Codec = {
91
+ check(text) {
92
+ if (!text.trim()) return undefined
93
+ try {
94
+ const j = JSON.parse(text)
95
+ if (!j || typeof j !== "object" || Array.isArray(j) || (j.entries !== undefined && !Array.isArray(j.entries))) return "it is not the app's config list; left as it is"
96
+ } catch {
97
+ return "it is not valid JSON; left as it is"
98
+ }
99
+ return undefined
100
+ },
101
+ get(text, key) {
102
+ const m = parseMeta(text)
103
+ // brain's entry by its id alone: renamed in the app, it is still brain's (and goes with brain's file at disconnect).
104
+ if (key === "entry") return m.entries.some((e) => e?.id === ENTRY_ID) ? { id: ENTRY_ID, name: ENTRY_NAME } : undefined
105
+ if (key === "appliedId") return text.trim() ? m.appliedId : undefined
106
+ return undefined
107
+ },
108
+ set(text, key, value) {
109
+ const m = parseMeta(text)
110
+ if (key === "entry") {
111
+ const at = m.entries.findIndex((e) => e?.id === ENTRY_ID)
112
+ if (value === undefined) {
113
+ if (at >= 0) m.entries.splice(at, 1)
114
+ } else if (at >= 0) m.entries[at] = value as Meta["entries"][number]
115
+ else m.entries.push(value as Meta["entries"][number])
116
+ } else if (key === "appliedId") {
117
+ // Never an id the list no longer has (the person deleted that configuration in the app meanwhile).
118
+ const id = typeof value === "string" ? value : ""
119
+ m.appliedId = id && m.entries.some((e) => e?.id === id) ? id : ""
120
+ }
121
+ else throw new Error(`unknown config list key ${key}`)
122
+ const rest = Object.keys(m).filter((k) => k !== "appliedId" && k !== "entries")
123
+ if (!m.entries.length && !m.appliedId && !rest.length) return ""
124
+ return `${JSON.stringify(m, null, 2)}\n`
125
+ },
126
+ }
127
+
128
+ const claudeDesktop: Connector = {
129
+ id: "claude-desktop",
130
+ label: "Claude Desktop",
131
+ file: (home) => entryPath(home),
132
+ probe: ["Library/Application Support/Claude"],
133
+ authHeader: "authorization",
134
+ edits: (home, s) => {
135
+ const auto = isAuto(s)
136
+ // The model the person chose comes first: the app starts a session on the first in the list.
137
+ const models = auto ? [AUTO] : s.model === "gemini-3.7-flash" ? [FLASH, PRO] : [PRO, FLASH]
138
+ return [
139
+ { path: helperPath(home), format: "custom", codec: helperCodec, values: { helper: { keyFile: keyPath(home) } }, mode: 0o700 },
140
+ {
141
+ path: entryPath(home),
142
+ format: "json",
143
+ values: {
144
+ inferenceProvider: "gateway",
145
+ inferenceGatewayBaseUrl: routeUrl(s),
146
+ inferenceGatewayAuthScheme: "bearer",
147
+ inferenceCredentialKind: "helper-script",
148
+ inferenceCredentialHelper: helperPath(home),
149
+ inferenceCredentialHelperTtlSec: 3600,
150
+ modelDiscoveryEnabled: false,
151
+ inferenceModels: models,
152
+ ...(s.contextEdit ? { inferenceCustomHeaders: { [EDIT_HEADER]: "on" } } : {}),
153
+ },
154
+ },
155
+ { path: metaPath(home), format: "custom", codec: metaCodec, values: { entry: { id: ENTRY_ID, name: ENTRY_NAME }, appliedId: ENTRY_ID } },
156
+ ]
157
+ },
158
+ notesHere: (home) =>
159
+ savedMode(home) === "1p"
160
+ ? ["Claude Desktop is set to start on your Claude account (you chose it in the app), so it keeps doing that: to use 100xprompt, choose Configure Third-Party Inference… in Claude's menu and switch to it there."]
161
+ : [],
162
+ notes: [
163
+ "this sets up Claude Desktop's third-party mode, \"Claude (third-party)\": a separate profile with its own sessions, on 100xprompt's models (shown as 100xprompt Pro and Flash, or Auto). Your Claude account's sessions stay where they are.",
164
+ "quit Claude and open it again: it starts in third-party mode. To go back to your Claude account, sign in with it again in the app; `brain gateway disconnect --tool claude-desktop` takes the setup away.",
165
+ "the key is read from brain's key file each time the app asks (its credential helper); it is not written into the app's files.",
166
+ ],
167
+ }
168
+
169
+ export const connectors: Connector[] = process.platform === "darwin" ? [claudeDesktop] : []
@@ -31,6 +31,7 @@ import { connectors as c_cursor_cli } from "./cursor-cli.ts"
31
31
  import { connectors as c_copilot_cli } from "./copilot-cli.ts"
32
32
  import { connectors as c_windsurf } from "./windsurf.ts"
33
33
  import { connectors as c_antigravity } from "./antigravity.ts"
34
+ import { connectors as c_claude_desktop } from "./claude-desktop.ts"
34
35
  import { connectors as c_amazon_q_cli } from "./amazon-q-cli.ts"
35
36
  import { connectors as c_amazon_q_ide } from "./amazon-q-ide.ts"
36
37
  import { connectors as c_kiro_cli } from "./kiro-cli.ts"
@@ -73,6 +74,7 @@ export const EXTRA_CONNECTORS: Connector[] = [
73
74
  ...c_copilot_cli,
74
75
  ...c_windsurf,
75
76
  ...c_antigravity,
77
+ ...c_claude_desktop,
76
78
  ...c_amazon_q_cli,
77
79
  ...c_amazon_q_ide,
78
80
  ...c_kiro_cli,
package/src/handoff.ts CHANGED
@@ -72,12 +72,28 @@ function sameProject(h: Handoff, cwd: string, gitRemote?: string): boolean {
72
72
  const claimPath = (home: string, id: string, tool: string) => join(handoffDir(home), `${id}.${tool}.claimed`)
73
73
 
74
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 {
75
+ /**
76
+ * Desktop apps that run a CLI's session-start hook (the same agent inside): a brief for the app is delivered by that hook.
77
+ * Claude Desktop's Code tab runs Claude Code's hooks; the Codex app runs Codex's.
78
+ */
79
+ export const HOOK_TOOL: Record<string, string> = { "claude-desktop": "claude-code", "codex-desktop": "codex" }
80
+
81
+ /**
82
+ * Whether the hook running is the desktop app's (its Claude Code says so in CLAUDE_CODE_ENTRYPOINT: "claude-desktop",
83
+ * "claude-desktop-3p"). The Codex app's hook cannot be told from the CLI's, so a brief for the Codex app goes to either.
84
+ */
85
+ export const fromDesktopApp = (target: string, env: Record<string, string | undefined> = process.env) =>
86
+ target !== "claude-desktop" || /^claude-desktop/.test(env.CLAUDE_CODE_ENTRYPOINT ?? "")
87
+
88
+ export function findHandoff(
89
+ home: string,
90
+ q: { cwd: string; tool: string; gitRemote?: string; now?: number; env?: Record<string, string | undefined> },
91
+ ): Handoff | undefined {
76
92
  const now = q.now ?? Date.now()
77
93
  return listHandoffs(home).find(
78
94
  (h) =>
79
95
  Date.parse(h.expiresAt) > now &&
80
- (h.target === q.tool || h.target === "any") &&
96
+ (h.target === q.tool || h.target === "any" || (HOOK_TOOL[h.target] === q.tool && fromDesktopApp(h.target, q.env))) &&
81
97
  !h.consumedBy.includes(q.tool) &&
82
98
  !existsSync(claimPath(home, h.id, q.tool)) &&
83
99
  sameProject(h, q.cwd, q.gitRemote),
package/src/hooks.ts CHANGED
@@ -3,7 +3,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSy
3
3
  import { dirname, join } from "node:path"
4
4
  import { toolLabel } from "./brief.ts"
5
5
  import { gitInfo, isDir, writeAtomic } from "./fsutil.ts"
6
- import { findHandoff, type Handoff, markConsumed } from "./handoff.ts"
6
+ import { findHandoff, type Handoff, HOOK_TOOL, markConsumed } from "./handoff.ts"
7
7
  import { syncInBackground } from "./memory/sync.ts"
8
8
  import { withBrainLock } from "./lock.ts"
9
9
 
@@ -150,7 +150,7 @@ function tomlHasHooks(toml: string): boolean {
150
150
  }
151
151
 
152
152
  /** "cursor" is what `continue --in` calls Cursor; its handoffs are recorded for "cursor-ide". */
153
- const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : tool)
153
+ const toolId = (tool: string) => (tool === "cursor" ? "cursor-ide" : HOOK_TOOL[tool] ?? tool)
154
154
 
155
155
  const sh = (s: string) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
156
156
 
@@ -31,6 +31,11 @@ export interface McpTool {
31
31
 
32
32
  const plain = (command: string, args: string[]): Json => ({ command, args })
33
33
 
34
+ /** The Claude app's folder, from home (its own MCP settings, claude_desktop_config.json, are there). */
35
+ function claudeDesktopDir(): string {
36
+ return process.platform === "darwin" ? "Library/Application Support/Claude" : process.platform === "win32" ? "AppData/Roaming/Claude" : ".config/Claude"
37
+ }
38
+
34
39
  function vscodeUser(): string {
35
40
  return process.platform === "darwin" ? "Library/Application Support/Code/User" : process.platform === "win32" ? "AppData/Roaming/Code/User" : ".config/Code/User"
36
41
  }
@@ -38,7 +43,11 @@ function vscodeUser(): string {
38
43
  export const MCP_TOOLS: McpTool[] = [
39
44
  { id: "claude-code", label: "Claude Code", file: ".claude.json", probe: [".claude.json", ".claude"], format: "json", key: "mcpServers", entry: plain },
40
45
  { id: "codex", label: "Codex", file: ".codex/config.toml", probe: [".codex"], format: "toml" },
46
+ // Claude Desktop: its chats and its Code tab's local tools.
47
+ { id: "claude-desktop", label: "Claude Desktop", file: `${claudeDesktopDir()}/claude_desktop_config.json`, probe: [`${claudeDesktopDir()}/claude_desktop_config.json`, claudeDesktopDir()], format: "json", key: "mcpServers", entry: plain },
41
48
  { id: "cursor", label: "Cursor", file: ".cursor/mcp.json", probe: [".cursor"], format: "json", key: "mcpServers", entry: plain },
49
+ // Antigravity (Google): its agent's MCP servers.
50
+ { id: "antigravity", label: "Antigravity", file: ".gemini/antigravity/mcp_config.json", probe: [".gemini/antigravity/mcp_config.json", ".gemini/antigravity"], format: "json", key: "mcpServers", entry: plain },
42
51
  { id: "gemini", label: "Gemini CLI", file: ".gemini/settings.json", probe: [".gemini"], format: "json", key: "mcpServers", entry: plain },
43
52
  {
44
53
  id: "opencode",
@@ -5,6 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
6
6
  import { join } from "node:path"
7
7
  import { gitInfo } from "../fsutil.ts"
8
+ import type { SessionRef } from "../adapters/types.ts"
8
9
  import type { Session } from "../session/types.ts"
9
10
  import { distill } from "./distill.ts"
10
11
  import { projectKey } from "./project.ts"
@@ -17,6 +18,8 @@ export interface SessionSource {
17
18
  id: string
18
19
  cwd?: string
19
20
  updatedAt?: string
21
+ /** Ids the same session was listed under before (a desktop app's session that brain once listed as its CLI's). */
22
+ formerIds?: string[]
20
23
  read(): Promise<Session>
21
24
  }
22
25
 
@@ -104,7 +107,20 @@ async function defaultSessions(home: string): Promise<SessionSource[]> {
104
107
  const lists = await Promise.all(READERS.map((r) => r.list(home).then((refs) => refs.map((ref) => ({ r, ref }))).catch(() => [])))
105
108
  return lists
106
109
  .flat()
107
- .map(({ r, ref }) => ({ id: `${ref.tool}:${ref.sourceId}`, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}), read: () => r.read(ref, home) }))
110
+ .map(({ r, ref }) => {
111
+ const former = formerIds(ref)
112
+ return { id: `${ref.tool}:${ref.sourceId}`, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}), ...(former.length ? { formerIds: former } : {}), read: () => r.read(ref, home) }
113
+ })
114
+ }
115
+
116
+ /** The ids a session was synced under before brain told the desktop apps apart from their CLIs. */
117
+ function formerIds(ref: SessionRef): string[] {
118
+ if (ref.tool === "claude-desktop") {
119
+ const ids = [ref.extra?.cliSessionId, ...(Array.isArray(ref.extra?.priorCliSessionIds) ? ref.extra.priorCliSessionIds : [])]
120
+ return ids.filter((x): x is string => typeof x === "string").map((x) => `claude-code:${x}`)
121
+ }
122
+ if (ref.tool === "codex-desktop") return [`codex:${ref.sourceId}`]
123
+ return []
108
124
  }
109
125
 
110
126
  export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessions?: () => Promise<SessionSource[]>; now?: () => number } = {}): Promise<SyncResult> {
@@ -135,9 +151,15 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
135
151
  try {
136
152
  const session = await s.read()
137
153
  const items = distill(session)
138
- if (items.length) {
154
+ const former = (s.formerIds ?? []).filter((id) => ledger[id])
155
+ if (items.length || former.length) {
139
156
  const key = projectKey({ ...session.header.project, ...(session.header.project.cwd ? gitInfo(session.header.project.cwd) : {}) })
140
- await memory.ingest(key, items)
157
+ // What was learned from this session under its old id goes, so it is not kept twice (item ids hash the session id).
158
+ for (const id of former) {
159
+ await memory.forget(key, { source: id })
160
+ delete ledger[id]
161
+ }
162
+ if (items.length) await memory.ingest(key, items)
141
163
  }
142
164
  ledger[s.id] = { u: s.updatedAt ?? "none", n: items.length }
143
165
  result.ingested++