@things-factory/board-ai 10.1.45 → 10.1.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.
Files changed (32) hide show
  1. package/dist-client/server/service/assistant.js +2 -0
  2. package/dist-client/server/service/assistant.js.map +1 -1
  3. package/dist-client/server/service/types.d.ts +6 -0
  4. package/dist-client/server/service/types.js.map +1 -1
  5. package/dist-client/server/service/validation/tool-validation.d.ts +1 -1
  6. package/dist-client/server/service/validation/tool-validation.js +27 -1
  7. package/dist-client/server/service/validation/tool-validation.js.map +1 -1
  8. package/dist-client/tsconfig.tsbuildinfo +1 -1
  9. package/dist-server/service/assistant.js +2 -0
  10. package/dist-server/service/assistant.js.map +1 -1
  11. package/dist-server/service/binding-tools.d.ts +23 -0
  12. package/dist-server/service/binding-tools.js +269 -0
  13. package/dist-server/service/binding-tools.js.map +1 -0
  14. package/dist-server/service/board-ai-resolver.d.ts +5 -0
  15. package/dist-server/service/board-ai-resolver.js +55 -24
  16. package/dist-server/service/board-ai-resolver.js.map +1 -1
  17. package/dist-server/service/index.d.ts +1 -0
  18. package/dist-server/service/index.js +1 -0
  19. package/dist-server/service/index.js.map +1 -1
  20. package/dist-server/service/types.d.ts +6 -0
  21. package/dist-server/service/types.js.map +1 -1
  22. package/dist-server/service/validation/tool-validation.d.ts +1 -1
  23. package/dist-server/service/validation/tool-validation.js +27 -1
  24. package/dist-server/service/validation/tool-validation.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +6 -6
  27. package/server/service/assistant.ts +2 -0
  28. package/server/service/binding-tools.ts +293 -0
  29. package/server/service/board-ai-resolver.ts +33 -6
  30. package/server/service/index.ts +1 -0
  31. package/server/service/types.ts +6 -0
  32. package/server/service/validation/tool-validation.ts +15 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/board-ai",
3
- "version": "10.1.45",
3
+ "version": "10.1.47",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/client/index.js",
6
6
  "things-factory": true,
@@ -32,12 +32,12 @@
32
32
  "@operato/i18n": "^10.0.0",
33
33
  "@operato/scene-ops": "^10.14.1",
34
34
  "@operato/styles": "^10.0.0",
35
- "@things-factory/ai-assistant": "^10.1.45",
35
+ "@things-factory/ai-assistant": "^10.1.47",
36
36
  "@things-factory/ai-client-base": "^10.1.39",
37
- "@things-factory/auth-base": "^10.1.44",
38
- "@things-factory/board-import": "^10.1.44",
37
+ "@things-factory/auth-base": "^10.1.47",
38
+ "@things-factory/board-import": "^10.1.47",
39
39
  "@things-factory/env": "^10.1.20",
40
- "@things-factory/shell": "^10.1.44",
40
+ "@things-factory/shell": "^10.1.47",
41
41
  "dompurify": "^3.0.0",
42
42
  "graphql-tag": "^2.12.6",
43
43
  "lit": "^3.1.2",
@@ -47,5 +47,5 @@
47
47
  "copyfiles": "^2.4.1",
48
48
  "rimraf": "^5.0.0"
49
49
  },
50
- "gitHead": "d72e1fd1285797fcd7839d97a18e31f5bf076ae3"
50
+ "gitHead": "185f20d7d9a15715a0ee2757a6a8189ec36cfb9d"
51
51
  }
@@ -179,8 +179,10 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
179
179
  * 답해, 사용자에게 없는 버튼을 누르라고 안내했다. 규율은 도구를 등록한 쪽이 소유하고 여기서는
180
180
  * 모아 붙이기만 한다(도메인 지식이 board-ai 프롬프트로 새지 않는다). */
181
181
  const guidance = getCategoryGuidance(options.toolCategories)
