@things-factory/ai-assistant 10.1.213 → 10.1.215

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 (89) hide show
  1. package/client/components/assistant-dock.ts +6 -3
  2. package/client/components/assistant-selection-bar.test.ts +42 -0
  3. package/client/components/assistant-selection-bar.ts +129 -0
  4. package/client/components/assistant-session-manager.ts +263 -12
  5. package/client/index.ts +2 -0
  6. package/client/utils/assistant-selection.test.ts +25 -1
  7. package/client/utils/assistant-selection.ts +27 -0
  8. package/client/utils/assistant-session-actions.ts +12 -0
  9. package/client/utils/assistant-session-admin.ts +70 -5
  10. package/dist-client/components/assistant-dock.js +6 -3
  11. package/dist-client/components/assistant-dock.js.map +1 -1
  12. package/dist-client/components/assistant-selection-bar.d.ts +44 -0
  13. package/dist-client/components/assistant-selection-bar.js +149 -0
  14. package/dist-client/components/assistant-selection-bar.js.map +1 -0
  15. package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
  16. package/dist-client/components/assistant-selection-bar.test.js +38 -0
  17. package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
  18. package/dist-client/components/assistant-session-manager.d.ts +37 -0
  19. package/dist-client/components/assistant-session-manager.js +281 -13
  20. package/dist-client/components/assistant-session-manager.js.map +1 -1
  21. package/dist-client/index.d.ts +1 -0
  22. package/dist-client/index.js +2 -0
  23. package/dist-client/index.js.map +1 -1
  24. package/dist-client/tsconfig.tsbuildinfo +1 -1
  25. package/dist-client/utils/assistant-selection.d.ts +24 -0
  26. package/dist-client/utils/assistant-selection.js +24 -0
  27. package/dist-client/utils/assistant-selection.js.map +1 -1
  28. package/dist-client/utils/assistant-selection.test.js +21 -1
  29. package/dist-client/utils/assistant-selection.test.js.map +1 -1
  30. package/dist-client/utils/assistant-session-actions.d.ts +15 -0
  31. package/dist-client/utils/assistant-session-actions.js +11 -0
  32. package/dist-client/utils/assistant-session-actions.js.map +1 -1
  33. package/dist-client/utils/assistant-session-admin.d.ts +32 -1
  34. package/dist-client/utils/assistant-session-admin.js +36 -5
  35. package/dist-client/utils/assistant-session-admin.js.map +1 -1
  36. package/dist-server/service/assistant-chat-resolver.js +6 -4
  37. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  38. package/dist-server/service/assistant-mode/index.d.ts +1 -1
  39. package/dist-server/service/assistant-mode/index.js +2 -1
  40. package/dist-server/service/assistant-mode/index.js.map +1 -1
  41. package/dist-server/service/assistant-mode/mode-categories.d.ts +30 -0
  42. package/dist-server/service/assistant-mode/mode-categories.js +35 -2
  43. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  44. package/dist-server/service/chat-session/session-cleanup.d.ts +28 -0
  45. package/dist-server/service/chat-session/session-cleanup.js +69 -0
  46. package/dist-server/service/chat-session/session-cleanup.js.map +1 -0
  47. package/dist-server/service/chat-session/session-inbox.d.ts +15 -0
  48. package/dist-server/service/chat-session/session-inbox.js +11 -0
  49. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  50. package/dist-server/service/chat-session/session-purge.d.ts +4 -0
  51. package/dist-server/service/chat-session/session-purge.js +45 -11
  52. package/dist-server/service/chat-session/session-purge.js.map +1 -1
  53. package/dist-server/service/chat-session-resolver.d.ts +24 -2
  54. package/dist-server/service/chat-session-resolver.js +193 -13
  55. package/dist-server/service/chat-session-resolver.js.map +1 -1
  56. package/dist-server/service/data-read/list-read.d.ts +3 -1
  57. package/dist-server/service/data-read/list-read.js +27 -2
  58. package/dist-server/service/data-read/list-read.js.map +1 -1
  59. package/dist-server/service/data-read/read-target.d.ts +16 -0
  60. package/dist-server/service/data-read/read-target.js +18 -1
  61. package/dist-server/service/data-read/read-target.js.map +1 -1
  62. package/dist-server/service/data-read/read-tools.js +3 -1
  63. package/dist-server/service/data-read/read-tools.js.map +1 -1
  64. package/dist-server/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +6 -6
  66. package/server/service/assistant-chat-resolver.ts +7 -5
  67. package/server/service/assistant-mode/conversation-category.test.ts +15 -2
  68. package/server/service/assistant-mode/index.ts +1 -1
  69. package/server/service/assistant-mode/mode-categories.ts +48 -2
  70. package/server/service/assistant-mode/mode-front.test.ts +27 -1
  71. package/server/service/assistant-mode/open-subject-door.test.ts +3 -1
  72. package/server/service/chat-session/session-cleanup.test.ts +83 -0
  73. package/server/service/chat-session/session-cleanup.ts +69 -0
  74. package/server/service/chat-session/session-inbox.ts +13 -0
  75. package/server/service/chat-session/session-purge.ts +46 -10
  76. package/server/service/chat-session-resolver.ts +150 -14
  77. package/server/service/data-read/list-read.ts +30 -3
  78. package/server/service/data-read/list-status.test.ts +46 -0
  79. package/server/service/data-read/read-target.ts +32 -1
  80. package/server/service/data-read/read-tools.ts +2 -1
  81. package/test/assistant-dock.test.ts +12 -1
  82. package/test/assistant-session-manager.test.ts +53 -1
  83. package/test/open-doors.test.ts +3 -1
  84. package/translations/en.json +45 -2
  85. package/translations/es.json +45 -2
  86. package/translations/ja.json +45 -2
  87. package/translations/ko.json +45 -2
  88. package/translations/ms.json +45 -2
  89. package/translations/zh.json +45 -2
