@things-factory/ai-assistant 10.1.88 → 10.1.91

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 (93) hide show
  1. package/client/components/assistant-chat.ts +45 -5
  2. package/client/components/assistant-dock.ts +30 -2
  3. package/client/components/entry-review.ts +246 -0
  4. package/client/components/workspace-view.ts +428 -0
  5. package/client/pages/assistant-workspace-page.ts +237 -0
  6. package/client/route.ts +9 -0
  7. package/client/utils/assistant-proposals.ts +11 -1
  8. package/client/utils/entry-proposal.ts +103 -0
  9. package/client/utils/workspace.ts +89 -0
  10. package/dist-client/components/assistant-chat.d.ts +3 -0
  11. package/dist-client/components/assistant-chat.js +46 -5
  12. package/dist-client/components/assistant-chat.js.map +1 -1
  13. package/dist-client/components/assistant-dock.d.ts +1 -0
  14. package/dist-client/components/assistant-dock.js +25 -2
  15. package/dist-client/components/assistant-dock.js.map +1 -1
  16. package/dist-client/components/entry-review.d.ts +9 -0
  17. package/dist-client/components/entry-review.js +247 -0
  18. package/dist-client/components/entry-review.js.map +1 -0
  19. package/dist-client/components/workspace-view.d.ts +19 -0
  20. package/dist-client/components/workspace-view.js +416 -0
  21. package/dist-client/components/workspace-view.js.map +1 -0
  22. package/dist-client/pages/assistant-workspace-page.d.ts +27 -0
  23. package/dist-client/pages/assistant-workspace-page.js +263 -0
  24. package/dist-client/pages/assistant-workspace-page.js.map +1 -0
  25. package/dist-client/route.d.ts +1 -0
  26. package/dist-client/route.js +10 -0
  27. package/dist-client/route.js.map +1 -0
  28. package/dist-client/tsconfig.tsbuildinfo +1 -1
  29. package/dist-client/utils/assistant-proposals.d.ts +1 -0
  30. package/dist-client/utils/assistant-proposals.js +9 -1
  31. package/dist-client/utils/assistant-proposals.js.map +1 -1
  32. package/dist-client/utils/entry-proposal.d.ts +55 -0
  33. package/dist-client/utils/entry-proposal.js +52 -0
  34. package/dist-client/utils/entry-proposal.js.map +1 -0
  35. package/dist-client/utils/workspace.d.ts +40 -0
  36. package/dist-client/utils/workspace.js +68 -0
  37. package/dist-client/utils/workspace.js.map +1 -0
  38. package/dist-server/service/assistant-mode/registry.js +13 -0
  39. package/dist-server/service/assistant-mode/registry.js.map +1 -1
  40. package/dist-server/service/assistant-mode/resolve.d.ts +11 -0
  41. package/dist-server/service/assistant-mode/resolve.js +15 -3
  42. package/dist-server/service/assistant-mode/resolve.js.map +1 -1
  43. package/dist-server/service/assistant-mode/types.d.ts +7 -1
  44. package/dist-server/service/assistant-mode/types.js.map +1 -1
  45. package/dist-server/service/data-entry/entry-apply.d.ts +46 -0
  46. package/dist-server/service/data-entry/entry-apply.js +102 -0
  47. package/dist-server/service/data-entry/entry-apply.js.map +1 -0
  48. package/dist-server/service/data-entry/entry-resolver.d.ts +4 -0
  49. package/dist-server/service/data-entry/entry-resolver.js +80 -0
  50. package/dist-server/service/data-entry/entry-resolver.js.map +1 -0
  51. package/dist-server/service/data-entry/entry-sources.d.ts +1 -0
  52. package/dist-server/service/data-entry/entry-sources.js +41 -0
  53. package/dist-server/service/data-entry/entry-sources.js.map +1 -0
  54. package/dist-server/service/data-entry/entry-target.d.ts +157 -0
  55. package/dist-server/service/data-entry/entry-target.js +343 -0
  56. package/dist-server/service/data-entry/entry-target.js.map +1 -0
  57. package/dist-server/service/data-entry/entry-tools.d.ts +43 -0
  58. package/dist-server/service/data-entry/entry-tools.js +185 -0
  59. package/dist-server/service/data-entry/entry-tools.js.map +1 -0
  60. package/dist-server/service/index.d.ts +6 -1
  61. package/dist-server/service/index.js +13 -2
  62. package/dist-server/service/index.js.map +1 -1
  63. package/dist-server/service/workspace/workspace-blocks.d.ts +24 -0
  64. package/dist-server/service/workspace/workspace-blocks.js +118 -0
  65. package/dist-server/service/workspace/workspace-blocks.js.map +1 -0
  66. package/dist-server/service/workspace/workspace-tools.d.ts +24 -0
  67. package/dist-server/service/workspace/workspace-tools.js +65 -0
  68. package/dist-server/service/workspace/workspace-tools.js.map +1 -0
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +2 -2
  71. package/server/service/assistant-mode/registry.ts +13 -0
  72. package/server/service/assistant-mode/resolve.test.ts +2 -1
  73. package/server/service/assistant-mode/resolve.ts +15 -3
  74. package/server/service/assistant-mode/types.ts +7 -1
  75. package/server/service/data-entry/data-entry.test.ts +311 -0
  76. package/server/service/data-entry/entry-apply.ts +134 -0
  77. package/server/service/data-entry/entry-resolver.ts +74 -0
  78. package/server/service/data-entry/entry-sources.ts +35 -0
  79. package/server/service/data-entry/entry-target.ts +442 -0
  80. package/server/service/data-entry/entry-tools.ts +174 -0
  81. package/server/service/index.ts +10 -1
  82. package/server/service/workspace/workspace-blocks.ts +127 -0
  83. package/server/service/workspace/workspace-tools.ts +66 -0
  84. package/server/service/workspace/workspace.test.ts +58 -0
  85. package/test/entry-proposal.test.ts +67 -0
  86. package/test/open-doors.test.ts +2 -1
  87. package/test/workspace.test.ts +54 -0
  88. package/things-factory.config.js +6 -1
  89. package/translations/en.json +56 -1
  90. package/translations/ja.json +56 -1
  91. package/translations/ko.json +56 -1
  92. package/translations/ms.json +56 -1
  93. package/translations/zh.json +56 -1
