@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
@@ -20,6 +20,7 @@ exports.INBOX_SCAN_CAP = void 0;
20
20
  exports.buildInbox = buildInbox;
21
21
  exports.applyHidden = applyHidden;
22
22
  exports.filterInbox = filterInbox;
23
+ exports.listedEntries = listedEntries;
23
24
  exports.searchInbox = searchInbox;
24
25
  exports.searchReadableMessages = searchReadableMessages;
25
26
  exports.countUnreadSessions = countUnreadSessions;
@@ -117,6 +118,16 @@ function filterInbox(entries, filter = {}) {
117
118
  return true;
118
119
  });
119
120
  }
121
+ /**
122
+ * Which entries the list stands on before its filters (shared-dock.md §2-7): the suggestions to clear are every
123
+ * candidate, hidden or archived; otherwise a hidden one waits for its filter, and an archived one for its filter or a
124
+ * search - finding an old conversation by a word is how it is mostly looked for.
125
+ */
126
+ function listedEntries(entries, opts = {}) {
127
+ if (opts.cleanupOnly)
128
+ return entries.filter(entry => entry.cleanupReason);
129
+ return entries.filter(entry => (opts.showHidden || !entry.hidden) && (opts.includeArchived || !!opts.search?.trim() || !entry.archived));
130
+ }
120
131
  function searchInbox(entries, search) {
121
132
  const q = (search ?? '').trim().toLowerCase();
122
133
  if (!q)
@@ -1 +1 @@
1
- {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\nimport { mayReadSession } from './session-visibility.js'\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n const owns = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n visibility: session.visibility ?? 'open',\n owner: owns.has(id),\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\n}\n\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */\nexport function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {\n const q = search.trim().toLowerCase()\n return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))\n .map(row => row.sessionId).filter((id): id is string => !!id))\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
1
+ {"version":3,"file":"session-inbox.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-inbox.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 세션 목록(받은편지함) 순수 로직 — 리졸버와 분리해 단위 테스트 가능.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 대화가 늘면(가벼운 질문을 승격할 때마다 새 세션) \"어디로 갈지\"를 목록에서 골라야 하고, 다른 참여자가\n * 남긴 새 메시지를 알아야 한다. 세션 목록은 **최근 활동순**이어야 하고 **안 읽음**이 보여야 한다.\n *\n * ── 집계 쿼리를 쓰지 않는다 ─────────────────────────────────────────────────\n * 세션마다 메시지를 집계하면(MAX/COUNT + group by) 목록 한 번에 집계가 붙고, 이 프레임워크는 5개 DB\n * 드라이버를 지원해야 한다. 대신 `ChatSession.lastMessageAt` 을 비정규화해 두고(메시지 저장 시 갱신),\n * 참여자의 `lastReadAt` 과 **값 비교**로 안 읽음을 판정한다 — 조회 두 번(세션·내 참여자)으로 끝난다.\n *\n * ── v1 은 개수가 아니라 \"안 읽음 여부\" ──────────────────────────────────────\n * 정확한 미읽음 **개수**는 세션별 count 가 필요하다(목록 N개면 N번). 목록에서는 점 하나로 충분하고,\n * 숫자가 필요해지면 화면에 보이는 세션에 한해 따로 세면 된다. 처음부터 N번 세지 않는다.\n */\n\nimport { mayReadSession } from './session-visibility.js'\n\n/** 목록에 필요한 세션 필드(엔티티·typeorm 비의존). */\nexport interface InboxSessionRow {\n id?: string\n name?: string\n /** 어느 대화인가 — 목록이 대화를 가리지 않으므로, 고른 뒤 화면이 따라갈 수 있게 함께 낸다. */\n kind?: string\n anchorType?: string\n anchorId?: string\n boardId?: string\n lastMessageAt?: Date | string | null\n lastMessagePreview?: string | null\n /** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */\n lastMessageDoors?: string[] | null\n createdAt?: Date | string | null\n /** 도메인 안 누구나 읽나(`open`) · 참여자만(`private`) — ADR-0073 판정 2. 값이 없으면 open(옛 행). */\n visibility?: string | null\n}\n\n/** 내 참여 기록(읽은 지점). */\nexport interface MyParticipation {\n sessionId?: string\n /** 이 대화에서 나의 자리 — `owner` 만 공개 범위를 바꾼다. */\n role?: string | null\n lastReadAt?: Date | string | null\n /** 내 목록에서 숨긴 시각 — 이후 새 활동이 생기면 다시 보인다. */\n hiddenAt?: Date | string | null\n}\n\nexport interface InboxEntry extends InboxSessionRow {\n /** 안 읽은 활동이 있는가 — 목록의 점 표시. */\n unread: boolean\n /** 내가 참여자인가 — 아니면 열어볼 수는 있어도 읽음 상태를 갖지 않는다. */\n participant: boolean\n /** 내가 숨긴 대화인가 — 목록에서 빠지지만 필터로 볼 수 있다(되돌리기 가능). */\n hidden?: boolean\n /** `open` · `private` — 목록이 자물쇠를 그리는 근거. */\n visibility: string\n /** 내가 이 대화의 owner 인가 — 공개 범위를 바꿀 수 있는 사람. UI 힌트이고 판정은 문이 다시 한다. */\n owner: boolean\n}\n\nfunction time(v: Date | string | null | undefined): number {\n if (!v) return 0\n const t = v instanceof Date ? v.getTime() : Date.parse(v)\n return Number.isFinite(t) ? t : 0\n}\n\n/**\n * 세션 + 내 참여 기록 → 목록 항목. **최근 활동순**(활동 없으면 생성순) 내림차순.\n *\n * 안 읽음 판정: 세션의 마지막 활동이 내가 읽은 지점보다 **뒤**면 안 읽음.\n * · 참여자가 아니면 안 읽음으로 표시하지 않는다(내 것이 아닌 대화에 배지를 띄우지 않는다).\n * · 참여자인데 한 번도 읽지 않았고(lastReadAt 없음) 활동이 있으면 안 읽음이다.\n * · 활동이 없는 세션(빈 대화)은 안 읽음이 아니다.\n *\n * 같은 시각 비교는 **엄격 초과**만 안 읽음으로 본다 — 읽은 직후 같은 밀리초에 저장된 활동을 안 읽음으로\n * 잡으면 방금 읽은 화면에 배지가 남는다.\n */\nexport function buildInbox(sessions: InboxSessionRow[], mine: MyParticipation[]): InboxEntry[] {\n const readBy = new Map<string, number>()\n const isParticipant = new Set<string>()\n const owns = new Set<string>()\n for (const p of mine) {\n if (!p.sessionId) continue\n isParticipant.add(p.sessionId)\n readBy.set(p.sessionId, time(p.lastReadAt))\n if (p.role === 'owner') owns.add(p.sessionId)\n }\n\n const entries = sessions\n /*\n * 남의 비공개 대화는 목록에도 서지 않는다 — ADR-0073 판정 1 의 「읽는 길은 같은 관문을 지난다」.\n * 메시지 문(`chatMessages`)만 막고 목록이 이름을 내보내면, 대화의 주제가 다른 길로 새어 나간다.\n */\n .filter(session => mayReadSession(session.visibility, isParticipant.has(session.id ?? '')))\n .map(session => {\n const id = session.id ?? ''\n const activity = time(session.lastMessageAt)\n const participant = isParticipant.has(id)\n const read = readBy.get(id) ?? 0\n return {\n ...session,\n participant,\n visibility: session.visibility ?? 'open',\n owner: owns.has(id),\n unread: participant && activity > 0 && activity > read\n }\n })\n\n return entries.sort(\n (a, b) => (time(b.lastMessageAt) || time(b.createdAt)) - (time(a.lastMessageAt) || time(a.createdAt))\n )\n}\n\n/**\n * 숨김 표시·필터.\n *\n * 숨김은 **나에게만** 적용되고 되돌릴 수 있다. 숨긴 **뒤에 새 활동이 생기면 다시 보인다** —\n * 아직 참여 중인 대화에서 조용히 잘려 나가는 것이 목록 한 줄보다 나쁘다.\n *\n * `showHidden` 이면 숨긴 것도 함께 돌려주되 표시(hidden)를 남긴다 — 필터를 켠 사용자가 무엇이\n * 숨겨진 것인지 알아야 되돌릴 수 있다.\n */\nexport function applyHidden(entries: InboxEntry[], hiddenAt: Map<string, Date | string | null | undefined>, showHidden = false): InboxEntry[] {\n const out: InboxEntry[] = []\n for (const entry of entries) {\n const at = hiddenAt.get(entry.id ?? '')\n /* 숨긴 시각보다 **뒤에** 활동이 있으면 숨김은 풀린 것으로 본다. */\n const hidden = !!at && time(entry.lastMessageAt) <= time(at)\n if (hidden && !showHidden) continue\n out.push(hidden ? { ...entry, hidden: true } : entry)\n }\n return out\n}\n\n/**\n * 검색 — 이름과 마지막 미리보기에 대한 대소문자 무시 부분일치.\n *\n * **왜 메모리에서 하는가**: 이 프레임워크는 5개 DB 드라이버를 지원한다. 대소문자 무시 검색은\n * 드라이버마다 표기가 갈리고(ILIKE=pg 전용, LOWER()+LIKE 는 되지만 정렬·NULL 취급이 또 갈린다),\n * 목록 경로에 드라이버별 SQL 을 두는 것은 이 프로젝트의 규율에 반한다. 그래서 조회는 앵커로\n * **상한을 두고** 가져오고(아래 INBOX_SCAN_CAP) 걸러내기·정렬·페이지는 여기서 한다.\n * 상한을 넘으면 잘렸다는 사실을 그대로 알린다(조용히 일부만 보여주지 않는다).\n */\n/** What the conversation list is narrowed by - the manager's filters (design/ai/shared-dock.md §2-6). */\nexport interface InboxFilter {\n /** Only this conversation kind (mode). Absent: every kind on the anchor. */\n kind?: string | null\n /** Only the ones I started. */\n mineOnly?: boolean | null\n /** Only the ones I am in (owner or member). */\n participantOnly?: boolean | null\n /** Only 'open' or only 'private'. */\n visibility?: string | null\n /** Only the ones with activity I have not read. */\n unreadOnly?: boolean | null\n}\n\n/**\n * Narrow the list. Pure - the resolver builds the entries (with the reader's gate already applied) and\n * this decides only what the person asked to see. A session from before the kind column has no kind:\n * it is shown under every kind, so it is never lost from the list it was started in.\n */\nexport function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries: T[], filter: InboxFilter = {}): T[] {\n return entries.filter(entry => {\n if (filter.kind && entry.kind && entry.kind !== filter.kind) return false\n if (filter.mineOnly && !entry.mine) return false\n if (filter.participantOnly && !entry.participant) return false\n if (filter.visibility && (entry.visibility ?? 'open') !== filter.visibility) return false\n if (filter.unreadOnly && !entry.unread) return false\n return true\n })\n}\n\n/**\n * Which entries the list stands on before its filters (shared-dock.md §2-7): the suggestions to clear are every\n * candidate, hidden or archived; otherwise a hidden one waits for its filter, and an archived one for its filter or a\n * search - finding an old conversation by a word is how it is mostly looked for.\n */\nexport function listedEntries<T extends { hidden?: boolean; archived?: boolean; cleanupReason?: string | null }>(\n entries: T[],\n opts: { showHidden?: boolean | null; includeArchived?: boolean | null; cleanupOnly?: boolean | null; search?: string | null } = {}\n): T[] {\n if (opts.cleanupOnly) return entries.filter(entry => entry.cleanupReason)\n return entries.filter(entry => (opts.showHidden || !entry.hidden) && (opts.includeArchived || !!opts.search?.trim() || !entry.archived))\n}\n\nexport function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {\n const q = (search ?? '').trim().toLowerCase()\n if (!q) return entries\n return entries.filter(e =>\n `${e.name ?? ''}\\n${e.lastMessagePreview ?? ''}`.toLowerCase().includes(q)\n )\n}\n\n/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */\nexport function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {\n const q = search.trim().toLowerCase()\n return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))\n .map(row => row.sessionId).filter((id): id is string => !!id))\n}\n\n/** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */\nexport const INBOX_SCAN_CAP = 500\n\n/** 안 읽은 세션 수 — 도크 버튼 배지(개수가 아니라 **세션 수**). */\nexport function countUnreadSessions(entries: InboxEntry[]): number {\n return entries.filter(e => e.unread).length\n}\n\n/**\n * 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다\n * (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).\n */\nexport function activityPatch(\n content: string,\n at: Date = new Date(),\n doors: string[] = []\n): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n /* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */\n return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }\n}\n\n/**\n * 첫 발화로 세션 이름을 만든다.\n *\n * `세션 2` 같은 순번은 목록에서 아무것도 알려주지 않는다 — \"A-DC 포화 원인\" 이어야 돌아갈 대화를 찾는다.\n * 사용자가 직접 지은 이름은 이 함수로 덮지 않는다(호출부가 이름 없을 때만 부른다).\n *\n * 규칙: 한 줄로 눌러 첫 문장 경계(., ?, !, 개행)까지, 최대 40자. 문장 부호로 끝나면 떼어 낸다\n * (목록 제목에 물음표가 붙으면 길이만 먹는다).\n */\nexport function autoSessionName(content: string, max = 40): string {\n const flat = (content ?? '').replace(/\\s+/g, ' ').trim()\n if (!flat) return ''\n const cut = flat.search(/[.?!\\n]/)\n const head = (cut > 0 ? flat.slice(0, cut) : flat).trim()\n const name = head.length > max ? head.slice(0, max).trim() + '…' : head\n return name\n}\n"]}
@@ -3,11 +3,15 @@ export { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-
3
3
  export declare const PURGE_BATCH = 100;
4
4
  export interface PurgeResult {
5
5
  purged: number;
6
+ /** Empty conversations removed - never asked anything, and nobody opened them for a day. */
7
+ emptied: number;
6
8
  failed: {
7
9
  sessionId: string;
8
10
  message: string;
9
11
  }[];
10
12
  }
13
+ /** Removes the empty conversations nobody opened for EMPTY_DAYS. Each is checked against its messages and seats, not the denormalized activity alone. */
14
+ export declare function removeEmptySessions(now?: Date, result?: PurgeResult): Promise<PurgeResult>;
11
15
  /** Empties the conversations whose deletion is older than the retention period. */
12
16
  export declare function purgeDeletedSessions(now?: Date, retentionDays?: number): Promise<PurgeResult>;
13
17
  /** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PURGE_BATCH = exports.retentionDaysOf = exports.purgeCutoff = exports.DEFAULT_RETENTION_DAYS = void 0;
4
+ exports.removeEmptySessions = removeEmptySessions;
4
5
  exports.purgeDeletedSessions = purgeDeletedSessions;
5
6
  exports.schedulePurge = schedulePurge;
6
7
  /*
@@ -13,6 +14,9 @@ exports.schedulePurge = schedulePurge;
13
14
  * files together once the retention period has passed.
14
15
  *
15
16
  * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.
17
+ *
18
+ * The same run removes empty conversations nobody opened for a day (`session-cleanup.ts`, shared-dock.md §2-7) -
19
+ * 「새 대화」 starts a row before its first question, and one never asked stays in the list otherwise.
16
20
  */
17
21
  const env_1 = require("@things-factory/env");
18
22
  const shell_1 = require("@things-factory/shell");
@@ -22,15 +26,50 @@ const chat_session_participant_js_1 = require("../chat-session-participant/chat-
22
26
  const chat_session_js_1 = require("./chat-session.js");
23
27
  const session_attachments_js_1 = require("./session-attachments.js");
24
28
  const session_retention_js_1 = require("./session-retention.js");
29
+ const session_cleanup_js_1 = require("./session-cleanup.js");
25
30
  var session_retention_js_2 = require("./session-retention.js");
26
31
  Object.defineProperty(exports, "DEFAULT_RETENTION_DAYS", { enumerable: true, get: function () { return session_retention_js_2.DEFAULT_RETENTION_DAYS; } });
27
32
  Object.defineProperty(exports, "purgeCutoff", { enumerable: true, get: function () { return session_retention_js_2.purgeCutoff; } });
28
33
  Object.defineProperty(exports, "retentionDaysOf", { enumerable: true, get: function () { return session_retention_js_2.retentionDaysOf; } });
29
34
  /** How many conversations one run empties - a large backlog is worked off over several runs. */
30
35
  exports.PURGE_BATCH = 100;
36
+ /** A conversation, its messages, its participants and its files, gone together. */
37
+ async function removeConversation(session) {
38
+ await (0, session_attachments_js_1.removeSessionFiles)(session);
39
+ /* Messages point at each other (parentMessage) - drop the links before the rows. */
40
+ await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).update({ session: { id: session.id }, parentMessage: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) }, { parentMessage: null });
41
+ await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).delete({ session: { id: session.id } });
42
+ await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).delete({ session: { id: session.id } });
43
+ /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
44
+ await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).delete({ id: (0, typeorm_1.In)([session.id]) });
45
+ }
46
+ /** Removes the empty conversations nobody opened for EMPTY_DAYS. Each is checked against its messages and seats, not the denormalized activity alone. */
47
+ async function removeEmptySessions(now = new Date(), result = { purged: 0, emptied: 0, failed: [] }) {
48
+ const sessions = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).find({
49
+ where: { lastMessageAt: (0, typeorm_1.IsNull)(), createdAt: (0, typeorm_1.LessThan)(new Date(now.getTime() - session_cleanup_js_1.EMPTY_DAYS * session_retention_js_1.DAY_MS)) },
50
+ relations: ['domain'],
51
+ take: exports.PURGE_BATCH
52
+ });
53
+ for (const session of sessions) {
54
+ try {
55
+ const messages = await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).count({ where: { session: { id: session.id } } });
56
+ const seats = await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).find({ where: { session: { id: session.id } }, withDeleted: true });
57
+ const touched = seats.flatMap(seat => [seat.updatedAt, seat.lastReadAt]).filter(Boolean);
58
+ const lastTouchedAt = touched.length ? new Date(Math.max(...touched.map(at => new Date(at).getTime()))) : null;
59
+ if (!(0, session_cleanup_js_1.emptyAndAbandoned)({ ...session, messages, lastTouchedAt }, now))
60
+ continue;
61
+ await removeConversation(session);
62
+ result.emptied++;
63
+ }
64
+ catch (error) {
65
+ result.failed.push({ sessionId: session.id, message: error.message });
66
+ }
67
+ }
68
+ return result;
69
+ }
31
70
  /** Empties the conversations whose deletion is older than the retention period. */