@@ -10,11 +10,12 @@ import { User } from '@things-factory/auth-base'
10
10
  import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
11
11
 
12
12
  import { getRepository, pubsub } from '@things-factory/shell'
13
- import { In, Like } from 'typeorm'
13
+ import { config } from '@things-factory/env'
14
+ import { In, IsNull, Like, Not } from 'typeorm'
14
15
 
15
16
  import { ChatSession } from './chat-session/chat-session.js'
16
17
  import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
17
- import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
18
+ import { buildInbox, applyHidden, activityPatch, listedEntries, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
18
19
  import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
19
20
  import { roomMessageBody } from './chat-message/room-message.js'
20
21
  import { readerGate } from './chat-message/reader-gate.js'
@@ -26,6 +27,17 @@ import { sendInviteNotification } from './chat-session-participant/invite-notifi
26
27
  import { readRoster } from './chat-session-participant/read-roster.js'
27
28
  import { isSessionParticipant } from './chat-session-participant/participant-lookup.js'
28
29
  import { assertMayWriteSession } from './chat-session/session-write-gate.js'
30
+ import { archiveDaysOf, cleanupReason, idleDays, isArchived } from './chat-session/session-cleanup.js'
31
+ import { DAY_MS, retentionDaysOf } from './chat-session/session-retention.js'
32
+
33
+ /** The keeping periods the config asks for (shared-dock.md §9-5, §2-7). */
34
+ function keeping(): { deletedDays: number | null; archiveDays: number | null } {
35
+ const options = config.get('ai-assistant', {}) ?? {}
36
+ return { deletedDays: retentionDaysOf(options.sessionRetentionDays), archiveDays: archiveDaysOf(options.sessionArchiveDays) }
37
+ }
38
+
39
+ /** One request reads the list once - the list, its total and its counts ask together and share it. */
40
+ const inboxReads = new WeakMap<object, Map<string, Promise<any[]>>>()
29
41
 
30
42
  /** 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(`sendInviteNotification`, 문 셋이 같은 한 곳). */
31
43
  async function notifyInvited(context: any, invited: string[], inviter: any, sessionName?: string): Promise<void> {
@@ -129,6 +141,43 @@ class ChatSessionInboxEntry {
129
141
 
130
142
  @Field({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' })
131
143
  owner!: boolean
144
+
145
+ /* No activity for the archive period: out of the list until asked for or searched. A new message brings it back. */
146
+ @Field({ nullable: true, description: 'Whether the session has had no activity for the archive period (config sessionArchiveDays, default 60) - left out of the list unless includeArchived or a search asks for it.' })
147
+ archived?: boolean
148
+
149
+ @Field({ nullable: true, description: "Why the list suggests clearing this session: 'empty' (never asked anything) or 'inactive' (no activity for 30 days). Only on sessions this viewer created." })
150
+ cleanupReason?: string
151
+
152
+ @Field(type => Int, { nullable: true, description: 'Whole days since the last activity, or since the start when there is none.' })
153
+ idleDays?: number
154
+ }
155
+
156
+ @ObjectType({ description: 'A session the caller deleted that is still kept - it can be brought back until it is emptied.' })
157
+ class DeletedSessionEntry {
158
+ @Field(type => ID, { description: 'Session id.' })
159
+ id!: string
160
+
161
+ @Field({ nullable: true, description: 'Session name.' })
162
+ name?: string
163
+
164
+ @Field({ nullable: true, description: 'Which conversation this session is - the registered mode kind.' })
165
+ kind?: string
166
+
167
+ @Field({ description: 'When it was deleted.' })
168
+ deletedAt!: Date
169
+
170
+ @Field({ nullable: true, description: 'When the daily job empties it for good; none when emptying is turned off.' })
171
+ purgesAt?: Date
172
+ }
173
+
174
+ @ObjectType({ description: 'How long conversations are kept - what the list says when it deletes or archives.' })
175
+ class SessionKeeping {
176
+ @Field(type => Int, { nullable: true, description: 'Days a deleted session can still be brought back; none when deleted sessions are never emptied.' })
177
+ deletedDays?: number
178
+
179
+ @Field(type => Int, { nullable: true, description: 'Days without activity after which a session is archived; none when archiving is off.' })
180
+ archiveDays?: number
132
181
  }
133
182
 
134
183
  @ObjectType({ description: 'A file stored with a conversation - opened only through the conversation.' })
@@ -230,10 +279,12 @@ export class ChatSessionResolver {
230
279
  @Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
231
280
  @Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
232
281
  @Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
233
- @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
282
+ @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean,
283
+ @Arg('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' }) includeArchived?: boolean,
284
+ @Arg('cleanupOnly', { nullable: true, description: "Only the caller's own the list suggests clearing (empty, or inactive for 30 days), hidden and archived ones included." }) cleanupOnly?: boolean
234
285
  ): Promise<ChatSessionInboxEntry[]> {
235
286
  const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
236
- showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly
287
+ showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
237
288
  })
238
289
  const from = Math.max(0, offset ?? 0)
239
290
  const size = Math.min(Math.max(1, limit ?? 50), 200)
@@ -261,10 +312,14 @@ export class ChatSessionResolver {
261
312
  @Arg('kind', { nullable: true, description: 'Only this conversation kind (mode).' }) kind?: string,
262
313
  @Arg('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' }) participantOnly?: boolean,
263
314
  @Arg('visibility', { nullable: true, description: "Only 'open' or only 'private'." }) visibility?: string,
264
- @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean
315
+ @Arg('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' }) unreadOnly?: boolean,
316
+ @Arg('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' }) includeArchived?: boolean,
317
+ @Arg('cleanupOnly', { nullable: true, description: "Only the caller's own the list suggests clearing." }) cleanupOnly?: boolean
265
318
  ): Promise<number> {
266
319
  return (
267
- await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly })
320
+ await this._inboxFiltered(anchorType, anchorId, context, {
321
+ showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
322
+ })
268
323
  ).length