@@ -10,6 +10,8 @@ import { entities as ChatSessionParticipantEntities } from './chat-session-parti
10
10
  import './chat-session/attachment-tools.js'
11
11
  import './chat-session/session-purge.js'
12
12
  import './chat-session/attachment-route.js'
13
+ import { EntryProposalResolver } from './data-entry/entry-resolver.js'
14
+ import './workspace/workspace-tools.js'
13
15
 
14
16
  export * from './types.js'
15
17
  export * from './chat-session-resolver.js'
@@ -23,6 +25,11 @@ export * from './assistant-capability-resolver.js'
23
25
  * 실린다. 시험은 경로로 가져온다: `…/service/testing/conversation-script.js`.
24
26
  */
25
27
  export * from './tool-access.js'
28
+ /* Data entry from material - an app registers what it can enter; a mode names it (design/ai/data-entry.md). */
29
+ export * from './data-entry/entry-target.js'
30
+ export { registerEntryTarget, entryCategoryOf } from './data-entry/entry-tools.js'
31
+ /* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
32
+ export * from './workspace/workspace-blocks.js'
26
33
  /* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
27
34
  export * from './grounding-sources.js'
28
35
  /* 앵커 순수 로직 — 호스트가 앵커 종류를 참조한다(리졸버는 데코레이터 때문에 무겁다). */
@@ -63,6 +70,8 @@ export const schema = {
63
70
  AssistantCapabilityResolver,
64
71
  ChatMessageSubscription,
65
72
  /* 내가 참여한 모든 세션의 활동 — 창을 닫아 둔 표면이 배지·목록을 갱신할 수 있게. */
66
- MySessionActivitySubscription
73
+ MySessionActivitySubscription,
74
+ /* 사람이 누른 입력 제안을 쓰는 자리 — 행마다 앱의 코드로, 그 사람의 권한으로. */
75
+ EntryProposalResolver
67
76
  ]
68
77
  }