182
+ const surface = (options.surfacePrompt ?? '').trim()
182
183
  const systemPrompt =
183
184
  this.buildSystemPromptForTools(knownTypes, categories, schemas) +
185
+ (surface ? `\n\n==== THIS SURFACE (declared by the app) ====\n${surface}` : '') +
184
186
  (guidance.length ? `\n\n==== TOOL USE RULES (from the tools exposed to this conversation) ====\n${guidance.join('\n')}` : '')
185
187
 
186
188
  const selectedRefids = (options.selectedRefids ?? []).filter(
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Data binding as first-class board tools — `getBindings`, `bindData`, `unbindData`.
3
+ *
4
+ * ── Why these exist ──────────────────────────────────────────────────────────
5
+ * A binding was reachable only through `modifyComponentByRefid` with a raw `mappings` array. The
6
+ * model had to know the mapping shape, where the mapping lives (on the component that HAS the
7
+ * data, not on the one that shows it), that a target is a selector, and that a `#id` selector
8
+ * needs the target to carry an id. It guessed, and a wrong guess is silent: the patch applies,
9
+ * nothing moves on the board, no error anywhere.
10
+ *
11
+ * These tools take refids and plain words and do the bookkeeping here:
12
+ * - the mapping is appended to the SOURCE's `mappings` (same target + property replaces);
13
+ * - a target given by refid gets an `id` if it has none, and the mapping points at `#id`;
14
+ * - the rule's parameter lands where the engine reads it (`mapping.param`).
15
+ *
16
+ * The engine side is `things-scene/src/components/data/data.ts` (spreading) and `evaluator.ts`
17
+ * (value / map / range / eval). The property panel writes the same shape.
18
+ *
19
+ * Writes return scene-edit `modify` ops, so they go through the same preview → apply → revert →
20
+ * save path as every other board edit. Nothing here touches the database.
21
+ */
22
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
23
+
24
+ import { collectAllComponents, findComponentByRefid } from './component-tree'
25
+
26
+ type Rule = 'value' | 'map' | 'range' | 'eval'
27
+ const RULES: Rule[] = ['value', 'map', 'range', 'eval']
28
+
29
+ interface Mapping {
30
+ source?: string
31
+ target?: string
32
+ property?: string
33
+ accessor?: string
34
+ rule?: Rule
35
+ param?: any
36
+ partial?: boolean
37
+ ndnsp?: boolean
38
+ }
39
+
40
+ /* The write builder receives the board's top-level components; wrap them so the tree helpers work. */
41
+ const boardOf = (components: ReadonlyArray<any> | undefined) => ({ components: [...(components ?? [])] }) as any
42
+
43
+ function idFor(component: any, refid: number, taken: Set<string>): string {
44
+ const base =
45
+ String(component?.type || 'component')
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '') || 'component'
49
+ let id = `${base}-${refid}`
50
+ for (let n = 2; taken.has(id); n++) id = `${base}-${refid}-${n}`
51
+ return id
52
+ }
53
+
54
+ /** What a mapping means, in the words the model and the person use. */
55
+ function describeMapping(m: Mapping, index: number, idToRefids: Map<string, number[]>) {
56
+ const target = (m.target || '(self)').trim()
57
+ const refids = target.startsWith('#') ? idToRefids.get(target.slice(1)) : undefined
58
+ return {
59
+ index,
60
+ target,
61
+ ...(refids ? { targetRefids: refids } : {}),
62
+ property: m.property,
63
+ accessor: m.accessor || undefined,
64
+ rule: m.rule || 'value',
65
+ ...(m.param !== undefined ? { param: m.param } : {}),
66
+ ...(m.partial ? { partial: true } : {}),
67
+ ...(m.ndnsp ? { ndnsp: true } : {})
68
+ }
69
+ }
70
+
71
+ const getBindings: ToolSpec = {
72
+ kind: 'read',
73
+ name: 'getBindings',
74
+ /* Answers from the board the surface sent; nothing behind it to gate. */
75
+ doors: [],
76
+ description:
77
+ 'List the data bindings on the board (or on one component). A binding lives on the component that holds the data ' +
78
+ '(the source) and spreads a value into a property of a target. Returns, per source component: refid, id, type, ' +
79
+ 'whether it holds initial data, and its mappings with target selector, resolved target refids, property, accessor, rule and rule parameter. ' +
80
+ 'Also lists components that already have an id (usable as targets without changes).',
81
+ schema: {
82
+ type: 'object',
83
+ properties: {
84
+ refid: { type: 'number', description: 'Only this component. Omit for every component that has bindings.' }
85
+ }
86
+ },
87
+ builder: (args: any, context: any) => {
88
+ const flat = collectAllComponents(context?.board)
89
+ const idToRefids = new Map<string, number[]>()
90
+ for (const f of flat) {
91
+ const id = (f.component as any)?.id
92
+ if (id && typeof f.refid === 'number') idToRefids.set(id, [...(idToRefids.get(id) ?? []), f.refid])
93
+ }
94
+
95
+ const only = typeof args?.refid === 'number' ? args.refid : undefined
96
+ const sources = flat
97
+ .filter(f => (only === undefined ? Array.isArray((f.component as any)?.mappings) && (f.component as any).mappings.length > 0 : f.refid === only))
98
+ .map(f => {
99
+ const c: any = f.component
100
+ const mappings: Mapping[] = Array.isArray(c.mappings) ? c.mappings : []
101
+ return {
102
+ refid: f.refid,
103
+ id: c.id || undefined,
104
+ type: c.type,
105
+ hasInitialData: c.data !== undefined && c.data !== null && c.data !== '',
106
+ mappings: mappings.map((m, i) => describeMapping(m, i, idToRefids))
107
+ }
108
+ })
109
+
110
+ if (only !== undefined && sources.length === 0) {
111
+ return { error: `No component with refid ${only} on this board.` }
112
+ }
113
+
114
+ return {
115
+ sources,
116
+ componentsWithId: [...idToRefids.entries()].map(([id, refids]) => ({ id, refids }))
117
+ }
118
+ }
119
+ }
120
+
121
+ const bindData: ToolSpec = {
122
+ kind: 'write',
123
+ name: 'bindData',
124
+ /* Proposes an edit the person saves through the board's own door — offer it only to those who can. */
125
+ doors: ['Mutation.updateBoard'],
126
+ description:
127
+ 'Bind data from a source component to a property of a target component. Use this for every data binding - never write ' +
128
+ '`mappings` through modifyComponentByRefid. The binding is stored on the SOURCE (the component that holds or receives the data, ' +
129
+ 'e.g. a data source, or the component itself). Give the target as targetRefid (an id is assigned to it if missing) or as a selector. ' +
130
+ 'rule "value" passes the value through; "map" translates exact values ({"ON":"#2e7d32","OFF":"#c62828","default":"#9e9e9e"}); ' +
131
+ '"range" maps numeric ranges written "from~to", open ends allowed ({"~50":"green","50~80":"orange","80~":"red"}); ' +
132
+ '"eval" runs a short expression over `value` - use only when map or range cannot express it. ' +
133
+ 'An existing binding with the same target and property on that source is replaced.',
134
+ schema: {
135
+ type: 'object',
136
+ properties: {
137
+ sourceRefid: { type: 'number', description: 'Component whose data is spread (where the binding is stored).' },
138
+ targetRefid: {
139
+ type: 'number',
140
+ description: 'Component that receives the value. Omit (or equal to sourceRefid) to bind into the source itself.'
141
+ },
142
+ targetSelector: {
143
+ type: 'string',
144
+ description: 'Instead of targetRefid: a selector for many targets, e.g. ".alarm" (class), "#line-3" (id), "(self)".'
145
+ },
146
+ property: {
147
+ type: 'string',
148
+ description: 'Property path on the target, e.g. "value", "text", "fillStyle", "strokeStyle", "hidden", "data", "rotation".'
149
+ },
150
+ accessor: {
151
+ type: 'string',
152
+ description: 'Path into the source data, e.g. "temperature" or "items[0].qty". Omit to pass the whole data.'
153
+ },
154
+ rule: { type: 'string', enum: RULES, description: 'How the value is turned into the property value. Default "value".' },
155
+ map: { type: 'object', description: 'rule "map": exact value -> result, optional "default".' },
156
+ range: { type: 'object', description: 'rule "range": "from~to" -> result, open ends allowed, optional "default".' },
157
+ eval: { type: 'string', description: 'rule "eval": a short JavaScript expression over `value` that returns the property value.' },
158
+ partial: { type: 'boolean', description: 'Merge an object value into the property instead of replacing it.' },
159
+ skipEmpty: { type: 'boolean', description: 'Do not spread empty values (null, undefined, "") - keeps the last value.' }
160
+ },
161
+ required: ['sourceRefid', 'property']
162
+ },
163
+ builder: (args: any, context: any) => {
164
+ const errors: string[] = []
165
+ const board = boardOf(context?.components)
166
+ const sourceRefid = args?.sourceRefid
167
+ const property = typeof args?.property === 'string' ? args.property.trim() : ''
168
+ const rule: Rule = RULES.includes(args?.rule) ? args.rule : 'value'
169
+
170
+ const source = typeof sourceRefid === 'number' ? findComponentByRefid(board, sourceRefid)?.component : undefined
171
+ if (!source) errors.push(`sourceRefid ${sourceRefid} is not a component on this board.`)
172
+ if (!property) errors.push('property is required (the property path on the target, e.g. "value").')
173
+ if (typeof args?.targetRefid === 'number' && args?.targetSelector) {
174
+ errors.push('Give either targetRefid or targetSelector, not both.')
175
+ }
176
+
177
+ let param: any = undefined
178
+ if (rule === 'map') {
179
+ if (!args?.map || typeof args.map !== 'object' || Array.isArray(args.map)) errors.push('rule "map" needs a "map" object.')
180
+ else param = args.map
181
+ } else if (rule === 'range') {
182
+ if (!args?.range || typeof args.range !== 'object' || Array.isArray(args.range))
183
+ errors.push('rule "range" needs a "range" object with "from~to" keys.')
184
+ else {
185
+ const bad = Object.keys(args.range).filter(k => k !== 'default' && !k.includes('~'))
186
+ if (bad.length) errors.push(`range keys must be written "from~to" (open ends allowed): ${bad.join(', ')}`)
187
+ param = args.range
188
+ }
189
+ } else if (rule === 'eval') {
190
+ if (typeof args?.eval !== 'string' || !args.eval.trim()) errors.push('rule "eval" needs an "eval" expression.')
191
+ else param = args.eval
192
+ }
193
+
194
+ const ops: any[] = []
195
+ let target = '(self)'
196
+
197
+ if (typeof args?.targetSelector === 'string' && args.targetSelector.trim()) {
198
+ target = args.targetSelector.trim()
199
+ } else if (typeof args?.targetRefid === 'number' && args.targetRefid !== sourceRefid) {
200
+ const found = findComponentByRefid(board, args.targetRefid)?.component as any
201
+ if (!found) {
202
+ errors.push(`targetRefid ${args.targetRefid} is not a component on this board.`)
203
+ } else if (found.id) {
204
+ target = `#${found.id}`
205
+ } else {
206
+ const taken = new Set(
207
+ collectAllComponents(board)
208
+ .map(f => (f.component as any)?.id)
209
+ .filter(Boolean)
210
+ )
211
+ const id = idFor(found, args.targetRefid, taken)
212
+ ops.push({ op: 'modify', refid: args.targetRefid, patch: { id } })
213
+ target = `#${id}`
214
+ }
215
+ }
216
+
217
+ if (errors.length) return { ops: [], errors }
218
+
219
+ const mapping: Mapping = {
220
+ target,
221
+ property,
222
+ rule,
223
+ ...(typeof args?.accessor === 'string' && args.accessor.trim() ? { accessor: args.accessor.trim() } : {}),
224
+ ...(param !== undefined ? { param } : {}),
225
+ ...(args?.partial ? { partial: true } : {}),
226
+ ...(args?.skipEmpty ? { ndnsp: true } : {})
227
+ }
228
+
229
+ const existing: Mapping[] = Array.isArray((source as any).mappings) ? (source as any).mappings : []
230
+ const same = existing.findIndex(m => (m?.target || '(self)').trim() === target && (m?.property || '') === property)
231
+ const mappings = same >= 0 ? existing.map((m, i) => (i === same ? mapping : m)) : [...existing, mapping]
232
+
233
+ ops.unshift({ op: 'modify', refid: sourceRefid, patch: { mappings } })
234
+ return { ops }
235
+ }
236
+ }
237
+
238
+ const unbindData: ToolSpec = {
239
+ kind: 'write',
240
+ name: 'unbindData',
241
+ doors: ['Mutation.updateBoard'],
242
+ description:
243
+ 'Remove a data binding from a source component - by its index (from getBindings) or by target selector and property. ' +
244
+ 'Leaves the target component unchanged.',
245
+ schema: {
246
+ type: 'object',
247
+ properties: {
248
+ sourceRefid: { type: 'number', description: 'Component the binding is stored on.' },
249
+ index: { type: 'number', description: 'Mapping index as returned by getBindings.' },
250
+ target: { type: 'string', description: 'Instead of index: the mapping target selector, e.g. "#gauge-12".' },
251
+ property: { type: 'string', description: 'Instead of index: the mapping property, with target.' }
252
+ },
253
+ required: ['sourceRefid']
254
+ },
255
+ builder: (args: any, context: any) => {
256
+ const board = boardOf(context?.components)
257
+ const source = typeof args?.sourceRefid === 'number' ? (findComponentByRefid(board, args.sourceRefid)?.component as any) : undefined
258
+ if (!source) return { ops: [], errors: [`sourceRefid ${args?.sourceRefid} is not a component on this board.`] }
259
+
260
+ const existing: Mapping[] = Array.isArray(source.mappings) ? source.mappings : []
261
+ let at = -1
262
+ if (typeof args?.index === 'number') at = args.index
263
+ else if (typeof args?.target === 'string' || typeof args?.property === 'string')
264
+ at = existing.findIndex(
265
+ m =>
266
+ (args.target === undefined || (m?.target || '(self)').trim() === String(args.target).trim()) &&
267
+ (args.property === undefined || m?.property === args.property)
268
+ )
269
+
270
+ if (at < 0 || at >= existing.length) {
271
+ return { ops: [], errors: [`No such binding on refid ${args.sourceRefid} (it has ${existing.length}). Call getBindings first.`] }
272
+ }
273
+
274
+ return { ops: [{ op: 'modify', refid: args.sourceRefid, patch: { mappings: existing.filter((_, i) => i !== at) } }] }
275
+ }
276
+ }
277
+
278
+ export const boardBindingToolsCategory: ToolCategory = {
279
+ name: 'board-binding',
280
+ description: 'Data binding on the open board: read, add, replace and remove mappings.',
281
+ guidance: [
282
+ 'Data binding rules:',
283
+ '- For any request to connect, bind, link, drive or "show" data on components, use bindData. Never write `mappings` with modifyComponentByRefid.',
284
+ '- Call getBindings before changing or explaining bindings, so you speak from what is on the board.',
285
+ '- A binding is stored on the SOURCE - the component whose data is spread (a data source, or the component itself). Pick the target by refid.',
286
+ '- Prefer rule "map" for states and "range" for thresholds; use "eval" only when neither can express it.',
287
+ '- After a bindData or unbindData call, say which source feeds which target property and with what rule. The person reviews and saves; do not say it is saved.'
288
+ ].join('\n'),
289
+ specs: [getBindings, bindData, unbindData]
290
+ }
291
+
292
+ // Registered on load, like board-import's category.
293
+ registerToolCategory(boardBindingToolsCategory)
@@ -18,6 +18,7 @@ import { DefaultBoardAIAssistant } from './assistant.js'
18
18
  import { effectiveBoardTools } from './board-tool-rights.js'
