@things-factory/ai-assistant 10.1.207 → 10.1.208

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 (43) hide show
  1. package/client/setup-assistant-dock.ts +1 -0
  2. package/dist-client/setup-assistant-dock.js +1 -0
  3. package/dist-client/setup-assistant-dock.js.map +1 -1
  4. package/dist-client/tsconfig.tsbuildinfo +1 -1
  5. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  6. package/dist-server/service/assistant-mode/index.js +3 -1
  7. package/dist-server/service/assistant-mode/index.js.map +1 -1
  8. package/dist-server/service/assistant-mode/screen-rules.d.ts +13 -0
  9. package/dist-server/service/assistant-mode/screen-rules.js +32 -5
  10. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  11. package/dist-server/service/data-entry/entry-target.d.ts +13 -0
  12. package/dist-server/service/data-entry/entry-target.js +20 -5
  13. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  14. package/dist-server/service/data-entry/entry-tools.js +2 -2
  15. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  16. package/dist-server/service/data-entry/entry-update.d.ts +2 -1
  17. package/dist-server/service/data-entry/entry-update.js +4 -2
  18. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  19. package/dist-server/service/data-read/read-target.d.ts +2 -0
  20. package/dist-server/service/data-read/read-target.js +2 -2
  21. package/dist-server/service/data-read/read-target.js.map +1 -1
  22. package/dist-server/service/data-read/read-tools.js +1 -1
  23. package/dist-server/service/data-read/read-tools.js.map +1 -1
  24. package/dist-server/service/testing/mode-conformance.js +2 -0
  25. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +9 -9
  28. package/server/explicit-graphql-types.test.ts +1 -1
  29. package/server/service/assistant-mode/index.ts +1 -1
  30. package/server/service/assistant-mode/resolve.test.ts +2 -1
  31. package/server/service/assistant-mode/screen-rules.test.ts +35 -3
  32. package/server/service/assistant-mode/screen-rules.ts +34 -4
  33. package/server/service/chat-message/fold-history-window.test.ts +1 -1
  34. package/server/service/data-entry/data-entry-update.test.ts +11 -0
  35. package/server/service/data-entry/data-entry.test.ts +65 -1
  36. package/server/service/data-entry/entry-target.ts +30 -5
  37. package/server/service/data-entry/entry-tools.ts +2 -2
  38. package/server/service/data-entry/entry-update.ts +5 -2
  39. package/server/service/data-read/read-target.ts +5 -3
  40. package/server/service/data-read/read-tools.ts +1 -1
  41. package/server/service/data-read/record-keys.test.ts +36 -0
  42. package/server/service/testing/mode-conformance.ts +1 -0
  43. package/translations/ko.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.207",
3
+ "version": "10.1.208",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,18 +30,18 @@
30
30
  "@operato/graphql": "^10.29.39",
31
31
  "@operato/i18n": "^10.0.0",
32
32
  "@operato/input": "^10.29.39",
33
- "@operato/layout": "^10.29.39",
33
+ "@operato/layout": "^10.29.49",
34
34
  "@operato/popup": "^10.29.39",
35
35
  "@operato/shell": "^10.29.39",
36
36
  "@operato/styles": "^10.0.0",
37
37
  "@operato/utils": "^10.29.39",
38
- "@things-factory/ai-client-base": "^10.1.207",
39
- "@things-factory/apptool-base": "^10.1.206",
40
- "@things-factory/attachment-base": "^10.1.206",
41
- "@things-factory/auth-base": "^10.1.206",
38
+ "@things-factory/ai-client-base": "^10.1.208",
39
+ "@things-factory/apptool-base": "^10.1.208",
40
+ "@things-factory/attachment-base": "^10.1.208",
41
+ "@things-factory/auth-base": "^10.1.208",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.206",
44
- "@things-factory/shell": "^10.1.206",
43
+ "@things-factory/setting-base": "^10.1.208",
44
+ "@things-factory/shell": "^10.1.208",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
@@ -55,5 +55,5 @@
55
55
  "copyfiles": "^2.4.1",
56
56
  "rimraf": "^5.0.0"
57
57
  },
58
- "gitHead": "e6e3fbf5c353629adbeb8850093150b14254e059"
58
+ "gitHead": "ffc1ceaa9471b9f4a9b86f46c3021f2fed72d17d"
59
59
  }
