@gotcos/glasses-server 6.46.0 → 6.47.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## 6.47.0
2
+
3
+ Meetings from Fireflies join the library, and recordings of the same meeting merge into one richer record. Paired Control build: 0.5.230. Glasses 6.9.474 adds Ref on the lens and needs no server change.
4
+
5
+ - Fireflies import, on Macs without the COS pipeline. Paste a Fireflies API key in COS Control; the server checks it and reports a typed state (ok, invalid key, rate limited, vendor down, unreachable, no key). Imports arrive through the Fireflies API only, never from the desktop app's files, with a 7, 30 or 90 day backfill that resumes across the plan's daily limit, a 6 hour poll that uses at most half of the day's budget, and a ledger that reconciles what the vendor listed against what was written. Every failure has its own state; a run that imported nothing and failed reads as the failure. Imported meetings carry Fireflies' overview and action items.
6
+ - A deterministic merge engine, on a worker thread. It pairs a G2 recording with the Fireflies meeting that shares the most unique three-word phrases at a consistent time offset, refuses a pairing whose clocks disagree by more than 15 minutes, collapses duplicate recordings of one meeting, and groups every G2 recording of a meeting into one merged record. No language model is involved anywhere in import, pairing, alignment, splitting or merging.
7
+ - Merges are additive. A merged record holds the Fireflies transcript, the full G2 capture, any alternate transcript, the union of attendees, the vendor summary and action items, and a Sources table. Where the recordings align, G2 voice matches label generic Fireflies speakers, shown with their score, and the original label is kept. Source files are never modified.
8
+ - Every automatic action can be undone. High confidence merges apply automatically with Undo; an undone merge is never repeated by automation. Lower confidence pairings become suggestions to confirm or dismiss. Recordings made before the engine was installed only ever become suggestions. Content-anchored splits of long recordings apply automatically with Revert on a Mac WITHOUT the COS pipeline, where a split writes new records; on a Mac with the pipeline a split is only ever a suggestion, because there is no additive, revertible way to turn one operations scribe into three, and accepting one in apply mode is refused with `split_not_supported_in_apply_mode`.
9
+ - Macs with the COS pipeline start in advise mode: the engine shows would-merge items and suggestions and writes nothing into the operations tree. Switching to apply in Control hands each decision to sync_meetings.py, which archives the original to a unique verified path, splices the merge into the existing scribe (title, summary, decisions and action items survive), and can revert byte for byte. One mode file is read by every pipeline process; there is no environment flag.
10
+ - Lists, detail, search, day counts and speaker review understand imported and merged records. A merged row replaces the rows it merged; its sources stay reachable from its detail. Imported and merged records are read only for speaker mutations, and never train voice profiles.
11
+ - New routes: fireflies-key set, status, check and delete; meeting-import fireflies run, status and settings; meeting-suggestions list, accept, confirm and dismiss; meeting-actions list, one by id, revert, retry and revert-all; meeting-engine status and mode. A failed merge or undo is not terminal: retry puts it back in its own direction with a fresh attempt budget. Every suggestion carries both sides resolved server-side (title, start, length, source and the record that still holds each one), so a pipeline Mac can read them. The contract is in docs/meeting-merge-contract.md.
12
+ - Compatibility: COS Control 0.5.229 and older show these features as unavailable and keep everything else. Glasses 6.9.473 lists merged meetings under an older source label. Older servers ignore the imports folder entirely. Rollback: run revert-all first if merged records should not remain, then set the mode back to advise before downgrading.
13
+
14
+ ## 6.46.1
15
+
16
+ G2 recordings show in a multi-folder Meetings library that has no COS pipeline to file them. No Control or glasses update is needed.
17
+
18
+ - A Meetings library on a multi-folder operations tree (COS_OPERATIONS_DIR), on a Mac without a working COS pipeline (no COS_SCRIPTS_DIR, or no venv Python or sync_meetings.py in it), listed only that tree. G2 recordings saved there never reach it, so Control's Meetings window showed an empty month while the recordings sat in the server's own recordings store. GET /api/meetings now lists them beside the operations rows, with their months and day counts.
19
+ - A recording that also reached the operations tree is listed once, as the operations copy matched by the session id in its sidecar, and its day is counted once.
20
+ - Where the COS pipeline can run, the list is unchanged: sync_meetings.py decides which recordings join the operations tree. Search and meeting detail already read the recordings store in every layout.
21
+
1
22
  ## 6.46.0
