@fugood/bricks-project 2.25.0-beta.43 → 2.25.0-beta.46

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.
@@ -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
@@ -100,9 +153,3 @@ export const readBuildConfig = async (projectDir: string): Promise<unknown> => {
100
153
  return null
101
154
  }
102
155
  }
103
-
104
- export const summarizeConfigChange = (change: ConfigChange): string => {
105
- if (change.status === 'no_baseline') return 'config change: no prior build to compare'
106
- if (change.status === 'unavailable') return 'config change: build artifact unavailable'
107
- return change.opCount === 0 ? 'config change: none' : `config change: ${change.opCount} op(s)`
108
- }
package/compile/index.ts CHANGED
@@ -8,10 +8,12 @@ import type { ExportNamedDeclaration, FunctionDeclaration } from 'acorn'
8
8
  import escodegen from 'escodegen'
9
9
  import { makeId } from '../utils/id'
10
10
  import { generateCalulationMap } from './util'
11
- import { computeConfigChange, readBuildConfig, summarizeConfigChange } from './config-diff'
12
11
  import { templateActionNameMap } from './action-name-map'
13
12
  import { templateEventPropsMap } from '../utils/event-props'
14
13
  import { sh } from '../tools/_shell'
14
+ import { computeConfigChange, readBuildConfig } from './config-diff'
15
+ import { appendEditRecord, editProvenance } from '../tools/_edits-log'
16
+ import { isTruthyEnv } from '../tools/mcp-env'
15
17
  import type {
16
18
  Application,
17
19
  Data,
@@ -702,24 +704,37 @@ const compileAutomation = (automationMap: AutomationMap) =>
702
704
  }),
703
705
  )
704
706
 
705
- // Print the minimal config delta vs the previous `bun compile` artifact (volatile
706
- // timestamp fields excluded). Silent when there is no prior build to compare, so
707
- // direct compile() callers (tests, tooling outside a project) emit nothing.
708
- const reportConfigChange = (previousConfig: unknown, config: unknown) => {
707
+ // Record the minimal compiled-config delta this compile produced to the shared audit
708
+ // log (`.bricks/edits.jsonl`), so editing files directly and running `bun compile`
709
+ // leaves the same trail as the MCP source-editing tools. Maintained only in the
710
+ // editing-tools context (`BRICKS_CTOR_ENABLE_EDITING_TOOLS`); the source-editing tools
711
+ // turn it off for their verify compiles (see _verify.ts) so a tool edit records one
712
+ // richer entry instead of an extra generic compile entry. Also silent when there is no
713
+ // prior build to diff against (fresh projects, package tests, tooling outside a project).
714
+ const recordConfigChange = async (previousConfig: unknown, config: unknown) => {
709
715
  if (previousConfig == null) return
710
- // The baseline was parsed from JSON; round-trip the fresh config the same way so
711
- // keys holding undefined (dropped by the artifact's JSON.stringify) don't diff
712
- // as phantom sets.
713
- const change = computeConfigChange(previousConfig, JSON.parse(JSON.stringify(config)))
716
+ if (!isTruthyEnv(process.env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)) return
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)
714
720
  if (change.status !== 'ok') return
715
- console.log(summarizeConfigChange(change))
716
- if (change.opCount > 0) console.log(JSON.stringify(change.ops, null, 2))
721
+ await appendEditRecord(process.cwd(), {
722
+ ts: new Date().toISOString(),
723
+ tool: 'compile',
724
+ provenance: editProvenance(),
725
+ outcome: 'ok',
726
+ summary:
727
+ change.opCount === 0
728
+ ? 'compile: no config change'
729
+ : `compile: ${change.opCount} config op(s)`,
730
+ configChange: change,
731
+ }).catch(() => undefined)
717
732
  }
718
733
 
