@gotcos/glasses-server 6.73.3 → 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.
- package/CHANGELOG.md +74 -0
- package/package.json +2 -1
- package/server/index.ts +7 -0
- package/server/lib/ask-session-id.ts +5 -0
- package/server/lib/decision-provider.ts +163 -0
- package/server/lib/decision-settings.ts +72 -0
- package/server/lib/glossary-learn.ts +221 -0
- package/server/lib/imported-library-rows.ts +62 -4
- package/server/lib/jev.ts +3 -3
- package/server/lib/lens-gist-engines.ts +7 -2
- package/server/lib/live-cos-ask.ts +2 -1
- package/server/lib/mac-recording-asr.ts +59 -0
- package/server/lib/mac-recording.ts +111 -0
- package/server/lib/maintenance-lifecycle.ts +1 -0
- package/server/lib/meeting-actions.ts +1 -0
- package/server/lib/meeting-ask-follow-up.ts +2 -2
- package/server/lib/meeting-ask-runner.ts +26 -5
- package/server/lib/meeting-ask-store.ts +2 -1
- package/server/lib/meeting-batch-transcribe.ts +19 -4
- package/server/lib/meeting-context-keys.ts +7 -4
- package/server/lib/meeting-parse.ts +2 -0
- package/server/lib/meeting-store.ts +62 -9
- package/server/lib/session-recommendation.ts +12 -9
- package/server/lib/speaker-embeddings.ts +2 -2
- package/server/lib/stranded-session-actions.ts +15 -0
- package/server/lib/subq-key.ts +38 -0
- package/server/lib/transcribe-audio.ts +9 -1
- package/server/lib/work-activity.ts +2 -2
- package/server/lib/work-chat.ts +122 -0
- package/server/lib/work-completion.ts +11 -8
- package/server/lib/work-intake-store.ts +2 -1
- package/server/lib/work-search.ts +32 -12
- package/server/routes/client-instance.ts +1 -1
- package/server/routes/decision-provider.ts +36 -0
- package/server/routes/glossary.ts +143 -11
- package/server/routes/jev.ts +3 -2
- package/server/routes/meeting.ts +44 -12
- package/server/routes/meetings.ts +42 -4
- package/server/routes/subq-key.ts +27 -0
- package/server/routes/transcribe-stream.ts +115 -9
- package/server/routes/transcribe.ts +20 -2
- package/server/routes/work-board.ts +42 -3
- package/server/routes/work-chat.ts +31 -0
- 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:
|
|
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
|
|
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)
|
package/server/routes/jev.ts
CHANGED
|
@@ -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() })
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|