@@ -0,0 +1,127 @@
1
+ /*
2
+ * The AI 작업판 - what a workspace is made of (operato-application/design/ai/workspace.md §3).
3
+ *
4
+ * The model does not write HTML. A workspace is a list of blocks of known kinds; the screen draws each, so
5
+ * theme, language and accessibility are the framework's. A block that does not fit is not dropped: it is
6
+ * kept as a fact in its place ("a block that cannot be drawn"), so the person sees that something was meant
7
+ * to be there. Pure - tested without a server.
8
+ */
9
+
10
+ export const WORKSPACE_BLOCK_KINDS = ['heading', 'text', 'metrics', 'table', 'chart', 'steps'] as const
11
+ export type WorkspaceBlockKind = (typeof WORKSPACE_BLOCK_KINDS)[number]
12
+
13
+ export const MAX_BLOCKS = 40
14
+ export const MAX_TABLE_ROWS = 1000
15
+ export const MAX_SERIES = 8
16
+ const MAX_TEXT = 20_000
17
+
18
+ export interface WorkspaceBlock {
19
+ kind: WorkspaceBlockKind | 'unreadable'
20
+ [key: string]: unknown
21
+ }
22
+
23
+ export interface Workspace {
24
+ name: string
25
+ title: string
26
+ summary?: string
27
+ blocks: WorkspaceBlock[]
28
+ }
29
+
30
+ const text = (value: unknown, max = 500): string | undefined => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : undefined)
31
+ const scalar = (value: unknown): string | number | boolean | null =>
32
+ value === null || value === undefined ? null : typeof value === 'number' || typeof value === 'boolean' ? value : String(value).slice(0, 500)
33
+
34
+ /** A short key of the workspace - the same name in one conversation is the same workspace, a new version. */
35
+ export function workspaceName(name: unknown, title: string): string {
36
+ const given = typeof name === 'string' ? name.trim().toLowerCase() : ''
37
+ if (/^[a-z0-9][a-z0-9-]{0,63}$/.test(given)) return given
38
+ const slug = (given || title)
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9가-힣]+/g, '-')
41
+ .replace(/^-+|-+$/g, '')
42
+ .slice(0, 64)
43
+ return slug || 'workspace'
44
+ }
45
+
46
+ function unreadable(kind: unknown, reason: string): WorkspaceBlock {
47
+ return { kind: 'unreadable', was: typeof kind === 'string' ? kind.slice(0, 40) : null, reason }
48
+ }
49
+
50
+ function block(raw: any): WorkspaceBlock {
51
+ const kind = raw?.kind
52
+ switch (kind) {
53
+ case 'heading': {
54
+ const value = text(raw.text, 200)
55
+ if (!value) return unreadable(kind, 'no-text')
56
+ const level = [1, 2, 3].includes(raw.level) ? raw.level : 2
57
+ return { kind, text: value, level }
58
+ }
59
+ case 'text': {
60
+ const value = text(raw.markdown ?? raw.text, MAX_TEXT)
61
+ return value ? { kind, markdown: value } : unreadable(kind, 'no-text')
62
+ }
63
+ case 'metrics': {
64
+ const items = (Array.isArray(raw.items) ? raw.items : [])
65
+ .filter((one: any) => text(one?.label) && one?.value !== undefined && one?.value !== null)
66
+ .slice(0, 12)
67
+ .map((one: any) => ({ label: text(one.label, 80), value: scalar(one.value), ...(text(one.unit, 20) ? { unit: text(one.unit, 20) } : {}), ...(text(one.note, 200) ? { note: text(one.note, 200) } : {}) }))
68
+ return items.length ? { kind, items } : unreadable(kind, 'no-items')
69
+ }
70
+ case 'table': {
71
+ const columns = (Array.isArray(raw.columns) ? raw.columns : [])
72
+ .filter((one: any) => text(one?.name, 80))
73
+ .slice(0, 30)
74
+ .map((one: any) => ({ name: text(one.name, 80)!, ...(text(one.label, 80) ? { label: text(one.label, 80) } : {}), type: ['number', 'date', 'string'].includes(one.type) ? one.type : 'string' }))
75
+ if (!columns.length) return unreadable(kind, 'no-columns')
76
+ const given = Array.isArray(raw.rows) ? raw.rows : []
77
+ /* A row is an object by column name, or an array in column order - models write both. */
78
+ const rows = given.slice(0, MAX_TABLE_ROWS).map((row: any) =>
79
+ Object.fromEntries(columns.map((column: any, i: number) => [column.name, scalar(Array.isArray(row) ? row[i] : row?.[column.name])]))
80
+ )
81
+ return { kind, columns, rows, ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}), ...(given.length > MAX_TABLE_ROWS ? { cut: given.length - MAX_TABLE_ROWS } : {}) }
82
+ }
83
+ case 'chart': {
84
+ const x = (Array.isArray(raw.x) ? raw.x : []).slice(0, 200).map((one: unknown) => String(one ?? '').slice(0, 60))
85
+ const series = (Array.isArray(raw.series) ? raw.series : [])
86
+ .filter((one: any) => text(one?.name, 60) && Array.isArray(one?.values))
87
+ .map((one: any) => ({ name: text(one.name, 60)!, values: x.map((_: unknown, i: number) => (typeof one.values[i] === 'number' && Number.isFinite(one.values[i]) ? one.values[i] : null)) }))
88
+ if (!x.length || !series.length) return unreadable(kind, 'no-series')
89
+ /* Never a ninth hue: past eight, the first seven stay and the rest fold into one "other". */
90
+ const shown =
91
+ series.length > MAX_SERIES
92
+ ? [
93
+ ...series.slice(0, MAX_SERIES - 1),
94
+ { name: 'other', other: true, values: x.map((_: unknown, i: number) => series.slice(MAX_SERIES - 1).reduce((acc: number, one: any) => acc + (one.values[i] ?? 0), 0)) }
95
+ ]
96
+ : series
97
+ return { kind, chart: raw.chart === 'line' || raw.type === 'line' || raw.shape === 'line' ? 'line' : 'bar', x, series: shown, ...(text(raw.unit, 20) ? { unit: text(raw.unit, 20) } : {}), ...(text(raw.caption, 300) ? { caption: text(raw.caption, 300) } : {}) }
98
+ }
99
+ case 'steps': {
100
+ const items = (Array.isArray(raw.items) ? raw.items : [])
101
+ .filter((one: any) => text(one?.label, 200))
102
+ .slice(0, 50)
103
+ .map((one: any) => ({ label: text(one.label, 200), state: ['done', 'doing', 'todo', 'failed'].includes(one.state) ? one.state : 'todo', ...(text(one.note, 300) ? { note: text(one.note, 300) } : {}) }))
104
+ return items.length ? { kind, items } : unreadable(kind, 'no-items')
105
+ }
106
+ default:
107
+ return unreadable(kind, 'unknown-kind')
108
+ }
109
+ }
110
+
111
+ /** The workspace as the screen reads it. */
112
+ export function normalizeWorkspace(args: any): { workspace?: Workspace; error?: string } {
113
+ const title = text(args?.title, 200)
114
+ if (!title) return { error: 'no-title' }
115
+ const given = Array.isArray(args?.blocks) ? args.blocks : []
116
+ if (!given.length) return { error: 'no-blocks' }
117
+ const blocks = given.slice(0, MAX_BLOCKS).map(block)
118
+ if (given.length > MAX_BLOCKS) blocks.push({ kind: 'unreadable', was: null, reason: 'too-many-blocks', cut: given.length - MAX_BLOCKS })
119
+ return { workspace: { name: workspaceName(args?.name, title), title, ...(text(args?.summary, 500) ? { summary: text(args.summary, 500) } : {}), blocks } }
120
+ }
121
+
122
+ /** One line of what is in it - for the card in the conversation and for the model. */
123
+ export function workspaceContents(workspace: Workspace): Record<string, number> {
124
+ const counts: Record<string, number> = {}
125
+ for (const one of workspace.blocks) if (['table', 'chart', 'metrics', 'steps'].includes(one.kind)) counts[one.kind] = (counts[one.kind] ?? 0) + 1
126
+ return counts
127
+ }
@@ -0,0 +1,66 @@
1
+ /*
2
+ * showInWorkspace - the model puts a large result on the AI 작업판 (operato-application/design/ai/workspace.md §5).
3
+ *
4
+ * The result rides on the answer as a proposal of kind 'workspace', so the conversation's gates hold for it:
5
+ * who may read the conversation reads the workspace, and a value behind a door the reader cannot pass hides
6
+ * it with the answer (ADR-0073). The tool itself reaches no door - what it carries came from the turn's other
7
+ * tools, whose doors the answer row collects.
8
+ */
9
+ import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
10
+
11
+ import { normalizeWorkspace, workspaceContents, WORKSPACE_BLOCK_KINDS } from './workspace-blocks.js'
12
+
13
+ export const WORKSPACE_CATEGORY = 'assistant-workspace'
14
+
15
+ export function showInWorkspace(args: any) {
16
+ const { workspace, error } = normalizeWorkspace(args)
17
+ if (!workspace) return { rejected: true, error, message: 'a workspace needs a title and at least one block.' }
18
+ const contents = workspaceContents(workspace)
19
+ return {
20
+ proposed: true,
21
+ kind: 'workspace',
22
+ icon: 'dashboard',
23
+ label: workspace.title,
24
+ workspace,
25
+ contents,
26
+ message: `put "${workspace.title}" on the workspace (${workspace.blocks.length} blocks). The person opens it from the card - say in one line what it holds; do not repeat its tables in the answer.`
27
+ }
28
+ }
29
+
30
+ const spec = {
31
+ kind: 'read',
32
+ name: 'showInWorkspace',
33
+ doors: [],
34
+ description:
35
+ 'Put a large result on the AI workspace - a page the person opens from a card, beside the conversation. Blocks: ' +
36
+ 'heading{text,level}, text{markdown}, metrics{items:[{label,value,unit,note}]}, table{columns:[{name,label,type:number|date|string}],rows,caption}, ' +
37
+ 'chart{chart:bar|line, x:[labels], series:[{name,values:[numbers]}], unit, caption}, steps{items:[{label,state:done|doing|todo|failed,note}]}.',
38
+ schema: {
39
+ type: 'object',
40
+ properties: {
41
+ name: { type: 'string', description: 'A short key, lowercase with dashes ("contracts-by-customer"). The same name again makes a new version of the same workspace.' },
42
+ title: { type: 'string' },
43
+ summary: { type: 'string', description: 'One line under the title.' },
44
+ blocks: {
45
+ type: 'array',
46
+ items: { type: 'object', properties: { kind: { type: 'string', enum: [...WORKSPACE_BLOCK_KINDS] } }, required: ['kind'] }
47
+ }
48
+ },
49
+ required: ['name', 'title', 'blocks']
50
+ },
51
+ builder: (args: any) => showInWorkspace(args)
52
+ } as ToolSpec
53
+
54
+ export const workspaceCategory: ToolCategory = {
55
+ name: WORKSPACE_CATEGORY,
56
+ description: 'the AI workspace - a page for results too large for the conversation',
57
+ guidance: [
58
+ '- When a result is more than a short table (over 20 rows, or two or more tables or charts), or the person asks for a page, call showInWorkspace. Keep the answer short: the point, and that it is on the workspace.',
59
+ '- Every number in a block comes from a tool result in this conversation - never computed or guessed by you.',
60
+ '- To change a workspace, call showInWorkspace again with the same name and the whole new content; it becomes a new version.',
61
+ '- Never say something is on the workspace unless you called showInWorkspace in this turn.'
62
+ ].join('\n'),
63
+ specs: [spec]
64
+ }
65
+
66
+ registerToolCategory(workspaceCategory)
@@ -0,0 +1,58 @@
1
+ /*
2
+ * The AI 작업판 (design/ai/workspace.md) - what a block may be, what happens to one that is not, and what the
3
+ * tool hands back.
4
+ */
5
+ import { normalizeWorkspace, workspaceName, MAX_SERIES } from './workspace-blocks'
6
+ import { showInWorkspace, workspaceCategory } from './workspace-tools'
7
+
8
+ describe('the workspace', () => {
9
+ it('★ is blocks of known kinds - a block that does not fit stays in its place as a fact, never dropped silently', () => {
10
+ const { workspace } = normalizeWorkspace({
11
+ name: 'contracts',
12
+ title: '2026 contracts',
13
+ blocks: [{ kind: 'heading', text: 'By customer' }, { kind: 'html', html: '<script>x</script>' }, { kind: 'table', rows: [] }]
14
+ })
15
+ expect(workspace!.blocks).toEqual([
16
+ { kind: 'heading', text: 'By customer', level: 2 },
17
+ { kind: 'unreadable', was: 'html', reason: 'unknown-kind' },
18
+ { kind: 'unreadable', was: 'table', reason: 'no-columns' }
19
+ ])
20
+ })
21
+
22
+ it('reads a table row as an object by column or an array in column order, and cuts at the limit saying how many', () => {
23
+ const rows = [{ customer: 'A', amount: 10 }, ['B', 20], ...Array.from({ length: 1000 }, () => ({ customer: 'x', amount: 1 }))]
24
+ const table: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'table', columns: [{ name: 'customer' }, { name: 'amount', type: 'number' }], rows }] }).workspace!.blocks[0]
25
+ expect(table.rows.slice(0, 2)).toEqual([{ customer: 'A', amount: 10 }, { customer: 'B', amount: 20 }])
26
+ expect(table.rows).toHaveLength(1000)
27
+ expect(table.cut).toBe(2)
28
+ })
29
+
30
+ it('★ a chart never gets a ninth colour - past eight series the rest fold into "other"', () => {
31
+ const series = Array.from({ length: 10 }, (_, i) => ({ name: `s${i}`, values: [1, 2] }))
32
+ const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', x: ['Jan', 'Feb'], series }] }).workspace!.blocks[0]
33
+ expect(chart.series).toHaveLength(MAX_SERIES)
34
+ expect(chart.series[MAX_SERIES - 1]).toEqual({ name: 'other', other: true, values: [3, 6] })
35
+ expect(chart.chart).toBe('bar')
36
+ })
37
+
38
+ it('a value that is not a number is a gap in the chart, never a zero', () => {
39
+ const chart: any = normalizeWorkspace({ title: 't', blocks: [{ kind: 'chart', chart: 'line', x: ['a', 'b', 'c'], series: [{ name: 'n', values: [1, 'x', 3] }] }] }).workspace!.blocks[0]
40
+ expect(chart.series[0].values).toEqual([1, null, 3])
41
+ })
42
+
43
+ it('the same name is the same workspace - a name that is not a key is made one from it', () => {
44
+ expect(workspaceName('contracts-by-customer', 'x')).toBe('contracts-by-customer')
45
+ expect(workspaceName(undefined, '2026 Contracts / 고객사')).toBe('2026-contracts-고객사')
46
+ })
47
+
48
+ it('★ the tool hands back a proposal of kind workspace, so the answer row carries it - and the conversation gates it', () => {
49
+ const result: any = showInWorkspace({ name: 'c', title: 'Contracts', blocks: [{ kind: 'table', columns: [{ name: 'a' }], rows: [[1]] }, { kind: 'metrics', items: [{ label: 'n', value: 26 }] }] })
50
+ expect(result).toMatchObject({ proposed: true, kind: 'workspace', label: 'Contracts', contents: { table: 1, metrics: 1 } })
51
+ expect((workspaceCategory.specs[0] as any).doors).toEqual([])
52
+ })
53
+
54
+ it('no title or no blocks is refused, with what it needs', () => {
55
+ expect(showInWorkspace({ blocks: [{ kind: 'text', markdown: 'x' }] })).toMatchObject({ rejected: true, error: 'no-title' })
56
+ expect(showInWorkspace({ title: 'x', blocks: [] })).toMatchObject({ rejected: true, error: 'no-blocks' })
57
+ })
58
+ })
@@ -0,0 +1,67 @@
1
+ /*
2
+ * The data entry card on the screen (design/ai/data-entry.md §5) - what it counts, which card is current,
3
+ * and what it leaves in the conversation. The chat's wiring is read as text: importing it pulls the shell.
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import { runProposal, setEntryProposalHandler } from '../client/utils/assistant-proposals'
9
+ import {
10
+ entryOutcomeNote,
11
+ entryProposalHandler,
12
+ entryReadyCount,
13
+ latestEntryKeys,
14
+ type EntryApplyAnswer
15
+ } from '../client/utils/entry-proposal'
16
+
17
+ const t = (key: string, options: any = {}) =>
18
+ String(options.defaultValue ?? key).replace(/\{(\w+)\}/g, (_, name) => String(options[name] ?? ''))
19
+
20
+ const card = (key: string, target = 'office.expense-line', rows: any[] = []) => ({ key, kind: 'entry', target, rows })
21
+
22
+ describe('the entry card', () => {
23
+ it('★ counts only the rows that will go in', () => {
24
+ const p = card('k', undefined, [
25
+ { index: 0, values: {}, sources: [], issues: [] },
26
+ { index: 1, values: {}, sources: [], issues: [{ code: 'required', field: 'amount', blocking: true }] },
27
+ { index: 2, values: {}, sources: [], issues: [{ code: 'no-source', blocking: false }] }
28
+ ])
29
+ expect(entryReadyCount(p)).toBe(2)
30
+ })
31
+
32
+ it('★ the newest card per record is the current one - an older one is replaced', () => {
33
+ const latest = latestEntryKeys([[card('k1')], [{ key: 'x', tool: 'proposeAction' }], [card('k2'), card('k3', 'office.leave')]])
34
+ expect(latest.get('office.expense-line')).toBe('k2')
35
+ expect(latest.get('office.leave')).toBe('k3')
36
+ })
37
+
38
+ it('leaves in the conversation what went in and each row that did not, by its place', () => {
39
+ const answer: EntryApplyAnswer = {
40
+ verdict: 'accepted',
41
+ applied: 2,
42
+ missed: 1,
43
+ rows: [{ index: 0, ok: true }, { index: 1, ok: false, error: 'closed period' }, { index: 2, ok: true }]
44
+ }
45
+ expect(entryOutcomeNote(answer, t)).toBe('entered 2 rows.\nrow 2: closed period')
46
+ })
47
+
48
+ it('★ every entry card runs the one framework handler, whatever record it enters - no app registers one', async () => {
49
+ const asked: string[] = []
50
+ setEntryProposalHandler(
51
+ entryProposalHandler(async (sessionId, key) => {
52
+ asked.push(`${sessionId}/${key}`)
53
+ return { verdict: 'accepted', applied: 1, missed: 0, rows: [{ index: 0, ok: true }] }
54
+ }, t)
55
+ )
56
+ const outcome = await runProposal({ ...card('k9'), tool: 'proposeOfficeExpenseLineEntries' }, { sessionId: 's1', confirm: async () => true, notify: () => {} })
57
+ expect(asked).toEqual(['s1/k9'])
58
+ expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 0 })
59
+ })
60
+
61
+ it('the chat draws the review and names the button by the rows it enters', () => {
62
+ const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
63
+ expect(chat).toContain('<ox-assistant-entry-review .proposal=${p}>')
64
+ expect(chat).toContain("'ai-assistant.entry.enter'")
65
+ expect(chat).toContain("'ai-assistant.entry.replaced'")
66
+ })
67
+ })
@@ -142,11 +142,12 @@ describe('what the walk itself has to be true for', () => {
142
142
  * 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
143
143
  * 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
144
144
  * 2026-09-28: 29 — requestProposal (a proposal is requested once, ADR-0097 Q-D4), declared.
145
+ * 2026-09-30: 30 — applyEntryProposal (enter the rows the person pressed, design/ai/data-entry.md), declared.
145
146
  */
