@gotcos/glasses-server 6.73.5 → 6.73.14

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 (42) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +5 -0
  4. package/server/lib/ask-session-id.ts +5 -0
  5. package/server/lib/decision-provider.ts +163 -0
  6. package/server/lib/decision-settings.ts +72 -0
  7. package/server/lib/glossary-learn.ts +221 -0
  8. package/server/lib/imported-library-rows.ts +62 -4
  9. package/server/lib/jev.ts +3 -3
  10. package/server/lib/lens-gist-engines.ts +7 -2
  11. package/server/lib/live-cos-ask.ts +2 -1
  12. package/server/lib/mac-recording-asr.ts +59 -0
  13. package/server/lib/mac-recording.ts +111 -0
  14. package/server/lib/meeting-actions.ts +1 -0
  15. package/server/lib/meeting-ask-follow-up.ts +2 -2
  16. package/server/lib/meeting-ask-runner.ts +26 -5
  17. package/server/lib/meeting-ask-store.ts +2 -1
  18. package/server/lib/meeting-batch-transcribe.ts +19 -4
  19. package/server/lib/meeting-context-keys.ts +7 -4
  20. package/server/lib/meeting-parse.ts +2 -0
  21. package/server/lib/meeting-store.ts +62 -9
  22. package/server/lib/session-recommendation.ts +12 -9
  23. package/server/lib/speaker-embeddings.ts +2 -2
  24. package/server/lib/stranded-session-actions.ts +15 -0
  25. package/server/lib/subq-key.ts +38 -0
  26. package/server/lib/transcribe-audio.ts +9 -1
  27. package/server/lib/work-activity.ts +2 -2
  28. package/server/lib/work-chat.ts +87 -11
  29. package/server/lib/work-completion.ts +11 -8
  30. package/server/lib/work-intake-store.ts +2 -1
  31. package/server/lib/work-search.ts +32 -12
  32. package/server/routes/client-instance.ts +1 -1
  33. package/server/routes/decision-provider.ts +36 -0
  34. package/server/routes/glossary.ts +143 -11
  35. package/server/routes/jev.ts +3 -2
  36. package/server/routes/meeting.ts +44 -12
  37. package/server/routes/meetings.ts +42 -4
  38. package/server/routes/subq-key.ts +27 -0
  39. package/server/routes/transcribe-stream.ts +115 -9
  40. package/server/routes/transcribe.ts +20 -2
  41. package/server/routes/work-chat.ts +8 -4
  42. package/server/routes/work-search.ts +2 -2
@@ -1,3 +1,4 @@
1
+ import { type DecisionClient, type DecisionProvider, createDecisionClient, DecisionProviderError } from './decision-provider.js'
1
2
  /**
2
3
  * Work search by meaning (6.65.0, Control 0.5.259).
3
4
  *
@@ -41,7 +42,7 @@ export const WORK_SEARCH_INSTRUCTIONS = 'The user typed `query` into the search
41
42
  export const WORK_SEARCH_NONE = 'None of these tasks is what the user is looking for.'
42
43
 
43
44
  export type WorkSearchReason = 'jev_not_configured' | 'jev_key_rejected' | 'jev_cap_reached' | 'jev_breaker_open' | 'jev_request_rejected'
44
- | 'jev_unavailable' | 'search_off' | 'too_many_candidates'
45
+ | 'jev_unavailable' | DecisionProviderError['code'] | 'search_off' | 'too_many_candidates'
45
46
  export const WORK_SEARCH_MESSAGES: Record<WorkSearchReason, string> = {
46
47
  jev_not_configured: 'Meaning search needs a TypeSafe key. Add one in Settings.',
47
48
  jev_key_rejected: 'TypeSafe did not accept the saved key. Check it in Settings. Word search still works.',
@@ -49,13 +50,26 @@ export const WORK_SEARCH_MESSAGES: Record<WorkSearchReason, string> = {
49
50
  jev_breaker_open: 'Meaning search is paused for an hour after repeated failures. Word search still works.',
50
51
  jev_request_rejected: 'Jev refused this search request. Word search still works.',
51
52
  jev_unavailable: 'Meaning search got no answer from Jev. Word search still works.',
53
+ decision_invalid_provider: 'Meaning search has an invalid decision provider setting. Word search still works.',
54
+ decision_not_configured: 'The selected decision provider needs an API key. Word search still works.',
55
+ decision_cap_reached: 'The selected decision provider has used today’s budget. Word search still works.',
56
+ decision_usage_unavailable: 'The selected decision provider’s usage accounting is unavailable. Word search still works.',
57
+ decision_breaker_open: 'The selected decision provider is paused after repeated failures. Word search still works.',
58
+ decision_key_rejected: 'The selected decision provider did not accept its key. Word search still works.',
59
+ decision_credits_required: 'The selected decision provider needs available credits. Word search still works.',
60
+ decision_rate_limited: 'The selected decision provider is rate limited. Word search still works.',
61
+ decision_request_rejected: 'The selected decision provider refused this request. Word search still works.',
62
+ decision_unavailable: 'The selected decision provider could not answer. Word search still works.',
63
+ decision_bad_answer: 'The selected decision provider returned an unreadable answer. Word search still works.',
64
+ decision_cancelled: 'Meaning search was cancelled. Word search still works.',
65
+ decision_images_unsupported: 'The selected decision provider does not accept images. Word search still works.',
52
66
  search_off: 'Meaning search is turned off on this server.',
53
67
  too_many_candidates: `Meaning search covers up to ${WORK_SEARCH_LIMITS.maxCandidates} cards. Pick a domain to search this view.`,
54
68
  }
55
69
 
56
70
  export interface WorkSearchHit { id: string; p: number }
57
71
  export type WorkSearchResult =
58
- | { available: true; results: WorkSearchHit[]; none: number | null; cached: boolean; tokens: number }
72
+ | { available: true; provider?: DecisionProvider | 'none'; results: WorkSearchHit[]; none: number | null; cached: boolean; tokens: number }
59
73
  | { available: false; reason: WorkSearchReason; message: string }
60
74
 
61
75
  export function unavailable(reason: WorkSearchReason): Extract<WorkSearchResult, { available: false }> {
@@ -179,42 +193,45 @@ export function normalizeQuery(query: string): string {
179
193
  const PASSED_THROUGH = new Set<string>(['jev_not_configured', 'jev_key_rejected', 'jev_cap_reached', 'jev_breaker_open', 'jev_request_rejected'])
180
194
 
181
195
  export class WorkSearcher {
182
- private cache = new Map<string, { at: number; results: WorkSearchHit[]; none: number | null }>()
196
+ private cache = new Map<string, { at: number; provider: DecisionProvider; results: WorkSearchHit[]; none: number | null }>()
183
197
  private inflight = new Map<string, Promise<WorkSearchResult>>()
184
- constructor(private readonly jev: JevClient, private readonly now: () => number = () => Date.now()) {}
198
+ constructor(private readonly jev: DecisionClient, private readonly now: () => number = () => Date.now()) {}
185
199
 
186
200
  /** One Jev Choice over `candidates` (already selected, at most 254). Never throws for a Jev failure. */
