@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.
@@ -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 limit = Math.max(1, Math.min(50, Math.trunc(options.limit ?? 20)))
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
+ }