@things-factory/ai-assistant 10.1.214 → 10.1.218
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-dock.ts +6 -3
- package/client/components/assistant-session-manager.ts +263 -12
- package/client/utils/assistant-session-actions.ts +12 -0
- package/client/utils/assistant-session-admin.ts +70 -5
- package/dist-client/components/assistant-dock.js +6 -3
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-session-manager.d.ts +37 -0
- package/dist-client/components/assistant-session-manager.js +281 -13
- package/dist-client/components/assistant-session-manager.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-actions.d.ts +15 -0
- package/dist-client/utils/assistant-session-actions.js +11 -0
- package/dist-client/utils/assistant-session-actions.js.map +1 -1
- package/dist-client/utils/assistant-session-admin.d.ts +32 -1
- package/dist-client/utils/assistant-session-admin.js +36 -5
- package/dist-client/utils/assistant-session-admin.js.map +1 -1
- package/dist-server/service/assistant-chat-resolver.js +1 -1
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +2 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +15 -0
- package/dist-server/service/assistant-mode/mode-categories.js +7 -1
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/chat-session/session-cleanup.d.ts +28 -0
- package/dist-server/service/chat-session/session-cleanup.js +69 -0
- package/dist-server/service/chat-session/session-cleanup.js.map +1 -0
- package/dist-server/service/chat-session/session-inbox.d.ts +15 -0
- package/dist-server/service/chat-session/session-inbox.js +11 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-purge.d.ts +4 -0
- package/dist-server/service/chat-session/session-purge.js +45 -11
- package/dist-server/service/chat-session/session-purge.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +24 -2
- package/dist-server/service/chat-session-resolver.js +193 -13
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/data-read/list-read.d.ts +3 -1
- package/dist-server/service/data-read/list-read.js +27 -2
- package/dist-server/service/data-read/list-read.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +16 -0
- package/dist-server/service/data-read/read-target.js +18 -1
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.js +3 -1
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -7
- package/server/service/assistant-chat-resolver.ts +2 -2
- package/server/service/assistant-mode/conversation-category.test.ts +15 -2
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +17 -1
- package/server/service/assistant-mode/open-subject-door.test.ts +3 -1
- package/server/service/chat-session/session-cleanup.test.ts +83 -0
- package/server/service/chat-session/session-cleanup.ts +69 -0
- package/server/service/chat-session/session-inbox.ts +13 -0
- package/server/service/chat-session/session-purge.ts +46 -10
- package/server/service/chat-session-resolver.ts +150 -14
- package/server/service/data-read/list-read.ts +30 -3
- package/server/service/data-read/list-status.test.ts +46 -0
- package/server/service/data-read/read-target.ts +32 -1
- package/server/service/data-read/read-tools.ts +2 -1
- package/test/assistant-dock.test.ts +12 -1
- package/test/assistant-session-manager.test.ts +53 -1
- package/test/open-doors.test.ts +3 -1
- package/translations/en.json +37 -1
- package/translations/es.json +37 -1
- package/translations/ja.json +37 -1
- package/translations/ko.json +37 -1
- package/translations/ms.json +37 -1
- package/translations/zh.json +37 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mode-categories.js","sourceRoot":"","sources":["../../../server/service/assistant-mode/mode-categories.ts"],"names":[],"mappings":"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","sourcesContent":["/* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */\nimport { entryTargetOf } from '../data-entry/entry-target.js'\nimport { detailReadOf, readTargetOf } from '../data-read/read-target.js'\nimport { PEOPLE_CATEGORIES } from '../people/people-keys.js'\n/**\n * A category another package opens in every conversation - a dashboard card (ADR-0160 decision 9). It is in reach of\n * every conversation; it is put in front only on the pages it names, and found with `openTools` elsewhere (ADR-0061\n * decision 9). Never in an unattended run: its tools are proposals, and nobody is there to press the card.\n */\nexport interface ConversationCategory {\n name: string\n /** The application routes whose conversations put it in front (`my-dashboard`). */\n frontPages?: string[]\n}\n\nconst conversationCategories = new Map<string, ConversationCategory>()\n\nexport function registerConversationCategory(category: ConversationCategory): void {\n conversationCategories.set(category.name, category)\n}\n\nexport function conversationCategoriesOf(): ConversationCategory[] {\n return [...conversationCategories.values()]\n}\n\nexport function clearConversationCategories(): void {\n conversationCategories.clear()\n}\n\n/**\n * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it\n * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).\n */\nexport function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {\n const extra = [\n /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */\n 'assistant-workspace',\n /* Every conversation may read who works here and offer to bring one of them in (people/, ADR-0073 decision 3). */\n ...PEOPLE_CATEGORIES,\n ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),\n ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),\n ...(mode.readTargets ?? []).map(key => `data-read:${key}`),\n ...conversationCategoriesOf().map(one => one.name)\n ]\n return [...new Set([...(mode.toolCategories ?? []), ...extra])]\n}\n\n/**\n * The categories an unattended run of a mode opens: its own and the lists it reads (design/ai/unattended-run.md §3).\n *\n * Not the 입력안 tools - nobody is there to press the card - nor the AI 작업판 or the attachment reader: there is no\n * conversation for a card or a file to belong to.\n */\nexport function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {\n return [...new Set([...(mode.toolCategories ?? []), ...(mode.readTargets ?? []).map(key => `data-read:${key}`)])]\n}\n\n/** The screen a turn is asked from - the page the dock is on, and the record its conversation is anchored to. */\nexport interface TurnScreen {\n /** The application's route of the open page (`cash-transaction-list`), as the dock sends it. */\n page?: string\n /** The axis of the record the conversation is anchored to (`contract` of `subject` · `contract:<id>`), from the stored anchor. */\n subjectAxis?: string\n /** The axes of the records the latest selection named (`contract` of `[keys] contracts contract:<id>`) - `selectionAxesOf`. */\n selectionAxes?: string[]\n}\n\n/** The line of a selection note the model reads - the client's `SELECTION_KEYS_PREFIX` (assistant-selection.ts). */\nexport const SELECTION_KEYS_LINE = '[keys]'\n\n/**\n * The axes of the records the latest selection note names (「AI와 작업」, shared-dock §2-11-1): a list hands its rows as\n * `<axis>:<uuid>`, so the rows' list is read with the tool of that axis. Put in front like an anchored record's - picked\n * on 미수금, the contracts' read was not in front, and the model looked for them among attachments and a dashboard's cards\n * before it opened the right tool (2026-10-25). A note is the server's (`appendSessionNote`, system); a person's words\n * are never read for it.\n */\nexport function selectionAxesOf(rows: { role?: string; content?: string | null }[]): string[] {\n for (let i = rows.length - 1; i >= 0; i--) {\n if (rows[i].role !== 'system') continue\n const line = String(rows[i].content ?? '')\n .split('\\n')\n .find(one => one.startsWith(SELECTION_KEYS_LINE))\n if (!line) continue\n const keys = line.slice(SELECTION_KEYS_LINE.length).trim().split(/\\s+/)\n return [...new Set(keys.map(one => one.match(/^([a-z0-9-]+):/)?.[1]).filter((one): one is string => !!one))]\n }\n return []\n}\n\ntype FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }\n\n/**\n * The records the open screen is about, among those the mode reads and enters (ADR-0061 decision 9). Nothing new is\n * declared: a read target's screen route is the page that lists it, and a key's last part is the axis of its record\n * (`office.contract` is the record of the axis `contract`) - reading and entering one record share its key.\n */\nexport function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {\n const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]\n return keys.filter(\n key =>\n (!!screen.page && routeOf(key) === screen.page) ||\n (!!screen.subjectAxis && axesOfKey(mode, key).includes(screen.subjectAxis)) ||\n (!!screen.selectionAxes?.length && axesOfKey(mode, key).some(axis => screen.selectionAxes!.includes(axis)))\n )\n}\n\n/** The records a key is about: its last part, and the axes a card joins or a whole answer covers (EntryTarget.axes · DetailRead.axes). */\nfunction axesOfKey(mode: FrontMode, key: string): string[] {\n const card = (mode.entryTargets ?? []).includes(key) ? entryTargetOf(key)?.axes ?? [] : []\n const answer = (mode.readTargets ?? []).includes(key) ? detailReadOf(key)?.axes ?? [] : []\n return [key.split('.').pop()!, ...card, ...answer]\n}\n\n/**\n * The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업판, the\n * people list and the invite card, the attachment reader, and the open screen's records. The rest the model opens with `openTools`.\n */\nexport function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): string[] {\n const keys = screenTargetKeys(mode, screen)\n return [\n ...new Set([\n ...(mode.toolCategories ?? []),\n 'assistant-workspace',\n /*\n * Two tools, always in front: 「정연희 초대해 줘」 asked in a conversation whose tools did not fit got the answer that\n * there was no tool to look people up (2026-10-24).\n */\n ...PEOPLE_CATEGORIES,\n ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),\n ...keys.filter(key => (mode.readTargets ?? []).includes(key)).map(key => `data-read:${key}`),\n ...keys.filter(key => (mode.entryTargets ?? []).includes(key)).map(key => `data-entry:${key}`),\n ...conversationCategoriesOf()\n .filter(one => !!screen.page && (one.frontPages ?? []).includes(screen.page))\n .map(one => one.name)\n ])\n ]\n}\n\n/** Each screen the mode could be asked from - a page that lists one of its records, a record's axis - for the boot report. */\nexport function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {\n const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]\n const pages = [...new Set([...keys.map(routeOf), ...conversationCategoriesOf().flatMap(one => one.frontPages ?? [])].filter(Boolean) as string[])]\n const axes = [...new Set(keys.flatMap(key => axesOfKey(mode, key)))]\n return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]\n}\n"]}
|
|
1
|
+
{"version":3,"file":"mode-categories.js","sourceRoot":"","sources":["../../../server/service/assistant-mode/mode-categories.ts"],"names":[],"mappings":"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","sourcesContent":["/* Read by the resolver and by the boot report, so the report and the conversation open the same tools. */\nimport { entryTargetOf } from '../data-entry/entry-target.js'\nimport { detailReadOf, readTargetOf } from '../data-read/read-target.js'\nimport { PEOPLE_CATEGORIES } from '../people/people-keys.js'\n/**\n * A category another package opens in every conversation - a dashboard card (ADR-0160 decision 9). It is in reach of\n * every conversation; it is put in front only on the pages it names, and found with `openTools` elsewhere (ADR-0061\n * decision 9). Never in an unattended run: its tools are proposals, and nobody is there to press the card.\n */\nexport interface ConversationCategory {\n name: string\n /** The application routes whose conversations put it in front (`my-dashboard`). */\n frontPages?: string[]\n /**\n * The axes of the records whose selection or anchor puts it in front (`dashboard-card`) - a card picked to change is\n * changed by this category's tools, wherever the dock is open.\n */\n frontAxes?: string[]\n /**\n * What a turn asked from one of its front pages must end in - said in that turn's system prompt only. On 나의 대시보드\n * a request to see a figure is a card; asked 「이번 달 지출 합계」 there, the model answered 「0원」 in words (2026-10-25).\n */\n pageRule?: string\n}\n\nconst conversationCategories = new Map<string, ConversationCategory>()\n\nexport function registerConversationCategory(category: ConversationCategory): void {\n conversationCategories.set(category.name, category)\n}\n\nexport function conversationCategoriesOf(): ConversationCategory[] {\n return [...conversationCategories.values()]\n}\n\nexport function clearConversationCategories(): void {\n conversationCategories.clear()\n}\n\n/** The system prompt of one turn - the mode's, the open record's line, and the rules of the page it is asked from. */\nexport function turnSystemPrompt(modePrompt: string, screen: { page?: string; subjectLine?: string } = {}): string {\n const pageRules = screen.page ? conversationCategoriesOf().filter(one => one.pageRule && (one.frontPages ?? []).includes(screen.page!)).map(one => one.pageRule!) : []\n return [modePrompt, screen.subjectLine, ...pageRules].filter(Boolean).join('\\n\\n')\n}\n\n/**\n * The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it\n * takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).\n */\nexport function modeToolCategories(mode: { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }): string[] | undefined {\n const extra = [\n /* Every conversation may put a large result on the AI 작업판 (design/ai/workspace.md). */\n 'assistant-workspace',\n /* Every conversation may read who works here and offer to bring one of them in (people/, ADR-0073 decision 3). */\n ...PEOPLE_CATEGORIES,\n ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),\n ...(mode.entryTargets ?? []).map(key => `data-entry:${key}`),\n ...(mode.readTargets ?? []).map(key => `data-read:${key}`),\n ...conversationCategoriesOf().map(one => one.name)\n ]\n return [...new Set([...(mode.toolCategories ?? []), ...extra])]\n}\n\n/**\n * The categories an unattended run of a mode opens: its own and the lists it reads (design/ai/unattended-run.md §3).\n *\n * Not the 입력안 tools - nobody is there to press the card - nor the AI 작업판 or the attachment reader: there is no\n * conversation for a card or a file to belong to.\n */\nexport function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {\n return [...new Set([...(mode.toolCategories ?? []), ...(mode.readTargets ?? []).map(key => `data-read:${key}`)])]\n}\n\n/** The screen a turn is asked from - the page the dock is on, and the record its conversation is anchored to. */\nexport interface TurnScreen {\n /** The application's route of the open page (`cash-transaction-list`), as the dock sends it. */\n page?: string\n /** The axis of the record the conversation is anchored to (`contract` of `subject` · `contract:<id>`), from the stored anchor. */\n subjectAxis?: string\n /** The axes of the records the latest selection named (`contract` of `[keys] contracts contract:<id>`) - `selectionAxesOf`. */\n selectionAxes?: string[]\n}\n\n/** The line of a selection note the model reads - the client's `SELECTION_KEYS_PREFIX` (assistant-selection.ts). */\nexport const SELECTION_KEYS_LINE = '[keys]'\n\n/**\n * The axes of the records the latest selection note names (「AI와 작업」, shared-dock §2-11-1): a list hands its rows as\n * `<axis>:<uuid>`, so the rows' list is read with the tool of that axis. Put in front like an anchored record's - picked\n * on 미수금, the contracts' read was not in front, and the model looked for them among attachments and a dashboard's cards\n * before it opened the right tool (2026-10-25). A note is the server's (`appendSessionNote`, system); a person's words\n * are never read for it.\n */\nexport function selectionAxesOf(rows: { role?: string; content?: string | null }[]): string[] {\n for (let i = rows.length - 1; i >= 0; i--) {\n if (rows[i].role !== 'system') continue\n const line = String(rows[i].content ?? '')\n .split('\\n')\n .find(one => one.startsWith(SELECTION_KEYS_LINE))\n if (!line) continue\n const keys = line.slice(SELECTION_KEYS_LINE.length).trim().split(/\\s+/)\n return [...new Set(keys.map(one => one.match(/^([a-z0-9-]+):/)?.[1]).filter((one): one is string => !!one))]\n }\n return []\n}\n\ntype FrontMode = { toolCategories?: string[]; attachments?: { kinds?: string[] }; entryTargets?: string[]; readTargets?: string[] }\n\n/**\n * The records the open screen is about, among those the mode reads and enters (ADR-0061 decision 9). Nothing new is\n * declared: a read target's screen route is the page that lists it, and a key's last part is the axis of its record\n * (`office.contract` is the record of the axis `contract`) - reading and entering one record share its key.\n */\nexport function screenTargetKeys(mode: FrontMode, screen: TurnScreen, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): string[] {\n const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]\n return keys.filter(\n key =>\n (!!screen.page && routeOf(key) === screen.page) ||\n (!!screen.subjectAxis && axesOfKey(mode, key).includes(screen.subjectAxis)) ||\n (!!screen.selectionAxes?.length && axesOfKey(mode, key).some(axis => screen.selectionAxes!.includes(axis)))\n )\n}\n\n/** The records a key is about: its last part, and the axes a card joins or a whole answer covers (EntryTarget.axes · DetailRead.axes). */\nfunction axesOfKey(mode: FrontMode, key: string): string[] {\n const card = (mode.entryTargets ?? []).includes(key) ? entryTargetOf(key)?.axes ?? [] : []\n const answer = (mode.readTargets ?? []).includes(key) ? detailReadOf(key)?.axes ?? [] : []\n return [key.split('.').pop()!, ...card, ...answer]\n}\n\n/**\n * The categories a turn puts in front when the mode's tools do not fit in one call: the mode's own, the AI 작업판, the\n * people list and the invite card, the attachment reader, and the open screen's records. The rest the model opens with `openTools`.\n */\nexport function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): string[] {\n const keys = screenTargetKeys(mode, screen)\n return [\n ...new Set([\n ...(mode.toolCategories ?? []),\n 'assistant-workspace',\n /*\n * Two tools, always in front: 「정연희 초대해 줘」 asked in a conversation whose tools did not fit got the answer that\n * there was no tool to look people up (2026-10-24).\n */\n ...PEOPLE_CATEGORIES,\n ...(mode.attachments?.kinds?.length ? ['chat-attachments'] : []),\n ...keys.filter(key => (mode.readTargets ?? []).includes(key)).map(key => `data-read:${key}`),\n ...keys.filter(key => (mode.entryTargets ?? []).includes(key)).map(key => `data-entry:${key}`),\n ...conversationCategoriesOf()\n .filter(one => (!!screen.page && (one.frontPages ?? []).includes(screen.page)) || (one.frontAxes ?? []).some(axis => axis === screen.subjectAxis || !!screen.selectionAxes?.includes(axis)))\n .map(one => one.name)\n ])\n ]\n}\n\n/** Each screen the mode could be asked from - a page that lists one of its records, a record's axis - for the boot report. */\nexport function modeScreens(mode: FrontMode, routeOf: (key: string) => string | undefined = key => readTargetOf(key)?.screen?.route): TurnScreen[] {\n const keys = [...new Set([...(mode.readTargets ?? []), ...(mode.entryTargets ?? [])])]\n const pages = [...new Set([...keys.map(routeOf), ...conversationCategoriesOf().flatMap(one => one.frontPages ?? [])].filter(Boolean) as string[])]\n const axes = [...new Set(keys.flatMap(key => axesOfKey(mode, key)))]\n return [{}, ...pages.map(page => ({ page })), ...axes.map(subjectAxis => ({ subjectAxis }))]\n}\n"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const DEFAULT_ARCHIVE_DAYS = 60;
|
|
2
|
+
/** A conversation of mine is suggested for clearing after this many days without activity. */
|
|
3
|
+
export declare const INACTIVE_DAYS = 30;
|
|
4
|
+
/** An empty conversation nobody touched for this long is removed. */
|
|
5
|
+
export declare const EMPTY_DAYS = 1;
|
|
6
|
+
export type CleanupReason = 'empty' | 'inactive';
|
|
7
|
+
interface Activity {
|
|
8
|
+
lastMessageAt?: Date | string | null;
|
|
9
|
+
createdAt?: Date | string | null;
|
|
10
|
+
}
|
|
11
|
+
/** The archive period the config asks for: a positive number of days, `null` when archiving is off, the default otherwise. */
|
|
12
|
+
export declare function archiveDaysOf(value: unknown): number | null;
|
|
13
|
+
/** Whole days since the conversation last moved - its last message, or its start when it has none. */
|
|
14
|
+
export declare function idleDays(session: Activity, now: Date): number;
|
|
15
|
+
export declare function isArchived(session: Activity, now: Date, archiveDays: number | null): boolean;
|
|
16
|
+
/** Why the list suggests clearing this conversation, or `null`. Only one the caller started - only the creator deletes. */
|
|
17
|
+
export declare function cleanupReason(session: Activity & {
|
|
18
|
+
mine?: boolean;
|
|
19
|
+
}, now: Date): CleanupReason | null;
|
|
20
|
+
/**
|
|
21
|
+
* Whether the daily job removes an empty conversation: no message, started more than EMPTY_DAYS ago, and no seat in it
|
|
22
|
+
* touched since (opening a conversation marks its reader's seat). A dock holding it open the same day keeps it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function emptyAndAbandoned(session: Activity & {
|
|
25
|
+
messages: number;
|
|
26
|
+
lastTouchedAt?: Date | string | null;
|
|
27
|
+
}, now: Date): boolean;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EMPTY_DAYS = exports.INACTIVE_DAYS = exports.DEFAULT_ARCHIVE_DAYS = void 0;
|
|
4
|
+
exports.archiveDaysOf = archiveDaysOf;
|
|
5
|
+
exports.idleDays = idleDays;
|
|
6
|
+
exports.isArchived = isArchived;
|
|
7
|
+
exports.cleanupReason = cleanupReason;
|
|
8
|
+
exports.emptyAndAbandoned = emptyAndAbandoned;
|
|
9
|
+
/*
|
|
10
|
+
* Keeping the conversation list short - pure, tested alone (design/ai/shared-dock.md §2-7).
|
|
11
|
+
*
|
|
12
|
+
* 2026-10-25, the user: 「AI 세션관리를 좀 더 편리하게」 - many at once, suggestions of what to clear, a period after the
|
|
13
|
+
* last update. Three rules come out of it:
|
|
14
|
+
*
|
|
15
|
+
* archived No activity for `sessionArchiveDays` (default 60): the list leaves it out until asked
|
|
16
|
+
* (「보관 포함」) or searched. Nothing is written - a new message brings it back by itself.
|
|
17
|
+
* Archiving deletes nothing: a conversation holds the cards it offered and what came of them.
|
|
18
|
+
* candidate What the list suggests clearing, only among the caller's own (only the creator deletes):
|
|
19
|
+
* `empty` - no message ever; `inactive` - no activity for INACTIVE_DAYS.
|
|
20
|
+
* empty A conversation that never got a message and that nobody opened for EMPTY_DAYS is removed by the
|
|
21
|
+
* daily job - there is nothing in it to keep.
|
|
22
|
+
*/
|
|
23
|
+
const session_retention_js_1 = require("./session-retention.js");
|
|
24
|
+
exports.DEFAULT_ARCHIVE_DAYS = 60;
|
|
25
|
+
/** A conversation of mine is suggested for clearing after this many days without activity. */
|
|
26
|
+
exports.INACTIVE_DAYS = 30;
|
|
27
|
+
/** An empty conversation nobody touched for this long is removed. */
|
|
28
|
+
exports.EMPTY_DAYS = 1;
|
|
29
|
+
function time(value) {
|
|
30
|
+
if (!value)
|
|
31
|
+
return 0;
|
|
32
|
+
const at = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
33
|
+
return Number.isFinite(at) ? at : 0;
|
|
34
|
+
}
|
|
35
|
+
/** The archive period the config asks for: a positive number of days, `null` when archiving is off, the default otherwise. */
|
|
36
|
+
function archiveDaysOf(value) {
|
|
37
|
+
if (value === false || value === 0 || value === '0')
|
|
38
|
+
return null;
|
|
39
|
+
const days = Number(value);
|
|
40
|
+
return Number.isFinite(days) && days > 0 ? days : exports.DEFAULT_ARCHIVE_DAYS;
|
|
41
|
+
}
|
|
42
|
+
/** Whole days since the conversation last moved - its last message, or its start when it has none. */
|
|
43
|
+
function idleDays(session, now) {
|
|
44
|
+
const last = time(session.lastMessageAt) || time(session.createdAt);
|
|
45
|
+
return last ? Math.floor((now.getTime() - last) / session_retention_js_1.DAY_MS) : 0;
|
|
46
|
+
}
|
|
47
|
+
function isArchived(session, now, archiveDays) {
|
|
48
|
+
return archiveDays !== null && idleDays(session, now) >= archiveDays;
|
|
49
|
+
}
|
|
50
|
+
/** Why the list suggests clearing this conversation, or `null`. Only one the caller started - only the creator deletes. */
|
|
51
|
+
function cleanupReason(session, now) {
|
|
52
|
+
if (!session.mine)
|
|
53
|
+
return null;
|
|
54
|
+
if (!time(session.lastMessageAt))
|
|
55
|
+
return 'empty';
|
|
56
|
+
return idleDays(session, now) >= exports.INACTIVE_DAYS ? 'inactive' : null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether the daily job removes an empty conversation: no message, started more than EMPTY_DAYS ago, and no seat in it
|
|
60
|
+
* touched since (opening a conversation marks its reader's seat). A dock holding it open the same day keeps it.
|
|
61
|
+
*/
|
|
62
|
+
function emptyAndAbandoned(session, now) {
|
|
63
|
+
if (session.messages > 0 || time(session.lastMessageAt))
|
|
64
|
+
return false;
|
|
65
|
+
const cutoff = now.getTime() - exports.EMPTY_DAYS * session_retention_js_1.DAY_MS;
|
|
66
|
+
const started = time(session.createdAt);
|
|
67
|
+
return !!started && started < cutoff && time(session.lastTouchedAt) < cutoff;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=session-cleanup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-cleanup.js","sourceRoot":"","sources":["../../../server/service/chat-session/session-cleanup.ts"],"names":[],"mappings":";;;AAoCA,sCAIC;AAGD,4BAGC;AAED,gCAEC;AAGD,sCAIC;AAMD,8CAKC;AApED;;;;;;;;;;;;;GAaG;AACH,iEAA+C;AAElC,QAAA,oBAAoB,GAAG,EAAE,CAAA;AACtC,8FAA8F;AACjF,QAAA,aAAa,GAAG,EAAE,CAAA;AAC/B,qEAAqE;AACxD,QAAA,UAAU,GAAG,CAAC,CAAA;AAS3B,SAAS,IAAI,CAAC,KAAuC;IACnD,IAAI,CAAC,KAAK;QAAE,OAAO,CAAC,CAAA;IACpB,MAAM,EAAE,GAAG,KAAK,YAAY,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IACtE,OAAO,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;AACrC,CAAC;AAED,8HAA8H;AAC9H,SAAgB,aAAa,CAAC,KAAc;IAC1C,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,GAAG;QAAE,OAAO,IAAI,CAAA;IAChE,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC1B,OAAO,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,4BAAoB,CAAA;AACxE,CAAC;AAED,sGAAsG;AACtG,SAAgB,QAAQ,CAAC,OAAiB,EAAE,GAAS;IACnD,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IACnE,OAAO,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,6BAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,UAAU,CAAC,OAAiB,EAAE,GAAS,EAAE,WAA0B;IACjF,OAAO,WAAW,KAAK,IAAI,IAAI,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,WAAW,CAAA;AACtE,CAAC;AAED,2HAA2H;AAC3H,SAAgB,aAAa,CAAC,OAAsC,EAAE,GAAS;IAC7E,IAAI,CAAC,OAAO,CAAC,IAAI;QAAE,OAAO,IAAI,CAAA;IAC9B,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC;QAAE,OAAO,OAAO,CAAA;IAChD,OAAO,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,qBAAa,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAA;AACpE,CAAC;AAED;;;GAGG;AACH,SAAgB,iBAAiB,CAAC,OAA8E,EAAE,GAAS;IACzH,IAAI,OAAO,CAAC,QAAQ,GAAG,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC;QAAE,OAAO,KAAK,CAAA;IACrE,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,EAAE,GAAG,kBAAU,GAAG,6BAAM,CAAA;IAClD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IACvC,OAAO,CAAC,CAAC,OAAO,IAAI,OAAO,GAAG,MAAM,IAAI,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,GAAG,MAAM,CAAA;AAC9E,CAAC","sourcesContent":["/*\n * Keeping the conversation list short - pure, tested alone (design/ai/shared-dock.md §2-7).\n *\n * 2026-10-25, the user: 「AI 세션관리를 좀 더 편리하게」 - many at once, suggestions of what to clear, a period after the\n * last update. Three rules come out of it:\n *\n * archived No activity for `sessionArchiveDays` (default 60): the list leaves it out until asked\n * (「보관 포함」) or searched. Nothing is written - a new message brings it back by itself.\n * Archiving deletes nothing: a conversation holds the cards it offered and what came of them.\n * candidate What the list suggests clearing, only among the caller's own (only the creator deletes):\n * `empty` - no message ever; `inactive` - no activity for INACTIVE_DAYS.\n * empty A conversation that never got a message and that nobody opened for EMPTY_DAYS is removed by the\n * daily job - there is nothing in it to keep.\n */\nimport { DAY_MS } from './session-retention.js'\n\nexport const DEFAULT_ARCHIVE_DAYS = 60\n/** A conversation of mine is suggested for clearing after this many days without activity. */\nexport const INACTIVE_DAYS = 30\n/** An empty conversation nobody touched for this long is removed. */\nexport const EMPTY_DAYS = 1\n\nexport type CleanupReason = 'empty' | 'inactive'\n\ninterface Activity {\n lastMessageAt?: Date | string | null\n createdAt?: Date | string | null\n}\n\nfunction time(value: Date | string | null | undefined): number {\n if (!value) return 0\n const at = value instanceof Date ? value.getTime() : Date.parse(value)\n return Number.isFinite(at) ? at : 0\n}\n\n/** The archive period the config asks for: a positive number of days, `null` when archiving is off, the default otherwise. */\nexport function archiveDaysOf(value: unknown): number | null {\n if (value === false || value === 0 || value === '0') return null\n const days = Number(value)\n return Number.isFinite(days) && days > 0 ? days : DEFAULT_ARCHIVE_DAYS\n}\n\n/** Whole days since the conversation last moved - its last message, or its start when it has none. */\nexport function idleDays(session: Activity, now: Date): number {\n const last = time(session.lastMessageAt) || time(session.createdAt)\n return last ? Math.floor((now.getTime() - last) / DAY_MS) : 0\n}\n\nexport function isArchived(session: Activity, now: Date, archiveDays: number | null): boolean {\n return archiveDays !== null && idleDays(session, now) >= archiveDays\n}\n\n/** Why the list suggests clearing this conversation, or `null`. Only one the caller started - only the creator deletes. */\nexport function cleanupReason(session: Activity & { mine?: boolean }, now: Date): CleanupReason | null {\n if (!session.mine) return null\n if (!time(session.lastMessageAt)) return 'empty'\n return idleDays(session, now) >= INACTIVE_DAYS ? 'inactive' : null\n}\n\n/**\n * Whether the daily job removes an empty conversation: no message, started more than EMPTY_DAYS ago, and no seat in it\n * touched since (opening a conversation marks its reader's seat). A dock holding it open the same day keeps it.\n */\nexport function emptyAndAbandoned(session: Activity & { messages: number; lastTouchedAt?: Date | string | null }, now: Date): boolean {\n if (session.messages > 0 || time(session.lastMessageAt)) return false\n const cutoff = now.getTime() - EMPTY_DAYS * DAY_MS\n const started = time(session.createdAt)\n return !!started && started < cutoff && time(session.lastTouchedAt) < cutoff\n}\n"]}
|
|
@@ -88,6 +88,21 @@ export interface InboxFilter {
|
|
|
88
88
|
export declare function filterInbox<T extends InboxEntry & {
|
|
89
89
|
mine?: boolean;
|
|
90
90
|
}>(entries: T[], filter?: InboxFilter): T[];
|
|
91
|
+
/**
|
|
92
|
+
* Which entries the list stands on before its filters (shared-dock.md §2-7): the suggestions to clear are every
|
|
93
|
+
* candidate, hidden or archived; otherwise a hidden one waits for its filter, and an archived one for its filter or a
|
|
94
|
+
* search - finding an old conversation by a word is how it is mostly looked for.
|
|
95
|
+
*/
|
|
96
|
+
export declare function listedEntries<T extends {
|
|
97
|
+
hidden?: boolean;
|
|
98
|
+
archived?: boolean;
|
|
99
|
+
cleanupReason?: string | null;
|
|
100
|
+
}>(entries: T[], opts?: {
|
|
101
|
+
showHidden?: boolean | null;
|
|
102
|
+
includeArchived?: boolean | null;
|
|
103
|
+
cleanupOnly?: boolean | null;
|
|
104
|
+
search?: string | null;
|
|
105
|
+
}): T[];
|
|
91
106
|
export declare function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[];
|
|
92
107
|
/** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
|
|
93
108
|
export declare function searchReadableMessages(rows: readonly {
|
|
@@ -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 (
|
|
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((
|
|
67
|
-
|
|
68
|
-
|
|
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
|
*
|