@things-factory/ai-assistant 10.1.165 → 10.1.168

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.165",
3
+ "version": "10.1.168",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,12 +34,12 @@
34
34
  "@operato/popup": "^10.16.0",
35
35
  "@operato/shell": "^10.0.0",
36
36
  "@operato/styles": "^10.0.0",
37
- "@things-factory/ai-client-base": "^10.1.165",
37
+ "@things-factory/ai-client-base": "^10.1.168",
38
38
  "@things-factory/apptool-base": "^10.1.165",
39
- "@things-factory/attachment-base": "^10.1.165",
40
- "@things-factory/auth-base": "^10.1.165",
39
+ "@things-factory/attachment-base": "^10.1.167",
40
+ "@things-factory/auth-base": "^10.1.167",
41
41
  "@things-factory/env": "^10.1.149",
42
- "@things-factory/setting-base": "^10.1.165",
42
+ "@things-factory/setting-base": "^10.1.167",
43
43
  "@things-factory/shell": "^10.1.165",
44
44
  "dompurify": "^3.0.0",
45
45
  "exceljs": "4.2.0",
@@ -54,5 +54,5 @@
54
54
  "copyfiles": "^2.4.1",
55
55
  "rimraf": "^5.0.0"
56
56
  },
57
- "gitHead": "d9e3774d70561b063e4f1d10c387caf721d99b61"
57
+ "gitHead": "fe067ff11d90effffc2523374453d9b8313b489f"
58
58
  }
@@ -50,6 +50,7 @@ import { isSessionParticipant } from './chat-session-participant/participant-loo
50
50
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
51
51
  import { attachmentHistoryLine, refuseAttachments, textCharsOf } from './chat-session/attachment-policy.js'
52
52
  import { attachmentForModel, describeAttachment, findChatAttachment } from './chat-session/session-attachments.js'
53
+ import { readTurnAttachments } from './chat-session/turn-attachments.js'
53
54
  import { getAssistantMode } from './assistant-mode/registry.js'
54
55
  import { resolveModeSettings } from './assistant-mode/resolve.js'
55
56
  import { splitNextRequest } from './assistant-mode/screen-rules.js'
@@ -468,22 +469,20 @@ export class AssistantChatResolver {
468
469
  )
469
470
  : input.messages.map(message => ({ role: message.role as any, content: message.content }))
470
471
 
