@fugood/bricks-ctor 2.25.0-beta.45 → 2.25.0-beta.47

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.
@@ -54,6 +54,45 @@ describe('computeConfigChange', () => {
54
54
  })
55
55
  })
56
56
 
57
+ test('ignores object fields omitted by JSON.stringify', () => {
58
+ const before = { subspace_map: { S: { title: 'Home' } } }
59
+ const after = {
60
+ subspace_map: {
61
+ S: {
62
+ title: 'Home',
63
+ description: undefined,
64
+ },
65
+ },
66
+ }
67
+ expect(computeConfigChange(before, after)).toEqual({ status: 'ok', ops: [], opCount: 0 })
68
+ })
69
+
70
+ test('emits added values in JSON-compatible shape', () => {
71
+ const before = { m: {} }
72
+ const after = {
73
+ m: {
74
+ nested: {
75
+ keep: 1,
76
+ drop: undefined,
77
+ },
78
+ items: [undefined, 2],
79
+ },
80
+ }
81
+ const change = computeConfigChange(before, after)
82
+ expect(change.ops).toEqual([
83
+ { op: 'set', path: ['m', 'nested'], value: { keep: 1 } },
84
+ { op: 'set', path: ['m', 'items'], value: [null, 2] },
85
+ ])
86
+ })
87
+
88
+ test('compares array undefined entries like JSON nulls', () => {
89
+ expect(computeConfigChange({ items: [null] }, { items: [undefined] })).toEqual({
90
+ status: 'ok',
91
+ ops: [],
92
+ opCount: 0,
93
+ })
94
+ })
95
+
57
96
  test('reports no_baseline / unavailable for missing sides', () => {
58
97
  expect(computeConfigChange(null, { a: 1 })).toEqual({ status: 'no_baseline' })
59
98
  expect(computeConfigChange({ a: 1 }, null)).toEqual({ status: 'unavailable' })
@@ -64,6 +64,28 @@ describe('validateConfig', () => {
64
64
  test('does not throw when error/output are falsy and inputs are empty', () => {
65
65
  expect(() => validateConfig(baseConfig())).not.toThrow()
66
66
  })
67
+
68
+ test('throws when error and output target the same id', () => {
69
+ const config = baseConfig({ output: 'shared', error: 'shared' })
70
+ expect(() => validateConfig(config)).toThrow(/key: error\/output/)
71
+ })
72
+
73
+ test('throws when output collides with an outputs target id', () => {
74
+ const config = baseConfig({ output: 'shared', outputs: { x: ['shared'] } })
75
+ expect(() => validateConfig(config)).toThrow(/key: output\/outputs/)
76
+ })
77
+
78
+ test('throws when error collides with an outputs target id', () => {
79
+ const config = baseConfig({ error: 'shared', outputs: { x: ['shared'] } })
80
+ expect(() => validateConfig(config)).toThrow(/key: error\/outputs/)
81
+ })
82
+
83
+ // The same id reused across *different* outputs entries is a supported last-wins case
84
+ // (see generateCalulationMap test below) and must stay allowed.
85
+ test('allows the same id across multiple outputs entries', () => {
86
+ const config = baseConfig({ outputs: { first: ['pb1'], second: ['pb1'] } })
87
+ expect(() => validateConfig(config)).not.toThrow()
88
+ })
67
89
  })
68
90
 
69
91
  // generateCalulationMap now seeds command ids from the owning calc id; tests that don't
@@ -27,17 +27,60 @@ export type ConfigChange =
27
27
  const isRecord = (value: unknown): value is Record<string, unknown> =>
28
28
  typeof value === 'object' && value !== null && !Array.isArray(value)
29
29
 
30
+ const isJsonDroppedValue = (value: unknown) =>
31
+ value === undefined || typeof value === 'function' || typeof value === 'symbol'
32
+
33
+ const toJsonComparableScalar = (value: unknown) => {
34
+ if (typeof value === 'number' && !Number.isFinite(value)) return null
35
+ return value
36
+ }
37
+
38
+ const toJsonCompatibleValue = (value: unknown): unknown => {
39
+ if (isJsonDroppedValue(value)) return undefined
40
+ if (Array.isArray(value)) {
41
+ return value.map((item) => (isJsonDroppedValue(item) ? null : toJsonCompatibleValue(item)))
42
+ }
43
+ if (isRecord(value)) {
44
+ return Object.entries(value).reduce((acc, [key, item]) => {
45
+ if (!isJsonDroppedValue(item)) acc[key] = toJsonCompatibleValue(item)
46
+ return acc
47
+ }, {})
48
+ }
49
+ return toJsonComparableScalar(value)
50
+ }
51
+
52
+ const hasJsonObjectKey = (value: Record<string, unknown>, key: string) =>
53
+ Object.prototype.hasOwnProperty.call(value, key) && !isJsonDroppedValue(value[key])
54
+
55
+ const getJsonArrayItem = (value: unknown[], index: number) => {
56
+ const item = value[index]
57
+ return isJsonDroppedValue(item) ? null : item
58
+ }
59
+
30
60
  const deepEqual = (a: unknown, b: unknown): boolean => {
61
+ a = toJsonComparableScalar(a)
62
+ b = toJsonComparableScalar(b)
31
63
  if (a === b) return true
32
64
  if (Array.isArray(a) && Array.isArray(b)) {
33
- return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]))
65
+ if (a.length !== b.length) return false
66
+ for (let index = 0; index < a.length; index += 1) {
67
+ if (!deepEqual(getJsonArrayItem(a, index), getJsonArrayItem(b, index))) return false
68
+ }
69
+ return true
34
70
  }