@@ -8,7 +8,7 @@
8
8
  * 컴파일은 통과하므로 타입검사도 못 잡는다. plant 레인이 설치본을 올리다 잡아 알려 주었다(2026-09-18).
9
9
  *
10
10
  * ── 왜 스키마를 안 세우고 글자를 읽나 ─────────────────────────────────────
11
- * 이 꾸러미의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
11
+ * 이 패키지의 시험기는 babel 이라 데코레이터 metadata 가 없다. 스키마를 세우면 리플렉션이 없어 전부
12
12
  * 같은 오류가 나고, 그러면 이 시험은 아무것도 못 가른다. 대신 선언을 읽는다 — 데코레이터가 형을
13
13
  * 인자로 받고 있는지만 본다. 그것이 빠진 자리가 정확히 터지는 자리다.
14
14
  */
@@ -12,6 +12,6 @@ export {
12
12
  clearAssistantModes
13
13
  } from './registry.js'
14
14
  export { resolveModeSettings, assistantModeView } from './resolve.js'
15
- export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
15
+ export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
16
16
  export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
17
17
  export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { clearAssistantModes, registerAssistantMode } from './registry'
10
10
  import { assistantModeView, resolveModeSettings } from './resolve'
11
- import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
11
+ import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt } from './screen-rules'
12
12
 
13
13
  /** Stands in for `context.t` — returns the key and params so the test can see what was translated. */
14
14
  const t = (key: string, params?: Record<string, any>) => `t(${key}${params?.count !== undefined ? `,${params.count}` : ''})`
@@ -48,6 +48,7 @@ it('★ every conversation carries the absence rule and the next-request rule on
48
48
  expect(systemPrompt.startsWith('you answer about one space')).toBe(true)
49
49
  expect(systemPrompt.split(ABSENCE_RULE)).toHaveLength(2)
50
50
  expect(systemPrompt.split(NEXT_REQUEST_RULE)).toHaveLength(2)
51
+ expect(systemPrompt.split(FINISH_RULE)).toHaveLength(2)
51
52
  })
52
53
 
