@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
@@ -63,6 +63,8 @@
63
63
  // event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
64
64
  // session-trail handoff, one layer down.
65
65
 
66
+ import { readSwitch } from './env-switch.js'
67
+
66
68
  /** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
67
69
  export type QueuedTurnStatus =
68
70
  | 'waiting'
@@ -93,6 +95,107 @@ export interface QueuedThreadTurn {
93
95
  * only for a client that said it would show it. Absent: the 6.61 Ask posture.
94
96
  */
95
97
  continueNoteAck?: true
98
+ // ---------------------------------------------------------------- 6.78.0 (P3)
99
+ // Every field below is OPTIONAL and written only while its flag is on, so with
100
+ // COS_QUEUE_RECONCILE, COS_QUEUE_RESEND and COS_SESSION_STEER all off a queue file is
101
+ // byte-for-byte what 6.77.0 writes. A 6.77 server reading a 6.78 file ignores them.
102
+ /**
103
+ * Stamped (`1`) when this row entered `delivering` under the attempt journal (6.78.0).
104
+ * Its ABSENCE on a `delivering` row is the whole point: a row with no stamp was written by
105
+ * a build that kept no attempt record, so "no attempt on file" proves nothing about it and
106
+ * the reconcile reads it as unknown, never as unsent.
107
+ */
108
+ journal?: 1
109
+ /** The server boot that moved this row to `delivering`. A different boot means it was cut off. */
110
+ deliveringBootId?: string
111
+ /** `unknown`: COS cannot establish whether the words reached the session. Never resent. */
112
+ deliveryState?: 'unknown'
113
+ /** `steer`: sent into the running turn through the session's peer inbox. Absent: `queue`. */
114
+ mode?: 'steer'
115
+ /** Send again: the row this one was copied from. */
116
+ resendOf?: string
117
+ /** Send again: the row that was copied from this one. Set once, and only once. */
118
+ resentAs?: string
119
+ }
120
+
121
+ // ------------------------------------------------------------------- 6.78.0 flags
122
+ //
123
+ // Read at CALL time through the one 6.78.0 switch reader (lib/env-switch.ts): `1`, `true`
124
+ // or `on` is on, `0`, `false` or `off` is off, anything else is the default (P7 S3: an exact
125
+ // `0`/`1` reader here made `COS_QUEUE_RECONCILE=false` read ON). One reader per flag, so
126
+ // every caller (routes, registry, drain, health) decides with the same answer in the same tick.
127
+ function triStateFlag(env: NodeJS.ProcessEnv, name: string, fallback: boolean): boolean {
128
+ return readSwitch(env[name], fallback)
129
+ }
130
+
131
+ /** `COS_QUEUE_RECONCILE`: the attempt journal, the restart reconcile, receipts, unknown-row retention and reason copy. Default ON. */
132
+ export function queueReconcileEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
133
+ return triStateFlag(env, 'COS_QUEUE_RECONCILE', true)
134
+ }
135
+
136
+ /** `COS_PIN_BOOT_REAP`: release a pin a dead boot left, once its orphan child must be gone (21 min). Default ON. */
137
+ export function pinBootReapEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
138
+ return triStateFlag(env, 'COS_PIN_BOOT_REAP', true)
139
+ }
140
+
141
+ /** `COS_QUEUE_RESEND`: Send again on a refused or expired row. Default ON. */
142
+ export function queueResendEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
143
+ return triStateFlag(env, 'COS_QUEUE_RESEND', true)
144
+ }
145
+
146
+ /** `COS_SESSION_STEER`: steer a busy Claude session through its peer inbox. Default OFF. */
147
+ export function sessionSteerEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
148
+ return triStateFlag(env, 'COS_SESSION_STEER', false)
149
+ }
150
+
151
+ /**
152
+ * Reasons whose outcome COS cannot establish. Such a row is never resent and is kept for
153
+ * UNKNOWN_DELIVERY_RETENTION_MS rather than 30 minutes, because the phone has to be able to
154
+ * read "may not have been sent" for as long as the person might come back to it.
155
+ */
156
+ export const UNKNOWN_DELIVERY_REASONS: ReadonlySet<string> = new Set([
157
+ 'delivery_unknown_after_restart',
158
+ 'steer_unverified',
159
+ 'delivery_ambiguous',
160
+ ])
161
+
162
+ /** How long a settled row with an unknown outcome stays readable. */
163
+ export const UNKNOWN_DELIVERY_RETENTION_MS = 6 * 60 * 60 * 1000
164
+
165
+ /** The lens body is 62 columns; every reason line fits one. */
166
+ export const QUEUED_TURN_REASON_COPY_MAX = 62
167
+
168
+ /** One line per reason, server-authored. Keys are the reasons a row can carry. */
169
+ const QUEUED_TURN_REASON_COPY: Readonly<Record<string, string>> = {
170
+ delivery_unknown_after_restart: 'May not have been sent. Read it, then say it again.',
171
+ steer_unverified: 'May not have been sent. Read it, then say it again.',
172
+ delivery_ambiguous: 'May not have been sent. Read it, then say it again.',
173
+ steered: 'Sent into the running turn.',
174
+ reconciled_after_restart: 'Sent before the server restarted.',
175
+ requeued_after_restart: 'Not sent before the restart. Waiting to send.',
176
+ queued_turn_expired: 'Not sent: the thread stayed busy too long.',
177
+ delivery_attempts_exhausted: 'Not sent: the session refused it five times.',
178
+ turn_cancelled: 'Cancelled before it was sent.',
179
+ steer_not_sent: 'Not sent: the session could not take it mid-turn.',
180
+ native_thread_working: 'Waiting for the current turn to end.',
181
+ live_desktop_process: 'Waiting for the current turn to end.',
182
+ native_target_busy: 'Waiting: another COS turn holds this thread.',
183
+ native_turn_in_progress: 'Waiting: a COS turn is running on this thread.',
184
+ native_target_fenced: 'Waiting: an earlier turn needs a check on your Mac.',
185
+ }
186
+
187
+ /**
188
+ * The reason line for a queue row, or null when there is nothing to say (no reason, or one
189
+ * this build has no words for: a raw code is never shown as if it were a sentence).
190
+ */
191
+ export function queuedTurnReasonCopy(reason: string | null | undefined): string | null {
192
+ if (typeof reason !== 'string' || reason.length === 0) return null
193
+ return Object.prototype.hasOwnProperty.call(QUEUED_TURN_REASON_COPY, reason) ? QUEUED_TURN_REASON_COPY[reason] : null
194
+ }
195
+
196
+ /** Every reason with copy, for the column test. */
197
+ export function queuedTurnReasonCopyKeys(): string[] {
198
+ return Object.keys(QUEUED_TURN_REASON_COPY)
96
199
  }