472
+ /*
473
+ * The files checked above, read once: the model reads them on this turn's question, and the tools get the same
474
+ * files as `context.attachments` (ADR-0061, 2026-10-16 decision 2) - figure's step generator draws from the
475
+ * picture the person sent. Never mixed into `hostContext`, which the client fills.
476
+ */
477
+ const turnFiles = await readTurnAttachments(
478
+ attachmentRows,
479
+ textCharsOf(getAssistantMode(input.mode?.kind ?? '')?.attachments),
480
+ attachmentForModel
481
+ )
471
482
  if (attachmentRows.length && conversation.length > 0) {
472
483
  const last = conversation[conversation.length - 1]
473
484
  if (last.role === 'user' && typeof last.content === 'string') {
474
- const parts: any[] = [{ type: 'text', text: last.content }]
475
- for (const row of attachmentRows) {
476
- const file = await attachmentForModel(row, textCharsOf(getAssistantMode(input.mode?.kind ?? '')?.attachments))
477
- if (file.type === 'image') {
478
- parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
479
- } else {
480
- parts.push({
481
- type: 'text',
482
- text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
483
- })
484
- }
485
- }
486
- last.content = parts as any
485
+ last.content = [{ type: 'text', text: last.content }, ...turnFiles.parts] as any
487
486
  }
488
487
  } else if (input.hostContext?.image?.data && conversation.length > 0) {
489
488
  /* The screen-image path (figure's rendered view) - not a file of the conversation. */
@@ -510,6 +509,7 @@ export class AssistantChatResolver {
510
509
  /* The stop notice is a sentence a person reads — the server writes it in their language (ADR-0054). */
511
510
  t,
512
511
  hostContext: input.hostContext,
512
+ attachments: turnFiles.forTools,
513
513
  /*
514
514
  * 근거는 도구 결과만이 아니다 — 시스템 프롬프트 · 이 대화의 말 · 화면이 보낸 스냅숏도 근거다
515
515
  * (`ai-client-base/utils/grounding`). 이 문은 그 셋을 넘기지 않아, **사람이 방금 타이핑한
@@ -63,7 +63,7 @@ export function getAssistantModeWarnings(): ReadonlyArray<AssistantModeWarning>
63
63
 
64
64
  /*
65
65
  * 한 화면에 대화 하나 — design/ai/screen-pattern.md §3. 같은 subject 를 두 대화가 적으면 화면은 어느 쪽을
66
- * 열지 모르고, 고르개로 사람에게 넘기면 원칙 3 을 어긴다. 성격이 다른 물음은 그 대화가 도구를 더 연다.
66
+ * 열지 모르고, 선택기로 사람에게 넘기면 원칙 3 을 어긴다. 성격이 다른 물음은 그 대화가 도구를 더 연다.
67
67
  */
68
68
  const owners = new Map<string, string[]>()
69
69
  for (const mode of modes.values()) {
@@ -0,0 +1,106 @@
1
+ /*
2
+ * The files of this turn, read once for the model and for the tools (ADR-0061, 2026-10-16 decision 2).
3
+ *
4
+ * Before this the door put the files on the model's last message only and handed the tools the client's
5
+ * `hostContext`. A tool that needed the picture - figure's inner step generator - never got it: the dock uploads by
6
+ * `attachmentIds` and leaves `hostContext.image` empty.
7
+ */
8
+ import { clearToolRegistry, registerToolCategory, registryAssistantChat } from '@things-factory/ai-client-base'
9
+
10
+ import { readTurnAttachments } from './turn-attachments'
11
+
12
+ const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47])
13
+ const JPG_BYTES = Buffer.from([0xff, 0xd8, 0xff])
14
+
15
+ const rows = [
16
+ { id: 'att-1', name: 'front.png', mimetype: 'image/png', size: '4' },
17
+ { id: 'att-2', name: 'notes.txt', mimetype: 'text/plain', size: 12 },
18
+ { id: 'att-3', name: 'side.jpg', mimetype: 'image/jpeg', size: 3 }
19
+ ]
20
+
21
+ /* Storage stand-in: what attachmentForModel answers for each row. */
22
+ const read = async (row: any, maxChars: number) => {
23
+ if (row.id === 'att-1') return { type: 'image' as const, data: PNG_BYTES, mediaType: 'image/png', name: row.name }
24
+ if (row.id === 'att-3') return { type: 'image' as const, data: JPG_BYTES, mediaType: 'image/jpeg', name: row.name }
25
+ return { type: 'text' as const, text: 'twelve chars'.slice(0, maxChars), name: row.name, truncated: maxChars < 12 }
26
+ }
27
+
28
+ describe('the files of this turn', () => {
29
+ it('★ the tools get every file as the contract shapes it, in the order the person attached them', async () => {
30
+ const { forTools } = await readTurnAttachments(rows, 20_000, read)
31
+
32
+ expect(forTools).toEqual([
33
+ { id: 'att-1', name: 'front.png', kind: 'image', mediaType: 'image/png', size: 4, data: PNG_BYTES },
34
+ { id: 'att-2', name: 'notes.txt', kind: 'text', mediaType: 'text/plain', size: 12, text: 'twelve chars', truncated: false },
35
+ { id: 'att-3', name: 'side.jpg', kind: 'image', mediaType: 'image/jpeg', size: 3, data: JPG_BYTES }
36
+ ])
37
+ })
38
+
39
+ it('the model reads the same files - an image as bytes, a text file as its text with the cut said', async () => {
40
+ const { parts } = await readTurnAttachments(rows, 6, read)
41
+
42
+ expect(parts).toEqual([
43
+ { type: 'image', source: { kind: 'base64', data: PNG_BYTES, mediaType: 'image/png' } },
44
+ { type: 'text', text: '[attachment att-2: notes.txt - cut, the file is longer]\ntwelve' },
45
+ { type: 'image', source: { kind: 'base64', data: JPG_BYTES, mediaType: 'image/jpeg' } }
46
+ ])
47
+ })
48
+
49
+ it('a text file cut for the model is cut for the tools too, and says so', async () => {
50
+ const { forTools } = await readTurnAttachments([rows[1]], 6, read)
51
+
52
+ expect(forTools).toEqual([expect.objectContaining({ kind: 'text', text: 'twelve', truncated: true })])
53
+ })
54
+
55
+ it('no files: nothing for either', async () => {
56
+ expect(await readTurnAttachments([], 20_000, read)).toEqual({ parts: [], forTools: [] })
57
+ })
58
+ })
59
+
60
+ describe('the files reach a registered tool through the door', () => {
61
+ afterEach(() => clearToolRegistry())
62
+
63
+ it('★ what the door read is what the tool is handed - in its own field, not in host', async () => {
64
+ const handed: any[] = []
65
+ registerToolCategory({
66
+ name: 'drawing',
67
+ guidance: '- Call proposeDrawing for a drawing.',
68
+ specs: [
69
+ {
70
+ kind: 'read',
71
+ name: 'proposeDrawing',
72
+ description: 'proposes a drawing',
73
+ schema: { type: 'object', properties: {} },
74
+ doors: [],
75
+ builder: (_args: any, context: any) => {
76
+ handed.push(context)
77
+ return { ok: true }
78
+ }
79
+ } as any
80
+ ]
81
+ })
82
+ const { forTools } = await readTurnAttachments(rows, 20_000, read)
83
+ let turn = 0
84
+ const client: any = {
85
+ chatWithTools: async () =>
86
+ turn++ === 0
87
+ ? { toolCalls: [{ id: 'c1', name: 'proposeDrawing', arguments: {}, type: 'tool_use' }], stopReason: 'tool_use' }
88
+ : { text: 'done', toolCalls: [], stopReason: 'end_turn' }
89
+ }
90
+
91
+ await registryAssistantChat({
92
+ client,
93
+ messages: [{ role: 'user', content: 'make it like the pictures' }],
94
+ systemPrompt: 'draw',
95
+ toolCategories: ['drawing'],
96
+ toolAccess: async () => true,
97
+ state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
98
+ hostContext: { figureId: 'fig-1' },
99
+ attachments: forTools
100
+ })
101
+
102
+ expect(handed).toHaveLength(1)
103
+ expect(handed[0].attachments.filter((file: any) => file.kind === 'image').map((file: any) => file.data)).toEqual([PNG_BYTES, JPG_BYTES])
104
+ expect(handed[0].host).toEqual({ figureId: 'fig-1' })
105
+ })
106
+ })
@@ -0,0 +1,45 @@
1
+ /*
2
+ * The files sent with this turn, read once: as the parts the model reads after the question, and as the tools'
3
+ * `context.attachments` (ADR-0061, 2026-10-16 decision 2).
4
+ *
5
+ * The door checked them before this - each is stored with this conversation, the caller may write to it, the mode's
6
+ * policy took it. Only the door builds the tools' list; what a client puts in `hostContext` never becomes one.
7
+ *
8
+ * Storage is the caller's: it passes `attachmentForModel`, so the rule is tested without a file store.
9
+ */
10
+ import type { ToolAttachment } from '@things-factory/ai-client-base'
11
+
12
+ export type ReadAttachment = (
13
+ row: any,
14
+ maxChars: number
15
+ ) => Promise<
16
+ | { type: 'image'; data: Buffer; mediaType: string; name: string }
17
+ | { type: 'text'; text: string; name: string; truncated: boolean }
18
+ >
19
+
20
+ export interface TurnAttachments {
21
+ /** What the model reads after the question's text, one part per file. */
22
+ parts: any[]
23
+ /** The same files for the tools, in the order the person attached them. */
24
+ forTools: ToolAttachment[]
25
+ }
26
+
27
+ export async function readTurnAttachments(rows: any[], maxChars: number, read: ReadAttachment): Promise<TurnAttachments> {
28
+ const parts: any[] = []
29
+ const forTools: ToolAttachment[] = []
30
+ for (const row of rows) {
31
+ const file = await read(row, maxChars)
32
+ const stored = { id: row.id, name: row.name, mediaType: row.mimetype, size: Number(row.size) || 0 }
33
+ if (file.type === 'image') {
34
+ parts.push({ type: 'image', source: { kind: 'base64', data: file.data, mediaType: file.mediaType } })
35
+ forTools.push({ ...stored, kind: 'image', data: file.data })
36
+ } else {
37
+ parts.push({
38
+ type: 'text',
39
+ text: `[attachment ${row.id}: ${file.name}${file.truncated ? ' - cut, the file is longer' : ''}]\n${file.text}`
40
+ })
41
+ forTools.push({ ...stored, kind: 'text', text: file.text, truncated: file.truncated })
42
+ }
43
+ }
44
+ return { parts, forTools }
45
+ }
@@ -82,7 +82,7 @@
82
82
  "ai-assistant.notice.provider-busy": "AI 서비스가 지금 응답하지 않습니다. 잠시 뒤에 다시 해 보세요.",
83
83
  "ai-assistant.notice.provider-error": "처리 중에 문제가 생겼습니다. 같은 문제가 계속되면 관리자에게 알려 주세요.",
84
84
  "ai-assistant.notice.too-long": "조회 {iter}번 안에 답을 만들지 못하고 멈췄습니다.",
85
- "ai-assistant.notice.tool-kept-failing": "{tool} 호출이 {count}번 잇따라 실패해서 멈췄습니다. 다르게 말해 보세요.",
85
+ "ai-assistant.notice.tool-kept-failing": "{tool} 호출이 연속 {count}회 실패하여 중단했습니다. 요청 내용을 구체적으로 입력하여 다시 시도해 주세요.",
86
86
  "ai-assistant.text.no-match": "맞는 것이 없습니다",
87
87
  "ai-assistant.text.messages-unreadable": "이 대화를 읽지 못했습니다.",
88
88
  "ai-assistant.text.sessions-unreadable": "대화 목록을 불러오지 못했습니다.",
@@ -118,7 +118,7 @@
118
118
  "ai-assistant.dock.proposal-result-uncheckable": "이 화면에서는 결과를 확인할 수 없습니다. 적용한 화면에서 확인하세요.",
119
119
  "ai-assistant.dock.proposal-result-unknown": "결과를 아직 알 수 없습니다. 다시 적용하지 않았습니다.",
120
120
  "ai-assistant.text.proposal-applied-count": " {total} 중 {applied}",
121
- "ai-assistant.text.say-to-room-tooltip": "여기 있는 사람들에게 말합니다. AI 는 답하지 않습니다",
121
+ "ai-assistant.text.say-to-room-tooltip": "대화 참여자에게 메시지를 보냅니다. AI는 응답하지 않습니다.",
122
122
  "ai-assistant.text.no-room-yet": "아직 저장되지 않은 대화라 같이 있는 사람이 없습니다",
123
123
  "ai-assistant.text.will-be-invited": "전송하면 {names} 님이 이 대화에 초대됩니다.",
124
124
  "ai-assistant.text.cannot-invite": "{names} 은 들어오지 않습니다. 이 대화에 있는 사람만 다른 사람을 부를 수 있습니다",
@@ -153,7 +153,7 @@
153
153
  "ai-assistant.session.visibility-refused": "이 대화의 공개 범위는 대화의 주인만 바꿀 수 있습니다.",
154
154
  "ai-assistant.session.remove-refused": "이 대화를 삭제하지 못했습니다.",
155
155
  "ai-assistant.text.attachments-not-accepted": "이 대화는 첨부를 받지 않습니다.",
156
- "ai-assistant.text.attachment-kind-not-accepted": "{name} 은 여기에 첨부할 수 없습니다. 이 대화는 {kinds} 를 받습니다.",
156
+ "ai-assistant.text.attachment-kind-not-accepted": "{name} 파일은 첨부할 수 없습니다. 지원하는 첨부 유형: {kinds}.",
157
157
  "ai-assistant.text.attachment-too-large": "{name} 은 {max} MB 보다 큽니다.",
158
158
  "ai-assistant.text.too-many-attachments": "한 번에 {max} 개까지 보낼 수 있습니다.",
159
159
  "ai-assistant.text.attachments-need-session": "첨부는 저장된 대화에서만 보낼 수 있습니다.",
@@ -318,7 +318,7 @@
318
318
  "ai-assistant.agent.this-domain": "이 사업장",
319
319
  "ai-assistant.agent.status-on": "이 사업장의 AI: 사용 가능 · 쓸 수 있는 에이전트 {count} · 기본 {name}",
320
320
  "ai-assistant.agent.status-no-key": "API 키가 있는 에이전트가 없어 이 사업장에서 AI 를 사용할 수 없습니다.",
321
- "ai-assistant.agent.status-all-off": "모든 에이전트가 꺼져 있어 이 사업장에서 AI 를 사용할 수 없습니다.",
321
+ "ai-assistant.agent.status-all-off": "모든 에이전트가 비활성화되어 이 사업장에서 AI를 사용할 수 없습니다.",
322
322
  "ai-assistant.agent.status-no-agent": "AI 에이전트가 없어 이 사업장에서 AI 를 사용할 수 없습니다.",
323
323
  "ai-assistant.agent.default": "기본",
324
324
  "ai-assistant.agent.default-hint": "선택 전에 쓰이는 에이전트",