@things-factory/ai-assistant 10.1.227 → 10.1.229

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 (75) hide show
  1. package/client/components/assistant-chat.ts +27 -4
  2. package/client/components/assistant-dock.ts +18 -2
  3. package/client/components/proposal-outcome-line.ts +5 -0
  4. package/client/utils/applied-screens.ts +36 -0
  5. package/client/utils/assistant-proposals.ts +2 -0
  6. package/client/utils/assistant-selection.ts +3 -2
  7. package/client/utils/entry-proposal.ts +58 -3
  8. package/dist-client/components/assistant-chat.d.ts +3 -0
  9. package/dist-client/components/assistant-chat.js +27 -4
  10. package/dist-client/components/assistant-chat.js.map +1 -1
  11. package/dist-client/components/assistant-dock.js +18 -2
  12. package/dist-client/components/assistant-dock.js.map +1 -1
  13. package/dist-client/components/proposal-outcome-line.d.ts +2 -0
  14. package/dist-client/components/proposal-outcome-line.js +4 -0
  15. package/dist-client/components/proposal-outcome-line.js.map +1 -1
  16. package/dist-client/tsconfig.tsbuildinfo +1 -1
  17. package/dist-client/utils/applied-screens.d.ts +21 -0
  18. package/dist-client/utils/applied-screens.js +32 -0
  19. package/dist-client/utils/applied-screens.js.map +1 -0
  20. package/dist-client/utils/assistant-proposals.d.ts +2 -0
  21. package/dist-client/utils/assistant-proposals.js.map +1 -1
  22. package/dist-client/utils/assistant-selection.d.ts +1 -1
  23. package/dist-client/utils/assistant-selection.js +3 -2
  24. package/dist-client/utils/assistant-selection.js.map +1 -1
  25. package/dist-client/utils/entry-proposal.d.ts +22 -0
  26. package/dist-client/utils/entry-proposal.js +56 -3
  27. package/dist-client/utils/entry-proposal.js.map +1 -1
  28. package/dist-server/service/assistant-mode/index.d.ts +1 -0
  29. package/dist-server/service/assistant-mode/index.js +5 -1
  30. package/dist-server/service/assistant-mode/index.js.map +1 -1
  31. package/dist-server/service/assistant-mode/record-subject.d.ts +25 -0
  32. package/dist-server/service/assistant-mode/record-subject.js +35 -0
  33. package/dist-server/service/assistant-mode/record-subject.js.map +1 -0
  34. package/dist-server/service/chat-session/attachment-tool-result.d.ts +1 -0
  35. package/dist-server/service/chat-session/attachment-tool-result.js +11 -0
  36. package/dist-server/service/chat-session/attachment-tool-result.js.map +1 -1
  37. package/dist-server/service/chat-session/attachment-tools.js +5 -2
  38. package/dist-server/service/chat-session/attachment-tools.js.map +1 -1
  39. package/dist-server/service/data-entry/entry-screen.d.ts +10 -0
  40. package/dist-server/service/data-entry/entry-screen.js +18 -0
  41. package/dist-server/service/data-entry/entry-screen.js.map +1 -0
  42. package/dist-server/service/data-entry/entry-sources.js +6 -1
  43. package/dist-server/service/data-entry/entry-sources.js.map +1 -1
  44. package/dist-server/service/data-entry/entry-target.d.ts +6 -0
  45. package/dist-server/service/data-entry/entry-target.js.map +1 -1
  46. package/dist-server/service/data-entry/entry-tools.d.ts +3 -0
  47. package/dist-server/service/data-entry/entry-tools.js +3 -0
  48. package/dist-server/service/data-entry/entry-tools.js.map +1 -1
  49. package/dist-server/service/data-entry/entry-update.d.ts +3 -0
  50. package/dist-server/service/data-entry/entry-update.js +3 -0
  51. package/dist-server/service/data-entry/entry-update.js.map +1 -1
  52. package/dist-server/tsconfig.tsbuildinfo +1 -1
  53. package/package.json +9 -9
  54. package/server/service/assistant-mode/index.ts +2 -0
  55. package/server/service/assistant-mode/record-subject.test.ts +41 -0
  56. package/server/service/assistant-mode/record-subject.ts +44 -0
  57. package/server/service/chat-session/attachment-tool-result.test.ts +30 -1
  58. package/server/service/chat-session/attachment-tool-result.ts +12 -0
  59. package/server/service/chat-session/attachment-tools.ts +5 -3
  60. package/server/service/data-entry/data-entry.test.ts +18 -0
  61. package/server/service/data-entry/entry-screen.ts +14 -0
  62. package/server/service/data-entry/entry-sources.ts +5 -1
  63. package/server/service/data-entry/entry-target.ts +6 -0
  64. package/server/service/data-entry/entry-tools.ts +3 -0
  65. package/server/service/data-entry/entry-update.ts +3 -0
  66. package/server/service/data-read/data-read.test.ts +3 -1
  67. package/test/applied-screens.test.ts +65 -0
  68. package/test/assistant-dock.test.ts +6 -1
  69. package/test/entry-proposal.test.ts +46 -0
  70. package/translations/en.json +3 -0
  71. package/translations/es.json +3 -0
  72. package/translations/ja.json +3 -0
  73. package/translations/ko.json +3 -0
  74. package/translations/ms.json +3 -0
  75. package/translations/zh.json +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.227",
