@christianirack/brainllm-runtime 0.1.0 → 0.4.0
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 +1 -1
- package/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-ai.js +129 -0
- package/brain-budget.js +142 -0
- package/brain-data.js +15 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +77 -27
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-mcp.js +32 -17
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +229 -13
- package/brain-rpc.js +3 -0
- package/brain-schema.js +105 -30
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +6 -5
- package/package.json +46 -2
- package/server/server.mjs +615 -52
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
package/brain-form.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
|
|
16
16
|
// submit); leaving the page (client navigation or closing the tab) then asks first. No API.
|
|
17
17
|
import { sig, fx, batch, untrack, _rt } from './brain.js'
|
|
18
|
-
import { rule, check } from './brain-schema.js'
|
|
18
|
+
import { rule, check, checkText, checkScalar, msg } from './brain-schema.js'
|
|
19
19
|
|
|
20
20
|
export { rule, check } // server.mjs validates posted forms with the same rules (§38)
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ const I = _rt()
|
|
|
23
23
|
|
|
24
24
|
const D = globalThis.document
|
|
25
25
|
let seq = 0
|
|
26
|
-
const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS
|
|
26
|
+
const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS (key form.error)
|
|
27
27
|
// navigate through the client router when it is loaded (no reload, base path applied), else a normal load
|
|
28
28
|
const go = u => {
|
|
29
29
|
const G = globalThis
|
|
@@ -41,9 +41,18 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
|
|
|
41
41
|
for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
|
|
42
42
|
})
|
|
43
43
|
}
|
|
44
|
-
const empty = v => v == null || v === '' ||
|
|
44
|
+
const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
|
|
45
45
|
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
46
|
-
const out = (r, v) =>
|
|
46
|
+
const out = (r, v) => {
|
|
47
|
+
if (r.list) {
|
|
48
|
+
if (!Array.isArray(v)) return []
|
|
49
|
+
if (r.t !== 'num') return v
|
|
50
|
+
const numbers = new Array(v.length)
|
|
51
|
+
for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
|
|
52
|
+
return numbers
|
|
53
|
+
}
|
|
54
|
+
return r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v
|
|
55
|
+
}
|
|
47
56
|
|
|
48
57
|
// state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
|
|
49
58
|
let SEEN
|
|
@@ -62,6 +71,46 @@ function posted(id) {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export function form(schema, o = {}) {
|
|
74
|
+
if (rule(schema).t !== 'obj') throw Error('form: schema must be a nested field object')
|
|
75
|
+
return createForm(schema, o, rule, check, remoteCheck)
|
|
76
|
+
}
|
|
77
|
+
// Compiler-only flat literal schema bytecode. Dynamic/public form() keeps the full parser.
|
|
78
|
+
export function formStatic(schema, o = {}) {
|
|
79
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, remoteCheck)
|
|
80
|
+
}
|
|
81
|
+
export function formScalars(schema, o = {}) {
|
|
82
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, remoteCheck)
|
|
83
|
+
}
|
|
84
|
+
// Compiler-only options proven not to configure or dynamically install async checks.
|
|
85
|
+
export function formStaticSync(schema, o = {}) {
|
|
86
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, null)
|
|
87
|
+
}
|
|
88
|
+
export function formScalarsSync(schema, o = {}) {
|
|
89
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, null)
|
|
90
|
+
}
|
|
91
|
+
function compiledForm(schema, o, types, validate, remote) {
|
|
92
|
+
if (remote === null && ('check' in o || 'validate' in o)) throw Error('form: invalid compiled synchronous options')
|
|
93
|
+
if (!schema || Object.getPrototypeOf(schema) !== Object.prototype || Object.keys(schema).length > 4096) throw Error('form: invalid compiled schema')
|
|
94
|
+
return createForm(schema, o, (value, key) => {
|
|
95
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key) || !Array.isArray(value) || value.length !== 2 || !types.includes(value[0]) || typeof value[1] !== 'boolean') throw Error('form: invalid compiled schema')
|
|
96
|
+
return { t: value[0], req: value[1], list: false, min: null, max: null }
|
|
97
|
+
}, validate, remote)
|
|
98
|
+
}
|
|
99
|
+
// Only public/dynamic or check-configured forms retain this optional capability.
|
|
100
|
+
function remoteCheck(o, x, checking) {
|
|
101
|
+
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
102
|
+
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
103
|
+
checking.v++
|
|
104
|
+
return Promise.resolve()
|
|
105
|
+
.then(() => c(v))
|
|
106
|
+
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
107
|
+
.then(m => {
|
|
108
|
+
if (untrack(() => x.s.v) === v) x.e.v = m
|
|
109
|
+
return untrack(() => x.e.v)
|
|
110
|
+
})
|
|
111
|
+
.finally(() => checking.v--)
|
|
112
|
+
}
|
|
113
|
+
function createForm(schema, o, readRule, validateRule, remote) {
|
|
65
114
|
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
66
115
|
const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
|
|
67
116
|
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
@@ -72,7 +121,7 @@ export function form(schema, o = {}) {
|
|
|
72
121
|
function build(sch, base, init) {
|
|
73
122
|
const v = {}, err = {}
|
|
74
123
|
for (const k of Object.keys(sch)) {
|
|
75
|
-
const r =
|
|
124
|
+
const r = readRule(sch[k], k), path = base ? base + '.' + k : k
|
|
76
125
|
if (r.t === 'obj') {
|
|
77
126
|
const sub = build(r.s, path, (init && init[k]) || {})
|
|
78
127
|
Object.defineProperty(v, k, { enumerable: true, value: sub.v })
|
|
@@ -115,23 +164,9 @@ export function form(schema, o = {}) {
|
|
|
115
164
|
const touch = x => {
|
|
116
165
|
validate(x)
|
|
117
166
|
if (!x.touched) (x.touched = true), shown.v++
|
|
118
|
-
remote(x)
|
|
119
|
-
}
|
|
120
|
-
// async check of one field (only when its own rule passes); resolves to its message
|
|
121
|
-
const remote = x => {
|
|
122
|
-
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
123
|
-
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
124
|
-
checking.v++
|
|
125
|
-
return Promise.resolve()
|
|
126
|
-
.then(() => c(v))
|
|
127
|
-
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || 'Could not check this value.'))
|
|
128
|
-
.then(m => {
|
|
129
|
-
if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
|
|
130
|
-
return untrack(() => x.e.v)
|
|
131
|
-
})
|
|
132
|
-
.finally(() => checking.v--)
|
|
167
|
+
if (remote) remote(o, x, checking)
|
|
133
168
|
}
|
|
134
|
-
const validate = x => (x.e.v = untrack(() =>
|
|
169
|
+
const validate = x => (x.e.v = untrack(() => validateRule(x.r, x.s.v)))
|
|
135
170
|
const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
|
|
136
171
|
|
|
137
172
|
// A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
|
|
@@ -165,9 +200,24 @@ export function form(schema, o = {}) {
|
|
|
165
200
|
})
|
|
166
201
|
}
|
|
167
202
|
|
|
203
|
+
// `f.v` and `f.err` are the form's live fields: replacing the object (`f.v = { … }`) would leave the inputs bound to
|
|
204
|
+
// signals the form no longer reads (submit then sees every field empty), so it fails loudly instead (§14, B0133)
|
|
205
|
+
const fixed = k => {
|
|
206
|
+
throw Error(`form: \`${k}\` cannot be replaced; start from values with form(schema, { init }) or load them later with f.reset(values)`)
|
|
207
|
+
}
|
|
168
208
|
const f = {
|
|
169
|
-
v
|
|
170
|
-
|
|
209
|
+
get v() {
|
|
210
|
+
return v
|
|
211
|
+
},
|
|
212
|
+
set v(_) {
|
|
213
|
+
fixed('f.v')
|
|
214
|
+
},
|
|
215
|
+
get err() {
|
|
216
|
+
return err
|
|
217
|
+
},
|
|
218
|
+
set err(_) {
|
|
219
|
+
fixed('f.err')
|
|
220
|
+
},
|
|
171
221
|
get busy() {
|
|
172
222
|
return busy.v
|
|
173
223
|
},
|
|
@@ -181,7 +231,7 @@ export function form(schema, o = {}) {
|
|
|
181
231
|
return checking.v > 0
|
|
182
232
|
},
|
|
183
233
|
get ok() {
|
|
184
|
-
for (const x of F) if (
|
|
234
|
+
for (const x of F) if (validateRule(x.r, x.s.v)) return false
|
|
185
235
|
return true
|
|
186
236
|
},
|
|
187
237
|
id: path => fid(path),
|
|
@@ -229,8 +279,8 @@ export function form(schema, o = {}) {
|
|
|
229
279
|
busy.v = true
|
|
230
280
|
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
231
281
|
try {
|
|
232
|
-
if (o.check) {
|
|
233
|
-
await Promise.all(F.map(remote))
|
|
282
|
+
if (remote && o.check) {
|
|
283
|
+
await Promise.all(F.map(x => remote(o, x, checking)))
|
|
234
284
|
first = F.find(x => untrack(() => x.e.v))
|
|
235
285
|
if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
|
|
236
286
|
}
|
|
@@ -247,7 +297,7 @@ export function form(schema, o = {}) {
|
|
|
247
297
|
if (x && x.errors) mark(x.errors)
|
|
248
298
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
249
299
|
// TypeError (the network dropped, or a bug) get the generic one
|
|
250
|
-
else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || GENERIC
|
|
300
|
+
else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || msg('form.error', GENERIC)
|
|
251
301
|
} finally {
|
|
252
302
|
busy.v = false
|
|
253
303
|
if (fm) fm.removeAttribute('aria-busy')
|
package/brain-hash.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Web Crypto owns cryptography in browsers and Node. No hashing implementation is bundled.
|
|
2
|
+
export async function hashText(value, algorithm = 'SHA-256') {
|
|
3
|
+
if (!['SHA-256', 'SHA-384', 'SHA-512'].includes(algorithm)) throw RangeError('hashText: SHA-256, SHA-384 or SHA-512 required')
|
|
4
|
+
if (typeof value !== 'string') throw TypeError('hashText: text input required')
|
|
5
|
+
if (value.length > 8388608) throw RangeError('hashText: input exceeds 8 MiB')
|
|
6
|
+
const bytes = new TextEncoder().encode(value)
|
|
7
|
+
if (bytes.byteLength > 8388608) throw RangeError('hashText: UTF-8 input exceeds 8 MiB')
|
|
8
|
+
if (!globalThis.crypto?.subtle) throw Error('hashText: Web Crypto requires a secure browser context or Node 22+')
|
|
9
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, bytes))
|
|
10
|
+
let hex = ''
|
|
11
|
+
for (let i = 0; i < digest.length; i++) hex += digest[i].toString(16).padStart(2, '0')
|
|
12
|
+
return hex
|
|
13
|
+
}
|
|
14
|
+
export async function shortHash(value, length = 32) {
|
|
15
|
+
if (!Number.isInteger(length) || length < 8 || length > 64) throw RangeError('shortHash: length must be 8..64 hex characters')
|
|
16
|
+
return (await hashText(value)).slice(0, length)
|
|
17
|
+
}
|
package/brain-inspect.js
CHANGED
|
@@ -16,8 +16,8 @@ const now = () => Date.now()
|
|
|
16
16
|
const pnow = () => (G.performance ? performance.now() : now())
|
|
17
17
|
const syncHeap = () => (G.performance && performance.memory ? performance.memory.usedJSHeapSize : G.process && process.memoryUsage ? process.memoryUsage().heapUsed : null)
|
|
18
18
|
|
|
19
|
-
let out = [], seq = 0, opts = {}, started = false
|
|
20
|
-
const push = r => out.push(r)
|
|
19
|
+
let out = [], seq = 0, opts = {}, started = false, dropped = 0
|
|
20
|
+
const push = r => { if (out.length >= 2000) { out.shift(); dropped++ } out.push(r) }
|
|
21
21
|
|
|
22
22
|
// ---------- instance identity and current owner ----------
|
|
23
23
|
const ids = new WeakMap()
|
|
@@ -612,6 +612,8 @@ export function tick() {
|
|
|
612
612
|
export function drain() {
|
|
613
613
|
if (!out.length) return null
|
|
614
614
|
const m = { t: 'm', v: 2, seq: seq++, at: now(), d: out }
|
|
615
|
+
if (dropped) m.dropped = dropped
|
|
616
|
+
dropped = 0
|
|
615
617
|
out = []
|
|
616
618
|
if (opts.send) opts.send(JSON.stringify(m))
|
|
617
619
|
return m
|
|
@@ -629,7 +631,7 @@ export function inspect(o) {
|
|
|
629
631
|
else i = instOf(x)
|
|
630
632
|
if (!i) return null
|
|
631
633
|
const chain = []
|
|
632
|
-
for (let j = i; j; j = instOf(j.p)) chain.push(nameOf(j))
|
|
634
|
+
for (let j = i; j && chain.length < 33; j = instOf(j.p)) chain.push(nameOf(j))
|
|
633
635
|
return { id: idOf(i), h: i.h, name: nameOf(i), file: (i.C && (i.C.f || (i.C.C && i.C.C.f))) || null, chain }
|
|
634
636
|
},
|
|
635
637
|
sigName,
|
|
@@ -643,7 +645,7 @@ export function inspect(o) {
|
|
|
643
645
|
if (done) done.add(key)
|
|
644
646
|
push({ k: 'chan', kind, name, role, id: i ? idOf(i) : null, h: i ? i.h : null, comp: i ? nameOf(i) : null })
|
|
645
647
|
})
|
|
646
|
-
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
648
|
+
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, brainStack: r.brainStack || null, location: r.location, generatedLocation: r.generatedLocation, frames: r.frames, causes: r.causes, truncated: r.truncated, mapping: r.mapping, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
647
649
|
patchTimers()
|
|
648
650
|
patchGlobalListeners()
|
|
649
651
|
patchHandlers()
|
package/brain-interop.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Explicit foreign-DOM ownership. The adapter, SDK and any React runtime belong to the app.
|
|
2
|
+
import { _rt, fx, untrack } from './brain.js'
|
|
3
|
+
|
|
4
|
+
const I = _rt()
|
|
5
|
+
|
|
6
|
+
// Call during component setup. Resolve the bound element only after insertion.
|
|
7
|
+
// mount must synchronously return a cleanup function or { update?, destroy }.
|
|
8
|
+
export function interop(element, adapter, props = {}, options = {}) {
|
|
9
|
+
const owner = I.owner()
|
|
10
|
+
if (!owner) throw Error('interop: call during component setup')
|
|
11
|
+
let alive = true, instance = null, pending = props, ready = false
|
|
12
|
+
const report = (error, phase) => {
|
|
13
|
+
if (typeof options.onError === 'function') options.onError(error, phase)
|
|
14
|
+
else I.oops(error, owner, 'interop:' + phase)
|
|
15
|
+
}
|
|
16
|
+
const destroy = () => {
|
|
17
|
+
if (!alive) return
|
|
18
|
+
alive = false
|
|
19
|
+
const old = instance
|
|
20
|
+
instance = null
|
|
21
|
+
if (old) try { old.destroy() } catch (error) { report(error, 'destroy') }
|
|
22
|
+
}
|
|
23
|
+
const controller = {
|
|
24
|
+
update(next) {
|
|
25
|
+
if (!alive) throw Error('interop: boundary has been destroyed')
|
|
26
|
+
pending = next
|
|
27
|
+
if (!ready) return
|
|
28
|
+
if (!instance || typeof instance.update !== 'function') throw Error('interop: adapter does not support updates')
|
|
29
|
+
try { untrack(() => instance.update(next)) } catch (error) { report(error, 'update') }
|
|
30
|
+
},
|
|
31
|
+
destroy,
|
|
32
|
+
}
|
|
33
|
+
// No SDK execution during SSR or prerender. Disposal still invalidates this controller.
|
|
34
|
+
fx(() => destroy)
|
|
35
|
+
if (globalThis.__brainSSR || globalThis.__brainRoute) return controller
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (!alive) return
|
|
38
|
+
try {
|
|
39
|
+
const el = typeof element === 'function' ? untrack(element) : element
|
|
40
|
+
if (!el || el.nodeType !== 1) throw Error('interop: expected a bound DOM element')
|
|
41
|
+
if (!adapter || typeof adapter.mount !== 'function') throw Error('interop: adapter.mount is required')
|
|
42
|
+
const result = untrack(() => adapter.mount(el, pending))
|
|
43
|
+
if (typeof result === 'function') instance = { destroy: result }
|
|
44
|
+
else if (result && typeof result.destroy === 'function' && typeof result.then !== 'function') instance = result
|
|
45
|
+
else throw Error('interop: mount must return cleanup or { update, destroy }; asynchronous mounts are unsupported')
|
|
46
|
+
ready = true
|
|
47
|
+
} catch (error) { report(error, 'mount') }
|
|
48
|
+
})
|
|
49
|
+
return controller
|
|
50
|
+
}
|
package/brain-mcp.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// parameters use form()'s mini-language and become a JSON Schema; arguments are validated before `run` is called and
|
|
6
6
|
// a bad call answers with the field's message instead of running. Tools act with the page's permissions only:
|
|
7
7
|
// anything destructive should still ask for confirmation in the UI.
|
|
8
|
+
// Optional 5th argument (WebMCP draft 2026-10-27): { title, readOnly, consequential, untrusted } → `title` and
|
|
9
|
+
// `annotations: { readOnlyHint, consequentialHint, untrustedContentHint }` on the registration; only the keys given.
|
|
8
10
|
import { fx } from './brain.js'
|
|
9
11
|
import { rule, check } from './brain-schema.js'
|
|
10
12
|
|
|
@@ -32,27 +34,40 @@ export function schema(params = {}) {
|
|
|
32
34
|
|
|
33
35
|
const text = v => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v ?? null) }] })
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
const HINTS = [['readOnly', 'readOnlyHint'], ['consequential', 'consequentialHint'], ['untrusted', 'untrustedContentHint']]
|
|
38
|
+
|
|
39
|
+
// The registration dictionary: name, description, inputSchema, then title/annotations only when given.
|
|
40
|
+
export function definition(name, description, params, opts) {
|
|
41
|
+
const def = { name, description, inputSchema: schema(params) }
|
|
42
|
+
if (!opts) return def
|
|
43
|
+
if (typeof opts.title === 'string' && opts.title) def.title = opts.title
|
|
44
|
+
let annotations = null
|
|
45
|
+
for (let i = 0; i < HINTS.length; i++) {
|
|
46
|
+
const v = opts[HINTS[i][0]]
|
|
47
|
+
if (v != null) (annotations || (annotations = {}))[HINTS[i][1]] = !!v
|
|
48
|
+
}
|
|
49
|
+
if (annotations) def.annotations = annotations
|
|
50
|
+
return def
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function tool(name, description, params, run, opts) {
|
|
36
54
|
const mc = context()
|
|
37
55
|
if (!mc || !mc.registerTool || G.__brainSSR || G.__brainRoute) return
|
|
38
56
|
const rules = Object.fromEntries(Object.entries(params || {}).map(([k, s]) => [k, rule(s)]))
|
|
39
57
|
fx(() => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
})
|
|
58
|
+
const def = definition(name, description, params, opts)
|
|
59
|
+
def.execute = async (args = {}) => {
|
|
60
|
+
for (const k in rules) {
|
|
61
|
+
const m = check(rules[k], args[k])
|
|
62
|
+
if (m) return text(`${k}: ${m}`)
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return text(await run(args))
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return text(`Error: ${(e && e.message) || e}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const reg = mc.registerTool(def)
|
|
56
71
|
return () => (reg && reg.unregister ? reg.unregister() : mc.unregisterTool && mc.unregisterTool(name))
|
|
57
72
|
})
|
|
58
73
|
}
|
package/brain-overlay.js
CHANGED
|
@@ -24,10 +24,21 @@ export function forLLM(d) {
|
|
|
24
24
|
})
|
|
25
25
|
if (d.fix) out.push(`fix: ${d.fix}`)
|
|
26
26
|
if (d.brainStack) out.push('stack:', ...[].concat(d.brainStack))
|
|
27
|
+
if (d.location || d.frames || d.causes || d.rid) out.push('structured trace (development only):', JSON.stringify(traceOf(d)))
|
|
28
|
+
if (d.mapping?.status && d.mapping.status !== 'mapped') out.push('Location mapping is incomplete; do not treat generated coordinates as original source coordinates.')
|
|
29
|
+
out.push('Agent next steps: inspect the exact source revision, apply a focused change, run brain check --json, then reproduce the failing action. Record evidence with brain history and refresh brain quality.')
|
|
27
30
|
if (d.file) out.push('brain check --json:', JSON.stringify((byFile.get(d.file) || [d]).map(({ snippet, ...x }) => x)))
|
|
28
31
|
return out.join('\n')
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function traceOf(d) {
|
|
35
|
+
return { schemaVersion: 1, requestId: d.rid || d.requestId || null, component: d.comp || null,
|
|
36
|
+
location: d.location || null, generatedLocation: d.generatedLocation || null,
|
|
37
|
+
mapping: d.mapping || null, truncated: d.truncated || null,
|
|
38
|
+
frames: Array.isArray(d.frames) ? d.frames.slice(0, 24) : [],
|
|
39
|
+
causes: Array.isArray(d.causes) ? d.causes.slice(0, 6) : [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
function item(d, i) {
|
|
32
43
|
const loc = d.file ? `${d.file}:${d.line || ''}${d.col ? ':' + d.col : ''}` : d.src || ''
|
|
33
44
|
const snip = d.snippet && d.snippet.text
|
|
@@ -35,6 +46,7 @@ function item(d, i) {
|
|
|
35
46
|
: ''
|
|
36
47
|
return `<li class="${d.level === 'warning' || d.level === 'info' ? d.level : 'error'}"><p class="t"><b>${esc(d.code || 'Error')}</b> <span>${esc(loc)}</span></p>` +
|
|
37
48
|
`<p>${esc(d.msg)}</p>${snip}${d.fix ? `<p class="fix">fix: ${esc(d.fix)}</p>` : ''}` +
|
|
49
|
+
`${d.location || d.frames || d.causes ? `<details><summary>Development trace and mapping</summary><pre>${esc(JSON.stringify(traceOf(d), null, 2))}</pre></details>` : ''}` +
|
|
38
50
|
`<button type="button" data-copy="${i}">Copy for the LLM</button></li>`
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Small public preferences only. Authentication belongs to HttpOnly server cookies, never Web Storage.
|
|
2
|
+
const G = globalThis
|
|
3
|
+
const privateName = /password|secret|token|authorization|sessionid|csrf|jwt|credit/i
|
|
4
|
+
const valid = name => typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(name) && !privateName.test(name)
|
|
5
|
+
const problem = fix => Object.assign(new Error(fix), { code: 'B0913', fix })
|
|
6
|
+
|
|
7
|
+
export function storage(name, options = {}) {
|
|
8
|
+
if (!valid(name) || !valid(options.namespace || 'app')) throw problem('storage: use public preference names; keep authentication in server sessions')
|
|
9
|
+
if (options.scope && !['local', 'session'].includes(options.scope)) throw problem('storage: scope must be local or session')
|
|
10
|
+
const prefix = `brain:pref:${options.namespace || 'app'}:${name}:`, version = options.version || 1
|
|
11
|
+
const backend = () => {
|
|
12
|
+
let s
|
|
13
|
+
try { s = options.scope === 'session' ? G.sessionStorage : G.localStorage } catch {}
|
|
14
|
+
if (!s) throw problem('Web Storage is unavailable; use an in-memory preference or a server data source')
|
|
15
|
+
return s
|
|
16
|
+
}
|
|
17
|
+
const key = k => { if (!valid(k)) throw problem('storage: keys must be bounded public preference names'); return prefix + k }
|
|
18
|
+
const keys = s => {
|
|
19
|
+
if (s.length > 4096) throw problem('storage: origin contains too many keys; reduce stored preferences')
|
|
20
|
+
const result = []
|
|
21
|
+
for (let i = 0; i < s.length; i++) { const k = s.key(i); if (k?.startsWith(prefix)) result.push(k) }
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
get(k, fallback = null) {
|
|
26
|
+
const s = backend(), raw = s.getItem(key(k))
|
|
27
|
+
if (raw == null) return fallback
|
|
28
|
+
if (raw.length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
|
|
29
|
+
let r; try { r = JSON.parse(raw) } catch { throw problem('storage: malformed preference; explicitly delete or replace this key') }
|
|
30
|
+
return r?.v === version ? r.data : fallback
|
|
31
|
+
},
|
|
32
|
+
set(k, value) {
|
|
33
|
+
const s = backend(), target = key(k)
|
|
34
|
+
let raw
|
|
35
|
+
try { raw = JSON.stringify({v:version,data:value}); if (value === undefined) throw Error() } catch { throw problem('storage: values must be JSON-serializable') }
|
|
36
|
+
if (new TextEncoder().encode(raw).length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
|
|
37
|
+
const existing = keys(s)
|
|
38
|
+
if (existing.length >= 128 && !existing.includes(target)) throw problem('storage: namespace exceeds 128 preference keys')
|
|
39
|
+
let total = new TextEncoder().encode(raw).length
|
|
40
|
+
for (const name of existing) if (name !== target) total += new TextEncoder().encode(s.getItem(name) || '').length
|
|
41
|
+
if (total > 65536) throw problem('storage: namespace exceeds 64 KiB; move structured data to database()')
|
|
42
|
+
try { s.setItem(target, raw) } catch { throw problem('storage: write failed or quota exceeded; reduce stored preferences') }
|
|
43
|
+
},
|
|
44
|
+
delete(k) { backend().removeItem(key(k)) },
|
|
45
|
+
clear() { const s = backend(); for (const k of keys(s)) s.removeItem(k) },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cookie(name, value, options = {}) {
|
|
50
|
+
if (!valid(name)) throw problem('cookie: use a public preference name; authentication cookies belong to the backend')
|
|
51
|
+
if (!G.document) throw problem('cookie: browser document unavailable; use ctx.cookies on the server')
|
|
52
|
+
const key = `brain_pref_${name}`
|
|
53
|
+
if (value === undefined) {
|
|
54
|
+
const matches = G.document.cookie.split(';').map(x => x.trim()).filter(x => x.startsWith(key + '='))
|
|
55
|
+
if (matches.length !== 1) return null
|
|
56
|
+
try { return decodeURIComponent(matches[0].slice(key.length + 1)) } catch { return null }
|
|
57
|
+
}
|
|
58
|
+
const encoded = value === null ? '' : encodeURIComponent(String(value))
|
|
59
|
+
if (encoded.length > 2048) throw problem('cookie: preferences must be at most 2 KiB; use database() for larger data')
|
|
60
|
+
const age = value === null ? 0 : options.maxAge ?? 2592000
|
|
61
|
+
if (!Number.isSafeInteger(age) || age < 0 || age > 31536000) throw problem('cookie: maxAge must be between zero and one year in seconds')
|
|
62
|
+
G.document.cookie = `${key}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${age}`
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Pure bounded transport shared by the page fallback and its dedicated module worker.
|
|
2
|
+
// No owners, DOM hooks, listeners or app modules execute when this module is imported.
|
|
3
|
+
export async function prefetchPage(url, signal) {
|
|
4
|
+
try {
|
|
5
|
+
const r = await fetch(url, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, credentials: 'same-origin', priority: 'low', redirect: 'error', signal })
|
|
6
|
+
const limit = 512 * 1024
|
|
7
|
+
if (!r.ok || r.redirected || !/text\/html/i.test(r.headers?.get('content-type') || '') || +(r.headers.get('content-length') || 0) > limit) {
|
|
8
|
+
await r.body?.cancel?.(); return null
|
|
9
|
+
}
|
|
10
|
+
let text, bytes = 0
|
|
11
|
+
if (r.body?.getReader) {
|
|
12
|
+
const reader = r.body.getReader(), decoder = new TextDecoder(), parts = []
|
|
13
|
+
try {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const { done, value } = await reader.read()
|
|
16
|
+
if (done) break
|
|
17
|
+
bytes += value.byteLength
|
|
18
|
+
if (bytes > limit) { await reader.cancel(); return null }
|
|
19
|
+
parts.push(decoder.decode(value, { stream: true }))
|
|
20
|
+
}
|
|
21
|
+
parts.push(decoder.decode()); text = parts.join('')
|
|
22
|
+
} finally { reader.releaseLock?.() }
|
|
23
|
+
} else {
|
|
24
|
+
text = await r.text(); bytes = text.length * 2
|
|
25
|
+
if (bytes > limit) return null
|
|
26
|
+
}
|
|
27
|
+
const assets = [], base = new URL(url)
|
|
28
|
+
const pattern = /<script[^>]+src=["']([^"']+)["']|\bfrom\s*["']([^"']+\.(?:js|jsbrain))["']|\bimport\(\s*["']([^"']+\.(?:js|jsbrain))["']/g
|
|
29
|
+
let hit
|
|
30
|
+
while ((hit = pattern.exec(text)) && assets.length < 16) {
|
|
31
|
+
const asset = new URL(hit[1] || hit[2] || hit[3], base)
|
|
32
|
+
if (asset.origin === base.origin && asset.pathname.includes('/_b/')) assets.push(asset.href)
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, status: r.status, url: r.url, headers: r.headers, text, bytes, assets }
|
|
35
|
+
} catch { return null }
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Dedicated speculative-fetch worker. Only bounded HTML/assets cross the bridge; app scripts never run.
|
|
2
|
+
import { prefetchPage } from './brain-prefetch-shared.js'
|
|
3
|
+
const jobs = new Map()
|
|
4
|
+
self.onmessage = async e => {
|
|
5
|
+
const m = e.data
|
|
6
|
+
if (!m || !Number.isSafeInteger(m.id)) return
|
|
7
|
+
if (m.cancel) { jobs.get(m.id)?.abort(); return }
|
|
8
|
+
if (jobs.size >= 2 || jobs.has(m.id) || typeof m.url !== 'string') return
|
|
9
|
+
let url
|
|
10
|
+
try { url = new URL(m.url); if (url.origin !== self.location.origin) return } catch { return }
|
|
11
|
+
const controller = new AbortController()
|
|
12
|
+
jobs.set(m.id, controller)
|
|
13
|
+
const timer = setTimeout(() => controller.abort(), 6000)
|
|
14
|
+
try {
|
|
15
|
+
const result = await prefetchPage(url.href, controller.signal)
|
|
16
|
+
self.postMessage(result ? { id: m.id, ...result, headers: [...result.headers] } : { id: m.id, ok: false })
|
|
17
|
+
} catch { self.postMessage({ id: m.id, ok: false }) }
|
|
18
|
+
finally { clearTimeout(timer); controller.abort(); jobs.delete(m.id) }
|
|
19
|
+
}
|