@gotcos/glasses-server 6.21.32 → 6.21.33

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/.env.example CHANGED
@@ -124,12 +124,15 @@ BIND_HOST=0.0.0.0
124
124
  # pipeline to inherit live tasks/calendar/people context. Omit for standalone.
125
125
  # COS_SCRIPTS_DIR=/path/to/your/cos/operations/scripts
126
126
  #
127
- # G2 "Review Meetings" reads COS meeting markdown from an operations tree:
128
- # {COS_OPERATIONS_DIR}/{quilt|personal|…}/meetings/YYYY-MM/*.md
129
- # Prefer an explicit ops root (each COS layout can differ). If unset, the
130
- # server falls back to COS_SCRIPTS_DIR/.. then to local G2 recordings only.
127
+ # G2 "Review Meetings" accepts either layout:
128
+ # {COS_MEETINGS_ROOT}/YYYY-MM/*.md # one read-only library
129
+ # {COS_OPERATIONS_DIR}/{domain}/meetings/YYYY-MM/*.md # multi-domain pipeline
130
+ # Keep COS_OPERATIONS_DIR for enrichment and writes. COS_MEETINGS_ROOT is a
131
+ # browse override; legacy values that contain a multi-domain operations tree
132
+ # retain their old alias behavior. If both are unset, the server tries
133
+ # COS_SCRIPTS_DIR/.. and then local G2 recordings.
131
134
  # COS_OPERATIONS_DIR=/path/to/your/cos/operations
132
- # COS_MEETINGS_ROOT=/path/to/your/cos/operations # alias for COS_OPERATIONS_DIR
135
+ # COS_MEETINGS_ROOT=/path/to/your/existing/meetings
133
136
 
134
137
  # Telegram session/activity notifications remain OFF even if the COS scripts
135
138
  # directory contains .telegram_config.json. Enable export explicitly:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 6.21.33
2
+
3
+ - **Existing meeting libraries can be selected directly.** `COS_MEETINGS_ROOT`
4
+ now accepts `meetings/YYYY-MM/*.md` as a read-only library, while
5
+ `COS_OPERATIONS_DIR` continues to own multi-domain enrichment and writes.
6
+ - **Mixed libraries stay coherent.** Review Meetings merges direct, enriched
7
+ operations, and standalone G2 records, dedupes by session identity, and
8
+ prefers the writable enriched copy when one exists.
9
+ - **Upgrades remain compatible.** A legacy multi-domain
10
+ `COS_MEETINGS_ROOT` keeps its prior operations-root meaning. Invalid explicit
11
+ roots report a degraded state instead of silently switching libraries.
12
+ - **Read-only means read-only.** Direct-library speaker mutations return a
13
+ typed conflict, paths and symlinks are contained, scans are bounded, and
14
+ public health never exposes the selected filesystem path.
15
+
1
16
  ## 6.21.32
2
17
 
3
18
  - **Adaptive meeting-audio cleanup is a default-off, replay-only canary.** When
package/README.md CHANGED
@@ -292,6 +292,15 @@ optional cleanup process cannot contend with live transcription. Cleanup uses
292
292
  one global worker, serves raw while that worker is busy, and preempts within
293
293
  100 ms if a meeting starts after a replay request was admitted.
294
294
 
295
+ Server 6.21.33 lets Review Meetings browse an existing single-library tree such
296
+ as `meetings/YYYY-MM/*.md`. Set `COS_MEETINGS_ROOT` to the folder that directly
297
+ contains the month folders. It is intentionally read-only. For the full COS
298
+ sync and enrichment pipeline, keep using `COS_OPERATIONS_DIR` with
299
+ `<domain>/meetings/YYYY-MM/*.md`; arbitrary domain names are supported. When a
300
+ direct library and an operations root are both configured, the server merges
301
+ them with standalone G2 recordings and prefers the enriched writable record
302
+ for the same session.
303
+
295
304
  The first server start downloads the real-time turbo model. True HQ additionally
296
305
  requires the full `ggml-large-v3.bin` model (about 3.1 GB):
297
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.32",
3
+ "version": "6.21.33",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,17 +4,17 @@
4
4
  * When configured, G2 "Review Meetings" reads markdown from a COS-style tree:
5
5
  * {operationsDir}/{domain}/meetings/YYYY-MM/*.md
6
6
  *
7
- * Resolution order:
8
- * 1. COS_OPERATIONS_DIR — explicit operations/ root (preferred)
9
- * 2. COS_MEETINGS_ROOT alias for the same path
10
- * 3. COS_SCRIPTS_DIR/.. — classic Starter Kit layout (operations/scripts → operations)
7
+ * Read resolution accepts COS_MEETINGS_ROOT as a direct YYYY-MM library.
8
+ * Write/enrichment resolution remains COS_OPERATIONS_DIR, a legacy
9
+ * multi-domain COS_MEETINGS_ROOT, then COS_SCRIPTS_DIR/...
11
10
  *
12
11
  * Standalone installs leave all of these unset and keep using MeetingStore
13
12
  * (~/.cos-glasses/data/recordings).
14
13
  */