35
71
  if (isRecord(a) && isRecord(b)) {
36
- const aKeys = Object.keys(a)
37
- return (
38
- aKeys.length === Object.keys(b).length &&
39
- aKeys.every((key) => key in b && deepEqual(a[key], b[key]))
40
- )
72
+ let comparableAKeys = 0
73
+ for (const key of Object.keys(a)) {
74
+ if (isJsonDroppedValue(a[key])) continue
75
+ comparableAKeys += 1
76
+ if (!hasJsonObjectKey(b, key) || !deepEqual(a[key], b[key])) return false
77
+ }
78
+
79
+ let comparableBKeys = 0
80
+ for (const key of Object.keys(b)) {
81
+ if (!isJsonDroppedValue(b[key])) comparableBKeys += 1
82
+ }
83
+ return comparableAKeys === comparableBKeys
41
84
  }
42
85
  return false
43
86
  }
@@ -64,22 +107,32 @@ const diffInto = (
64
107
  if (isRecord(before) && isRecord(after)) {
65
108
  const keys = new Set([...Object.keys(before), ...Object.keys(after)])
66
109
  for (const key of keys) {
110
+ const beforeHasKey = hasJsonObjectKey(before, key)
111
+ const afterHasKey = hasJsonObjectKey(after, key)
112
+ if (!beforeHasKey && !afterHasKey) continue
113
+
67
114
  const nextPath = [...currentPath, key]
68
- if (!(key in after)) ops.push({ op: 'unset', path: nextPath })
69
- else if (!(key in before)) ops.push({ op: 'set', path: nextPath, value: after[key] })
70
- else diffInto(before[key], after[key], nextPath, ops)
115
+ if (!afterHasKey) ops.push({ op: 'unset', path: nextPath })
116
+ else if (!beforeHasKey) {
117
+ ops.push({ op: 'set', path: nextPath, value: toJsonCompatibleValue(after[key]) })
118
+ } else diffInto(before[key], after[key], nextPath, ops)
71
119
  }
72
120
  return
73
121
  }
74
122
 
75
123
  if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
76
124
  for (let index = 0; index < after.length; index += 1) {
77
- diffInto(before[index], after[index], [...currentPath, index], ops)
125
+ diffInto(
126
+ getJsonArrayItem(before, index),
127
+ getJsonArrayItem(after, index),
128
+ [...currentPath, index],
129
+ ops,
130
+ )
78
131
  }
79
132
  return
80
133
  }
81
134
 
82
- ops.push({ op: 'set', path: currentPath, value: after })
135
+ ops.push({ op: 'set', path: currentPath, value: toJsonCompatibleValue(after) })
83
136
  }
84
137
 
85
138
  // Diff two compiled configs. `before == null` means there was no prior build to compare