3
+ "version": "10.1.229",
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.49",
33
+ "@operato/layout": "^10.29.60",
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.227",
39
- "@things-factory/apptool-base": "^10.1.227",
40
- "@things-factory/attachment-base": "^10.1.227",
41
- "@things-factory/auth-base": "^10.1.227",
38
+ "@things-factory/ai-client-base": "^10.1.228",
39
+ "@things-factory/apptool-base": "^10.1.228",
40
+ "@things-factory/attachment-base": "^10.1.228",
41
+ "@things-factory/auth-base": "^10.1.228",
42
42
  "@things-factory/env": "^10.1.149",
43
- "@things-factory/setting-base": "^10.1.227",
44
- "@things-factory/shell": "^10.1.227",
43
+ "@things-factory/setting-base": "^10.1.228",
44
+ "@things-factory/shell": "^10.1.228",
45
45
  "dompurify": "^3.0.0",
46
46
  "exceljs": "4.2.0",
47
47
  "graphql-scalars": "^1.22.0",
@@ -57,5 +57,5 @@
57
57
  "copyfiles": "^2.4.1",
58
58
  "rimraf": "^5.0.0"
59
59
  },
60
- "gitHead": "f66591e3864aab08a6fb93fb1fcf1c9de6b546ac"
60
+ "gitHead": "df6361882dc166ef3f8bd54492113253c802f879"
61
61
  }