15
14
 
16
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
17
- import { basename, join, resolve } from 'node:path'
15
+ import { closeSync, existsSync, lstatSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync } from 'node:fs'
16
+ import { createHash } from 'node:crypto'
17
+ import { basename, dirname, join, resolve } from 'node:path'
18
18
  import { discoveredDomains, domainAbbreviation as deriveAbbr, isSafeDomainName as safeName } from './domains.js'
19
19
  import type { MeetingDetail, MeetingMeta } from './meeting-store.js'
20
20
  import { MEETING_SOURCE_MAX_BYTES } from './meeting-store.js'
@@ -41,6 +41,103 @@ const DETAIL_CHUNK_ESTIMATE_CHARS = 1700
41
41
 
42
42
  export type CosOperationsMeetingMeta = MeetingMeta & { time?: string }
43
43
 
44
+ export type MeetingLibraryLayout = 'direct' | 'multi_domain' | 'standalone' | 'invalid_explicit_root'
45
+
46
+ export interface MeetingLibraryInspection {
47
+ layout: MeetingLibraryLayout
48
+ root: string | null
49
+ rootFingerprint: string | null
50
+ meetingCount: number
51
+ warnings: string[]
52
+ domains: string[]
53
+ }
54
+
55
+ const MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/
56
+ const MAX_LIST_CANDIDATES = 2_000
57
+ const MAX_LIST_FILE_BYTES = 100_000
58
+ const MAX_DETAIL_FILE_BYTES = 10 * 1024 * 1024
59
+
60
+ function rootFingerprint(root: string): string {
61
+ return createHash('sha256').update(root).digest('hex').slice(0, 16)
62
+ }
63
+
64
+ function safeRoot(path: string): string | null {
65
+ try {
66
+ const stat = lstatSync(path)
67
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return null
68
+ return realpathSync(path)
69
+ } catch { return null }
70
+ }
71
+
72
+ function safeChildDirectory(parentReal: string, path: string): string | null {
73
+ try {
74
+ const stat = lstatSync(path)
75
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return null
76
+ const real = realpathSync(path)
77
+ return dirname(real) === parentReal ? real : null
78
+ } catch { return null }
79
+ }
80
+
81
+ function safeRegularFile(parentReal: string, path: string, maxBytes = MAX_LIST_FILE_BYTES): string | null {
82
+ try {
83
+ const stat = lstatSync(path)
84
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > maxBytes) return null
85
+ const real = realpathSync(path)
86
+ if (dirname(real) !== parentReal) return null
87
+ return readFileSync(real, 'utf8')
88
+ } catch { return null }
89
+ }
90
+
91
+ function safeBoundedRegularFile(parentReal: string, path: string, maxReadBytes: number): { content: string; truncated: boolean } | null {
92
+ let fd: number | null = null
93
+ try {
94
+ const stat = lstatSync(path)
95
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAX_DETAIL_FILE_BYTES) return null
96
+ const real = realpathSync(path)
97
+ if (dirname(real) !== parentReal) return null
98
+ fd = openSync(real, 'r')
99
+ const buffer = Buffer.alloc(Math.min(maxReadBytes, stat.size))
100
+ const read = readSync(fd, buffer, 0, buffer.length, 0)
101
+ return { content: buffer.subarray(0, read).toString('utf8'), truncated: stat.size > read }
102
+ } catch { return null }
103
+ finally {
104
+ if (fd !== null) { try { closeSync(fd) } catch { /* already closed */ } }
105
+ }
106
+ }
107
+
108
+ export function inspectMeetingLibraryPath(path: string): MeetingLibraryInspection {
109
+ const requested = resolve(path)
110
+ const root = safeRoot(requested)
111
+ if (!root) {
112
+ return { layout: 'invalid_explicit_root', root: null, rootFingerprint: null, meetingCount: 0, warnings: ['folder is missing, unreadable, or unsafe'], domains: [] }
113
+ }
114
+ const entries = (() => { try { return readdirSync(root) } catch { return [] } })()
115
+ const directMonths = entries.filter(name => MONTH_PATTERN.test(name) && safeChildDirectory(root, join(root, name))).sort()
116
+ const domains = entries.filter(safeName).filter(name => {
117
+ const domainReal = safeChildDirectory(root, join(root, name))
118
+ if (!domainReal) return false
119
+ return safeChildDirectory(domainReal, join(domainReal, 'meetings')) != null
120
+ }).sort()
121
+ const warnings: string[] = []
122
+ if (directMonths.length > 0 && domains.length > 0) {
123
+ return { layout: 'invalid_explicit_root', root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings: ['folder mixes direct months and domain/meetings trees'], domains }
124
+ }
125
+ const layout: MeetingLibraryLayout = directMonths.length > 0 ? 'direct' : domains.length > 0 ? 'multi_domain' : 'invalid_explicit_root'
126
+ if (layout === 'invalid_explicit_root') {
127
+ const nearMisses = entries.filter(safeName).filter(name => {
128
+ const child = safeChildDirectory(root, join(root, name))
129
+ if (!child) return false
130
+ try {
131
+ return readdirSync(child).some(entry => MONTH_PATTERN.test(entry) && safeChildDirectory(child, join(child, entry)))
132
+ } catch { return false }
133
+ })
134
+ warnings.push(nearMisses.length > 0
135
+ ? `missing meetings/ inside: ${nearMisses.slice(0, 6).join(', ')}`
136
+ : 'expected YYYY-MM folders or <domain>/meetings/YYYY-MM')
137
+ }
138
+ return { layout, root, rootFingerprint: rootFingerprint(root), meetingCount: 0, warnings, domains }
139
+ }
140
+
44
141
  /** Enough to clear the sidecar's leading metadata keys whatever their order. */
