@gotcos/glasses-server 6.76.1 → 6.78.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 (52) hide show
  1. package/.env.example +40 -0
  2. package/CHANGELOG.md +56 -0
  3. package/README.md +12 -1
  4. package/managed-runtime-contract.json +17 -1
  5. package/package.json +10 -1
  6. package/server/index.ts +98 -10
  7. package/server/lib/agent-session-binding-registry.ts +623 -9
  8. package/server/lib/boot-resume.ts +552 -0
  9. package/server/lib/claude-bridge.ts +152 -3
  10. package/server/lib/claude-cli-support.ts +39 -0
  11. package/server/lib/claude-steer.ts +259 -0
  12. package/server/lib/cos-bridge-notes.ts +73 -0
  13. package/server/lib/decision-provider.ts +9 -0
  14. package/server/lib/env-switch.ts +17 -0
  15. package/server/lib/fuzzy-correct.ts +77 -49
  16. package/server/lib/live-cos-ask.ts +41 -7
  17. package/server/lib/live-cues-v2-notes.ts +152 -0
  18. package/server/lib/live-cues-v2-settings.ts +63 -0
  19. package/server/lib/live-cues-v2.ts +198 -38
  20. package/server/lib/managed-restart.ts +8 -0
  21. package/server/lib/provider-actions.ts +18 -0
  22. package/server/lib/query-job-coordinator.ts +300 -0
  23. package/server/lib/query-job-feature.ts +39 -0
  24. package/server/lib/query-job-resume.ts +269 -0
  25. package/server/lib/query-job-runtime.ts +23 -3
  26. package/server/lib/query-job-store.ts +516 -7
  27. package/server/lib/query-job-types.ts +210 -1
  28. package/server/lib/queue-turn-evidence.ts +213 -0
  29. package/server/lib/thread-turn-queue-deliver.ts +6 -1
  30. package/server/lib/thread-turn-queue-store.ts +13 -4
  31. package/server/lib/thread-turn-queue.ts +151 -1
  32. package/server/lib/transcript-cleanup-context.ts +234 -0
  33. package/server/lib/transcript-cleanup-ledger.ts +304 -0
  34. package/server/lib/transcript-cleanup-model.ts +217 -0
  35. package/server/lib/transcript-cleanup-screen.ts +249 -0
  36. package/server/lib/transcript-cleanup-spans.ts +358 -0
  37. package/server/lib/transcript-cleanup-state.ts +201 -0
  38. package/server/lib/transcript-cleanup-suggestions.ts +126 -0
  39. package/server/lib/transcript-cleanup-text.ts +200 -0
  40. package/server/lib/transcript-cleanup.ts +427 -0
  41. package/server/routes/agent-session-bindings.ts +169 -11
  42. package/server/routes/glossary.ts +38 -19
  43. package/server/routes/health.ts +29 -1
  44. package/server/routes/live-cues.ts +32 -3
  45. package/server/routes/maintenance.ts +34 -0
  46. package/server/routes/meeting.ts +92 -9
  47. package/server/routes/query-jobs.ts +107 -13
  48. package/server/routes/recovery.ts +4 -0
  49. package/server/routes/thread-turn-queue.ts +371 -10
  50. package/server/routes/transcribe.ts +3 -0
  51. package/server/routes/transcript-cleanup.ts +237 -0
  52. package/shared/live-cues-v2.ts +9 -4
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 6.78.0: the ONE reader for every 6.78.0 switch (P7 S3). Before it, the Sessions switches
3
+ * took only an exact `1` or `0` while the Messages and crash-loop switches also took
4
+ * `true`/`false`/`on`/`off`, so `COS_QUEUE_RECONCILE=false` read ON.
5
+ *
6
+ * `1`, `true` or `on` force a switch on; `0`, `false` or `off` force it off; case and
7
+ * surrounding space are ignored. Anything else (unset or empty included) is the switch's
8
+ * DEFAULT. Callers read it at call time, so a restart applies a change.
9
+ *
10
+ * Pure, no imports: the Pi Durable canaries load modules that use it.
11
+ */
12
+ export function readSwitch(raw: string | undefined, fallback: boolean): boolean {
13
+ const value = (raw ?? '').trim().toLowerCase()
14
+ if (value === '1' || value === 'true' || value === 'on') return true
15
+ if (value === '0' || value === 'false' || value === 'off') return false
16
+ return fallback
17
+ }
@@ -4,7 +4,11 @@
4
4
  //