187
201
  async search(query: string, candidates: readonly SearchableRow[]): Promise<WorkSearchResult> {
188
202
  if (candidates.length > WORK_SEARCH_LIMITS.maxCandidates) return unavailable('too_many_candidates')
189
- if (!candidates.length) return { available: true, results: [], none: 1, cached: false, tokens: 0 }
203
+ if (!candidates.length) return { available: true, provider: 'none', results: [], none: 1, cached: false, tokens: 0 }
204
+ let client: DecisionClient
205
+ try { client = this.jev.snapshot?.() ?? this.jev } catch (e) { if (e instanceof DecisionProviderError) return unavailable(e.code); throw e }
190
206
  const texts = candidates.map(row => searchOptionText(row.text))
191
207
  const set = createHash('sha256').update(JSON.stringify(candidates.map((row, i) => [row.id, texts[i]]))).digest('hex')
192
- const key = createHash('sha256').update(normalizeQuery(query) + '\0' + set).digest('hex')
208
+ const key = createHash('sha256').update((client.provider ?? 'jev') + '\0' + normalizeQuery(query) + '\0' + set).digest('hex')
193
209
  const hit = this.cache.get(key)
194
210
  if (hit && this.now() - hit.at < WORK_SEARCH_LIMITS.cacheMs) {
195
- return { available: true, results: hit.results, none: hit.none, cached: true, tokens: 0 }
211
+ return { available: true, provider: hit.provider, results: hit.results, none: hit.none, cached: true, tokens: 0 }
196
212
  }
197
213
  if (hit) this.cache.delete(key)
198
214
  // Typing can send the same search twice before the first answers; the second waits for the first.
199
215
  const running = this.inflight.get(key)
200
216
  if (running) return running
201
- const work = this.ask(query, candidates.map(row => row.id), texts, key)
217
+ const work = this.ask(query, candidates.map(row => row.id), texts, key, client)
202
218
  this.inflight.set(key, work)
203
219
  try { return await work } finally { this.inflight.delete(key) }
204
220
  }
205
221
 
