@things-factory/ai-assistant 10.1.101 → 10.1.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.101",
3
+ "version": "10.1.103",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "44abc5cac98642b8028b17bf3e019d083539ccab"
54
+ "gitHead": "db9d51a78d29b96c2c9a5c6a5beac70998aaaeb5"
55
55
  }
@@ -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 { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
5
+ import { 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'
@@ -355,3 +355,20 @@ describe('a moment', () => {
355
355
  })
356
356
  })
357
357
 
358
+ describe("a moment is the domain's wall time", () => {
359
+ it('★ a time without an offset gets the domain time zone\'s offset at that day - DST taken at the day, not today', () => {
360
+ expect(offsetAt('Asia/Seoul', '2026-10-01T08:30:00')).toBe('+09:00')
361
+ expect(offsetAt('America/New_York', '2026-07-01T08:30:00')).toBe('-04:00')
362
+ expect(offsetAt('America/New_York', '2026-12-01T08:30:00')).toBe('-05:00')
363
+ const rows = [{ values: { at: '2026-10-01T08:30:00', other: '2026-10-01T08:30:00+00:00' } }]
364
+ zonedDateTimes([{ name: 'at', type: 'datetime' }, { name: 'other', type: 'datetime' }], rows, 'Asia/Seoul')
365
+ expect(rows[0].values).toEqual({ at: '2026-10-01T08:30:00+09:00', other: '2026-10-01T08:30:00+00:00' })
366
+ })
367
+
368
+ it('the tool gives the domain\'s offset to what the model wrote', async () => {
369
+ const shift: EntryTarget = { ...expense, fields: [{ name: 'startTime', type: 'datetime', required: true }] }
370
+ const result: any = await proposeEntries(shift, { rows: [{ values: { startTime: '2026-10-01 08:30' }, sources: [{ where: 'x' }] }] }, { state: { domain: { timezone: 'Asia/Seoul' } } }, async () => new Map(), async () => ({}))
371
+ expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
372
+ })
373
+ })
374
+
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
14
14
  import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
