@100xprompt/brain 0.1.3 → 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 +56 -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/connect.ts +252 -101
- package/src/gateway/connectors/hermes.ts +1 -1
- 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
package/src/memory/sync.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { distill } from "./distill.ts"
|
|
|
10
10
|
import { projectKey } from "./project.ts"
|
|
11
11
|
import { openMemory } from "./store.ts"
|
|
12
12
|
import type { Memory } from "./types.ts"
|
|
13
|
+
import { MEMORY_OFF, memoryOn } from "../settings/values.ts"
|
|
13
14
|
|
|
14
15
|
export interface SessionSource {
|
|
15
16
|
/** Portable id (`<tool>:<id>`). */
|
|
@@ -112,6 +113,7 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
|
|
|
112
113
|
const result: SyncResult = { ingested: 0, items: 0, upToDate: 0, deferred: 0, errors: [], ms: 0 }
|
|
113
114
|
const done = (): SyncResult => ({ ...result, ms: now() - t0 })
|
|
114
115
|
|
|
116
|
+
if (!memoryOn(o.home)) return { ...done(), unavailable: MEMORY_OFF }
|
|
115
117
|
const memory = deps.memory ?? openMemory(o.home)
|
|
116
118
|
const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
|
|
117
119
|
if (!status.available) return { ...done(), unavailable: status.why ?? "unavailable" }
|
|
@@ -156,6 +158,7 @@ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessio
|
|
|
156
158
|
export function syncInBackground(home: string, cwd: string, cli: string) {
|
|
157
159
|
if (process.env.BRAIN_NO_MEMORY === "1") return
|
|
158
160
|
try {
|
|
161
|
+
if (!memoryOn(home)) return
|
|
159
162
|
// Only where memory has been set up on this machine: a person who never used it does not get a store created by a hook.
|
|
160
163
|
if (!existsSync(join(home, ".brain", "memory"))) return
|
|
161
164
|
const child = Bun.spawn([process.execPath, cli, "memory", "sync", "--here", "--quiet"], { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore", env: { ...process.env, BRAIN_HOME: home } })
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Exchanging settings with the person's 100xprompt account (docs/settings-contract.md, section 5). A paired computer has
|
|
2
|
+
// its own token (~/.brain/machine.key, from `brain pair`); model API keys never read or change settings.
|
|
3
|
+
//
|
|
4
|
+
// 1. send the outbox: what was changed here, one PATCH per level (this computer, all your computers). Accepted and
|
|
5
|
+
// refused changes leave it; refused ones are kept to be shown, and the account's value then applies again.
|
|
6
|
+
// 2. fetch what the account has (GET, with the revision last seen: "unchanged" costs nothing).
|
|
7
|
+
// 3. take it: the account's entries become this computer's and the person's levels, except settings only this computer
|
|
8
|
+
// can have and changes made here that are not sent yet. Changes from elsewhere go into the history.
|
|
9
|
+
// 4. apply what that changed (apply.ts reconcile), and tell the account what applying gave.
|
|
10
|
+
// Network calls never run under the brain lock; the whole exchange keeps to a time budget (3 s for commands). Never run
|
|
11
|
+
// from the session hook, `brain mcp serve` or the background children. Anything the account answers is checked before it
|
|
12
|
+
// is taken: an answer that cannot be read changes nothing here.
|
|
13
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
|
14
|
+
import type { Env } from "../gateway/common.ts"
|
|
15
|
+
import { writeAtomic } from "../fsutil.ts"
|
|
16
|
+
import { withBrainLockSync } from "../lock.ts"
|
|
17
|
+
import { gatewayUrl, safeUrl } from "../dashboard/send.ts"
|
|
18
|
+
import { brainDir, MACHINE_TOKEN, machineId, machineKeyPath, readMachineToken } from "../dashboard/state.ts"
|
|
19
|
+
import { readOrgConfig } from "../org/config.ts"
|
|
20
|
+
import { type Row, reconcile } from "./apply.ts"
|
|
21
|
+
import { checkValue, parseId } from "./catalog.ts"
|
|
22
|
+
import { type Entry, type Level, readSettingsFile, same, type SettingsDoc, type Source, updateDoc, type Value } from "./store.ts"
|
|
23
|
+
import { memoryOn } from "./values.ts"
|
|
24
|
+
|
|
25
|
+
export { MACHINE_TOKEN, readMachineToken }
|
|
26
|
+
|
|
27
|
+
export function storeMachineToken(home: string, token: string): void {
|
|
28
|
+
withBrainLockSync(home, () => {
|
|
29
|
+
mkdirSync(brainDir(home), { recursive: true, mode: 0o700 })
|
|
30
|
+
writeAtomic(machineKeyPath(home), `${token}\n`, 0o600)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function forgetMachineToken(home: string): boolean {
|
|
35
|
+
const had = existsSync(machineKeyPath(home))
|
|
36
|
+
rmSync(machineKeyPath(home), { force: true })
|
|
37
|
+
return had
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A computer the account knows afresh (paired again after "Remove this machine", or a copied ~/.brain paired as a computer
|
|
42
|
+
* of its own) has an empty document there: every value set at this computer's level here is sent again, and the next fetch
|
|
43
|
+
* starts from nothing, so the account's empty document never erases them.
|
|
44
|
+
*/
|
|
45
|
+
export function sendAllAgain(home: string): void {
|
|
46
|
+
updateDoc(home, (d) => {
|
|
47
|
+
const at = new Date().toISOString()
|
|
48
|
+
for (const [id, e] of Object.entries(d.levels.computer)) {
|
|
49
|
+
const ref = parseId(id)
|
|
50
|
+
if ("error" in ref || ref.family.remote !== "yes") continue
|
|
51
|
+
d.outbox = d.outbox.filter((c) => !(c.id === id && c.level === "computer"))
|
|
52
|
+
d.outbox.push({ id, level: "computer", value: e.value, source: e.source, at: e.at || at, ...(e.command ? { command: e.command } : {}) })
|
|
53
|
+
}
|
|
54
|
+
delete d.sync.rev
|
|
55
|
+
d.sync.machine = machineId(home)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Exchange {
|
|
60
|
+
paired: boolean
|
|
61
|
+
/** The account was reached (sending and fetching worked). */
|
|
62
|
+
ok: boolean
|
|
63
|
+
why?: string
|
|
64
|
+
/** Changes made here the account accepted. */
|
|
65
|
+
sent: number
|
|
66
|
+
refused: Array<{ id: string; code: string; message: string }>
|
|
67
|
+
/** Values that came from the account (changed since the last exchange). */
|
|
68
|
+
received: string[]
|
|
69
|
+
applied: Row[]
|
|
70
|
+
/** Values from the account this computer did not take, and why. */
|
|
71
|
+
held: Array<{ id: string; why: string }>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LEVEL_OUT: Record<"computer" | "person", "machine" | "account"> = { computer: "machine", person: "account" }
|
|
75
|
+
const SOURCES: Source[] = ["settings", "command", "dashboard", "admin", "local-page"]
|
|
76
|
+
const isValue = (v: unknown): v is Value => typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || typeof v === "string"
|
|
77
|
+
const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
|
|
78
|
+
const isObj = (v: unknown) => !!v && typeof v === "object" && !Array.isArray(v)
|
|
79
|
+
const MEMORY_HELD = "this computer keeps memory on your team's server: only this computer turns it on (brain settings set memory.enabled on)"
|
|
80
|
+
|
|
81
|
+
/** An entry as the account sends it, if it is one this brain can keep: a known setting the account may change, at a level it can be set at, with a valid value. */
|
|
82
|
+
function entryFrom(id: string, level: Level, e: unknown): Entry | undefined {
|
|
83
|
+
const x = obj(e)
|
|
84
|
+
if (!isValue(x.value) || typeof x.at !== "string" || x.at.length > 40 || !Number.isFinite(Date.parse(x.at))) return undefined
|
|
85
|
+
const ref = parseId(id)
|
|
86
|
+
if ("error" in ref || ref.id !== id || ref.family.remote !== "yes" || !ref.family.levels.includes(level) || !checkValue(ref.family.type, x.value)) return undefined
|
|
87
|
+
return { value: x.value, source: SOURCES.includes(x.source) ? x.source : "dashboard", at: x.at, ...(Number.isSafeInteger(x.rev) ? { rev: x.rev } : {}) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** "<machine>.<account>" as two numbers; undefined when it is not one. */
|
|
91
|
+
const revParts = (r: unknown): [number, number] | undefined => {
|
|
92
|
+
const m = typeof r === "string" ? /^(\d{1,15})\.(\d{1,15})$/.exec(r) : null
|
|
93
|
+
return m ? [Number(m[1]), Number(m[2])] : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Plain words for what the account answered. */
|
|
97
|
+
function trouble(status: number, said: string): string {
|
|
98
|
+
if (status === 401) return "your account no longer takes this computer's token (was the computer removed in the dashboard?): pair it again with `brain pair`"
|
|
99
|
+
if (status === 403) return `your account refused this exchange${said ? ` (${said})` : ""}: pair this computer again with \`brain pair\``
|
|
100
|
+
if (status === 404) return "the gateway does not keep brain settings yet"
|
|
101
|
+
if (status === 429) return "too many exchanges for now; the next one carries everything"
|
|
102
|
+
return `the gateway answered HTTP ${status}${said ? `: ${said}` : ""}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class Unreachable extends Error {}
|
|
106
|
+
class Unreadable extends Error {}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* One exchange with the account. Does nothing (paired: false) on a computer that is not paired. Never throws; what went
|
|
110
|
+
* wrong is in `why` and kept in settings.json for `brain status`.
|
|
111
|
+
*/
|
|
112
|
+
export async function exchange(home: string, o: { env?: Env; fetchImpl?: typeof fetch; timeoutMs?: number; apply?: boolean } = {}): Promise<Exchange> {
|
|
113
|
+
const out: Exchange = { paired: false, ok: false, sent: 0, refused: [], received: [], applied: [], held: [] }
|
|
114
|
+
// The computer's id first: a ~/.brain copied from another computer gets its own id here, and drops that one's token.
|
|
115
|
+
const machine = machineId(home)
|
|
116
|
+
const token = readMachineToken(home)
|
|
117
|
+
if (!token) return { ...out, why: "this computer is not paired with your account (brain pair)" }
|
|
118
|
+
out.paired = true
|
|
119
|
+
const file = readSettingsFile(home)
|
|
120
|
+
if (file.problem) return { ...out, why: file.problem === "newer" ? "settings.json was written by a newer brain: update brain to exchange settings here" : "settings.json could not be read: `brain settings set` writes a new one" }
|
|
121
|
+
const base = gatewayUrl(home)
|
|
122
|
+
if (!safeUrl(`${base}/api/brain/settings`)) return { ...out, why: `the gateway address ${base} is not https; the token is never sent over it` }
|
|
123
|
+
// A computer the account knows afresh: everything set here goes again (see sendAllAgain).
|
|
124
|
+
if (file.doc.sync.machine !== machine) sendAllAgain(home)
|
|
125
|
+
const deadline = Date.now() + (o.timeoutMs ?? 3_000)
|
|
126
|
+
const call = async (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }> => {
|
|
127
|
+
const left = deadline - Date.now()
|
|
128
|
+
if (left <= 0) throw Object.assign(new Error("out of time"), { name: "TimeoutError" })
|
|
129
|
+
let res: Response
|
|
130
|
+
try {
|
|
131
|
+
res = await (o.fetchImpl ?? fetch)(`${base}${path}`, {
|
|
132
|
+
method,
|
|
133
|
+
headers: { Authorization: `Bearer ${token}`, ...(body ? { "Content-Type": "application/json" } : {}) },
|
|
134
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
135
|
+
// A redirect is not followed: it could carry the token to another host.
|
|
136
|
+
redirect: "manual",
|
|
137
|
+
signal: AbortSignal.timeout(Math.max(200, left)),
|
|
138
|
+
})
|
|
139
|
+
} catch (e) {
|
|
140
|
+
const n = (e as Error)?.name
|
|
141
|
+
if (n === "TimeoutError" || n === "AbortError") throw e
|
|
142
|
+
throw new Unreachable(String((e as Error)?.message ?? e))
|
|
143
|
+
}
|
|
144
|
+
// Reading the body may run out of time too: that is a failed exchange, never an empty answer.
|
|
145
|
+
const text = await res.text()
|
|
146
|
+
let json: unknown
|
|
147
|
+
try {
|
|
148
|
+
json = text ? JSON.parse(text) : undefined
|
|
149
|
+
} catch {
|
|
150
|
+
json = undefined
|
|
151
|
+
}
|
|
152
|
+
return { status: res.status, json }
|
|
153
|
+
}
|
|
154
|
+
const said = (j: any) => String(j?.error?.message ?? j?.error ?? j?.message ?? "").slice(0, 200)
|
|
155
|
+
const fail = (why: string): Exchange => {
|
|
156
|
+
try {
|
|
157
|
+
updateDoc(home, (d) => {
|
|
158
|
+
d.sync.lastError = why
|
|
159
|
+
})
|
|
160
|
+
} catch {}
|
|
161
|
+
return { ...out, why }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// 1. Send what changed here.
|
|
166
|
+
const snapshot = readSettingsFile(home).doc.outbox
|
|
167
|
+
for (const level of ["computer", "person"] as const) {
|
|
168
|
+
const changes = snapshot.filter((c) => {
|
|
169
|
+
if (c.level !== level) return false
|
|
170
|
+
const r = parseId(c.id)
|
|
171
|
+
return !("error" in r) && r.family.remote === "yes"
|
|
172
|
+
})
|
|
173
|
+
if (!changes.length) continue
|
|
174
|
+
const r = await call("PATCH", "/api/brain/settings", { level: LEVEL_OUT[level], machine, changes: changes.map((c) => ({ id: c.id, value: c.value, source: c.source, at: c.at })) })
|
|
175
|
+
if (r.status !== 200) return fail(trouble(r.status, said(r.json)))
|
|
176
|
+
const j = obj(r.json)
|
|
177
|
+
if (!Array.isArray(j.accepted) || !Array.isArray(j.refused)) throw new Unreadable("PATCH")
|
|
178
|
+
const accepted = new Map<string, number>()
|
|
179
|
+
for (const a of j.accepted) if (typeof a?.id === "string") accepted.set(a.id, Number(a.rev))
|
|
180
|
+
const refused = j.refused.filter((x: any) => typeof x?.id === "string").map((x: any) => ({ id: String(x.id).slice(0, 80), code: String(x.code ?? "refused").slice(0, 40), message: String(x.message ?? "").slice(0, 200) }))
|
|
181
|
+
out.sent += accepted.size
|
|
182
|
+
out.refused.push(...refused)
|
|
183
|
+
const settledIds = new Set([...accepted.keys(), ...refused.map((x: { id: string }) => x.id)])
|
|
184
|
+
updateDoc(home, (d) => {
|
|
185
|
+
// Only what was sent leaves the outbox: a change made here meanwhile waits for the next exchange.
|
|
186
|
+
d.outbox = d.outbox.filter((c) => !(c.level === level && settledIds.has(c.id) && changes.some((s) => s.id === c.id && s.at === c.at && same(s.value, c.value))))
|
|
187
|
+
for (const [id, rev] of accepted) {
|
|
188
|
+
const e = d.levels[level][id]
|
|
189
|
+
if (e && Number.isSafeInteger(rev)) e.rev = rev
|
|
190
|
+
}
|
|
191
|
+
const at = new Date().toISOString()
|
|
192
|
+
for (const x of refused) {
|
|
193
|
+
const sent = changes.find((c) => c.id === x.id)
|
|
194
|
+
d.sync.refused.push({ id: x.id, level, value: sent?.value ?? null, code: x.code, message: x.message, at })
|
|
195
|
+
}
|
|
196
|
+
d.sync.refused = d.sync.refused.slice(-20)
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 2. Fetch what the account has. After a refusal the account's revision has not moved, but this computer must go back
|
|
201
|
+
// to the account's value: the whole state is fetched then.
|
|
202
|
+
const since = out.refused.length ? "" : (readSettingsFile(home).doc.sync.rev ?? "")
|
|
203
|
+
const r = await call("GET", `/api/brain/settings?machine=${encodeURIComponent(machine)}&since=${encodeURIComponent(since)}`)
|
|
204
|
+
if (r.status !== 200) return fail(trouble(r.status, said(r.json)))
|
|
205
|
+
const j = obj(r.json)
|
|
206
|
+
const now = new Date().toISOString()
|
|
207
|
+
if (j.unchanged === true && revParts(j.rev)) {
|
|
208
|
+
updateDoc(home, (d) => {
|
|
209
|
+
d.sync.lastSyncAt = now
|
|
210
|
+
delete d.sync.lastError
|
|
211
|
+
})
|
|
212
|
+
} else {
|
|
213
|
+
// Only an answer of the contract's shape is taken: anything else (an HTML page, `{}`) would erase every setting here.
|
|
214
|
+
const fetched = revParts(j.rev)
|
|
215
|
+
if (!fetched || !isObj(j.machine) || !isObj(j.machine.entries) || !isObj(j.account) || !isObj(j.account.entries)) throw new Unreadable("GET")
|
|
216
|
+
// 3. Take it.
|
|
217
|
+
const org = !!readOrgConfig(home)
|
|
218
|
+
updateDoc(home, (d) => {
|
|
219
|
+
// Another brain (a second terminal) may have taken a newer answer meanwhile: an older one is not taken over it.
|
|
220
|
+
const stored = revParts(d.sync.rev)
|
|
221
|
+
if (stored && (fetched[0] < stored[0] || fetched[1] < stored[1])) return
|
|
222
|
+
const pending = new Set(d.outbox.map((c) => `${c.level}\0${c.id}`))
|
|
223
|
+
const next: Record<"computer" | "person", Record<string, Entry>> = { computer: {}, person: {} }
|
|
224
|
+
for (const [level, srv] of [
|
|
225
|
+
["computer", j.machine.entries],
|
|
226
|
+
["person", j.account.entries],
|
|
227
|
+
] as Array<["computer" | "person", Record<string, unknown>]>) {
|
|
228
|
+
// Kept from here: settings only this computer can have, and changes not sent yet.
|
|
229
|
+
for (const [id, e] of Object.entries(d.levels[level])) {
|
|
230
|
+
const ref = parseId(id)
|
|
231
|
+
if (pending.has(`${level}\0${id}`) || "error" in ref || ref.family.remote !== "yes") next[level][id] = e
|
|
232
|
+
}
|
|
233
|
+
for (const [id, raw] of Object.entries(srv)) {
|
|
234
|
+
if (!Object.hasOwn(srv, id) || pending.has(`${level}\0${id}`) || Object.hasOwn(next[level], id)) continue
|
|
235
|
+
const e = entryFrom(id, level, raw)
|
|
236
|
+
if (!e) continue
|
|
237
|
+
const was = d.levels[level][id]
|
|
238
|
+
// The same value from the same source: kept as it is here (with the command that set it).
|
|
239
|
+
next[level][id] = was && same(was.value, e.value) && was.source === e.source ? { ...was, ...(e.rev !== undefined ? { rev: e.rev } : {}) } : e
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Memory turned on from afar on a computer that sends it to a team server: only this computer turns that on. Judged
|
|
243
|
+
// by the effect (whatever level or source did it): memory off before, on after.
|
|
244
|
+
const held: Array<{ id: string; why: string }> = []
|
|
245
|
+
if (org && !memoryOn(home, d) && memoryOn(home, { ...d, levels: { ...d.levels, computer: next.computer, person: next.person } })) {
|
|
246
|
+
for (const level of ["computer", "person"] as const) {
|
|
247
|
+
const was = d.levels[level]["memory.enabled"]
|
|
248
|
+
if (was) next[level]["memory.enabled"] = was
|
|
249
|
+
else delete next[level]["memory.enabled"]
|
|
250
|
+
}
|
|
251
|
+
held.push({ id: "memory.enabled", why: MEMORY_HELD })
|
|
252
|
+
d.applied["memory.enabled"] = { want: true, ok: false, at: now, error: MEMORY_HELD }
|
|
253
|
+
}
|
|
254
|
+
for (const level of ["computer", "person"] as const) {
|
|
255
|
+
for (const id of new Set([...Object.keys(d.levels[level]), ...Object.keys(next[level])])) {
|
|
256
|
+
const was = d.levels[level][id]
|
|
257
|
+
const now2 = next[level][id]
|
|
258
|
+
if (was && now2 && same(was.value, now2.value) && was.source === now2.source) continue
|
|
259
|
+
if (!was && !now2) continue
|
|
260
|
+
// A change from elsewhere goes into the history, with what it replaced here. A removal at this computer's
|
|
261
|
+
// level comes from the dashboard; one for all the person's computers may come from another computer.
|
|
262
|
+
d.history.push({
|
|
263
|
+
id,
|
|
264
|
+
level,
|
|
265
|
+
value: now2 ? now2.value : null,
|
|
266
|
+
source: now2?.source ?? (level === "computer" ? "dashboard" : "settings"),
|
|
267
|
+
at: now2?.at ?? now,
|
|
268
|
+
...(was ? { replaced: { value: was.value, source: was.source, at: was.at, ...(was.command ? { command: was.command } : {}) } } : {}),
|
|
269
|
+
})
|
|
270
|
+
out.received.push(id)
|
|
271
|
+
}
|
|
272
|
+
d.levels[level] = next[level]
|
|
273
|
+
}
|
|
274
|
+
// Team defaults and locks come with step 4: until then the account sends none.
|
|
275
|
+
const locks: SettingsDoc["locks"] = {}
|
|
276
|
+
for (const [id, l] of Object.entries(obj(j.locks))) if (Object.hasOwn(obj(j.locks), id) && isValue(obj(l).value) && !("error" in parseId(id))) locks[id] = { value: obj(l).value }
|
|
277
|
+
d.locks = locks
|
|
278
|
+
d.history = d.history.slice(-200)
|
|
279
|
+
d.sync.rev = `${fetched[0]}.${fetched[1]}`
|
|
280
|
+
d.sync.lastSyncAt = now
|
|
281
|
+
d.sync.machine = machine
|
|
282
|
+
delete d.sync.lastError
|
|
283
|
+
d.sync.held = held
|
|
284
|
+
out.held = held
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
out.ok = true
|
|
288
|
+
} catch (e) {
|
|
289
|
+
const name = (e as Error)?.name
|
|
290
|
+
if (name === "TimeoutError" || name === "AbortError") return fail("the account did not answer in time; changes made here wait for the next exchange")
|
|
291
|
+
if (e instanceof Unreachable) return fail(`could not reach ${base}`)
|
|
292
|
+
if (e instanceof Unreadable) return fail("the account's answer could not be read; nothing here was changed")
|
|
293
|
+
return fail(`the exchange stopped: ${String((e as Error)?.message ?? e).slice(0, 200)}`)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 4. Apply what changed, and say what it gave (a value held back here counts as failed).
|
|
297
|
+
if (o.apply !== false) {
|
|
298
|
+
try {
|
|
299
|
+
out.applied = (await reconcile(home, { env: o.env ?? process.env })).applied
|
|
300
|
+
} catch {}
|
|
301
|
+
const applied: Record<string, unknown> = {}
|
|
302
|
+
for (const row of out.applied)
|
|
303
|
+
applied[row.id] = { want: row.value ?? null, ok: row.state !== "failed" && row.state !== "not on this computer" && row.state !== "can't read", ...(row.error ? { error: row.error } : {}), ...(row.notes.length ? { notes: row.notes.slice(0, 5) } : {}), at: new Date().toISOString() }
|
|
304
|
+
for (const h of out.held) applied[h.id] = { want: true, ok: false, error: h.why, at: new Date().toISOString() }
|
|
305
|
+
if (Object.keys(applied).length && Date.now() < deadline) {
|
|
306
|
+
try {
|
|
307
|
+
await call("POST", "/api/brain/settings/applied", { machine, applied })
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What `brain status` says about the account exchange. */
|
|
315
|
+
export function exchangeState(home: string): {
|
|
316
|
+
paired: boolean
|
|
317
|
+
lastSyncAt?: string
|
|
318
|
+
lastError?: string
|
|
319
|
+
waiting: number
|
|
320
|
+
refused: SettingsDoc["sync"]["refused"]
|
|
321
|
+
held: Array<{ id: string; why: string }>
|
|
322
|
+
} {
|
|
323
|
+
const d = readSettingsFile(home).doc
|
|
324
|
+
const waiting = d.outbox.filter((c) => {
|
|
325
|
+
const r = parseId(c.id)
|
|
326
|
+
return !("error" in r) && r.family.remote === "yes"
|
|
327
|
+
}).length
|
|
328
|
+
return {
|
|
329
|
+
paired: !!readMachineToken(home),
|
|
330
|
+
...(d.sync.lastSyncAt ? { lastSyncAt: d.sync.lastSyncAt } : {}),
|
|
331
|
+
...(d.sync.lastError ? { lastError: d.sync.lastError } : {}),
|
|
332
|
+
waiting,
|
|
333
|
+
refused: d.sync.refused,
|
|
334
|
+
held: d.sync.held,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -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
|
+
}
|