206
- private async ask(query: string, ids: string[], texts: string[], key: string): Promise<WorkSearchResult> {
222
+ private async ask(query: string, ids: string[], texts: string[], key: string, client: DecisionClient): Promise<WorkSearchResult> {
207
223
  const state = { query: query.trim() }
208
224
  const criteria: Record<string, string> = Object.fromEntries(texts.map((text, i) => [`t${i}`, text]))
209
225
  criteria.none = WORK_SEARCH_NONE
210
226
  const questions = { c: { type: 'choice', instructions: WORK_SEARCH_INSTRUCTIONS, criteria } }
211
227
  try {
212
- const reply = await this.jev.ask(state, questions, estimateJevTokens({ state, questions }))
228
+ const reply = await client.ask(state, questions, estimateJevTokens({ state, questions }))
213
229
  const { results, none } = rankHits(reply.answers.c?.probabilities, ids)
214
230
  if (this.cache.size >= WORK_SEARCH_LIMITS.cacheEntries) this.cache.delete(this.cache.keys().next().value!)
215
- this.cache.set(key, { at: this.now(), results, none })
216
- return { available: true, results, none, cached: false, tokens: reply.inputTokens }
231
+ this.cache.set(key, { at: this.now(), provider: reply.provider ?? 'jev', results, none })
232
+ return { available: true, provider: reply.provider ?? 'jev', results, none, cached: false, tokens: reply.inputTokens }
217
233
  } catch (e) {
234
+ if (e instanceof DecisionProviderError) return unavailable(e.code)
218
235
  if (!(e instanceof JevError)) throw e
219
236
  // Each says what happened. A 400, 413 or 422 is jev_request_rejected: the request itself was refused, which does
220
237
  // not count toward the breaker (jev.ts), because the same cards would be refused again and an outage pause would
@@ -225,3 +242,6 @@ export class WorkSearcher {
225
242
  }
226
243
  }
227
244
  }
245
+
246
+ let sharedDecisionSearch: DecisionClient | null = null
247
+ export function sharedWorkSearchDecisionClient(): DecisionClient { return sharedDecisionSearch ??= createDecisionClient('search', sharedWorkSearchJevClient()) }
@@ -36,7 +36,7 @@ export interface ClientInstanceRouterDeps {
36
36
 
37
37
  /** A POST that carries a meeting chunk: a live one, or an iPhone offline chunk replayed. The preview is not one. */
38
38
  export function isMeetingChunkPost(method: string, path: string): boolean {
39
- return method === 'POST' && (path === '/transcribe-stream' || /^\/transcribe-stream\/offline-sessions\/[^/]+\/chunks$/.test(path))
39
+ return method === 'POST' && (path === '/transcribe-stream' || /^\/mac-recording\/mac_[0-9a-f-]+\/chunks$/.test(path) || /^\/transcribe-stream\/offline-sessions\/[^/]+\/chunks$/.test(path))
40
40
  }
41
41
 
42
42
  /** `::ffff:100.64.1.2` and `100.64.1.2` are one device. */
@@ -0,0 +1,36 @@
1
+ /** Mounted behind the ordinary /api token and mutation-lease middleware. Selection only; no credential egress. */
2
+ import { Router } from 'express'
3
+ import { JEV_MODEL, resolveJevKey } from '../lib/jev.js'
4
+ import { resolveSubqKey, subqKeyStatus } from '../lib/subq-key.js'
5
+ import { SUBQ_MODEL } from '../lib/decision-provider.js'
6
+ import { decisionSettingsView, DecisionSettingsError, patchDecisionSettings, readDecisionSettings, writeDecisionSettings, type DecisionProvider, type DecisionSettings } from '../lib/decision-settings.js'
7
+ export interface DecisionProviderRouteDeps {
8
+ read: () => DecisionSettings
9
+ write: (settings: DecisionSettings) => void
10
+ configured: () => Record<DecisionProvider, boolean>
11
+ env: NodeJS.ProcessEnv
12
+ keyStatus: () => { jev: { configured:boolean; source:string }; subq: { configured:boolean; source:string } }
13
+ }
14
+ export function createDecisionProviderRouter(overrides: Partial<DecisionProviderRouteDeps> = {}): Router {
15
+ const deps: DecisionProviderRouteDeps = { read: readDecisionSettings, write: writeDecisionSettings, configured: () => ({ jev: !!resolveJevKey(), subq: !!resolveSubqKey() }), env: process.env, keyStatus: () => { const jev = resolveJevKey(); return { jev: { configured:!!jev, source:jev?.source ?? 'none' }, subq:subqKeyStatus() } }, ...overrides }
16
+ const router = Router()
17
+ const view = (settings: DecisionSettings) => ({ ...decisionSettingsView(settings, deps.configured(), deps.env), models: { jev: JEV_MODEL, subq: SUBQ_MODEL }, keyStatus: deps.keyStatus(),
18
+ capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env' } })
19
+ router.use('/decision-provider', (_req,res,next) => { res.set('Cache-Control','private, no-store'); next() })
20
+ router.get('/decision-provider', (_req,res) => {
21
+ try { res.json(view(deps.read())) } catch { res.status(503).json({ error: { code: 'decision_settings_unavailable', message: 'Decision provider settings could not be read.' } }) }
22
+ })
23
+ router.patch('/decision-provider', (req,res) => {
24
+ try {
25
+ const next = patchDecisionSettings(deps.read(), req.body)
26
+ const result = view(next) // validate effective environment and build safe response BEFORE committing
27
+ deps.write(next)
28
+ res.json(result)
29
+ } catch (e) {
30
+ if (e instanceof DecisionSettingsError && e.code === 'decision_settings_conflict') return void res.status(409).json({ error: { code: e.code, message: 'Decision settings changed. Reload them before saving.' } })
31
+ if (e instanceof DecisionSettingsError && e.code === 'invalid_decision_settings') return void res.status(400).json({ error: { code: e.code, message: 'Choose jev or subq, or null to clear a saved setting. Unknown fields are not accepted.' } })
32
+ res.status(503).json({ error: { code: 'decision_settings_unavailable', message: 'Decision provider settings could not be saved.' } })
33
+ }
34
+ })
35
+ return router
36
+ }
@@ -7,34 +7,48 @@
7
7
  // PUT /api/transcription-glossary → same shape; PARTIAL (any omitted field is
8
8
  // left unchanged). whisper_corrections is stored as a JSON STRING in the
9
9
  // profile (legacy decoder contract), so the route encodes/decodes it here.
10
+ // POST /api/transcription-glossary/correction {heard, written, source} → learn
11
+ // ONE correction and its spelling (6.73.11, Fix a word), logged for undo.
12
+ // GET /api/transcription-glossary/recent → { entries } newest first.
13
+ // POST /api/transcription-glossary/correction/undo {id} → revert that entry.
14
+ // POST /api/transcription-glossary/vocabulary/add {term}, /vocabulary/remove {term},
15
+ // /correction/remove {heard} → one atomic hand edit (6.73.12), not logged.
10
16
 
11
17
  import { Router } from 'express'
18
+ import { randomUUID } from 'node:crypto'
19
+ import { readFileSync } from 'node:fs'
12
20
  import { errMsg } from '../lib/utils.js'
21
+ import { atomicWriteFileSync } from '../lib/atomic-fs.js'
22
+ import { dataPath } from '../lib/data-dir.js'
13
23
  import {
14
24
  getVocabulary,
15
25
  getNegativeRules,
16
26
  getWhisperCorrections,
27
+ loadProfileObject,
17
28
  updateProfileFields,
18
29
  } from '../lib/profile.js'
30
+ import {
31
+ MAX_TERMS,
32
+ MAX_TERM_LEN,
33
+ addVocabulary,
34
+ appendLog,
35
+ learnCorrection,
36
+ looksLikeUrlEmailPath,
37
+ parseLog,
38
+ removeCorrection,
39
+ removeVocabulary,
40
+ undoCorrection,
41
+ type CorrectionLogEntry,
42
+ type GlossaryWords,
43
+ } from '../lib/glossary-learn.js'
19
44
  import { resetDecoderCaches } from '../lib/whisper-local.js'
20
45
  import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
21
46
  import { validateNegativeRule } from '../lib/hallucination-filter.js'
22
47
 
23
48
  export const glossaryRouter = Router()
24
49
 
25
- const MAX_TERMS = 500
26
- const MAX_TERM_LEN = 100
27
50
  const MAX_RULES = 500
28
51
 
29
- // Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
30
- // there induce ".com"/handle hallucinations on quiet audio — reject them.
31
- function looksLikeUrlEmailPath(s: string): boolean {
32
- return /(?:https?:\/\/|www\.)/i.test(s)
33
- || /@[\w.-]+\.\w/.test(s)
34
- || /\.(?:com|net|org|io|ai|co|gov|edu|app|dev)\b/i.test(s)
35
- || /[/\\]/.test(s)
36
- }
37
-
38
52
  function readCorrections(): Record<string, string> {
39
53
  return getWhisperCorrections()
40
54
  }
@@ -142,3 +156,121 @@ glossaryRouter.put('/transcription-glossary', async (req, res) => {
142
156
  return res.status(500).json({ error: errMsg(err) })
143
157
  }
144
158
  })
159
+
160
+ // ── One-word learning (6.73.11) ──────────────────────────────────────────────
161
+ // The recent-corrections log lives in the data dir (private, atomic, bounded);
162
+ // the dictionary itself stays in .cos-profile.json. Both writes run inside the
163
+ // same putChain as PUT, so a learn can never interleave with a whole-map edit.
164
+
165
+ function correctionsLogPath(): string {
166
+ return dataPath('transcription-corrections.json')
167
+ }
168
+
169
+ function readCorrectionsLog(): CorrectionLogEntry[] {
170
+ try {
171
+ return parseLog(JSON.parse(readFileSync(correctionsLogPath(), 'utf-8')))
172
+ } catch {
173
+ return []
174
+ }
175
+ }
176
+
177
+ function writeCorrectionsLog(entries: CorrectionLogEntry[]): void {
178
+ atomicWriteFileSync(correctionsLogPath(), JSON.stringify({ version: 1, entries }, null, 2), { mode: 0o600 })
179
+ }
180
+
181
+ /** The raw stored vocabulary (strings only), so a learn appends without rewriting placeholder handling. */
182
+ function storedWords(): GlossaryWords {
183
+ const raw = loadProfileObject().vocabulary
184
+ const vocabulary = Array.isArray(raw) ? raw.filter((term): term is string => typeof term === 'string') : []
185
+ return { vocabulary, corrections: readCorrections() }
186
+ }
187
+
188
+ /** Log first, then the profile; a failed profile write puts the old log back. */
189
+ function commitWords(words: GlossaryWords, nextLog: CorrectionLogEntry[], previousLog: CorrectionLogEntry[]): void {
190
+ writeCorrectionsLog(nextLog)
191
+ try {
192
+ updateProfileFields({ vocabulary: words.vocabulary, whisper_corrections: JSON.stringify(words.corrections) })
193
+ } catch (err) {
194
+ try { writeCorrectionsLog(previousLog) } catch { /* the profile is unchanged; a stale log row only refuses its own undo */ }
195
+ throw err
196
+ }
197
+ resetDecoderCaches()
198
+ resetVocabEchoCache()
199
+ }
200
+
201
+ type Outcome = { status: number; body: unknown }
202
+
203
+ function serialized(work: () => Outcome): Promise<Outcome> {
204
+ const run = putChain.catch(() => {}).then(work)
205
+ putChain = run
206
+ return run
207
+ }
208
+
209
+ glossaryRouter.post('/transcription-glossary/correction', async (req, res) => {
210
+ const body = req.body ?? {}
211
+ try {
212
+ const outcome = await serialized(() => {
213
+ const previousLog = readCorrectionsLog()
214
+ const learned = learnCorrection(storedWords(), body, { id: randomUUID(), at: new Date().toISOString() })
215
+ if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
216
+ if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
217
+ commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
218
+ return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
219
+ })
220
+ return res.status(outcome.status).json(outcome.body)
221
+ } catch (err) {
222
+ return res.status(500).json({ error: errMsg(err) })
223
+ }
224
+ })
225
+
226
+ glossaryRouter.get('/transcription-glossary/recent', (_req, res) => {
227
+ try {
228
+ res.json({ entries: readCorrectionsLog() })
229
+ } catch (err) {
230
+ res.status(500).json({ error: errMsg(err) })
231
+ }
232
+ })
233
+
234
+ glossaryRouter.post('/transcription-glossary/correction/undo', async (req, res) => {
235
+ const id = (req.body ?? {}).id
236
+ try {
237
+ const outcome = await serialized(() => {
238
+ const previousLog = readCorrectionsLog()
239
+ const undone = undoCorrection(storedWords(), previousLog, id)
240
+ if (!undone.ok) return { status: undone.status, body: { error: undone.error } }
241
+ commitWords(undone.words, undone.log, previousLog)
242
+ return { status: 200, body: { undone: undone.entry, glossary: currentGlossary() } }
243
+ })
244
+ return res.status(outcome.status).json(outcome.body)
245
+ } catch (err) {
246
+ return res.status(500).json({ error: errMsg(err) })
247
+ }
248
+ })
249
+
250
+ // ── Single-item hand edits (6.73.12) ─────────────────────────────────────────
251
+ // One word or one correction at a time, inside the same chain as PUT and learn,
252
+ // so COS Control never has to read the whole map and write it back.
253
+ function singleEdit(path: string, field: 'term' | 'heard', edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>) {
254
+ glossaryRouter.post(path, async (req, res) => {
255
+ const raw = (req.body ?? {})[field]
256
+ try {
257
+ const outcome = await serialized(() => {
258
+ const result = edit(storedWords(), raw)
259
+ if (!result.ok) return { status: result.status, body: { error: result.error } }
260
+ if (!result.unchanged) {
261
+ updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
262
+ resetDecoderCaches()
263
+ resetVocabEchoCache()
264
+ }
265
+ return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
266
+ })
267
+ return res.status(outcome.status).json(outcome.body)
268
+ } catch (err) {
269
+ return res.status(500).json({ error: errMsg(err) })
270
+ }
271
+ })
272
+ }
273
+
274
+ singleEdit('/transcription-glossary/vocabulary/add', 'term', addVocabulary)
275
+ singleEdit('/transcription-glossary/vocabulary/remove', 'term', removeVocabulary)
276
+ singleEdit('/transcription-glossary/correction/remove', 'heard', removeCorrection)
@@ -1,3 +1,4 @@
1
+ import { createDecisionClient } from '../lib/decision-provider.js'
1
2
  /**
2
3
  * Jev key management (COS Control Settings) and Work session recommendations. Auth comes from the parent /api mount.
3
4
  *
@@ -42,8 +43,8 @@ function findTask<T extends { id: string; workIdentity?: string }>(rows: T[], id
42
43
 
43
44
  export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}): Router {
44
45
  const jev = overrides.jev ?? sharedJevClient()
45
- const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(jev), list: listBoard, validate: validateJevKey, save: saveJevKey,
46
- completion: new CompletionChecker(jev), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
46
+ const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(createDecisionClient('session', jev)), list: listBoard, validate: validateJevKey, save: saveJevKey,
47
+ completion: new CompletionChecker(createDecisionClient('completion', jev)), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
47
48
  searchJev: sharedWorkSearchJevClient(), ...overrides }
48
49
  const router = Router()
49
50
  router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
@@ -1,3 +1,4 @@
1
+ import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
1
2
  import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
2
3
  // POST /api/meeting/save — finalize an existing transcribe-stream session into
3
4
  // the standalone public meeting store. The live transcript and chunk metadata
@@ -30,6 +31,16 @@ import {
30
31
  import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
31
32
  import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
32
33
 
34
+ function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
35
+ try {
36
+ requireMacRecording(sessionId)
37
+ assertMacCaptureComplete(sessionId, expectedChunkCount)
38
+ } catch (error) {
39
+ const failure = error as Error & { status?: number; reason?: string }
40
+ throw new MeetingStoreError(failure.message, failure.status ?? 409, failure.reason ?? 'mac_capture_not_started')
41
+ }
42
+ }
43
+
33
44
  /**
34
45
  * The label a de-attributed voice takes, numbered within its meeting.
35
46
  *
@@ -106,6 +117,7 @@ import {
106
117
  import {
107
118
  analyzeTranscriptGaps,
108
119
  deleteSession,
120
+ assertMacCaptureComplete,
109
121
  drainSessionAudioWrites,
110
122
  durableSessionRecoveryHasRun,
111
123
  getSessionChunkEntries,
@@ -357,7 +369,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
357
369
  markCanonicalFinalizationState(current.sidecarPath, 'ops_pending', current.claimPending)
358
370
  }
359
371
 
360
- if (cosOpsPipelineConfigured()) {
372
+ if (cosOpsPipelineConfigured() && !MAC_CAPTURE_ID.test(current.sessionId)) {
361
373
  await handoffMeetingToOperations(current.meetingPath)
362
374
  } else {
363
375
  // Standalone: no sync_meetings.py to produce summary/topics/decisions.
@@ -370,7 +382,7 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
370
382
  // The capture is finished and its files are durable, so the merge engine may score it.
371
383
  // Fire-and-forget by contract: the runner queues, defers under a drain or a live
372
384
  // capture, and never throws back at the finalization path.
373
- triggerMeetingMergeRun('g2_finalized')
385
+ if (!MAC_CAPTURE_ID.test(current.sessionId)) triggerMeetingMergeRun('g2_finalized')
374
386
  runtime.finalizationJobs.remove(current.sessionId)
375
387
  finalizationRetryCounts.delete(key)
376
388
  }).catch(error => {
@@ -463,6 +475,7 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
463
475
  : 100
464
476
  return {
465
477
  receiptVersion: 1,
478
+ ...(saved.sourceType === 'mac_system_audio' ? { sourceType: saved.sourceType, sessionId: saved.captureId, captureId: saved.captureId } : {}),
466
479
  serverInstanceId: getServerInstanceId(),
467
480
  saved: true,
468
481
  // Keep the build199 string field without leaking an absolute host path.
@@ -639,6 +652,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
639
652
  return
640
653
  }
641
654
  const pin = suppliedServerPin(req, body?.serverInstanceId)
655
+ if (MAC_CAPTURE_ID.test(sessionId) && !pin) {
656
+ res.status(400).json({ error: 'Server identity pin required', reason: 'server_identity_pin_required' })
657
+ return
658
+ }
642
659
  if (pin && pin !== serverInstanceId) {
643
660
  res.status(409).json({ error: 'Server identity mismatch', reason: 'server_instance_mismatch' })
644
661
  return
@@ -661,6 +678,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
661
678
  res.json(publicSaveResponse(alreadySaved, true))
662
679
  return
663
680
  }
681
+ if (MAC_CAPTURE_ID.test(sessionId)) {
682
+ requireMacRecordingForSave(sessionId, body?.expectedChunkCount)
683
+ }
664
684
  if (savingSessions.has(sessionId)) {
665
685
  res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
666
686
  return
@@ -689,7 +709,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
689
709
  const chunkEntries = sessions.getChunkEntries(sessionId)
690
710
  ?? chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk }))
691
711
  const startTime = sessions.getStartTime(sessionId) ?? Date.now()
692
- const durationFromTimeline = chunks.reduce(
712
+ const macRecording = readMacRecording(sessionId)
713
+ const durationFromTimeline = macRecording
714
+ ? macChunkReceipt(sessionId, Number(body?.expectedChunkCount) - 1)?.endElapsed ?? 0
715
+ : chunks.reduce(
693
716
  (maximum, chunk) => Math.max(maximum, chunk?.elapsed ?? 0),
694
717
  0,
695
718
  )
@@ -697,7 +720,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
697
720
  ? durationFromTimeline
698
721
  : Math.max(0, Date.now() - startTime)
699
722
  const integrity = sessions.getIntegrity(sessionId)
700
- const needsOperations = cosOpsPipelineConfigured()
723
+ const needsOperations = cosOpsPipelineConfigured() && !macRecording
701
724
  // Standalone saves need a finalization pass too, for summary enrichment.
702
725
  // Before 6.37 a standalone save with no audio nulled the job below and
703
726
  // never reached the ops_pending slot, so enrichment could never run.
@@ -806,7 +829,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
806
829
  markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
807
830
  } else if (finalizationJob) {
808
831
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
809
- triggerMeetingMergeRun('g2_finalized')
832
+ if (!readMacRecording(sessionId)) triggerMeetingMergeRun('g2_finalized')
810
833
  finalizationJobs.remove(sessionId)
811
834
  finalizationJob = null
812
835
  }
@@ -963,6 +986,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
963
986
  })
964
987
  router.post('/meeting/:sessionId/speaker-policy', (req, res) => {
965
988
  const sessionId = String(req.params.sessionId ?? '')
989
+ if (MAC_CAPTURE_ID.test(sessionId) && req.body?.mode !== 'high-confidence') { res.status(409).json({ reason: 'mac_capture_policy_locked', error: 'The Mac pilot requires high-confidence voice identity' }); return }
966
990
  const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
967
991
  if (refusal) { res.status(refusal.status).json(refusal.body); return }
968
992
  if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId) || !isSpeakerMode(req.body?.mode) || typeof req.body?.expectedPolicyRevision !== 'string') {
@@ -2170,7 +2194,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2170
2194
  return
2171
2195
  }
2172
2196
  const capture = synthesizeEntriesFromChunkWavs(quarantineDir)
2173
- const entries = capture.entries
2197
+ const macCapture = readMacRecording(sessionId)
2198
+ const entries = capture.entries.map(entry => {
2199
+ if (!macCapture) return entry
2200
+ const receipt = macChunkReceipt(sessionId, entry.chunkIndex)
2201
+ return { ...entry, chunk: { ...entry.chunk, speaker: 'Ext', speakerPolicy: 'high-confidence' as const,
2202
+ ...(receipt ? { elapsed: receipt.endElapsed - receipt.durationMs, audioDurationMs: receipt.durationMs } : {}) } }
2203
+ })
2174
2204
  if (entries.length === 0) {
2175
2205
  res.status(422).json({ error: 'Quarantined directory holds no readable chunk audio', reason: 'no_chunk_audio' })
2176
2206
  return
@@ -2213,7 +2243,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2213
2243
  // decoder serialization — that lives in the queue tail. Chain onto it,
2214
2244
  // or two back-to-back recoveries plus a live post-meeting batch would
2215
2245
  // run parallel 16-thread large-v3 decoders on the user's Mac.
2216
- const hqJob = recoveryHqJob(quarantineDir, segments, entries)
2246
+ const hqJob = recoveryHqJob(quarantineDir, segments, entries, undefined, sessionId)
2217
2247
  const plan = hqJob.plan
2218
2248
  const results = await enqueueSerializedHqWork(hqJob.run)
2219
2249
  // Batch whisper over a dead session has no streaming baseline to
@@ -2242,6 +2272,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2242
2272
  // Receipt it and let it clear. The audio stays on disk for its full
2243
2273
  // retention window, so a capture wrongly judged silent is not lost.
2244
2274
  markRecoveredNoSpeech(quarantineDir, entries.length)
2275
+ if (macCapture) closeMacRecording(sessionId)
2245
2276
  console.log(
2246
2277
  `[meeting/orphans] ${sessionId} held no speech `
2247
2278
  + `(${entries.length} chunks, ${Math.round((Date.now() - startedAt) / 1000)}s) `
@@ -2251,7 +2282,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2251
2282
  }
2252
2283
  const recoveredChunks = results.map(item => ({
2253
2284
  text: item.text,
2254
- speaker: 'Unknown',
2285
+ speaker: macCapture ? 'Ext' : 'Unknown',
2286
+ ...(macCapture ? { speakerPolicy: 'high-confidence' as const } : {}),
2255
2287
  elapsed: item.segment.startElapsed,
2256
2288
  similarity: 0,
2257
2289
  words: item.words,
@@ -2266,8 +2298,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2266
2298
  resolveCosOperationsDir(),
2267
2299
  ),
2268
2300
  transcript,
2269
- startTime: capture.startTime,
2270
- durationMs: capture.durationMs,
2301
+ startTime: macCapture?.startTime ?? capture.startTime,
2302
+ durationMs: macCapture ? Math.max(...entries.map(e => e.chunk.elapsed + (e.chunk.audioDurationMs ?? 0))) : capture.durationMs,
2271
2303
  chunks: recoveredChunks,
2272
2304
  chunkEntries: recoveredChunks.map((chunk, position) => ({
2273
2305
  chunkIndex: results[position]?.segment.startChunkIdx ?? position,
@@ -2294,7 +2326,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2294
2326
  },
2295
2327
  })
2296
2328
  } catch { /* display is best-effort */ }
2297
- if (cosOpsPipelineConfigured()) {
2329
+ if (cosOpsPipelineConfigured() && !macCapture) {
2298
2330
  await handoffMeetingToOperations(saved.filepath)
2299
2331
  } else {
2300
2332
  // Orphan recovery is its own path and never reaches
@@ -2305,7 +2337,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
2305
2337
  // Control surfaces and warns on before committing a drain.
2306
2338
  await enrichStandaloneMeeting(saved.filepath, Date.now())
2307
2339
  }
2308
- triggerMeetingMergeRun('orphan_recovered')
2340
+ if (!macCapture) triggerMeetingMergeRun('orphan_recovered')
2309
2341
  }).catch(error => {
2310
2342
  // The quarantined audio is untouched on failure — retry stays possible
2311
2343
  // until the retention clock clears it.
@@ -18,6 +18,7 @@ import { meetingListLimit } from '../lib/meeting-store.js'
18
18
  import { searchMeetingLibrary } from '../lib/meeting-library-search.js'
19
19
  import { meetingContextFields, meetingContextKeys, readOnlyContext } from '../lib/meeting-context-keys.js'
20
20
  import { g2RecordingsReachOperations } from '../lib/g2-ops-handoff.js'
21
+ import { MAC_CAPTURE_ID } from '../lib/mac-recording.js'
21
22
  import {
22
23
  type ImportedMeetingLibrary,
23
24
  IMPORTED_DOMAIN,
@@ -67,11 +68,36 @@ function withStandaloneIdentity(meeting: MeetingMeta): MeetingMeta {
67
68
  export function withMeetingContext<T extends MeetingMeta>(meeting: T, source: MeetingMeta['librarySource'] = meeting.librarySource): T {
68
69
  if (meeting.contextKeys !== undefined) return meeting
69
70
  if (source === 'standalone_recordings') {
70
- return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId })) }
71
+ return { ...meeting, ...meetingContextFields(meetingContextKeys({ g2SessionId: meeting.sessionId, sourceType: meeting.sourceType })) }
71
72
  }
72
73
  return { ...meeting, ...readOnlyContext() }
73
74
  }
74
75
 
76
+ /** A Mac capture's session id, as routes/meeting.ts keys the pipeline skip. */
77
+ export function isMacCaptureId(sessionId: string | undefined): boolean {
78
+ return typeof sessionId === 'string' && MAC_CAPTURE_ID.test(sessionId)
79
+ }
80
+
81
+ /**
82
+ * Operations rows plus the store's Mac recordings (6.73.14), newest first by date and time, capped at `limit`. Only Mac
83
+ * rows are added: a G2 store row is the pipeline's to file, and its operations copy is the row. A Mac row is left out
84
+ * when an operations row holds its session, or when `held` does (the sessions merged scribes declare), the same rule
85
+ * the day count applies (imported-library-rows macStoreDayCounts). Operations rows are never deduplicated against each
86
+ * other or reordered among themselves, but a newer Mac row can push the oldest of them past `limit`.
87
+ */
88
+ export function withMacStoreRows(operations: MeetingMeta[], storeRows: MeetingMeta[], limit: number, held: ReadonlySet<string> = new Set()): MeetingMeta[] {
89
+ const sessions = new Set(operations.map(row => row.sessionId).filter(Boolean))
90
+ const mac = storeRows
91
+ // Keyed on the sidecar's session, as the save route keys the pipeline skip (MAC_CAPTURE_ID). The row's sourceType
92
+ // comes from the markdown's Capture source line instead, which an edited or older record can lack.
93
+ .filter(row => isMacCaptureId(row.sessionId) && !sessions.has(row.sessionId) && !held.has(row.sessionId!))
94
+ .map(withStandaloneIdentity)
95
+ if (mac.length === 0) return operations
96
+ return [...operations, ...mac]
97
+ .sort((a, b) => `${b.date}T${b.time || '00:00'}`.localeCompare(`${a.date}T${a.time || '00:00'}`) || b.filename.localeCompare(a.filename))
98
+ .slice(0, Math.max(limit, 1))
99
+ }
100
+
75
101
  function mergeMeetingSources(groups: MeetingMeta[][], limit: number, max = 50): MeetingMeta[] {
76
102
  const seenSessions = new Set<string>()
77
103
  const seenExact = new Set<string>()
@@ -213,7 +239,7 @@ function savedMeetingDetailWithContext(
213
239
  const source = detail.librarySource ?? (detail.recordId?.startsWith('standalone:') ? 'standalone_recordings' : undefined)
214
240
  if (source === 'standalone_recordings' && detail.contextKeys === undefined) {
215
241
  const sessionId = detail.sessionId || store.storedSessionId(descriptor.month, detail.filename || descriptor.filename)
216
- return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId })) }
242
+ return { ...detail, ...meetingContextFields(meetingContextKeys({ g2SessionId: sessionId, sourceType: detail.sourceType })) }
217
243
  }