32
71
  async function purgeDeletedSessions(now = new Date(), retentionDays = (0, session_retention_js_1.retentionDaysOf)(env_1.config.get('ai-assistant', {})?.sessionRetentionDays)) {
33
- const result = { purged: 0, failed: [] };
72
+ const result = { purged: 0, emptied: 0, failed: [] };
34
73
  if (retentionDays === null)
35
74
  return result;
36
75
  const sessions = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).find({
@@ -41,13 +80,7 @@ async function purgeDeletedSessions(now = new Date(), retentionDays = (0, sessio
41
80
  });
42
81
  for (const session of sessions) {
43
82
  try {
44
- await (0, session_attachments_js_1.removeSessionFiles)(session);
45
- /* Messages point at each other (parentMessage) - drop the links before the rows. */
46
- await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).update({ session: { id: session.id }, parentMessage: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) }, { parentMessage: null });
47
- await (0, shell_1.getRepository)(chat_message_js_1.ChatMessage).delete({ session: { id: session.id } });
48
- await (0, shell_1.getRepository)(chat_session_participant_js_1.ChatSessionParticipant).delete({ session: { id: session.id } });
49
- /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
50
- await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).delete({ id: (0, typeorm_1.In)([session.id]) });
83
+ await removeConversation(session);
51
84
  result.purged++;
52
85
  }