5
5
  // Used by:
6
6
  // - /api/transcribe (short-form dictation / messaging)
7
- // - /api/meeting/save (batch post-processing, Phase 3.5 — via meeting.ts)
7
+ // - prompt drafts (routes/prompt-drafts.ts)
8
+ // - the post-save transcript cleanup (6.77.0, transcript-cleanup.ts), one word at
9
+ // a time through fuzzyReplacementFor, with the meeting's own screen and file
10
+ // terms as extra targets. Not otherwise on the meeting save path: the Phase 3.5
11
+ // LLM pass this file once fed was deleted with the app's server fork.
8
12
  //
9
13
  // Zero LLM cost, ~5ms on typical dictation length.
10
14
  //
@@ -191,6 +195,72 @@ const COMMON_ENGLISH_WORDS = new Set<string>([
191
195
  'willie', 'zachary',
192
196
  ])
193
197
 
198
+ /** True for a word the stop list protects from ever being replaced (lowercase compare). */
199
+ export function isCommonEnglishWord(word: string): boolean {
200
+ return COMMON_ENGLISH_WORDS.has(word.toLowerCase())
201
+ }
202
+
203
+ /**
204
+ * The proper-noun targets fuzzy correction may write, in first-seen order: speaker
205
+ * tokens (Ext, Unknown, the owner label) and entries that do not start uppercase or
206
+ * are shorter than 4 characters are dropped.
207
+ */
208
+ export function canonicalFuzzyTargets(targets: string[]): string[] {
209
+ const owner = getOwnerSpeakerLabel()
210
+ return Array.from(new Set(
211
+ targets
212
+ .filter(t => {
213
+ if (!t || t.length < 4) return false
214
+ if (t === 'Ext' || t === 'Unknown' || t === owner) return false
215
+ return /^[A-Z]/.test(t.trim())
216
+ })
217
+ .map(t => t.trim()),
218
+ ))
219
+ }
220
+
221
+ /**
222
+ * The replacement fuzzy correction would write for ONE word, or null to leave it.
223
+ * `canonical` comes from canonicalFuzzyTargets. Same rules as applyFuzzyCorrections,
224
+ * which calls this for every candidate word.
225
+ */
226
+ export function fuzzyReplacementFor(word: string, canonical: string[], targetLowerSet?: ReadonlySet<string>): string | null {
227
+ if (!/^[A-Za-z][A-Za-z'-]{4,}$/.test(word)) return null
228
+ const lower = word.toLowerCase()
229
+
230
+ // Skip common English words (primary false-positive defense)
231
+ if (COMMON_ENGLISH_WORDS.has(lower)) return null
232
+
233
+ // Skip exact matches (already correct)
234
+ const exact = targetLowerSet ?? new Set(canonical.map(t => t.toLowerCase()))
235
+ if (exact.has(lower)) return null
236
+
237
+ // Length-scaled distance threshold
238
+ const len = word.length
239
+ const maxDist = len <= 8 ? 1 : len <= 11 ? 2 : 3
240
+
241
+ let bestTarget: string | null = null
242
+ let bestDist = maxDist + 1
243
+
244
+ for (const target of canonical) {
245
+ // Split multi-word targets — match on individual tokens
246
+ const tokens = target.split(/\s+/)
247
+ for (const token of tokens) {
248
+ if (token.length < 4) continue
249
+ // Length difference must be small to prevent collapses
250
+ if (Math.abs(token.length - len) > 2) continue
251
+ const dist = levenshtein(lower, token.toLowerCase())
252
+ if (dist > 0 && dist < bestDist && dist <= maxDist) {
253
+ bestDist = dist
254
+ bestTarget = token
255
+ }
256
+ }
257
+ }
258
+
259
+ if (!bestTarget) return null
260
+ // Preserve original capitalization pattern
261
+ return /^[A-Z]/.test(word) ? bestTarget : bestTarget.toLowerCase()
262
+ }
263
+
194
264
  /**
195
265
  * Fuzzy-match transcript words against a canonical list of proper nouns.
196
266
  *
@@ -218,16 +288,7 @@ export function applyFuzzyCorrections(
218
288
 
219
289
  // Keep only targets that look like proper nouns.
220
290
  // Drop speaker tokens and entries that don't start with uppercase.
221
- const owner = getOwnerSpeakerLabel()
222
- const canonical = Array.from(new Set(
223
- targets
224
- .filter(t => {
225
- if (!t || t.length < 4) return false
226
- if (t === 'Ext' || t === 'Unknown' || t === owner) return false
227
- return /^[A-Z]/.test(t.trim())
228
- })
229
- .map(t => t.trim()),
230
- ))
291
+ const canonical = canonicalFuzzyTargets(targets)
231
292
  if (canonical.length === 0) return { text, replacements: 0 }
232
293
 
233
294
  const targetLowerSet = new Set(canonical.map(t => t.toLowerCase()))
@@ -237,44 +298,11 @@ export function applyFuzzyCorrections(
237
298
  // Match any 5+ char word (case-insensitive). Stop-list + strict distance
238
299
  // thresholds are the primary defenses against false positives.
239
300
  const result = text.replace(/\b[A-Za-z][A-Za-z'-]{4,}\b/g, (word) => {
240
- const lower = word.toLowerCase()
241
-
242
- // Skip common English words (primary false-positive defense)
243
- if (COMMON_ENGLISH_WORDS.has(lower)) return word
244
-
245
- // Skip exact matches (already correct)
246
- if (targetLowerSet.has(lower)) return word
247
-
248
- // Length-scaled distance threshold
249
- const len = word.length
250
- const maxDist = len <= 8 ? 1 : len <= 11 ? 2 : 3
251
-
252
- let bestTarget: string | null = null
253
- let bestDist = maxDist + 1
254
-
255
- for (const target of canonical) {
256
- // Split multi-word targets — match on individual tokens
257
- const tokens = target.split(/\s+/)
258
- for (const token of tokens) {
259
- if (token.length < 4) continue
260
- // Length difference must be small to prevent collapses
261
- if (Math.abs(token.length - len) > 2) continue
262
- const dist = levenshtein(lower, token.toLowerCase())
263
- if (dist > 0 && dist < bestDist && dist <= maxDist) {
264
- bestDist = dist
265
- bestTarget = token
266
- }
267
- }
268
- }
269
-
270
- if (bestTarget) {
271
- replacements++
272
- fuzzyLog.push({ from: word, to: bestTarget })
273
- // Preserve original capitalization pattern
274
- const wasCapitalized = /^[A-Z]/.test(word)
275
- return wasCapitalized ? bestTarget : bestTarget.toLowerCase()
276
- }
277
- return word
301
+ const replacement = fuzzyReplacementFor(word, canonical, targetLowerSet)
302
+ if (replacement === null) return word
303
+ replacements++
304
+ fuzzyLog.push({ from: word, to: replacement })
305
+ return replacement
278
306
  })
279
307
 
280
308
  if (fuzzyLog.length > 0) {
@@ -11,6 +11,7 @@ import { createHash } from 'node:crypto'
11
11
  import { runLensGistEngine, redactSecrets } from './lens-gist-engines.js'
12
12
  import { AskProcessError } from './meeting-ask-runner.js'
13
13
  import { searchMemories } from './context-library-search.js'
14
+ import { listBridgeMemories, localDateOf, RankFusion, scoreBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
14
15
  import { searchMeetingLibrary } from './meeting-library-search.js'
15
16
  import { MEETING_ASK_LABELS, meetingAskAnswerMarkdown, meetingAskCitations, type MeetingAskSection, type MeetingAskPhaseTiming, type MeetingAskTimings } from '../../shared/meeting-ask.js'
16
17
  import { ABSENCE_CLAIM, matchOwnerName, ownerCardSource, personLookupQuery, personQuestion, searchedDisclosure, type OwnerCard } from './meeting-ask-owner.js'
@@ -240,18 +241,51 @@ export function sectionsFromAnswer(action: AskAction, answer: string, allowed: R
240
241
  }
241
242
  /** 'meaning' adds the embedding search (default); 'keyword' stays on local keyword matching, so no query is embedded. */
242
243
  export type CosSearchMode = 'meaning' | 'keyword'
243
- export async function searchAskHistory(query: string, excludeSessionId?: string, signal?: AbortSignal, mode: CosSearchMode = 'meaning', domain?: string): Promise<{ sources: AskSource[]; notices: string[] }> {
244
+ /** The memory bridge listing searchAskHistory falls back to (cos-bridge-notes.ts); a parameter so tests can fake it. */
245
+ export type AskBridgeList = (signal?: AbortSignal) => Promise<BridgeMemory[]>
246
+ const ASK_HIT_MIN = 0.35
247
+ export async function searchAskHistory(query: string, excludeSessionId?: string, signal?: AbortSignal, mode: CosSearchMode = 'meaning', domain?: string, bridge: AskBridgeList = s => listBridgeMemories(s)): Promise<{ sources: AskSource[]; notices: string[] }> {
244
248
  const semantic = mode !== 'keyword'
245
249
  const results = await Promise.allSettled([searchMemories({ query, limit: domain ? 50 : 3, semantic, ...(signal?{signal}:{}) }), searchMeetingLibrary({ query, limit: 3, semantic, ...(domain ? {domain} : {}), ...(signal?{signal}:{}) })])
246
250
  const sources: AskSource[] = [], notices: string[] = semantic ? [] : ['COS search used keywords only, as set in Meeting assistant settings.']
247
251
  if (domain) notices.push(`COS search focused on the confirmed ${domain.replace(/_/g, ' ')} domain.`)
248
- results.forEach((result, i) => {
249
- if (result.status === 'rejected') { if(result.reason instanceof AskProcessError)throw result.reason; notices.push(`${i === 0 ? 'COS memory' : 'Earlier meeting'} search is unavailable.`); return }
250
- if (semantic && !result.value.semanticAvailable) notices.push(`${i === 0 ? 'COS memory' : 'Earlier meeting'} semantic search is unavailable; local keyword results are shown.`)
251
- result.value.hits.filter(h => (!domain || h.domain === domain) && (!excludeSessionId || !('sessionId' in h) || h.sessionId !== excludeSessionId) && Math.max(h.keywordScore, h.semanticScore) >= 0.35).slice(0, 3).forEach(h => {
252
- sources.push({ id: `H${sources.length + 1}`, kind: i === 0 ? 'memory' : 'meeting', title: redactSecrets(h.title + ('date' in h && h.date ? ' · ' + h.date : '')).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), ...('recordId' in h ? { recordId: h.recordId, sessionId: h.sessionId, domain: h.domain, month: h.month, filename: h.filename } : { memoryId: h.id, domain: h.domain }) })
252
+ for (const result of results) if (result.status === 'rejected' && result.reason instanceof AskProcessError) throw result.reason
253
+ const keep = (h: { domain?: string; keywordScore: number; semanticScore: number; sessionId?: string }) =>
254
+ (!domain || h.domain === domain) && (!excludeSessionId || !('sessionId' in h) || h.sessionId !== excludeSessionId) && Math.max(h.keywordScore, h.semanticScore) >= ASK_HIT_MIN
255
+ // id first: the same key order as before, so an unchanged search yields the same prompt bytes.
256
+ const source = (s: Omit<AskSource, 'id'>): AskSource => ({ id: `H${sources.length + 1}`, ...s })
257
+ // Memories. 6.76.2: when memory search did not answer (it failed, its meaning arm failed, or it found nothing), the
258
+ // owner's notes are also read from the memory bridge listing and matched by keyword (cos-bridge-notes.ts, shared with
259
+ // live cues). The public memory runtime keeps them only there. A memory search that answers is used exactly as before.
260
+ const memory = results[0]
261
+ if (memory.status === 'rejected') notices.push('COS memory search is unavailable.')
262
+ else if (semantic && !memory.value.semanticAvailable) notices.push('COS memory semantic search is unavailable; local keyword results are shown.')
263
+ const memoryRaw = memory.status === 'fulfilled' ? memory.value.hits : []
264
+ const memoryHits = memoryRaw.filter(keep)
265
+ const fused = new RankFusion<Omit<AskSource, 'id'> & { listed?: true }>()
266
+ fused.add(memoryHits.map(h => ({ key: `memory:${h.id}`, item: { kind: 'memory' as const, title: redactSecrets(h.title).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), memoryId: h.id, domain: h.domain } })))
267
+ const memoryDidNotAnswer = memoryRaw.length === 0 || (memory.status === 'fulfilled' && semantic && !memory.value.semanticAvailable)
268
+ if (memoryDidNotAnswer && !signal?.aborted) {
269
+ let listed: BridgeMemory[] = []
270
+ try { listed = await bridge(signal) } catch { /* no listing: the notices above stand */ }
271
+ fused.add(scoreBridgeMemories(listed, query).filter(x => x.score >= ASK_HIT_MIN && (!domain || x.row.domain === domain)).map(({ row }) => {
272
+ const date = localDateOf(row.created_at)
273
+ const title = row.summary || row.content.split('\n').find(l => l.trim()) || row.id
274
+ return { key: `memory:${row.id}`, item: { kind: 'memory' as const, title: redactSecrets(title.replace(/\s+/g, ' ').trim().slice(0, 140) + (date ? ' · ' + date : '')).slice(0, 180), text: redactSecrets(row.content).slice(0, 1500), memoryId: row.id, domain: row.domain, listed: true as const } }
275
+ }))
276
+ }
277
+ const memorySources = fused.ranked(3)
278
+ for (const { listed, ...s } of memorySources) sources.push(source(s))
279
+ if (memorySources.some(s => s.listed)) notices.push('COS also searched the notes in its memory list by keyword.')
280
+ // Earlier meetings: unchanged.
281
+ const meetings = results[1]
282
+ if (meetings.status === 'rejected') notices.push('Earlier meeting search is unavailable.')
283
+ else {
284
+ if (semantic && !meetings.value.semanticAvailable) notices.push('Earlier meeting semantic search is unavailable; local keyword results are shown.')
285
+ meetings.value.hits.filter(keep).slice(0, 3).forEach(h => {
286
+ sources.push(source({ kind: 'meeting', title: redactSecrets(h.title + (h.date ? ' · ' + h.date : '')).slice(0, 180), text: redactSecrets(h.snippet).slice(0, 1500), recordId: h.recordId, sessionId: h.sessionId, domain: h.domain, month: h.month, filename: h.filename }))
253
287
  })
254
- })
288
+ }
255
289
  if (!sources.length) notices.push('No relevant COS history was found.')
256
290
  return { sources, notices }
257
291
  }
@@ -0,0 +1,152 @@
1
+ // Live cues v2 notes fallback (6.76.2). When cue_memory.py is missing, fails or times out (any Mac but Miles's has no such
2
+ // script), the memory arm looks in the owner's own COS instead, with no model call and no query leaving the Mac:
3
+ // - searchMemories and searchMeetingLibrary, the search the Live COS Ask uses, in its keyword-only mode (semantic: false),
4
+ // - the memory bridge listing (`cos_api_bridge.py memory`, what COS Control's Memory list shows), where the public memory
5
+ // runtime 0.1.0 keeps the owner's notes (cos-bridge-notes.ts, shared with the Live COS Ask).
6
+ // The window is never sent as a query. A few salient words are taken from it, and the candidates are ranked by
7
+ // reciprocal rank fusion (k=60, as cue_memory.py does). The Jev or SubQ judge then decides relevance, exactly as for
8
+ // cue_memory candidates. The whole arm is bounded by the 3 s dense budget of cue_memory.py. Nothing here logs.
9
+
10
+ import { searchMemories, type ContextSearchHit } from './context-library-search.js'
11
+ import { searchMeetingLibrary, type MeetingSearchHit } from './meeting-library-search.js'
12
+ import { listBridgeMemories, localDateOf, RankFusion, scoreBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
13
+ export { localDateOf, normalizeBridgeMemories, type BridgeMemory } from './cos-bridge-notes.js'
14
+
15
+ /** Mirrors cue_memory.py DENSE_BUDGET_S: the notes arm never takes longer than its dense arm may. */
16
+ export const NOTES_BUDGET_MS = 3_000
17
+ export const NOTES_TOP = 4
18
+ const NOTE_CHARS = 520
19
+ const TITLE_CHARS = 90
20
+
21
+ export interface NoteCandidate { id: string; date: string; title: string; notes: string; key: string }
22
+ export interface NotesResult { notes: NoteCandidate[]; error?: string }
23
+
24
+ export interface NotesSearchers {
25
+ memories(query: string, signal: AbortSignal): Promise<ContextSearchHit[]>
26
+ meetings(query: string, signal: AbortSignal): Promise<MeetingSearchHit[]>
27
+ bridge(signal: AbortSignal, timeoutMs: number): Promise<BridgeMemory[]>
28
+ }
29
+
30
+ // cue_memory.py STOP, plus conversational filler that ASR produces and that would match every note.
31
+ const STOP = new Set(`a an the and or but if then so of to in on at for with from by as is are was were be been being it its this that these those
32
+ i you he she we they me him her us them my your our their what which who whom whose when where why how all any both each few more most other
33
+ some such no nor not only own same than too very can will just don should now do does did doing have has had having would could might must
34
+ shall may yeah yes okay ok like um uh gonna wanna kind sort really right know think going get got one two also there here about into over again
35
+ further once out up down off say said says thing things lot gotta yep nope hmm mhm let lets thats theres dont didnt doesnt cant wont isnt im ive
36
+ youre were theyre hes shes its okay alright actually basically literally maybe probably pretty much well good great cool sure thank thanks
37
+ because cause want wanted need needs make made take took come came look looking see saw tell told talk talking mean means time today tomorrow
38
+ yesterday week year day back still even something someone anything everything nothing way guess feel`.split(/\s+/).filter(Boolean))
39
+
40
+ // Capitalised in every sentence position, so a capital says nothing about them being a name.
41
+ const CALENDAR = new Set('monday tuesday wednesday thursday friday saturday sunday january february march april may june july august september october november december'.split(' '))
42
+
43
+ /** The most salient words of the window, most salient first: frequency, a bonus for the last ~600 characters (what the
44
+ * room is on now, the slice cue_memory.py embeds) and for a capitalised use mid-sentence (likely a name). Tokens use the
45
+ * library tokenizer's alphabet ([a-z0-9]) so every term can match. */
46
+ export function notesQueryTerms(window: string, max = 6): string[] {
47
+ const recentFrom = Math.max(0, window.length - 600)
48
+ const stats = new Map<string, { n: number; recent: boolean; proper: boolean; last: number }>()
49
+ for (const m of window.matchAll(/[A-Za-z0-9]+/g)) {
50
+ const raw = m[0], term = raw.toLowerCase(), at = m.index ?? 0
51
+ if (term.length < 3 || STOP.has(term) || /^\d{1,2}$/.test(term)) continue
52
+ const st = stats.get(term) ?? { n: 0, recent: false, proper: false, last: 0 }
53
+ st.n += 1
54
+ st.last = at
55
+ if (at >= recentFrom) st.recent = true
56
+ const before = window.slice(Math.max(0, at - 2), at)
57
+ if (/^[A-Z]/.test(raw) && !/[.!?\n]\s*$/.test(before) && at > 0) st.proper = true
58
+ stats.set(term, st)
59
+ }
60
+ return [...stats.entries()]
61
+ .map(([term, st]) => ({ term, score: st.n + (st.recent ? 0.5 : 0) + (st.proper && !CALENDAR.has(term) ? 1 : 0), last: st.last }))
62
+ .sort((a, b) => b.score - a.score || b.last - a.last)
63
+ .slice(0, max)
64
+ .map(x => x.term)
65
+ }
66
+
67
+ /** Two keyword queries: the top four terms (a note must share two of them, or one in its title, to match) and the top
68
+ * two (either one matches). Distinct, in that order. */
69
+ export function notesQueries(terms: string[]): string[] {
70
+ const qs = [terms.slice(0, 4).join(' '), terms.slice(0, 2).join(' ')].filter(q => q.trim())
71
+ return [...new Set(qs)]
72
+ }
73
+
74
+ const dateOf = localDateOf
75
+ const flat = (v: unknown, max: number): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
76
+
77
+ function memoryCandidate(hit: ContextSearchHit): NoteCandidate | null {
78
+ if (!hit.id) return null
79
+ return { id: '', date: dateOf(hit.created_at), title: flat(hit.title || hit.id, TITLE_CHARS), notes: flat(hit.snippet || hit.title, NOTE_CHARS), key: `memory:${hit.id}` }
80
+ }
81
+ function meetingCandidate(hit: MeetingSearchHit): NoteCandidate | null {
82
+ if (!hit.recordId) return null
83
+ return { id: '', date: dateOf(hit.date), title: flat(hit.title, TITLE_CHARS), notes: flat(hit.snippet || hit.title, NOTE_CHARS), key: `meeting:${hit.recordId}` }
84
+ }
85
+ function bridgeCandidate(row: BridgeMemory): NoteCandidate {
86
+ const firstLine = row.content.split('\n').find(l => l.trim()) ?? row.id
87
+ return { id: '', date: dateOf(row.created_at), title: flat(row.summary || firstLine, TITLE_CHARS), notes: flat(row.content, NOTE_CHARS), key: `memory:${row.id}` }
88
+ }
89
+
90
+ /** Bridge rows ranked for one query, best first (the shared scorer). */
91
+ export function rankBridgeMemories(rows: BridgeMemory[], query: string): BridgeMemory[] {
92
+ return scoreBridgeMemories(rows, query).map(x => x.row)
93
+ }
94
+
95
+ export const realNotesSearchers: NotesSearchers = {
96
+ memories: async (query, signal) => (await searchMemories({ query, limit: 10, semantic: false, signal })).hits,
97
+ meetings: async (query, signal) => (await searchMeetingLibrary({ query, limit: 10, semantic: false, signal })).hits,
98
+ bridge: (signal, timeoutMs) => listBridgeMemories(signal, timeoutMs),
99
+ }
100
+
101
+ /**
102
+ * Candidates for the judge from the owner's own COS. `before` is exclusive (YYYY-MM-DD), like cue_memory.py; a note with
103
+ * no date is kept. `sessionId` drops the meeting being recorded if the library already holds a copy of it. Never throws:
104
+ * a source that fails is skipped, and running out of budget returns what was found so far with `error: notes_timeout`.
105
+ */
106
+ export async function notesMemory(window: string, before: string, signal: AbortSignal,
107
+ opts: { sessionId?: string; budgetMs?: number; searchers?: NotesSearchers; top?: number } = {}): Promise<NotesResult> {
108
+ const budgetMs = opts.budgetMs ?? NOTES_BUDGET_MS
109
+ const searchers = opts.searchers ?? realNotesSearchers
110
+ const top = opts.top ?? NOTES_TOP
111
+ const terms = notesQueryTerms(window)
112
+ if (!terms.length) return { notes: [], error: 'notes_no_terms' }
113
+ const queries = notesQueries(terms)
114
+ const local = new AbortController()
115
+ const onAbort = () => local.abort()
116
+ signal.addEventListener('abort', onAbort, { once: true })
117
+ if (signal.aborted) local.abort()
118
+
119
+ const fused = new RankFusion<NoteCandidate>()
120
+ const failed: string[] = []
121
+ // The date bound applies before ranking, so a dropped note never takes a rank from one that stays.
122
+ const add = (list: Array<NoteCandidate | null>) => {
123
+ fused.add(list.filter((c): c is NoteCandidate => !!c && !(c.date && c.date >= before)).map(c => ({ key: c.key, item: c })))
124
+ }
125
+ const dropLive = (hits: MeetingSearchHit[]) => hits.filter(h => !(opts.sessionId && h.sessionId === opts.sessionId))
126
+
127
+ const work = (async () => {
128
+ const bridge = searchers.bridge(local.signal, budgetMs).catch(() => { failed.push('bridge'); return [] as BridgeMemory[] })
129
+ for (const q of queries) {
130
+ if (local.signal.aborted) return
131
+ try { add((await searchers.memories(q, local.signal)).map(memoryCandidate)) } catch { failed.push('memories') }
132
+ if (local.signal.aborted) return
133
+ try { add(dropLive(await searchers.meetings(q, local.signal)).map(meetingCandidate)) } catch { failed.push('meetings') }
134
+ }
135
+ const rows = await bridge
136
+ if (local.signal.aborted) return
137
+ for (const q of queries) add(rankBridgeMemories(rows, q).map(bridgeCandidate))
138
+ })()
139
+
140
+ let timer: ReturnType<typeof setTimeout> | undefined
141
+ const timedOut = await Promise.race([
142
+ work.then(() => false),
143
+ new Promise<boolean>(r => { timer = setTimeout(() => r(true), budgetMs) }),
144
+ ])
145
+ clearTimeout(timer)
146
+ local.abort()
147
+ signal.removeEventListener('abort', onAbort)
148
+ const notes = fused.ranked(top).map((c, i) => ({ ...c, id: `M${i + 1}` }))
149
+ if (signal.aborted) return { notes: [] }
150
+ if (timedOut) return { notes, error: 'notes_timeout' }
151
+ return failed.length ? { notes, error: `notes_${[...new Set(failed)].sort().join('_')}_failed` } : { notes }
152
+ }
@@ -0,0 +1,63 @@
1
+ // Live cues v2 runtime switch (6.76.2). COS Control offers an on/off toggle; the choice is kept in this server's own data
2
+ // dir and applies without a restart. Resolution order:
3
+ // 1. env COS_LIVE_CUES_V2=1 forces v2 on (source 'env'); a request to turn it off is refused (409 env_forced).
4
+ // 2. the saved setting (source 'setting').
5
+ // 3. off (source 'default').
6
+ // Only the exact value '1' forces. Any other env value (unset, '0', 'true') defers to the setting, as it always read as off.
7
+ // An unreadable or malformed settings file reads as no setting, so it fails toward off, never toward spending.
8
+
9
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
10
+ import { dirname } from 'node:path'
11
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
12
+ import { dataPath } from './data-dir.js'
13
+
14
+ export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
15
+
16
+ /** What the engine and the routes need from the store: the saved choice, or null when there is none. */
17
+ export interface LiveCuesV2SettingReader { stored(): boolean | null }
18
+
19
+ export class LiveCuesV2Settings implements LiveCuesV2SettingReader {
20
+ /** Loaded once; only this server writes the file, and every write goes through save(). */
21
+ private cache: { value: boolean | null } | null = null
22
+ constructor(readonly file: string) {}
23
+
24
+ stored(): boolean | null {
25
+ if (this.cache) return this.cache.value
26
+ let value: boolean | null = null
27
+ try {
28
+ if (existsSync(this.file)) {
29
+ const raw = JSON.parse(readFileSync(this.file, 'utf8')) as unknown
30
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
31
+ value = (raw as { enabled: boolean }).enabled
32
+ }
33
+ }
34
+ } catch { value = null }
35
+ this.cache = { value }
36
+ return value
37
+ }
38
+
39
+ /** Atomic and durable. The in-memory value changes only after the file is written, so a failed write changes nothing. */
40
+ save(enabled: boolean, now: Date = new Date()): void {
41
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
42
+ durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, enabled, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
43
+ this.cache = { value: enabled }
44
+ }
45
+ }
46
+
47
+ export function liveCuesV2EnvForced(env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return env.COS_LIVE_CUES_V2 === '1'
49
+ }
50
+
51
+ export function resolveLiveCuesV2(env: NodeJS.ProcessEnv, settings: LiveCuesV2SettingReader | null): { enabled: boolean; source: LiveCuesV2SettingSource } {
52
+ if (liveCuesV2EnvForced(env)) return { enabled: true, source: 'env' }
53
+ let stored: boolean | null = null
54
+ try { stored = settings ? settings.stored() : null } catch { stored = null }
55
+ if (stored !== null) return { enabled: stored, source: 'setting' }
56
+ return { enabled: false, source: 'default' }
57
+ }
58
+
59
+ let singleton: LiveCuesV2Settings | null = null
60
+ export function liveCuesV2Settings(): LiveCuesV2Settings {
61
+ if (!singleton) singleton = new LiveCuesV2Settings(dataPath('live-cues-v2-settings.json'))
62
+ return singleton
63
+ }