2
23
 
3
24
  Name held voice samples and preview the meeting labels they will change. Paired Control build: 0.5.223.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.46.0",
3
+ "version": "6.47.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "start": "node bin/cli.cjs",
13
13
  "start:server": "node --import tsx/esm server/index.ts",
14
14
  "test": "vitest run --maxWorkers=1",
15
- "typecheck": "tsc --noEmit"
15
+ "typecheck": "tsc --noEmit",
16
+ "mutation:gate": "node server/scripts/mutation-gate.mjs"
16
17
  },
17
18
  "keywords": [
18
19
  "even-g2",
@@ -36,6 +37,9 @@
36
37
  "server/models/silero_vad.onnx",
37
38
  "shared",
38
39
  "!server/**/*.test.ts",
40
+ "!server/scripts/engine-canary.ts",
41
+ "!server/scripts/mutation-gate.mjs",
42
+ "!server/lib/meeting-engine/__fixtures__/**",
39
43
  "!shared/**/*.test.ts",
40
44
  ".env.example",
41
45
  ".cos-profile.example.json",
package/server/index.ts CHANGED
@@ -43,6 +43,20 @@ import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.j
43
43
  import { meetingsRouter } from './routes/meetings.js'
44
44
  import { openaiCompatRouter } from './routes/openai-compat.js'
45
45
  import { openaiKeyRouter } from './routes/openai-key.js'
46
+ import { firefliesKeyRouter } from './routes/fireflies-key.js'
47
+ import { meetingImportRouter } from './routes/meeting-import.js'
48
+ import { startMeetingImportScheduler, stopMeetingImportScheduler } from './lib/meeting-import.js'
49
+ import { meetingSuggestionsRouter } from './routes/meeting-suggestions.js'
50
+ import { meetingActionsRouter } from './routes/meeting-actions.js'
51
+ import { meetingEngineRouter } from './routes/meeting-engine.js'
52
+ import {
53
+ redrivePendingMergeActions,
54
+ sweepOrphanDecisions,
55
+ startMeetingMergeScheduler,
56
+ stopMeetingMergeScheduler,
57
+ } from './lib/meeting-actions.js'
58
+ import { getImportedMeetingLibrary } from './lib/imported-meeting-library.js'
59
+ import { maintenanceLifecycle } from './lib/maintenance-lifecycle.js'
46
60
  import { messageRefRouter } from './routes/message-ref.js'
47
61
  import { archiveRouter } from './routes/archive.js'
48
62
  import { sessionsRouter } from './routes/sessions.js'
@@ -220,6 +234,18 @@ app.use('/api', (req, res, next) => {
220
234
  || req.path === '/transcribe'
221
235
  || req.path.startsWith('/transcribe-stream')
222
236
  || req.path === '/meeting/save'
237
+ // The import run answers 202 and keeps working. It takes its own
238
+ // maintenance lease per page and checks admissions itself, so the global
239
+ // request lease would only double-own the same work.
240
+ || req.path === '/meeting-import/fireflies/run'
241
+ // The suggestion and action mutations queue onto the merge runner, which takes its
242
+ // own `meeting_merge` lease PER ACTION and catches a drain by deferring that action.
243
+ // The global request lease would double-own the same work, and in apply mode it
244
+ // would hold across a 75s pipeline spawn — past COS Control's 90s drain timeout.
245
+ // Each of these still refuses on its own when admissions are closed.
246
+ || req.path.startsWith('/meeting-suggestions/')
247
+ || req.path.startsWith('/meeting-actions/')
248
+ || req.path === '/meeting-engine/mode'
223
249
  || req.path.startsWith('/prompt-drafts')
224
250
  || req.path.startsWith('/maintenance/drain')
225
251
  if (lifecycleOwned) return next()
@@ -675,6 +701,13 @@ app.use('/api', memoryRouter)
675
701
  app.use('/api', threadsRouter)
676
702
  app.use('/api', skillsRouter)
677
703
  app.use('/api', openaiKeyRouter)
704
+ // Fireflies: the key, and importing meetings this Mac did not record (6.47.0).
705
+ app.use('/api', firefliesKeyRouter)
706
+ app.use('/api', meetingImportRouter)
707
+ // The merge engine: what it wants decided, what it did, and its own state (6.47.0).
708
+ app.use('/api', meetingSuggestionsRouter)
709
+ app.use('/api', meetingActionsRouter)
710
+ app.use('/api', meetingEngineRouter)
678
711
  // v6.3.0 — Message History, cross-day 'reference message N', and history
679
712
  // recovery for public npx users (previously full-COS-server only).
680
713
  app.use('/api', messageRefRouter)
@@ -725,6 +758,8 @@ async function gracefulShutdown(): Promise<void> {
725
758
  const forceExit = setTimeout(() => process.exit(1), 8_000)
726
759
  forceExit.unref?.()
727
760
  stopMorningBriefScheduler()
761
+ stopMeetingImportScheduler()
762
+ stopMeetingMergeScheduler()
728
763
  try {
729
764
  await shutdownQueryJobRuntime('server_shutdown')
730
765
  } catch (error) {
@@ -915,6 +950,47 @@ listenRequiredServers(listeners).then(() => {
915
950
  // was interrupted by a prior server update or process exit.
916
951
  resumeMeetingFinalizationJobs()
917
952
 
953
+ // Debris from an import killed mid-write: a temp file, or a sidecar whose
954
+ // markdown never landed. Both are invisible to every reader, so nothing
955
+ // would ever surface them. Skipped while a writer holds the lease, because
956
+ // mid-write that sidecar is the correct intermediate state.
957
+ try {
958
+ const swept = getImportedMeetingLibrary().sweep({
959
+ isBusy: () => (maintenanceLifecycle.snapshot().activeByKind.meeting_import ?? 0) > 0,
960
+ })
961
+ if (swept.removedTemp > 0 || swept.removedSidecars > 0) {
962
+ console.log(`[imports] startup sweep: ${swept.removedTemp} temp file(s), ${swept.removedSidecars} orphan sidecar(s)`)
963
+ }
964
+ } catch (error) {
965
+ console.warn('[imports] startup sweep failed', error)
966
+ }
967
+
968
+ // The Fireflies poll. Its own tick refuses in advise mode, without a key,
969
+ // while admissions are closed, and past its share of the daily budget, so
970
+ // starting it here is safe in every configuration.
971
+ startMeetingImportScheduler()
972
+
973
+ // The merge engine's own 30s tick (a full pass every six hours), and the
974
+ // central speaker-correction hook that re-derives affected records. Both
975
+ // defer while a capture is live or admissions are closed.
976
+ startMeetingMergeScheduler()
977
+
978
+ // AFTER the imports sweep, not before: the sweep removes a killed write's
979
+ // debris, and re-driving a pending apply into a half-written decision is
980
+ // exactly what the sweep exists to prevent. A pending action is one a
981
+ // previous process wrote and did not finish; the pipeline steps are
982
+ // idempotent, so re-driving one that actually completed is a no-op.
983
+ void redrivePendingMergeActions()
984
+ .then(count => { if (count > 0) console.log(`[meeting-merge] re-drove ${count} waiting action(s)`) })
985
+ .then(() => {
986
+ // A decision file is written BEFORE its action row, so a crash in that window
987
+ // leaves transcript text nothing will ever read or delete. Runs after the
988
+ // re-drive, so an action being driven right now still owns its decision.
989
+ const removed = sweepOrphanDecisions()
990
+ if (removed > 0) console.log(`[meeting-merge] removed ${removed} orphan decision file(s)`)
991
+ })
992
+ .catch(error => console.warn('[meeting-merge] pending re-drive failed', error))
993
+
918
994
  void initQueryJobRuntime().then(health => {
919
995
  if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
920
996
  console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
@@ -182,6 +182,37 @@ export function sidecarSessionId(monthDir: string, meetingFilename: string): str
182
182
  return sidecarListHints(monthDir, meetingFilename).sessionId
183
183
  }
184
184
 
185
+ // ── Merged-scribe markers (6.47.0, apply mode) ───────────────────────────────
186
+ //
187
+ // On a Mac with the COS pipeline the merge is spliced INTO the existing Fireflies
188
+ // scribe rather than written as a new record, so there is no `.derived.json` to
189
+ // read and the scribe itself has to say what it holds. The pipeline writes four
190
+ // markers (WS8 step 4); these two are the ones a row identity needs.
191
+ //
192
+ // WHY THE SCRIBE AND NOT A SIDECAR. A merged scribe replaced a Fireflies scribe,
193
+ // which never had a `.g2-chunks.json`. Its G2 sidecar stays beside the retired
194
+ // standalone capture, so the only place that says "this file holds session X" is
195
+ // the file.
196
+
197
+ /** `<!-- g2-session: <sessionId> -->`, once per G2 capture the scribe holds. */
198
+ export const G2_SESSION_MARKER = /<!--\s*g2-session:\s*([A-Za-z0-9:_-]{3,96})\s*-->/g
199
+ /** `<!-- merge-action: <actionId> -->`, so Control can offer the merge's Undo. */
200
+ export const MERGE_ACTION_MARKER = /<!--\s*merge-action:\s*([A-Za-z0-9_-]{1,64})\s*-->/
201
+
202
+ /** Every session a merged scribe declares, in the order it declares them. */
203
+ export function mergedScribeSessions(content: string): string[] {
204
+ const sessions: string[] = []
205
+ for (const match of content.matchAll(G2_SESSION_MARKER)) {
206
+ if (!sessions.includes(match[1])) sessions.push(match[1])
207
+ }
208
+ return sessions
209
+ }
210
+
211
+ /** The action a merged scribe was written by, when it declares one. */
212
+ export function mergedScribeActionId(content: string): string | undefined {
213
+ return content.match(MERGE_ACTION_MARKER)?.[1]
214
+ }
215
+
185
216
  function envPath(name: string): string | null {
186
217
  const raw = process.env[name]?.trim()
187
218
  if (!raw) return null
@@ -439,6 +470,14 @@ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string
439
470
  *
440
471
  * Scans sidecar heads rather than parsing them, so the cost is a 4 KB read per
441
472
  * candidate and not the transcript.
473
+ *
474
+ * A MERGED SCRIBE OUTRANKS AN ORPHAN SIDECAR (6.47.0). Applying a merge splices
475
+ * the capture into the Fireflies scribe and then RETIRES the G2 standalone
476
+ * scribe, leaving its `.g2-chunks.json` behind with no markdown beside it. The
477
+ * chunks are still the evidence — that sidecar is what the speaker review reads —
478
+ * but the readable meeting is now the merged scribe, so this returns the orphan's
479
+ * sidecar path with the merged scribe's path, filename and title. Without that,
480
+ * naming a voice on a merged meeting opened a file that no longer existed.
442
481
  */
443
482
  export function findCosOperationsMeetingBySessionId(sessionId: string): {
444
483
  sidecarPath: string
@@ -468,17 +507,29 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
468
507
  for (const sidecarName of sidecars) {
469
508
  const meetingFilename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
470
509
  if (sidecarSessionId(monthDir, meetingFilename) !== sessionId) continue
471
- const meetingPath = join(monthDir, meetingFilename)
472
- let title = meetingFilename.replace(/\.md$/, '')
510
+ const sidecarPath = join(monthDir, sidecarName)
511
+ let meetingPath = join(monthDir, meetingFilename)
512
+ let resolvedFilename = meetingFilename
513
+ let content: string | null = null
473
514
  try {
474
- const head = readFileSync(meetingPath, 'utf-8').slice(0, 4000)
475
- const heading = head.match(/^#\s+(.+)$/m)?.[1]?.trim()
476
- if (heading) title = heading
477
- } catch { /* fall back to the filename stem */ }
515
+ content = readFileSync(meetingPath, 'utf-8')
516
+ } catch {
517
+ // The capture's own scribe is gone. Only now is it worth reading the
518
+ // month's other scribes to find the one that declares this session.
519
+ const merged = findMergedScribeForSession(monthDir, sessionId)
520
+ if (merged) {
521
+ resolvedFilename = merged.filename
522
+ meetingPath = join(monthDir, merged.filename)
523
+ content = merged.content
524
+ }
525
+ }
526
+ let title = resolvedFilename.replace(/\.md$/, '')
527
+ const heading = content?.slice(0, 4000).match(/^#\s+(.+)$/m)?.[1]?.trim()
528
+ if (heading) title = heading
478
529
  return {
479
- sidecarPath: join(monthDir, sidecarName),
530
+ sidecarPath,
480
531
  meetingPath,
481
- filename: meetingFilename,
532
+ filename: resolvedFilename,
482
533
  domain,
483
534
  month,
484
535
  title,
@@ -489,6 +540,32 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
489
540
  return null
490
541
  }
491
542
 
543
+ /**
544
+ * The scribe in this month that declares `sessionId` through `<!-- g2-session -->`.
545
+ *
546
+ * Only ever called when a capture's own scribe is missing, so the full-content
547
+ * read it costs lands on a path that was about to answer with a dead file.
548
+ */
549
+ function findMergedScribeForSession(
550
+ monthDir: string,
551
+ sessionId: string,
552
+ ): { filename: string; content: string } | null {
553
+ let names: string[]
554
+ try {
555
+ names = readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()
556
+ } catch {
557
+ return null
558
+ }
559
+ for (const filename of names.slice(0, MAX_LIST_CANDIDATES)) {
560
+ let content: string
561
+ try {
562
+ content = readFileSync(join(monthDir, filename), 'utf-8')
563
+ } catch { continue }
564
+ if (mergedScribeSessions(content).includes(sessionId)) return { filename, content }
565
+ }
566
+ return null
567
+ }
568
+
492
569
  export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
493
570
  recordId?: string
494
571
  librarySource?: 'direct_library' | 'cos_operations'
@@ -588,6 +665,20 @@ export function listCosOperationsMeetings(options: {
588
665
  if (hints.speakers.length > 0) {
589
666
  meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
590
667
  }
668
+ // A scribe the pipeline merged says so in its own body: it holds
669
+ // captures whose standalone rows this list must then drop, and it
670
+ // names the action Control offers Undo for. The content is already
671
+ // in memory, so this costs no extra read. The sidecar wins for
672
+ // `sessionId` when both are present — the sidecar is the file the
673
+ // speaker system keys on.
674
+ const declared = mergedScribeSessions(content)
675
+ if (declared.length > 0) {
676
+ meta.derivedKind = 'merge'
677
+ meta.g2SessionIds = declared
678
+ if (!meta.sessionId) meta.sessionId = declared[0]
679
+ const actionId = mergedScribeActionId(content)
680
+ if (actionId) meta.actionId = actionId
681
+ }
591
682
  if (options.day && meta.date !== options.day) continue
592
683
  allMeetings.push(meta)
593
684
  } catch { /* skip unreadable files */ }