218
244
  return withMeetingContext(detail, source)
219
245
  }
@@ -312,7 +338,11 @@ export function createMeetingsRouter(
312
338
  day: filters.day,
313
339
  })
314
340
  const declaredSessions = supersededFromRows(rows).g2Sessions
315
- const meetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
341
+ const operationsMeetings = dropSupersededRows(rows, { g2Sessions: declaredSessions, importRecordIds: new Set<string>(), isEmpty: declaredSessions.size === 0 })
342
+ // 6.73.14 (Miles 2026-10-15): routes/meeting.ts never hands a Mac recording to the pipeline (MAC_CAPTURE_ID),
343
+ // so on a pipeline Mac it lives only in this store and never appeared in Meetings; Control's hand-off read
344
+ // "Saved meeting not found yet" forever. The store's Mac rows join the operations rows here.
345
+ const meetings = withMacStoreRows(operationsMeetings, store.list({ ...listOptions, sessionMatch: isMacCaptureId }), limit, declaredSessions)
316
346
  // ONLY WHEN THE LIST SAW THE WHOLE MONTH. The row list is capped, and a capped
317
347
  // list knows about fewer merged scribes than the month holds, so handing its
318
348
  // sessions to an UNCAPPED day count would subtract too little and put the dot
@@ -320,7 +350,15 @@ export function createMeetingsRouter(
320
350
  const listSawWholeMonth = rows.length < limit
321
351
  res.json({
322
352
  meetings: meetings.map(meeting => withMeetingContext(meeting)),
323
- months: listCosOperationsMeetingMonths(domain),
353
+ // 6.73.14: a month holding only Mac recordings still reaches the calendar pager. Only store months the
354
+ // operations tree lacks are read, so a usual request reads nothing extra.
355
+ months: (() => {
356
+ const operationsMonths = listCosOperationsMeetingMonths(domain)
357
+ const known = new Set(operationsMonths)
358
+ const macOnly = store.listMonths().filter(month => !known.has(month)
359
+ && store.list({ month, domain, limit: 1, sessionMatch: isMacCaptureId }).length > 0)
360
+ return macOnly.length ? uniqueSortedMonths([operationsMonths, macOnly]) : operationsMonths
361
+ })(),
324
362
  // The rows above already read every scribe in this month and told us which
325
363
  // sessions the merged ones hold. Handing that over is the difference between
326
364
  // one read per file and two on every month request.
@@ -0,0 +1,27 @@
1
+ /** Same authenticated push-only key workflow as Jev. No key values or upstream bodies are returned. */
2
+ import { Router } from 'express'
3
+ import { existsSync, unlinkSync } from 'node:fs'
4
+ import { resetSubqDecisionClients } from '../lib/decision-provider.js'
5
+ import { saveSubqKey, subqKeyStatus, SUBQ_KEY_FILE, validateSubqKey } from '../lib/subq-key.js'
6
+ import { secureExistingPrivateFile } from '../lib/secure-user-config.js'
7
+ interface SubqKeyRouteDeps { status: typeof subqKeyStatus; save: (key:string)=>void; validate: typeof validateSubqKey; remove:()=>void; reset:()=>void }
8
+ export function createSubqKeyRouter(overrides: Partial<SubqKeyRouteDeps> = {}): Router {
9
+ const deps:SubqKeyRouteDeps={status:()=>subqKeyStatus(),save:key=>saveSubqKey(key),validate:validateSubqKey,remove:()=>{secureExistingPrivateFile(SUBQ_KEY_FILE);if(existsSync(SUBQ_KEY_FILE))unlinkSync(SUBQ_KEY_FILE)},reset:resetSubqDecisionClients,...overrides}
10
+ const router=Router();router.use('/subq-key',(_req,res,next)=>{res.set('Cache-Control','private, no-store');next()})
11
+ router.get('/subq-key/status',(_req,res)=>res.json(deps.status()))
12
+ router.post('/subq-key/set',async(req,res)=>{
13
+ const raw=req.body?.key
14
+ if (!req.body||typeof req.body!=='object'||Object.keys(req.body).some(k=>k!=='key')||typeof raw!=='string') return void res.status(400).json({error:{code:'invalid_subq_key',message:'Paste a SubQ API key.'}})
15
+ const key=raw.trim()
16
+ if(key.length<16||key.length>512||/[\s\x00-\x1f\x7f]/.test(key)) return void res.status(400).json({error:{code:'invalid_subq_key',message:'That does not look like a SubQ API key.'}})
17
+ const check=await deps.validate(key)
18
+ if(!check.ok)return void res.status(400).json({error:{code:'subq_key_not_accepted',message:check.reason,upstreamStatus:check.status}})
19
+ try{deps.save(key);deps.reset()}catch{return void res.status(500).json({error:{code:'subq_key_not_saved',message:'The key could not be saved. Check the server data folder.'}})}
20
+ res.json({ok:true,...deps.status()})
21
+ })
22
+ router.delete('/subq-key',(_req,res)=>{
23
+ try{deps.remove();deps.reset()}catch{return void res.status(500).json({error:{code:'subq_key_not_removed',message:'The saved key could not be removed.'}})}
24
+ res.json({ok:true,...deps.status()})
25
+ })
26
+ return router
27
+ }