package/compile/index.ts CHANGED
@@ -714,9 +714,9 @@ const compileAutomation = (automationMap: AutomationMap) =>
714
714
  const recordConfigChange = async (previousConfig: unknown, config: unknown) => {
715
715
  if (previousConfig == null) return
716
716
  if (!isTruthyEnv(process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)) return
717
- // The baseline was parsed from JSON; round-trip the fresh config the same way so keys
718
- // holding undefined (dropped by the artifact's JSON.stringify) don't diff as phantom sets.
719
- const change = computeConfigChange(previousConfig, JSON.parse(JSON.stringify(config)))
717
+ // The baseline was parsed from JSON; `computeConfigChange` applies the same
718
+ // JSON-omitted-field rules lazily so compile avoids cloning the full config.
719
+ const change = computeConfigChange(previousConfig, config)
720
720
  if (change.status !== 'ok') return
721
721
  await appendEditRecord(process.cwd(), {
722
722
  ts: new Date().toISOString(),
package/compile/util.ts CHANGED
@@ -27,6 +27,22 @@ export const validateConfig = (config: ScriptConfig) => {
27
27
  if (Object.values(config.outputs).some((value) => value.some((id) => config.inputs[id]))) {
28
28
  throw new Error(`${errorMsg}. key: outputs`)
29
29
  }
30
+ // The same data-node id reused across the output-side targets (output / error / outputs)
31
+ // also collides: generateCalulationMap spreads their node objects last-wins, so the later
32
+ // one silently overwrites the earlier wiring (e.g. error == output drops the error change
33
+ // link). The checks above only compare against `inputs`, so guard the output-side pairs
34
+ // too. (The same id appearing in multiple `outputs` entries is a supported last-wins case
35
+ // and stays allowed.)
36
+ if (config.error && config.output && config.error === config.output) {
37
+ throw new Error(`${errorMsg}. key: error/output`)
38
+ }
39
+ const outputsIds = Object.values(config.outputs).flat()
40
+ if (config.output && outputsIds.includes(config.output)) {
41
+ throw new Error(`${errorMsg}. key: output/outputs`)
42
+ }
43
+ if (config.error && outputsIds.includes(config.error)) {
44
+ throw new Error(`${errorMsg}. key: error/outputs`)
45
+ }
30
46
  }
31
47
 
32
48
  const padding = 15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.45",
3
+ "version": "2.25.0-beta.47",
4
4
  "main": "index.ts",
5
5
  "scripts": {
6
6
  "typecheck": "tsc --noEmit",
@@ -11,7 +11,7 @@
11
11
  "@babel/parser": "7.28.5",
12
12
  "@babel/traverse": "7.28.5",
13
13
  "@babel/types": "7.28.5",
14
- "@fugood/bricks-cli": "^2.25.0-beta.45",
14
+ "@fugood/bricks-cli": "^2.25.0-beta.46",
15
15
  "@huggingface/gguf": "^0.3.2",
16
16
  "@iarna/toml": "^3.0.0",
17
17
  "@modelcontextprotocol/sdk": "^1.15.0",
@@ -29,5 +29,5 @@
29
29
  "peerDependencies": {
30
30
  "oxfmt": "^0.36.0"
31
31
  },
32
- "gitHead": "37dd0194e56025e5fe727bab6af7e2b8eb1d7ae1"
32
+ "gitHead": "95f194a3fb6331c0f25a879e55675e3ff331a412"
33
33
  }
@@ -46,7 +46,7 @@ These work, but with browser caveats:
46
46
  | WebView / WebCrawler | Subject to browser CORS — a load/fetch that works on device may be blocked |
47
47
  | On-device AI (LLM / STT / VAD / Vector Store / Reranker) | Runs **single-threaded** — far slower than the device, not representative of real latency. Also subject to the model fallbacks below |
48
48
  | On-device database (SQLite — `GENERATOR_SQLITE`) | Runs for real on the in-memory WASM `sqlite-vec` build — `execute` / `query` / `transaction` / batch all work. `storageType: file` is transparently treated as in-memory, so nothing persists across reloads (see above) |
49
- | Scene3D / Maps / Sketch / WebRTC | Supported |
49
+ | Scene3D / Sketch / WebRTC | Supported |
50
50
 
51
51
  Feature availability also varies across the device platforms themselves (iOS / tvOS / Android / the desktop OSes). When a deployment targets a specific platform's capability, confirm it on that platform.
52
52
 
@@ -89,6 +89,7 @@ So that camera and AI features are usable without device permissions, multi-giga
89
89
  | Brick / Generator | In the Simulator | Does NOT prove |
90
90
  |-------------------|------------------|----------------|
91
91
  | Camera (`BRICK_CAMERA`) | A 3D mock canvas, no camera permission prompt. `takePicture` snapshots the canvas; recording produces a placeholder clip | Real camera feed, focus, recording, permission flow |
92
+ | Maps (`BRICK_MAPS`) | A real interactive map on free OpenStreetMap-based tiles — no Google Maps API key needed. Markers, path polyline, the six themes / map types (approximated with free tile sets + CSS tints), and the zoom / pan / navigate / focus / reset / fit actions all work | Google / Apple Maps rendering, exact `customMapStyle` / theme styling (approximated), traffic / buildings / indoors layers, real device geolocation |
92
93
  | Thermal Printer (`GENERATOR_THERMAL_PRINTER`) | A simulated printer — `init` / `checkStatus` / `scan` fake per-driver status and discovered devices (ESC/POS, Star, TSC, Castles); `print` renders an approximate on-screen receipt. A bottom-left bubble shows live status with a fault toggle to exercise error wiring. Print results can be exported as PNG via `bricks-cli` (see below) | Real device connection, actual paper output, exact native driver status codes |
93
94
  | LLM (`GENERATOR_LLM`) | Swapped to a tiny local stand-in model | Output quality / latency of your real model |
94
95
  | Reranker — GGML (`GENERATOR_RERANKER`) | Swapped to a small local multilingual reranker model | Ranking quality / latency of your real model |
@@ -119,7 +120,7 @@ The PNG is the same approximate receipt the on-screen preview shows (rendered fr
119
120
 
120
121
  ### Running the real implementation instead
121
122
 
122
- Each substituted brick/generator can be switched back to its real implementation per item: open the **gear (Simulator settings)** in the editor's preview toolbar, uncheck the item, and **Apply**. Apply persists the choice and reloads the preview so it takes effect (a plain refresh won't). Use this to, e.g., point a Vector Store at a real API key in the preview. The browser limits above still apply, and **Buttress stays disabled regardless** — there's no backend for it here.
123
+ Each substituted brick/generator can be switched back to its real implementation per item: open the **gear (Simulator settings)** in the editor's preview toolbar, uncheck the item, and **Apply**. Apply persists the choice and reloads the preview so it takes effect (a plain refresh won't). Use this to, e.g., point a Vector Store at a real API key in the preview, or render the real Google/Apple Maps brick (which needs a Maps API key on web). The browser limits above still apply, and **Buttress stays disabled regardless** — there's no backend for it here.
123
124
 
124
125
  The Thermal Printer is the exception: it has no real web implementation to switch to (the native drivers can't run in a browser), so it is **always simulated** and is not in the gear list.
125
126
 
@@ -0,0 +1,49 @@
1
+ import { buildGGUFSplitFiles } from '../huggingface'
2
+
3
+ declare const describe: (name: string, fn: () => void) => void
4
+ declare const it: (name: string, fn: () => void) => void
5
+ declare const expect: (actual: unknown) => { toEqual: (expected: unknown) => void }
6
+
7
+ describe('buildGGUFSplitFiles', () => {
8
+ it('builds split GGUF files with indexed sibling metadata lookup', () => {
9
+ const siblings = [
10
+ {
11
+ rfilename: 'other-file.txt',
12
+ size: 999,
13
+ },
14
+ {
15
+ rfilename: 'model-00001-of-00003.gguf',
16
+ size: 1,
17
+ lfs: { sha256: 'one' },
18
+ },
19
+ {
20
+ rfilename: 'model-00002-of-00003.gguf',
21
+ size: 2,
22
+ lfs: { sha256: 'two' },
23
+ },
24
+ {
25
+ rfilename: 'model-00002-of-00003.gguf',
26
+ size: 22,
27
+ lfs: { sha256: 'two-late' },
28
+ },
29
+ ]
30
+
31
+ expect(buildGGUFSplitFiles('model-00001-of-00003.gguf', '00003', siblings)).toEqual([
32
+ {
33
+ rfilename: 'model-00001-of-00003.gguf',
34
+ size: 1,
35
+ lfs: { sha256: 'one' },
36
+ },
37
+ {
38
+ rfilename: 'model-00002-of-00003.gguf',
39
+ size: 2,
40
+ lfs: { sha256: 'two' },
41
+ },
42
+ {
43
+ rfilename: 'model-00003-of-00003.gguf',
44
+ size: undefined,
45
+ lfs: undefined,
46
+ },
47
+ ])
48
+ })
49
+ })
@@ -0,0 +1,21 @@
1
+ import { searchIcons } from '../icons'
2
+
3
+ declare const describe: (name: string, fn: () => void) => void
4
+ declare const test: (name: string, fn: () => void) => void
5
+ declare const expect: (actual: unknown) => {
6
+ toBe: (expected: unknown) => void
7
+ toHaveLength: (expected: number) => void
8
+ }
9
+
10
+ describe('icon_search helpers', () => {
11
+ test('searches the selected style corpus directly', () => {
12
+ const results = searchIcons('x', 10, 'brands')
13
+
14
+ expect(results).toHaveLength(10)
15
+ expect(results.every((result) => result.item.styles.includes('brands'))).toBe(true)
16
+ })
17
+
18
+ test('keeps unstyled searches capped by limit', () => {
19
+ expect(searchIcons('arrow', 5)).toHaveLength(5)
20
+ })
21
+ })
@@ -269,6 +269,28 @@ const fetchHFModelDetails = async (modelId: string): Promise<HFModel> => {
269
269
  // Example: Mixtral-8x22B-v0.1.IQ3_XS-00001-of-00005.gguf
270
270
  const ggufSplitPattern = /-(\d{5})-of-(\d{5})\.gguf$/
271
271
 
272
+ export const buildGGUFSplitFiles = (
273
+ filename: string,
274
+ splitTotal: string,
275
+ siblings: HFSibling[],
276
+ ): HFSibling[] => {
277
+ const siblingByFilename = new Map<string, HFSibling>()
278
+ for (const sibling of siblings) {
279
+ if (!siblingByFilename.has(sibling.rfilename)) siblingByFilename.set(sibling.rfilename, sibling)
280
+ }
281
+
282
+ return Array.from({ length: Number(splitTotal) }, (_, i) => {
283
+ const split = String(i + 1).padStart(5, '0')
284
+ const splitRFilename = filename.replace(ggufSplitPattern, `-${split}-of-${splitTotal}.gguf`)
285
+ const sibling = siblingByFilename.get(splitRFilename)
286
+ return {
287
+ rfilename: splitRFilename,
288
+ size: sibling?.size,
289
+ lfs: sibling?.lfs,
290
+ }
291
+ })
292
+ }
293
+
272
294
  export function register(server: McpServer) {
273
295
  server.tool(
274
296
  'huggingface_search',
@@ -657,19 +679,7 @@ export function register(server: McpServer) {
657
679
 
658
680
  if (isSplit) {
659
681
  const [, , splitTotal] = matched!
660
- const splitFiles = Array.from({ length: Number(splitTotal) }, (_, i) => {
661
- const split = String(i + 1).padStart(5, '0')
662
- const splitRFilename = filename.replace(
663
- ggufSplitPattern,
664
- `-${split}-of-${splitTotal}.gguf`,
665
- )
666
- const sibling = siblings.find((sb) => sb.rfilename === splitRFilename)
667
- return {
668
- rfilename: splitRFilename,
669
- size: sibling?.size,
670
- lfs: sibling?.lfs,
671
- }
672
- })
682
+ const splitFiles = buildGGUFSplitFiles(filename, splitTotal, siblings)
673
683
 
674
684
  const first = splitFiles[0]
675
685
  const rest = splitFiles.slice(1)
@@ -36,11 +36,31 @@ const iconList = Object.entries(glyphmap as Record<string, number>).map(([name,
36
36
  return { name, code, styles }
37
37
  })
38
38
 
39
- const iconFuse = new Fuse(iconList, {
39
+ const fuseOptions = {
40
40
  keys: ['name'],
41
41
  threshold: 0.3,
42
42
  includeScore: true,
43
- })
43
+ }
44
+
45
+ const iconFuse = new Fuse(iconList, fuseOptions)
46
+ const iconFuseByStyle = new Map<IconStyle, Fuse<(typeof iconList)[number]>>()
47
+
48
+ function getStyleFuse(style: IconStyle) {
49
+ let fuse = iconFuseByStyle.get(style)
50
+ if (!fuse) {
51
+ fuse = new Fuse(
52
+ iconList.filter((icon) => icon.styles.includes(style)),
53
+ fuseOptions,
54
+ )
55
+ iconFuseByStyle.set(style, fuse)
56
+ }
57
+ return fuse
58
+ }
59
+
60
+ export function searchIcons(query: string, limit: number, style?: IconStyle) {
61
+ if (style) return getStyleFuse(style).search(query, { limit })
62
+ return iconFuse.search(query, { limit })
63
+ }
44
64
 
45
65
  export function register(server: McpServer) {
46
66
  server.tool(
@@ -54,11 +74,7 @@ export function register(server: McpServer) {
54
74
  .describe('Filter by icon style'),
55
75
  },
56
76
  async ({ query, limit, style }) => {
57
- let results = iconFuse.search(query, { limit: style ? limit * 3 : limit })
58
-
59
- if (style) {
60
- results = results.filter((r) => r.item.styles.includes(style)).slice(0, limit)
61
- }
77
+ const results = searchIcons(query, limit, style)
62
78
 
63
79
  const icons = results.map((r) => ({
64
80
  name: r.item.name,