@100xprompt/brain 0.1.2 → 0.1.4
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/README.md +59 -2
- package/package.json +1 -1
- package/src/cli.ts +74 -9
- package/src/dashboard/cli.ts +25 -9
- package/src/dashboard/report.ts +84 -0
- package/src/dashboard/send.ts +20 -4
- package/src/dashboard/state.ts +67 -5
- package/src/fsutil.ts +19 -1
- package/src/gateway/common.ts +56 -4
- package/src/gateway/connect.ts +256 -100
- package/src/gateway/connectors/aider.ts +3 -5
- package/src/gateway/connectors/cline-sdk.ts +2 -5
- package/src/gateway/connectors/continue.ts +5 -6
- package/src/gateway/connectors/crush.ts +2 -2
- package/src/gateway/connectors/factory-droid.ts +6 -1
- package/src/gateway/connectors/goose.ts +4 -4
- package/src/gateway/connectors/hermes.ts +7 -3
- package/src/gateway/connectors/kimi-code.ts +2 -2
- package/src/gateway/connectors/letta-code.ts +4 -7
- package/src/gateway/connectors/openhands.ts +9 -4
- package/src/gateway/connectors/qwen-code.ts +16 -2
- package/src/gateway/connectors/vscode-copilot.ts +4 -6
- package/src/gateway/signin.ts +44 -12
- package/src/hooks.ts +20 -9
- package/src/lock.ts +269 -0
- package/src/memory/audit.ts +6 -4
- package/src/memory/cli.ts +6 -0
- package/src/memory/externalize.ts +17 -4
- package/src/memory/mcp-install.ts +63 -7
- package/src/memory/mcp-server.ts +4 -0
- package/src/memory/prepare.ts +12 -4
- package/src/memory/sync.ts +3 -0
- package/src/settings/account.ts +336 -0
- package/src/settings/apply.ts +306 -0
- package/src/settings/catalog.ts +476 -0
- package/src/settings/cli.ts +439 -0
- package/src/settings/env.ts +101 -0
- package/src/settings/pair.ts +83 -0
- package/src/settings/store.ts +216 -0
- package/src/settings/values.ts +80 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// The one list of brain's settings. The command line (`brain settings`), and later the dashboard, the background agent and
|
|
2
|
+
// the local page, all go by it: a setting that is not here cannot be set from anywhere, and a value of the wrong type is
|
|
3
|
+
// refused before anything changes ("settings, never commands").
|
|
4
|
+
//
|
|
5
|
+
// Settings come in families, so the list stays short with 40 tools: `tools.<tool>.gateway` is one family. Each family says
|
|
6
|
+
// its type, the levels it can be set at, whether the dashboard may change it and what a team admin may do, how to read
|
|
7
|
+
// its actual state on this computer (from the files themselves) and how to apply a value. Ways 1 and 2 (lean, memory) have
|
|
8
|
+
// nothing to apply: brain reads them where it uses them (values.ts).
|
|
9
|
+
import { AUTO_MODEL, AUTO_ROUTE, DEFAULT_MODEL, DEFAULT_ROUTE, DEFAULT_URL, EDIT_HEADER, type Env, type GatewaySettings, modelId, modelName, readKey } from "../gateway/common.ts"
|
|
10
|
+
import { CONNECTORS, connect, connectionState, connectorFor, disconnect, hasKey, installedHere } from "../gateway/connect.ts"
|
|
11
|
+
import { HOOK_TOOLS, hookToolHere, installHooks, uninstallHooks } from "../hooks.ts"
|
|
12
|
+
import { installMcp, MCP_TOOLS, mcpEntry, mcpHasRun, uninstallMcp } from "../memory/mcp-install.ts"
|
|
13
|
+
import { redact } from "../memory/redact.ts"
|
|
14
|
+
import { readSettings as readDashboard, updateSettings as updateDashboard } from "../dashboard/state.ts"
|
|
15
|
+
import { readOrgConfig } from "../org/config.ts"
|
|
16
|
+
import { toolLabel } from "../brief.ts"
|
|
17
|
+
import { DEFAULTS, LIMITS } from "./values.ts"
|
|
18
|
+
import { type Effective, effective, type Level, type SettingsDoc, type Value } from "./store.ts"
|
|
19
|
+
|
|
20
|
+
export type Type = { kind: "bool" } | { kind: "choice"; choices: string[] } | { kind: "number"; min: number; max: number; unit: string }
|
|
21
|
+
|
|
22
|
+
export interface Ctx {
|
|
23
|
+
home: string
|
|
24
|
+
env: Env
|
|
25
|
+
doc: SettingsDoc
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Actual {
|
|
29
|
+
/** What this computer has now; null when it has none, or it cannot be told (`note` says why). */
|
|
30
|
+
value: Value | null
|
|
31
|
+
/** Nothing to have yet: a model for a tool that is not on the gateway (it is used once the tool is connected). */
|
|
32
|
+
idle?: boolean
|
|
33
|
+
/** Why it is not simply that value, or what else is true. */
|
|
34
|
+
note?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApplyResult {
|
|
38
|
+
ok: boolean
|
|
39
|
+
error?: string
|
|
40
|
+
notes?: string[]
|
|
41
|
+
/** The tool is not on this computer. */
|
|
42
|
+
missing?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Family {
|
|
46
|
+
/** `tools.*.gateway`, or a plain id such as `lean.threshold`. */
|
|
47
|
+
key: string
|
|
48
|
+
label: string
|
|
49
|
+
help: string
|
|
50
|
+
type: Type
|
|
51
|
+
/** Where it can be set. A setting only this computer can have is ["computer"]. */
|
|
52
|
+
levels: Level[]
|
|
53
|
+
/** Whether the dashboard may change it. "no": only on this computer (privacy switches, and what a change from afar would break). */
|
|
54
|
+
remote: "yes" | "no"
|
|
55
|
+
/** What a team admin may do with it. */
|
|
56
|
+
team: "none" | "default" | "default-and-lock" | "lock-off"
|
|
57
|
+
/** A value the dashboard may not set without this computer confirming it (why), when there is one. */
|
|
58
|
+
remoteGuard?: (value: Value, ctx: Ctx) => string | undefined
|
|
59
|
+
/** brain's value when nothing is set. Without one, brain leaves the setting as it finds it until someone sets it. */
|
|
60
|
+
default?: Value
|
|
61
|
+
/** For tools.* families: the tools it exists for (brain's ids). */
|
|
62
|
+
tools?: string[]
|
|
63
|
+
present(ctx: Ctx, tool?: string): boolean
|
|
64
|
+
read(ctx: Ctx, tool?: string): Promise<Actual>
|
|
65
|
+
/** Makes it so on this computer. Absent: brain reads the value where it uses it, so there is nothing to do. */
|
|
66
|
+
apply?(ctx: Ctx, value: Value, tool?: string): Promise<ApplyResult>
|
|
67
|
+
/** Why what brain applied cannot work any more (its own entry is broken), so `brain sync` should apply it again. */
|
|
68
|
+
repair?(ctx: Ctx, tool?: string): string | undefined
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---- values against a family ------------------------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** Whether a value is of the setting's type (a hand-edited or newer file may hold anything). */
|
|
74
|
+
export function checkValue(type: Type, v: Value): boolean {
|
|
75
|
+
if (type.kind === "bool") return typeof v === "boolean"
|
|
76
|
+
if (type.kind === "choice") return typeof v === "string" && type.choices.includes(v)
|
|
77
|
+
return typeof v === "number" && Number.isInteger(v) && v >= type.min && v <= type.max
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Which stored values count for a family: of its type, at a level it can be set at, and a lock only where a team admin may
|
|
82
|
+
* lock it (any value for default-and-lock, only off for lock-off). Anything else in the file is passed over.
|
|
83
|
+
*/
|
|
84
|
+
export const accepts = (f: Family) => (from: "lock" | Level, v: Value) =>
|
|
85
|
+
checkValue(f.type, v) && (from === "lock" ? f.team === "default-and-lock" || (f.team === "lock-off" && v === false) : f.levels.includes(from))
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Text from a tool or a command, made safe to keep and show: no secrets (the gateway key, tokens, keys a tool's file
|
|
89
|
+
* held), no home folder path. Error text from connect can quote a file it could not change.
|
|
90
|
+
*/
|
|
91
|
+
export function cleanText(text: string, home: string): string {
|
|
92
|
+
let t = redact(text)
|
|
93
|
+
const key = readKey(home)
|
|
94
|
+
if (key) t = t.replaceAll(key, "<key>")
|
|
95
|
+
if (home) t = t.replaceAll(home, "~")
|
|
96
|
+
return t.length > 400 ? `${t.slice(0, 399)}…` : t
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- tool ids ---------------------------------------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** What the MCP installer calls a tool, by brain's id (the same mapping the dashboard report uses). */
|
|
102
|
+
const MCP_INSTALLER_ID: Record<string, string> = { "cursor-ide": "cursor", "gemini-cli": "gemini", "vscode-copilot": "vscode" }
|
|
103
|
+
const fromInstaller = (id: string) => Object.entries(MCP_INSTALLER_ID).find(([, v]) => v === id)?.[0] ?? id
|
|
104
|
+
const installerId = (tool: string) => MCP_INSTALLER_ID[tool] ?? tool
|
|
105
|
+
/** brain's id for a tool the MCP installer names (cursor: cursor-ide). */
|
|
106
|
+
export const toolOfMcpInstaller = fromInstaller
|
|
107
|
+
/** Short names people type for a tool. */
|
|
108
|
+
const ALIASES: Record<string, string> = { claude: "claude-code", cursor: "cursor-ide", gemini: "gemini-cli", vscode: "vscode-copilot", copilot: "copilot-cli" }
|
|
109
|
+
|
|
110
|
+
/** brain's id for a tool as typed: an id, a short name, or a connector's label. */
|
|
111
|
+
export function canonicalTool(given: string): string {
|
|
112
|
+
const t = given.trim().toLowerCase()
|
|
113
|
+
if (ALIASES[t]) return ALIASES[t]!
|
|
114
|
+
return connectorFor(t)?.id ?? t
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const toolName = (tool: string) => connectorFor(tool)?.label ?? MCP_TOOLS.find((t) => fromInstaller(t.id) === tool)?.label ?? toolLabel(tool)
|
|
118
|
+
|
|
119
|
+
// ---- the gateway families ---------------------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
const BY_PRODUCT: Record<string, string> = { "100xprompt Pro": "pro", "100xprompt Flash": "flash", "100xprompt Auto": "auto" }
|
|
122
|
+
/**
|
|
123
|
+
* pro, flash or auto for a gateway model id, by the product it is (every Flash model is flash), or "other" for a model the
|
|
124
|
+
* person named themselves. Never the id itself: people are not shown the gateway's model ids.
|
|
125
|
+
*/
|
|
126
|
+
export const modelAlias = (id: string | undefined) => (id === undefined ? undefined : (BY_PRODUCT[modelName(id)] ?? "other"))
|
|
127
|
+
|
|
128
|
+
const gatewayHere = (ctx: Ctx, tool?: string) => {
|
|
129
|
+
const c = connectorFor(tool ?? "")
|
|
130
|
+
if (!c) return false
|
|
131
|
+
try {
|
|
132
|
+
return installedHere(c, ctx.home, ctx.env) || connectionState(ctx.home, c.id, ctx.env).state !== "not-connected"
|
|
133
|
+
} catch {
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const olderBrain = (tool: string) => `${toolName(tool)} was connected by an older brain, so its settings cannot be told: run \`brain gateway connect --tool ${tool}\` once`
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The settings a connect of `tool` uses: the tool's own model and context editing (set here, or as it was connected), its
|
|
142
|
+
* route and address, with `change` on top. The auto model goes with the auto route, so a route is passed only when
|
|
143
|
+
* neither side of the change is auto.
|
|
144
|
+
*/
|
|
145
|
+
function connectSettings(ctx: Ctx, tool: string, change: { model?: string; contextEdit?: boolean } = {}): Partial<GatewaySettings> {
|
|
146
|
+
const rec = connectionState(ctx.home, tool, ctx.env).settings
|
|
147
|
+
const m = change.model ?? resolveId(ctx.doc, `tools.${tool}.model`).value
|
|
148
|
+
const ce = change.contextEdit ?? (HEADER_TOOLS.includes(tool) ? resolveId(ctx.doc, `tools.${tool}.contextEdit`).value : undefined)
|
|
149
|
+
const out: Partial<GatewaySettings> = {}
|
|
150
|
+
if (rec?.url) out.url = rec.url
|
|
151
|
+
if (typeof m === "string") out.model = m
|
|
152
|
+
else if (rec?.model) out.model = rec.model
|
|
153
|
+
if (typeof ce === "boolean") out.contextEdit = ce
|
|
154
|
+
else if (rec) out.contextEdit = rec.contextEdit
|
|
155
|
+
const model = out.model ? modelId(out.model) : undefined
|
|
156
|
+
if (rec?.route && rec.route !== AUTO_ROUTE && model !== AUTO_MODEL) out.route = rec.route
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* connect's notes ("an exported ANTHROPIC_API_KEY wins over the helper") and warnings (an account over its limit), for the
|
|
162
|
+
* person. A reconnect for a new model keeps only the warnings: the notes were said when the tool was connected.
|
|
163
|
+
*/
|
|
164
|
+
const notesOf = (lines: string[], home: string, kinds = /^(note|warning): /) => lines.filter((l) => kinds.test(l)).map((l) => cleanText(l.replace(/^(note|warning): /, ""), home))
|
|
165
|
+
|
|
166
|
+
async function reconnect(ctx: Ctx, tool: string, change: { model?: string; contextEdit?: boolean }): Promise<ApplyResult> {
|
|
167
|
+
const cs = connectionState(ctx.home, tool, ctx.env)
|
|
168
|
+
if (cs.state === "not-connected") return { ok: true }
|
|
169
|
+
if (!cs.settings) return { ok: false, error: olderBrain(tool) }
|
|
170
|
+
if (!hasKey(ctx.home)) return { ok: false, error: "not signed in to the gateway (brain gateway login)" }
|
|
171
|
+
const r = await connect(ctx.home, tool, { settings: connectSettings(ctx, tool, change), probe: false, env: ctx.env })
|
|
172
|
+
return r.ok ? { ok: true, notes: notesOf(r.lines, ctx.home, /^warning: /) } : { ok: false, error: cleanText(r.lines.join("; "), ctx.home) }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const CONNECTOR_IDS = CONNECTORS.map((c) => c.id)
|
|
176
|
+
/** The tools whose connection changes with context editing (the others cannot send the header that turns it on). */
|
|
177
|
+
export const HEADER_TOOLS = CONNECTORS.filter((c) => {
|
|
178
|
+
try {
|
|
179
|
+
const base = { url: DEFAULT_URL, route: DEFAULT_ROUTE, model: DEFAULT_MODEL }
|
|
180
|
+
const h = "/nonexistent-brain-home"
|
|
181
|
+
return JSON.stringify(c.edits(h, { ...base, contextEdit: true }, {})) !== JSON.stringify(c.edits(h, { ...base, contextEdit: false }, {}))
|
|
182
|
+
} catch {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}).map((c) => c.id)
|
|
186
|
+
|
|
187
|
+
const gateway: Family = {
|
|
188
|
+
key: "tools.*.gateway",
|
|
189
|
+
label: "On the 100xprompt gateway",
|
|
190
|
+
help: "The tool sends its requests through 100xprompt (brain gateway connect / disconnect).",
|
|
191
|
+
type: { kind: "bool" },
|
|
192
|
+
levels: ["computer", "person"],
|
|
193
|
+
remote: "yes",
|
|
194
|
+
// A member's traffic through the gateway is visible to the team admin, and the computer may be the person's own:
|
|
195
|
+
// only the person turns a tool on.
|
|
196
|
+
team: "none",
|
|
197
|
+
tools: CONNECTOR_IDS,
|
|
198
|
+
present: gatewayHere,
|
|
199
|
+
async read(ctx, tool) {
|
|
200
|
+
const cs = connectionState(ctx.home, tool!, ctx.env)
|
|
201
|
+
if (cs.state === "connected") return { value: true }
|
|
202
|
+
if (cs.state === "partial") return { value: "partly", note: cs.why! }
|
|
203
|
+
return { value: false, ...(cs.why ? { note: cs.why } : {}) }
|
|
204
|
+
},
|
|
205
|
+
async apply(ctx, value, tool) {
|
|
206
|
+
const c = connectorFor(tool!)!
|
|
207
|
+
if (value === false) {
|
|
208
|
+
const r = disconnect(ctx.home, c.id, { env: ctx.env })
|
|
209
|
+
const kept = r.lines.filter((l) => /left as it is/.test(l)).map((l) => cleanText(l.trim(), ctx.home))
|
|
210
|
+
return r.ok ? { ok: true, ...(kept.length ? { notes: kept } : {}) } : { ok: false, error: cleanText(r.lines.join("; "), ctx.home) }
|
|
211
|
+
}
|
|
212
|
+
if (!installedHere(c, ctx.home, ctx.env)) return { ok: false, missing: true, error: `${c.label} has not run on this computer` }
|
|
213
|
+
if (!hasKey(ctx.home)) return { ok: false, error: "not signed in to the gateway (brain gateway login)" }
|
|
214
|
+
// No network check: it works offline with the stored key (`brain gateway connect` checks the key first).
|
|
215
|
+
const r = await connect(ctx.home, c.id, { settings: connectSettings(ctx, c.id), probe: false, env: ctx.env })
|
|
216
|
+
return r.ok ? { ok: true, notes: notesOf(r.lines, ctx.home) } : { ok: false, error: cleanText(r.lines.join("; "), ctx.home) }
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const model: Family = {
|
|
221
|
+
key: "tools.*.model",
|
|
222
|
+
label: "Model",
|
|
223
|
+
help: "Which 100xprompt model the tool uses on the gateway: pro, flash or auto (auto-routing).",
|
|
224
|
+
type: { kind: "choice", choices: ["pro", "flash", "auto"] },
|
|
225
|
+
levels: ["computer", "person", "team"],
|
|
226
|
+
remote: "yes",
|
|
227
|
+
team: "default",
|
|
228
|
+
tools: CONNECTOR_IDS,
|
|
229
|
+
present: gatewayHere,
|
|
230
|
+
async read(ctx, tool) {
|
|
231
|
+
const cs = connectionState(ctx.home, tool!, ctx.env)
|
|
232
|
+
if (cs.state === "not-connected") return { value: null, idle: true }
|
|
233
|
+
if (!cs.settings) return { value: null, note: olderBrain(tool!) }
|
|
234
|
+
const id = cs.settings.model
|
|
235
|
+
if (cs.changed?.some((v) => v.includes(id))) return { value: "partly", note: `the model in ${toolName(tool!)}'s files was changed since brain connected it` }
|
|
236
|
+
return { value: modelAlias(id)! }
|
|
237
|
+
},
|
|
238
|
+
apply: (ctx, value, tool) => reconnect(ctx, tool!, { model: String(value) }),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const contextEdit: Family = {
|
|
242
|
+
key: "tools.*.contextEdit",
|
|
243
|
+
label: "Context editing",
|
|
244
|
+
help: "The gateway clears old tool results from long requests when that saves money (way 6).",
|
|
245
|
+
type: { kind: "bool" },
|
|
246
|
+
levels: ["computer", "person", "team"],
|
|
247
|
+
remote: "yes",
|
|
248
|
+
team: "default",
|
|
249
|
+
tools: HEADER_TOOLS,
|
|
250
|
+
present: gatewayHere,
|
|
251
|
+
async read(ctx, tool) {
|
|
252
|
+
const cs = connectionState(ctx.home, tool!, ctx.env)
|
|
253
|
+
if (cs.state === "not-connected") return { value: null, idle: true }
|
|
254
|
+
if (!cs.settings) return { value: null, note: olderBrain(tool!) }
|
|
255
|
+
if (cs.changed?.some((v) => v.includes(EDIT_HEADER))) return { value: "partly", note: `the context-editing header in ${toolName(tool!)}'s files was changed since brain connected it` }
|
|
256
|
+
return { value: cs.settings.contextEdit }
|
|
257
|
+
},
|
|
258
|
+
apply: (ctx, value, tool) => reconnect(ctx, tool!, { contextEdit: value === true }),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- MCP tools and hooks ----------------------------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
const mcp: Family = {
|
|
264
|
+
key: "tools.*.mcp",
|
|
265
|
+
label: "brain tools",
|
|
266
|
+
help: "The tool can use brain_recall, brain_expand and brain_map (brain mcp install / uninstall).",
|
|
267
|
+
type: { kind: "bool" },
|
|
268
|
+
levels: ["computer", "person"],
|
|
269
|
+
remote: "yes",
|
|
270
|
+
team: "none",
|
|
271
|
+
tools: MCP_TOOLS.map((t) => fromInstaller(t.id)),
|
|
272
|
+
present: (ctx, tool) => mcpHasRun(ctx.home, installerId(tool!)) || mcpEntry(ctx.home, installerId(tool!)).installed,
|
|
273
|
+
async read(ctx, tool) {
|
|
274
|
+
const e = mcpEntry(ctx.home, installerId(tool!))
|
|
275
|
+
const note = e.broken ? `cannot start: ${e.broken}` : e.off ? `turned off in ${toolName(tool!)}'s settings` : undefined
|
|
276
|
+
return { value: e.installed && !e.broken && !e.off, ...(note ? { note } : {}) }
|
|
277
|
+
},
|
|
278
|
+
repair(ctx, tool) {
|
|
279
|
+
const e = mcpEntry(ctx.home, installerId(tool!))
|
|
280
|
+
return e.installed && e.broken ? e.broken : undefined
|
|
281
|
+
},
|
|
282
|
+
async apply(ctx, value, tool) {
|
|
283
|
+
const id = installerId(tool!)
|
|
284
|
+
if (value === true) {
|
|
285
|
+
// Any brain's working entry counts: it is left as it is, so two brains never keep rewriting each other's.
|
|
286
|
+
const e = mcpEntry(ctx.home, id)
|
|
287
|
+
if (e.installed && !e.broken && !e.off) return { ok: true }
|
|
288
|
+
}
|
|
289
|
+
const [c] = value === true ? installMcp([id], ctx.home) : uninstallMcp([id], ctx.home)
|
|
290
|
+
if (!c || c.action === "skipped") return { ok: false, ...(c?.why && /has not run/.test(c.why) ? { missing: true } : {}), error: cleanText(c?.why ?? "nothing to change", ctx.home) }
|
|
291
|
+
return { ok: true, ...(value === true && c.action === "installed" ? { notes: [`restart ${toolName(tool!)} for it to pick up the brain tools`] } : {}) }
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const hooks: Family = {
|
|
296
|
+
key: "tools.*.hooks",
|
|
297
|
+
label: "Session-start hook",
|
|
298
|
+
help: "The tool receives a handoff by itself when a session starts (brain session hooks install / uninstall).",
|
|
299
|
+
type: { kind: "bool" },
|
|
300
|
+
// Codex trusts hooks by their place in the file: changing them from afar would make it ask again about the person's own.
|
|
301
|
+
levels: ["computer"],
|
|
302
|
+
remote: "no",
|
|
303
|
+
team: "none",
|
|
304
|
+
tools: HOOK_TOOLS,
|
|
305
|
+
present: (ctx, tool) => hookToolHere(tool!, ctx.home),
|
|
306
|
+
async read(ctx, tool) {
|
|
307
|
+
const [c] = await installHooks([tool!], ctx.home, { dryRun: true })
|
|
308
|
+
if (!c || c.action === "skipped") return { value: null, ...(c?.why ? { note: cleanText(c.why, ctx.home) } : {}) }
|
|
309
|
+
return { value: c.action === "already-installed" }
|
|
310
|
+
},
|
|
311
|
+
async apply(ctx, value, tool) {
|
|
312
|
+
const [c] = value === true ? await installHooks([tool!], ctx.home) : await uninstallHooks([tool!], ctx.home)
|
|
313
|
+
if (!c || c.action === "skipped") return { ok: false, ...(c?.why && /has not run/.test(c.why) ? { missing: true } : {}), error: cleanText(c?.why ?? "nothing to change", ctx.home) }
|
|
314
|
+
return { ok: true, ...(c.why ? { notes: [cleanText(c.why, ctx.home)] } : {}) }
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- ways 1 and 2, and reports ------------------------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
/** A setting brain reads where it uses it: its actual value is the one that applies. */
|
|
321
|
+
const internal = (key: string, f: Omit<Family, "key" | "present" | "read" | "default">): Family => ({
|
|
322
|
+
key,
|
|
323
|
+
...f,
|
|
324
|
+
default: DEFAULTS[key],
|
|
325
|
+
present: () => true,
|
|
326
|
+
async read(ctx) {
|
|
327
|
+
return { value: resolveId(ctx.doc, key).value ?? null }
|
|
328
|
+
},
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
const ALL_LEVELS: Level[] = ["computer", "person", "team"]
|
|
332
|
+
|
|
333
|
+
const leanAuto = internal("lean.auto", {
|
|
334
|
+
label: "Lean long sessions",
|
|
335
|
+
help: "A session moved to another tool is made lean by itself when it is long (way 1): big tool output becomes digests, the full text stays on this computer.",
|
|
336
|
+
type: { kind: "bool" },
|
|
337
|
+
levels: ALL_LEVELS,
|
|
338
|
+
remote: "yes",
|
|
339
|
+
team: "default-and-lock",
|
|
340
|
+
})
|
|
341
|
+
const leanThreshold = internal("lean.threshold", {
|
|
342
|
+
label: "Lean from",
|
|
343
|
+
help: "The size, in tokens, from which a session is made lean by itself.",
|
|
344
|
+
type: { kind: "number", ...LIMITS["lean.threshold"], unit: "tokens" },
|
|
345
|
+
levels: ALL_LEVELS,
|
|
346
|
+
remote: "yes",
|
|
347
|
+
team: "default-and-lock",
|
|
348
|
+
})
|
|
349
|
+
const leanMinChars = internal("lean.minChars", {
|
|
350
|
+
label: "Digest outputs over",
|
|
351
|
+
help: "In a lean session, tool output longer than this many characters is replaced by a digest (the first level of --budget too).",
|
|
352
|
+
type: { kind: "number", ...LIMITS["lean.minChars"], unit: "characters" },
|
|
353
|
+
levels: ALL_LEVELS,
|
|
354
|
+
remote: "yes",
|
|
355
|
+
team: "default-and-lock",
|
|
356
|
+
})
|
|
357
|
+
const memoryEnabled: Family = {
|
|
358
|
+
...internal("memory.enabled", {
|
|
359
|
+
label: "Project memory",
|
|
360
|
+
help: "What sessions establish is kept per project and recalled in later briefs and by brain_recall (way 2).",
|
|
361
|
+
type: { kind: "bool" },
|
|
362
|
+
levels: ALL_LEVELS,
|
|
363
|
+
remote: "yes",
|
|
364
|
+
team: "lock-off",
|
|
365
|
+
}),
|
|
366
|
+
// With team memory (brain org login), memory sends items to the organisation's server: only this computer turns that on.
|
|
367
|
+
remoteGuard: (value, ctx) => (value === true && readOrgConfig(ctx.home) ? "this computer sends memory to your team's server (brain org): turn it on on the computer itself" : undefined),
|
|
368
|
+
async read(ctx) {
|
|
369
|
+
const v = resolveId(ctx.doc, "memory.enabled").value ?? true
|
|
370
|
+
const org = ctx.env.BRAIN_ORG !== "off" && readOrgConfig(ctx.home)
|
|
371
|
+
const notes = [org ? `kept on your team's server (${org.server})` : "", ctx.env.BRAIN_NO_MEMORY === "1" ? "BRAIN_NO_MEMORY is set here: no background memory sync" : ""].filter(Boolean)
|
|
372
|
+
return { value: v, ...(v === true && notes.length ? { note: notes.join("; ") } : {}) }
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const reports: Family = {
|
|
377
|
+
key: "reports.enabled",
|
|
378
|
+
label: "Reports to your account",
|
|
379
|
+
help: "This computer sends its report (tools, gateway, conversions; never chat text) for the dashboard's Brain view (brain dashboard on / off).",
|
|
380
|
+
type: { kind: "bool" },
|
|
381
|
+
// A privacy switch the server could turn back on would not be one.
|
|
382
|
+
levels: ["computer"],
|
|
383
|
+
remote: "no",
|
|
384
|
+
team: "none",
|
|
385
|
+
present: () => true,
|
|
386
|
+
async read(ctx) {
|
|
387
|
+
const v = ctx.env.BRAIN_NO_REPORT
|
|
388
|
+
const envOff = v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false"
|
|
389
|
+
return { value: !readDashboard(ctx.home).off, ...(envOff ? { note: "BRAIN_NO_REPORT is set here, so nothing is sent while it is" } : {}) }
|
|
390
|
+
},
|
|
391
|
+
async apply(ctx, value) {
|
|
392
|
+
updateDashboard(ctx.home, (s) => {
|
|
393
|
+
if (value === true) delete s.off
|
|
394
|
+
else s.off = true
|
|
395
|
+
})
|
|
396
|
+
return { ok: true }
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export const FAMILIES: Family[] = [gateway, model, contextEdit, mcp, hooks, leanAuto, leanThreshold, leanMinChars, memoryEnabled, reports]
|
|
401
|
+
|
|
402
|
+
// ---- ids and values -----------------------------------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
export interface SettingRef {
|
|
405
|
+
id: string
|
|
406
|
+
family: Family
|
|
407
|
+
tool?: string
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A setting id as typed (`tools.cursor.mcp`, `lean.threshold`), or why there is no such setting. */
|
|
411
|
+
export function parseId(given: string): SettingRef | { error: string } {
|
|
412
|
+
const raw = given.trim()
|
|
413
|
+
const m = /^tools\.(.+)\.([A-Za-z]+)$/.exec(raw)
|
|
414
|
+
if (m) {
|
|
415
|
+
const family = FAMILIES.find((f) => f.key === `tools.*.${m[2]}`)
|
|
416
|
+
if (!family) return { error: `no setting "${raw}": a tool's settings are ${FAMILIES.filter((f) => f.tools).map((f) => f.key.slice(8)).join(", ")}` }
|
|
417
|
+
const tool = canonicalTool(m[1]!)
|
|
418
|
+
if (!family.tools!.includes(tool)) return { error: `${family.key.replace("*", "<tool>")} exists for ${family.tools!.join(", ")}; not for "${m[1]}"` }
|
|
419
|
+
return { id: `tools.${tool}.${m[2]}`, family, tool }
|
|
420
|
+
}
|
|
421
|
+
const family = FAMILIES.find((f) => !f.tools && f.key.toLowerCase() === raw.toLowerCase())
|
|
422
|
+
if (!family) return { error: `no setting "${raw}"; \`brain settings ids\` lists them` }
|
|
423
|
+
return { id: family.key, family }
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** What applies for a setting, by the family's rules (accepts): the value brain uses and applies. */
|
|
427
|
+
export function resolve(doc: SettingsDoc, ref: SettingRef): Effective {
|
|
428
|
+
return effective(doc, ref.id, ref.family.default, accepts(ref.family))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** resolve() by id; nothing applies for an id that is not a setting. */
|
|
432
|
+
export function resolveId(doc: SettingsDoc, id: string): Effective {
|
|
433
|
+
const ref = parseId(id)
|
|
434
|
+
return "error" in ref ? { from: "none" } : resolve(doc, ref)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** A value as typed, checked against the type (on/off, a choice, a whole number in range: 50000, 50,000 or 50k). */
|
|
438
|
+
export function parseValue(type: Type, given: string): { value: Value } | { error: string } {
|
|
439
|
+
const v = given.trim().toLowerCase()
|
|
440
|
+
if (type.kind === "bool") {
|
|
441
|
+
if (["on", "true", "yes", "1", "enabled"].includes(v)) return { value: true }
|
|
442
|
+
if (["off", "false", "no", "0", "disabled"].includes(v)) return { value: false }
|
|
443
|
+
return { error: `"${given}" is not on or off` }
|
|
444
|
+
}
|
|
445
|
+
if (type.kind === "choice") return type.choices.includes(v) ? { value: v } : { error: `"${given}" is not one of ${type.choices.join(", ")}` }
|
|
446
|
+
// Commas and underscores only as thousands separators (1,500 or 1_500; not 1,5k).
|
|
447
|
+
const m = /^(\d{1,3}(?:,\d{3})+|\d{1,3}(?:_\d{3})+|\d+)(k)?$/.exec(v.replace(/\s+/g, ""))
|
|
448
|
+
const n = m ? Number(m[1]!.replace(/[,_]/g, "")) * (m[2] ? 1_000 : 1) : NaN
|
|
449
|
+
if (!Number.isInteger(n)) return { error: `"${given}" is not a whole number` }
|
|
450
|
+
if (n < type.min || n > type.max) return { error: `${n.toLocaleString("en-US")} is outside ${type.min.toLocaleString("en-US")} to ${type.max.toLocaleString("en-US")} ${type.unit}` }
|
|
451
|
+
return { value: n }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** A value for people: on/off, a number with its unit, a model by its short name. */
|
|
455
|
+
export function showValue(type: Type, v: Value | null | undefined): string {
|
|
456
|
+
if (v === null || v === undefined) return "-"
|
|
457
|
+
if (v === "partly") return "partly"
|
|
458
|
+
if (type.kind === "bool") return v === true ? "on" : v === false ? "off" : String(v)
|
|
459
|
+
if (type.kind === "number" && typeof v === "number") return `${v.toLocaleString("en-US")} ${type.unit}`
|
|
460
|
+
if (type.kind === "choice" && typeof v === "string" && !type.choices.includes(v)) return "another model"
|
|
461
|
+
return String(v)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Every setting there can be on this computer: the families without tools, and each tool family for each tool here. */
|
|
465
|
+
export function settingsHere(ctx: Ctx): SettingRef[] {
|
|
466
|
+
const out: SettingRef[] = []
|
|
467
|
+
for (const f of FAMILIES) {
|
|
468
|
+
if (!f.tools) out.push({ id: f.key, family: f })
|
|
469
|
+
else for (const t of f.tools) if (f.present(ctx, t)) out.push({ id: f.key.replace("*", t), family: f, tool: t })
|
|
470
|
+
}
|
|
471
|
+
return out
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The catalog as data (for the dashboard sync and the check that the console knows every family). */
|
|
475
|
+
export const describeCatalog = () =>
|
|
476
|
+
FAMILIES.map((f) => ({ key: f.key, label: f.label, type: f.type, levels: f.levels, remote: f.remote, team: f.team, ...(f.default !== undefined ? { default: f.default } : {}), ...(f.tools ? { tools: f.tools } : {}) }))
|