19
19
  import type { LLMMessage } from './types.js'
20
20
  import { ChatSession } from '@things-factory/ai-assistant'
21
+ import { resolveModeSettings, requestTranslator, type ModeSettings } from '@things-factory/ai-assistant'
21
22
  import { ChatMessage } from '@things-factory/ai-assistant'
22
23
  import { buildLlmHistory } from '@things-factory/ai-assistant'
23
24
  import { splitByCap, summaryInstruction } from '@things-factory/ai-assistant'
@@ -75,6 +76,15 @@ class BoardAIMentionPickInput {
75
76
  refid: number
76
77
  }
77
78
 
79
+ @InputType({ description: 'Which registered conversation this board chat is, and what it is about.' })
80
+ export class BoardAIModeInput {
81
+ @Field({ description: 'A mode kind the app server registered, e.g. "board.modeller".' })
82
+ kind: string
83
+
84
+ @Field({ nullable: true, description: 'What the conversation is about, e.g. the board id.' })
85
+ target?: string
86
+ }
87
+
78
88
  @InputType()
79
89
  class BoardAIChatInput {
80
90
  @Field({
@@ -112,6 +122,13 @@ class BoardAIChatInput {
112
122
  })
113
123
  truncateAfterMessageId?: string
114
124
 
125
+ @Field(() => BoardAIModeInput, {
126
+ nullable: true,
127
+ description:
128
+ 'The registered conversation this is (ADR-0061 decision 7). When given, the server takes the persona, tool categories, model and limits from the mode the app registered, and ignores toolCategories / model / temperature / maxTokens / requireGroundingTools sent by the caller. Omit for the older behaviour.'
129
+ })
130
+ mode?: BoardAIModeInput
131
+
115
132
  @Field(() => [String], {
116
133
  nullable: true,
117
134
  description:
@@ -287,6 +304,15 @@ export class BoardAIChatResolver {
287
304
  )
288
305
  }
289
306
 
307
+ /*
308
+ * A declared conversation decides its own authority (ADR-0061 decision 7): with a mode, the
309
+ * categories, model and limits come from what the app server registered, not from the request.
310
+ * An unknown kind is refused here, before anything is saved, in the person's language.
311
+ */
312
+ const modeSettings: ModeSettings | undefined = input.mode?.kind
313
+ ? resolveModeSettings({ kind: input.mode.kind, target: input.mode.target }, requestTranslator(context))
314
+ : undefined
315
+
290
316
  const { domain, user } = context.state
291
317
  const sessionRepo = getRepository(ChatSession)
292
318
  const messageRepo = getRepository(ChatMessage)
@@ -416,18 +442,19 @@ export class BoardAIChatResolver {
416
442
  const r = await assistant.chat(llmMessages, input.currentBoard ?? undefined, {
417
443
  scopes: input.scopes,
418
444
  hostContext: input.hostContext ?? undefined,
419
- /* 대화면별 도구 노출 — 미지정이면 현행(전부 허용) 그대로. */
420
- toolCategories: input.toolCategories ?? undefined,
445
+ surfacePrompt: modeSettings?.systemPrompt,
446
+ /* 대화면별 도구 노출 — 모드가 있으면 서버 선언(빈 목록 = 외부 도구 없음), 없으면 호출자 값(미지정 = 전부). */
447
+ toolCategories: modeSettings ? modeSettings.toolCategories ?? [] : input.toolCategories ?? undefined,
421
448
  boardTools: await effectiveBoardTools(input.boardTools, context),
422
- requireGroundingTools: input.requireGroundingTools ?? undefined,
449
+ requireGroundingTools: modeSettings ? modeSettings.requireToolOnFirstTurn : input.requireGroundingTools ?? undefined,
423
450
  knownTypes: input.knownTypes,
424
451
  categories: input.categories as ComponentCategory[] | undefined,
425
452
  selectedRefids: input.selectedRefids,
426
453
  mentionPicks: input.mentions,
427
454
  componentSchemas: Array.isArray(input.componentSchemas) ? input.componentSchemas : undefined,
428
- model: input.model,
429
- temperature: input.temperature ?? undefined,
430
- maxTokens: input.maxTokens ?? undefined,
455
+ model: modeSettings ? modeSettings.model : input.model,
456
+ temperature: modeSettings ? modeSettings.temperature : input.temperature ?? undefined,
457
+ maxTokens: modeSettings ? modeSettings.maxTokens : input.maxTokens ?? undefined,
431
458
  // 외부 도메인 (board-import 등) 의 read/external tool builder 가 server-side
432
459
  // 의존성 (도메인 / 유저 / 트랜잭션) 을 사용할 수 있도록 ResolverContext.state 전달.
433
460
  // board-ai 코어 자체는 이 값을 알지 않고 dispatch 단계에서 closure 로만 흘림.
@@ -21,6 +21,7 @@ export * from './mention-resolver.js'
21
21
  export * from './catalog-resolver.js'
22
22
  export * from './board-ai-resolver.js'
23
23
  export * from './patch-entry/index.js'
24
+ export * from './binding-tools.js'
24
25
 
25
26
  /* 세션 계열 엔티티는 `ai-assistant` 가 낸다 — 프레임워크가 의존을 타고 재귀로 모은다. */
26
27
  export const entities = [...PatchEntryEntities]
@@ -19,6 +19,12 @@ import type { LLMMessage } from '@things-factory/ai-assistant'
19
19
  export type { LLMMessage }
20
20
 
21
21
  export interface ChatOptions {
22
+ /**
23
+ * Who the assistant is on this surface — a registered mode's `systemPrompt` (ADR-0061 decision 7).
24
+ * Added after board-ai's own authoring instructions, which stay: they describe the board document
25
+ * and its tools, and the mode describes the product and the person's work.
26
+ */
27
+ surfacePrompt?: string
22
28
  /**
23
29
  * 이 대화에 노출할 **외부 도구 카테고리** 화이트리스트(registerToolCategory 이름).
24
30
  * 미지정 = 등록된 전부(현행 동작). 성격이 다른 대화면(보드 저작 도크 · 운영 도크)이
@@ -10,7 +10,7 @@
10
10
  * 이게 silent 데이터 손상을 막는 마지막 능동 방어선. 검증 통과한 args 만 누적
11
11
  * → patch 로 변환되므로 깨진 데이터가 영속까지 못 감.
12
12
  */
13
- import type { AIToolCall } from '@things-factory/ai-client-base'
13
+ import { findToolSpec, type AIToolCall } from '@things-factory/ai-client-base'
14
14
  import {
15
15
  validateComponentPatch,
16
16
  validateComponent,
@@ -181,8 +181,21 @@ export function validateWriteToolCall(
181
181
  }
182
182
 
183
183
  // 그 외 — 기존 dispatcher 검증에 위임
184
- default:
184
+ default: {
185
+ /*
186
+ * A write tool registered by another category (e.g. board-binding) checks its own arguments
187
+ * and says what is wrong in `errors`. Its ops were dropped with the errors unread, so the model
188
+ * heard nothing and told the person the edit was made. Return them as a validation failure.
189
+ */
190
+ const spec = findToolSpec(tc.name)
191
+ if (spec && spec.kind === 'write') {
192
+ const r: any = (spec.builder as any)(args, { components })
193
+ if (r && !Array.isArray(r) && Array.isArray(r.errors) && r.errors.length > 0) {
194
+ return { valid: false, errors: r.errors }
195
+ }
196
+ }
185
197
  return { valid: true }
198
+ }
186
199
  }
187
200
  }
188
201