@@ -14,6 +14,8 @@ export {
14
14
  export { resolveModeSettings, assistantModeView } from './resolve.js'
15
15
  /* A category another package opens in every conversation, in front on the pages it names (ADR-0160 decision 9). */
16
16
  export { registerConversationCategory, conversationCategoriesOf, turnSystemPrompt, type ConversationCategory } from './mode-categories.js'
17
+ /* The record an open 360 is about, named by the module that owns it (screen-pattern §2-1). */
18
+ export { recordSubjectDescriber, registerRecordSubject, type RecordSubject } from './record-subject.js'
17
19
  export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SELECTION_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
18
20
  export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
19
21
  export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
@@ -0,0 +1,41 @@
1
+ /*
2
+ * An open 360's record is named by its owner, behind the door its 360 reads it by (screen-pattern §2-1).
3
+ */
4
+ import { recordSubjectDescriber } from './record-subject'
5
+
6
+ const ROW = { id: 'r1', name: 'Q4 renewal' }
7
+
8
+ const subject = {
9
+ axis: 'opportunity',
10
+ noun: 'sales opportunity',
11
+ door: 'Query.opportunity',
12
+ read: async (rowId: string, domainId: string) => (rowId === 'r1' && domainId === 'd1' ? ROW : null),
13
+ nameOf: (row: any) => row.name
14
+ }
15
+
16
+ const asked: string[] = []
17
+ const open = () => async (door: string) => (asked.push(door), true)
18
+ const closed = () => async (door: string) => (asked.push(door), false)
19
+
20
+ describe('a record subject', () => {
21
+ beforeEach(() => (asked.length = 0))
22
+
23
+ it('★ names the record of its axis, asking the 360 read\'s door', async () => {
24
+ const describer = recordSubjectDescriber(subject, open)
25
+ expect(describer).toMatchObject({ anchorType: 'subject', axis: 'opportunity' })
26
+ expect(await describer.describe({ anchorId: 'opportunity:r1', domain: { id: 'd1' }, state: {} })).toEqual({ noun: 'sales opportunity', name: 'Q4 renewal' })
27
+ expect(asked).toEqual(['Query.opportunity'])
28
+ })
29
+
30
+ it('★ names nothing for someone the door does not let through', async () => {
31
+ expect(await recordSubjectDescriber(subject, closed).describe({ anchorId: 'opportunity:r1', domain: { id: 'd1' }, state: {} })).toBeUndefined()
32
+ })
33
+
34
+ it('names nothing of another axis, another domain or no row', async () => {
35
+ const describer = recordSubjectDescriber(subject, open)
36
+ expect(await describer.describe({ anchorId: 'invoice:r1', domain: { id: 'd1' } })).toBeUndefined()
37
+ expect(await describer.describe({ anchorId: 'opportunity:', domain: { id: 'd1' } })).toBeUndefined()
38
+ expect(await describer.describe({ anchorId: 'opportunity:r1', domain: { id: 'd2' } })).toBeUndefined()
39
+ expect(await describer.describe({ anchorId: 'opportunity:r9', domain: { id: 'd1' } })).toBeUndefined()
40
+ })
41
+ })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * A record whose 360 is open, named for the AI each turn (operato-application design/ai/screen-pattern.md §2-1).
3
+ *
4
+ * A 360 kind with `assistantSubject` (@operato/layout) anchors its conversation to `subject` · `<kind>:<row uuid>`. The
5
+ * module that owns the record says here what to call it: the kind is the axis, and the door is the GraphQL field its
6
+ * 360 reads the record by (`Query.invoice`). The record is named only for someone that door lets through - asked of
7
+ * AUTH's field table as a tool's door is asked (`tool-access.ts`), so the declaration is the door's, never a copy.
8
+ * A row of another domain, or one that is gone, is not named.
9
+ */
10
+ import { registerAssistantSubject, type AssistantSubjectDescriber } from '@things-factory/ai-client-base'
11
+
12
+ import { toolAccessFor } from '../tool-access.js'
13
+
14
+ export interface RecordSubject {
15
+ /** The 360 kind - the anchor's `<axis>:` part. */
16
+ axis: string
17
+ /** `invoice`, `sales opportunity` - English, a word the model can use in a sentence. */
18
+ noun: string
19
+ /** The root field the 360 reads the record by, `Query.<field>` - whoever may not pass it is told no name. */
20
+ door: string
21
+ /** The row of this domain, `null` when there is none there. */
22
+ read(rowId: string, domainId: string): Promise<any | null>
23
+ /** The name the 360 shows. */
24
+ nameOf(row: any): string
25
+ }
26
+
27
+ export function recordSubjectDescriber(subject: RecordSubject, accessFor = toolAccessFor): AssistantSubjectDescriber {
28
+ return {
29
+ anchorType: 'subject',
30
+ axis: subject.axis,
31
+ async describe({ anchorId, domain, state }) {
32
+ const rowId = anchorId.slice(subject.axis.length + 1)
33
+ if (!anchorId.startsWith(`${subject.axis}:`) || !rowId) return undefined
34
+ if (!(await accessFor({ state })(subject.door))) return undefined
35
+ const row = await subject.read(rowId, domain.id)
36
+ const name = row ? subject.nameOf(row) : ''
37
+ return name ? { noun: subject.noun, name } : undefined
38
+ }
39
+ }
40
+ }
41
+
42
+ export function registerRecordSubject(subject: RecordSubject): void {
43
+ registerAssistantSubject(recordSubjectDescriber(subject))
44
+ }
@@ -4,7 +4,10 @@
4
4
  /* The source, not the built package - the two land together and the test must see the change before a build. */
5
5
  import { takeToolImages } from '../../../../ai-client-base/server/service/agentic-loop/agentic-loop'
6
6
 
7
- import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result'
7
+ import { readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+
10
+ import { attachmentIdOf, attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result'
8
11
 
9
12
  describe('a file opened again in a later turn', () => {
10
13
  const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 9])
@@ -32,3 +35,29 @@ describe('an id that names no file', () => {
32
35
  expect(NO_SUCH_ATTACHMENT).toMatch(/Do not ask for it again/)
33
36
  })
34
37
  })
38
+
39
+ describe('the file id in what the model wrote', () => {
40
+ const ID = '0f3c2a9e-1b2d-4c5e-8f90-a1b2c3d4e5f6'
41
+
42
+ it('★ the whole history line is read as the id it begins with (office.hatiolab.com, 2026-10-29: postgres 22P02)', () => {
43
+ expect(attachmentIdOf(`${ID}: 기술개발인력 이력서.pdf.txt (text/plain, 6429 bytes) - readChatAttachment to open it`)).toBe(ID)
44
+ expect(attachmentIdOf(`[attachment ${ID}: 영수증.png]`)).toBe(ID)
45
+ expect(attachmentIdOf(` ${ID.toUpperCase()} `)).toBe(ID.toUpperCase())
46
+ })
47
+
48
+ it('★ anything else names no file - a record key, a cut id, words', () => {
49
+ expect(attachmentIdOf(`contract:${ID}`)).toBeUndefined()
50
+ expect(attachmentIdOf(ID.slice(0, 33))).toBeUndefined()
51
+ expect(attachmentIdOf(`${ID}0`)).toBeUndefined()
52
+ expect(attachmentIdOf('unknown')).toBeUndefined()
53
+ expect(attachmentIdOf(undefined)).toBeUndefined()
54
+ })
55
+
56
+ it('★ both readers ask the database only for the id read out of it', () => {
57
+ const tool = readFileSync(join(__dirname, 'attachment-tools.ts'), 'utf8')
58
+ expect(tool).toMatch(/const id = attachmentIdOf\(given\)\n\s+if \(!id\) return \{ absent: 'not-found'/)
59
+ expect(tool.indexOf('attachmentIdOf(given)')).toBeLessThan(tool.indexOf('getRepository(Attachment).findOne'))
60
+ const sources = readFileSync(join(__dirname, '..', 'data-entry', 'entry-sources.ts'), 'utf8')
61
+ expect(sources).toMatch(/const fileId = attachmentIdOf\(id\)\n\s+if \(!fileId\) continue\n\s+const row: any = await getRepository\(Attachment\)\.findOne\(\{ where: \{ id: fileId,/)
62
+ })
63
+ })
@@ -23,3 +23,15 @@ export function attachmentToolResult(attachmentId: string, file: FileForModel) {
23
23
  */
24
24
  export const NO_SUCH_ATTACHMENT =
25
25
  'there is no file with this id in a conversation here - it is not an attachment. Do not ask for it again; a record is read with its own read tool.'
26
+
27
+ /*
28
+ * The file's id in what the model wrote (2026-10-29, office.hatiolab.com): it passed the whole history line -
29
+ * 「<id>: 이력서.pdf.txt (text/plain, 1547 bytes) - readChatAttachment to open it」 - and postgres refused the text as a
30
+ * uuid (22P02). The id is the uuid the text begins with, after an optional "[attachment "; anything else - a record's
31
+ * key, a cut id - names no file and is not asked of the database.
32
+ */
33
+ const LEADING_ID = /^\[?(?:attachment\s+)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?![0-9a-f-])/i
34
+
35
+ export function attachmentIdOf(text: unknown): string | undefined {
36
+ return typeof text === 'string' ? LEADING_ID.exec(text.trim())?.[1] : undefined
37
+ }
@@ -18,7 +18,7 @@ import { isSessionParticipant } from '../chat-session-participant/participant-lo
18
18
  import { attachmentForModel, CHAT_ATTACHMENT_REF } from './session-attachments.js'
19
19
  import { textCharsOf } from './attachment-policy.js'
20
20
  import { getAssistantMode } from '../assistant-mode/registry.js'
21
- import { attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result.js'
21
+ import { attachmentIdOf, attachmentToolResult, NO_SUCH_ATTACHMENT } from './attachment-tool-result.js'
22
22
 
23
23
  export const CHAT_ATTACHMENT_CATEGORY = 'chat-attachments'
24
24
 
@@ -36,8 +36,10 @@ const readChatAttachment: ToolSpec = {
36
36
  },
37
37
  builder: async (args: any, context: any) => {
38
38
  const { domain, user } = context?.state ?? {}
39
- const id = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : ''
40
- if (!domain?.id || !id) return { absent: 'no-attachment', message: 'no attachment id was given.' }
39
+ const given = typeof args?.attachmentId === 'string' ? args.attachmentId.trim() : ''
40
+ if (!domain?.id || !given) return { absent: 'no-attachment', message: 'no attachment id was given.' }
41
+ const id = attachmentIdOf(given)
42
+ if (!id) return { absent: 'not-found', attachmentId: given, message: NO_SUCH_ATTACHMENT }
41
43
 
42
44
  const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
43
45
  const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
@@ -6,6 +6,8 @@ import { readFieldValue, recordIdOfKey, resolveReferences, offsetAt, zonedDateTi
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'
9
+ import { clearReadTargets, declareReadTarget } from '../data-read/read-target'
10
+ import { listReadTarget } from '../data-read/list-read'
9
11
 
10
12
  const written: any[] = []
11
13
 
@@ -128,6 +130,22 @@ describe('proposing', () => {
128
130
  const result: any = await proposeEntries(expense, { rows: [] }, { state: {} })
129
131
  expect(result).toMatchObject({ rejected: true, error: 'no-rows' })
130
132
  })
133
+
134
+ it("★ carries the screen where the records are seen - the target's own, else its list read's, else none", async () => {
135
+ const one = { rows: [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: 'x' }] }] }
136
+ clearReadTargets()
137
+ expect((await proposeEntries(expense, one, { state: {} }, async () => new Map()) as any).screen).toBeUndefined()
138
+
139
+ declareReadTarget(listReadTarget({ key: 'office.expense-line', title: 't', name: 'expense lines', listQuery: 'expenseLines', selection: 'id amount', columns: [{ name: 'amount', type: 'number' }], route: 'expense-line-list', screenQuery: true }))
140
+ expect(await proposeEntries(expense, one, { state: {} }, async () => new Map())).toMatchObject({ screen: 'expense-line-list', screenLists: true })
141
+ /* A screen the target declares shows other records (a parent's, a workspace) - its address is not narrowed by these ids. */
142
+ const own: any = await proposeEntries({ ...expense, screen: 'my-expense-page' }, one, { state: {} }, async () => new Map())
143
+ expect(own.screen).toBe('my-expense-page')
144
+ expect(own.screenLists).toBeUndefined()
145
+ /* The records it is about besides its own - their 360s read again once it is applied. */
146
+ expect(((await proposeEntries({ ...expense, axes: ['contract'] }, one, { state: {} }, async () => new Map())) as any).axes).toEqual(['contract'])
147
+ clearReadTargets()
148
+ })
131
149
  })
132
150
 
133
151
  describe('entering what the person pressed', () => {
@@ -0,0 +1,14 @@
1
+ import { readTargetOf } from '../data-read/read-target.js'
2
+ import type { EntryTarget } from './entry-target.js'
3
+
4
+ /**
5
+ * Where the records of a card are seen once they went in - the target's own screen, else the list screen of the read
6
+ * target with the same key. Carried on the card, so it is there after a refresh. `screenLists` says that screen lists
7
+ * these very records and takes conditions in its address, so the card may open it narrowed to them.
8
+ */
9
+ export function entryScreenOf(target: EntryTarget): { screen: string; screenLists?: true } | {} {
10
+ if (target.screen) return { screen: target.screen }
11
+ const screen = readTargetOf(target.key)?.screen
12
+ if (!screen?.route) return {}
13
+ return { screen: screen.route, ...(screen.queryState ? { screenLists: true as const } : {}) }
14
+ }
@@ -11,6 +11,7 @@ import { mayReadSession } from '../chat-session/session-visibility.js'
11
11
  import { isSessionParticipant } from '../chat-session-participant/participant-lookup.js'
12
12
  import { CHAT_ATTACHMENT_REF } from '../chat-session/session-attachments.js'
13
13
  import { attachmentKindOf } from '../chat-session/attachment-policy.js'
14
+ import { attachmentIdOf } from '../chat-session/attachment-tool-result.js'
14
15
 
15
16
  /** The whole text is read - the check must see every line, not the part the model was shown. */
16
17
  const MAX_CHARS = 2_000_000
@@ -20,7 +21,10 @@ export async function loadSourceTexts(ids: string[], context: any): Promise<Map<
20
21
  const texts = new Map<string, string>()
21
22
  if (!domain?.id) return texts
22
23
  for (const id of [...new Set(ids)].slice(0, 20)) {
23
- const row: any = await getRepository(Attachment).findOne({ where: { id, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
24
+ /* Keyed by the id as cited; a citation that names no file is not asked of the database. */
25
+ const fileId = attachmentIdOf(id)
26
+ if (!fileId) continue
27
+ const row: any = await getRepository(Attachment).findOne({ where: { id: fileId, domain: { id: domain.id }, refType: CHAT_ATTACHMENT_REF } as any })
24
28
  const session: any = row && (await getRepository(ChatSession).findOne({ where: { id: row.refBy, domain: { id: domain.id } } as any }))
25
29
  if (!row || !session) continue
26
30
  if (!mayReadSession(session.visibility, await isSessionParticipant(domain.id, session.id, user?.id))) continue
@@ -173,6 +173,12 @@ export interface EntryTarget {
173
173
  variables(rows: EntryRow[]): Record<string, unknown>
174
174
  appliedOf?(data: any, rows: EntryRow[]): EntryApplied | undefined
175
175
  }
176
+ /**
177
+ * The application's route where the entered or changed records are seen - the card offers it once they went in.
178
+ * Left out, the list screen of the read target with the same key; declared where that is none (a parent's screen,
179
+ * a workspace).
180
+ */
181
+ screen?: string
176
182
  /** At most this many rows in one proposal. Default 200. */
177
183
  maxRows?: number
178
184
  /**
@@ -10,6 +10,7 @@
10
10
  import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
11
11
 
12
12
  import { findRecords, proposeChanges, changeableFields, type GraphqlRunner } from './entry-update.js'
13
+ import { entryScreenOf } from './entry-screen.js'
13
14
  import {
14
15
  changeToolName,
15
16
  findToolName,
@@ -113,6 +114,8 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
113
114
  rows,
114
115
  counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut, verified, unmatched, ...groupCounts },
115
116
  ...(target.group ? { groupBy: target.group.by } : {}),
117
+ ...entryScreenOf(target),
118
+ ...(target.axes?.length ? { axes: target.axes } : {}),
116
119
  ...(totals.length ? { totals } : {}),
117
120
  ...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
118
121
  ...(checkFailed ? { checkFailed } : {}),
@@ -22,6 +22,7 @@ import {
22
22
  type EntryRecord,
23
23
  type EntryTarget
24
24
  } from './entry-target.js'
25
+ import { entryScreenOf } from './entry-screen.js'
25
26
 
26
27
  /** Runs one of the application's queries or mutations as the person. */
27
28
  export type GraphqlRunner = (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
@@ -169,6 +170,8 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
169
170
  columns: changedColumns(target, rows),
170
171
  rows,
171
172
  counts: { rows: rows.length, ready: rows.length - blocked, blocked, cut: 0 },
173
+ ...entryScreenOf(target),
174
+ ...(target.axes?.length ? { axes: target.axes } : {}),
172
175
  ...(typeof args?.note === 'string' && args.note.trim() ? { reason: args.note.trim().slice(0, 300) } : {}),
173
176
  message:
174
177
  `proposed ${rows.length} changes (${rows.length - blocked} ready, ${blocked} blocked). Nothing changes until the person presses the card. ` +
@@ -6,6 +6,7 @@
6
6
  import { modeToolCategories } from '../assistant-mode/resolve'
7
7
  import { clearReadTargets, declareDetailRead, declareReadTarget, detailToolName, groupsOf, MAX_DETAIL_CHARS, MAX_READ_ROWS, queryToolName, readCategoryName, screenAddress, screenToolName, type DetailRead, type ReadTarget } from './read-target'
8
8
  import { detailCategoryOf, openScreen, queryDetail, queryRecords, readCategoryOf } from './read-tools'
9
+ import { decodeListQuery } from '@operato/utils/list-query.js'
9
10
 
10
11
  /* The application, as a fake: a bank ledger behind a list query that pages and filters like ListParam. */
11
12
  let ledger: any[]
@@ -224,7 +225,8 @@ describe('a list declared in one line', () => {
224
225
  expect(query.has('year')).toBe(false)
225
226
  expect(query.has('month')).toBe(false)
226
227
  expect(query.has('flow')).toBe(false)
227
- expect(JSON.parse(query.get('filters')!)).toEqual([
228
+ /* the address layer writes its own list form (json5), so the test reads it back the way the screen does */
229
+ expect(decodeListQuery(address.href.split('?')[1]).filters).toEqual([
228
230
  { name: 'transactionDate', operator: 'gte', value: '2026-01-01' },
229
231
  { name: 'year', operator: 'eq', value: 2026 },
230
232
  { name: 'month', operator: 'eq', value: 9 },
@@ -0,0 +1,65 @@
1
+ /*
2
+ * What an applied card wrote is read again by the screens that show it, and only by them (applied-screens.ts).
3
+ */
4
+ import { refreshAppliedScreens } from '../client/utils/applied-screens'
5
+
6
+ function deps(page: { name?: string; dirty?: boolean; refresh?: boolean } = {}, shown?: { kind: string; id: string }) {
7
+ const asked: any[] = []
8
+ let read360 = false
9
+ return {
10
+ asked,
11
+ read360: () => read360,
12
+ deps: {
13
+ page: () => ({
14
+ name: page.name,
15
+ element: page.refresh === false ? {} : { refresh: async (options?: any) => (asked.push(options), !page.dirty) }
16
+ }),
17
+ refresh360: (about: (subject: { kind: string; id: string }) => boolean) => (read360 = !!shown && about(shown))
18
+ }
19
+ }
20
+ }
21
+
22
+ const card = { target: 'office.counterparty', screen: 'counterparty-list' }
23
+
24
+ describe('the screens behind the dock after a card is applied', () => {
25
+ it('★ the page that is the card\'s screen reads again, never asking about unsaved edits', async () => {
26
+ const one = deps({ name: 'counterparty-list' })
27
+ await refreshAppliedScreens(card, ['a'], one.deps)
28
+ expect(one.asked).toEqual([{ ask: false }])
29
+ })
30
+
31
+ it('★ a page with unsaved edits is left as it is, and that is said', async () => {
32
+ const one = deps({ name: 'counterparty-list', dirty: true })
33
+ expect(await refreshAppliedScreens(card, ['a'], one.deps)).toBe('kept-edits')
34
+ expect(await refreshAppliedScreens(card, ['a'], deps({ name: 'counterparty-list' }).deps)).toBeUndefined()
35
+ })
36
+
37
+ it('★ another page keeps its rows and its scroll', async () => {
38
+ const one = deps({ name: 'contract-list' })
39
+ await refreshAppliedScreens(card, ['a'], one.deps)
40
+ expect(one.asked).toEqual([])
41
+ })
42
+
43
+ it('a shell without the page refresh leaves the page as it is', async () => {
44
+ await expect(refreshAppliedScreens(card, ['a'], deps({ name: 'counterparty-list', refresh: false }).deps)).resolves.toBeUndefined()
45
+ })
46
+
47
+ it('★ the shown 360 reads again when it is one of the records, or a record the card is about', async () => {
48
+ const own = deps({}, { kind: 'counterparty', id: 'a' })
49
+ await refreshAppliedScreens(card, ['a'], own.deps)
50
+ expect(own.read360()).toBe(true)
51
+
52
+ const other = deps({}, { kind: 'counterparty', id: 'z' })
53
+ await refreshAppliedScreens(card, ['a'], other.deps)
54
+ expect(other.read360()).toBe(false)
55
+
56
+ const about = deps({}, { kind: 'contract', id: 'c' })
57
+ await refreshAppliedScreens({ target: 'office.collection-match', axes: ['cash-transaction', 'contract'] }, ['l'], about.deps)
58
+ expect(about.read360()).toBe(true)
59
+ })
60
+
61
+ it('a refresh that throws keeps the screen and the card', async () => {
62
+ const failing = { page: () => ({ name: 'counterparty-list', element: { refresh: async () => { throw new Error('x') } } }), refresh360: () => { throw new Error('y') } }
63
+ await expect(refreshAppliedScreens(card, ['a'], failing as any)).resolves.toBeUndefined()
64
+ })
65
+ })
@@ -57,7 +57,12 @@ describe('every request the chat makes is taken', () => {
57
57
 
58
58
  describe('every proposal ends in one reported outcome', () => {
59
59
  it('the dock reports whatever the handler returned', () => {
60
- assert.match(dock, /const outcome: ProposalOutcome =[\s\S]*await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
60
+ assert.match(dock, /const outcome: ProposalOutcome =[\s\S]*await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*const \{ records, \.\.\.reported \} = outcome\s*await this\.report\(sessionId, proposalKey, reported\)/)
61
+ })
62
+
63
+ it('★ an applied card has the screens that show its records read them again - not a refused one', () => {
64
+ assert.match(dock, /if \(outcome\.verdict === 'accepted' && \(outcome\.applied \?\? 1\) > 0\)\s*void refreshAppliedScreens\(proposal, records, APPLIED_SCREENS\)\.then\(kept => \{\s*if \(kept\) notify\(\{ level: 'info', message: t\('screen-kept-edits'/)
65
+ assert.match(dock, /page: \(\) => \{\s*const route = \(store\.getState\(\) as any\)\?\.route \?\? \{\}\s*return \{ name: route\.page, element: route\.activePage \}/)
61
66
  })
62
67
 
63
68
  it('a kind nobody handles is unavailable, by name', async () => {
@@ -9,9 +9,13 @@ const mockNotify = jest.fn()
9
9
  jest.mock('@operato/layout', () => ({ notify: (...args: any[]) => mockNotify(...args) }))
10
10
 
11
11
  import { runProposal, setEntryProposalHandler } from '../client/utils/assistant-proposals'
12
+ import { entryWentIn } from '../client/components/proposal-outcome-line'
13
+ import { noteForDisplay } from '../client/utils/assistant-selection'
12
14
  import {
13
15
  entryGroupOf,
14
16
  entryOutcomeNote,
17
+ entryRecordsOf,
18
+ entryScreenAddress,
15
19
  entryProposalHandler,
16
20
  entryReadyCount,
17
21
  entryTitle,
@@ -68,6 +72,48 @@ describe('the entry card', () => {
68
72
  expect(entryOutcomeNote(answer, t)).toBe('entered 2 rows.\nrow 2: closed period')
69
73
  })
70
74
 
75
+ it('★ names the records that went in - once per record, ten at most', () => {
76
+ const rows = Array.from({ length: 12 }, (_, index) => ({ index, ok: true, label: `R${index}` }))
77
+ const lines = [{ index: 0, ok: true, id: 'a', label: 'order A' }, { index: 1, ok: true, id: 'a', label: 'order A' }, { index: 2, ok: true }]
78
+ expect(entryOutcomeNote({ verdict: 'accepted', applied: 3, missed: 0, rows: lines }, t)).toBe('entered 3 rows.\norder A')
79
+ expect(entryOutcomeNote({ verdict: 'accepted', applied: 12, missed: 0, rows }, t)).toBe('entered 12 rows.\nR0, R1, R2, R3, R4, R5, R6, R7, R8, R9 and 2 more')
80
+ })
81
+
82
+ it('★ the card offers the screen where its records are seen only once something went in', () => {
83
+ const answered = (verdict: any, applied?: number) => ({ state: 'answered' as const, outcome: { key: 'k', verdict, applied } })
84
+ expect(entryWentIn(answered('accepted', 2))).toBe(true)
85
+ expect(entryWentIn(answered('accepted', 0))).toBe(false)
86
+ expect(entryWentIn(answered('refused', 1))).toBe(false)
87
+ expect(entryWentIn({ state: 'ready' })).toBe(false)
88
+ const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
89
+ /* In the card's row beside its outcome, a button of its own - not a quiet link under the table. */
90
+ expect(chat).toMatch(/entry && entryWentIn\(card\) && isAppAddress\(p\.screen\)\s*\? html`<button class="run see" @click=\$\{\(\) => this\.openEntryScreen\(p, key, records\)\}>/)
91
+ expect(chat).toMatch(/entryScreenAddress\(p, records\.get\(key\), entity360KindRegistered\)[\s\S]{0,80}'assistant-open-page', \{ detail: \{ href \}/)
92
+ })
93
+
94
+ it('★ the note keeps which records a card entered - hidden from the person, and never as the selection', () => {
95
+ const note = entryOutcomeNote({ verdict: 'accepted', applied: 2, missed: 0, key: 'k1', target: 'office.counterparty', rows: [{ index: 0, ok: true, id: 'a', label: 'A' }, { index: 1, ok: true, id: 'b', label: 'B' }] }, t)
96
+ expect(note).toBe('entered 2 rows.\nA, B\n[records] k1 office.counterparty a b')
97
+ expect(entryRecordsOf(note)).toEqual({ key: 'k1', target: 'office.counterparty', ids: ['a', 'b'] })
98
+ expect(noteForDisplay(note)).toBe('entered 2 rows.\nA, B')
99
+ expect(note).not.toMatch(/^\[keys\]/m)
100
+ /* Nothing went in, or no ids came back: no line. */
101
+ expect(entryOutcomeNote({ verdict: 'accepted', applied: 1, missed: 0, key: 'k1', target: 'office.x', rows: [{ index: 0, ok: true }] }, t)).not.toContain('[records]')
102
+ })
103
+
104
+ it('★ "see on the screen" opens one record\'s 360, narrows a list of the same records, or opens the screen as it is', () => {
105
+ const has360 = (kind: string) => kind === 'counterparty'
106
+ const p = { target: 'office.counterparty', screen: 'counterparty-list', screenLists: true }
107
+ expect(entryScreenAddress(p, ['a'], has360)).toBe('counterparty-list?f=counterparty%3Aa')
108
+ expect(decodeURIComponent(entryScreenAddress(p, ['a', 'b'], has360))).toBe("counterparty-list?filters=[{name:'id',operator:'in',value:['a','b']}]")
109
+ /* A screen of other records (a contract's rounds on the contract list) is not narrowed by their ids. */
110
+ expect(entryScreenAddress({ target: 'office.contract-round', screen: 'contract-list' }, ['a', 'b'], has360)).toBe('contract-list')
111
+ expect(entryScreenAddress({ target: 'office.contract-round', screen: 'contract-list' }, ['a'], has360)).toBe('contract-list')
112
+ /* Too many for an address, or none known (a card from before): the screen as it is. */
113
+ expect(entryScreenAddress(p, Array.from({ length: 51 }, (_, i) => `id${i}`), has360)).toBe('counterparty-list')
114
+ expect(entryScreenAddress(p, undefined, has360)).toBe('counterparty-list')
115
+ })
116
+
71
117
  it('★ every entry card runs the one framework handler, whatever record it enters - no app registers one', async () => {
72
118
  const asked: string[] = []
73
119
  setEntryProposalHandler(
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "OK",
166
166
  "ai-assistant.dock.cancel": "Cancel",
167
167
  "ai-assistant.dock.proposal-not-runnable": "this proposal cannot be run on this screen.",
168
+ "ai-assistant.dock.screen-kept-edits": "the list was not read again - it has unsaved edits. save them, then refresh it to see what went in.",
168
169
  "ai-assistant.dock.patch-missed": "{missed} of the changes could not be applied.",
169
170
  "ai-assistant.dock.not-configured": "no AI is set up for this site. an administrator can set one up in the AI settings.",
170
171
  "ai-assistant.dock.capability-unknown": "whether AI is set up here could not be read.",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "no row was entered.",
204
205
  "ai-assistant.entry.row-refused": "row {n}: {reason}",
205
206
  "ai-assistant.entry.more-refused": "{count} more rows were not entered.",
207
+ "ai-assistant.entry.more-applied": "and {count} more",
208
+ "ai-assistant.entry.see-on-screen": "see on the screen",
206
209
  "ai-assistant.entry.issue.required": "no value",
207
210
  "ai-assistant.entry.issue.not-a-number": "not a number",
208
211
  "ai-assistant.entry.issue.not-an-integer": "not a whole number",
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "Aceptar",
166
166
  "ai-assistant.dock.cancel": "Cancelar",
167
167
  "ai-assistant.dock.proposal-not-runnable": "esta propuesta no se puede ejecutar en esta pantalla.",
168
+ "ai-assistant.dock.screen-kept-edits": "la lista no se volvió a leer porque tiene cambios sin guardar. guárdelos y actualícela para ver lo introducido.",
168
169
  "ai-assistant.dock.patch-missed": "no se pudieron aplicar {missed} de los cambios.",
169
170
  "ai-assistant.dock.not-configured": "no hay ninguna IA configurada para este sitio. un administrador puede configurarla en la configuración de IA.",
170
171
  "ai-assistant.dock.capability-unknown": "no se pudo comprobar si la IA está configurada aquí.",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "no se introdujo ninguna fila.",
204
205
  "ai-assistant.entry.row-refused": "fila {n}: {reason}",
205
206
  "ai-assistant.entry.more-refused": "no se introdujeron {count} filas más.",
207
+ "ai-assistant.entry.more-applied": "y {count} más",
208
+ "ai-assistant.entry.see-on-screen": "ver en la pantalla",
206
209
  "ai-assistant.entry.issue.required": "sin valor",
207
210
  "ai-assistant.entry.issue.not-a-number": "no es un número",
208
211
  "ai-assistant.entry.issue.not-an-integer": "no es un número entero",
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "OK",
166
166
  "ai-assistant.dock.cancel": "キャンセル",
167
167
  "ai-assistant.dock.proposal-not-runnable": "この提案はこの画面では実行できません。",
168
+ "ai-assistant.dock.screen-kept-edits": "保存していない編集があるため、一覧を読み直しませんでした。保存してから再読み込みすると入力した記録が表示されます。",
168
169
  "ai-assistant.dock.patch-missed": "変更のうち {missed} 件を適用できませんでした。",
169
170
  "ai-assistant.dock.not-configured": "この現場には AI が設定されていません。管理者が AI 設定で接続できます。",
170
171
  "ai-assistant.dock.capability-unknown": "AI が設定されているか読み取れませんでした。",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "入力された行はありません。",
204
205
  "ai-assistant.entry.row-refused": "{n}行目: {reason}",
205
206
  "ai-assistant.entry.more-refused": "さらに{count}行が入力されませんでした。",
207
+ "ai-assistant.entry.more-applied": "ほか{count}件",
208
+ "ai-assistant.entry.see-on-screen": "画面で見る",
206
209
  "ai-assistant.entry.issue.required": "値がありません",
207
210
  "ai-assistant.entry.issue.not-a-number": "数値ではありません",
208
211
  "ai-assistant.entry.issue.not-an-integer": "整数ではありません",
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "확인",
166
166
  "ai-assistant.dock.cancel": "취소",
167
167
  "ai-assistant.dock.proposal-not-runnable": "이 제안은 이 화면에서 실행할 수 없습니다.",
168
+ "ai-assistant.dock.screen-kept-edits": "저장하지 않은 편집이 있어 목록을 다시 읽지 않았습니다. 저장한 뒤 새로 고침하면 들어간 기록이 보입니다.",
168
169
  "ai-assistant.dock.patch-missed": "변경 중 {missed} 개를 적용하지 못했습니다.",
169
170
  "ai-assistant.dock.not-configured": "이 현장에는 AI 가 설정되어 있지 않습니다. 관리자가 AI 설정에서 연결할 수 있습니다.",
170
171
  "ai-assistant.dock.capability-unknown": "AI 설정 여부를 읽지 못했습니다.",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "입력한 행이 없습니다.",
204
205
  "ai-assistant.entry.row-refused": "{n}행: {reason}",
205
206
  "ai-assistant.entry.more-refused": "{count}행이 더 입력되지 않았습니다.",
207
+ "ai-assistant.entry.more-applied": "외 {count}건",
208
+ "ai-assistant.entry.see-on-screen": "화면에서 보기",
206
209
  "ai-assistant.entry.issue.required": "값이 없습니다",
207
210
  "ai-assistant.entry.issue.not-a-number": "숫자가 아닙니다",
208
211
  "ai-assistant.entry.issue.not-an-integer": "정수가 아닙니다",
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "OK",
166
166
  "ai-assistant.dock.cancel": "Batal",
167
167
  "ai-assistant.dock.proposal-not-runnable": "cadangan ini tidak boleh dijalankan pada skrin ini.",
168
+ "ai-assistant.dock.screen-kept-edits": "senarai tidak dibaca semula kerana ada suntingan belum disimpan. simpan dahulu, kemudian muat semula untuk melihat rekod yang dimasukkan.",
168
169
  "ai-assistant.dock.patch-missed": "{missed} daripada perubahan tidak dapat digunakan.",
169
170
  "ai-assistant.dock.not-configured": "tiada AI disediakan untuk tapak ini. pentadbir boleh menyediakannya dalam tetapan AI.",
170
171
  "ai-assistant.dock.capability-unknown": "sama ada AI disediakan di sini tidak dapat dibaca.",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "tiada baris dimasukkan.",
204
205
  "ai-assistant.entry.row-refused": "baris {n}: {reason}",
205
206
  "ai-assistant.entry.more-refused": "{count} baris lagi tidak dimasukkan.",
207
+ "ai-assistant.entry.more-applied": "dan {count} lagi",
208
+ "ai-assistant.entry.see-on-screen": "lihat pada skrin",
206
209
  "ai-assistant.entry.issue.required": "tiada nilai",
207
210
  "ai-assistant.entry.issue.not-a-number": "bukan nombor",
208
211
  "ai-assistant.entry.issue.not-an-integer": "bukan nombor bulat",
@@ -165,6 +165,7 @@
165
165
  "ai-assistant.dock.confirm": "确定",
166
166
  "ai-assistant.dock.cancel": "取消",
167
167
  "ai-assistant.dock.proposal-not-runnable": "此提案无法在此界面运行。",
168
+ "ai-assistant.dock.screen-kept-edits": "列表有未保存的编辑,因此未重新读取。保存后刷新即可看到录入的记录。",
168
169
  "ai-assistant.dock.patch-missed": "有 {missed} 项更改未能应用。",
169
170
  "ai-assistant.dock.not-configured": "此现场尚未设置 AI。管理员可以在 AI 设置中进行设置。",
170
171
  "ai-assistant.dock.capability-unknown": "无法读取此处是否已设置 AI。",
@@ -203,6 +204,8 @@
203
204
  "ai-assistant.entry.entered-none": "未录入任何行。",
204
205
  "ai-assistant.entry.row-refused": "第{n}行:{reason}",
205
206
  "ai-assistant.entry.more-refused": "另有{count}行未录入。",
207
+ "ai-assistant.entry.more-applied": "等另外{count}项",
208
+ "ai-assistant.entry.see-on-screen": "在页面中查看",
206
209
  "ai-assistant.entry.issue.required": "缺少值",
207
210
  "ai-assistant.entry.issue.not-a-number": "不是数字",
208
211
  "ai-assistant.entry.issue.not-an-integer": "不是整数",