@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.
- package/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- 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
|
-
|
|
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
|
+
}
|