53
54
  it('★ an unregistered kind is refused — no plain-conversation fallback', () => {
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * The rules every conversation follows (operato-application design/ai/screen-pattern.md §4).
3
3
  */
4
- import { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules'
4
+ import { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules'
5
5
 
6
6
  describe('the next request an answer names', () => {
7
7
  it('★ the last line is taken off the reply and becomes the suggestion', () => {
@@ -24,16 +24,48 @@ describe('the next request an answer names', () => {
24
24
  expect(splitNextRequest('It has 3 blades.')).toEqual({ reply: 'It has 3 blades.' })
25
25
  })
26
26
 
27
+ /* 2026-10-23, an office link: the input offered a sentence telling the person what to say, with the invoice's key in it. */
28
+ it('★ a line that tells the person what to say is taken off and offered as nothing', () => {
29
+ const answer = '계산서 연결 후보를 찾았습니다.'
30
+ const line = 'Next: "2026-01-20 계산서(tax-invoice:fe0e8503-ec5f-4da4-9c07-2d342c93d498)로 연결 제안 진행해줘”라고 해줘.'
31
+ expect(splitNextRequest(`${answer}\n${line}`)).toEqual({ reply: answer })
32
+ expect(splitNextRequest(`${answer}\nNext: "연결 진행해줘"라고 말해 주세요`)).toEqual({ reply: answer })
33
+ expect(splitNextRequest(`${answer}\nNext: 「연결해 줘」라고 해 줘`)).toEqual({ reply: answer })
34
+ })
35
+
36
+ it('★ a line that carries an id or a record key is no request - nobody types one', () => {
37
+ expect(splitNextRequest('Found it.\nNext: tax-invoice:fe0e8503-ec5f 로 연결해 줘')).toEqual({ reply: 'Found it.' })
38
+ expect(splitNextRequest('Found it.\nNext: fe0e8503-ec5f-4da4-9c07-2d342c93d498 계산서로 연결해 줘')).toEqual({ reply: 'Found it.' })
39
+ })
40
+
41
+ it('a request in the person\'s words is offered, apostrophes and numbers included', () => {
42
+ expect(isPersonRequest("Show me last month's deposits")).toBe(true)
43
+ expect(isPersonRequest('2026-01 승화푸드 입금 550,000원도 연결해 줘')).toBe(true)
44
+ expect(isPersonRequest('')).toBe(false)
45
+ })
46
+
27
47
  it('a reply that is only the line keeps it - an empty answer is worse than an odd one', () => {
28
48
  expect(splitNextRequest('Next: 날개를 늘려 줘')).toEqual({ reply: 'Next: 날개를 늘려 줘' })
29
49
  })
30
50
  })
31
51
 
32
52
  describe('the prompt every conversation gets', () => {
33
- it('★ the persona first, then the absence rule and the next-request rule', () => {
53
+ it('★ the persona first, then the absence rule, the finish rule and the next-request rule', () => {
34
54
  const prompt = conversationPrompt('You edit figures.')
35
55
  expect(prompt.indexOf('You edit figures.')).toBe(0)
36
56
  expect(prompt.indexOf(ABSENCE_RULE)).toBeGreaterThan(0)
37
- expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
57
+ expect(prompt.indexOf(FINISH_RULE)).toBeGreaterThan(prompt.indexOf(ABSENCE_RULE))
58
+ expect(prompt.indexOf(NEXT_REQUEST_RULE)).toBeGreaterThan(prompt.indexOf(FINISH_RULE))
59
+ })
60
+
61
+ /* What the two rules must keep saying - a softer wording let the model ask before a card and hand the person a script. */
62
+ it('★ a card is proposed in the turn, never asked about; the next request is the person\'s own words', () => {
63
+ expect(FINISH_RULE).toMatch(/in the same turn/)
64
+ expect(FINISH_RULE).toMatch(/never ask whether to propose it/)
65
+ expect(FINISH_RULE).toMatch(/never ask the person to confirm, repeat or resend/)
66
+ expect(FINISH_RULE).toMatch(/no internal ids, keys, field names or codes/)
67
+ expect(NEXT_REQUEST_RULE).toMatch(/no quotation marks/)
68
+ expect(NEXT_REQUEST_RULE).toMatch(/Never a yes to your own question/)
69
+ expect(NEXT_REQUEST_RULE).toMatch(/no ids or keys/)
38
70
  })
39
71
  })
@@ -13,18 +13,47 @@ export const ABSENCE_RULE =
13
13
  'When a tool says something is absent or refuses, state that and what the person can do. ' +
14
14
  'Do not guess why, and do not describe our machinery - words like passed, delivered or loaded name our plumbing, not their situation.'
15
15
 
16
+ /**
17
+ * §4-3 — a request is finished in the turn it is asked (2026-10-23, the user: 「한번에 끝낼 일을 왜 자꾸 확인하고, 프롬프트를
18
+ * 또 입력하게 만드는가」). Asked to link a deposit, the model found the invoice, then asked whether to propose the link
19
+ * and offered 「…진행해줘"라고 해줘」 as the next request - a second turn for what the first could do. A proposal card is
20
+ * the person's review; asking before it is asking twice.
21
+ */
22
+ export const FINISH_RULE =
23
+ 'Finish what was asked in this turn: read what it needs and, when it changes records, propose the card in the same turn - ' +
24
+ 'the card is the person\'s review, so never ask whether to propose it, and never ask the person to confirm, repeat or ' +
25
+ 'resend the request. Do not ask for an id, a number or a name your tools can read. Ask only when the records leave a ' +
26
+ 'choice that only the person can make, and then ask just that choice. Answer briefly: what was found and what the card ' +
27
+ 'does, in the person\'s words - no internal ids, keys, field names or codes.'
28
+
16
29
  /** The line a reply ends with when a next request is natural (§4-1). */
17
30
  const NEXT_PREFIX = 'Next:'
18
31
 
19
32
  /** §4-1 — the reply names the next request, and the input offers it. */
20
33
  export const NEXT_REQUEST_RULE =
21
34
  `After answering, when there is a natural next request, end the reply with one last line "${NEXT_PREFIX} <request>": ` +
22
- "the request the person is most likely to make next, written as they would type it, in the language they wrote in. " +
23
- 'Offer only what this conversation can do with its tools. When nothing natural follows, leave the line out.'
35
+ 'a new request the person is most likely to make next, exactly the words they would type, in the language they wrote in - ' +
36
+ 'no quotation marks, no "say ..." or "ask me ...", no ids or keys. Never a yes to your own question and never what this ' +
37
+ 'reply could have done - do that now instead. Offer only what this conversation can do with its tools. ' +
38
+ 'When nothing natural follows, leave the line out.'
24
39
 
25
40
  /** A mode's persona with the rules every conversation follows. */
26
41
  export function conversationPrompt(modePrompt: string): string {
27
- return [modePrompt.trim(), ABSENCE_RULE, NEXT_REQUEST_RULE].join('\n\n')
42
+ return [modePrompt.trim(), ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE].join('\n\n')
43
+ }
44
+
45
+ /** Quotation marks inside a request: it quotes another sentence, so it is about a request, not one. */
46
+ const QUOTED = /["“”‘「」『』`]/
47
+ /** An id or a record key - nothing a person types. */
48
+ const MACHINE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\b[a-z]+(?:-[a-z]+)*:[0-9a-f-]{8,}/i
49
+
50
+ /**
51
+ * Whether the line names a request the person could send as it is. The input offers it to be sent with one key, so a
52
+ * line that tells the person what to say (「"…진행해줘"라고 해줘」) or carries an id is no request: offered, it is sent
53
+ * back to the model as the person's words.
54
+ */
55
+ export function isPersonRequest(text: string): boolean {
56
+ return !!text && !QUOTED.test(text) && !MACHINE.test(text)
28
57
  }
29
58
 
30
59
  /**
@@ -43,5 +72,6 @@ export function splitNextRequest(reply: string): { reply: string; suggestion?: s
43
72
  const suggestion = match[1].replace(/^["“'`]+|["”'`]+$/g, '').trim()
44
73
  const rest = lines.slice(0, -1).join('\n').replace(/\s+$/, '')
45
74
  if (!suggestion || !rest) return { reply: text }
46
- return { reply: rest, suggestion }
75
+ /* The line is ours either way - a line that is no request is taken off and offered as nothing. */
76
+ return isPersonRequest(suggestion) ? { reply: rest, suggestion } : { reply: rest }
47
77
  }
@@ -17,7 +17,7 @@
17
17
  * ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
18
18
  *
19
19
  * ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
20
- * 요약 접기를 `board-ai` 에서 이 꾸러미로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
20
+ * 요약 접기를 `board-ai` 에서 이 패키지로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
21
21
  * 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
22
22
  * 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
23
23
  */
@@ -74,6 +74,17 @@ describe('which record is meant', () => {
74
74
  expect(resolveChangeRefs('office.expense-line', [{ ref: 'current', values: {} }], { record: { target: 'office.contract', id: 'c1' } }).unresolved).toEqual(['current'])
75
75
  })
76
76
 
77
+ /* 2026-10-23: the open record is named to the model by its key; a change naming it so found nothing. */
78
+ it('★ the key a record was shown by names it - `<axis>:<id>`, the axis being the target key\'s last part', () => {
79
+ const id = 'f92c04ec-40c4-4be8-8160-080cff20e750'
80
+ const picked = (key: string, ref: string) => resolveChangeRefs(key, [{ ref, values: {} }], undefined).picked.map(one => one.id)
81
+ expect(picked('office.contract-round', `contract-round:${id}`)).toEqual([id])
82
+ expect(picked('office.contract-round', id)).toEqual([id])
83
+ /* Another record's key is not this record, whatever its uuid - it is looked up as written and found by nobody. */
84
+ expect(picked('office.contract-round', `contract:${id}`)).toEqual([`contract:${id}`])
85
+ expect(picked('office.contract', `contract-round:${id}`)).toEqual([`contract-round:${id}`])
86
+ })
87
+
77
88
  it('★ an id from the screen is only a candidate - a record the person cannot read is not found, and nothing is shown of it', async () => {
78
89
  const result: any = await proposeChanges(expense, { changes: [{ ref: 'current', values: { amount: 13000 } }] }, { host: { record: { id: 'someone-elses' } } }, run)
79
90
  expect(result.rows[0].issues).toEqual([{ code: 'record-not-found', blocking: true }])
@@ -2,7 +2,7 @@
2
2
  * Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
3
3
  * and what the server asks again before it writes.
4
4
  */
5
- import { offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
5
+ import { readFieldValue, recordIdOfKey, resolveReferences, offsetAt, zonedDateTimes, clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
6
6
  import { entryCategoryOf, proposeEntries } from './entry-tools'
7
7
  import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
8
8
  import { modeToolCategories } from '../assistant-mode/resolve'
@@ -410,3 +410,67 @@ describe("a moment is the domain's wall time", () => {
410
410
  expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
411
411
  })
412
412
  })
413
+
414
+ /*
415
+ * 2026-10-23: an office link card took `tax-invoice:<uuid>` - the key the link evidence and the open record show - and
416
+ * looked it up as an approval number. A reference that names a record of an axis reads that axis's key as its id.
417
+ */
418
+ describe('a reference names the record the model was shown', () => {
419
+ const ID = 'fe0e8503-ec5f-4da4-9c07-2d342c93d498'
420
+ const invoice = {
421
+ name: 'invoice',
422
+ type: 'reference' as const,
423
+ axis: 'tax-invoice',
424
+ reference: {
425
+ query: 'query ($text: String) { invoices(text: $text) { items { id approvalNo } } }',
426
+ variables: (text: string) => ({ text }),
427
+ recordsOf: (data: any, text: string) => (data?.invoices?.items ?? []).filter((one: any) => one.id === text || one.approvalNo === text)
428
+ }
429
+ }
430
+ const asked: string[] = []
431
+ const run = async (_query: string, variables: any) => {
432
+ asked.push(variables.text)
433
+ return { data: { invoices: { items: [{ id: ID, approvalNo: '20260120-10260120-17151515' }] } } }
434
+ }
435
+ const resolved = async (text: string, field: any = invoice) => {
436
+ const [row] = await resolveReferences([field], [{ values: { invoice: { text } }, issues: [] as any[] }], run)
437
+ return row.issues.length ? row.issues[0].code : row.values.invoice.id
438
+ }
439
+ beforeEach(() => (asked.length = 0))
440
+
441
+ it('★ the key `<axis>:<uuid>`, the bare uuid and the record\'s own words all find it', async () => {
442
+ expect(await resolved(`tax-invoice:${ID}`)).toBe(ID)
443
+ expect(asked).toEqual([ID])
444
+ expect(await resolved(ID)).toBe(ID)
445
+ expect(await resolved('20260120-10260120-17151515')).toBe(ID)
446
+ })
447
+
448
+ it('★ the key of a record of another axis is not this record, whatever its uuid', async () => {
449
+ expect(await resolved(`cash-transaction:${ID}`)).toBe('reference-not-found')
450
+ })
451
+
452
+ it('a reference with no axis reads what was written, as before', async () => {
453
+ const { axis, ...plain } = invoice
454
+ expect(await resolved(`tax-invoice:${ID}`, plain)).toBe('reference-not-found')
455
+ expect(asked).toEqual([`tax-invoice:${ID}`])
456
+ })
457
+
458
+ it('★ a field that holds a record\'s id reads its key as the id - one axis or any of several', () => {
459
+ const question = { name: 'questionId', type: 'string' as const, axis: 'strategy-question' }
460
+ expect(readFieldValue(question, ` strategy-question:${ID} `)).toEqual({ read: ID })
461
+ expect(readFieldValue(question, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
462
+ const owner = { name: 'ownerId', type: 'string' as const, axis: ['strategy-question', 'initiative'] }
463
+ expect(readFieldValue(owner, `initiative:${ID}`)).toEqual({ read: ID })
464
+ expect(readFieldValue({ name: 'note', type: 'string' }, `initiative:${ID}`)).toEqual({ read: `initiative:${ID}` })
465
+ /* A model that hands a resolved reference the key, not the id. */
466
+ expect(readFieldValue(invoice, { id: `tax-invoice:${ID}` })).toEqual({ read: { id: ID } })
467
+ })
468
+
469
+ it('a key names an id only when it is one', () => {
470
+ expect(recordIdOfKey('contract', `contract:${ID}`)).toBe(ID)
471
+ expect(recordIdOfKey('contract', ID.toUpperCase())).toBe(ID.toUpperCase())
472
+ expect(recordIdOfKey('contract', 'contract:H2026012')).toBeUndefined()
473
+ expect(recordIdOfKey('contract', 'H2026012')).toBeUndefined()
474
+ expect(recordIdOfKey('contract', `contract-round:${ID}`)).toBeUndefined()
475
+ })
476
+ })
@@ -36,6 +36,14 @@ export interface EntryField {
36
36
  * taken; none or several keep the row out and say so - the server never picks among several.
37
37
  */
38
38
  reference?: EntryReference
39
+ /**
40
+ * The axis of the record the field names (`tax-invoice`, `contract`) - several when it may name a record of each - for a
41
+ * record the model is shown by its key: an open record's anchor and an application's link evidence write
42
+ * `<axis>:<id>`. The key is read as the id it names, so what the model was shown is what it may write (2026-10-23: a
43
+ * card took `tax-invoice:<uuid>` from the evidence, looked it up as an approval number and found nothing). A reference
44
+ * reads it before its query runs; a string field holding an id reads it as the id.
45
+ */
46
+ axis?: string | readonly string[]
39
47
  }
40
48
 
41
49
  export interface EntryReference {
@@ -46,6 +54,22 @@ export interface EntryReference {
46
54
  recordsOf(data: any, text: string): { id: string; label?: string }[]
47
55
  }
48
56
 
57
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
58
+
59
+ /**
60
+ * The id a record's key names - `<axis>:<uuid>` as an anchor writes it, or the uuid alone. Nothing for a word, or for
61
+ * the key of a record of another axis: that one is not this record, whatever its uuid.
62
+ */
63
+ export function recordIdOfKey(axis: string | readonly string[], text: string): string | undefined {
64
+ const value = String(text ?? '').trim()
65
+ const prefix = (typeof axis === 'string' ? [axis] : axis).map(one => `${one}:`).find(one => value.startsWith(one))
66
+ const id = prefix ? value.slice(prefix.length) : value
67
+ return UUID.test(id) ? id : undefined
68
+ }
69
+
70
+ /** What the model wrote for a field that names a record - the id, when it wrote the record's key. */
71
+ const shownId = (field: EntryField, text: string): string => (field.axis && recordIdOfKey(field.axis, text)) || text
72
+
49
73
  /** A resolved reference, as a row keeps it. */
50
74
  export interface ReferenceValue {
51
75
  id: string
@@ -381,11 +405,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
381
405
  return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
382
406
  case 'reference':
383
407
  /* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
384
- if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
408
+ if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: shownId(field, (value as any).id), ...((value as any).label ? { label: String((value as any).label) } : {}) } }
385
409
  if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
386
410
  return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
387
411
  default:
388
- return { read: String(value).trim() }
412
+ return { read: shownId(field, String(value).trim()) }
389
413
  }
390
414
  }
391
415
 
@@ -651,11 +675,12 @@ export async function resolveReferences<T extends { values: Record<string, unkno
651
675
  for (const field of references) {
652
676
  const value: any = row.values[field.name]
653
677
  if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
654
- const key = `${field.name}\u0000${value.text}`
678
+ const text = shownId(field, value.text)
679
+ const key = `${field.name}\u0000${text}`
655
680
  if (!asked.has(key)) {
656
681
  try {
657
- const answer = await run(field.reference!.query, field.reference!.variables(value.text))
658
- asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
682
+ const answer = await run(field.reference!.query, field.reference!.variables(text))
683
+ asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, text) ?? [])
659
684
  } catch (error: any) {
660
685
  asked.set(key, error instanceof Error ? error : new Error(String(error)))
661
686
  }
@@ -153,7 +153,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
153
153
  doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
154
154
  description:
155
155
  `Propose changes to existing records: ${target.description} ` +
156
- 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
156
+ 'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen), an id a find tool returned, or the record\'s key (`<kind>:<id>`) as you were shown it. ' +
157
157
  'The server reads each record, and the card shows what it holds now and what it becomes; nothing changes until the person presses it.',
158
158
  schema: {
159
159
  type: 'object',
@@ -163,7 +163,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
163
163
  items: {
164
164
  type: 'object',
165
165
  properties: {
166
- ref: { type: 'string', description: '"current", "selected", or a record id from a find tool - never a guessed id.' },
166
+ ref: { type: 'string', description: '"current", "selected", a record id from a find tool, or the record\'s key as shown - never a guessed id.' },
167
167
  values: { type: 'object', description: 'Only the fields that change, with their new values.', properties: Object.fromEntries(changeableFields(target).map(field => [field.name, fieldSchema(field)])) }
168
168
  },
169
169
  required: ['ref', 'values']
@@ -13,6 +13,7 @@ import {
13
13
  changeToolName,
14
14
  findToolName,
15
15
  readFieldValue,
16
+ recordIdOfKey,
16
17
  resolveReferences,
17
18
  zonedDateTimes,
18
19
  sameFieldValue,
@@ -52,7 +53,8 @@ export function changeableFields(target: EntryTarget): EntryField[] {
52
53
  /**
53
54
  * The records the changes name. `current` is the record on screen, `selected` every row selected there -
54
55
  * both from the surface's host context, which is **not trusted**: an id from it is only a candidate, read
55
- * through the application before anything is shown. Any other ref is an id, from the find tool.
56
+ * through the application before anything is shown. Any other ref is an id, from the find tool, or the key the record
57
+ * was shown by.
56
58
  */
57
59
  export function resolveChangeRefs(
58
60
  targetKey: string,
@@ -74,7 +76,8 @@ export function resolveChangeRefs(
74
76
  if (ids.length) for (const id of ids) picked.push({ id, via: 'selected', values })
75
77
  else unresolved.push('selected')
76
78
  } else if (ref) {
77
- picked.push({ id: ref, via: 'id', values })
79
+ /* An open record's key - `contract-round:<id>` for `office.contract-round` - names the record of this target's axis. */
80
+ picked.push({ id: recordIdOfKey(targetKey.split('.').pop()!, ref) ?? ref, via: 'id', values })
78
81
  } else {
79
82
  unresolved.push('no-ref')
80
83
  }
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Pure but for the GraphQL runner, which comes in as an argument - the rules are tested without a server.
11
11
  */
12
- import { readFieldValue, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
12
+ import { readFieldValue, recordIdOfKey, rootFieldOf, type EntryField } from '../data-entry/entry-target.js'
13
13
  import { encodeListQuery } from '@operato/utils/list-query.js'
14
14
  import type { GraphqlRunner } from '../data-entry/entry-update.js'
15
15
  import { parse } from 'graphql'
@@ -143,6 +143,8 @@ export interface DetailArg {
143
143
  values?: string[]
144
144
  description?: string
145
145
  required?: boolean
146
+ /** A record's id: the axis (or axes) of the record, whose key `<axis>:<id>` the model may have been shown (EntryField.axis). */
147
+ axis?: string | readonly string[]
146
148
  }
147
149
 
148
150
  /**
@@ -200,8 +202,8 @@ export function detailReadOf(key: string | undefined): DetailRead | undefined {
200
202
  export function readCriteria(target: ReadTarget, args: any): { criteria: Record<string, unknown>; refused: { name: string; code: string }[] } {
201
203
  const criteria: Record<string, unknown> = {}
202
204
  const refused: { name: string; code: string }[] = []
203
- /* One record by its id - every target takes it (the id a list answer or a find tool gave). */
204
- if (typeof args?.id === 'string' && args.id.trim()) criteria.id = args.id.trim()
205
+ /* One record by its id - every target takes it (the id a list answer or a find tool gave, or the key `<axis>:<id>` an open record is shown by, the axis being the key's last part). */
206
+ if (typeof args?.id === 'string' && args.id.trim()) criteria.id = recordIdOfKey(target.key.split('.').pop()!, args.id) ?? args.id.trim()
205
207
  if (target.search && typeof args?.text === 'string' && args.text.trim()) criteria.text = args.text.trim()
206
208
  for (const criterion of target.criteria) {
207
209
  const value = args?.[criterion.name]
@@ -246,7 +246,7 @@ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRun
246
246
  if (arg.required) refused.push({ name: arg.name, code: 'required' })
247
247
  continue
248
248
  }
249
- const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values } as any, value)
249
+ const { read, code } = readFieldValue({ name: arg.name, type: arg.type === 'string' ? 'text' : arg.type, values: arg.values, axis: arg.axis } as any, value)
250
250
  if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
251
251
  else variables[arg.name] = read
252
252
  }
@@ -0,0 +1,36 @@
1
+ /*
2
+ * A read takes a record by the key it was shown by (2026-10-23) - `<axis>:<uuid>`, as an open record's anchor and an
3
+ * application's evidence write it. A 360 read and a list read asked by id took the key as the id and found nothing.
4
+ */
5
+ import { readCriteria, type DetailRead, type ReadTarget } from './read-target'
6
+ import { queryDetail } from './read-tools'
7
+
8
+ const ID = 'f92c04ec-40c4-4be8-8160-080cff20e750'
9
+
10
+ describe('a read names the record the model was shown', () => {
11
+ const lines = { key: 'office.cash-transaction', criteria: [] } as unknown as ReadTarget
12
+
13
+ it('★ a list read by id takes the key of its own axis - the target key\'s last part', () => {
14
+ expect(readCriteria(lines, { id: `cash-transaction:${ID}` }).criteria).toEqual({ id: ID })
15
+ expect(readCriteria(lines, { id: ID }).criteria).toEqual({ id: ID })
16
+ /* Another record's key is not this record - asked as written, it finds nothing. */
17
+ expect(readCriteria(lines, { id: `contract:${ID}` }).criteria).toEqual({ id: `contract:${ID}` })
18
+ })
19
+
20
+ it('★ a whole answer takes the key of the axis its id argument declares', async () => {
21
+ const summary: DetailRead = {
22
+ key: 'office.contract-360',
23
+ description: "one contract's 360.",
24
+ query: 'query ($id: String!) { contract360(id: $id) { code } }',
25
+ args: [{ name: 'id', type: 'string', required: true, axis: 'contract' }]
26
+ }
27
+ const asked: any[] = []
28
+ const run = async (_document: string, variables: any) => {
29
+ asked.push(variables)
30
+ return { data: { contract360: { code: 'H2026012' } } }
31
+ }
32
+ await queryDetail(summary, { id: `contract:${ID}` }, run)
33
+ await queryDetail(summary, { id: `cash-transaction:${ID}` }, run)
34
+ expect(asked).toEqual([{ id: ID }, { id: `cash-transaction:${ID}` }])
35
+ })
36
+ })
@@ -145,6 +145,7 @@ export function modeCarriesSharedRules(kind: string): string[] {
145
145
  const found: string[] = []
146
146
  if (/name our plumbing|do not describe our machinery/i.test(prompt)) found.push('absence-rule-copy')
147
147
  if (/^\s*Next:/im.test(prompt) || /end the reply with one last line/i.test(prompt)) found.push('next-request-rule-copy')
148
+ if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
148
149
  return found
149
150
  }
150
151
 
@@ -260,7 +260,7 @@
260
260
  "ai-assistant.entry.change": "{count}건 수정",
261
261
  "ai-assistant.entry.title-create": "{name} 입력안",
262
262
  "ai-assistant.entry.title-update": "{name} 수정안",
263
- "ai-assistant.entry.changed": "{count}건을 고쳤습니다.",
263
+ "ai-assistant.entry.changed": "{count}건을 수정했습니다.",
264
264
  "ai-assistant.entry.changed-none": "수정된 기록이 없습니다.",
265
265
  "ai-assistant.entry.changes": "{count}건",
266
266
  "ai-assistant.entry.not-changed": "{count}건은 변경하지 않습니다.",
@@ -399,5 +399,5 @@
399
399
  "ai-assistant.run.assistant-run-model-error": "실행 중 AI 모델 호출이 실패했습니다.",
400
400
  "ai-assistant.run.assistant-run-abandoned": "실행이 도중에 멈춰 다시 시작했습니다.",
401
401
  "ai-assistant.run.ai-not-configured": "이 사업장에는 AI 가 설정되어 있지 않습니다.",
402
- "ai-assistant.run.ai-agent-not-available": "이 실행에 고른 AI 에이전트를 여기서 쓸 수 없습니다."
402
+ "ai-assistant.run.ai-agent-not-available": "이 실행에서 선택한 AI 에이전트는 현재 사용할 수 없습니다."
403
403
  }