269
324
  }
270
325
 
@@ -273,10 +328,10 @@ export class ChatSessionResolver {
273
328
  anchorType: string,
274
329
  anchorId: string,
275
330
  context: ResolverContext,
276
- opts: { showHidden?: boolean; search?: string } & InboxFilter
331
+ opts: { showHidden?: boolean; search?: string; includeArchived?: boolean; cleanupOnly?: boolean } & InboxFilter
277
332
  ): Promise<ChatSessionInboxEntry[]> {
278
333
  const all = await this._inboxEntries(anchorType, anchorId, context)
279
- let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
334
+ let entries = listedEntries(all, opts)
280
335
  entries = filterInbox(entries as any, opts) as any
281
336
  if (!opts.search?.trim() || !entries.length) return entries
282
337
  // Search only readable rooms, then pass their messages through the SAME reader gate.
@@ -315,8 +370,81 @@ export class ChatSessionResolver {
315
370
  return all.filter(e => e.hidden).length
316
371
  }
317
372
 
373
+ /* Like the hidden count: an archived conversation must not be forgotten, so the list says how many wait there. */
374
+ @Query(() => Int, { description: 'Number of sessions on this anchor that are archived (no activity for the archive period) and not hidden by the caller.' })
375
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
376
+ async archivedSessionCount(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<number> {
377
+ return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.archived && !e.hidden).length
378
+ }
379
+
380
+ @Query(() => Int, { description: "Number of the caller's own sessions on this anchor the list suggests clearing - empty, or inactive for 30 days." })
381
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
382
+ async cleanupSessionCount(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<number> {
383
+ return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.cleanupReason).length
384
+ }
385
+
386
+ @Query(() => SessionKeeping, { description: 'How long deleted sessions can be brought back and after how long an idle one is archived.' })
387
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
388
+ aiSessionKeeping(): SessionKeeping {
389
+ const { deletedDays, archiveDays } = keeping()
390
+ return { deletedDays: deletedDays ?? undefined, archiveDays: archiveDays ?? undefined }
391
+ }
392
+
393
+ /** What I deleted here and can still bring back - newest first. Only the creator deletes, so only the creator sees them. */
394
+ @Query(() => [DeletedSessionEntry], { description: 'Sessions on this anchor that the caller deleted and that are still kept, newest deletion first. They can be brought back with restoreAISession until they are emptied.' })
395
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
396
+ async deletedAISessions(@Arg('anchorType') anchorType: string, @Arg('anchorId') anchorId: string, @Ctx() context: ResolverContext): Promise<DeletedSessionEntry[]> {
397
+ const { domain, user } = context.state
398
+ if (!user?.id) return []
399
+ const { deletedDays } = keeping()
400
+ const rows = await getRepository(ChatSession).find({
401
+ where: { ...anchorWhere(domain.id, anchorType, anchorId), creator: { id: user.id }, deletedAt: Not(IsNull()) } as any,
402
+ withDeleted: true,
403
+ order: { deletedAt: 'DESC' } as any,
404
+ take: INBOX_SCAN_CAP
405
+ })
406
+ return rows.map(row => ({
407
+ id: row.id!,
408
+ name: (row as any).name ?? undefined,
409
+ kind: (row as any).kind ?? undefined,
410
+ deletedAt: (row as any).deletedAt,
411
+ purgesAt: deletedDays === null ? undefined : new Date(new Date((row as any).deletedAt).getTime() + deletedDays * DAY_MS)
412
+ }))
413
+ }
414
+
415
+ /** Bringing a deleted conversation back - its creator, while it is kept. Its messages and seats were never touched. */
416
+ @Mutation(() => Boolean, { description: 'Bring back a session the caller deleted, while it is still kept (before the daily job empties it). Creator only, like deleting.' })
417
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
418
+ async restoreAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
419
+ const { domain, user } = context.state
420
+ const repo = getRepository(ChatSession)
421
+ const session = await repo.findOne({ where: { id: sessionId, domain: { id: domain.id } } as any, withDeleted: true })
422
+ if (!session || !(session as any).deletedAt) return false
423
+ if (!user?.id || (session as any).creatorId !== user.id) {
424
+ throw new Error('Only the user who created this session may bring it back.')
425
+ }
426
+ await repo.restore(sessionId)
427
+ publishSessionActivity({
428
+ domainId: domain.id,
429
+ sessionId,
430
+ kind: 'meta',
431
+ anchorType: (session as any).anchorType,
432
+ anchorId: (session as any).anchorId,
433
+ actorId: user.id
434
+ })
435
+ return true
436
+ }
437
+
318
438
  /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