146
147
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
147
148
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
148
149
 
149
- expect(all).toBe(29)
150
+ expect(all).toBe(30)
150
151
  expect(open).toBe(2)
151
152
  })
152
153
  })
@@ -0,0 +1,54 @@
1
+ /*
2
+ * The AI 작업판 on the screen (design/ai/workspace.md) - which versions a reader sees, the card's version, and the
3
+ * table's sort and CSV. The page and the chat are read as text: importing them pulls the shell.
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join } from 'path'
7
+
8
+ import { sortRows, tableCsv, versionOfCard, workspaceAddress, workspaceVersions } from '../client/utils/workspace'
9
+
10
+ const ws = (name: string, title = name) => ({ key: `k-${title}`, kind: 'workspace', workspace: { name, title, blocks: [] } })
11
+
12
+ describe('the workspace on the screen', () => {
13
+ it('★ the same name in one conversation is one workspace; its versions are counted in order', () => {
14
+ const messages = [
15
+ { id: 'm1', proposalsJson: [ws('contracts', 'v1')] },
16
+ { id: 'm2', proposalsJson: [{ key: 'x', tool: 'proposeAction' }, ws('loans', 'l1')] },
17
+ { id: 'm3', proposalsJson: null },
18
+ { id: 'm4', proposalsJson: [ws('contracts', 'v2')] }
19
+ ]
20
+ expect(workspaceVersions(messages, 'contracts').map(one => `${one.version}:${one.messageId}:${one.workspace.title}`)).toEqual(['1:m1:v1', '2:m4:v2'])
21
+ })
22
+
23
+ it('★ a version behind a door the reader cannot pass is not among them - its row comes back without proposals', () => {
24
+ const messages = [{ id: 'm1', proposalsJson: [ws('contracts', 'v1')] }, { id: 'm2', proposalsJson: null }]
25
+ expect(workspaceVersions(messages, 'contracts')).toHaveLength(1)
26
+ })
27
+
28
+ it('a card knows which version it is', () => {
29
+ expect(versionOfCard([[ws('c', 'a')], [ws('d', 'x')], [ws('c', 'b')]], 'c', 'k-b')).toEqual({ version: 2, of: 2 })
30
+ })
31
+
32
+ it('the address carries the conversation, the name and the version', () => {
33
+ expect(workspaceAddress('s-1', 'contracts', 2)).toBe('assistant-workspace?session=s-1&name=contracts&version=2')
34
+ })
35
+
36
+ it('sorts numbers as numbers and puts empty cells last, both ways', () => {
37
+ const rows = [{ v: 10 }, { v: null }, { v: 9 }, { v: 100 }]
38
+ expect(sortRows(rows, 'v', 'asc').map(row => row.v)).toEqual([9, 10, 100, null])
39
+ expect(sortRows(rows, 'v', 'desc').map(row => row.v)).toEqual([100, 10, 9, null])
40
+ })
41
+
42
+ it('CSV quotes what needs quoting and starts with a BOM so a spreadsheet reads Korean', () => {
43
+ expect(tableCsv([{ name: 'a', label: '고객사' }, { name: 'b' }], [{ a: 'A, Inc', b: 'say "hi"' }])).toBe('고객사,b\r\n"A, Inc","say ""hi"""')
44
+ })
45
+
46
+ it('★ the card opens the page and runs nothing; the page reads the conversation through its own door', () => {
47
+ const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
48
+ expect(chat).toContain('if (isWorkspaceProposal(p)) return this.renderWorkspaceCard(p, key)')
49
+ const page = readFileSync(join(__dirname, '..', 'client', 'pages', 'assistant-workspace-page.ts'), 'utf8')
50
+ expect(page).toContain('chatMessages(sessionId: $sessionId')
51
+ const config = readFileSync(join(__dirname, '..', 'things-factory.config.js'), 'utf8')
52
+ expect(config).toContain("page: 'assistant-workspace'")
53
+ })
54
+ })
@@ -1 +1,6 @@
1
- export default {}
1
+ import route from './dist-client/route.js'
2
+
3
+ export default {
4
+ route,
5
+ routes: [{ tagname: 'assistant-workspace-page', page: 'assistant-workspace' }]
6
+ }
@@ -197,5 +197,60 @@
197
197
  "ai-assistant.session.filter-private": "Private",
198
198
  "ai-assistant.session.filter-unread": "Unread",
199
199
  "ai-assistant.dock.ask-needs-conversation": "the question was not sent.",
200
- "ai-assistant.dock.audience-open-band": "Public conversation - everyone in this domain can read and write here."
200
+ "ai-assistant.dock.audience-open-band": "Public conversation - everyone in this domain can read and write here.",
201
+ "ai-assistant.entry.rows": "{count} rows",
202
+ "ai-assistant.entry.ready": "{count} ready",
203
+ "ai-assistant.entry.blocked": "{count} will not be entered",
204
+ "ai-assistant.entry.cut": "{count} left out over the limit",
205
+ "ai-assistant.entry.source": "from",
206
+ "ai-assistant.entry.missing": "missing",
207
+ "ai-assistant.entry.more-rows": "{count} more rows are not shown - they are entered too.",
208
+ "ai-assistant.entry.check-failed": "the application could not check these rows; they are checked again when entered.",
209
+ "ai-assistant.entry.replaced": "replaced by a newer card",
210
+ "ai-assistant.entry.enter": "enter {count} rows",
211
+ "ai-assistant.entry.nothing-to-enter": "nothing to enter",
212
+ "ai-assistant.entry.entered": "entered {count} rows.",
213
+ "ai-assistant.entry.entered-none": "no row was entered.",
214
+ "ai-assistant.entry.row-refused": "row {n}: {reason}",
215
+ "ai-assistant.entry.more-refused": "{count} more rows were not entered.",
216
+ "ai-assistant.entry.issue.required": "no value",
217
+ "ai-assistant.entry.issue.not-a-number": "not a number",
218
+ "ai-assistant.entry.issue.not-an-integer": "not a whole number",
219
+ "ai-assistant.entry.issue.not-a-date": "not a date",
220
+ "ai-assistant.entry.issue.not-a-boolean": "not yes or no",
221
+ "ai-assistant.entry.issue.not-in-list": "not one of {values}",
222
+ "ai-assistant.entry.issue.unknown-field": "not a field of this record - left out",
223
+ "ai-assistant.entry.issue.no-source": "no source given",
224
+ "ai-assistant.entry.verified": "{count} found in their file",
225
+ "ai-assistant.entry.unmatched": "{count} with a value their file does not have",
226
+ "ai-assistant.entry.evidence": "found on line {line} of the file",
227
+ "ai-assistant.entry.total": "{field} total {sum}",
228
+ "ai-assistant.entry.total-ready": "entered {sum}",
229
+ "ai-assistant.entry.total-declared": "the material says {sum}",
230
+ "ai-assistant.entry.total-not-found": "not found in the file",
231
+ "ai-assistant.entry.total-differs": "differs by {sum}",
232
+ "ai-assistant.entry.total-matches": "matches",
233
+ "ai-assistant.entry.issue.not-in-source": "the file does not have this value",
234
+ "ai-assistant.entry.issue.source-scattered": "the values are not on one line of the file",
235
+ "ai-assistant.entry.issue.source-not-checked": "a photo cannot be checked here",
236
+ "ai-assistant.workspace.title": "AI workspace",
237
+ "ai-assistant.workspace.unreadable": "a block that cannot be drawn ({reason})",
238
+ "ai-assistant.workspace.rows": "{count} rows",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "{count} more rows were left out over the limit.",
241
+ "ai-assistant.workspace.other": "other",
242
+ "ai-assistant.workspace.as-chart": "chart",
243
+ "ai-assistant.workspace.as-table": "table",
244
+ "ai-assistant.workspace.loading": "loading the workspace…",
245
+ "ai-assistant.workspace.unreadable-page": "could not read this workspace: {reason}",
246
+ "ai-assistant.workspace.not-found": "there is no workspace here that you can see.",
247
+ "ai-assistant.workspace.newer": "a newer version ({version}) is ready.",
248
+ "ai-assistant.workspace.show-newer": "show it",
249
+ "ai-assistant.workspace.versions": "versions",
250
+ "ai-assistant.workspace.version": "version {version} of {of}",
251
+ "ai-assistant.workspace.open": "open in workspace",
252
+ "ai-assistant.workspace.contains-table": "{count} tables",
253
+ "ai-assistant.workspace.contains-chart": "{count} charts",
254
+ "ai-assistant.workspace.contains-metrics": "{count} figures",
255
+ "ai-assistant.workspace.contains-steps": "{count} progress"
201
256
  }
@@ -197,5 +197,60 @@
197
197
  "ai-assistant.session.filter-private": "非公開",
198
198
  "ai-assistant.session.filter-unread": "未読",
199
199
  "ai-assistant.dock.ask-needs-conversation": "質問は送信されませんでした。",
200
- "ai-assistant.dock.audience-open-band": "公開会話 — この事業所の誰でも読み書きできます。"
200
+ "ai-assistant.dock.audience-open-band": "公開会話 — この事業所の誰でも読み書きできます。",
201
+ "ai-assistant.entry.rows": "{count}行",
202
+ "ai-assistant.entry.ready": "入力可能 {count}",
203
+ "ai-assistant.entry.blocked": "{count}行は入力しません",
204
+ "ai-assistant.entry.cut": "上限を超えた{count}行は除外されました",
205
+ "ai-assistant.entry.source": "出典",
206
+ "ai-assistant.entry.missing": "なし",
207
+ "ai-assistant.entry.more-rows": "残り{count}行は表示されませんが、一緒に入力します。",
208
+ "ai-assistant.entry.check-failed": "アプリケーションがこれらの行を検査できませんでした。入力時に再検査します。",
209
+ "ai-assistant.entry.replaced": "新しいカードに置き換え",
210
+ "ai-assistant.entry.enter": "{count}行を入力",
211
+ "ai-assistant.entry.nothing-to-enter": "入力できる行なし",
212
+ "ai-assistant.entry.entered": "{count}行を入力しました。",
213
+ "ai-assistant.entry.entered-none": "入力された行はありません。",
214
+ "ai-assistant.entry.row-refused": "{n}行目: {reason}",
215
+ "ai-assistant.entry.more-refused": "さらに{count}行が入力されませんでした。",
216
+ "ai-assistant.entry.issue.required": "値がありません",
217
+ "ai-assistant.entry.issue.not-a-number": "数値ではありません",
218
+ "ai-assistant.entry.issue.not-an-integer": "整数ではありません",
219
+ "ai-assistant.entry.issue.not-a-date": "日付ではありません",
220
+ "ai-assistant.entry.issue.not-a-boolean": "はい/いいえではありません",
221
+ "ai-assistant.entry.issue.not-in-list": "許可された値ではありません ({values})",
222
+ "ai-assistant.entry.issue.unknown-field": "この記録にない項目のため除外しました",
223
+ "ai-assistant.entry.issue.no-source": "出典がありません",
224
+ "ai-assistant.entry.verified": "{count}行を資料と照合済み",
225
+ "ai-assistant.entry.unmatched": "{count}行に資料にない値",
226
+ "ai-assistant.entry.evidence": "資料の{line}行目で確認",
227
+ "ai-assistant.entry.total": "{field} 合計 {sum}",
228
+ "ai-assistant.entry.total-ready": "入力分 {sum}",
229
+ "ai-assistant.entry.total-declared": "資料の合計 {sum}",
230
+ "ai-assistant.entry.total-not-found": "資料で見つかりません",
231
+ "ai-assistant.entry.total-differs": "差 {sum}",
232
+ "ai-assistant.entry.total-matches": "一致",
233
+ "ai-assistant.entry.issue.not-in-source": "資料にない値です",
234
+ "ai-assistant.entry.issue.source-scattered": "値が資料の同じ行にありません",
235
+ "ai-assistant.entry.issue.source-not-checked": "写真の資料は照合できませんでした",
236
+ "ai-assistant.workspace.title": "AI作業ボード",
237
+ "ai-assistant.workspace.unreadable": "描画できないブロックです ({reason})",
238
+ "ai-assistant.workspace.rows": "{count}行",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "上限を超えた{count}行は除外されました。",
241
+ "ai-assistant.workspace.other": "その他",
242
+ "ai-assistant.workspace.as-chart": "チャート",
243
+ "ai-assistant.workspace.as-table": "表で見る",
244
+ "ai-assistant.workspace.loading": "作業ボードを読み込み中…",
245
+ "ai-assistant.workspace.unreadable-page": "作業ボードを読み込めませんでした: {reason}",
246
+ "ai-assistant.workspace.not-found": "表示できる作業ボードがありません。",
247
+ "ai-assistant.workspace.newer": "新しい版({version})があります。",
248
+ "ai-assistant.workspace.show-newer": "表示",
249
+ "ai-assistant.workspace.versions": "版",
250
+ "ai-assistant.workspace.version": "版 {version} / {of}",
251
+ "ai-assistant.workspace.open": "作業ボードで見る",
252
+ "ai-assistant.workspace.contains-table": "表 {count}",
253
+ "ai-assistant.workspace.contains-chart": "チャート {count}",
254
+ "ai-assistant.workspace.contains-metrics": "指標 {count}",
255
+ "ai-assistant.workspace.contains-steps": "経過 {count}"
201
256
  }
@@ -197,5 +197,60 @@
197
197
  "ai-assistant.session.filter-private": "비공개",
198
198
  "ai-assistant.session.filter-unread": "안 읽음",
199
199
  "ai-assistant.dock.ask-needs-conversation": "질문을 보내지 않았습니다.",
200
- "ai-assistant.dock.audience-open-band": "공개 대화 — 이 사업장의 누구나 읽고 쓸 수 있습니다."
200
+ "ai-assistant.dock.audience-open-band": "공개 대화 — 이 사업장의 누구나 읽고 쓸 수 있습니다.",
201
+ "ai-assistant.entry.rows": "{count}행",
202
+ "ai-assistant.entry.ready": "입력 가능 {count}",
203
+ "ai-assistant.entry.blocked": "{count}행은 입력하지 않습니다",
204
+ "ai-assistant.entry.cut": "한도를 넘어 {count}행이 빠졌습니다",
205
+ "ai-assistant.entry.source": "출처",
206
+ "ai-assistant.entry.missing": "없음",
207
+ "ai-assistant.entry.more-rows": "나머지 {count}행은 표에 보이지 않지만 함께 입력합니다.",
208
+ "ai-assistant.entry.check-failed": "애플리케이션이 이 행들을 검사하지 못했습니다. 입력할 때 다시 검사합니다.",
209
+ "ai-assistant.entry.replaced": "새 카드로 바뀜",
210
+ "ai-assistant.entry.enter": "{count}행 입력",
211
+ "ai-assistant.entry.nothing-to-enter": "입력할 행 없음",
212
+ "ai-assistant.entry.entered": "{count}행을 입력했습니다.",
213
+ "ai-assistant.entry.entered-none": "입력한 행이 없습니다.",
214
+ "ai-assistant.entry.row-refused": "{n}행: {reason}",
215
+ "ai-assistant.entry.more-refused": "{count}행이 더 입력되지 않았습니다.",
216
+ "ai-assistant.entry.issue.required": "값이 없습니다",
217
+ "ai-assistant.entry.issue.not-a-number": "숫자가 아닙니다",
218
+ "ai-assistant.entry.issue.not-an-integer": "정수가 아닙니다",
219
+ "ai-assistant.entry.issue.not-a-date": "날짜가 아닙니다",
220
+ "ai-assistant.entry.issue.not-a-boolean": "예/아니오가 아닙니다",
221
+ "ai-assistant.entry.issue.not-in-list": "허용된 값이 아닙니다 ({values})",
222
+ "ai-assistant.entry.issue.unknown-field": "이 기록에 없는 칸이라 뺐습니다",
223
+ "ai-assistant.entry.issue.no-source": "출처가 없습니다",
224
+ "ai-assistant.entry.verified": "{count}행 자료와 대조됨",
225
+ "ai-assistant.entry.unmatched": "{count}행에 자료에 없는 값",
226
+ "ai-assistant.entry.evidence": "자료 {line}번째 줄에서 확인",
227
+ "ai-assistant.entry.total": "{field} 합계 {sum}",
228
+ "ai-assistant.entry.total-ready": "입력분 {sum}",
229
+ "ai-assistant.entry.total-declared": "자료의 합계 {sum}",
230
+ "ai-assistant.entry.total-not-found": "자료에서 찾지 못함",
231
+ "ai-assistant.entry.total-differs": "차이 {sum}",
232
+ "ai-assistant.entry.total-matches": "일치",
233
+ "ai-assistant.entry.issue.not-in-source": "자료에 없는 값입니다",
234
+ "ai-assistant.entry.issue.source-scattered": "값이 자료의 한 줄에 함께 있지 않습니다",
235
+ "ai-assistant.entry.issue.source-not-checked": "사진 자료는 대조하지 못했습니다",
236
+ "ai-assistant.workspace.title": "AI 작업판",
237
+ "ai-assistant.workspace.unreadable": "그릴 수 없는 블록입니다 ({reason})",
238
+ "ai-assistant.workspace.rows": "{count}행",
239
+ "ai-assistant.workspace.csv": "CSV",
240
+ "ai-assistant.workspace.cut": "한도를 넘은 {count}행은 빠졌습니다.",
241
+ "ai-assistant.workspace.other": "기타",
242
+ "ai-assistant.workspace.as-chart": "차트",
243
+ "ai-assistant.workspace.as-table": "표로 보기",
244
+ "ai-assistant.workspace.loading": "작업판을 불러오는 중…",
245
+ "ai-assistant.workspace.unreadable-page": "작업판을 읽지 못했습니다: {reason}",
246
+ "ai-assistant.workspace.not-found": "볼 수 있는 작업판이 없습니다.",
247
+ "ai-assistant.workspace.newer": "새 판({version})이 있습니다.",
248
+ "ai-assistant.workspace.show-newer": "보기",
249
+ "ai-assistant.workspace.versions": "판",
250
+ "ai-assistant.workspace.version": "판 {version} / {of}",
251
+ "ai-assistant.workspace.open": "작업판에서 보기",
252
+ "ai-assistant.workspace.contains-table": "표 {count}",
253
+ "ai-assistant.workspace.contains-chart": "차트 {count}",
254
+ "ai-assistant.workspace.contains-metrics": "수치 {count}",
255
+ "ai-assistant.workspace.contains-steps": "경과 {count}"
201
256
  }