15
- import { changeToolName, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
15
+ import { changeToolName, zonedDateTimes, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
16
16
 
17
17
  export interface StoredProposalMessage {
18
18
  id: string
@@ -86,7 +86,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
86
86
  /* Read again from the stored values - the same rules, and today's records for the target's checks. */
87
87
  let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
88
88
  /* A reference that was not settled on the card is asked again - never entered as a name. */
89
- rows = await resolveReferences(target.fields, rows, deps.execute)
89
+ rows = await resolveReferences(target.fields, zonedDateTimes(target.fields, rows, deps.state?.domain?.timezone), deps.execute)
90
90
  if (target.check) {
91
91
  try {
92
92
  rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
@@ -68,6 +68,8 @@ export interface EntryIssue {
68
68
  code: string
69
69
  messageKey?: string
70
70
  params?: Record<string, unknown>
71
+ /** A param that is a code the screen words - { status: 'text.state-' } shows `down` as 고장. */
72
+ paramKeys?: Record<string, string>
71
73
  /** A blocking issue keeps the row out when the person presses the button. */
72
74
  blocking: boolean
73
75
  }
@@ -681,3 +683,46 @@ export function entryGroups<T extends { index: number; values: Record<string, un
681
683
  return [...groups.entries()]
682
684
  }
683
685
 
686
+ /** The UTC offset a time zone has at a wall time - "+09:00". DST is taken at that day, not today. */
687
+ export function offsetAt(timeZone: string | undefined, wall: string): string | undefined {
688
+ const match = wall.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/)
689
+ if (!match) return undefined
690
+ const [y, mo, d, h, mi, se] = match.slice(1).map(Number)
691
+ let format: Intl.DateTimeFormat
692
+ try {
693
+ format = new Intl.DateTimeFormat('en-US', { timeZone: timeZone || undefined, timeZoneName: 'longOffset', year: 'numeric' })
694
+ } catch {
695
+ return undefined
696
+ }
697
+ const minutesOf = (at: number) => {
698
+ const name = format.formatToParts(new Date(at)).find(part => part.type === 'timeZoneName')?.value ?? 'GMT'
699
+ const m = name.match(/GMT([+-])(\d{2}):?(\d{2})?/)
700
+ return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0)) : 0
701
+ }
702
+ /* The offset at the wall time read as UTC, then once more at the instant that gives - right across a DST change. */
703
+ const asUtc = Date.UTC(y, mo - 1, d, h, mi, se)
704
+ const minutes = minutesOf(asUtc - minutesOf(asUtc) * 60_000)
705
+ const sign = minutes < 0 ? '-' : '+'
706
+ const abs = Math.abs(minutes)
707
+ return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`
708
+ }
709
+
710
+ /**
711
+ * A moment written without an offset is the domain's wall time - "08:30" in a factory is 08:30 there. Each such
712
+ * value gets the domain time zone's offset at that day, so an API taking DateTimeISO accepts it and reads the moment
713
+ * meant (plant refused every downtime log without it, 2026-10-01).
714
+ */
715
+ export function zonedDateTimes<T extends { values: Record<string, unknown> }>(fields: EntryField[], rows: T[], timeZone: string | undefined): T[] {
716
+ const moments = fields.filter(field => field.type === 'datetime')
717
+ if (!moments.length) return rows
718
+ for (const row of rows) {
719
+ for (const field of moments) {
720
+ const value = row.values[field.name]
721
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(value)) continue
722
+ const offset = offsetAt(timeZone, value)
723
+ if (offset) row.values[field.name] = `${value}${offset}`
724
+ }
725
+ }
726
+ return rows
727
+ }
728
+
@@ -22,6 +22,7 @@ import {
22
22
  entryToolName,
23
23
  isBlocked,
24
24
  normalizeEntryRows,
25
+ zonedDateTimes,
25
26
  entryGroups,
26
27
  referenceDoorsOf,
27
28
  resolveReferences,
@@ -80,7 +81,7 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
80
81
  const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
81
82
  const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
82
83
  /* A name the model wrote for another record is settled to its id by the application's query, as the person. */
83
- const resolved = await resolveReferences(target.fields, normalized, run ?? runAsCaller(context))
84
+ const resolved = await resolveReferences(target.fields, zonedDateTimes(target.fields, normalized, context?.state?.domain?.timezone), run ?? runAsCaller(context))
84
85
  const read = verifyRowsAgainstSources(resolved, target.fields, texts)
85
86
 
86
87
  let rows: EntryRow[] = read
@@ -251,6 +252,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
251
252
  guidance: [
252
253
  `- When the person gives material to enter as records (files, photos, pasted text), read all of it first - call readChatAttachment for a file sent in an earlier turn - then call ${name} once with every record as a row.`,
253
254
  '- Never invent a value. Leave a field out when the material does not say it; the card marks it, and you ask the person for it.',
255
+ '- A row kept out because a name was not found, or matches several records, is settled in the conversation: say which name, list the candidates the card names, and ask; then propose again with the name the person chose. The card has no picker - never tell the person to choose on it.',
254
256
  '- Give each row its sources: which attachment and where in it, written in the person\'s language. A value the person gave or corrected in the conversation adds the source { person: true }. The server looks for each row\'s numbers and dates in the cited file and marks what it does not find.',
255
257
  '- When the material prints a total, always pass it as declaredTotal exactly as printed - do not add it up yourself. The server also reads a total line of a text file itself.',
256
258
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
@@ -14,6 +14,7 @@ import {
14
14
  findToolName,
15
15
  readFieldValue,
16
16
  resolveReferences,
17
+ zonedDateTimes,
17
18
  sameFieldValue,
18
19
  type EntryField,
19
20
  type EntryIssue,
@@ -147,7 +148,7 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
147
148
  } catch (error: any) {
148
149
  return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
149
150
  }
150
- const rows = await resolveReferences(changeableFields(target), changeRows(target, picked, records), run)
151
+ const rows = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
151
152
  const blocked = rows.filter(blockedChange).length
152
153
  return {
153
154
  proposed: true,