319
- private async _inboxEntries(
439
+ private _inboxEntries(anchorType: string, anchorId: string, context: ResolverContext): Promise<ChatSessionInboxEntry[]> {
440
+ const reads = inboxReads.get(context) ?? new Map<string, Promise<any[]>>()
441
+ inboxReads.set(context, reads)
442
+ const key = `${anchorType}\u0000${anchorId}`
443
+ if (!reads.has(key)) reads.set(key, this._readInbox(anchorType, anchorId, context))
444
+ return reads.get(key)!
445
+ }
446
+
447
+ private async _readInbox(
320
448
  anchorType: string,
321
449
  anchorId: string,
322
450
  context: ResolverContext
@@ -339,10 +467,18 @@ export class ChatSessionResolver {
339
467
  const hiddenAt = new Map(rows.map(r => [(r as any).sessionId as string, r.hiddenAt]))
340
468
  /* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
341
469
  const createdByMe = new Set(sessions.filter(s => (s as any).creatorId === user?.id).map(s => s.id))
342
- const entries = buildInbox(sessions as any, participations as any).map(entry => ({
343
- ...entry,
344
- mine: createdByMe.has(entry.id)
345
- }))
470
+ const now = new Date()
471
+ const { archiveDays } = keeping()
472
+ const entries = buildInbox(sessions as any, participations as any).map(entry => {
473
+ const mine = createdByMe.has(entry.id)
474
+ return {
475
+ ...entry,
476
+ mine,
477
+ idleDays: idleDays(entry, now),
478
+ archived: isArchived(entry, now, archiveDays),
479
+ cleanupReason: cleanupReason({ ...entry, mine }, now) ?? undefined
480
+ }
481
+ })
346
482
  /* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
347
483
  const shown = applyHidden(entries as any, hiddenAt, true) as any[]
348
484
 
@@ -374,7 +510,7 @@ export class ChatSessionResolver {
374
510
  */
375
511
  @Mutation(() => Boolean, {
376
512
  description:
377
- 'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
513
+ 'Delete a chat session — creator only, since it disappears for every participant. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete: the creator can bring it back with restoreAISession until the daily job empties it (sessionRetentionDays). Broadcast as meta activity so every list drops it.'
378
514
  })
379
515
  @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
380
516
  async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
@@ -23,7 +23,7 @@ export interface ListRead {
23
23
  dateField?: string
24
24
  /** Integer year and month fields, and an in/out field (INCOME | EXPENSE) - the `year`, `month`, `flow` conditions. */
25
25
  narrowing?: { year?: string; month?: string; flow?: string }
26
- /** A field holding the period as YYYY-MM - the `period` condition. */
26
+ /** A field holding the period as YYYY-MM - the `period` condition, and `periodFrom` / `periodTo` for a range of months. */
27
27
  monthField?: string
28
28
  /** A status field and its values - the `status` condition. */
29
29
  status?: { field: string; values: string[] }
@@ -39,6 +39,8 @@ export interface ListRead {
39
39
  * searched the asker's own name (2026-10-24).
40
40
  */
41
41
  missing?: { name: string; description: string; filters: ReadFilter[] }[]
42
+ /** The rows are the person's own, whoever reads them (`myExpenditureRequests`) - ReadTarget.mine. */
43
+ mine?: boolean
42
44
  }
43
45
 
44
46
  /** The money column a minimum or maximum amount means - `amount` when there is one, else the first summed column. */
@@ -63,10 +65,34 @@ export function listReadTarget(list: ListRead): ReadTarget {
63
65
  ? [{ name: 'flow', type: 'enum', values: ['INCOME', 'EXPENSE'], labelKey: 'ai-assistant.read.flow', description: 'money in or out', filter: (v: any) => ({ name: narrowing.flow!, operator: 'eq', value: v }) }]
64
66
  : []),
65
67
  ...(list.monthField
66
- ? [{ name: 'period', type: 'string', labelKey: 'ai-assistant.read.period', description: 'a month as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'eq', value: v }) }]
68
+ ? [
69
+ { name: 'period', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period', description: 'a month as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'eq', value: v }) },
70
+ /* A range of months - "the last six months" of a list kept by month (2026-10-25: only one fixed month could be read). */
71
+ { name: 'periodFrom', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period-from', description: 'the first month, inclusive, as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'gte', value: v }) },
72
+ { name: 'periodTo', type: 'string', format: 'month', labelKey: 'ai-assistant.read.period-to', description: 'the last month, inclusive, as YYYY-MM', filter: (v: any) => ({ name: list.monthField!, operator: 'lte', value: v }) }
73
+ ]
67
74
  : []),
68
75
  ...(list.status
69
- ? [{ name: 'status', type: 'enum', values: list.status.values, labelKey: 'ai-assistant.read.status', filter: (v: any) => ({ name: list.status!.field, operator: 'eq', value: v }) }]
76
+ ? [
77
+ {
78
+ name: 'status',
79
+ type: 'enum',
80
+ values: list.status.values,
81
+ many: true,
82
+ labelKey: 'ai-assistant.read.status',
83
+ description: 'the statuses to read',
84
+ filter: (v: any) => (Array.isArray(v) && v.length > 1 ? { name: list.status!.field, operator: 'in', value: v } : { name: list.status!.field, operator: 'eq', value: [v].flat()[0] })
85
+ },
86
+ {
87
+ name: 'statusNot',
88
+ type: 'enum',
89
+ values: list.status.values,
90
+ many: true,
91
+ labelKey: 'ai-assistant.read.status-not',
92
+ description: 'the statuses to leave out - every other status is read',
93
+ filter: (v: any) => ({ name: list.status!.field, operator: 'notin', value: [v].flat() })
94
+ }
95
+ ]
70
96
  : []),
71
97
  ...(list.missing?.length
72
98
  ? [
@@ -101,6 +127,7 @@ export function listReadTarget(list: ListRead): ReadTarget {
101
127
  criteria,
102
128
  ...(list.fixedFilters?.length ? { fixedFilters: list.fixedFilters } : {}),
103
129
  columns: list.columns,
130
+ ...(list.mine ? { mine: true } : {}),
104
131
  rowsOf: data => ({ rows: data?.[list.listQuery]?.items ?? [], total: data?.[list.listQuery]?.total ?? 0 }),
105
132
  ...(search ? { search: { description: `the ${list.search}` } } : {}),
106
133
  ...(list.dateField ? { sortings: [{ name: list.dateField, desc: true }] } : {}),
@@ -0,0 +1,46 @@
1
+ /*
2
+ * A list's status condition takes several statuses, and the statuses to leave out (2026-10-25): asked for the
3
+ * expenditure requests not approved, rejected or withdrawn, the model read the list once per status and stopped.
4
+ */
5
+ import { listReadTarget } from './list-read'
6
+ import { filtersOf, readCriteria } from './read-target'
7
+
8
+ const target = listReadTarget({
9
+ key: 'office.expenditure-request',
10
+ title: 'menu.expenditure-request-list',
11
+ name: '지출결의',
12
+ listQuery: 'expenditureRequests',
13
+ selection: 'id status totalAmount',
14
+ columns: [
15
+ { name: 'status', type: 'string' },
16
+ { name: 'totalAmount', type: 'number', total: true }
17
+ ],
18
+ status: { field: 'status', values: ['DRAFT', 'SUBMITTED', 'APPROVED', 'REJECTED', 'WITHDRAWN'] }
19
+ })
20
+ const filters = (args: any) => {
21
+ const { criteria, refused } = readCriteria(target, args)
22
+ return refused.length ? { refused } : filtersOf(target, criteria)
23
+ }
24
+
25
+ describe('a status condition', () => {
26
+ it('★ one status is eq, several are in', () => {
27
+ expect(filters({ status: 'SUBMITTED' })).toEqual([{ name: 'status', operator: 'eq', value: 'SUBMITTED' }])
28
+ expect(filters({ status: ['SUBMITTED'] })).toEqual([{ name: 'status', operator: 'eq', value: 'SUBMITTED' }])
29
+ expect(filters({ status: ['DRAFT', 'SUBMITTED'] })).toEqual([{ name: 'status', operator: 'in', value: ['DRAFT', 'SUBMITTED'] }])
30
+ })
31
+
32
+ it('★ the statuses to leave out are notin - every other status is read', () => {
33
+ expect(filters({ statusNot: ['APPROVED', 'REJECTED', 'WITHDRAWN'] })).toEqual([{ name: 'status', operator: 'notin', value: ['APPROVED', 'REJECTED', 'WITHDRAWN'] }])
34
+ expect(filters({ statusNot: 'APPROVED' })).toEqual([{ name: 'status', operator: 'notin', value: ['APPROVED'] }])
35
+ })
36
+
37
+ it('a status the list does not have is refused, in a list too', () => {
38
+ expect(filters({ statusNot: ['APPROVED', 'PAID'] })).toEqual({ refused: [{ name: 'statusNot', code: 'not-in-list' }] })
39
+ expect(filters({ status: ['NOPE'] })).toEqual({ refused: [{ name: 'status', code: 'not-in-list' }] })
40
+ })
41
+
42
+ it('a condition that takes one value refuses a list', () => {
43
+ const { refused } = readCriteria(target, { minAmount: [1, 2] })
44
+ expect(refused).toEqual([{ name: 'minAmount', code: 'one-value' }])
45
+ })
46
+ })
@@ -23,6 +23,13 @@ export interface ReadFilter {
23
23
  /** A condition the model may set - read with the entry fields' rules, turned into the query's filters by the application. */
24
24
  export interface ReadCriterion extends Omit<EntryField, 'required' | 'reference' | 'total'> {
25
25
  filter: (value: any) => ReadFilter | ReadFilter[]
26
+ /** A string condition holding a month as YYYY-MM - a value of another shape is refused (`not-a-month`). */
27
+ format?: 'month'
28
+ /**
29
+ * The condition takes several values at once - its filter is given the list. Asked for the expenditure requests not
30
+ * approved, rejected or withdrawn, the model read the list once per status and stopped (2026-10-25).
31
+ */
32
+ many?: boolean
26
33
  }
27
34
 
28
35
  export interface ReadColumn {
@@ -81,6 +88,11 @@ export interface ReadTarget {
81
88
  /** Orders the rows; the query's own order when left out. */
82
89
  sortings?: { name: string; desc?: boolean }[]
83
90
  screen?: ReadScreen
91
+ /**
92
+ * The rows are the person's own, whoever reads them (내 지출결의) - the query answers for the one asking. A dashboard
93
+ * card of it shows each viewer their own rows and takes no filter on a person (ADR-0160).
94
+ */
95
+ mine?: boolean
84
96
  }
85
97
 
86
98
  /** At most this many rows are read - the AI 작업판 table's own limit (workspace-blocks MAX_TABLE_ROWS). */
@@ -165,6 +177,8 @@ export interface DetailArg {
165
177
  required?: boolean
166
178
  /** A record's id: the axis (or axes) of the record, whose key `<axis>:<id>` the model may have been shown (EntryField.axis). */
167
179
  axis?: string | readonly string[]
180
+ /** A string argument holding a month as YYYY-MM. */
181
+ format?: 'month'
168
182
  }
169
183
 
170
184
  /**
@@ -192,6 +206,8 @@ export interface DetailRead {
192
206
  * (ADR-0160 decision 2). The card keeps only `key`; where the rows are and what their columns are is read here.
193
207
  */
194
208
  lists?: DetailList[]
209
+ /** The answer is the person's own, whoever asks (나의 전략 업무, 내 급여) - ReadTarget.mine. */
210
+ mine?: boolean
195
211
  }
196
212
 
197
213
  /** A list inside a detail read's answer. */
@@ -248,6 +264,9 @@ export function detailReadOf(key: string | undefined): DetailRead | undefined {
248
264
  return key ? details.get(key) : undefined
249
265
  }
250
266
 
267
+ /** A month as a `month` condition holds it. */
268
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
269
+
251
270
  /** The conditions the model set, read by their declared types; a value that does not fit is said, never guessed at. */
252
271
  export function readCriteria(target: ReadTarget, args: any): { criteria: Record<string, unknown>; refused: { name: string; code: string }[] } {
253
272
  const criteria: Record<string, unknown> = {}
@@ -257,9 +276,21 @@ export function readCriteria(target: ReadTarget, args: any): { criteria: Record<
257
276
  if (target.search && typeof args?.text === 'string' && args.text.trim()) criteria.text = args.text.trim()
258
277
  for (const criterion of target.criteria) {
259
278
  const value = args?.[criterion.name]
260
- if (value === undefined || value === null || value === '') continue
279
+ if (value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length)) continue
280
+ if (criterion.many && Array.isArray(value)) {
281
+ const each = value.map(one => readFieldValue(criterion as EntryField, one))
282
+ const wrong = each.find(one => one.read === undefined)
283
+ if (wrong) refused.push({ name: criterion.name, code: wrong.code ?? 'unreadable' })
284
+ else criteria[criterion.name] = [...new Set(each.map(one => one.read))]
285
+ continue
286
+ }
287
+ if (Array.isArray(value)) {
288
+ refused.push({ name: criterion.name, code: 'one-value' })
289
+ continue
290
+ }
261
291
  const { read, code } = readFieldValue(criterion as EntryField, value)
262
292
  if (read === undefined) refused.push({ name: criterion.name, code: code ?? 'unreadable' })
293
+ else if (criterion.format === 'month' && !MONTH.test(String(read))) refused.push({ name: criterion.name, code: 'not-a-month' })
263
294
  else criteria[criterion.name] = read
264
295
  }
265
296
  return { criteria, refused }
@@ -48,9 +48,10 @@ const wordsOf = (context: any) => (key: string | undefined, fallback: string) =>
48
48
  function criterionSchema(criterion: ReadCriterion): any {
49
49
  /* One type per field - Gemini's function schema refuses a list of types (2026-10-01). */
50
50
  const type = criterion.type === 'number' ? 'number' : criterion.type === 'integer' ? 'integer' : criterion.type === 'boolean' ? 'boolean' : 'string'
51
- const said = [criterion.description, criterion.type === 'date' ? 'YYYY-MM-DD' : undefined, criterion.type === 'enum' ? `one of: ${criterion.values!.join(', ')}` : undefined]
51
+ const said = [criterion.description, criterion.type === 'date' ? 'YYYY-MM-DD' : criterion.format === 'month' ? 'YYYY-MM' : undefined, criterion.type === 'enum' ? `one of: ${criterion.values!.join(', ')}` : undefined]
52
52
  .filter(Boolean)
53
53
  .join('. ')
54
+ if (criterion.many) return { type: 'array', items: { type }, ...(said ? { description: `${said} - one or more` } : {}) }
54
55
  return { type, ...(said ? { description: said } : {}) }
55
56
  }
56
57
 
@@ -161,7 +161,9 @@ describe('what twin needed of the dock', () => {
161
161
  it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
162
162
  assert.match(dock, /if \(waiting\.ask\) notify\(/)
163
163
  assert.match(dock, /else holdAssistantAsk\(waiting\)/)
164
- assert.match(dock, /if \(this\.anchor && !isAnchor\(this\.anchor\)\) void this\.takeAsk\(\)/)
164
+ /* Taken when the opening ends whatever it found - a stuck anchor is said by takeAsk at once. */
165
+ assert.match(dock, /this\.loading = false\s*if \(this\.anchor\) void this\.takeAsk\(\)/)
166
+ assert.match(dock, /if \(!isAnchor\(this\.anchor\)\) \{/)
165
167
  })
166
168
 
167
169
  it('a handler\'s note stays in the conversation after its outcome is reported', () => {
@@ -227,6 +229,15 @@ describe('a screen request', () => {
227
229
  const take = dock.slice(dock.indexOf('private async takeScreenRequest()'), dock.indexOf('\n }\n', dock.indexOf('private async takeScreenRequest()')))
228
230
  const send = chat.slice(chat.indexOf('async sendScreenRequest('), chat.indexOf('\n }\n', chat.indexOf('async sendScreenRequest(')))
229
231
 
232
+ it('★ a request that reopens this screen\'s conversation is taken once the opening is done - never while it loads', () => {
233
+ const opening = dock.slice(dock.indexOf('private async open(): Promise<void> {'), dock.indexOf('private target('))
234
+ const [body, rest] = opening.split('} finally {')
235
+ /* takeAsk passes while loading: called inside the opening, the request stayed waiting and was never sent. */
236
+ assert.doesNotMatch(body, /this\.takeAsk\(\)/)
237
+ assert.match(rest, /this\.loading = false\s*if \(this\.anchor\) void this\.takeAsk\(\)/)
238
+ assert.match(dock, /private async takeAsk\(\) \{\s*if \(this\.loading \|\| !this\.anchor\) return/)
239
+ })
240
+
230
241
  it('goes its own way - a request carrying a selection is a screen request', () => {
231
242
  assert.match(dock, /if \(peekAssistantAsk\(\)\?\.selection\) return this\.takeScreenRequest\(\)/)
232
243
  })
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict'
9
9
  import { readFileSync } from 'node:fs'
10
10
  import { join } from 'node:path'
11
11
 
12
- import { sessionActions } from '../client/utils/assistant-session-actions.ts'
12
+ import { bulkActions, sessionActions } from '../client/utils/assistant-session-actions.ts'
13
13
 
14
14
  const read = (file: string) => readFileSync(join(__dirname, file), 'utf8')
15
15
  const manager = read('../client/components/assistant-session-manager.ts')
@@ -111,6 +111,58 @@ describe('opening and starting stay the host\'s', () => {
111
111
  })
112
112
  })
113
113
 
114
+ describe('many at once, suggestions, archive and bringing back (shared dock §2-7, 2026-10-25)', () => {
115
+ it('★ the picked rows are each done by their own hints: delete mine, leave the ones I am only in, hide any', () => {
116
+ const can = bulkActions([
117
+ { id: 'mine', mine: true, owner: true, participant: true },
118
+ { id: 'member', participant: true },
119
+ { id: 'seen', participant: false },
120
+ { id: 'hidden', participant: true, hidden: true }
121
+ ])
122
+ assert.deepEqual(can.remove, ['mine'])
123
+ assert.deepEqual(can.leave, ['member', 'hidden'])
124
+ assert.deepEqual(can.hide, ['mine', 'member', 'seen'])
125
+ })
126
+
127
+ it('the list, the deleted ones and bringing back go through the framework doors', () => {
128
+ for (const door of ['archivedSessionCount', 'cleanupSessionCount', 'aiSessionKeeping', 'deletedAISessions', 'restoreAISession', 'includeArchived', 'cleanupOnly']) {
129
+ assert.match(admin, new RegExp(door), door)
130
+ }
131
+ })
132
+
133
+ it('★ deleting many asks once and says how long they can come back; hiding many does not ask', () => {
134
+ assert.match(manager, /private async askRemoveMany\(ids: string\[\]\) \{\s*const ok = await this\.ask\(/)
135
+ assert.match(manager, /confirm-remove-many[^\n]*\$\{this\.keptFor\(\)\}/)
136
+ assert.match(manager, /confirm-remove'[^\n]*\$\{this\.keptFor\(\)\}/)
137
+ assert.match(manager, /private hideMany\(ids: string\[\]\) \{\s*void this\.runMany\(/)
138
+ })
139
+
140
+ it('★ each success is told to the host like a row\'s own change, each refusal said by name', () => {
141
+ assert.match(manager, /if \(await run\(id\)\) \{\s*count\+\+\s*this\.emit\('assistant-session-changed', \{ sessionId: id, change \}\)/)
142
+ assert.match(manager, /some-refused/)
143
+ })
144
+
145
+ it('★ the suggestions arrive picked; the archived wait for their view', () => {
146
+ assert.match(manager, /this\.pickAllOnRead = view === 'cleanup'/)
147
+ assert.match(manager, /if \(this\.pickAllOnRead\) \{\s*this\.pickAllOnRead = false\s*this\.picked = listed/)
148
+ assert.match(manager, /includeArchived: this\.view === 'archived',\s*cleanupOnly: this\.view === 'cleanup'/)
149
+ })
150
+
151
+ it('the box picks from the space around it too, so a press beside it does not open the conversation', () => {
152
+ assert.equal((manager.match(/<label class="pick-area"><input type="checkbox" class="pick"/g) ?? []).length, 2)
153
+ })
154
+
155
+ it('the deleted view shows only what applies to it, and its search narrows by name', () => {
156
+ assert.match(manager, /\$\{this\.view === 'deleted' \? nothing : html`<select aria-label=\$\{t\('filter-whose'/)
157
+ assert.match(manager, /\.filter\(one => !word \|\| \(one\.name \?\? ''\)\.toLowerCase\(\)\.includes\(word\)\)/)
158
+ })
159
+
160
+ it('the deleted ones come back from their own view, by the creator\'s door', () => {
161
+ assert.match(manager, /this\.admin\.deleted\(this\.anchorType, this\.anchorId\)/)
162
+ assert.match(manager, /id => this\.admin\.restore\(id\)/)
163
+ })
164
+ })
165
+
114
166
  describe('every word is in every locale', () => {
115
167
  const keys = [...new Set([...manager.matchAll(/\bt\('([a-z-]+)'/g)].map(match => `ai-assistant.session.${match[1]}`))]
116
168
 
@@ -150,11 +150,13 @@ describe('what the walk itself has to be true for', () => {
150
150
  * agents and their usage, design/ai/agent-settings.md), declared.
151
151
  * 2026-10-24: 41 — mayInviteToAISession · inviteToAISession · removeFromAISession · leaveAISession · sayTyping declared, chatTyping open (a
152
152
  * group conversation and its typing line, ADR-0073 decision 3).
153
+ * 2026-10-25: 46 — archivedSessionCount · cleanupSessionCount · aiSessionKeeping · deletedAISessions · restoreAISession
154
+ * (archive, suggestions to clear and bringing a deleted one back, shared dock §2-7), declared.
153
155
  */
154
156
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
155
157
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
156
158
 
157
- expect(all).toBe(41)
159
+ expect(all).toBe(46)
158
160
  expect(open).toBe(3)
159
161
  })
160
162
  })
@@ -146,7 +146,7 @@
146
146
  "ai-assistant.session.unhide": "Show in my list",
147
147
  "ai-assistant.session.remove": "Delete",
148
148
  "ai-assistant.session.cancel": "Cancel",
149
- "ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant and cannot be brought back.",
149
+ "ai-assistant.session.confirm-remove": "Delete \"{name}\"? It disappears for every participant.",
150
150
  "ai-assistant.session.confirm-open": "Make \"{name}\" public? Everyone in this domain will be able to read what is already written.",
151
151
  "ai-assistant.session.rename-refused": "this conversation could not be renamed.",
152
152
  "ai-assistant.session.hide-refused": "this conversation could not be hidden.",
@@ -249,7 +249,10 @@
249
249
  "ai-assistant.read.flow": "in or out",
250
250
  "ai-assistant.read.missing": "missing",
251
251
  "ai-assistant.read.period": "month",
252
+ "ai-assistant.read.period-from": "first month",
253
+ "ai-assistant.read.period-to": "last month",
252
254
  "ai-assistant.read.status": "status",
255
+ "ai-assistant.read.status-not": "status left out",
253
256
  "ai-assistant.read.min-amount": "minimum amount",
254
257
  "ai-assistant.read.max-amount": "maximum amount",
255
258
  "ai-assistant.read.text": "search words",
@@ -420,6 +423,39 @@
420
423
  "ai-assistant.session.confirm-leave": "Leave \"{name}\"? You will not read it again unless its owner brings you back.",
421
424
  "ai-assistant.session.leave-refused": "you could not leave this conversation.",
422
425
  "ai-assistant.session.audience-group": "Invited people",
426
+ "ai-assistant.session.archived": "Archived",
427
+ "ai-assistant.session.archived-count": "{count} archived - no activity for {days} days",
428
+ "ai-assistant.session.cleanup-empty": "Nothing of yours to clear here.",
429
+ "ai-assistant.session.cleanup-suggest": "{count} of yours look ready to clear - see them",
430
+ "ai-assistant.session.confirm-leave-many": "Leave {count} conversations? They stop coming to your list, and a group or private one is read again only when its owner brings you back.",
431
+ "ai-assistant.session.confirm-remove-many": "Delete {count} conversations? They disappear for every participant.",
432
+ "ai-assistant.session.deleted-empty": "Nothing you deleted here is kept.",
433
+ "ai-assistant.session.deleted-on": "Deleted {date}",
434
+ "ai-assistant.session.filter-view": "Show",
435
+ "ai-assistant.session.hidden-many": "{count} hidden from your list.",
436
+ "ai-assistant.session.hide-many": "Hide {count}",
437
+ "ai-assistant.session.kept": "You can bring it back from Deleted.",
438
+ "ai-assistant.session.kept-for": "You can bring it back from Deleted for {days} days.",
439
+ "ai-assistant.session.leave-many": "Leave {count}",
440
+ "ai-assistant.session.left-many": "Left {count}.",
441
+ "ai-assistant.session.pick": "Pick {name}",
442
+ "ai-assistant.session.pick-all": "Pick all",
443
+ "ai-assistant.session.picked": "{count} picked",
444
+ "ai-assistant.session.purges-on": "emptied {date}",
445
+ "ai-assistant.session.reason-empty": "Never asked anything",
446
+ "ai-assistant.session.reason-inactive": "No activity for {days} days",
447
+ "ai-assistant.session.remove-many": "Delete {count}",
448
+ "ai-assistant.session.removed-many": "{count} deleted.",
449
+ "ai-assistant.session.restore": "Bring back",
450
+ "ai-assistant.session.restore-many": "Bring back {count}",
451
+ "ai-assistant.session.restored": "\"{name}\" is back.",
452
+ "ai-assistant.session.restored-many": "{count} brought back.",
453
+ "ai-assistant.session.some-refused": "Not done for {count}: {names}",
454
+ "ai-assistant.session.unpick": "Clear the pick",
455
+ "ai-assistant.session.view-archived": "Including archived",
456
+ "ai-assistant.session.view-cleanup": "Suggested to clear",
457
+ "ai-assistant.session.view-deleted": "Deleted",
458
+ "ai-assistant.session.view-list": "Active",
423
459
  "ai-assistant.text.invite-title": "Conversation invite",
424
460
  "ai-assistant.text.invite-body-named": "{who} brought you into the conversation \"{name}\".",
425
461
  "ai-assistant.text.invite-body": "{who} brought you into a conversation.",
@@ -441,5 +477,12 @@
441
477
  "ai-assistant.dock.screen-request-default": "Read the selected records and suggest what can be done with them now.",
442
478
  "ai-assistant.dock.screen-request-failed": "The request was not sent. Try again.",
443
479
  "ai-assistant.text.screen-request-busy": "an answer is still being written.",
444
- "ai-assistant.text.screen-request-no-session": "this screen has no conversation to send to."
480
+ "ai-assistant.text.screen-request-no-session": "this screen has no conversation to send to.",
481
+ "ai-assistant.selection.count": "{n} selected",
482
+ "ai-assistant.selection.request": "Describe what to do with these records",
483
+ "ai-assistant.selection.work-with-ai": "Work with AI",
484
+ "ai-assistant.selection.region": "Work with selection",
485
+ "ai-assistant.selection.pick-rows": "Select rows to work on with AI.",
486
+ "ai-assistant.selection.save-first": "Save or cancel pending edits before working with the selected rows.",
487
+ "ai-assistant.selection.ask": "Read the {n} selected records from {title} and suggest what can be done with them now."
445
488
  }