@things-factory/ai-assistant 10.1.214 → 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.
- 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 +6 -6
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.215",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"@operato/shell": "^10.29.39",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
37
|
"@operato/utils": "^10.29.39",
|
|
38
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
38
|
+
"@things-factory/ai-client-base": "^10.1.215",
|
|
39
39
|
"@things-factory/apptool-base": "^10.1.208",
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.215",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.215",
|
|
42
42
|
"@things-factory/env": "^10.1.149",
|
|
43
|
-
"@things-factory/setting-base": "^10.1.
|
|
43
|
+
"@things-factory/setting-base": "^10.1.215",
|
|
44
44
|
"@things-factory/shell": "^10.1.208",
|
|
45
45
|
"dompurify": "^3.0.0",
|
|
46
46
|
"exceljs": "4.2.0",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"copyfiles": "^2.4.1",
|
|
56
56
|
"rimraf": "^5.0.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "7de5e80eb1f4e2e268746aee39f820e7563f454d"
|
|
59
59
|
}
|
|
@@ -54,7 +54,7 @@ import { attachmentForModel, describeAttachment, findChatAttachment } from './ch
|
|
|
54
54
|
import { readTurnAttachments } from './chat-session/turn-attachments.js'
|
|
55
55
|
import { getAssistantMode } from './assistant-mode/registry.js'
|
|
56
56
|
import { resolveModeSettings } from './assistant-mode/resolve.js'
|
|
57
|
-
import { modeFrontCategories, selectionAxesOf } from './assistant-mode/mode-categories.js'
|
|
57
|
+
import { modeFrontCategories, selectionAxesOf, turnSystemPrompt } from './assistant-mode/mode-categories.js'
|
|
58
58
|
import { usedToolNames } from './chat-message/used-tools.js'
|
|
59
59
|
import { splitNextRequest } from './assistant-mode/screen-rules.js'
|
|
60
60
|
import { requestTranslator } from './request-translator.js'
|
|
@@ -321,7 +321,7 @@ export class AssistantChatResolver {
|
|
|
321
321
|
state: context.state
|
|
322
322
|
})
|
|
323
323
|
: undefined
|
|
324
|
-
const systemPrompt =
|
|
324
|
+
const systemPrompt = turnSystemPrompt(settings.systemPrompt, { subjectLine, page: input.page })
|
|
325
325
|
|
|
326
326
|
const lastUserMessage = input.messages[input.messages.length - 1]
|
|
327
327
|
let userMessageId: string | undefined
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* A category another package opens in every conversation (ADR-0160 decision 9) - in reach everywhere, in front only on
|
|
3
3
|
* the pages it names, never in an unattended run.
|
|
4
4
|
*/
|
|
5
|
-
import { clearConversationCategories, modeFrontCategories, modeScreens, modeToolCategories, registerConversationCategory, unattendedToolCategories } from './mode-categories'
|
|
5
|
+
import { clearConversationCategories, modeFrontCategories, modeScreens, modeToolCategories, registerConversationCategory, turnSystemPrompt, unattendedToolCategories } from './mode-categories'
|
|
6
6
|
|
|
7
7
|
const mode = { toolCategories: ['office-ops'], readTargets: ['office.receivables'] }
|
|
8
8
|
|
|
9
9
|
beforeEach(() => {
|
|
10
10
|
clearConversationCategories()
|
|
11
|
-
registerConversationCategory({ name: 'dashboard-card', frontPages: ['my-dashboard'] })
|
|
11
|
+
registerConversationCategory({ name: 'dashboard-card', frontPages: ['my-dashboard'], frontAxes: ['dashboard-card'] })
|
|
12
12
|
})
|
|
13
13
|
|
|
14
14
|
afterAll(() => clearConversationCategories())
|
|
@@ -25,6 +25,19 @@ describe('a category every conversation opens', () => {
|
|
|
25
25
|
expect(modeFrontCategories(mode, {})).not.toContain('dashboard-card')
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
it('★ a record of its axis picked or anchored puts it in front, on any page - a picked card is changed by its tools', () => {
|
|
29
|
+
expect(modeFrontCategories(mode, { page: 'receivable-page', selectionAxes: ['dashboard-card'] })).toContain('dashboard-card')
|
|
30
|
+
expect(modeFrontCategories(mode, { subjectAxis: 'dashboard-card' })).toContain('dashboard-card')
|
|
31
|
+
expect(modeFrontCategories(mode, { page: 'receivable-page', selectionAxes: ['contract'] })).not.toContain('dashboard-card')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('★ its page rule is said in the turns asked from its pages, and only there', () => {
|
|
35
|
+
registerConversationCategory({ name: 'dashboard-card', frontPages: ['my-dashboard'], pageRule: 'end in a card.' })
|
|
36
|
+
expect(turnSystemPrompt('persona', { page: 'my-dashboard', subjectLine: 'open: x' })).toBe('persona\n\nopen: x\n\nend in a card.')
|
|
37
|
+
expect(turnSystemPrompt('persona', { page: 'receivable-page' })).toBe('persona')
|
|
38
|
+
expect(turnSystemPrompt('persona', {})).toBe('persona')
|
|
39
|
+
})
|
|
40
|
+
|
|
28
41
|
it('an unattended run never opens it - nobody is there to press the card', () => {
|
|
29
42
|
expect(unattendedToolCategories(mode)).not.toContain('dashboard-card')
|
|
30
43
|
})
|
|
@@ -13,7 +13,7 @@ export {
|
|
|
13
13
|
} from './registry.js'
|
|
14
14
|
export { resolveModeSettings, assistantModeView } from './resolve.js'
|
|
15
15
|
/* A category another package opens in every conversation, in front on the pages it names (ADR-0160 decision 9). */
|
|
16
|
-
export { registerConversationCategory, conversationCategoriesOf, type ConversationCategory } from './mode-categories.js'
|
|
16
|
+
export { registerConversationCategory, conversationCategoriesOf, turnSystemPrompt, type ConversationCategory } from './mode-categories.js'
|
|
17
17
|
export { ABSENCE_RULE, FINISH_RULE, NEXT_REQUEST_RULE, SELECTION_RULE, SPEAKER_RULE, conversationPrompt, isPersonRequest, splitNextRequest } from './screen-rules.js'
|
|
18
18
|
export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
|
|
19
19
|
export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
|
|
@@ -11,6 +11,16 @@ export interface ConversationCategory {
|
|
|
11
11
|
name: string
|
|
12
12
|
/** The application routes whose conversations put it in front (`my-dashboard`). */
|
|
13
13
|
frontPages?: string[]
|
|
14
|
+
/**
|
|
15
|
+
* The axes of the records whose selection or anchor puts it in front (`dashboard-card`) - a card picked to change is
|
|
16
|
+
* changed by this category's tools, wherever the dock is open.
|
|
17
|
+
*/
|
|
18
|
+
frontAxes?: string[]
|
|
19
|
+
/**
|
|
20
|
+
* What a turn asked from one of its front pages must end in - said in that turn's system prompt only. On 나의 대시보드
|
|
21
|
+
* a request to see a figure is a card; asked 「이번 달 지출 합계」 there, the model answered 「0원」 in words (2026-10-25).
|
|
22
|
+
*/
|
|
23
|
+
pageRule?: string
|
|
14
24
|
}
|
|
15
25
|
|
|
16
26
|
const conversationCategories = new Map<string, ConversationCategory>()
|
|
@@ -27,6 +37,12 @@ export function clearConversationCategories(): void {
|
|
|
27
37
|
conversationCategories.clear()
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
/** The system prompt of one turn - the mode's, the open record's line, and the rules of the page it is asked from. */
|
|
41
|
+
export function turnSystemPrompt(modePrompt: string, screen: { page?: string; subjectLine?: string } = {}): string {
|
|
42
|
+
const pageRules = screen.page ? conversationCategoriesOf().filter(one => one.pageRule && (one.frontPages ?? []).includes(screen.page!)).map(one => one.pageRule!) : []
|
|
43
|
+
return [modePrompt, screen.subjectLine, ...pageRules].filter(Boolean).join('\n\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
/**
|
|
31
47
|
* The categories a mode opens: its own, the AI 작업판 (design/ai/workspace.md), the attachment reader when it
|
|
32
48
|
* takes files (design/ai/shared-dock.md §9-4), and one propose tool per record it may enter (design/ai/data-entry.md).
|
|
@@ -131,7 +147,7 @@ export function modeFrontCategories(mode: FrontMode, screen: TurnScreen = {}): s
|
|
|
131
147
|
...keys.filter(key => (mode.readTargets ?? []).includes(key)).map(key => `data-read:${key}`),
|
|
132
148
|
...keys.filter(key => (mode.entryTargets ?? []).includes(key)).map(key => `data-entry:${key}`),
|
|
133
149
|
...conversationCategoriesOf()
|
|
134
|
-
.filter(one => !!screen.page && (one.frontPages ?? []).includes(screen.page))
|
|
150
|
+
.filter(one => (!!screen.page && (one.frontPages ?? []).includes(screen.page)) || (one.frontAxes ?? []).some(axis => axis === screen.subjectAxis || !!screen.selectionAxes?.includes(axis)))
|
|
135
151
|
.map(one => one.name)
|
|
136
152
|
])
|
|
137
153
|
]
|
|
@@ -8,10 +8,12 @@ const door = readFileSync(join(__dirname, '../assistant-chat-resolver.ts'), 'utf
|
|
|
8
8
|
|
|
9
9
|
describe('the door', () => {
|
|
10
10
|
it('★ builds the line from the stored session anchor every turn, never from what the screen sent', () => {
|
|
11
|
-
const block = door.slice(door.indexOf('const subjectLine = session'), door.indexOf('const systemPrompt =
|
|
11
|
+
const block = door.slice(door.indexOf('const subjectLine = session'), door.indexOf('const systemPrompt = turnSystemPrompt('))
|
|
12
12
|
expect(block).toMatch(/anchorType: \(session as any\)\.anchorType/)
|
|
13
13
|
expect(block).toMatch(/anchorId: \(session as any\)\.anchorId/)
|
|
14
14
|
expect(block).not.toMatch(/hostContext/)
|
|
15
|
+
/* The page adds its category's rule, never a line of the screen's. */
|
|
16
|
+
expect(door).toMatch(/const systemPrompt = turnSystemPrompt\(settings\.systemPrompt, \{ subjectLine, page: input\.page \}\)/)
|
|
15
17
|
})
|
|
16
18
|
|
|
17
19
|
it('★ the model and the grounding check both read the prompt with the line', () => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* Keeping the conversation list short - design/ai/shared-dock.md §2-7. */
|
|
2
|
+
|
|
3
|
+
import { listedEntries } from './session-inbox'
|
|
4
|
+
import { archiveDaysOf, cleanupReason, DEFAULT_ARCHIVE_DAYS, emptyAndAbandoned, idleDays, isArchived } from './session-cleanup'
|
|
5
|
+
|
|
6
|
+
const now = new Date('2026-10-25T12:00:00Z')
|
|
7
|
+
const daysAgo = (days: number) => new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
|
8
|
+
|
|
9
|
+
describe('archive', () => {
|
|
10
|
+
it('defaults to 60 days and reads a positive number; 0 or false turns it off', () => {
|
|
11
|
+
expect(DEFAULT_ARCHIVE_DAYS).toBe(60)
|
|
12
|
+
expect(archiveDaysOf(undefined)).toBe(60)
|
|
13
|
+
expect(archiveDaysOf('90')).toBe(90)
|
|
14
|
+
expect(archiveDaysOf(0)).toBeNull()
|
|
15
|
+
expect(archiveDaysOf(false)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('counts idle days from the last message, or from the start when there is none', () => {
|
|
19
|
+
expect(idleDays({ lastMessageAt: daysAgo(3), createdAt: daysAgo(100) }, now)).toBe(3)
|
|
20
|
+
expect(idleDays({ lastMessageAt: null, createdAt: daysAgo(7) }, now)).toBe(7)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('archives after the period without activity, and a new message brings it back', () => {
|
|
24
|
+
expect(isArchived({ lastMessageAt: daysAgo(60) }, now, 60)).toBe(true)
|
|
25
|
+
expect(isArchived({ lastMessageAt: daysAgo(59) }, now, 60)).toBe(false)
|
|
26
|
+
expect(isArchived({ lastMessageAt: daysAgo(400) }, now, null)).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('what the list suggests clearing', () => {
|
|
31
|
+
it('suggests only my own conversations', () => {
|
|
32
|
+
expect(cleanupReason({ mine: false, lastMessageAt: null, createdAt: daysAgo(5) }, now)).toBeNull()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('an empty one, and one idle for 30 days', () => {
|
|
36
|
+
expect(cleanupReason({ mine: true, lastMessageAt: null, createdAt: daysAgo(0) }, now)).toBe('empty')
|
|
37
|
+
expect(cleanupReason({ mine: true, lastMessageAt: daysAgo(30) }, now)).toBe('inactive')
|
|
38
|
+
expect(cleanupReason({ mine: true, lastMessageAt: daysAgo(29) }, now)).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('the empty conversations the daily job removes', () => {
|
|
43
|
+
const empty = { lastMessageAt: null, messages: 0, createdAt: daysAgo(2), lastTouchedAt: daysAgo(2) }
|
|
44
|
+
|
|
45
|
+
it('removes one never asked anything and not opened for a day', () => {
|
|
46
|
+
expect(emptyAndAbandoned(empty, now)).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('keeps one with any message, even when the activity column was never written', () => {
|
|
50
|
+
expect(emptyAndAbandoned({ ...empty, messages: 1 }, now)).toBe(false)
|
|
51
|
+
expect(emptyAndAbandoned({ ...empty, lastMessageAt: daysAgo(2) }, now)).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps one started today, or opened today', () => {
|
|
55
|
+
expect(emptyAndAbandoned({ ...empty, createdAt: daysAgo(0) }, now)).toBe(false)
|
|
56
|
+
expect(emptyAndAbandoned({ ...empty, lastTouchedAt: daysAgo(0) }, now)).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('what the list stands on', () => {
|
|
61
|
+
const rows = [
|
|
62
|
+
{ id: 'live' },
|
|
63
|
+
{ id: 'old', archived: true },
|
|
64
|
+
{ id: 'hid', hidden: true },
|
|
65
|
+
{ id: 'empty', cleanupReason: 'empty', hidden: true },
|
|
66
|
+
{ id: 'idle', cleanupReason: 'inactive', archived: true }
|
|
67
|
+
]
|
|
68
|
+
const ids = (opts = {}) => listedEntries(rows, opts).map(row => row.id)
|
|
69
|
+
|
|
70
|
+
it('leaves the archived and the hidden out by default', () => {
|
|
71
|
+
expect(ids()).toEqual(['live'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('shows the archived when asked, or when searching', () => {
|
|
75
|
+
expect(ids({ includeArchived: true })).toEqual(['live', 'old', 'idle'])
|
|
76
|
+
expect(ids({ search: 'x' })).toEqual(['live', 'old', 'idle'])
|
|
77
|
+
expect(ids({ search: ' ' })).toEqual(['live'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('the suggestions are every candidate, hidden or archived', () => {
|
|
81
|
+
expect(ids({ cleanupOnly: true })).toEqual(['empty', 'idle'])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Keeping the conversation list short - pure, tested alone (design/ai/shared-dock.md §2-7).
|
|
3
|
+
*
|
|
4
|
+
* 2026-10-25, the user: 「AI 세션관리를 좀 더 편리하게」 - many at once, suggestions of what to clear, a period after the
|
|
5
|
+
* last update. Three rules come out of it:
|
|
6
|
+
*
|
|
7
|
+
* archived No activity for `sessionArchiveDays` (default 60): the list leaves it out until asked
|
|
8
|
+
* (「보관 포함」) or searched. Nothing is written - a new message brings it back by itself.
|
|
9
|
+
* Archiving deletes nothing: a conversation holds the cards it offered and what came of them.
|
|
10
|
+
* candidate What the list suggests clearing, only among the caller's own (only the creator deletes):
|
|
11
|
+
* `empty` - no message ever; `inactive` - no activity for INACTIVE_DAYS.
|
|
12
|
+
* empty A conversation that never got a message and that nobody opened for EMPTY_DAYS is removed by the
|
|
13
|
+
* daily job - there is nothing in it to keep.
|
|
14
|
+
*/
|
|
15
|
+
import { DAY_MS } from './session-retention.js'
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_ARCHIVE_DAYS = 60
|
|
18
|
+
/** A conversation of mine is suggested for clearing after this many days without activity. */
|
|
19
|
+
export const INACTIVE_DAYS = 30
|
|
20
|
+
/** An empty conversation nobody touched for this long is removed. */
|
|
21
|
+
export const EMPTY_DAYS = 1
|
|
22
|
+
|
|
23
|
+
export type CleanupReason = 'empty' | 'inactive'
|
|
24
|
+
|
|
25
|
+
interface Activity {
|
|
26
|
+
lastMessageAt?: Date | string | null
|
|
27
|
+
createdAt?: Date | string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function time(value: Date | string | null | undefined): number {
|
|
31
|
+
if (!value) return 0
|
|
32
|
+
const at = value instanceof Date ? value.getTime() : Date.parse(value)
|
|
33
|
+
return Number.isFinite(at) ? at : 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The archive period the config asks for: a positive number of days, `null` when archiving is off, the default otherwise. */
|
|
37
|
+
export function archiveDaysOf(value: unknown): number | null {
|
|
38
|
+
if (value === false || value === 0 || value === '0') return null
|
|
39
|
+
const days = Number(value)
|
|
40
|
+
return Number.isFinite(days) && days > 0 ? days : DEFAULT_ARCHIVE_DAYS
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whole days since the conversation last moved - its last message, or its start when it has none. */
|
|
44
|
+
export function idleDays(session: Activity, now: Date): number {
|
|
45
|
+
const last = time(session.lastMessageAt) || time(session.createdAt)
|
|
46
|
+
return last ? Math.floor((now.getTime() - last) / DAY_MS) : 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isArchived(session: Activity, now: Date, archiveDays: number | null): boolean {
|
|
50
|
+
return archiveDays !== null && idleDays(session, now) >= archiveDays
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Why the list suggests clearing this conversation, or `null`. Only one the caller started - only the creator deletes. */
|
|
54
|
+
export function cleanupReason(session: Activity & { mine?: boolean }, now: Date): CleanupReason | null {
|
|
55
|
+
if (!session.mine) return null
|
|
56
|
+
if (!time(session.lastMessageAt)) return 'empty'
|
|
57
|
+
return idleDays(session, now) >= INACTIVE_DAYS ? 'inactive' : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether the daily job removes an empty conversation: no message, started more than EMPTY_DAYS ago, and no seat in it
|
|
62
|
+
* touched since (opening a conversation marks its reader's seat). A dock holding it open the same day keeps it.
|
|
63
|
+
*/
|
|
64
|
+
export function emptyAndAbandoned(session: Activity & { messages: number; lastTouchedAt?: Date | string | null }, now: Date): boolean {
|
|
65
|
+
if (session.messages > 0 || time(session.lastMessageAt)) return false
|
|
66
|
+
const cutoff = now.getTime() - EMPTY_DAYS * DAY_MS
|
|
67
|
+
const started = time(session.createdAt)
|
|
68
|
+
return !!started && started < cutoff && time(session.lastTouchedAt) < cutoff
|
|
69
|
+
}
|
|
@@ -171,6 +171,19 @@ export function filterInbox<T extends InboxEntry & { mine?: boolean }>(entries:
|
|
|
171
171
|
})
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Which entries the list stands on before its filters (shared-dock.md §2-7): the suggestions to clear are every
|
|
176
|
+
* candidate, hidden or archived; otherwise a hidden one waits for its filter, and an archived one for its filter or a
|
|
177
|
+
* search - finding an old conversation by a word is how it is mostly looked for.
|
|
178
|
+
*/
|
|
179
|
+
export function listedEntries<T extends { hidden?: boolean; archived?: boolean; cleanupReason?: string | null }>(
|
|
180
|
+
entries: T[],
|
|
181
|
+
opts: { showHidden?: boolean | null; includeArchived?: boolean | null; cleanupOnly?: boolean | null; search?: string | null } = {}
|
|
182
|
+
): T[] {
|
|
183
|
+
if (opts.cleanupOnly) return entries.filter(entry => entry.cleanupReason)
|
|
184
|
+
return entries.filter(entry => (opts.showHidden || !entry.hidden) && (opts.includeArchived || !!opts.search?.trim() || !entry.archived))
|
|
185
|
+
}
|
|
186
|
+
|
|
174
187
|
export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[] {
|
|
175
188
|
const q = (search ?? '').trim().toLowerCase()
|
|
176
189
|
if (!q) return entries
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* files together once the retention period has passed.
|
|
9
9
|
*
|
|
10
10
|
* Retention: config `ai-assistant.sessionRetentionDays`, default 30. `0` or `false` turns the job off.
|
|
11
|
+
*
|
|
12
|
+
* The same run removes empty conversations nobody opened for a day (`session-cleanup.ts`, shared-dock.md §2-7) -
|
|
13
|
+
* 「새 대화」 starts a row before its first question, and one never asked stays in the list otherwise.
|
|
11
14
|
*/
|
|
12
15
|
import { config } from '@things-factory/env'
|
|
13
16
|
import { getRepository } from '@things-factory/shell'
|
|
@@ -19,6 +22,7 @@ import { ChatSession } from './chat-session.js'
|
|
|
19
22
|
import { removeSessionFiles } from './session-attachments.js'
|
|
20
23
|
|
|
21
24
|
import { DAY_MS, purgeCutoff, retentionDaysOf } from './session-retention.js'
|
|
25
|
+
import { EMPTY_DAYS, emptyAndAbandoned } from './session-cleanup.js'
|
|
22
26
|
|
|
23
27
|
export { DEFAULT_RETENTION_DAYS, purgeCutoff, retentionDaysOf } from './session-retention.js'
|
|
24
28
|
/** How many conversations one run empties - a large backlog is worked off over several runs. */
|
|
@@ -26,12 +30,48 @@ export const PURGE_BATCH = 100
|
|
|
26
30
|
|
|
27
31
|
export interface PurgeResult {
|
|
28
32
|
purged: number
|
|
33
|
+
/** Empty conversations removed - never asked anything, and nobody opened them for a day. */
|
|
34
|
+
emptied: number
|
|
29
35
|
failed: { sessionId: string; message: string }[]
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** A conversation, its messages, its participants and its files, gone together. */
|
|
39
|
+
async function removeConversation(session: ChatSession): Promise<void> {
|
|
40
|
+
await removeSessionFiles(session as any)
|
|
41
|
+
/* Messages point at each other (parentMessage) - drop the links before the rows. */
|
|
42
|
+
await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)
|
|
43
|
+
await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)
|
|
44
|
+
await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)
|
|
45
|
+
/* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
|
|
46
|
+
await getRepository(ChatSession).delete({ id: In([session.id]) } as any)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Removes the empty conversations nobody opened for EMPTY_DAYS. Each is checked against its messages and seats, not the denormalized activity alone. */
|
|
50
|
+
export async function removeEmptySessions(now = new Date(), result: PurgeResult = { purged: 0, emptied: 0, failed: [] }): Promise<PurgeResult> {
|
|
51
|
+
const sessions = await getRepository(ChatSession).find({
|
|
52
|
+
where: { lastMessageAt: IsNull(), createdAt: LessThan(new Date(now.getTime() - EMPTY_DAYS * DAY_MS)) } as any,
|
|
53
|
+
relations: ['domain'],
|
|
54
|
+
take: PURGE_BATCH
|
|
55
|
+
})
|
|
56
|
+
for (const session of sessions) {
|
|
57
|
+
try {
|
|
58
|
+
const messages = await getRepository(ChatMessage).count({ where: { session: { id: session.id } } as any })
|
|
59
|
+
const seats = await getRepository(ChatSessionParticipant).find({ where: { session: { id: session.id } } as any, withDeleted: true })
|
|
60
|
+
const touched = seats.flatMap(seat => [seat.updatedAt, seat.lastReadAt]).filter(Boolean) as Date[]
|
|
61
|
+
const lastTouchedAt = touched.length ? new Date(Math.max(...touched.map(at => new Date(at).getTime()))) : null
|
|
62
|
+
if (!emptyAndAbandoned({ ...(session as any), messages, lastTouchedAt }, now)) continue
|
|
63
|
+
await removeConversation(session)
|
|
64
|
+
result.emptied++
|
|
65
|
+
} catch (error) {
|
|
66
|
+
result.failed.push({ sessionId: session.id!, message: (error as Error).message })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
|
|
32
72
|
/** Empties the conversations whose deletion is older than the retention period. */
|
|
33
73
|
export async function purgeDeletedSessions(now = new Date(), retentionDays = retentionDaysOf(config.get('ai-assistant', {})?.sessionRetentionDays)): Promise<PurgeResult> {
|
|
34
|
-
const result: PurgeResult = { purged: 0, failed: [] }
|
|
74
|
+
const result: PurgeResult = { purged: 0, emptied: 0, failed: [] }
|
|
35
75
|
if (retentionDays === null) return result
|
|
36
76
|
|
|
37
77
|
const sessions = await getRepository(ChatSession).find({
|
|
@@ -43,13 +83,7 @@ export async function purgeDeletedSessions(now = new Date(), retentionDays = ret
|
|
|
43
83
|
|
|
44
84
|
for (const session of sessions) {
|
|
45
85
|
try {
|
|
46
|
-
await
|
|
47
|
-
/* Messages point at each other (parentMessage) - drop the links before the rows. */
|
|
48
|
-
await getRepository(ChatMessage).update({ session: { id: session.id }, parentMessage: Not(IsNull()) } as any, { parentMessage: null } as any)
|
|
49
|
-
await getRepository(ChatMessage).delete({ session: { id: session.id } } as any)
|
|
50
|
-
await getRepository(ChatSessionParticipant).delete({ session: { id: session.id } } as any)
|
|
51
|
-
/* Board patches and anything else hanging on the session go with it (ON DELETE CASCADE). */
|
|
52
|
-
await getRepository(ChatSession).delete({ id: In([session.id]) } as any)
|
|
86
|
+
await removeConversation(session)
|
|
53
87
|
result.purged++
|
|
54
88
|
} catch (error) {
|
|
55
89
|
/* One conversation that cannot be emptied must not stop the rest; it is tried again next run. */
|
|
@@ -67,8 +101,10 @@ export function schedulePurge(): void {
|
|
|
67
101
|
if (timer) return
|
|
68
102
|
const run = () =>
|
|
69
103
|
purgeDeletedSessions()
|
|
70
|
-
.then((
|
|
71
|
-
|
|
104
|
+
.then(result => removeEmptySessions(new Date(), result))
|
|
105
|
+
.then(({ purged, emptied, failed }) => {
|
|
106
|
+
if (purged || emptied || failed.length)
|
|
107
|
+
console.info(`[ai-assistant] purged ${purged} deleted and ${emptied} empty conversation(s)`, failed.length ? { failed } : '')
|
|
72
108
|
})
|
|
73
109
|
.catch(error => console.warn('[ai-assistant] purging deleted conversations failed:', (error as Error).message))
|
|
74
110
|
|