45
142
  const SIDECAR_HEAD_BYTES = 4096
46
143
 
@@ -60,8 +157,11 @@ function sidecarSessionId(monthDir: string, meetingFilename: string): string | u
60
157
  const path = join(monthDir, sidecarName)
61
158
  let fd: number | null = null
62
159
  try {
63
- const stat = statSync(path)
64
- if (!stat.isFile() || stat.size === 0) return undefined
160
+ const linkStat = lstatSync(path)
161
+ if (linkStat.isSymbolicLink() || !linkStat.isFile() || linkStat.size === 0) return undefined
162
+ const real = realpathSync(path)
163
+ if (dirname(real) !== realpathSync(monthDir)) return undefined
164
+ const stat = statSync(real)
65
165
  fd = openSync(path, 'r')
66
166
  const buffer = Buffer.alloc(Math.min(SIDECAR_HEAD_BYTES, stat.size))
67
167
  const read = readSync(fd, buffer, 0, buffer.length, 0)
@@ -84,12 +184,17 @@ function envPath(name: string): string | null {
84
184
  /** Resolve the COS operations directory, or null in standalone mode.
85
185
  * Reads process.env on each call so tests and Control env updates stay live. */
86
186
  export function resolveCosOperationsDir(): string | null {
87
- const explicit = envPath('COS_OPERATIONS_DIR') || envPath('COS_MEETINGS_ROOT')
88
- if (explicit && existsSync(explicit)) return explicit
187
+ const operations = envPath('COS_OPERATIONS_DIR')
188
+ if (operations && inspectMeetingLibraryPath(operations).layout === 'multi_domain') return safeRoot(operations)
189
+ // Backward compatibility: before 6.21.33 COS_MEETINGS_ROOT was documented as
190
+ // an alias for COS_OPERATIONS_DIR. Preserve that meaning only for a real
191
+ // multi-domain tree; a direct library is never a write destination.
192
+ const legacy = envPath('COS_MEETINGS_ROOT')
193
+ if (legacy && inspectMeetingLibraryPath(legacy).layout === 'multi_domain') return safeRoot(legacy)
89
194
  const scriptsDir = envPath('COS_SCRIPTS_DIR')
90
195
  if (scriptsDir) {
91
196
  const inferred = resolve(scriptsDir, '..')
92
- if (existsSync(inferred)) return inferred
197
+ if (inspectMeetingLibraryPath(inferred).layout === 'multi_domain') return safeRoot(inferred)
93
198
  }
94
199
  return null
95
200
  }
@@ -98,6 +203,19 @@ export function cosOperationsMeetingsConfigured(): boolean {
98
203
  return resolveCosOperationsDir() != null
99
204
  }
100
205
 
206
+ export function resolveMeetingLibrary(): MeetingLibraryInspection {
207
+ const explicit = envPath('COS_MEETINGS_ROOT')
208
+ if (explicit) return inspectMeetingLibraryPath(explicit)
209
+ const operations = resolveCosOperationsDir()
210
+ if (operations) return inspectMeetingLibraryPath(operations)
211
+ return { layout: 'standalone', root: null, rootFingerprint: null, meetingCount: 0, warnings: [], domains: [] }
212
+ }
213
+
214
+ export function meetingLibraryConfigured(): boolean {
215
+ const layout = resolveMeetingLibrary().layout
216
+ return layout === 'direct' || layout === 'multi_domain'
217
+ }
218
+
101
219
  function boundedMeetingSource(content: string): { sourceContent: string; sourceTruncated: boolean } {
102
220
  const bytes = Buffer.from(content, 'utf8')
103
221
  if (bytes.length <= MEETING_SOURCE_MAX_BYTES) return { sourceContent: content, sourceTruncated: false }
@@ -364,6 +482,41 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
364
482
  return null
365
483
  }
366
484
 
485
+ export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
486
+ recordId?: string
487
+ librarySource?: 'direct_library' | 'cos_operations'
488
+ mutable?: boolean
489
+ }
490
+
491
+ /** Read resolver for direct libraries. Mutations must require recordId and
492
+ * mutable=true; the legacy operations resolver above remains writable. */
493
+ export function findDirectLibraryMeetingBySessionId(sessionId: string): MeetingLibraryRecord | null {
494
+ const inspection = resolveMeetingLibrary()
495
+ if (inspection.layout !== 'direct' || !inspection.root) return null
496
+ const root = inspection.root
497
+ const months = readdirSync(root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()
498
+ for (const month of months) {
499
+ const monthDir = safeChildDirectory(root, join(root, month))
500
+ if (!monthDir) continue
501
+ for (const sidecarName of readdirSync(monthDir).filter(name => name.endsWith('.g2-chunks.json')).sort().reverse()) {
502
+ const filename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
503
+ if (sidecarSessionId(monthDir, filename) !== sessionId) continue
504
+ const meetingPath = join(monthDir, filename)
505
+ const bounded = safeBoundedRegularFile(monthDir, meetingPath, MEETING_SOURCE_MAX_BYTES)
506
+ if (bounded == null) continue
507
+ const title = bounded.content.match(/^#\s+(.+)$/m)?.[1]?.trim() || filename.replace(/\.md$/, '')
508
+ return {
509
+ sidecarPath: join(monthDir, sidecarName), meetingPath, filename,
510
+ domain: 'library', month, title,
511
+ recordId: `direct:${month}:${filename}`,
512
+ librarySource: 'direct_library',
513
+ mutable: false,
514
+ }
515
+ }
516
+ }
517
+ return null
518
+ }
519
+
367
520
  export function listCosOperationsMeetings(options: {
368
521
  limit?: number
369
522
  domain?: string
@@ -387,7 +540,6 @@ export function listCosOperationsMeetings(options: {
387
540
  .filter(d => /^\d{4}-\d{2}$/.test(d))
388
541
  .sort()
389
542
  .reverse()
390
- .slice(0, 3)
391
543
 
392
544
  for (const month of months) {
393
545
  const monthDir = join(meetingsBase, month)
@@ -403,6 +555,10 @@ export function listCosOperationsMeetings(options: {
403
555
  const content = readFileSync(filepath, 'utf-8')
404
556
  const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, domain), content)
405
557
  meta.month = month
558
+ meta.librarySource = 'cos_operations'
559
+ meta.recordId = `ops:${domain}:${month}:${file}`
560
+ meta.mutable = true
561
+ meta.canonicalRecord = `operations/${domain}/meetings/${month}/${file}`
406
562
  const sessionId = sidecarSessionId(monthDir, file)
407
563
  if (sessionId) meta.sessionId = sessionId
408
564
  allMeetings.push(meta)
@@ -417,6 +573,35 @@ export function listCosOperationsMeetings(options: {
417
573
  return allMeetings.slice(0, limit)
418
574
  }
419
575
 
576
+ export function listDirectLibraryMeetings(options: { limit?: number } = {}): CosOperationsMeetingMeta[] {
577
+ const inspection = resolveMeetingLibrary()
578
+ if (inspection.layout !== 'direct' || !inspection.root) return []
579
+ const limit = Math.min(Math.max(options.limit ?? 20, 1), 50)
580
+ const all: CosOperationsMeetingMeta[] = []
581
+ let candidates = 0
582
+ for (const month of readdirSync(inspection.root).filter(name => MONTH_PATTERN.test(name)).sort().reverse()) {
583
+ const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
584
+ if (!monthDir) continue
585
+ for (const file of readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()) {
586
+ if (++candidates > MAX_LIST_CANDIDATES) break
587
+ const bounded = safeBoundedRegularFile(monthDir, join(monthDir, file), MAX_LIST_FILE_BYTES)
588
+ if (bounded == null) continue
589
+ const content = bounded.content
590
+ const meta = withMeetingListInsights(parseMeetingMeta(content.slice(0, 4000), file, 'library'), content)
591
+ meta.month = month
592
+ meta.librarySource = 'direct_library'
593
+ meta.recordId = `direct:${month}:${file}`
594
+ meta.mutable = false
595
+ const sessionId = sidecarSessionId(monthDir, file)
596
+ if (sessionId) meta.sessionId = sessionId
597
+ all.push(meta)
598
+ }
599
+ if (candidates > MAX_LIST_CANDIDATES) break
600
+ }
601
+ all.sort(compareMeetingsNewestFirst)
602
+ return all.slice(0, limit)
603
+ }
604
+
420
605
  export function getCosOperationsMeetingDetail(
421
606
  domain: string,
422
607
  month: string,
@@ -473,3 +658,36 @@ export function getCosOperationsMeetingDetail(
473
658
  ...source,
474
659
  }
475
660
  }
661
+
662
+ export function getDirectLibraryMeetingDetail(month: string, filename: string): MeetingDetail | null {
663
+ const inspection = resolveMeetingLibrary()
664
+ if (inspection.layout !== 'direct' || !inspection.root) return null
665
+ if (!MONTH_PATTERN.test(month) || basename(filename) !== filename || !filename.endsWith('.md')) return null
666
+ const monthDir = safeChildDirectory(inspection.root, join(inspection.root, month))
667
+ if (!monthDir) return null
668
+ let resolvedFilename = filename
669
+ let bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
670
+ if (bounded == null) {
671
+ const candidates = readdirSync(monthDir).filter(name => name.endsWith('.md')).slice(0, MAX_LIST_CANDIDATES)
672
+ .map(name => ({ filename: name, content: safeRegularFile(monthDir, join(monthDir, name), 4_000) ?? '' }))
673
+ const renamed = matchRenamedMeetingFilename(filename, candidates)
674
+ if (!renamed) return null
675
+ resolvedFilename = renamed
676
+ bounded = safeBoundedRegularFile(monthDir, join(monthDir, resolvedFilename), MEETING_SOURCE_MAX_BYTES)
677
+ if (bounded == null) return null
678
+ }
679
+ const content = bounded.content
680
+ const meta = parseMeetingMeta(content, resolvedFilename, 'library')
681
+ meta.month = month
682
+ meta.librarySource = 'direct_library'
683
+ meta.recordId = `direct:${month}:${resolvedFilename}`
684
+ meta.mutable = false
685
+ const sessionId = sidecarSessionId(monthDir, resolvedFilename)
686
+ if (sessionId) meta.sessionId = sessionId
687
+ const source = { sourceContent: content, sourceTruncated: bounded.truncated }
688
+ return {
689
+ ...meta,
690
+ summary: extractSummary(content), topics: extractTopics(content), decisions: extractDecisions(content),
691
+ actionItems: extractActionItems(content), attendees: extractAttendees(content), transcript: content, ...source,
692
+ }
693
+ }
@@ -52,6 +52,7 @@ export interface MeetingMeta {
52
52
  sessionId?: string
53
53
  title: string
54
54
  date: string
55
+ time?: string
55
56
  domain: string
56
57
  domainAbbr: string
57
58
  source: string
@@ -64,6 +65,12 @@ export interface MeetingMeta {
64
65
  decisionCount?: number
65
66
  actionCount?: number
66
67
  attendeeCount?: number
68
+ /** Additive archive identity. Older companions ignore these fields. */
69
+ librarySource?: 'direct_library' | 'cos_operations' | 'standalone_recordings'
70
+ recordId?: string
71
+ mutable?: boolean
72
+ /** Present only when the server can state a truthful local record. */
73
+ canonicalRecord?: string
67
74
  }
68
75
 
69
76
  export interface MeetingActionItem {
@@ -437,7 +444,7 @@ export class MeetingStore {
437
444
  // When COS ops is configured, use the private-app pipeline markers so
438
445
  // sync_meetings.py --g2-file will enrich (and reclassify domain). Plain
439
446
  // "Standalone recording" summaries are treated as already-final and skipped.
440
- ...(process.env.COS_SCRIPTS_DIR || process.env.COS_OPERATIONS_DIR || process.env.COS_MEETINGS_ROOT
447
+ ...(process.env.COS_SCRIPTS_DIR
441
448
  ? [
442
449
  '<!-- g2-needs-domain-review -->',
443
450
  '',
@@ -47,6 +47,7 @@ import { getHealthStaticProbes } from '../lib/health-static-probes.js'
47
47
  import { getEarlyMeetingSyncSnapshot } from '../lib/g2-ops-handoff.js'
48
48
  import { getProgressiveHqSnapshot } from '../lib/meeting-batch-transcribe.js'
49
49
  import { getMeetingFinalizationSnapshot } from '../lib/meeting-finalization-jobs.js'
50
+ import { resolveMeetingLibrary } from '../lib/cos-operations-meetings.js'
50
51
 
51
52
  export const healthRouter = Router()
52
53
 
@@ -234,6 +235,7 @@ healthRouter.get('/health', async (_req, res) => {
234
235
  recovered: item.recovered,
235
236
  })),
236
237
  }
238
+ const meetingLibrary = resolveMeetingLibrary()
237
239
  res.json({
238
240
  ...checks,
239
241
  server_version: managedServerVersion(),
@@ -249,6 +251,11 @@ healthRouter.get('/health', async (_req, res) => {
249
251
  codex_models,
250
252
  cursor_models,
251
253
  meeting_sync,
254
+ meeting_library: {
255
+ layout: meetingLibrary.layout,
256
+ ready: meetingLibrary.layout !== 'invalid_explicit_root',
257
+ warningCount: meetingLibrary.warnings.length,
258
+ },
252
259
  unsaved_captures,
253
260
  chunk_embeddings: chunkEmbeddings,
254
261
  speaker_corrections: speakerCorrections,
@@ -112,6 +112,7 @@ import {
112
112
  import { getServerInstanceId } from '../lib/server-instance-id.js'
113
113
  import {
114
114
  cosOperationsMeetingsConfigured,
115
+ findDirectLibraryMeetingBySessionId,
115
116
  findCosOperationsMeetingBySessionId,
116
117
  resolveCosOperationsDir,
117
118
  } from '../lib/cos-operations-meetings.js'
@@ -156,6 +157,10 @@ function cosOpsPipelineConfigured(): boolean {
156
157
  return Boolean(process.env.COS_SCRIPTS_DIR?.trim())
157
158
  }
158
159
 
160
+ function requestedRecordMatches(requested: unknown, expected: string): boolean {
161
+ return requested == null || requested === '' || requested === expected
162
+ }
163
+
159
164
  interface MeetingSessionSource {
160
165
  getTranscript(sessionId: string): string | null
161
166
  getStartTime(sessionId: string): number | null
@@ -712,15 +717,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
712
717
  const operations = cosOperationsMeetingsConfigured()
713
718
  ? findCosOperationsMeetingBySessionId(sessionId)
714
719
  : null
715
- const saved = operations ? null : store.findBySessionId(sessionId)
716
- if (!operations && !saved) {
720
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
721
+ const saved = operations || direct ? null : store.findBySessionId(sessionId)
722
+ if (!operations && !direct && !saved) {
717
723
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
718
724
  return
719
725
  }
720
- const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
721
- const title = operations?.title ?? saved!.title
722
- const domain = operations?.domain ?? saved!.domain
723
- const filename = operations?.filename ?? saved!.filename
726
+ const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
727
+ const title = operations?.title ?? direct?.title ?? saved!.title
728
+ const domain = operations?.domain ?? direct?.domain ?? saved!.domain
729
+ const filename = operations?.filename ?? direct?.filename ?? saved!.filename
724
730
 
725
731
  let chunks: unknown
726
732
  try {
@@ -770,7 +776,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
770
776
  title,
771
777
  domain,
772
778
  filename,
773
- source: operations ? 'cos_operations' : 'standalone_recordings',
779
+ source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
780
+ recordId: operations
781
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
782
+ : direct?.recordId ?? `standalone:${sessionId}`,
783
+ mutable: direct == null,
774
784
  ...(saved ? { durationMin: saved.durationMin } : {}),
775
785
  ...review,
776
786
  })
@@ -812,14 +822,15 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
812
822
  const operations = cosOperationsMeetingsConfigured()
813
823
  ? findCosOperationsMeetingBySessionId(sessionId)
814
824
  : null
815
- const saved = operations ? null : store.findBySessionId(sessionId)
816
- if (!operations && !saved) {
825
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
826
+ const saved = operations || direct ? null : store.findBySessionId(sessionId)
827
+ if (!operations && !direct && !saved) {
817
828
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
818
829
  return
819
830
  }
820
- const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
821
- const title = operations?.title ?? saved!.title
822
- const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
831
+ const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
832
+ const title = operations?.title ?? direct?.title ?? saved!.title
833
+ const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
823
834
 
824
835
  let sidecar: Record<string, unknown>
825
836
  try {
@@ -909,7 +920,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
909
920
  // Which business this is. The sibling /speakers route has always carried
910
921
  // this and this one dropped it, so a personal 1:1 about someone's
911
922
  // compensation was byte-identical to a marketing sync.
912
- domain: operations?.domain ?? saved?.domain ?? '',
923
+ domain: operations?.domain ?? direct?.domain ?? saved?.domain ?? '',
913
924
  // Transcript actually captured, whether or not a write-up exists yet. 140
914
925
  // of 399 real sidecars have no .md, and the fallback text claimed there was
915
926
  // no transcript while holding one.
@@ -943,6 +954,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
943
954
  // no write-up yet.
944
955
  capturedChars: clip.capturedChars,
945
956
  domain: clip.domain,
957
+ source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
958
+ recordId: operations
959
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
960
+ : direct?.recordId ?? `standalone:${sessionId}`,
961
+ mutable: direct == null,
946
962
  // So the panel can warn above the write-up, not just the clipboard.
947
963
  removedNames: clip.removed,
948
964
  coverage,
@@ -990,11 +1006,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
990
1006
  const operations = cosOperationsMeetingsConfigured()
991
1007
  ? findCosOperationsMeetingBySessionId(sessionId)
992
1008
  : null
1009
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
1010
+ if (direct) {
1011
+ if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
1012
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1013
+ return
1014
+ }
1015
+ res.status(409).json({
1016
+ error: 'This meeting comes from a read-only library',
1017
+ reason: 'direct_library_read_only',
1018
+ recordId: direct.recordId,
1019
+ mutable: false,
1020
+ })
1021
+ return
1022
+ }
993
1023
  const saved = operations ? null : store.findBySessionId(sessionId)
994
1024
  if (!operations && !saved) {
995
1025
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
996
1026
  return
997
1027
  }
1028
+ const selectedRecordId = operations
1029
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
1030
+ : `standalone:${sessionId}`
1031
+ if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
1032
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1033
+ return
1034
+ }
998
1035
  const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
999
1036
  const meetingPath = operations?.meetingPath ?? saved!.filepath
1000
1037
  const title = operations?.title ?? saved!.title
@@ -1164,11 +1201,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1164
1201
  const operations = cosOperationsMeetingsConfigured()
1165
1202
  ? findCosOperationsMeetingBySessionId(sessionId)
1166
1203
  : null
1204
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
1205
+ if (direct) {
1206
+ if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
1207
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1208
+ return
1209
+ }
1210
+ res.status(409).json({
1211
+ error: 'This meeting comes from a read-only library',
1212
+ reason: 'direct_library_read_only',
1213
+ recordId: direct.recordId,
1214
+ mutable: false,
1215
+ })
1216
+ return
1217
+ }
1167
1218
  const saved = operations ? null : store.findBySessionId(sessionId)
1168
1219
  if (!operations && !saved) {
1169
1220
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
1170
1221
  return
1171
1222
  }
1223
+ const selectedRecordId = operations
1224
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
1225
+ : `standalone:${sessionId}`
1226
+ if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
1227
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1228
+ return
1229
+ }
1172
1230
  const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
1173
1231
 
1174
1232
  // Refuse to confirm a label the meeting does not actually carry. Otherwise
@@ -1242,11 +1300,32 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1242
1300
  const operations = cosOperationsMeetingsConfigured()
1243
1301
  ? findCosOperationsMeetingBySessionId(sessionId)
1244
1302
  : null
1303
+ const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
1304
+ if (direct) {
1305
+ if (!requestedRecordMatches(req.body?.recordId, direct.recordId || '')) {
1306
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1307
+ return
1308
+ }
1309
+ res.status(409).json({
1310
+ error: 'This meeting comes from a read-only library',
1311
+ reason: 'direct_library_read_only',
1312
+ recordId: direct.recordId,
1313
+ mutable: false,
1314
+ })
1315
+ return
1316
+ }
1245
1317
  const saved = operations ? null : store.findBySessionId(sessionId)
1246
1318
  if (!operations && !saved) {
1247
1319
  res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
1248
1320
  return
1249
1321
  }
1322
+ const selectedRecordId = operations
1323
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
1324
+ : `standalone:${sessionId}`
1325
+ if (!requestedRecordMatches(req.body?.recordId, selectedRecordId)) {
1326
+ res.status(409).json({ error: 'Meeting source changed; reopen the meeting', reason: 'record_source_mismatch' })
1327
+ return
1328
+ }
1250
1329
  const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
1251
1330
  const meetingPath = operations?.meetingPath ?? saved!.filepath
1252
1331
  const title = operations?.title ?? saved!.title
@@ -4,10 +4,44 @@ import { Router } from 'express'
4
4
  import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
5
5
  import {
6
6
  cosOperationsMeetingsConfigured,
7
+ getDirectLibraryMeetingDetail,
7
8
  getCosOperationsMeetingDetail,
9
+ listDirectLibraryMeetings,
8
10
  listCosOperationsMeetings,
9
- resolveCosOperationsDir,
11
+ resolveMeetingLibrary,
10
12
  } from '../lib/cos-operations-meetings.js'
13
+ import type { MeetingMeta } from '../lib/meeting-store.js'
14
+
15
+ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
16
+ return {
17
+ ...meeting,
18
+ librarySource: 'standalone_recordings',
19
+ recordId: `standalone:${meeting.sessionId || `${meeting.domain}:${meeting.month}:${meeting.filename}`}`,
20
+ mutable: true,
21
+ }
22
+ }
23
+
24
+ function mergeMeetingSources(groups: MeetingMeta[][], limit: number): MeetingMeta[] {
25
+ const seenSessions = new Set<string>()
26
+ const seenExact = new Set<string>()
27
+ const merged: MeetingMeta[] = []
28
+ for (const group of groups) {
29
+ for (const meeting of group) {
30
+ if (meeting.sessionId) {
31
+ if (seenSessions.has(meeting.sessionId)) continue
32
+ seenSessions.add(meeting.sessionId)
33
+ } else {
34
+ const exact = `${meeting.librarySource || ''}:${meeting.domain}:${meeting.month}:${meeting.filename}`
35
+ if (seenExact.has(exact)) continue
36
+ seenExact.add(exact)
37
+ }
38
+ merged.push(meeting)
39
+ }
40
+ }
41
+ merged.sort((a, b) => `${b.date}T${b.time || '00:00'}`.localeCompare(`${a.date}T${a.time || '00:00'}`)
42
+ || b.filename.localeCompare(a.filename))
43
+ return merged.slice(0, Math.min(Math.max(limit, 1), 50))
44
+ }
11
45
 
12
46
  export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
13
47
  const router = Router()
@@ -20,17 +54,54 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
20
54
  const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
21
55
  res.set('Cache-Control', 'private, no-store')
22
56
 
23
- if (cosOperationsMeetingsConfigured()) {
57
+ const library = resolveMeetingLibrary()
58
+ if (library.layout === 'invalid_explicit_root') {
59
+ res.status(409).json({
60
+ error: 'Configured meetings library is unavailable or malformed',
61
+ reason: 'invalid_explicit_root',
62
+ layout: library.layout,
63
+ warnings: library.warnings,
64
+ })
65
+ return
66
+ }
67
+
68
+ if (library.layout === 'direct') {
69
+ const operations = cosOperationsMeetingsConfigured()
70
+ ? listCosOperationsMeetings({ limit: 50, domain })
71
+ : []
72
+ const direct = domain === 'all' || domain === 'library'
73
+ ? listDirectLibraryMeetings({ limit: 50 })
74
+ : []
75
+ const standalone = store.list({ limit: 50, domain }).map(withStandaloneIdentity)
76
+ const meetings = mergeMeetingSources([operations, direct, standalone], limit)
77
+ res.json({
78
+ meetings,
79
+ source: operations.length > 0 ? 'mixed_library' : 'direct_library',
80
+ layout: 'direct',
81
+ root: library.root,
82
+ rootFingerprint: library.rootFingerprint,
83
+ meetingCount: meetings.length,
84
+ warnings: library.warnings,
85
+ })
86
+ return
87
+ }
88
+
89
+ if (library.layout === 'multi_domain') {
24
90
  const meetings = listCosOperationsMeetings({ limit, domain })
25
91
  res.json({
26
92
  meetings,
27
93
  source: 'cos_operations',
28
- operationsDir: resolveCosOperationsDir(),
94
+ layout: 'multi_domain',
95
+ root: library.root,
96
+ rootFingerprint: library.rootFingerprint,
97
+ meetingCount: meetings.length,
98
+ warnings: library.warnings,
29
99
  })
30
100
  return
31
101
  }
32
102
 
33
- res.json({ meetings: store.list({ limit, domain }), source: 'standalone_recordings' })
103
+ const meetings = store.list({ limit, domain }).map(withStandaloneIdentity)
104
+ res.json({ meetings, source: 'standalone_recordings', layout: 'standalone', meetingCount: meetings.length })
34
105
  } catch (error) {
35
106
  sendMeetingStoreError(res, error)
36
107
  }
@@ -49,6 +120,14 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
49
120
  }
50
121
  res.set('Cache-Control', 'private, no-store')
51
122
 
123
+ if (domain === 'library') {
124
+ const detail = getDirectLibraryMeetingDetail(month, filename)
125
+ if (detail) {
126
+ res.json(detail)
127
+ return
128
+ }
129
+ }
130
+
52
131
  if (cosOperationsMeetingsConfigured()) {
53
132
  const detail = getCosOperationsMeetingDetail(domain, month, filename)
54
133
  if (detail) {
@@ -71,6 +150,14 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
71
150
  try {
72
151
  res.set('Cache-Control', 'private, no-store')
73
152
 
153
+ if (req.params.domain === 'library') {
154
+ const detail = getDirectLibraryMeetingDetail(req.params.month, req.params.filename)
155
+ if (detail) {
156
+ res.json(detail)
157
+ return
158
+ }
159
+ }
160
+
74
161
  if (cosOperationsMeetingsConfigured()) {
75
162
  const detail = getCosOperationsMeetingDetail(
76
163
  req.params.domain,