@gotcos/glasses-server 6.46.1 → 6.48.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 (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
@@ -7,10 +7,8 @@ import {
7
7
  getDirectLibraryMeetingDetail,
8
8
  getCosOperationsMeetingDetail,
9
9
  listDirectLibraryMeetings,
10
- listDirectLibraryMeetingDays,
11
10
  listDirectLibraryMeetingMonths,
12
11
  listCosOperationsMeetings,
13
- listCosOperationsMeetingDays,
14
12
  listCosOperationsMeetingMonths,
15
13
  resolveMeetingLibrary,
16
14
  } from '../lib/cos-operations-meetings.js'
@@ -18,10 +16,38 @@ import type { MeetingMeta } from '../lib/meeting-store.js'
18
16
  import { meetingListLimit } from '../lib/meeting-store.js'
19
17
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
20
18
  import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
19
+ import {
20
+ type ImportedMeetingLibrary,
21
+ IMPORTED_DOMAIN,
22
+ getImportedMeetingLibrary,
23
+ } from '../lib/imported-meeting-library.js'
24
+ import {
25
+ type DerivedRecordSummary,
26
+ derivedSourcesFor,
27
+ dropSupersededRows,
28
+ importedLibraryMonths,
29
+ listImportedLibraryRows,
30
+ readDerivedRecords,
31
+ supersededDayCounts,
32
+ supersededFromRows,
33
+ supersededInputsOf,
34
+ withDerivedIdentity,
35
+ } from '../lib/imported-library-rows.js'
21
36
 
22
37
  const MONTH_QUERY = /^\d{4}-(0[1-9]|1[0-2])$/
23
38
  const DAY_QUERY = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
24
39
 
40
+ /**
41
+ * The only filenames the imported branch of detail will answer for.
42
+ *
43
+ * Deliberately stricter than "is it in the imports folder": a G2 recording whose
44
+ * TITLE contains the word "merged" produces a store filename that must keep
45
+ * resolving through the store, and a hand-dropped or iCloud-conflicted file in
46
+ * the imports root must not be served as a record. The branch falls through on a
47
+ * miss rather than 404ing, so a near-miss is answered by the next source.
48
+ */
49
+ export const IMPORTED_DETAIL_FILENAME = /^\d{4}-\d{2}-\d{2}_(?:fireflies|merged|piece)_[0-9a-f]{16}\.md$/
50
+
25
51
  function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
26
52
  return {
27
53
  ...meeting,
@@ -74,29 +100,78 @@ function parseListFilters(query: { month?: unknown; day?: unknown }): {
74
100
  return { month, day }
75
101
  }
76
102
 
77
- function mergeDayCounts(
78
- groups: Array<Array<{ date: string; count: number }>>,
79
- ): Array<{ date: string; count: number }> {
80
- const counts = new Map<string, number>()
81
- for (const group of groups) {
82
- for (const { date, count } of group) {
83
- counts.set(date, (counts.get(date) ?? 0) + count)
84
- }
85
- }
86
- return [...counts.entries()]
87
- .sort((a, b) => a[0].localeCompare(b[0]))
88
- .map(([date, count]) => ({ date, count }))
103
+ function uniqueSortedMonths(groups: string[][]): string[] {
104
+ return [...new Set(groups.flat())].sort().reverse()
89
105
  }
90
106
 
91
- function dayCountsOf(rows: MeetingMeta[]): Array<{ date: string; count: number }> {
92
- return mergeDayCounts([rows.map(row => ({ date: row.date, count: 1 }))])
107
+ /**
108
+ * Detail for one record of the imported library, or null to fall through.
109
+ *
110
+ * NULL, NEVER 404. The three detail sources share one URL shape, and a record
111
+ * that is not here may still be an operations meeting or a store recording, so a
112
+ * miss has to hand the request on. The two gates — the routing domain and the
113
+ * strict filename — are what keep an ordinary meeting out of this branch.
114
+ *
115
+ * A merged record answers with `sources[]`, each input naming the record that
116
+ * still holds it. Nothing is read from the client: the record's own sidecar is
117
+ * the only thing consulted.
118
+ */
119
+ function importedMeetingDetail(
120
+ domain: string,
121
+ month: string,
122
+ filename: string,
123
+ store: MeetingStore,
124
+ library: ImportedMeetingLibrary,
125
+ ): MeetingMeta | null {
126
+ if (domain !== IMPORTED_DOMAIN) return null
127
+ if (!IMPORTED_DETAIL_FILENAME.test(filename)) return null
128
+ const detail = library.detail(month, filename)
129
+ if (!detail) return null
130
+ if (detail.librarySource !== 'blended') return detail as MeetingMeta
131
+ const record = readDerivedRecords(library).find(entry => entry.recordId === detail.recordId)
132
+ if (!record) return detail as MeetingMeta
133
+ return {
134
+ ...withDerivedIdentity(detail as MeetingMeta, record),
135
+ sources: derivedSourcesFor(record, store),
136
+ }
93
137
  }
94
138
 
95
- function uniqueSortedMonths(groups: string[][]): string[] {
96
- return [...new Set(groups.flat())].sort().reverse()
139
+ /**
140
+ * The imported library's contribution to one list request.
141
+ *
142
+ * Read ONCE per request: the derived sidecars decide both what the derived rows
143
+ * say about themselves and which G2, import and long-original rows they have
144
+ * taken over, and reading them twice would let a write between the two reads
145
+ * show a meeting and its merged replacement side by side.
146
+ */
147
+ function importedContribution(
148
+ options: { limit: number; domain: string; month?: string; day?: string },
149
+ library: ImportedMeetingLibrary,
150
+ ): {
151
+ imports: MeetingMeta[]
152
+ derived: MeetingMeta[]
153
+ records: DerivedRecordSummary[]
154
+ drop: (rows: MeetingMeta[]) => MeetingMeta[]
155
+ present: boolean
156
+ } {
157
+ const records = readDerivedRecords(library)
158
+ const { imports, derived } = listImportedLibraryRows(options, library, records)
159
+ const superseded = supersededInputsOf(records)
160
+ return {
161
+ imports,
162
+ derived,
163
+ records,
164
+ drop: rows => dropSupersededRows(rows, superseded),
165
+ present: imports.length > 0 || derived.length > 0,
166
+ }
97
167
  }
98
168
 
99
- export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
169
+ export function createMeetingsRouter(
170
+ store: MeetingStore = getMeetingStore(),
171
+ // Injectable so a test can drive a whole imports library without reaching the
172
+ // process-wide data home. Production passes nothing and gets the singleton.
173
+ importsLibrary: ImportedMeetingLibrary = getImportedMeetingLibrary(),
174
+ ): Router {
100
175
  const router = Router()
101
176
 
102
177
  // GET /api/meetings?limit=20&domain=all
@@ -131,6 +206,7 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
131
206
  const listOptions = { limit: sourceLimit, domain, month: filters.month, day: filters.day }
132
207
 
133
208
  if (library.layout === 'direct') {
209
+ const imported = importedContribution(listOptions, importsLibrary)
134
210
  const operations = cosOperationsMeetingsConfigured()
135
211
  ? listCosOperationsMeetings(listOptions)
136
212
  : []
@@ -138,24 +214,30 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
138
214
  ? listDirectLibraryMeetings({ limit: sourceLimit, month: filters.month, day: filters.day })
139
215
  : []
140
216
  const standalone = store.list(listOptions).map(withStandaloneIdentity)
141
- const meetings = mergeMeetingSources([operations, direct, standalone], limit, sourceLimit)
217
+ // Derived rows lead. They carry the sessionId of the earliest capture they
218
+ // hold, so leading makes a merged record win that session outright even if
219
+ // its sidecar became unreadable and the supersession filter went quiet.
220
+ const meetings = mergeMeetingSources([
221
+ imported.derived,
222
+ imported.drop(operations),
223
+ imported.drop(direct),
224
+ imported.drop(standalone),
225
+ imported.drop(imported.imports),
226
+ ], limit, sourceLimit)
142
227
  const months = uniqueSortedMonths([
143
228
  ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingMonths(domain)] : []),
144
229
  ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingMonths()] : []),
145
230
  store.listMonths(),
231
+ importedLibraryMonths(importsLibrary),
146
232
  ])
147
233
  const days = filters.month
148
- ? mergeDayCounts([
149
- ...(cosOperationsMeetingsConfigured() ? [listCosOperationsMeetingDays(filters.month, domain)] : []),
150
- ...(domain === 'all' || domain === 'library' ? [listDirectLibraryMeetingDays(filters.month)] : []),
151
- store.listDayCounts(filters.month),
152
- ])
234
+ ? supersededDayCounts(filters.month, 'direct', { domain, store, library: importsLibrary })
153
235
  : []
154
236
  res.json({
155
237
  meetings,
156
238
  months,
157
239
  days,
158
- source: operations.length > 0 ? 'mixed_library' : 'direct_library',
240
+ source: operations.length > 0 || imported.present ? 'mixed_library' : 'direct_library',
159
241
  layout: 'direct',
160
242
  root: library.root,
161
243
  rootFingerprint: library.rootFingerprint,
@@ -167,16 +249,38 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
167
249
 
168
250
  if (library.layout === 'multi_domain') {
169
251
  if (g2RecordingsReachOperations()) {
170
- const meetings = listCosOperationsMeetings({
252
+ // The pipeline owns this tree, so the tree is the whole library: no
253
+ // imports, no derived records from `data/imports`. A merge here was
254
+ // spliced into the Fireflies scribe, which declares the captures it
255
+ // holds, so the capture's own row is dropped from its own tree.
256
+ const rows = listCosOperationsMeetings({
171
257
  limit,
172
258
  domain,
173
259
  month: filters.month,
174
260
  day: filters.day,
175
261
  })
262
+ const declaredSessions = supersededFromRows(rows).g2Sessions
263
+ const meetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
264
+ // ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
265
+ // list knows about fewer merged scribes than the month holds, so handing its
266
+ // sessions to an UNCAPPED day count would subtract too little and put the dot
267
+ // back above the row. A capped page pays for the scan instead.
268
+ const listSawWholeMonth = rows.length < limit
176
269
  res.json({
177
270
  meetings,
178
271
  months: listCosOperationsMeetingMonths(domain),
179
- days: filters.month ? listCosOperationsMeetingDays(filters.month, domain) : [],
272
+ // The rows above already read every scribe in this month and told us which
273
+ // sessions the merged ones hold. Handing that over is the difference between
274
+ // one read per file and two on every month request.
275
+ days: filters.month
276
+ ? supersededDayCounts(filters.month, 'multi_domain', {
277
+ domain,
278
+ store,
279
+ library: importsLibrary,
280
+ pipeline: true,
281
+ ...(listSawWholeMonth ? { declaredSessions } : {}),
282
+ })
283
+ : [],
180
284
  source: 'cos_operations',
181
285
  layout: 'multi_domain',
182
286
  root: library.root,
@@ -191,28 +295,27 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
191
295
  // without COS_SCRIPTS_DIR, 2026-09-14), so they live only in this server's store. List them beside the
192
296
  // operations rows. Operations rows go first, so a copy that did reach operations wins by sessionId, and
193
297
  // the day counts skip the store row it covers. Day counts always describe the whole month.
298
+ const imported = importedContribution(listOptions, importsLibrary)
194
299
  const operations = listCosOperationsMeetings(listOptions)
195
300
  const standalone = store.list(listOptions).map(withStandaloneIdentity)
196
- const meetings = mergeMeetingSources([operations, standalone], limit, sourceLimit)
197
- let days: Array<{ date: string; count: number }> = []
198
- if (filters.month) {
199
- const monthOperations = filters.day
200
- ? listCosOperationsMeetings({ limit: sourceLimit, domain, month: filters.month })
201
- : operations
202
- const monthStore = filters.day
203
- ? store.list({ limit: sourceLimit, domain, month: filters.month })
204
- : standalone
205
- const covered = new Set(monthOperations.flatMap(row => (row.sessionId ? [row.sessionId] : [])))
206
- days = mergeDayCounts([
207
- listCosOperationsMeetingDays(filters.month, domain),
208
- dayCountsOf(monthStore.filter(row => !row.sessionId || !covered.has(row.sessionId))),
209
- ])
210
- }
301
+ const meetings = mergeMeetingSources([
302
+ imported.derived,
303
+ imported.drop(operations),
304
+ imported.drop(standalone),
305
+ imported.drop(imported.imports),
306
+ ], limit, sourceLimit)
307
+ const days = filters.month
308
+ ? supersededDayCounts(filters.month, 'multi_domain', { domain, store, library: importsLibrary, pipeline: false })
309
+ : []
211
310
  res.json({
212
311
  meetings,
213
- months: uniqueSortedMonths([listCosOperationsMeetingMonths(domain), store.listMonths()]),
312
+ months: uniqueSortedMonths([
313
+ listCosOperationsMeetingMonths(domain),
314
+ store.listMonths(),
315
+ importedLibraryMonths(importsLibrary),
316
+ ]),
214
317
  days,
215
- source: standalone.length > 0 ? 'mixed_library' : 'cos_operations',
318
+ source: standalone.length > 0 || imported.present ? 'mixed_library' : 'cos_operations',
216
319
  layout: 'multi_domain',
217
320
  root: library.root,
218
321
  rootFingerprint: library.rootFingerprint,
@@ -222,17 +325,23 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
222
325
  return
223
326
  }
224
327
 
225
- const meetings = store.list({
226
- limit,
328
+ const imported = importedContribution(listOptions, importsLibrary)
329
+ const standalone = store.list({
330
+ limit: sourceLimit,
227
331
  domain,
228
332
  month: filters.month,
229
333
  day: filters.day,
230
334
  }).map(withStandaloneIdentity)
335
+ const meetings = mergeMeetingSources([
336
+ imported.derived,
337
+ imported.drop(standalone),
338
+ imported.drop(imported.imports),
339
+ ], limit, sourceLimit)
231
340
  res.json({
232
341
  meetings,
233
- months: store.listMonths(),
234
- days: filters.month ? store.listDayCounts(filters.month) : [],
235
- source: 'standalone_recordings',
342
+ months: uniqueSortedMonths([store.listMonths(), importedLibraryMonths(importsLibrary)]),
343
+ days: filters.month ? supersededDayCounts(filters.month, 'standalone', { domain, store, library: importsLibrary }) : [],
344
+ source: imported.present ? 'mixed_library' : 'standalone_recordings',
236
345
  layout: 'standalone',
237
346
  meetingCount: meetings.length,
238
347
  })
@@ -284,6 +393,12 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
284
393
  }
285
394
  }
286
395
 
396
+ const imported = importedMeetingDetail(domain, month, filename, store, importsLibrary)
397
+ if (imported) {
398
+ res.json(imported)
399
+ return
400
+ }
401
+
287
402
  if (cosOperationsMeetingsConfigured()) {
288
403
  const detail = getCosOperationsMeetingDetail(domain, month, filename)
289
404
  if (detail) {
@@ -314,6 +429,18 @@ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): R
314
429
  }
315
430
  }
316
431
 
432
+ const imported = importedMeetingDetail(
433
+ req.params.domain,
434
+ req.params.month,
435
+ req.params.filename,
436
+ store,
437
+ importsLibrary,
438
+ )
439
+ if (imported) {
440
+ res.json(imported)
441
+ return
442
+ }
443
+
317
444
  if (cosOperationsMeetingsConfigured()) {
318
445
  const detail = getCosOperationsMeetingDetail(
319
446
  req.params.domain,
@@ -0,0 +1,70 @@
1
+ // Session hooks: status, install, uninstall, and today's runs.
2
+ //
3
+ // `GET /api/session-hooks/status` is what Control's banner keys off. Five outcomes on
4
+ // the client side and this route owns three of them: 200 with `installed` (no banner),
5
+ // 200 with `drift`, `missing`, `script_outdated`, `settings_*` (banner with Install). A
6
+ // 404 means a server older than 6.48.0 (`route_absent`), and no answer at all is
7
+ // `unreachable`; neither is "not installed", and Control must never say so for them.
8
+
9
+ import { Router } from 'express'
10
+ import { installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
11
+ import { deskIdleSeconds, invalidateHookStatus, sessionHooksHealthFields, sessionSignalStore } from '../lib/session-hooks-runtime.js'
12
+ import { workspaceFromCwd } from '../lib/claude-session-registry.js'
13
+
14
+ export function createSessionHooksRouter(options: { port: number }): Router {
15
+ const router = Router()
16
+
17
+ router.get('/session-hooks/status', (_req, res) => {
18
+ res.set('Cache-Control', 'private, no-store')
19
+ res.json({ ok: true, ...sessionHooksHealthFields() })
20
+ })
21
+
22
+ router.post('/session-hooks/install', (req, res) => {
23
+ const dryRun = req.query.dryRun === '1' || (req.body && typeof req.body === 'object' && (req.body as { dryRun?: unknown }).dryRun === true)
24
+ const result = installClaudeHooks({ port: options.port, deskIdleSeconds: deskIdleSeconds(), dryRun })
25
+ invalidateHookStatus()
26
+ if (!result.ok) {
27
+ res.status(409).json({ ok: false, reason: result.reason ?? 'install_failed', status: result.status })
28
+ return
29
+ }
30
+ res.json({ ok: true, changed: result.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
31
+ })
32
+
33
+ router.post('/session-hooks/uninstall', (req, res) => {
34
+ const dryRun = req.query.dryRun === '1'
35
+ const result = uninstallClaudeHooks({ dryRun })
36
+ invalidateHookStatus()
37
+ if (!result.ok) {
38
+ res.status(409).json({ ok: false, reason: result.reason ?? 'uninstall_failed', status: result.status })
39
+ return
40
+ }
41
+ res.json({ ok: true, changed: result.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
42
+ })
43
+
44
+ // Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
45
+ // shorter than the pet's 20 s poll is recorded here where the poll never saw it.
46
+ router.get('/session-hooks/runs', (req, res) => {
47
+ res.set('Cache-Control', 'private, no-store')
48
+ const since = Number(req.query.since)
49
+ const sinceMs = Number.isFinite(since) && since > 0 ? since : Date.now() - 24 * 60 * 60_000
50
+ const runs: Array<Record<string, unknown>> = []
51
+ for (const signal of sessionSignalStore.snapshot()) {
52
+ if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
53
+ runs.push({
54
+ session_id: signal.sessionId,
55
+ started_at: new Date(signal.firstSeenAt).toISOString(),
56
+ ended_at: signal.ended ? new Date(signal.ended.at).toISOString() : null,
57
+ end_reason: signal.ended?.reason ?? null,
58
+ // The registry route reduces cwd to a workspace name on the wire; so does this one.
59
+ workspace: workspaceFromCwd(signal.cwd),
60
+ keep_warm: signal.keepWarm,
61
+ child_events: signal.childEvents,
62
+ last_reply: signal.lastReply || null,
63
+ })
64
+ }
65
+ runs.sort((a, b) => String(b.started_at).localeCompare(String(a.started_at)))
66
+ res.json({ ok: true, runs, since: new Date(sinceMs).toISOString() })
67
+ })
68
+
69
+ return router
70
+ }
@@ -20,6 +20,7 @@ import { fanOutSpeakerRename, type SpeakerRenameFanOut } from '../lib/speaker-re
20
20
  import { HeldGroupError, discardHeldSamples, enrollHeldGroup, heldVoiceGroups, parseHeldMembers, previewDiscard } from '../lib/held-voice-groups.js'
21
21
  import { previewHeldNaming, applyHeldNaming, undoHeldNaming, resumeHeldNaming, namingBatchList, resolveStoredName, NAMING_CAPABILITIES } from '../lib/held-naming-batches.js'
22
22
  import { resolveCosOperationsDir } from '../lib/cos-operations-meetings.js'
23
+ import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
23
24
 
24
25
  // These MUST match the writer in transcribe-stream.ts, which saves under
25
26
  // dataPath(). They previously resolved relative to __dirname — i.e. inside the
@@ -364,6 +365,10 @@ voiceRouter.post('/voice/enroll-ext', async (req, res) => {
364
365
  if (!name || typeof name !== 'string' || name.length < 2) {
365
366
  return res.status(400).json({ error: 'name is required (min 2 chars)' })
366
367
  }
368
+ // An imported or derived record never has audio to enrol from, and its
369
+ // speaker names came from a vendor rather than a voiceprint.
370
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId])
371
+ if (evidenceRefusal) return res.status(evidenceRefusal.status).json(evidenceRefusal.body)
367
372
 
368
373
  if (!existsSync(EXT_AUDIO_DIR)) {
369
374
  return res.json({ enrolled: 0, message: 'No ext-audio available' })
@@ -583,6 +588,19 @@ voiceRouter.post('/voice/held-groups/enroll', async (req, res) => {
583
588
  const nameCheck = checkSpeakerName(req.body?.name, { ownerLabel: getOwnerSpeakerLabel() })
584
589
  if (!nameCheck.ok) return res.status(400).json({ success:false,error:nameCheck.message,reason:nameCheck.reason })
585
590
  const name = resolveStoredName(String(req.body.name).trim())
591
+ // Held samples are audio this Mac captured. A member pointing at an imported
592
+ // or derived record is not held audio, so it never reaches the preview.
593
+ //
594
+ // ON THE RAW BODY, BEFORE parseHeldMembers. That parser calls
595
+ // `normalizeSessionId`, which rewrites every colon to an underscore for the
596
+ // on-disk directory name, so `blended:<h16>` arrives here as
597
+ // `blended_<h16>` and an id-kind guard reading the parsed members never
598
+ // fires. Caught by the execution test, which is the only place it could be.
599
+ const rawMembers = Array.isArray(req.body?.members) ? req.body.members : []
600
+ const evidenceRefusal = assertVoiceEvidenceSource(
601
+ rawMembers.map((member: unknown) => (member as { sessionId?: unknown })?.sessionId),
602
+ )
603
+ if (evidenceRefusal) return res.status(evidenceRefusal.status).json(evidenceRefusal.body)
586
604
  const members = parseHeldMembers(req.body?.members)
587
605
  if (!req.body?.previewHash || req.body?.dryRun === true) {
588
606
  if (name.owner && req.body?.confirm === true && req.body?.dryRun !== true) return res.status(400).json({success:false,error:'Owner confirmation requires a preview and ownerAck',reason:'owner_confirmation_required'})
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env tsx
2
+ // Install, inspect or remove the COS session hook in ~/.claude/settings.json.
3
+ //
4
+ // npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141]
5
+ // npx --yes @gotcos/glasses-server@latest --hooks status
6
+ // npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run]
7
+ //
8
+ // Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
9
+ // settings, a missing packaged script), 64 on a bad argument. Never prints the token.
10
+
11
+ import { hookStatus, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
12
+ import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
13
+
14
+ const args = process.argv.slice(2)
15
+ const action = args.find(a => !a.startsWith('--'))
16
+ const dryRun = args.includes('--dry-run')
17
+ const portIndex = args.indexOf('--port')
18
+ const port = portIndex >= 0 ? Number(args[portIndex + 1]) : Number(process.env.PORT ?? 3141)
19
+ const deskIndex = args.indexOf('--desk-idle-s')
20
+ const deskIdleSeconds = deskIndex >= 0 ? Number(args[deskIndex + 1]) : Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S ?? 90)
21
+
22
+ function print(value: unknown): void {
23
+ console.log(JSON.stringify(value, null, 2))
24
+ }
25
+
26
+ // `serverApplies` says whether the running server would READ the spool into rows: the
27
+ // hooks can be installed while the feature is off (COS_CLAUDE_SESSIONS_ENABLED unset),
28
+ // and a status that said only "installed" would hide that.
29
+ const serverApplies = sessionHooksEnabled()
30
+ const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
31
+
32
+ if (action === 'status') {
33
+ print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...hookStatus() })
34
+ process.exit(0)
35
+ }
36
+ if (action === 'install') {
37
+ if (!Number.isFinite(port) || port <= 0) { console.error('Bad --port'); process.exit(64) }
38
+ const result = installClaudeHooks({ port, deskIdleSeconds: Number.isFinite(deskIdleSeconds) ? deskIdleSeconds : 90, dryRun })
39
+ print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result })
40
+ process.exit(result.ok ? 0 : 2)
41
+ }
42
+ if (action === 'uninstall') {
43
+ const result = uninstallClaudeHooks({ dryRun })
44
+ print({ action, dryRun, ...result })
45
+ process.exit(result.ok ? 0 : 2)
46
+ }
47
+ console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N]')
48
+ process.exit(64)
@@ -1,2 +0,0 @@
1
- {"schemaVersion":1,"recordId":"02b09947-ed7d-47ef-99c5-4985af3ec924","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"43b397bd-dd67-4312-9a5c-4c5d4b246f1d","clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"turnId":"a456fd1d-1e82-4b2d-8a20-5d34681201fd","requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"11111111-1111-4111-8111-111111111111","generation":2,"query":"written by 6.43.3","sessionId":"session-6-43-3","model":"opus","effort":"high","cursorExecutionMode":"ask","messageEra":"era1","globalMsgNum":78,"reference":{"query":"earlier q","response":"earlier a"},"handoffCode":"ABCD","handoffLatest":true,"clientQueueItemId":"q1","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"024966024134bf0d46090335da03a732a7fb7eaea161b1fd07dc4c5b9d536e95"}}
2
- {"schemaVersion":1,"recordId":"a1145a40-2ced-4da3-9cf7-76d84cffc946","partitionDay":"2099-01-01","persistedAt":"2099-01-01T12:00:00.000Z","bootId":"boot-6-43-3-fixture","jobId":"64c5b502-13f2-4871-b6c4-007b91be2414","clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"turnId":"843e633f-0f92-4fe3-b2a1-9dc683715791","requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638","eventSeq":1,"type":"accepted","status":"accepted","request":{"clientJobId":"22222222-2222-4222-8222-222222222222","generation":1,"query":"minimal by 6.43.3","sessionId":"session-6-43-3-min","attachmentIds":[],"attachmentRefs":[],"activityToolMode":"status"},"patch":{},"eventData":{"requestFingerprint":"91edfdeeb088e9fb3b1ffab4202bca02d3e7a23674d8b54dd51a50ce9adbf638"}}