@things-factory/ai-assistant 10.1.167 → 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.167",
3
+ "version": "10.1.168",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,7 +34,7 @@
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.167",
37
+ "@things-factory/ai-client-base": "^10.1.168",
38
38
  "@things-factory/apptool-base": "^10.1.165",
39
39
  "@things-factory/attachment-base": "^10.1.167",
40
40
  "@things-factory/auth-base": "^10.1.167",
@@ -54,5 +54,5 @@
54
54
  "copyfiles": "^2.4.1",
55
55
  "rimraf": "^5.0.0"
56
56
  },
57
- "gitHead": "86283c5d35042880be6827f777067fc5e6417eed"
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`). 이 문은 그 셋을 넘기지 않아, **사람이 방금 타이핑한
@@ -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
+ }