719
734
  export const compile = async (app: Application) => {
720
735
  await new Promise((resolve) => setImmediate(resolve, 0))
721
- // Snapshot the previous build artifact before the caller's compile.ts overwrites it,
722
- // so the config change introduced by this compile can be reported on return.
736
+ // Snapshot the prior build artifact before the caller's compile.ts overwrites it, so
737
+ // the config change introduced by this compile can be recorded on return.
723
738
  const previousConfig = await readBuildConfig(process.cwd())
724
739
  const timestamp = Date.now()
725
740
  // Pre-index subspace ids so the canvas-item validation below stays O(1).
@@ -1433,12 +1448,7 @@ export const compile = async (app: Application) => {
1433
1448
  : null,
1434
1449
  }
1435
1450
 
1436
- Object.assign(
1437
- calc,
1438
- generateCalulationMap(calc.script_config, {
1439
- snapshotMode: process.env.BRICKS_SNAPSHOT_MODE === '1',
1440
- }),
1441
- )
1451
+ Object.assign(calc, generateCalulationMap(calc.script_config, dataCalcId))
1442
1452
  }
1443
1453
  map[dataCalcId] = calc
1444
1454
  return map
@@ -1475,7 +1485,7 @@ export const compile = async (app: Application) => {
1475
1485
  automation_map: compiledAutomationMap || app.metadata?.TEMP_automation_map || {},
1476
1486
  update_timestamp: timestamp,
1477
1487
  }
1478
- reportConfigChange(previousConfig, config)
1488
+ await recordConfigChange(previousConfig, config)
1479
1489
  return config
1480
1490
  }
1481
1491
 
package/compile/util.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { makeId } from '../utils/id'
1
+ import { makeSeededId } from '../utils/id'
2
2
 