97
200
 
98
201
  /**
@@ -286,10 +389,14 @@ export function pruneQueue(
286
389
  now: number,
287
390
  settledRetentionMs: number = 30 * 60 * 1000,
288
391
  ): QueuedThreadTurn[] {
392
+ // 6.78.0: a row whose outcome is unknown stays for six hours (COS_QUEUE_RECONCILE). Read
393
+ // once per prune, so one pass cannot keep some rows by the new rule and drop others by the old.
394
+ const keepUnknown = queueReconcileEnabled()
289
395
  return queue.filter(t => {
290
396
  if (t.status === 'waiting' || t.status === 'delivering') return true
291
397
  const settled = typeof t.settledAt === 'number' ? t.settledAt : t.queuedAt
292
- return now - settled < settledRetentionMs
398
+ const unknown = keepUnknown && (t.deliveryState === 'unknown' || (typeof t.reason === 'string' && UNKNOWN_DELIVERY_REASONS.has(t.reason)))
399
+ return now - settled < (unknown ? Math.max(settledRetentionMs, UNKNOWN_DELIVERY_RETENTION_MS) : settledRetentionMs)
293
400
  })
294
401
  }
295
402
 
@@ -297,3 +404,46 @@ export function pruneQueue(
297
404
  export function queuePosition(queue: readonly QueuedThreadTurn[], clientTurnId: string): number {
298
405
  return queue.filter(t => t.status === 'waiting').findIndex(t => t.clientTurnId === clientTurnId)
299
406
  }
407
+
408
+ // ------------------------------------------------------------- 6.78.0 session steer
409
+
410
+ /** Why a busy session cannot take a steer right now (the wire enum, plan v3.1 section 5.3). */
411
+ export type SessionSteerUnavailable = 'no_outside_channel' | 'engine_limit' | 'permission_pending'
412
+
413
+ export type SessionSteerAvailability =
414
+ | { available: true; mode: 'peer_inbox' }
415
+ | { available: false; reason: SessionSteerUnavailable }
416
+
417
+ /** Occupancy reasons that mean "a session outside COS is running this thread". Only those can be steered. */
418
+ const STEERABLE_GATE_REASONS: ReadonlySet<string> = new Set(['native_thread_working', 'live_desktop_process'])
419
+
420
+ /**
421
+ * Can a turn go INTO the running turn of this thread now (6.78.0, COS_SESSION_STEER)?
422
+ *
423
+ * PURE, one rule for the attach refusal's `steer` field and the queued-turns POST, so the
424
+ * row the lens offers and the answer to the tap agree.
425
+ * cursor engine_limit: the Stop hook is its only door
426
+ * codex no_outside_channel: `turn/steer` lives in an app-server COS does not hold
427
+ * claude, live hop off no_outside_channel (`COS_CONTINUE_LIVE=0`)
428
+ * claude, not a desk run no_outside_channel: COS's own binding or turn, a fence, or a
429
+ * thread nobody could measure; nothing outside COS to fold into
430
+ * claude, no peer inbox no_outside_channel
431
+ * claude, held on a prompt permission_pending: a session waiting on a dialog reaches no
432
+ * tool boundary, and the steer verify window (4 s) is far shorter
433
+ * than the hook's wait (630 s); it is queued for the turn's end
434
+ */
435
+ export function sessionSteerAvailability(input: {
436
+ provider: string
437
+ gateReason: string | null
438
+ continueLive: boolean
439
+ peerInbox: boolean
440
+ waitingOnPrompt: boolean
441
+ }): SessionSteerAvailability {
442
+ if (input.provider === 'cursor') return { available: false, reason: 'engine_limit' }
443
+ if (input.provider !== 'claude') return { available: false, reason: 'no_outside_channel' }
444
+ if (!input.continueLive) return { available: false, reason: 'no_outside_channel' }
445
+ if (typeof input.gateReason !== 'string' || !STEERABLE_GATE_REASONS.has(input.gateReason)) return { available: false, reason: 'no_outside_channel' }
446
+ if (!input.peerInbox) return { available: false, reason: 'no_outside_channel' }
447
+ if (input.waitingOnPrompt) return { available: false, reason: 'permission_pending' }
448
+ return { available: true, mode: 'peer_inbox' }
449
+ }
@@ -0,0 +1,234 @@
1
+ // Transcript cleanup (6.77.0): what the cleanup knows about a meeting besides its audio.
2
+ //
3
+ // TEXTUAL AWARENESS. The meeting's title and attendees, the enrolled speaker names (COS
4
+ // people), the owner card (meeting-ask-owner.ts), the profile vocabulary and the
5
+ // heard-to-written corrections (the COS dictionary, which dictation also uses), the text
6
+ // of files dropped on the meeting (the Meeting Ask file list), and a few local notes hits
7
+ // for the names the doubtful passages are near (searchMemories and searchMeetingLibrary in
8
+ // keyword-only mode: nothing leaves the Mac).
9
+ //
10
+ // SCREEN AWARENESS. On-screen text lines COS Control recorded during the meeting
11
+ // (transcript-cleanup-screen.ts), shown beside each passage by time.
12
+ //
13
+ // TERMS. Every source contributes terms: capitalised words and runs of them ("Queen
14
+ // Ukaoma", "Jewel360"). Terms are the per-meeting vocabulary: deterministic correction
15
+ // may write a term from this meeting's screen or files, the model may write any term,
16
+ // and a term the model writes that came from the meeting (not the dictionary) becomes a
17
+ // dictionary SUGGESTION, never an automatic addition.
18
+
19
+ import { isCommonEnglishWord } from './fuzzy-correct.js'
20
+ import { redactSecrets } from './lens-gist-engines.js'
21
+ import type { OwnerCard } from './meeting-ask-owner.js'
22
+ import { fold } from './transcript-cleanup-text.js'
23
+ import type { ScreenLine } from './transcript-cleanup-screen.js'
24
+
25
+ /** Where a term came from. vocabulary and correction are the dictionary itself; the rest are evidence. */
26
+ export type TermSource = 'vocabulary' | 'correction' | 'speaker' | 'owner' | 'attendee' | 'screen' | 'file' | 'notes'
27
+ /** Sources that make a written form a dictionary suggestion. Matches Control's evidence enum. */
28
+ export type EvidenceSource = 'screen' | 'file' | 'owner' | 'people' | 'notes'
29
+
30
+ export interface ContextTerm {
31
+ display: string
32
+ key: string
33
+ sources: Set<TermSource>
34
+ /** A file name, memory id or app name for the evidence row. Never transcript or OCR text. */
35
+ details: Map<TermSource, string>
36
+ }
37
+
38
+ export interface CleanupFile { name: string; text: string }
39
+ /** `id` is the prompt's own label (N1, N2); `ref` the memory id or meeting record id, for evidence rows. */
40
+ export interface CleanupNote { id: string; ref: string; kind: 'memory' | 'meeting'; title: string; snippet: string; query: string }
41
+
42
+ export interface ContextPack {
43
+ title: string
44
+ date: string
45
+ attendees: string[]
46
+ speakers: string[]
47
+ owner: OwnerCard | null
48
+ vocabulary: string[]
49
+ corrections: Array<{ heard: string; written: string }>
50
+ files: Array<{ name: string; excerpt: string }>
51
+ screen: ScreenLine[]
52
+ notes: CleanupNote[]
53
+ terms: Map<string, ContextTerm>
54
+ }
55
+
56
+ export const CONTEXT_LIMITS = {
57
+ speakers: 150,
58
+ vocabulary: 300,
59
+ corrections: 120,
60
+ files: 6,
61
+ fileChars: 60_000,
62
+ fileExcerptChars: 900,
63
+ termsPerSource: 400,
64
+ notes: 6,
65
+ noteChars: 280,
66
+ noteQueries: 4,
67
+ } as const
68
+
69
+ const WORD = /[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu
70
+
71
+ function looksLikeName(word: string): boolean {
72
+ if (!/^\p{Lu}/u.test(word)) return false
73
+ const f = fold(word)
74
+ if (f.length < 4) return false
75
+ if (isCommonEnglishWord(f)) return false
76
+ // An all-caps run of five letters or fewer is an acronym or a UI label, not a name.
77
+ if (/^\p{Lu}+$/u.test(word) && f.length <= 5) return false
78
+ return true
79
+ }
80
+
81
+ /** Capitalised name-like words of a text, and runs of two or three of them. */
82
+ export function extractTerms(text: string, max: number = CONTEXT_LIMITS.termsPerSource): string[] {
83
+ const out = new Set<string>()
84
+ const words = [...text.matchAll(WORD)].map(m => ({ w: m[0].replace(/['’]s$/i, ''), at: m.index ?? 0, end: (m.index ?? 0) + m[0].length }))
85
+ for (let i = 0; i < words.length && out.size < max; i++) {
86
+ const w = words[i].w
87
+ const capital = /^\p{Lu}/u.test(w)
88
+ if (looksLikeName(w)) out.add(w)
89
+ // "Queen Ukaoma": a capitalised word directly before a name-like word, with nothing but a space between.
90
+ if (capital && i + 1 < words.length && text.slice(words[i].end, words[i + 1].at) === ' ' && looksLikeName(words[i + 1].w)) {
91
+ out.add(`${w} ${words[i + 1].w}`)
92
+ }
93
+ }
94
+ return [...out].slice(0, max)
95
+ }
96
+
97
+ function addTerm(terms: Map<string, ContextTerm>, display: string, source: TermSource, detail?: string): void {
98
+ const clean = display.replace(/\s+/g, ' ').trim()
99
+ const key = fold(clean)
100
+ if (!key || key.length < 2 || clean.length > 100) return
101
+ const existing = terms.get(key)
102
+ if (existing) {
103
+ existing.sources.add(source)
104
+ if (detail && !existing.details.has(source)) existing.details.set(source, detail)
105
+ return
106
+ }
107
+ terms.set(key, { display: clean, key, sources: new Set([source]), details: new Map(detail ? [[source, detail]] : []) })
108
+ }
109
+
110
+ export interface ContextInputs {
111
+ title: string
112
+ date: string
113
+ attendees: string[]
114
+ speakers: string[]
115
+ owner: OwnerCard | null
116
+ vocabulary: string[]
117
+ corrections: Record<string, string>
118
+ files: CleanupFile[]
119
+ screen: ScreenLine[]
120
+ notes?: CleanupNote[]
121
+ }
122
+
123
+ const SPEAKER_TOKENS = new Set(['ext', 'unknown', 'me', 'speaker'])
124
+
125
+ /** The pack, with every text bounded and passed through redactSecrets. */
126
+ export function buildContextPack(input: ContextInputs): ContextPack {
127
+ const terms = new Map<string, ContextTerm>()
128
+ const vocabulary = input.vocabulary.map(v => v.trim()).filter(Boolean).slice(0, CONTEXT_LIMITS.vocabulary)
129
+ for (const v of vocabulary) {
130
+ addTerm(terms, v, 'vocabulary')
131
+ for (const part of v.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'vocabulary')
132
+ }
133
+ const corrections = Object.entries(input.corrections).slice(0, CONTEXT_LIMITS.corrections).map(([heard, written]) => ({ heard, written }))
134
+ for (const c of corrections) addTerm(terms, c.written, 'correction')
135
+ const speakers = [...new Set(input.speakers.map(s => s.trim()).filter(s => s && !SPEAKER_TOKENS.has(s.toLowerCase()) && !/^Unidentified \d+$/.test(s)))].slice(0, CONTEXT_LIMITS.speakers)
136
+ for (const s of speakers) {
137
+ addTerm(terms, s, 'speaker')
138
+ for (const part of s.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'speaker')
139
+ }
140
+ const attendees = [...new Set(input.attendees.map(s => s.trim()).filter(s => s && !SPEAKER_TOKENS.has(s.toLowerCase())))].slice(0, 40)
141
+ for (const a of attendees) {
142
+ addTerm(terms, a, 'attendee')
143
+ for (const part of a.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'attendee')
144
+ }
145
+ if (input.owner) {
146
+ addTerm(terms, input.owner.name, 'owner')
147
+ for (const part of input.owner.name.split(/\s+/)) if (looksLikeName(part)) addTerm(terms, part, 'owner')
148
+ if (input.owner.notes) for (const t of extractTerms(input.owner.notes, 60)) addTerm(terms, t, 'owner')
149
+ }
150
+ const files: ContextPack['files'] = []
151
+ for (const file of input.files.slice(0, CONTEXT_LIMITS.files)) {
152
+ const text = redactSecrets(file.text.slice(0, CONTEXT_LIMITS.fileChars))
153
+ const name = file.name.replace(/[\u0000-\u001f]/g, ' ').slice(0, 120)
154
+ for (const t of extractTerms(text)) addTerm(terms, t, 'file', name)
155
+ files.push({ name, excerpt: text.replace(/\s+/g, ' ').trim().slice(0, CONTEXT_LIMITS.fileExcerptChars) })
156
+ }
157
+ const screen = input.screen.map(l => ({ ...l, text: redactSecrets(l.text) }))
158
+ for (const line of screen.slice(0, 2_000)) for (const t of extractTerms(line.text, 20)) addTerm(terms, t, 'screen', line.app)
159
+ const notes = (input.notes ?? []).slice(0, CONTEXT_LIMITS.notes).map(n => ({ ...n, title: redactSecrets(n.title).slice(0, 120), snippet: redactSecrets(n.snippet).replace(/\s+/g, ' ').slice(0, CONTEXT_LIMITS.noteChars) }))
160
+ for (const n of notes) for (const t of extractTerms(`${n.title}. ${n.snippet}`, 40)) addTerm(terms, t, 'notes', n.ref)
161
+ return {
162
+ title: redactSecrets(input.title).slice(0, 200),
163
+ date: input.date.slice(0, 40),
164
+ attendees,
165
+ speakers,
166
+ owner: input.owner,
167
+ vocabulary,
168
+ corrections,
169
+ files,
170
+ screen,
171
+ notes,
172
+ terms,
173
+ }
174
+ }
175
+
176
+ /** Add notes found after the pack was built (the search is async and runs only for spans' names). */
177
+ export function addNotes(pack: ContextPack, notes: CleanupNote[]): void {
178
+ for (const n of notes.slice(0, CONTEXT_LIMITS.notes - pack.notes.length)) {
179
+ const note = { ...n, title: redactSecrets(n.title).slice(0, 120), snippet: redactSecrets(n.snippet).replace(/\s+/g, ' ').slice(0, CONTEXT_LIMITS.noteChars) }
180
+ pack.notes.push(note)
181
+ for (const t of extractTerms(`${note.title}. ${note.snippet}`, 40)) addTerm(pack.terms, t, 'notes', note.ref)
182
+ }
183
+ }
184
+
185
+ /** A term the dictionary already holds (vocabulary, a correction's written form) is never suggested. */
186
+ export function inDictionary(term: ContextTerm): boolean {
187
+ return term.sources.has('vocabulary') || term.sources.has('correction')
188
+ }
189
+
190
+ /** The evidence a term carries for a dictionary suggestion, or [] when it has none. */
191
+ export function evidenceSources(term: ContextTerm): Array<{ source: EvidenceSource; detail: string | null }> {
192
+ const out: Array<{ source: EvidenceSource; detail: string | null }> = []
193
+ const map: Array<[TermSource, EvidenceSource]> = [['screen', 'screen'], ['file', 'file'], ['owner', 'owner'], ['speaker', 'people'], ['attendee', 'people'], ['notes', 'notes']]
194
+ const seen = new Set<EvidenceSource>()
195
+ for (const [from, to] of map) {
196
+ if (!term.sources.has(from) || seen.has(to)) continue
197
+ seen.add(to)
198
+ out.push({ source: to, detail: term.details.get(from)?.slice(0, 120) ?? null })
199
+ }
200
+ return out
201
+ }
202
+
203
+ /** Name-like targets for near-name detection and deterministic correction, each with where it came from. */
204
+ export interface NameTarget { display: string; key: string; fromMeeting: boolean }
205
+
206
+ /**
207
+ * Single-word name targets. `fromMeeting` marks a target ONLY this meeting's screen or
208
+ * files supplied: deterministic correction holds those to stricter rules (the source word
209
+ * must be capitalised and not an inflection of the target), because screen text carries UI
210
+ * labels a dictionary never would.
211
+ */
212
+ export function nameTargets(pack: ContextPack): NameTarget[] {
213
+ const out: NameTarget[] = []
214
+ for (const term of pack.terms.values()) {
215
+ if (term.display.includes(' ')) continue
216
+ if (!/^\p{Lu}/u.test(term.display) || term.key.length < 4) continue
217
+ const trusted = ['vocabulary', 'correction', 'speaker', 'owner', 'attendee', 'notes'].some(s => term.sources.has(s as TermSource))
218
+ out.push({ display: term.display, key: term.key, fromMeeting: !trusted })
219
+ }
220
+ return out
221
+ }
222
+
223
+ /** Every known spelling, single words and phrases, folded. An exact match is not doubtful. */
224
+ export function knownKeys(pack: ContextPack): Set<string> {
225
+ const keys = new Set<string>()
226
+ for (const term of pack.terms.values()) {
227
+ keys.add(term.key)
228
+ for (const part of term.display.split(/\s+/)) {
229
+ const k = fold(part)
230
+ if (k.length >= 3) keys.add(k)
231
+ }
232
+ }
233
+ return keys
234
+ }