@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.
@@ -0,0 +1,216 @@
1
+ // brain's settings: <home>/.brain/settings.json (0600 in a 0700 folder). What each setting should be, at each level, and
2
+ // what happened when brain applied it. One file for every way of changing brain (`brain settings`, brain's other commands,
3
+ // later the dashboard and the local page), so each of them sees what the others did. This file only keeps the record:
4
+ // catalog.ts says which settings exist and how each is read and applied, apply.ts changes things.
5
+ //
6
+ // levels computer (this computer), person (all of the person's computers), team (the team's default). The first that
7
+ // has a value applies, unless the team admin locked the setting: then the lock does.
8
+ // outbox changes made here that the person's 100xprompt account has not received yet (one per setting and level).
9
+ // history the last 200 changes, with who made them and what each replaced.
10
+ // applied for each setting: the value brain last applied, and whether that worked (or why not).
11
+ //
12
+ // Written only under the brain lock (lock.ts), atomically; read without it.
13
+ import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs"
14
+ import { join } from "node:path"
15
+ import { writeAtomic } from "../fsutil.ts"
16
+ import { withBrainLockSync } from "../lock.ts"
17
+
18
+ export const SETTINGS_SCHEMA = 1
19
+ export const settingsFile = (home: string) => join(home, ".brain", "settings.json")
20
+ export const HISTORY_MAX = 200
21
+
22
+ export type Value = boolean | number | string
23
+ export type Level = "computer" | "person" | "team"
24
+ /** Highest first: the first level with a value applies. */
25
+ export const LEVELS: Level[] = ["computer", "person", "team"]
26
+ /** Who made a change: `brain settings`, another brain command (gateway connect, mcp install, ...), the dashboard, the team admin, the local page. */
27
+ export type Source = "settings" | "command" | "dashboard" | "admin" | "local-page"
28
+
29
+ export interface Entry {
30
+ value: Value
31
+ source: Source
32
+ /** When it was made, by the clock where it was made: shown, never used to order changes. */
33
+ at: string
34
+ /** The command that made it, for source "command" (`brain gateway connect`). */
35
+ command?: string
36
+ /** The order the person's account gave it (from the dashboard sync, later). */
37
+ rev?: number
38
+ }
39
+
40
+ export interface Change {
41
+ id: string
42
+ level: Level
43
+ /** null: the setting was cleared at this level. */
44
+ value: Value | null
45
+ source: Source
46
+ at: string
47
+ command?: string
48
+ /** The value it replaced at this level, when another way of changing brain had set it. */
49
+ replaced?: { value: Value; source: Source; at: string; command?: string }
50
+ }
51
+
52
+ export interface Applied {
53
+ /** The value brain applied (or tried to). */
54
+ want: Value
55
+ ok: boolean
56
+ at: string
57
+ error?: string
58
+ /** What the person should know (an exported key that wins over the tool's config, say). */
59
+ notes?: string[]
60
+ /** The tool is not on this computer. */
61
+ missing?: boolean
62
+ }
63
+
64
+ /** The exchange with the person's account (account.ts). */
65
+ export interface SyncState {
66
+ /** The account's revision last fetched ("<machine rev>.<account rev>"). */
67
+ rev?: string
68
+ lastSyncAt?: string
69
+ /** Why the last exchange did not get through, cleared by one that does. */
70
+ lastError?: string
71
+ /** Changes made here that the account refused for good (kept to be shown), newest last, at most 20. */
72
+ refused: Array<{ id: string; level: Level; value: Value | null; code: string; message: string; at: string }>
73
+ /** Values from the account this computer did not take, and why. */
74
+ held: Array<{ id: string; why: string }>
75
+ /** The computer id the last exchange was for (a new one: the account knows this computer afresh). */
76
+ machine?: string
77
+ }
78
+
79
+ export interface SettingsDoc {
80
+ schema: number
81
+ levels: Record<Level, Record<string, Entry>>
82
+ locks: Record<string, { value: Value; at?: string; by?: string }>
83
+ outbox: Change[]
84
+ history: Change[]
85
+ applied: Record<string, Applied>
86
+ sync: SyncState
87
+ }
88
+
89
+ export const emptyDoc = (): SettingsDoc => ({ schema: SETTINGS_SCHEMA, levels: { computer: {}, person: {}, team: {} }, locks: {}, outbox: [], history: [], applied: {}, sync: { refused: [], held: [] } })
90
+
91
+ const isValue = (v: unknown): v is Value => typeof v === "boolean" || (typeof v === "number" && Number.isFinite(v)) || typeof v === "string"
92
+ const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
93
+ const list = (v: unknown): any[] => (Array.isArray(v) ? v : [])
94
+ const isChange = (c: any) => c && typeof c.id === "string" && LEVELS.includes(c.level) && (c.value === null || isValue(c.value)) && typeof c.at === "string"
95
+
96
+ /** Whatever the file holds, made into a document of the known shape (anything malformed is dropped, not trusted). */
97
+ function normalize(raw: Record<string, any>): SettingsDoc {
98
+ const d = emptyDoc()
99
+ d.schema = typeof raw.schema === "number" ? raw.schema : SETTINGS_SCHEMA
100
+ const levels = obj(raw.levels)
101
+ for (const l of LEVELS)
102
+ for (const [id, e] of Object.entries(obj(levels[l]))) if (isValue(e?.value) && typeof e?.source === "string" && typeof e?.at === "string") d.levels[l][id] = e as Entry
103
+ for (const [id, k] of Object.entries(obj(raw.locks))) if (isValue(k?.value)) d.locks[id] = k
104
+ d.outbox = list(raw.outbox).filter(isChange)
105
+ d.history = list(raw.history).filter(isChange).slice(-HISTORY_MAX)
106
+ for (const [id, a] of Object.entries(obj(raw.applied))) if (isValue(a?.want) && typeof a?.ok === "boolean") d.applied[id] = a as Applied
107
+ const s = obj(raw.sync)
108
+ d.sync = {
109
+ ...(typeof s.rev === "string" ? { rev: s.rev } : {}),
110
+ ...(typeof s.lastSyncAt === "string" ? { lastSyncAt: s.lastSyncAt } : {}),
111
+ ...(typeof s.lastError === "string" ? { lastError: s.lastError } : {}),
112
+ ...(typeof s.machine === "string" ? { machine: s.machine } : {}),
113
+ refused: list(s.refused).filter((r) => r && typeof r.id === "string" && typeof r.code === "string").slice(-20),
114
+ held: list(s.held).filter((h) => h && typeof h.id === "string" && typeof h.why === "string").slice(-20),
115
+ }
116
+ return d
117
+ }
118
+
119
+ /**
120
+ * Upgrades from older layouts, one step per version (MIGRATIONS[n] turns version n into n + 1). Version 1 is the first;
121
+ * a file without a schema is read as version 1.
122
+ */
123
+ const MIGRATIONS: Record<number, (raw: Record<string, any>) => Record<string, any>> = {}
124
+
125
+ export interface ReadResult {
126
+ doc: SettingsDoc
127
+ /** The file could not be used as it is: it is not JSON, or a newer brain wrote it. */
128
+ problem?: "damaged" | "newer"
129
+ }
130
+
131
+ export function readSettingsFile(home: string): ReadResult {
132
+ const p = settingsFile(home)
133
+ if (!existsSync(p)) return { doc: emptyDoc() }
134
+ let raw: Record<string, any>
135
+ try {
136
+ raw = obj(JSON.parse(readFileSync(p, "utf8")))
137
+ } catch {
138
+ return { doc: emptyDoc(), problem: "damaged" }
139
+ }
140
+ let v = typeof raw.schema === "number" ? raw.schema : SETTINGS_SCHEMA
141
+ while (v < SETTINGS_SCHEMA && MIGRATIONS[v]) raw = { ...MIGRATIONS[v]!(raw), schema: ++v }
142
+ const doc = normalize(raw)
143
+ return doc.schema > SETTINGS_SCHEMA ? { doc, problem: "newer" } : { doc }
144
+ }
145
+
146
+ /** The settings as they are now (an empty document when there is no file or it cannot be read). */
147
+ export const readDoc = (home: string): SettingsDoc => readSettingsFile(home).doc
148
+
149
+ export class SettingsUnwritable extends Error {}
150
+
151
+ /**
152
+ * Changes the document under the brain lock: reads it, lets `fn` change it, writes it back. A file a newer brain wrote is
153
+ * never overwritten (that would lose what it knows); a damaged one is kept aside (settings.json.damaged-<time>) first.
154
+ */
155
+ export function updateDoc(home: string, fn: (d: SettingsDoc) => void): SettingsDoc {
156
+ return withBrainLockSync(home, () => {
157
+ const r = readSettingsFile(home)
158
+ if (r.problem === "newer") throw new SettingsUnwritable(`${settingsFile(home)} was written by a newer brain; update brain (bun add -g @100xprompt/brain) to change settings here`)
159
+ if (r.problem === "damaged") {
160
+ try {
161
+ renameSync(settingsFile(home), `${settingsFile(home)}.damaged-${new Date().toISOString().replace(/[:.]/g, "-")}`)
162
+ } catch {}
163
+ }
164
+ const d = r.doc
165
+ fn(d)
166
+ d.schema = SETTINGS_SCHEMA
167
+ mkdirSync(join(home, ".brain"), { recursive: true, mode: 0o700 })
168
+ writeAtomic(settingsFile(home), `${JSON.stringify(d, null, 1)}\n`, 0o600)
169
+ return d
170
+ })
171
+ }
172
+
173
+ export const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
174
+
175
+ export interface Effective {
176
+ value?: Value
177
+ /** Where the value comes from: a lock, a level, brain's default, or nowhere (brain does not manage it). */
178
+ from: "lock" | Level | "default" | "none"
179
+ entry?: Entry
180
+ }
181
+
182
+ /**
183
+ * The value that applies: the team admin's lock, else the first level that has one, else the default. `accept` says which
184
+ * places and values count for this setting (catalog.ts: only its levels, a lock only where admins may lock, and only a
185
+ * value of its type); one that does not count is passed over, and the next place decides.
186
+ */
187
+ export function effective(d: SettingsDoc, id: string, dflt?: Value, accept: (from: "lock" | Level, v: Value) => boolean = () => true): Effective {
188
+ const lock = d.locks[id]
189
+ if (lock && accept("lock", lock.value)) return { value: lock.value, from: "lock" }
190
+ for (const l of LEVELS) {
191
+ const e = d.levels[l][id]
192
+ if (e && accept(l, e.value)) return { value: e.value, from: l, entry: e }
193
+ }
194
+ return dflt === undefined ? { from: "none" } : { value: dflt, from: "default" }
195
+ }
196
+
197
+ /**
198
+ * Records one change: the entry at its level (removed for null), the history, and the outbox for the levels the person's
199
+ * account keeps (this computer's and the person's; the team's is set at the account). Nothing when it changes nothing.
200
+ */
201
+ export function recordChange(d: SettingsDoc, c: Omit<Change, "replaced">, o: { upload?: boolean } = {}): Change | undefined {
202
+ const prev = d.levels[c.level][c.id]
203
+ if (c.value === null ? !prev : prev && same(prev.value, c.value) && prev.source === c.source && prev.command === c.command) return undefined
204
+ const change: Change = { ...c }
205
+ if (prev && (prev.source !== c.source || prev.command !== c.command)) change.replaced = { value: prev.value, source: prev.source, at: prev.at, ...(prev.command ? { command: prev.command } : {}) }
206
+ if (c.value === null) delete d.levels[c.level][c.id]
207
+ else d.levels[c.level][c.id] = { value: c.value, source: c.source, at: c.at, ...(c.command ? { command: c.command } : {}) }
208
+ d.history.push(change)
209
+ if (d.history.length > HISTORY_MAX) d.history.splice(0, d.history.length - HISTORY_MAX)
210
+ // Settings only this computer can have (privacy switches, hooks) never go to the account.
211
+ if (c.level !== "team" && o.upload !== false) {
212
+ d.outbox = d.outbox.filter((x) => !(x.id === c.id && x.level === c.level))
213
+ d.outbox.push(change)
214
+ }
215
+ return change
216
+ }
@@ -0,0 +1,80 @@
1
+ // The settings brain reads where it uses them: ways 1 and 2 (lean sessions, project memory), and the model and context
2
+ // editing saved for a tool. Kept small and free of heavy imports, because `brain session continue`, the MCP server, the
3
+ // session hook's memory sync and `brain gateway connect` read them on every use. catalog.ts describes the same settings
4
+ // for `brain settings`, with the same rules for which levels and values count.
5
+ import { effective, type Level, readSettingsFile, type SettingsDoc, type Value } from "./store.ts"
6
+
7
+ /** The defaults: what brain did before these were settings (threshold: prepare.ts LEAN_AUTO_TOKENS, minChars: externalize's 1,500). */
8
+ export const LEAN_DEFAULTS = { auto: true, threshold: 50_000, minChars: 1_500 } as const
9
+
10
+ export const LIMITS = {
11
+ "lean.threshold": { min: 1_000, max: 2_000_000 },
12
+ "lean.minChars": { min: 300, max: 100_000 },
13
+ } as const
14
+
15
+ export const DEFAULTS: Record<string, Value> = {
16
+ "lean.auto": LEAN_DEFAULTS.auto,
17
+ "lean.threshold": LEAN_DEFAULTS.threshold,
18
+ "lean.minChars": LEAN_DEFAULTS.minChars,
19
+ "memory.enabled": true,
20
+ }
21
+
22
+ /** Whether a value is a valid one for this setting (a hand-edited or newer file may hold anything). */
23
+ export function validValue(id: string, v: Value): boolean {
24
+ if (typeof DEFAULTS[id] === "boolean") return typeof v === "boolean"
25
+ const lim = LIMITS[id as keyof typeof LIMITS]
26
+ return typeof v === "number" && Number.isInteger(v) && (!lim || (v >= lim.min && v <= lim.max))
27
+ }
28
+
29
+ /** What a team admin's lock may hold: any value for lean, only "off" for memory. */
30
+ const lockAllowed = (id: string, v: Value) => (id === "memory.enabled" ? v === false : id.startsWith("lean."))
31
+
32
+ const valueOf = (d: SettingsDoc, id: string): Value =>
33
+ effective(d, id, DEFAULTS[id], (from, v) => validValue(id, v) && (from !== "lock" || lockAllowed(id, v))).value ?? DEFAULTS[id]!
34
+
35
+ export interface LeanSettings {
36
+ /** Long sessions are made lean without --lean. */
37
+ auto: boolean
38
+ /** From this many tokens. */
39
+ threshold: number
40
+ /** Tool output longer than this many characters is replaced by a digest. */
41
+ minChars: number
42
+ }
43
+
44
+ export function leanSettings(home: string, d: SettingsDoc = readSettingsFile(home).doc): LeanSettings {
45
+ return { auto: valueOf(d, "lean.auto") as boolean, threshold: valueOf(d, "lean.threshold") as number, minChars: valueOf(d, "lean.minChars") as number }
46
+ }
47
+
48
+ /**
49
+ * Whether project memory is on for this computer (`brain settings set memory.enabled off` turns it off). A settings file
50
+ * that cannot be read counts as off: it may be the one that turned memory off, and memory can send items to a team server.
51
+ */
52
+ export function memoryOn(home: string, d?: SettingsDoc): boolean {
53
+ if (d) return valueOf(d, "memory.enabled") as boolean
54
+ const r = readSettingsFile(home)
55
+ return r.problem === "damaged" ? false : (valueOf(r.doc, "memory.enabled") as boolean)
56
+ }
57
+
58
+ /** What brain says where memory would have been used. */
59
+ export const MEMORY_OFF = "memory is turned off on this computer (`brain settings set memory.enabled on` turns it on)"
60
+
61
+ const MODEL_LEVELS: Level[] = ["computer", "person", "team"]
62
+
63
+ /**
64
+ * The model and context editing set for a tool with `brain settings` (or, later, the dashboard), at whatever level applies;
65
+ * nothing where none is set. `brain gateway connect` uses them where its flags leave them open. What connect itself recorded
66
+ * does not count here: a plain connect keeps doing what it always did (the last connect's settings).
67
+ */
68
+ export function toolGatewayPrefs(home: string, tool: string, d: SettingsDoc = readSettingsFile(home).doc): { model?: string; contextEdit?: boolean } {
69
+ // The first level with a valid value decides; when that value is one a command recorded, there is no saved choice.
70
+ const chosen = (id: string, ok: (v: Value) => boolean) => {
71
+ for (const l of MODEL_LEVELS) {
72
+ const e = d.levels[l][id]
73
+ if (e && ok(e.value)) return e.source === "command" ? undefined : e.value
74
+ }
75
+ return undefined
76
+ }
77
+ const m = chosen(`tools.${tool}.model`, (v) => typeof v === "string" && ["pro", "flash", "auto"].includes(v))
78
+ const ce = chosen(`tools.${tool}.contextEdit`, (v) => typeof v === "boolean")
79
+ return { ...(typeof m === "string" ? { model: m } : {}), ...(typeof ce === "boolean" ? { contextEdit: ce } : {}) }
80
+ }