3
3
  type ScriptConfig = {
4
4
  title?: string
@@ -33,15 +33,18 @@ const padding = 15
33
33
  const layerXInterval = 300
34
34
  const layerYInterval = 150
35
35
 
36
- export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMode?: boolean }) => {
36
+ // `calcId` (the owning script calc's stable id) seeds every derived command-node id, so an
37
+ // unchanged calc recompiles to identical ids and editing one calc never shifts another's —
38
+ // keeping the compiled config byte-stable for change detection. See makeSeededId in utils/id.
39
+ export const generateCalulationMap = (config: ScriptConfig, calcId: string) => {
37
40
  validateConfig(config)
38
- const sandboxId = makeId('property_bank_command', opts)
39
- const sandboxErrorId = makeId('property_bank_command', opts)
40
- const sandboxResultId = makeId('property_bank_command', opts)
41
+ const sandboxId = makeSeededId('property_bank_command', `${calcId}:sandbox-run`)
42
+ const sandboxErrorId = makeSeededId('property_bank_command', `${calcId}:sandbox-error`)
43
+ const sandboxResultId = makeSeededId('property_bank_command', `${calcId}:sandbox-result`)
41
44
 
42
45
  const inputs = Object.entries(config.inputs).reduce(
43
46
  (acc, [key, value], index) => {
44
- const commandId = makeId('property_bank_command', opts)
47
+ const commandId = makeSeededId('property_bank_command', `${calcId}:input:${key}`)
45
48
  acc.map[key] = {
46
49
  type: 'data-node',
47
50
  properties: {},
@@ -123,7 +126,7 @@ export const generateCalulationMap = (config: ScriptConfig, opts?: { snapshotMod
123
126
  let y = 0
124
127
  const outputs = Object.entries(config.outputs).reduce(
125
128
  (acc, [key, pbList], index) => {
126
- const commandId = makeId('property_bank_command', opts)
129
+ const commandId = makeSeededId('property_bank_command', `${calcId}:output:${key}`)
127
130
  acc.commandIdList.push(commandId)
128
131
  acc.map[commandId] = {
129
132
  type: 'command-node-object',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-project",
3
- "version": "2.25.0-beta.43",
3
+ "version": "2.25.0-beta.46",
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.43",
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",
package/package.json.bak CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fugood/bricks-ctor",
3
- "version": "2.25.0-beta.43",
3
+ "version": "2.25.0-beta.46",
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.43",
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",
@@ -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
 
@@ -71,6 +71,8 @@ Useful flags:
71
71
 
72
72
  For ad-hoc CDP inspection against this local preview, connect any CDP client to `localhost:19852` — Chrome DevTools front-end works directly. For an agent-friendly CLI over CDP (screenshot, brick tree/query, input emulation, storage reads, runtime eval, network capture), the `bricks-cli` skill documents the `bricks devtools` command surface — read that skill if it is installed in this workspace. If it is not installed, run `bricks --help` and `bricks devtools --help`; the CLI's own help output is authoritative.
73
73
 
74
+ To inspect Data / Property Bank or storage state, prefer the dedicated `bricks devtools storage` subcommands — `storage data-bank get <S_xxxx>` (saved Data values), `storage system persist|memory`, `storage system get <key>` — over hand-written `runtime eval`. Reach for `runtime eval` only for *live* store internals that aren't persisted to a data bank (e.g. a current transient value: `runtime eval "system.data.property('S_xxxx', '<alias>')"`); don't reverse-engineer the `system.*` globals.
75
+
74
76
  ### Project Automations
75
77
 
76
78
  E2E tests authored in TypeScript inside the project (`AutomationTest` / `TestCase`). Test cases include:
@@ -0,0 +1,41 @@
1
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ const auditLogIgnoreEntry = '.bricks/edits.jsonl'
5
+
6
+ // Ensure the project's .gitignore excludes the audit log. No-op when it is already
7
+ // ignored directly or via a broader `.bricks` rule.
8
+ const ensureAuditLogIgnored = async (projectDir: string) => {
9
+ const gitignorePath = path.join(projectDir, '.gitignore')
10
+ const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
11
+ if (err?.code === 'ENOENT') return ''
12
+ throw err
13
+ })
14
+ const ignored = content
15
+ .split(/\r?\n/)
16
+ .map((line) => line.trim())
17
+ .some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
18
+ if (ignored) return
19
+
20
+ const prefix = content && !content.endsWith('\n') ? '\n' : ''
21
+ await writeFile(
22
+ gitignorePath,
23
+ `${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
24
+ )
25
+ }
26
+
27
+ // Append one JSON record to `.bricks/edits.jsonl`, creating the directory and the
28
+ // gitignore entry as needed. Shared by the source-editing tools and `compile()` so
29
+ // every project mutation lands in the same audit log.
30
+ export const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
31
+ const bricksDir = path.join(projectDir, '.bricks')
32
+ await mkdir(bricksDir, { recursive: true })
33
+ await ensureAuditLogIgnored(projectDir)
34
+ await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
35
+ }
36
+
37
+ // Provenance stamped on every audit record: which agent/session produced the change.
38
+ export const editProvenance = () => ({
39
+ session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
40
+ agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
41
+ })
package/tools/mcp-env.ts CHANGED
@@ -9,5 +9,5 @@ export function isTruthyEnv(value: string | undefined) {
9
9
  }
10
10
 
11
11
  export function shouldRegisterEditingTools(env: Record<string, string | undefined> = process.env) {
12
- return isTruthyEnv(env.BRICKS_CTOR_MCP_ENABLE_EDITING_TOOLS)
12
+ return isTruthyEnv(env.BRICKS_CTOR_ENABLE_EDITING_TOOLS)
13
13
  }
@@ -17,7 +17,12 @@ export const compileAndDiff = async (projectDir: string): Promise<VerificationRe
17
17
  // Snapshot the prior compiled config before recompiling (the new artifact overwrites it).
18
18
  const before = await readBuildConfig(projectDir)
19
19
  try {
20
- await sh`bun compile`.cwd(projectDir).env(noColorEnv).text()
20
+ // Turn off the editing-tools audit for this spawned compile so compile() doesn't write
21
+ // a duplicate generic entry — the calling editing tool records its own richer entry.
22
+ await sh`bun compile`
23
+ .cwd(projectDir)
24
+ .env({ ...noColorEnv, BRICKS_CTOR_ENABLE_EDITING_TOOLS: '0' })
25
+ .text()
21
26
  } catch (err: any) {
22
27
  const stdout = err.stdout?.toString() ?? ''
23
28
  const stderr = err.stderr?.toString() ?? ''
@@ -10,9 +10,9 @@ const noColorEnv = { FORCE_COLOR: '0', NO_COLOR: '1' }
10
10
  export function register(server: McpServer, projectDir: string) {
11
11
  const { dirname } = import.meta
12
12
 
13
+ // `bun compile` records the resulting config delta to `.bricks/edits.jsonl` itself
14
+ // (see compile() in compile/index.ts), so the spawned output below already reflects it.
13
15
  server.tool('compile', {}, async () => {
14
- // The config-change report is printed by compile() itself (compile/config-diff.ts),
15
- // so the spawned `bun compile` output below already carries it.
16
16
  let log = 'Type checking & Compiling...\n'
17
17
  try {
18
18
  log += await sh`bun compile`.cwd(projectDir).env(noColorEnv).text()
@@ -4,16 +4,15 @@ import * as t from '@babel/types'
4
4
  import { parse as parseSandboxModule } from 'acorn'
5
5
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6
6
  import { format as formatWithOxfmt } from 'oxfmt'
7
- import { appendFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
7
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
8
8
  import path from 'node:path'
9
9
  import { z } from 'zod'
10
10
 
11
11
  import { verifyProject } from './_verify'
12
+ import { appendEditRecord, editProvenance } from '../_edits-log'
12
13
 
13
14
  const generate = (generateModule as any).default || generateModule
14
15
 
15
- const auditLogIgnoreEntry = '.bricks/edits.jsonl'
16
-
17
16
  const oxfmtOptions = {
18
17
  trailingComma: 'all',
19
18
  tabWidth: 2,
@@ -646,32 +645,6 @@ const writeParsedFile = async (parsed: ParsedFile) => {
646
645
  return code
647
646
  }
648
647
 
649
- const ensureAuditLogIgnored = async (projectDir: string) => {
650
- const gitignorePath = path.join(projectDir, '.gitignore')
651
- const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
652
- if (err?.code === 'ENOENT') return ''
653
- throw err
654
- })
655
- const ignored = content
656
- .split(/\r?\n/)
657
- .map((line) => line.trim())
658
- .some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
659
- if (ignored) return
660
-
661
- const prefix = content && !content.endsWith('\n') ? '\n' : ''
662
- await writeFile(
663
- gitignorePath,
664
- `${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
665
- )
666
- }
667
-
668
- const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
669
- const bricksDir = path.join(projectDir, '.bricks')
670
- await mkdir(bricksDir, { recursive: true })
671
- await ensureAuditLogIgnored(projectDir)
672
- await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
673
- }
674
-
675
648
  const responseFor = (result: any): any => ({
676
649
  content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
677
650
  isError: result.isError || undefined,
@@ -692,10 +665,7 @@ const runOperation = async (
692
665
  operation: () => Promise<any>,
693
666
  ) => {
694
667
  const startedAt = new Date().toISOString()
695
- const provenance = {
696
- session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
697
- agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
698
- }
668
+ const provenance = editProvenance()
699
669
  try {
700
670
  const result = await operation()
701
671
  await appendEditRecord(projectDir, {
@@ -5,17 +5,16 @@ import * as t from '@babel/types'
5
5
  import { parse as parseRuntimeExpression } from 'acorn'
6
6
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
7
7
  import { format as formatWithOxfmt } from 'oxfmt'
8
- import { mkdir, readFile, readdir, appendFile, writeFile } from 'node:fs/promises'
8
+ import { readFile, readdir, writeFile } from 'node:fs/promises'
9
9
  import path from 'node:path'
10
10
  import { z } from 'zod'
11
11
 
12
12
  import { verifyProject } from './_verify'
13
+ import { appendEditRecord, editProvenance } from '../_edits-log'
13
14
 
14
15
  const generate = (generateModule as any).default || generateModule
15
16
  const traverse = (traverseModule as any).default || traverseModule
16
17
 
17
- const auditLogIgnoreEntry = '.bricks/edits.jsonl'
18
-
19
18
  const oxfmtOptions = {
20
19
  trailingComma: 'all',
21
20
  tabWidth: 2,
@@ -894,32 +893,6 @@ const writeParsedFile = async (parsed: ParsedFile) => {
894
893
  return code
895
894
  }
896
895
 
897
- const appendEditRecord = async (projectDir: string, record: Record<string, unknown>) => {
898
- const bricksDir = path.join(projectDir, '.bricks')
899
- await mkdir(bricksDir, { recursive: true })
900
- await ensureAuditLogIgnored(projectDir)
901
- await appendFile(path.join(bricksDir, 'edits.jsonl'), `${JSON.stringify(record)}\n`)
902
- }
903
-
904
- const ensureAuditLogIgnored = async (projectDir: string) => {
905
- const gitignorePath = path.join(projectDir, '.gitignore')
906
- const content = await readFile(gitignorePath, 'utf8').catch((err: any) => {
907
- if (err?.code === 'ENOENT') return ''
908
- throw err
909
- })
910
- const ignored = content
911
- .split(/\r?\n/)
912
- .map((line) => line.trim())
913
- .some((line) => line === auditLogIgnoreEntry || line === '.bricks/' || line === '.bricks')
914
- if (ignored) return
915
-
916
- const prefix = content && !content.endsWith('\n') ? '\n' : ''
917
- await writeFile(
918
- gitignorePath,
919
- `${content}${prefix}\n# MCP entry-editing audit log\n${auditLogIgnoreEntry}\n`,
920
- )
921
- }
922
-
923
896
  const summarizeTarget = (parsed?: ParsedFile, entry?: ExportEntry) => {
924
897
  if (!parsed) return undefined
925
898
  const subspace = getSubspaceLabelFromPath(parsed.absPath)
@@ -941,10 +914,7 @@ const runOperation = async (
941
914
  entry: input?.entry,
942
915
  id: input?.id,
943
916
  },
944
- provenance: {
945
- session: process.env.BRICKS_CTOR_SESSION_ID || process.env.CODEX_SESSION_ID,
946
- agent: process.env.BRICKS_CTOR_AGENT_ID || process.env.USER,
947
- },
917
+ provenance: editProvenance(),
948
918
  }
949
919
 
950
920
  try {
@@ -250,7 +250,7 @@ export type DataCommandColorRandom = DataCommand & {
250
250
  outputs?: Array<DataCalcOutput<'result'> /* target: string */>
251
251
  }
252
252
 
253
- /* RGBA — Generate color string RGBA values */
253
+ /* RGBA — Generate a color from RGBA channels (red/green/blue: 0-255, alpha: 0-1) */
254
254
  export type DataCommandColorRgba = DataCommand & {
255
255
  __commandName: 'COLOR_RGBA'
256
256
  inputs?: Array<
@@ -22,7 +22,7 @@ export type DataCommandDatetimeDate = DataCommand & {
22
22
  outputs?: Array<DataCalcOutput<'result'> /* target: number */>
23
23
  }
24
24
 
25
- /* Day — Get day (Sunday is 7) */
25
+ /* Day — Get day of week (0-based: Sunday is 0, Saturday is 6) */
26
26
  export type DataCommandDatetimeDay = DataCommand & {
27
27
  __commandName: 'DATETIME_DAY'
28
28
  inputs?: Array<
@@ -64,7 +64,7 @@ export type DataCommandDatetimeMinute = DataCommand & {
64
64
  outputs?: Array<DataCalcOutput<'result'> /* target: number */>
65
65
  }
66
66
 
67
- /* Month — Get month (January is 1) */
67
+ /* Month — Get month (0-based: January is 0, December is 11) */
68
68
  export type DataCommandDatetimeMonth = DataCommand & {
69
69
  __commandName: 'DATETIME_MONTH'
70
70
  inputs?: Array<
package/utils/calc.ts CHANGED
@@ -28,6 +28,10 @@ export const generateDataCalculationMapEditorInfo = (
28
28
  DataCalculationData | DataCommand,
29
29
  Set<DataCalculationData | DataCommand>
30
30
  >()
31
+ const nodeById = new Map<string, DataCalculationData | DataCommand>()
32
+ for (const node of nodes) {
33
+ if ('id' in node) nodeById.set(node.id, node)
34
+ }
31
35
 
32
36
  // Analyze node connections
33
37
  nodes.forEach((node) => {
@@ -48,7 +52,7 @@ export const generateDataCalculationMapEditorInfo = (
48
52
  if (!connectedTo.has(node)) {
49
53
  connectedTo.set(node, new Set())
50
54
  }
51
- const sourceNode = nodes.find((n) => 'id' in n && n.id === conn.id)
55
+ const sourceNode = nodeById.get(conn.id)
52
56
  if (sourceNode) {
53
57
  connectedTo.get(node)!.add(sourceNode)
54
58
  }
package/utils/id.ts CHANGED
@@ -69,63 +69,65 @@ const makeStableUuid = (type: string, alias?: string) => {
69
69
  })
70
70
  }
71
71
 
72
- // Make stable ids by default; explicit snapshotMode: false preserves the random escape hatch.
73
- export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
74
- if (type === 'subspace') {
75
- throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
76
- }
77
-
78
- const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
79
- const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
80
-
81
- let prefix = ''
72
+ const idPrefix = (type: IdType): string => {
82
73
  switch (type) {
83
74
  case 'animation':
84
- prefix = 'ANIMATION_'
85
- break
75
+ return 'ANIMATION_'
86
76
  case 'brick':
87
- prefix = 'BRICK_'
88
- break
77
+ return 'BRICK_'
89
78
  case 'dynamic-brick':
90
- prefix = 'DYNAMIC_BRICK_'
91
- break
79
+ return 'DYNAMIC_BRICK_'
92
80
  case 'canvas':
93
- prefix = 'CANVAS_'
94
- break
81
+ return 'CANVAS_'
95
82
  case 'generator':
96
- prefix = 'GENERATOR_'
97
- break
83
+ return 'GENERATOR_'
98
84
  case 'data':
99
- prefix = 'PROPERTY_BANK_DATA_NODE_'
100
- break
85
+ return 'PROPERTY_BANK_DATA_NODE_'
101
86
  case 'switch':
102
- prefix = 'BRICK_STATE_GROUP_'
103
- break
87
+ return 'BRICK_STATE_GROUP_'
104
88
  case 'property_bank_command':
105
- prefix = 'PROPERTY_BANK_COMMAND_NODE_'
106
- break
89
+ return 'PROPERTY_BANK_COMMAND_NODE_'
107
90
  case 'property_bank_calc':
108
- prefix = 'PROPERTY_BANK_COMMAND_MAP_'
109
- break
91
+ return 'PROPERTY_BANK_COMMAND_MAP_'
110
92
  case 'automation_map':
111
- prefix = 'AUTOMATION_MAP_'
112
- break
93
+ return 'AUTOMATION_MAP_'
113
94
  case 'test':
114
- prefix = 'TEST_'
115
- break
95
+ return 'TEST_'
116
96
  case 'test_case':
117
- prefix = 'TEST_CASE_'
118
- break
97
+ return 'TEST_CASE_'
119
98
  case 'test_var':
120
- prefix = 'TEST_VAR_'
121
- break
99
+ return 'TEST_VAR_'
122
100
  default:
101
+ return ''
102
+ }
103
+ }
104
+
105
+ // Make stable ids by default; explicit snapshotMode: false preserves the random escape hatch.
106
+ export const makeId = (type: IdType, aliasOrOpts?: string | IdOptions, opts?: IdOptions) => {
107
+ if (type === 'subspace') {
108
+ throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
123
109
  }
124
110
 
111
+ const alias = typeof aliasOrOpts === 'string' ? aliasOrOpts : undefined
112
+ const options = typeof aliasOrOpts === 'string' ? opts : (aliasOrOpts ?? opts)
113
+
125
114
  const useCountFallback = aliasOrOpts === undefined && opts === undefined
126
115
  const id =
127
116
  alias !== undefined || options?.snapshotMode || useCountFallback
128
117
  ? makeStableUuid(type, alias)
129
118
  : uuid()
130
- return `${prefix}${id}`
119
+ return `${idPrefix(type)}${id}`
120
+ }
121
+
122
+ // Deterministic id derived solely from a caller-supplied seed. Unlike makeId it keeps no
123
+ // global state — no incrementing counter, no alias registry — so the same (type, seed)
124
+ // always maps to the same id on every compile and in any process. Compiled artifacts seed
125
+ // their ids this way (e.g. generateCalulationMap's command nodes, seeded by their calc id +
126
+ // structural role) so unchanged source recompiles byte-identically and editing one calc
127
+ // never shifts another's ids. The seed must be unique within a single config.
128
+ export const makeSeededId = (type: IdType, seed: string) => {
129
+ if (type === 'subspace') {
130
+ throw new Error('Currently subspace is not supported for ID generation, please use a fixed ID')
131
+ }
132
+ return `${idPrefix(type)}${uuid({ random: hashToRandomBytes([readApplicationId(), type, seed]) })}`
131
133
  }