@100xprompt/brain 0.1.3 → 0.1.5

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,306 @@
1
+ // The only code that changes a setting. `brain settings`, `brain sync` and (later) the background agent and the local page
2
+ // all call it, so a change looks the same whichever way it was made: it is checked against the catalog, recorded with who
3
+ // made it and what it replaced, applied under the brain lock, read back from the files, and the result kept, so every way
4
+ // in can say whether it worked. brain's other commands (gateway connect, mcp install, ...) make their changes themselves,
5
+ // as before, and record them here (recordChanges, recordConnected) in the same hold of the lock, so they show up the same way.
6
+ import type { Env, GatewaySettings } from "../gateway/common.ts"
7
+ import { connectionState } from "../gateway/connect.ts"
8
+ import { withBrainLock } from "../lock.ts"
9
+ import { type Actual, cleanText, type Ctx, type Family, FAMILIES, HEADER_TOOLS, modelAlias, parseId, parseValue, resolve, settingsHere, type SettingRef } from "./catalog.ts"
10
+ import { type Applied, type Change, type Effective, type Entry, type Level, LEVELS, readSettingsFile, recordChange, same, type SettingsDoc, SettingsUnwritable, settingsFile, type Source, updateDoc, type Value } from "./store.ts"
11
+
12
+ export type State = "applied" | "default" | "as found" | "waiting" | "failed" | "not on this computer" | "changed outside brain" | "can't read" | "used when connected" | "locked"
13
+
14
+ export interface Row {
15
+ id: string
16
+ family: Family
17
+ tool?: string
18
+ /** The value that applies here, and where it comes from. */
19
+ value?: Value
20
+ from: Effective["from"]
21
+ entry?: Entry
22
+ actual: Actual
23
+ state: State
24
+ error?: string
25
+ notes: string[]
26
+ }
27
+
28
+ export interface Outcome {
29
+ ok: boolean
30
+ /** Why nothing was saved (an unknown setting, a value of the wrong type, a level it cannot be set at). */
31
+ refused?: string
32
+ row?: Row
33
+ change?: Change
34
+ /** What the person should know beyond the row (the value comes from another level, say). */
35
+ notes: string[]
36
+ }
37
+
38
+ const now = () => new Date().toISOString()
39
+ const readDoc = (home: string) => readSettingsFile(home).doc
40
+ const ctxOf = (home: string, env: Env, doc: SettingsDoc = readDoc(home)): Ctx => ({ home, env, doc })
41
+
42
+ /** A settings file written by a newer brain is read, never acted on: applying what it says could undo what that brain did. */
43
+ function refuseNewer(home: string) {
44
+ if (readSettingsFile(home).problem === "newer") throw new SettingsUnwritable(`${settingsFile(home)} was written by a newer brain; update brain (bun add -g @100xprompt/brain) to change or apply settings here`)
45
+ }
46
+
47
+ /** One setting as it stands: what applies, from where, what this computer actually has, and whether that is applied. */
48
+ export async function rowOf(ctx: Ctx, ref: SettingRef): Promise<Row> {
49
+ const { id, family, tool } = ref
50
+ const eff = resolve(ctx.doc, ref)
51
+ let actual: Actual
52
+ try {
53
+ actual = await family.read(ctx, tool)
54
+ } catch (e) {
55
+ actual = { value: null, note: `could not read it (${cleanText((e as Error).message, ctx.home).slice(0, 160)})` }
56
+ }
57
+ const ap: Applied | undefined = ctx.doc.applied[id]
58
+ // What a tool's files say can hold this computer's paths: cleaned before it is shown, kept or sent.
59
+ const notes = [...(actual.note ? [cleanText(actual.note, ctx.home)] : []), ...(ap?.ok && ap.notes ? ap.notes : [])]
60
+ const held = ctx.doc.sync.held.find((h) => h.id === id)
61
+ const base = { id, family, ...(tool ? { tool } : {}), ...(eff.value !== undefined ? { value: eff.value } : {}), from: eff.from, ...(eff.entry ? { entry: eff.entry } : {}), actual }
62
+ let state: State
63
+ let error: string | undefined
64
+ if (family.apply && tool && eff.from !== "none" && !family.present(ctx, tool)) state = "not on this computer"
65
+ else if (held) {
66
+ // A value from the account this computer did not take (memory turned on from afar on a team-memory computer).
67
+ state = "failed"
68
+ error = held.why
69
+ } else if (!family.apply) state = eff.from === "lock" ? "locked" : eff.from === "default" ? "default" : "applied"
70
+ else if (eff.from === "none") state = actual.value === null && !actual.idle ? "can't read" : "as found"
71
+ else if (ap && !ap.ok && same(ap.want, eff.value)) {
72
+ state = ap.missing ? "not on this computer" : "failed"
73
+ error = ap.error
74
+ } else if (!ap || !same(ap.want, eff.value)) state = "waiting"
75
+ else if (actual.value === null) state = actual.idle ? "used when connected" : "can't read"
76
+ else if (!same(actual.value, eff.value)) state = "changed outside brain"
77
+ else state = eff.from === "lock" ? "locked" : "applied"
78
+ return { ...base, state, ...(error ? { error } : {}), notes: [...new Set(notes)] }
79
+ }
80
+
81
+ /** The settings that have a value at some level (or a lock), as catalog references; ids this brain does not know are left out. */
82
+ function refsSet(doc: SettingsDoc): SettingRef[] {
83
+ const ids = new Set([...Object.keys(doc.locks), ...LEVELS.flatMap((l) => Object.keys(doc.levels[l]))])
84
+ return [...ids].map((id) => parseId(id)).filter((r): r is SettingRef => !("error" in r))
85
+ }
86
+
87
+ /** Every setting on this computer, plus any set at some level for a tool that is not here (it shows as not on this computer). */
88
+ export async function listSettings(home: string, env: Env = process.env): Promise<Row[]> {
89
+ const ctx = ctxOf(home, env)
90
+ const refs = settingsHere(ctx)
91
+ const seen = new Set(refs.map((r) => r.id))
92
+ for (const r of refsSet(ctx.doc)) if (!seen.has(r.id)) refs.push(r)
93
+ return Promise.all(refs.map((r) => rowOf(ctx, r)))
94
+ }
95
+
96
+ const upload = (f: Family) => f.remote === "yes"
97
+
98
+ /** Marks a tool's model and context editing applied when what they ask for is what the tool was just connected with. */
99
+ function markConnected(d: SettingsDoc, tool: string, s: GatewaySettings, at: string) {
100
+ const asked: Array<[string, Value]> = [
101
+ [`tools.${tool}.model`, modelAlias(s.model)!],
102
+ ...(HEADER_TOOLS.includes(tool) ? [[`tools.${tool}.contextEdit`, s.contextEdit] as [string, Value]] : []),
103
+ ]
104
+ for (const [id, has] of asked) {
105
+ const ref = parseId(id)
106
+ if ("error" in ref) continue
107
+ const v = resolve(d, ref).value
108
+ if (v !== undefined && same(v, has)) d.applied[id] = { want: v, ok: true, at }
109
+ }
110
+ }
111
+
112
+ /** Applies what now applies for `ref` (inside the lock), and keeps the result. Settings brain reads where it uses them have nothing to apply. */
113
+ async function applyNow(home: string, env: Env, ref: SettingRef): Promise<void> {
114
+ const doc = readDoc(home)
115
+ const eff = resolve(doc, ref)
116
+ if (!ref.family.apply) return
117
+ if (eff.value === undefined) {
118
+ // Nobody sets it any more: brain stops managing it and leaves it as it is.
119
+ updateDoc(home, (d) => {
120
+ delete d.applied[ref.id]
121
+ })
122
+ return
123
+ }
124
+ let r
125
+ try {
126
+ r = await ref.family.apply(ctxOf(home, env, doc), eff.value, ref.tool)
127
+ } catch (e) {
128
+ r = { ok: false, error: cleanText((e as Error).message, home) }
129
+ }
130
+ const at = now()
131
+ const record: Applied = { want: eff.value, ok: r.ok, at, ...(r.error ? { error: r.error } : {}), ...(r.notes?.length ? { notes: r.notes } : {}), ...(r.missing ? { missing: true } : {}) }
132
+ const connected = r.ok && ref.tool && ref.family.key !== "tools.*.mcp" && ref.family.key !== "tools.*.hooks" ? connectionState(home, ref.tool, env) : undefined
133
+ updateDoc(home, (d) => {
134
+ d.applied[ref.id] = record
135
+ // Connecting a tool (or changing its model) also applies its model and context editing when those are what it got.
136
+ if (connected?.state === "connected" && connected.settings) markConnected(d, ref.tool!, connected.settings, at)
137
+ })
138
+ }
139
+
140
+ const LEVEL_WORD: Record<"computer" | "person", string> = { computer: "this computer", person: "all your computers" }
141
+
142
+ /**
143
+ * `brain settings set <id> <value> [--for computer|me]`: saves the value at the level, then applies what applies here. A
144
+ * value that cannot be applied stays saved (it is what the person wants), marked failed with the reason; `brain sync`
145
+ * tries it again. A value for all the person's computers, for a tool that is not on this one, is saved and not applied here.
146
+ */
147
+ export async function setSetting(home: string, id: string, given: string, o: { level?: "computer" | "person"; source?: Source; env?: Env } = {}): Promise<Outcome> {
148
+ const env = o.env ?? process.env
149
+ const level = o.level ?? "computer"
150
+ const ref = parseId(id)
151
+ if ("error" in ref) return { ok: false, refused: ref.error, notes: [] }
152
+ const parsed = parseValue(ref.family.type, given)
153
+ if ("error" in parsed) return { ok: false, refused: `${ref.id}: ${parsed.error}`, notes: [] }
154
+ if (!ref.family.levels.includes(level)) return { ok: false, refused: `${ref.id} can only be set on this computer`, notes: [] }
155
+ const here = !ref.tool || ref.family.present(ctxOf(home, env), ref.tool)
156
+ if (level === "computer" && !here) return { ok: false, refused: `${ref.id}: this tool has not run on this computer; nothing was saved`, notes: [] }
157
+ return withBrainLock(
158
+ home,
159
+ async () => {
160
+ let change: Change | undefined
161
+ updateDoc(home, (d) => {
162
+ change = recordChange(d, { id: ref.id, level, value: parsed.value, source: o.source ?? "settings", at: now() }, { upload: upload(ref.family) })
163
+ })
164
+ const notes: string[] = []
165
+ if (here) await applyNow(home, env, ref)
166
+ else notes.push(`${ref.tool} is not on this computer: nothing to apply here`)
167
+ const doc = readDoc(home)
168
+ const eff = resolve(doc, ref)
169
+ if (eff.from === "lock") notes.push(`your team admin has locked ${ref.id}: that value applies here`)
170
+ else if (eff.from !== level) notes.push(`this computer keeps its own value for ${ref.id}; \`brain settings unset ${ref.id}\` uses the one for all your computers`)
171
+ if (level === "person") notes.push("saved for all your computers: it reaches the others once settings sync with your 100xprompt account")
172
+ const row = await rowOf(ctxOf(home, env, doc), ref)
173
+ const ok = !here || (row.state !== "failed" && row.state !== "not on this computer")
174
+ return { ok, row, ...(change ? { change } : {}), notes }
175
+ },
176
+ { what: `changing ${ref.id}` },
177
+ )
178
+ }
179
+
180
+ /** `brain settings unset <id>`: clears the value at the level; what is set at the level above applies, or brain stops managing it. */
181
+ export async function unsetSetting(home: string, id: string, o: { level?: "computer" | "person"; env?: Env } = {}): Promise<Outcome> {
182
+ const env = o.env ?? process.env
183
+ const level = o.level ?? "computer"
184
+ const ref = parseId(id)
185
+ if ("error" in ref) return { ok: false, refused: ref.error, notes: [] }
186
+ return withBrainLock(
187
+ home,
188
+ async () => {
189
+ let change: Change | undefined
190
+ updateDoc(home, (d) => {
191
+ change = recordChange(d, { id: ref.id, level, value: null, source: "settings", at: now() }, { upload: upload(ref.family) })
192
+ })
193
+ if (!change) return { ok: true, notes: [`nothing was set for ${ref.id} on ${LEVEL_WORD[level]}`] }
194
+ const here = !ref.tool || ref.family.present(ctxOf(home, env), ref.tool)
195
+ if (here) await applyNow(home, env, ref)
196
+ const doc = readDoc(home)
197
+ const eff = resolve(doc, ref)
198
+ const notes = eff.from === "none" ? ["brain no longer manages it: it stays as it is now"] : []
199
+ const row = await rowOf(ctxOf(home, env, doc), ref)
200
+ return { ok: row.state !== "failed", row, change, notes }
201
+ },
202
+ { what: `clearing ${ref.id}` },
203
+ )
204
+ }
205
+
206
+ /**
207
+ * Applies every setting whose value changed since brain last applied it (and, with `retryFailed`, those that failed and
208
+ * brain entries that broke). A setting brain applied that someone changed by hand since is NOT put back: that would fight
209
+ * the person; it shows as "changed outside brain" until it is set again. A tool that is not on this computer is skipped.
210
+ * Tools first, so a connect carries the tool's model and context editing. Never acts on a newer brain's settings file.
211
+ */
212
+ export async function reconcile(home: string, o: { env?: Env; retryFailed?: boolean } = {}): Promise<{ applied: Row[]; unknown: string[]; elsewhere: string[] }> {
213
+ const env = o.env ?? process.env
214
+ refuseNewer(home)
215
+ return withBrainLock(
216
+ home,
217
+ async () => {
218
+ refuseNewer(home)
219
+ const doc = readDoc(home)
220
+ const ids = new Set([...Object.keys(doc.locks), ...LEVELS.flatMap((l) => Object.keys(doc.levels[l]))])
221
+ const unknown: string[] = []
222
+ const refs: SettingRef[] = []
223
+ for (const id of ids) {
224
+ const r = parseId(id)
225
+ if ("error" in r) unknown.push(id)
226
+ else if (r.family.apply) refs.push(r)
227
+ }
228
+ const order = (r: SettingRef) => FAMILIES.indexOf(r.family)
229
+ refs.sort((a, b) => order(a) - order(b))
230
+ const done: SettingRef[] = []
231
+ const elsewhere: string[] = []
232
+ for (const r of refs) {
233
+ const cur = readDoc(home)
234
+ const ctx = ctxOf(home, env, cur)
235
+ const eff = resolve(cur, r)
236
+ const ap = cur.applied[r.id]
237
+ if (eff.value === undefined) continue
238
+ if (r.tool && !r.family.present(ctx, r.tool)) {
239
+ elsewhere.push(r.id)
240
+ continue
241
+ }
242
+ const changed = !ap || !same(ap.want, eff.value)
243
+ const retry = !!o.retryFailed && ((ap && !ap.ok) || (eff.value === true && !!r.family.repair?.(ctx, r.tool)))
244
+ if (!changed && !retry) continue
245
+ await applyNow(home, env, r)
246
+ done.push(r)
247
+ }
248
+ const ctx = ctxOf(home, env)
249
+ return { applied: await Promise.all(done.map((r) => rowOf(ctx, r))), unknown, elsewhere }
250
+ },
251
+ { what: "applying settings" },
252
+ )
253
+ }
254
+
255
+ /**
256
+ * What one of brain's other commands just did, recorded at this computer's level as that command's change (`brain mcp
257
+ * install` records tools.<tool>.mcp on); null clears this computer's value. The command already made the change, so it is
258
+ * marked applied. Call it inside the command's own hold of the brain lock. Never fails the command.
259
+ */
260
+ export function recordChanges(home: string, changes: Array<{ id: string; value: Value | null }>, command: string): Change[] {
261
+ const out: Change[] = []
262
+ try {
263
+ updateDoc(home, (d) => {
264
+ const at = now()
265
+ for (const c of changes) {
266
+ const ref = parseId(c.id)
267
+ if ("error" in ref) continue
268
+ const ch = recordChange(d, { id: ref.id, level: "computer", value: c.value, source: "command", command, at }, { upload: upload(ref.family) })
269
+ if (ch) out.push(ch)
270
+ if (c.value !== null && ref.family.apply) d.applied[ref.id] = { want: c.value, ok: true, at }
271
+ }
272
+ })
273
+ } catch {}
274
+ return out
275
+ }
276
+
277
+ /**
278
+ * `brain gateway connect` (s: the settings it connected with) or `disconnect` (s undefined), recorded inside the same hold
279
+ * of the lock: the tool on or off, and for a connect the model it got (cleared when it is a model brain has no short name
280
+ * for) and its context editing. A value already in force is not recorded again, so a choice made with `brain settings`
281
+ * keeps its source; what is asked for and now in force is marked applied (a model that failed earlier, say).
282
+ */
283
+ export function recordConnected(home: string, tool: string, s: GatewaySettings | undefined): void {
284
+ const command = s ? "brain gateway connect" : "brain gateway disconnect"
285
+ try {
286
+ updateDoc(home, (d) => {
287
+ const at = now()
288
+ const put = (id: string, value: Value | null) => {
289
+ const ref = parseId(id)
290
+ if ("error" in ref) return
291
+ const cur = resolve(d, ref).value
292
+ if (value === null ? d.levels.computer[ref.id] === undefined : cur !== undefined && same(cur, value)) return
293
+ recordChange(d, { id: ref.id, level: "computer", value, source: "command", command, at }, { upload: upload(ref.family) })
294
+ }
295
+ put(`tools.${tool}.gateway`, !!s)
296
+ if (s) {
297
+ const alias = modelAlias(s.model)
298
+ put(`tools.${tool}.model`, alias && alias !== "other" ? alias : null)
299
+ if (HEADER_TOOLS.includes(tool)) put(`tools.${tool}.contextEdit`, s.contextEdit)
300
+ }
301
+ const g = parseId(`tools.${tool}.gateway`)
302
+ if (!("error" in g)) d.applied[g.id] = { want: !!s, ok: true, at }
303
+ if (s) markConnected(d, tool, s, at)
304
+ })
305
+ } catch {}
306
+ }