@gotcos/glasses-server 6.27.6 → 6.27.7
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/CHANGELOG.md +62 -0
- package/README.md +4 -4
- package/bin/cli.cjs +4 -4
- package/package.json +1 -1
- package/server/index.ts +3 -0
- package/server/lib/agent-session-search.ts +447 -0
- package/server/lib/agent-session-store.ts +1065 -0
- package/server/lib/context-library-search.ts +346 -0
- package/server/lib/cos-operations-meetings.ts +75 -6
- package/server/lib/cursor-model-catalog.ts +55 -14
- package/server/lib/meeting-library-search.ts +439 -0
- package/server/lib/meeting-store.ts +56 -2
- package/server/lib/message-era-reset.ts +112 -0
- package/server/lib/video-upload-v2.ts +59 -0
- package/server/routes/agent-sessions.ts +201 -0
- package/server/routes/media.ts +10 -0
- package/server/routes/meetings.ts +125 -10
- package/server/routes/memory.ts +20 -0
- package/server/routes/message-ref.ts +16 -0
- package/server/routes/threads.ts +20 -0
- package/server/scripts/reset-message-era.ts +9 -9
- package/shared/model-preference.ts +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting library lookup for COS Control.
|
|
3
|
+
*
|
|
4
|
+
* Keyword: local title/summary/filename scan. No model.
|
|
5
|
+
* Semantic: existing Qdrant meeting index via semantic_search.py — one query
|
|
6
|
+
* embedding, no LLM. LightRAG is intentionally not used.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join, resolve } from 'node:path'
|
|
12
|
+
import { COS_SCRIPTS_DIR, PYTHON_BIN } from './python-bridge.js'
|
|
13
|
+
import {
|
|
14
|
+
cosOperationsMeetingsConfigured,
|
|
15
|
+
discoverMeetingDomains,
|
|
16
|
+
listDirectLibraryMeetingMonths,
|
|
17
|
+
listDirectLibraryMeetings,
|
|
18
|
+
resolveCosOperationsDir,
|
|
19
|
+
resolveMeetingLibrary,
|
|
20
|
+
sidecarSessionId,
|
|
21
|
+
} from './cos-operations-meetings.js'
|
|
22
|
+
import { getMeetingStore, MeetingStore } from './meeting-store.js'
|
|
23
|
+
import type { MeetingMeta } from './meeting-store.js'
|
|
24
|
+
|
|
25
|
+
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
26
|
+
const MAX_SCAN_FILES = 2_000
|
|
27
|
+
const HEAD_BYTES = 8_000
|
|
28
|
+
const SEMANTIC_TIMEOUT_MS = 15_000
|
|
29
|
+
const STOPWORDS = new Set([
|
|
30
|
+
'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'for', 'on', 'at', 'by',
|
|
31
|
+
'with', 'from', 'vs', 'is', 'it', 'be', 'as', 'we', 'our',
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
export interface MeetingSearchHit {
|
|
35
|
+
recordId: string
|
|
36
|
+
sessionId?: string
|
|
37
|
+
title: string
|
|
38
|
+
date: string
|
|
39
|
+
domain: string
|
|
40
|
+
duration: string
|
|
41
|
+
month: string
|
|
42
|
+
filename: string
|
|
43
|
+
source: string
|
|
44
|
+
librarySource?: MeetingMeta['librarySource']
|
|
45
|
+
snippet: string
|
|
46
|
+
keywordScore: number
|
|
47
|
+
semanticScore: number
|
|
48
|
+
match: 'keyword' | 'semantic' | 'both'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MeetingSearchResult {
|
|
52
|
+
hits: MeetingSearchHit[]
|
|
53
|
+
keywordCount: number
|
|
54
|
+
semanticCount: number
|
|
55
|
+
semanticAvailable: boolean
|
|
56
|
+
semanticReason?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function tokenizeMeetingQuery(query: string): string[] {
|
|
60
|
+
const raw = query.toLowerCase().match(/[a-z0-9]{2,}/g) ?? []
|
|
61
|
+
return [...new Set(raw.filter(token => !STOPWORDS.has(token)))]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function libraryRefFromPath(filePath: string): { domain: string; month: string; filename: string } | null {
|
|
65
|
+
const normalized = filePath.replace(/\\/g, '/')
|
|
66
|
+
const ops = normalized.match(/\/([a-z][a-z0-9_]{0,31})\/meetings\/(\d{4}-\d{2})\/([^/]+\.md)$/i)
|
|
67
|
+
if (ops) return { domain: ops[1], month: ops[2], filename: ops[3] }
|
|
68
|
+
const standalone = normalized.match(/\/recordings\/(\d{4}-\d{2})\/([^/]+\.md)$/i)
|
|
69
|
+
if (standalone) return { domain: 'personal', month: standalone[1], filename: standalone[2] }
|
|
70
|
+
const direct = normalized.match(/\/(\d{4}-\d{2})\/([^/]+\.md)$/)
|
|
71
|
+
if (direct) return { domain: 'library', month: direct[1], filename: direct[2] }
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function scoreKeywordMatch(tokens: string[], title: string, haystack: string): { score: number; snippet: string } {
|
|
76
|
+
if (tokens.length === 0) return { score: 0, snippet: '' }
|
|
77
|
+
const titleL = title.toLowerCase()
|
|
78
|
+
const hayL = haystack.toLowerCase()
|
|
79
|
+
let hits = 0
|
|
80
|
+
let titleHits = 0
|
|
81
|
+
let firstAt = -1
|
|
82
|
+
for (const token of tokens) {
|
|
83
|
+
const inTitle = titleL.includes(token)
|
|
84
|
+
const inHay = hayL.includes(token)
|
|
85
|
+
if (!inTitle && !inHay) continue
|
|
86
|
+
hits += 1
|
|
87
|
+
if (inTitle) titleHits += 1
|
|
88
|
+
if (firstAt < 0) {
|
|
89
|
+
const at = hayL.indexOf(token)
|
|
90
|
+
firstAt = at >= 0 ? at : 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (hits === 0) return { score: 0, snippet: '' }
|
|
94
|
+
const coverage = hits / tokens.length
|
|
95
|
+
if (coverage < 0.5 && titleHits === 0) return { score: 0, snippet: '' }
|
|
96
|
+
const score = Math.min(1, coverage * 0.65 + (titleHits / tokens.length) * 0.35)
|
|
97
|
+
const start = Math.max(0, firstAt - 40)
|
|
98
|
+
const snippet = haystack.slice(start, start + 180).replace(/\s+/g, ' ').trim()
|
|
99
|
+
return { score, snippet }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function titleFrom(content: string, filename: string): string {
|
|
103
|
+
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
104
|
+
if (heading) return heading
|
|
105
|
+
return filename.replace(/^\d{4}-\d{2}-\d{2}_/, '').replace(/\.md$/i, '').replace(/_/g, ' ')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function dateFrom(content: string, filename: string): string {
|
|
109
|
+
const field = content.match(/\*\*Date\*\*\s*[|:]\s*([0-9]{4}-[0-9]{2}-[0-9]{2})/i)
|
|
110
|
+
if (field) return field[1]
|
|
111
|
+
return filename.match(/^(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function durationFrom(content: string): string {
|
|
115
|
+
const match = content.match(/\*\*Duration\*\*\s*[|:]\s*(.+)/i)
|
|
116
|
+
return match ? match[1].replace(/\s*\|?\s*$/, '').trim() : ''
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sourceFrom(content: string): string {
|
|
120
|
+
const match = content.match(/\*\*Source\*\*\s*[|:]\s*(.+)/i)
|
|
121
|
+
return match ? match[1].replace(/\s*\|?\s*$/, '').trim() : ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hitIdentity(row: {
|
|
125
|
+
domain: string
|
|
126
|
+
month: string
|
|
127
|
+
filename: string
|
|
128
|
+
librarySource?: MeetingMeta['librarySource']
|
|
129
|
+
sessionId?: string
|
|
130
|
+
}): Pick<MeetingSearchHit, 'recordId' | 'month' | 'filename' | 'domain'> {
|
|
131
|
+
const recordId = row.librarySource === 'direct_library'
|
|
132
|
+
? `direct:${row.month}:${row.filename}`
|
|
133
|
+
: row.librarySource === 'standalone_recordings'
|
|
134
|
+
? `standalone:${row.sessionId || `${row.domain}:${row.month}:${row.filename}`}`
|
|
135
|
+
: `ops:${row.domain}:${row.month}:${row.filename}`
|
|
136
|
+
return { recordId, month: row.month, filename: row.filename, domain: row.domain }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function keywordHitsFromOps(tokens: string[], domainFilter: string, budget: { remaining: number }): MeetingSearchHit[] {
|
|
140
|
+
const operationsDir = resolveCosOperationsDir()
|
|
141
|
+
if (!operationsDir) return []
|
|
142
|
+
const discovered = discoverMeetingDomains(operationsDir)
|
|
143
|
+
const domains = domainFilter === 'all'
|
|
144
|
+
? discovered
|
|
145
|
+
: discovered.includes(domainFilter) ? [domainFilter] : []
|
|
146
|
+
const hits: MeetingSearchHit[] = []
|
|
147
|
+
|
|
148
|
+
for (const domain of domains) {
|
|
149
|
+
const meetingsBase = join(operationsDir, domain, 'meetings')
|
|
150
|
+
let months: string[] = []
|
|
151
|
+
try {
|
|
152
|
+
months = readdirSync(meetingsBase).filter(name => MONTH_PATTERN.test(name)).sort().reverse()
|
|
153
|
+
} catch {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
for (const month of months) {
|
|
157
|
+
const monthDir = join(meetingsBase, month)
|
|
158
|
+
let files: string[] = []
|
|
159
|
+
try {
|
|
160
|
+
files = readdirSync(monthDir).filter(name => name.endsWith('.md'))
|
|
161
|
+
} catch {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
for (const filename of files) {
|
|
165
|
+
if (budget.remaining <= 0) return hits
|
|
166
|
+
budget.remaining -= 1
|
|
167
|
+
let content = ''
|
|
168
|
+
try {
|
|
169
|
+
content = readFileSync(join(monthDir, filename), 'utf8').slice(0, HEAD_BYTES)
|
|
170
|
+
} catch {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
const title = titleFrom(content, filename)
|
|
174
|
+
const haystack = `${filename}\n${title}\n${content}`
|
|
175
|
+
const scored = scoreKeywordMatch(tokens, title, haystack)
|
|
176
|
+
if (scored.score <= 0) continue
|
|
177
|
+
const sessionId = sidecarSessionId(monthDir, filename)
|
|
178
|
+
const identity = hitIdentity({ domain, month, filename, librarySource: 'cos_operations', sessionId })
|
|
179
|
+
hits.push({
|
|
180
|
+
...identity,
|
|
181
|
+
...(sessionId ? { sessionId } : {}),
|
|
182
|
+
title,
|
|
183
|
+
date: dateFrom(content, filename),
|
|
184
|
+
duration: durationFrom(content),
|
|
185
|
+
source: sourceFrom(content),
|
|
186
|
+
librarySource: 'cos_operations',
|
|
187
|
+
snippet: scored.snippet,
|
|
188
|
+
keywordScore: scored.score,
|
|
189
|
+
semanticScore: 0,
|
|
190
|
+
match: 'keyword',
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return hits
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function keywordHitsFromDirect(tokens: string[], budget: { remaining: number }): MeetingSearchHit[] {
|
|
199
|
+
const inspection = resolveMeetingLibrary()
|
|
200
|
+
if (inspection.layout !== 'direct') return []
|
|
201
|
+
const hits: MeetingSearchHit[] = []
|
|
202
|
+
for (const month of listDirectLibraryMeetingMonths()) {
|
|
203
|
+
if (budget.remaining <= 0) break
|
|
204
|
+
const rows = listDirectLibraryMeetings({ limit: 200, month })
|
|
205
|
+
for (const row of rows) {
|
|
206
|
+
if (budget.remaining <= 0) break
|
|
207
|
+
budget.remaining -= 1
|
|
208
|
+
const haystack = `${row.filename}\n${row.title}\n${row.domain}`
|
|
209
|
+
const scored = scoreKeywordMatch(tokens, row.title, haystack)
|
|
210
|
+
if (scored.score <= 0) continue
|
|
211
|
+
hits.push({
|
|
212
|
+
recordId: row.recordId || `direct:${row.month}:${row.filename}`,
|
|
213
|
+
...(row.sessionId ? { sessionId: row.sessionId } : {}),
|
|
214
|
+
title: row.title,
|
|
215
|
+
date: row.date,
|
|
216
|
+
domain: row.domain,
|
|
217
|
+
duration: row.duration,
|
|
218
|
+
month: row.month,
|
|
219
|
+
filename: row.filename,
|
|
220
|
+
source: row.source,
|
|
221
|
+
librarySource: 'direct_library',
|
|
222
|
+
snippet: scored.snippet || row.title,
|
|
223
|
+
keywordScore: scored.score,
|
|
224
|
+
semanticScore: 0,
|
|
225
|
+
match: 'keyword',
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return hits
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function keywordHitsFromStandalone(
|
|
233
|
+
tokens: string[],
|
|
234
|
+
domainFilter: string,
|
|
235
|
+
budget: { remaining: number },
|
|
236
|
+
store: MeetingStore,
|
|
237
|
+
): MeetingSearchHit[] {
|
|
238
|
+
const hits: MeetingSearchHit[] = []
|
|
239
|
+
for (const month of store.listMonths()) {
|
|
240
|
+
if (budget.remaining <= 0) break
|
|
241
|
+
const rows = store.list({ limit: 200, month, domain: domainFilter })
|
|
242
|
+
for (const row of rows) {
|
|
243
|
+
if (budget.remaining <= 0) break
|
|
244
|
+
budget.remaining -= 1
|
|
245
|
+
let content = ''
|
|
246
|
+
try {
|
|
247
|
+
content = readFileSync(join(store.root, row.month, row.filename), 'utf8').slice(0, HEAD_BYTES)
|
|
248
|
+
} catch {
|
|
249
|
+
content = ''
|
|
250
|
+
}
|
|
251
|
+
const haystack = `${row.filename}\n${row.title}\n${row.domain}\n${content}`
|
|
252
|
+
const scored = scoreKeywordMatch(tokens, row.title, haystack)
|
|
253
|
+
if (scored.score <= 0) continue
|
|
254
|
+
const identity = hitIdentity({
|
|
255
|
+
domain: row.domain,
|
|
256
|
+
month: row.month,
|
|
257
|
+
filename: row.filename,
|
|
258
|
+
librarySource: 'standalone_recordings',
|
|
259
|
+
sessionId: row.sessionId,
|
|
260
|
+
})
|
|
261
|
+
hits.push({
|
|
262
|
+
...identity,
|
|
263
|
+
...(row.sessionId ? { sessionId: row.sessionId } : {}),
|
|
264
|
+
title: row.title,
|
|
265
|
+
date: row.date,
|
|
266
|
+
duration: row.duration,
|
|
267
|
+
source: row.source,
|
|
268
|
+
librarySource: 'standalone_recordings',
|
|
269
|
+
snippet: scored.snippet || row.title,
|
|
270
|
+
keywordScore: scored.score,
|
|
271
|
+
semanticScore: 0,
|
|
272
|
+
match: 'keyword',
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return hits
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function keywordSearchMeetings(
|
|
280
|
+
query: string,
|
|
281
|
+
domain = 'all',
|
|
282
|
+
store: MeetingStore = getMeetingStore(),
|
|
283
|
+
): MeetingSearchHit[] {
|
|
284
|
+
const tokens = tokenizeMeetingQuery(query)
|
|
285
|
+
if (tokens.length === 0) return []
|
|
286
|
+
const budget = { remaining: MAX_SCAN_FILES }
|
|
287
|
+
const grouped = new Map<string, MeetingSearchHit>()
|
|
288
|
+
const push = (hit: MeetingSearchHit) => {
|
|
289
|
+
const existing = grouped.get(hit.recordId)
|
|
290
|
+
if (!existing || hit.keywordScore > existing.keywordScore) grouped.set(hit.recordId, hit)
|
|
291
|
+
}
|
|
292
|
+
if (cosOperationsMeetingsConfigured()) {
|
|
293
|
+
for (const hit of keywordHitsFromOps(tokens, domain, budget)) push(hit)
|
|
294
|
+
}
|
|
295
|
+
const library = resolveMeetingLibrary()
|
|
296
|
+
if (library.layout === 'direct' && (domain === 'all' || domain === 'library')) {
|
|
297
|
+
for (const hit of keywordHitsFromDirect(tokens, budget)) push(hit)
|
|
298
|
+
}
|
|
299
|
+
for (const hit of keywordHitsFromStandalone(tokens, domain, budget, store)) push(hit)
|
|
300
|
+
return [...grouped.values()].sort((a, b) => b.keywordScore - a.keywordScore)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface SemanticRawHit {
|
|
304
|
+
title?: string
|
|
305
|
+
date?: string
|
|
306
|
+
domain?: string
|
|
307
|
+
score?: number
|
|
308
|
+
summary?: string
|
|
309
|
+
file_path?: string
|
|
310
|
+
meeting_id?: string
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function semanticHitToLibrary(raw: SemanticRawHit): MeetingSearchHit | null {
|
|
314
|
+
const fromPath = raw.file_path ? libraryRefFromPath(raw.file_path) : null
|
|
315
|
+
const domain = fromPath?.domain || raw.domain || ''
|
|
316
|
+
const date = raw.date || ''
|
|
317
|
+
const month = fromPath?.month || (date.length >= 7 ? date.slice(0, 7) : '')
|
|
318
|
+
let filename = fromPath?.filename || ''
|
|
319
|
+
if (!filename && raw.meeting_id) {
|
|
320
|
+
filename = raw.meeting_id.endsWith('.md') ? raw.meeting_id : `${raw.meeting_id}.md`
|
|
321
|
+
}
|
|
322
|
+
if (!filename || !month) return null
|
|
323
|
+
const title = raw.title || titleFrom('', filename)
|
|
324
|
+
const identity = hitIdentity({
|
|
325
|
+
domain: domain || 'quilt',
|
|
326
|
+
month,
|
|
327
|
+
filename,
|
|
328
|
+
librarySource: fromPath?.domain === 'library' ? 'direct_library' : 'cos_operations',
|
|
329
|
+
})
|
|
330
|
+
const semanticScore = typeof raw.score === 'number' && Number.isFinite(raw.score) ? Math.max(0, Math.min(1, raw.score)) : 0
|
|
331
|
+
return {
|
|
332
|
+
...identity,
|
|
333
|
+
title,
|
|
334
|
+
date,
|
|
335
|
+
duration: '',
|
|
336
|
+
source: '',
|
|
337
|
+
librarySource: identity.recordId.startsWith('direct:') ? 'direct_library' : 'cos_operations',
|
|
338
|
+
snippet: String(raw.summary || '').slice(0, 180),
|
|
339
|
+
keywordScore: 0,
|
|
340
|
+
semanticScore,
|
|
341
|
+
match: 'semantic',
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function semanticSearchAvailable(): { ok: boolean; reason?: string } {
|
|
346
|
+
if (!PYTHON_BIN || !COS_SCRIPTS_DIR) return { ok: false, reason: 'no_cos_pipeline' }
|
|
347
|
+
if (!existsSync(PYTHON_BIN)) return { ok: false, reason: 'no_cos_pipeline' }
|
|
348
|
+
const script = resolve(COS_SCRIPTS_DIR, 'semantic_search.py')
|
|
349
|
+
if (!existsSync(script)) return { ok: false, reason: 'no_memory_scripts' }
|
|
350
|
+
return { ok: true }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function semanticSearchMeetings(query: string, domain = 'all', limit = 20): Promise<{
|
|
354
|
+
hits: MeetingSearchHit[]
|
|
355
|
+
reason?: string
|
|
356
|
+
}> {
|
|
357
|
+
const available = semanticSearchAvailable()
|
|
358
|
+
if (!available.ok) return Promise.resolve({ hits: [], reason: available.reason })
|
|
359
|
+
const script = resolve(COS_SCRIPTS_DIR!, 'semantic_search.py')
|
|
360
|
+
const args = [script, query, '--json', '--limit', String(limit), '--min-score', '0.25', '--temporal-mode', 'off']
|
|
361
|
+
if (domain !== 'all' && domain !== 'library') args.push('--domain', domain)
|
|
362
|
+
return new Promise(resolvePromise => {
|
|
363
|
+
execFile(
|
|
364
|
+
PYTHON_BIN!,
|
|
365
|
+
args,
|
|
366
|
+
{ cwd: COS_SCRIPTS_DIR!, timeout: SEMANTIC_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 },
|
|
367
|
+
(error, stdout) => {
|
|
368
|
+
if (error) {
|
|
369
|
+
resolvePromise({ hits: [], reason: 'qdrant_unreachable' })
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(String(stdout)) as SemanticRawHit[]
|
|
374
|
+
const hits = (Array.isArray(parsed) ? parsed : []).flatMap(row => {
|
|
375
|
+
const hit = semanticHitToLibrary(row)
|
|
376
|
+
return hit ? [hit] : []
|
|
377
|
+
})
|
|
378
|
+
resolvePromise({ hits })
|
|
379
|
+
} catch {
|
|
380
|
+
resolvePromise({ hits: [], reason: 'qdrant_parse_error' })
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function mergeMeetingSearchHits(
|
|
388
|
+
keywordHits: MeetingSearchHit[],
|
|
389
|
+
semanticHits: MeetingSearchHit[],
|
|
390
|
+
limit: number,
|
|
391
|
+
): MeetingSearchHit[] {
|
|
392
|
+
const merged = new Map<string, MeetingSearchHit>()
|
|
393
|
+
const keyFor = (hit: MeetingSearchHit) => hit.recordId || `${hit.domain}:${hit.month}:${hit.filename}`
|
|
394
|
+
for (const hit of keywordHits) merged.set(keyFor(hit), { ...hit })
|
|
395
|
+
for (const hit of semanticHits) {
|
|
396
|
+
const key = keyFor(hit)
|
|
397
|
+
const existing = merged.get(key)
|
|
398
|
+
if (!existing) {
|
|
399
|
+
merged.set(key, hit)
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
merged.set(key, {
|
|
403
|
+
...existing,
|
|
404
|
+
snippet: existing.snippet || hit.snippet,
|
|
405
|
+
semanticScore: Math.max(existing.semanticScore, hit.semanticScore),
|
|
406
|
+
match: existing.keywordScore > 0 && hit.semanticScore > 0 ? 'both' : existing.match,
|
|
407
|
+
sessionId: existing.sessionId || hit.sessionId,
|
|
408
|
+
duration: existing.duration || hit.duration,
|
|
409
|
+
source: existing.source || hit.source,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
return [...merged.values()]
|
|
413
|
+
.sort((a, b) => {
|
|
414
|
+
const bothDelta = Number(b.match === 'both') - Number(a.match === 'both')
|
|
415
|
+
if (bothDelta) return bothDelta
|
|
416
|
+
return Math.max(b.keywordScore, b.semanticScore) - Math.max(a.keywordScore, a.semanticScore)
|
|
417
|
+
})
|
|
418
|
+
.slice(0, Math.max(1, Math.min(limit, 50)))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function searchMeetingLibrary(options: {
|
|
422
|
+
query: string
|
|
423
|
+
domain?: string
|
|
424
|
+
limit?: number
|
|
425
|
+
}, store: MeetingStore = getMeetingStore()): Promise<MeetingSearchResult> {
|
|
426
|
+
const query = options.query.trim()
|
|
427
|
+
const domain = options.domain || 'all'
|
|
428
|
+
const limit = Math.max(1, Math.min(options.limit ?? 20, 50))
|
|
429
|
+
const keywordHits = keywordSearchMeetings(query, domain, store)
|
|
430
|
+
const semantic = await semanticSearchMeetings(query, domain, limit)
|
|
431
|
+
const hits = mergeMeetingSearchHits(keywordHits, semantic.hits, limit)
|
|
432
|
+
return {
|
|
433
|
+
hits,
|
|
434
|
+
keywordCount: keywordHits.length,
|
|
435
|
+
semanticCount: semantic.hits.length,
|
|
436
|
+
semanticAvailable: !semantic.reason,
|
|
437
|
+
...(semantic.reason ? { semanticReason: semantic.reason } : {}),
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -26,6 +26,29 @@ import type {
|
|
|
26
26
|
} from '../routes/transcribe-stream.js'
|
|
27
27
|
|
|
28
28
|
const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
29
|
+
const DAY_PATTERN = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
|
|
30
|
+
const DAY_FILE_PREFIX = /^(\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))_/
|
|
31
|
+
const LIST_CAP = 50
|
|
32
|
+
const LIST_CAP_SCOPED = 200
|
|
33
|
+
|
|
34
|
+
export function meetingListLimit(limit: number | undefined, scoped: boolean): number {
|
|
35
|
+
const raw = typeof limit === 'number' && Number.isFinite(limit) ? Math.trunc(limit) : 20
|
|
36
|
+
return Math.max(1, Math.min(scoped ? LIST_CAP_SCOPED : LIST_CAP, raw))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Calendar dots from filenames. Does not open the markdown. */
|
|
40
|
+
export function meetingDayCountsFromNames(names: string[]): Array<{ date: string; count: number }> {
|
|
41
|
+
const counts = new Map<string, number>()
|
|
42
|
+
for (const name of names) {
|
|
43
|
+
const match = name.match(DAY_FILE_PREFIX)
|
|
44
|
+
if (!match) continue
|
|
45
|
+
counts.set(match[1], (counts.get(match[1]) ?? 0) + 1)
|
|
46
|
+
}
|
|
47
|
+
return [...counts.entries()]
|
|
48
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
49
|
+
.map(([date, count]) => ({ date, count }))
|
|
50
|
+
}
|
|
51
|
+
|
|
29
52
|
const SAFE_FILENAME_PATTERN = /^\d{4}-\d{2}-\d{2}_[A-Za-z0-9][A-Za-z0-9_-]{0,95}\.md$/
|
|
30
53
|
const DOMAIN_PATTERN = /^[a-z][a-z0-9_]{0,31}$/
|
|
31
54
|
const MAX_MEETING_BYTES = 10 * 1024 * 1024
|
|
@@ -550,17 +573,25 @@ export class MeetingStore {
|
|
|
550
573
|
return null
|
|
551
574
|
}
|
|
552
575
|
|
|
553
|
-
list(options: { limit?: number; domain?: string } = {}): MeetingMeta[] {
|
|
554
|
-
const
|
|
576
|
+
list(options: { limit?: number; domain?: string; month?: string; day?: string } = {}): MeetingMeta[] {
|
|
577
|
+
const scoped = Boolean(options.month || options.day)
|
|
578
|
+
const limit = meetingListLimit(options.limit, scoped)
|
|
555
579
|
const domain = options.domain ?? 'all'
|
|
556
580
|
if (domain !== 'all' && !isSafeDomainName(domain)) {
|
|
557
581
|
throw new MeetingStoreError('Invalid domain filter', 400, 'invalid_domain')
|
|
558
582
|
}
|
|
583
|
+
if (options.month && !MONTH_PATTERN.test(options.month)) {
|
|
584
|
+
throw new MeetingStoreError('Invalid month filter', 400, 'invalid_month')
|
|
585
|
+
}
|
|
586
|
+
if (options.day && !DAY_PATTERN.test(options.day)) {
|
|
587
|
+
throw new MeetingStoreError('Invalid day filter', 400, 'invalid_day')
|
|
588
|
+
}
|
|
559
589
|
const rootReal = this.existingRootRealpath()
|
|
560
590
|
if (!rootReal) return []
|
|
561
591
|
const meetings: MeetingMeta[] = []
|
|
562
592
|
|
|
563
593
|
for (const month of readdirSync(this.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
|
|
594
|
+
if (options.month && month !== options.month) continue
|
|
564
595
|
const monthDir = join(this.root, month)
|
|
565
596
|
const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
|
|
566
597
|
if (!monthReal) continue
|
|
@@ -570,6 +601,7 @@ export class MeetingStore {
|
|
|
570
601
|
if (content === null) continue
|
|
571
602
|
const detail = parseMeeting(content, filename, month)
|
|
572
603
|
if (domain !== 'all' && detail.domain !== domain) continue
|
|
604
|
+
if (options.day && detail.date !== options.day) continue
|
|
573
605
|
meetings.push(toMeta(detail, this.sidecarSessionId(monthDir, monthReal, filename)))
|
|
574
606
|
} catch {
|
|
575
607
|
// One unreadable/corrupt entry must not hide the rest of the store.
|
|
@@ -582,6 +614,28 @@ export class MeetingStore {
|
|
|
582
614
|
return meetings.slice(0, limit)
|
|
583
615
|
}
|
|
584
616
|
|
|
617
|
+
/** Folder names only. Used by the Control calendar pager. */
|
|
618
|
+
listMonths(): string[] {
|
|
619
|
+
const rootReal = this.existingRootRealpath()
|
|
620
|
+
if (!rootReal) return []
|
|
621
|
+
return readdirSync(this.root)
|
|
622
|
+
.filter(name => MONTH_PATTERN.test(name) && this.safeDirectoryRealpath(join(this.root, name), rootReal))
|
|
623
|
+
.sort()
|
|
624
|
+
.reverse()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
listDayCounts(month: string): Array<{ date: string; count: number }> {
|
|
628
|
+
if (!MONTH_PATTERN.test(month)) return []
|
|
629
|
+
const rootReal = this.existingRootRealpath()
|
|
630
|
+
if (!rootReal) return []
|
|
631
|
+
const monthDir = join(this.root, month)
|
|
632
|
+
const monthReal = this.safeDirectoryRealpath(monthDir, rootReal)
|
|
633
|
+
if (!monthReal) return []
|
|
634
|
+
return meetingDayCountsFromNames(
|
|
635
|
+
readdirSync(monthDir).filter(name => SAFE_FILENAME_PATTERN.test(name) || name.endsWith('.md')),
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
|
|
585
639
|
detail(domain: string, month: string, filename: string): MeetingDetail {
|
|
586
640
|
if (!isSafeDomainName(domain)) {
|
|
587
641
|
throw new MeetingStoreError('Invalid domain', 400, 'invalid_domain')
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Archive live sessions, then start short-numbering at #1.
|
|
2
|
+
// History is retained in day archives; only the current era's ceiling resets.
|
|
3
|
+
// Disk mtime on message-era.json is enough — the live server re-reads it.
|
|
4
|
+
// Do not rotate the era if a query is in flight or an archive write fails.
|
|
5
|
+
|
|
6
|
+
import { endSession, getActiveSessions } from './conversation.js'
|
|
7
|
+
import {
|
|
8
|
+
createMessageEra,
|
|
9
|
+
currentMessageEraState,
|
|
10
|
+
type MessageEraState,
|
|
11
|
+
} from './message-era.js'
|
|
12
|
+
import type { SessionToArchive } from './archive.js'
|
|
13
|
+
|
|
14
|
+
export class MessageEraResetError extends Error {
|
|
15
|
+
readonly code: string
|
|
16
|
+
readonly status: number
|
|
17
|
+
|
|
18
|
+
constructor(code: string, message: string, status = 400) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'MessageEraResetError'
|
|
21
|
+
this.code = code
|
|
22
|
+
this.status = status
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MessageEraResetResult {
|
|
27
|
+
ok: true
|
|
28
|
+
era: string
|
|
29
|
+
previousEra: string
|
|
30
|
+
archived: number
|
|
31
|
+
max: 0
|
|
32
|
+
startedAt: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MessageEraResetInput {
|
|
36
|
+
confirm: boolean
|
|
37
|
+
now?: number
|
|
38
|
+
activeRuns?: number
|
|
39
|
+
shuttingDown?: boolean
|
|
40
|
+
sessions?: SessionToArchive[]
|
|
41
|
+
archiveAndRelease?: (session: SessionToArchive) => Promise<boolean>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveJobHealth(input: MessageEraResetInput): Promise<{ activeRuns: number; shuttingDown: boolean }> {
|
|
45
|
+
if (input.activeRuns != null || input.shuttingDown != null) {
|
|
46
|
+
return {
|
|
47
|
+
activeRuns: input.activeRuns ?? 0,
|
|
48
|
+
shuttingDown: input.shuttingDown ?? false,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const { queryJobCoordinator } = await import('./query-job-runtime.js')
|
|
52
|
+
const health = queryJobCoordinator.getHealth()
|
|
53
|
+
return { activeRuns: health.activeRuns, shuttingDown: health.shuttingDown }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function resetLiveMessageEra(input: MessageEraResetInput): Promise<MessageEraResetResult> {
|
|
57
|
+
if (input.confirm !== true) {
|
|
58
|
+
throw new MessageEraResetError(
|
|
59
|
+
'confirmation_required',
|
|
60
|
+
'confirmation required',
|
|
61
|
+
400,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const jobs = await resolveJobHealth(input)
|
|
66
|
+
if (jobs.activeRuns > 0) {
|
|
67
|
+
throw new MessageEraResetError(
|
|
68
|
+
'query_in_flight',
|
|
69
|
+
'A query is still running. Wait for it to finish, then reset.',
|
|
70
|
+
409,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (jobs.shuttingDown) {
|
|
74
|
+
throw new MessageEraResetError(
|
|
75
|
+
'server_shutting_down',
|
|
76
|
+
'Server is shutting down. Try again after it is healthy.',
|
|
77
|
+
409,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const previous = currentMessageEraState()
|
|
82
|
+
const sessions = input.sessions ?? getActiveSessions()
|
|
83
|
+
const archiveAndRelease = input.archiveAndRelease ?? (async (session: SessionToArchive) => {
|
|
84
|
+
const result = await endSession(session.id)
|
|
85
|
+
if (!result) return true
|
|
86
|
+
if (result.exchangeCount > 0 && !result.logged) return false
|
|
87
|
+
return true
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
let archived = 0
|
|
91
|
+
for (const session of sessions) {
|
|
92
|
+
const released = await archiveAndRelease(session)
|
|
93
|
+
if (!released) {
|
|
94
|
+
throw new MessageEraResetError(
|
|
95
|
+
'archive_failed',
|
|
96
|
+
'Archive failed; message count was not reset.',
|
|
97
|
+
503,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
archived++
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const next: MessageEraState = createMessageEra(input.now ?? Date.now())
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
era: next.era,
|
|
107
|
+
previousEra: previous.era,
|
|
108
|
+
archived,
|
|
109
|
+
max: 0,
|
|
110
|
+
startedAt: next.startedAt,
|
|
111
|
+
}
|
|
112
|
+
}
|