53
86
  catch (error) {
@@ -63,9 +96,10 @@ function schedulePurge() {
63
96
  if (timer)
64
97
  return;
65
98
  const run = () => purgeDeletedSessions()
66
- .then(({ purged, failed }) => {
67
- if (purged || failed.length)
68
- console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '');
99
+ .then(result => removeEmptySessions(new Date(), result))
100
+ .then(({ purged, emptied, failed }) => {
101
+ if (purged || emptied || failed.length)
102
+ console.info(`[ai-assistant] purged ${purged} deleted and ${emptied} empty conversation(s)`, failed.length ? { failed } : '');
69
103
  })
70
104
  .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', error.message));
71
105
  const first = setTimeout(run, 60 * 1000);
@@ -1 +1 @@
1
- {"version":3,"file":"session-purge.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-purge.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.\n *\n * `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files\n * (every door checks the session), and within the retention period it can still be brought back whole.\n * Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed\n * forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached\n * files together once the retention period has passed.\n *\n * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.\n */\nimport { config } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\nimport { In, IsNull, LessThan, Not } from 'typeorm'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { ChatSession } from './chat-session.js'\nimport { removeSessionFiles } from './session-attachments.js'\n\nimport { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'\n\nexport { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'\n/** How many conversations one run empties - a large backlog is worked off over several runs. */\nexport const PURGE_BATCH = 100\n\nexport interface PurgeResult {\n purged: number\n failed: { sessionId: string; message: string }[]\n}\n\n/** Empties the conversations whose deletion is older than the retention period. */\nexport async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {\n const result: PurgeResult = { purged: 0, failed: [] }\n if (retentionDays === null) return result\n\n const sessions = await getRepository(ChatSession).find({\n where: { deletedAt: LessThan(purgeCutoff(now, retentionDays)) } as any,\n withDeleted: true,\n relations: ['domain'],\n take: PURGE_BATCH\n })\n\n for (const session of sessions) {\n try {\n await removeSessionFiles(session as any)\n /* Messages point at each other (parentMessage) - drop the links before the rows. */\n await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)\n await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)\n await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)\n /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */\n await getRepository(ChatSession).delete({ id: In([session.id]) } as any)\n result.purged++\n } catch (error) {\n /* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */\n result.failed.push({ sessionId: session.id!, message: (error as Error).message })\n }\n }\n\n return result\n}\n\nlet timer: ReturnType<typeof setInterval> | undefined\n\n/** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */\nexport function schedulePurge(): void {\n if (timer) return\n const run = () =>\n purgeDeletedSessions()\n .then(({ purged, failed }) => {\n if (purged || failed.length) console.info(`[ai-assistant] purged ${purged} deleted conversation(s)`, failed.length ? { failed } : '')\n })\n .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))\n\n const first = setTimeout(run, 60 * 1000)\n first.unref?.()\n timer = setInterval(run, DAY_MS)\n timer.unref?.()\n}\n\nprocess.on('bootstrap-module-start' as any, () => schedulePurge())\n"]}
1
+ {"version":3,"file":"session-purge.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-purge.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Emptying deleted conversations after they have been kept long enough - design/ai/shared-dock.md §9-5.\n *\n * `deleteAISession` is a soft delete: from that moment nobody can open the conversation or its files\n * (every door checks the session), and within the retention period it can still be brought back whole.\n * Nothing ever emptied them afterwards, so a deleted conversation's messages - and now its files - stayed\n * forever (2026-09-25). This job removes a conversation, its messages, its participants and its attached\n * files together once the retention period has passed.\n *\n * Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.\n *\n * The same run removes empty conversations nobody opened for a day (`session-cleanup.ts`, shared-dock.md §2-7) -\n * 「새 대화」 starts a row before its first question, and one never asked stays in the list otherwise.\n */\nimport { config } from '@things-factory/env'\nimport { getRepository } from '@things-factory/shell'\nimport { In, IsNull, LessThan, Not } from 'typeorm'\n\nimport { ChatMessage } from '../chat-message/chat-message.js'\nimport { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'\nimport { ChatSession } from './chat-session.js'\nimport { removeSessionFiles } from './session-attachments.js'\n\nimport { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'\nimport { EMPTY_DAYS, emptyAndAbandoned } from './session-cleanup.js'\n\nexport { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'\n/** How many conversations one run empties - a large backlog is worked off over several runs. */\nexport const PURGE_BATCH = 100\n\nexport interface PurgeResult {\n purged: number\n /** Empty conversations removed - never asked anything, and nobody opened them for a day. */\n emptied: number\n failed: { sessionId: string; message: string }[]\n}\n\n/** A conversation, its messages, its participants and its files, gone together. */\nasync function removeConversation(session: ChatSession): Promise<void> {\n await removeSessionFiles(session as any)\n /* Messages point at each other (parentMessage) - drop the links before the rows. */\n await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)\n await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)\n await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)\n /* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */\n await getRepository(ChatSession).delete({ id: In([session.id]) } as any)\n}\n\n/** Removes the empty conversations nobody opened for EMPTY_DAYS. Each is checked against its messages and seats, not the denormalized activity alone. */\nexport async function removeEmptySessions(now = new Date(), result: PurgeResult = { purged: 0, emptied: 0, failed: [] }): Promise<PurgeResult> {\n const sessions = await getRepository(ChatSession).find({\n where: { lastMessageAt: IsNull(), createdAt: LessThan(new Date(now.getTime() - EMPTY_DAYS * DAY_MS)) } as any,\n relations: ['domain'],\n take: PURGE_BATCH\n })\n for (const session of sessions) {\n try {\n const messages = await getRepository(ChatMessage).count({ where: { session: { id: session.id } } as any })\n const seats = await getRepository(ChatSessionParticipant).find({ where: { session: { id: session.id } } as any, withDeleted: true })\n const touched = seats.flatMap(seat => [seat.updatedAt, seat.lastReadAt]).filter(Boolean) as Date[]\n const lastTouchedAt = touched.length ? new Date(Math.max(...touched.map(at => new Date(at).getTime()))) : null\n if (!emptyAndAbandoned({ ...(session as any), messages, lastTouchedAt }, now)) continue\n await removeConversation(session)\n result.emptied++\n } catch (error) {\n result.failed.push({ sessionId: session.id!, message: (error as Error).message })\n }\n }\n return result\n}\n\n/** Empties the conversations whose deletion is older than the retention period. */\nexport async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {\n const result: PurgeResult = { purged: 0, emptied: 0, failed: [] }\n if (retentionDays === null) return result\n\n const sessions = await getRepository(ChatSession).find({\n where: { deletedAt: LessThan(purgeCutoff(now, retentionDays)) } as any,\n withDeleted: true,\n relations: ['domain'],\n take: PURGE_BATCH\n })\n\n for (const session of sessions) {\n try {\n await removeConversation(session)\n result.purged++\n } catch (error) {\n /* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */\n result.failed.push({ sessionId: session.id!, message: (error as Error).message })\n }\n }\n\n return result\n}\n\nlet timer: ReturnType<typeof setInterval> | undefined\n\n/** Runs the job once a little after boot, then daily. The timer never keeps the process alive. */\nexport function schedulePurge(): void {\n if (timer) return\n const run = () =>\n purgeDeletedSessions()\n .then(result => removeEmptySessions(new Date(), result))\n .then(({ purged, emptied, failed }) => {\n if (purged || emptied || failed.length)\n console.info(`[ai-assistant] purged ${purged} deleted and ${emptied} empty conversation(s)`, failed.length ? { failed } : '')\n })\n .catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))\n\n const first = setTimeout(run, 60 * 1000)\n first.unref?.()\n timer = setInterval(run, DAY_MS)\n timer.unref?.()\n}\n\nprocess.on('bootstrap-module-start' as any, () => schedulePurge())\n"]}
@@ -23,6 +23,20 @@ declare class ChatSessionInboxEntry {
23
23
  hidden?: boolean;
24
24
  visibility: string;
25
25
  owner: boolean;
26
+ archived?: boolean;
27
+ cleanupReason?: string;
28
+ idleDays?: number;
29
+ }
30
+ declare class DeletedSessionEntry {
31
+ id: string;
32
+ name?: string;
33
+ kind?: string;
34
+ deletedAt: Date;
35
+ purgesAt?: Date;
36
+ }
37
+ declare class SessionKeeping {
38
+ deletedDays?: number;
39
+ archiveDays?: number;
26
40
  }
27
41
  export declare class ChatAttachmentInfo {
28
42
  id: string;
@@ -38,14 +52,14 @@ export declare class ChatSessionResolver {
38
52
  * 집계 쿼리를 쓰지 않는다: 세션의 비정규화된 lastMessageAt 과 내 참여자 lastReadAt 을 **값 비교**한다
39
53
  * (5개 DB 드라이버 지원 때문에 group by/MAX 를 목록 경로에 두지 않는다).
40
54
  */
41
- chatSessionInbox(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean, limit?: number, offset?: number, kind?: string, participantOnly?: boolean, visibility?: string, unreadOnly?: boolean): Promise<ChatSessionInboxEntry[]>;
55
+ chatSessionInbox(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean, limit?: number, offset?: number, kind?: string, participantOnly?: boolean, visibility?: string, unreadOnly?: boolean, includeArchived?: boolean, cleanupOnly?: boolean): Promise<ChatSessionInboxEntry[]>;
42
56
  /**
43
57
  * 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
44
58
  *
45
59
  * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
46
60
  * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
47
61
  */
48
- chatSessionInboxTotal(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean, kind?: string, participantOnly?: boolean, visibility?: string, unreadOnly?: boolean): Promise<number>;
62
+ chatSessionInboxTotal(anchorType: string, anchorId: string, context: ResolverContext, showHidden?: boolean, search?: string, mineOnly?: boolean, kind?: string, participantOnly?: boolean, visibility?: string, unreadOnly?: boolean, includeArchived?: boolean, cleanupOnly?: boolean): Promise<number>;
49
63
  /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
50
64
  private _inboxFiltered;
51
65
  /**
@@ -55,8 +69,16 @@ export declare class ChatSessionResolver {
55
69
  * 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
56
70
  */
57
71
  hiddenSessionCount(anchorType: string, anchorId: string, context: ResolverContext): Promise<number>;
72
+ archivedSessionCount(anchorType: string, anchorId: string, context: ResolverContext): Promise<number>;
73
+ cleanupSessionCount(anchorType: string, anchorId: string, context: ResolverContext): Promise<number>;
74
+ aiSessionKeeping(): SessionKeeping;
75
+ /** What I deleted here and can still bring back - newest first. Only the creator deletes, so only the creator sees them. */
76
+ deletedAISessions(anchorType: string, anchorId: string, context: ResolverContext): Promise<DeletedSessionEntry[]>;
77
+ /** Bringing a deleted conversation back - its creator, while it is kept. Its messages and seats were never touched. */
78
+ restoreAISession(sessionId: string, context: ResolverContext): Promise<boolean>;
58
79
  /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
59
80
  private _inboxEntries;
81
+ private _readInbox;
60
82
  /**
61
83
  * 세션 삭제 — **만든 사람만.**
62
84
  *
@@ -13,6 +13,7 @@ const graphql_scalars_1 = require("graphql-scalars");
13
13
  const auth_base_1 = require("@things-factory/auth-base");
14
14
  const get_domain_users_js_1 = require("@things-factory/auth-base/dist-server/utils/get-domain-users.js");
15
15
  const shell_1 = require("@things-factory/shell");
16
+ const env_1 = require("@things-factory/env");
16
17
  const typeorm_1 = require("typeorm");
17
18
  const chat_session_js_1 = require("./chat-session/chat-session.js");
18
19
  const session_anchor_js_1 = require("./chat-session/session-anchor.js");
@@ -28,6 +29,15 @@ const invite_notification_js_1 = require("./chat-session-participant/invite-noti
28
29
  const read_roster_js_1 = require("./chat-session-participant/read-roster.js");
29
30
  const participant_lookup_js_1 = require("./chat-session-participant/participant-lookup.js");
30
31
  const session_write_gate_js_1 = require("./chat-session/session-write-gate.js");
32
+ const session_cleanup_js_1 = require("./chat-session/session-cleanup.js");
33
+ const session_retention_js_1 = require("./chat-session/session-retention.js");
34
+ /** The keeping periods the config asks for (shared-dock.md §9-5, §2-7). */
35
+ function keeping() {
36
+ const options = env_1.config.get('ai-assistant', {}) ?? {};
37
+ return { deletedDays: (0, session_retention_js_1.retentionDaysOf)(options.sessionRetentionDays), archiveDays: (0, session_cleanup_js_1.archiveDaysOf)(options.sessionArchiveDays) };
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();
31
41
  /** 초대 알림 — 발송은 호스트의 기전을 그대로 쓴다(`sendInviteNotification`, 문 셋이 같은 한 곳). */
32
42
  async function notifyInvited(context, invited, inviter, sessionName) {
33
43
  await (0, invite_notification_js_1.sendInviteNotification)(context.state?.notify, invited, inviter, sessionName, undefined, (0, request_translator_js_1.requestTranslator)(context));
@@ -128,11 +138,61 @@ tslib_1.__decorate([
128
138
  (0, type_graphql_1.Field)({ description: 'Whether this viewer is the owner of the session - only the owner may change its visibility.' }),
129
139
  tslib_1.__metadata("design:type", Boolean)
130
140
  ], ChatSessionInboxEntry.prototype, "owner", void 0);
141
+ tslib_1.__decorate([
142
+ (0, type_graphql_1.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.' }),
143
+ tslib_1.__metadata("design:type", Boolean)
144
+ ], ChatSessionInboxEntry.prototype, "archived", void 0);
145
+ tslib_1.__decorate([
146
+ (0, type_graphql_1.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." }),
147
+ tslib_1.__metadata("design:type", String)
148
+ ], ChatSessionInboxEntry.prototype, "cleanupReason", void 0);
149
+ tslib_1.__decorate([
150
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { nullable: true, description: 'Whole days since the last activity, or since the start when there is none.' }),
151
+ tslib_1.__metadata("design:type", Number)
152
+ ], ChatSessionInboxEntry.prototype, "idleDays", void 0);
131
153
  ChatSessionInboxEntry = tslib_1.__decorate([
132
154
  (0, type_graphql_1.ObjectType)({
133
155
  description: 'Session list entry for the inbox — a session plus this viewer\'s unread state. Ordered by recent activity so a user can find where to go back to.'
134
156
  })
135
157
  ], ChatSessionInboxEntry);
158
+ let DeletedSessionEntry = class DeletedSessionEntry {
159
+ };
160
+ tslib_1.__decorate([
161
+ (0, type_graphql_1.Field)(type => type_graphql_1.ID, { description: 'Session id.' }),
162
+ tslib_1.__metadata("design:type", String)
163
+ ], DeletedSessionEntry.prototype, "id", void 0);
164
+ tslib_1.__decorate([
165
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Session name.' }),
166
+ tslib_1.__metadata("design:type", String)
167
+ ], DeletedSessionEntry.prototype, "name", void 0);
168
+ tslib_1.__decorate([
169
+ (0, type_graphql_1.Field)({ nullable: true, description: 'Which conversation this session is - the registered mode kind.' }),
170
+ tslib_1.__metadata("design:type", String)
171
+ ], DeletedSessionEntry.prototype, "kind", void 0);
172
+ tslib_1.__decorate([
173
+ (0, type_graphql_1.Field)({ description: 'When it was deleted.' }),
174
+ tslib_1.__metadata("design:type", Date)
175
+ ], DeletedSessionEntry.prototype, "deletedAt", void 0);
176
+ tslib_1.__decorate([
177
+ (0, type_graphql_1.Field)({ nullable: true, description: 'When the daily job empties it for good; none when emptying is turned off.' }),
178
+ tslib_1.__metadata("design:type", Date)
179
+ ], DeletedSessionEntry.prototype, "purgesAt", void 0);
180
+ DeletedSessionEntry = tslib_1.__decorate([
181
+ (0, type_graphql_1.ObjectType)({ description: 'A session the caller deleted that is still kept - it can be brought back until it is emptied.' })
182
+ ], DeletedSessionEntry);
183
+ let SessionKeeping = class SessionKeeping {
184
+ };
185
+ tslib_1.__decorate([
186
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { nullable: true, description: 'Days a deleted session can still be brought back; none when deleted sessions are never emptied.' }),
187
+ tslib_1.__metadata("design:type", Number)
188
+ ], SessionKeeping.prototype, "deletedDays", void 0);
189
+ tslib_1.__decorate([
190
+ (0, type_graphql_1.Field)(type => type_graphql_1.Int, { nullable: true, description: 'Days without activity after which a session is archived; none when archiving is off.' }),
191
+ tslib_1.__metadata("design:type", Number)
192
+ ], SessionKeeping.prototype, "archiveDays", void 0);
193
+ SessionKeeping = tslib_1.__decorate([
194
+ (0, type_graphql_1.ObjectType)({ description: 'How long conversations are kept - what the list says when it deletes or archives.' })
195
+ ], SessionKeeping);
136
196
  let ChatAttachmentInfo = class ChatAttachmentInfo {
137
197
  };
138
198
  exports.ChatAttachmentInfo = ChatAttachmentInfo;
@@ -203,9 +263,9 @@ let ChatSessionResolver = class ChatSessionResolver {
203
263
  * 집계 쿼리를 쓰지 않는다: 세션의 비정규화된 lastMessageAt 과 내 참여자 lastReadAt 을 **값 비교**한다
204
264
  * (5개 DB 드라이버 지원 때문에 group by/MAX 를 목록 경로에 두지 않는다).
205
265
  */
206
- async chatSessionInbox(anchorType, anchorId, context, showHidden, search, mineOnly, limit, offset, kind, participantOnly, visibility, unreadOnly) {
266
+ async chatSessionInbox(anchorType, anchorId, context, showHidden, search, mineOnly, limit, offset, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly) {
207
267
  const filtered = await this._inboxFiltered(anchorType, anchorId, context, {
208
- showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly
268
+ showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
209
269
  });
210
270
  const from = Math.max(0, offset ?? 0);
211
271
  const size = Math.min(Math.max(1, limit ?? 50), 200);
@@ -217,13 +277,15 @@ let ChatSessionResolver = class ChatSessionResolver {
217
277
  * 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
218
278
  * 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
219
279
  */
220
- async chatSessionInboxTotal(anchorType, anchorId, context, showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly) {
221
- return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly })).length;
280
+ async chatSessionInboxTotal(anchorType, anchorId, context, showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly) {
281
+ return (await this._inboxFiltered(anchorType, anchorId, context, {
282
+ showHidden, search, mineOnly, kind, participantOnly, visibility, unreadOnly, includeArchived, cleanupOnly
283
+ })).length;
222
284
  }
223
285
  /** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
224
286
  async _inboxFiltered(anchorType, anchorId, context, opts) {
225
287
  const all = await this._inboxEntries(anchorType, anchorId, context);
226
- let entries = opts.showHidden ? all : all.filter(e => !e.hidden);
288
+ let entries = (0, session_inbox_js_1.listedEntries)(all, opts);
227
289
  entries = (0, session_inbox_js_1.filterInbox)(entries, opts);
228
290
  if (!opts.search?.trim() || !entries.length)
229
291
  return entries;
@@ -252,8 +314,68 @@ let ChatSessionResolver = class ChatSessionResolver {
252
314
  const all = await this._inboxEntries(anchorType, anchorId, context);
253
315
  return all.filter(e => e.hidden).length;
254
316
  }
317
+ /* Like the hidden count: an archived conversation must not be forgotten, so the list says how many wait there. */
318
+ async archivedSessionCount(anchorType, anchorId, context) {
319
+ return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.archived && !e.hidden).length;
320
+ }
321
+ async cleanupSessionCount(anchorType, anchorId, context) {
322
+ return (await this._inboxEntries(anchorType, anchorId, context)).filter(e => e.cleanupReason).length;
323
+ }
324
+ aiSessionKeeping() {
325
+ const { deletedDays, archiveDays } = keeping();
326
+ return { deletedDays: deletedDays ?? undefined, archiveDays: archiveDays ?? undefined };
327
+ }
328
+ /** What I deleted here and can still bring back - newest first. Only the creator deletes, so only the creator sees them. */
329
+ async deletedAISessions(anchorType, anchorId, context) {
330
+ const { domain, user } = context.state;
331
+ if (!user?.id)
332
+ return [];
333
+ const { deletedDays } = keeping();
334
+ const rows = await (0, shell_1.getRepository)(chat_session_js_1.ChatSession).find({
335
+ where: { ...(0, session_anchor_js_1.anchorWhere)(domain.id, anchorType, anchorId), creator: { id: user.id }, deletedAt: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) },
336
+ withDeleted: true,
337
+ order: { deletedAt: 'DESC' },
338
+ take: session_inbox_js_1.INBOX_SCAN_CAP
339
+ });
340
+ return rows.map(row => ({
341
+ id: row.id,
342
+ name: row.name ?? undefined,
343
+ kind: row.kind ?? undefined,
344
+ deletedAt: row.deletedAt,
345
+ purgesAt: deletedDays === null ? undefined : new Date(new Date(row.deletedAt).getTime() + deletedDays * session_retention_js_1.DAY_MS)
346
+ }));
347
+ }
348
+ /** Bringing a deleted conversation back - its creator, while it is kept. Its messages and seats were never touched. */
349
+ async restoreAISession(sessionId, context) {
350
+ const { domain, user } = context.state;
351
+ const repo = (0, shell_1.getRepository)(chat_session_js_1.ChatSession);
352
+ const session = await repo.findOne({ where: { id: sessionId, domain: { id: domain.id } }, withDeleted: true });
353
+ if (!session || !session.deletedAt)
354
+ return false;
355
+ if (!user?.id || session.creatorId !== user.id) {
356
+ throw new Error('Only the user who created this session may bring it back.');
357
+ }
358
+ await repo.restore(sessionId);
359
+ (0, session_activity_publish_js_1.publishSessionActivity)({
360
+ domainId: domain.id,
361
+ sessionId,
362
+ kind: 'meta',
363
+ anchorType: session.anchorType,
364
+ anchorId: session.anchorId,
365
+ actorId: user.id
366
+ });
367
+ return true;
368
+ }
255
369
  /** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
256
- async _inboxEntries(anchorType, anchorId, context) {
370
+ _inboxEntries(anchorType, anchorId, context) {
371
+ const reads = inboxReads.get(context) ?? new Map();
372
+ inboxReads.set(context, reads);
373
+ const key = `${anchorType}\u0000${anchorId}`;
374
+ if (!reads.has(key))
375
+ reads.set(key, this._readInbox(anchorType, anchorId, context));
376
+ return reads.get(key);
377
+ }
378
+ async _readInbox(anchorType, anchorId, context) {
257
379
  const { domain, user } = context.state;
258
380
  /* 조회 상한 — 한 앵커의 대화가 아무리 늘어도 한 번에 이만큼만 훑는다. 최근 활동순으로 자른다
259
381
  * (오래된 대화가 잘리는 것이 최근 대화가 잘리는 것보다 낫다). 정렬·검색·페이지는 순수 로직이 한다. */
@@ -273,10 +395,18 @@ let ChatSessionResolver = class ChatSessionResolver {
273
395
  const hiddenAt = new Map(rows.map(r => [r.sessionId, r.hiddenAt]));
274
396
  /* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
275
397
  const createdByMe = new Set(sessions.filter(s => s.creatorId === user?.id).map(s => s.id));
276
- const entries = (0, session_inbox_js_1.buildInbox)(sessions, participations).map(entry => ({
277
- ...entry,
278
- mine: createdByMe.has(entry.id)
279
- }));
398
+ const now = new Date();
399
+ const { archiveDays } = keeping();
400
+ const entries = (0, session_inbox_js_1.buildInbox)(sessions, participations).map(entry => {
401
+ const mine = createdByMe.has(entry.id);
402
+ return {
403
+ ...entry,
404
+ mine,
405
+ idleDays: (0, session_cleanup_js_1.idleDays)(entry, now),
406
+ archived: (0, session_cleanup_js_1.isArchived)(entry, now, archiveDays),
407
+ cleanupReason: (0, session_cleanup_js_1.cleanupReason)({ ...entry, mine }, now) ?? undefined
408
+ };
409
+ });
280
410
  /* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
281
411
  const shown = (0, session_inbox_js_1.applyHidden)(entries, hiddenAt, true);
282
412
  /*
@@ -1018,8 +1148,10 @@ tslib_1.__decorate([
1018
1148
  tslib_1.__param(9, (0, type_graphql_1.Arg)('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' })),
1019
1149
  tslib_1.__param(10, (0, type_graphql_1.Arg)('visibility', { nullable: true, description: "Only 'open' or only 'private'." })),
1020
1150
  tslib_1.__param(11, (0, type_graphql_1.Arg)('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' })),
1151
+ tslib_1.__param(12, (0, type_graphql_1.Arg)('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' })),
1152
+ tslib_1.__param(13, (0, type_graphql_1.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." })),
1021
1153
  tslib_1.__metadata("design:type", Function),
1022
- tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean, Number, Number, String, Boolean, String, Boolean]),
1154
+ tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean, Number, Number, String, Boolean, String, Boolean, Boolean, Boolean]),
1023
1155
  tslib_1.__metadata("design:returntype", Promise)
1024
1156
  ], ChatSessionResolver.prototype, "chatSessionInbox", null);
1025
1157
  tslib_1.__decorate([
@@ -1037,8 +1169,10 @@ tslib_1.__decorate([
1037
1169
  tslib_1.__param(7, (0, type_graphql_1.Arg)('participantOnly', { nullable: true, description: 'Only conversations the caller is in.' })),
1038
1170
  tslib_1.__param(8, (0, type_graphql_1.Arg)('visibility', { nullable: true, description: "Only 'open' or only 'private'." })),
1039
1171
  tslib_1.__param(9, (0, type_graphql_1.Arg)('unreadOnly', { nullable: true, description: 'Only conversations with unread activity.' })),
1172
+ tslib_1.__param(10, (0, type_graphql_1.Arg)('includeArchived', { nullable: true, description: 'Also the archived ones - no activity for the archive period.' })),
1173
+ tslib_1.__param(11, (0, type_graphql_1.Arg)('cleanupOnly', { nullable: true, description: "Only the caller's own the list suggests clearing." })),
1040
1174
  tslib_1.__metadata("design:type", Function),
1041
- tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean, String, Boolean, String, Boolean]),
1175
+ tslib_1.__metadata("design:paramtypes", [String, String, Object, Boolean, String, Boolean, String, Boolean, String, Boolean, Boolean, Boolean]),
1042
1176
  tslib_1.__metadata("design:returntype", Promise)
1043
1177
  ], ChatSessionResolver.prototype, "chatSessionInboxTotal", null);
1044
1178
  tslib_1.__decorate([
@@ -1053,9 +1187,55 @@ tslib_1.__decorate([
1053
1187
  tslib_1.__metadata("design:paramtypes", [String, String, Object]),
1054
1188
  tslib_1.__metadata("design:returntype", Promise)
1055
1189
  ], ChatSessionResolver.prototype, "hiddenSessionCount", null);
1190
+ tslib_1.__decorate([
1191
+ (0, type_graphql_1.Query)(() => type_graphql_1.Int, { description: 'Number of sessions on this anchor that are archived (no activity for the archive period) and not hidden by the caller.' }),
1192
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)'),
1193
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
1194
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
1195
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
1196
+ tslib_1.__metadata("design:type", Function),
1197
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
1198
+ tslib_1.__metadata("design:returntype", Promise)
1199
+ ], ChatSessionResolver.prototype, "archivedSessionCount", null);
1200
+ tslib_1.__decorate([
1201
+ (0, type_graphql_1.Query)(() => type_graphql_1.Int, { description: "Number of the caller's own sessions on this anchor the list suggests clearing - empty, or inactive for 30 days." }),
1202
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)'),
1203
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
1204
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
1205
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
1206
+ tslib_1.__metadata("design:type", Function),
1207
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
1208
+ tslib_1.__metadata("design:returntype", Promise)
1209
+ ], ChatSessionResolver.prototype, "cleanupSessionCount", null);
1210
+ tslib_1.__decorate([
1211
+ (0, type_graphql_1.Query)(() => SessionKeeping, { description: 'How long deleted sessions can be brought back and after how long an idle one is archived.' }),
1212
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)'),
1213
+ tslib_1.__metadata("design:type", Function),
1214
+ tslib_1.__metadata("design:paramtypes", []),
1215
+ tslib_1.__metadata("design:returntype", SessionKeeping)
1216
+ ], ChatSessionResolver.prototype, "aiSessionKeeping", null);
1217
+ tslib_1.__decorate([
1218
+ (0, type_graphql_1.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.' }),
1219
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)'),
1220
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('anchorType')),
1221
+ tslib_1.__param(1, (0, type_graphql_1.Arg)('anchorId')),
1222
+ tslib_1.__param(2, (0, type_graphql_1.Ctx)()),
1223
+ tslib_1.__metadata("design:type", Function),
1224
+ tslib_1.__metadata("design:paramtypes", [String, String, Object]),
1225
+ tslib_1.__metadata("design:returntype", Promise)
1226
+ ], ChatSessionResolver.prototype, "deletedAISessions", null);
1227
+ tslib_1.__decorate([
1228
+ (0, type_graphql_1.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.' }),
1229
+ (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
1230
+ tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),
1231
+ tslib_1.__param(1, (0, type_graphql_1.Ctx)()),
1232
+ tslib_1.__metadata("design:type", Function),
1233
+ tslib_1.__metadata("design:paramtypes", [String, Object]),
1234
+ tslib_1.__metadata("design:returntype", Promise)
1235
+ ], ChatSessionResolver.prototype, "restoreAISession", null);
1056
1236
  tslib_1.__decorate([
1057
1237
  (0, type_graphql_1.Mutation)(() => Boolean, {
1058
- description: '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.'
1238
+ description: '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.'
1059
1239
  }),
1060
1240
  (0, type_graphql_1.Directive)('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)'),
1061
1241
  tslib_1.__param(0, (0, type_graphql_1.Arg)('sessionId')),