@things-factory/ai-assistant 10.1.101 → 10.1.102

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.102",
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": "8bba629dd321caa7c047d52f451d61d2678cc6e2"
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
@@ -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,