@gotcos/glasses-server 6.95.0 → 6.98.1
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 +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Live cues v3 grounded retrieval: pure helpers that turn the three retrieval arms (cue_memory.py, the owner's COS notes, and the
|
|
2
|
+
// files attached to this meeting) into the candidates the grounded judge sees. Nothing here calls a model, reads a disk or
|
|
3
|
+
// logs; live-cues-v2.ts runs the arms and the judge and passes what they returned.
|
|
4
|
+
//
|
|
5
|
+
// Rules, each from the 2026-11-01 replay and its validation:
|
|
6
|
+
// - self and twin exclusion: a meeting's own filed copy, or its twin's, was in cue_memory's raw top 8 for 579 of 614 windows
|
|
7
|
+
// and rank 1 for 432, so a candidate is dropped when its record is this session or a known twin, or when its text shares
|
|
8
|
+
// at least 15% of its 6-word sequences with what has been said so far;
|
|
9
|
+
// - personal-domain notes stay out of a meeting that is not personal;
|
|
10
|
+
// - one meeting is one candidate however many arms found it (date and normalised title, memory id, record id, file id);
|
|
11
|
+
// - at most M1..M4 from cue_memory, N1 from the notes, F1 from the files, D1 from the Tableau data arm, six in all. The data
|
|
12
|
+
// candidate (live-cues-v2-data.ts) is deterministic and rare, so it keeps a slot: when the six are full it displaces the lowest-ranked
|
|
13
|
+
// cue_memory candidate (never the file passage the room reads from). It is not a copy of any meeting, so the self and twin rules do not apply to it, and it has no domain.
|
|
14
|
+
|
|
15
|
+
import { notesQueryTerms } from './live-cues-v2-notes.js'
|
|
16
|
+
import type { MemoryNote } from './live-cues-v2.js'
|
|
17
|
+
import type { CueSourceRef } from '../../shared/live-cues-v2.js'
|
|
18
|
+
|
|
19
|
+
export const GROUNDED_LIMITS = {
|
|
20
|
+
/** Shared 6-word sequences, as a share of the candidate's own, at which it counts as this conversation. */
|
|
21
|
+
selfOverlap: 0.15,
|
|
22
|
+
shingleWords: 6,
|
|
23
|
+
/** cue_memory.py is asked for this many so that exclusions still leave `cueMemoryTop` (the replay's rule). */
|
|
24
|
+
cueMemoryRaw: 8,
|
|
25
|
+
cueMemoryTop: 4,
|
|
26
|
+
notesTop: 1,
|
|
27
|
+
filesTop: 1,
|
|
28
|
+
maxCandidates: 6,
|
|
29
|
+
/** A file passage is at most this long, and so is what the judge reads of it. */
|
|
30
|
+
fileChars: 300,
|
|
31
|
+
/** 6-word sequences kept for the whole meeting so far, oldest dropped first: 50,000 is about 50,000 spoken words, a 5 to 6 hour
|
|
32
|
+
* meeting at a normal pace, and a few megabytes. */
|
|
33
|
+
historyShingles: 50_000,
|
|
34
|
+
/** A passage must share this many of the window's salient words to be offered at all. */
|
|
35
|
+
fileMinTerms: 2,
|
|
36
|
+
fileWindowTerms: 12,
|
|
37
|
+
} as const
|
|
38
|
+
|
|
39
|
+
export type GroundedArm = 'cue_memory' | 'notes' | 'files' | 'data'
|
|
40
|
+
export type GroundedKind = 'meeting' | 'memory' | 'file' | 'data'
|
|
41
|
+
/** A candidate for the judge: the note fields the judge and the writer read, plus where it came from. */
|
|
42
|
+
export interface GroundedNote extends MemoryNote { arm: GroundedArm; kind: GroundedKind }
|
|
43
|
+
|
|
44
|
+
/** The file fields grounded cues read (a MeetingAskFile). */
|
|
45
|
+
export interface GroundedFile { id: string; name: string; status: string; text: string }
|
|
46
|
+
|
|
47
|
+
/** Python's meeting_canonical.normalize_title: lower case, ASCII punctuation to spaces, whitespace collapsed. */
|
|
48
|
+
export function normalizeMeetingTitle(title: string): string {
|
|
49
|
+
return title.toLowerCase().replace(/[!-/:-@[-`{-~]/g, ' ').split(/\s+/).filter(Boolean).join(' ')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Every identity a candidate answers to; two candidates sharing one are the same meeting, memory or file. */
|
|
53
|
+
export function candidateIdentities(c: Pick<MemoryNote, 'date' | 'title' | 'recordId' | 'memoryId' | 'fileId'>): string[] {
|
|
54
|
+
const ids: string[] = []
|
|
55
|
+
// A file is dated with the meeting it was attached to and named by its file name; that says nothing about a past meeting.
|
|
56
|
+
if (c.date && c.title && !c.fileId) ids.push(`meeting:${c.date}|${normalizeMeetingTitle(c.title)}`)
|
|
57
|
+
if (c.recordId) ids.push(`record:${c.recordId}`)
|
|
58
|
+
if (c.memoryId) ids.push(`memory:${c.memoryId}`)
|
|
59
|
+
if (c.fileId) ids.push(`file:${c.fileId}`)
|
|
60
|
+
return ids
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const wordsOf = (text: string): string[] => text.toLowerCase().match(/[a-z0-9']+/g) ?? []
|
|
64
|
+
|
|
65
|
+
/** The set of 6-word sequences in `text` (lower case, letters, digits and apostrophes). */
|
|
66
|
+
export function shingleSet(text: string, n: number = GROUNDED_LIMITS.shingleWords): Set<string> {
|
|
67
|
+
const words = wordsOf(text)
|
|
68
|
+
const out = new Set<string>()
|
|
69
|
+
for (let i = 0; i + n <= words.length; i += 1) out.add(words.slice(i, i + n).join(' '))
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Every 6-word sequence said in one meeting since it was armed, for the self-copy check. The engine's window keeps only the last
|
|
74
|
+
* ~400 words, so a copy of something said an hour ago would pass a check against the window alone. Chunks are added as they
|
|
75
|
+
* arrive; the last five words of one chunk join the next, so a sequence across a chunk boundary is counted. Over its cap the
|
|
76
|
+
* oldest go first. */
|
|
77
|
+
export class SessionShingles {
|
|
78
|
+
private readonly seen = new Set<string>()
|
|
79
|
+
private tail: string[] = []
|
|
80
|
+
constructor(readonly cap: number = GROUNDED_LIMITS.historyShingles) {}
|
|
81
|
+
add(text: string): void {
|
|
82
|
+
const n = GROUNDED_LIMITS.shingleWords
|
|
83
|
+
const words = [...this.tail, ...wordsOf(text)]
|
|
84
|
+
for (let i = 0; i + n <= words.length; i += 1) {
|
|
85
|
+
this.seen.add(words.slice(i, i + n).join(' '))
|
|
86
|
+
}
|
|
87
|
+
this.tail = words.slice(-(n - 1))
|
|
88
|
+
while (this.seen.size > this.cap) this.seen.delete(this.seen.values().next().value as string)
|
|
89
|
+
}
|
|
90
|
+
get shingles(): ReadonlySet<string> { return this.seen }
|
|
91
|
+
clear(): void { this.seen.clear(); this.tail = [] }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The share of the candidate's own 6-word sequences that also appear in the transcript so far (0 when it has none). */
|
|
95
|
+
export function sixGramOverlap(candidateText: string, transcript: ReadonlySet<string>): number {
|
|
96
|
+
const own = shingleSet(candidateText)
|
|
97
|
+
if (!own.size) return 0
|
|
98
|
+
let shared = 0
|
|
99
|
+
for (const s of own) if (transcript.has(s)) shared += 1
|
|
100
|
+
return shared / own.size
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Ids that mean "this conversation": the session and its known twins, also in the library's `standalone:` form. */
|
|
104
|
+
export function selfIdSet(sessionId: string, twins: Iterable<string> = []): Set<string> {
|
|
105
|
+
const out = new Set<string>()
|
|
106
|
+
for (const id of [sessionId, ...twins]) { out.add(id); out.add(`standalone:${id}`) }
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function isSelfCopy(c: Pick<MemoryNote, 'notes' | 'recordId' | 'sessionId'>, transcript: ReadonlySet<string>, selfIds: ReadonlySet<string>): boolean {
|
|
111
|
+
if ((c.recordId && selfIds.has(c.recordId)) || (c.sessionId && selfIds.has(c.sessionId))) return true
|
|
112
|
+
return sixGramOverlap(c.notes, transcript) >= GROUNDED_LIMITS.selfOverlap
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A personal-domain note never reaches a meeting that is not personal. An unknown meeting domain counts as not personal. */
|
|
116
|
+
export function isForeignPersonal(noteDomain: string | undefined, meetingDomain: string | null): boolean {
|
|
117
|
+
const norm = (v: unknown) => (typeof v === 'string' ? v.trim().toLowerCase() : '')
|
|
118
|
+
return norm(noteDomain) === 'personal' && norm(meetingDomain) !== 'personal'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.trim() !== ''
|
|
122
|
+
|
|
123
|
+
/** A retrieved note the code below can read: the four fields it calls string methods on are strings. cue_memory.py's answer is
|
|
124
|
+
* parsed JSON from a subprocess, so a malformed row is skipped here instead of throwing into the evaluation. */
|
|
125
|
+
function wellFormed(n: unknown): n is MemoryNote {
|
|
126
|
+
const r = n as Partial<Record<keyof MemoryNote, unknown>> | null
|
|
127
|
+
return !!r && typeof r === 'object' && typeof r.id === 'string' && typeof r.date === 'string' && typeof r.title === 'string' && typeof r.notes === 'string'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Kind and locator from a retrieved note's key: `memory:<id>` and `meeting:<recordId>` come from the notes arm, anything
|
|
131
|
+
* else (cue_memory.py's `date|title`, or no key) is a meeting with no record id known. Only the fields the arms produce are
|
|
132
|
+
* copied: the note's own id, date, title, notes and key, its domain (cue_memory.py and the notes arm say it when they know
|
|
133
|
+
* it) and the session it was recorded from, each only when it is a non-empty string. */
|
|
134
|
+
function describe(note: MemoryNote, arm: GroundedArm): Omit<GroundedNote, 'id'> {
|
|
135
|
+
const key = typeof note.key === 'string' ? note.key : ''
|
|
136
|
+
const base = { date: note.date, title: note.title, notes: note.notes, key, arm, ...(nonEmpty(note.domain) ? { domain: note.domain } : {}),
|
|
137
|
+
...(nonEmpty(note.sessionId) ? { sessionId: note.sessionId } : {}) }
|
|
138
|
+
if (key.startsWith('memory:') && key.length > 'memory:'.length) return { ...base, kind: 'memory', memoryId: key.slice('memory:'.length) }
|
|
139
|
+
if (key.startsWith('meeting:') && key.length > 'meeting:'.length) return { ...base, kind: 'meeting', recordId: key.slice('meeting:'.length) }
|
|
140
|
+
return { ...base, kind: 'meeting' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The files with the four fields grounded cues read, all strings; anything else in the list is dropped. */
|
|
144
|
+
export function usableFiles(raw: unknown): GroundedFile[] {
|
|
145
|
+
if (!Array.isArray(raw)) return []
|
|
146
|
+
return raw.filter((f): f is GroundedFile => !!f && typeof f === 'object' && typeof f.id === 'string' && typeof f.name === 'string' && typeof f.status === 'string' && typeof f.text === 'string')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Passages of at most `max` characters: blank-line blocks, then sentences, then a hard cut. */
|
|
150
|
+
export function passagesOf(text: string, max: number = GROUNDED_LIMITS.fileChars): string[] {
|
|
151
|
+
const out: string[] = []
|
|
152
|
+
for (const block of text.split(/\n\s*\n/)) {
|
|
153
|
+
const flat = block.replace(/\s+/g, ' ').trim()
|
|
154
|
+
if (!flat) continue
|
|
155
|
+
if (flat.length <= max) { out.push(flat); continue }
|
|
156
|
+
let cur = ''
|
|
157
|
+
for (const sentence of flat.split(/(?<=[.!?])\s+/)) {
|
|
158
|
+
if (cur && cur.length + 1 + sentence.length > max) { out.push(cur); cur = '' }
|
|
159
|
+
cur = cur ? `${cur} ${sentence}` : sentence
|
|
160
|
+
while (cur.length > max) { out.push(cur.slice(0, max)); cur = cur.slice(max) }
|
|
161
|
+
}
|
|
162
|
+
if (cur) out.push(cur)
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The one file passage that best matches the window, or null. Only `ready` files count (a secret-held file has no text and is
|
|
168
|
+
* never read, whatever it carries), a passage must share at least fileMinTerms of the window's salient words, and the first
|
|
169
|
+
* best passage wins a tie. */
|
|
170
|
+
export function bestFilePassage(files: GroundedFile[], window: string): { file: GroundedFile; passage: string } | null {
|
|
171
|
+
const terms = notesQueryTerms(window, GROUNDED_LIMITS.fileWindowTerms)
|
|
172
|
+
if (!terms.length) return null
|
|
173
|
+
let best: { file: GroundedFile; passage: string; score: number } | null = null
|
|
174
|
+
for (const file of files) {
|
|
175
|
+
if (file.status !== 'ready' || typeof file.text !== 'string' || !file.text.trim()) continue
|
|
176
|
+
for (const passage of passagesOf(file.text)) {
|
|
177
|
+
const words = new Set(passage.toLowerCase().match(/[a-z0-9]+/g) ?? [])
|
|
178
|
+
const score = terms.reduce((n, t) => n + (words.has(t) ? 1 : 0), 0)
|
|
179
|
+
if (score >= GROUNDED_LIMITS.fileMinTerms && (!best || score > best.score)) best = { file, passage, score }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return best ? { file: best.file, passage: best.passage } : null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface GroundedInputs {
|
|
186
|
+
/** cue_memory.py's answer, up to cueMemoryRaw, best first. */
|
|
187
|
+
cueMemory: MemoryNote[]
|
|
188
|
+
/** The owner's COS notes, best first. */
|
|
189
|
+
notes: MemoryNote[]
|
|
190
|
+
files: unknown
|
|
191
|
+
/** The 6-word sequences of everything said in this meeting so far (SessionShingles; speaker labels removed). */
|
|
192
|
+
transcript: ReadonlySet<string>
|
|
193
|
+
/** The last part of it: what the room is on now. */
|
|
194
|
+
window: string
|
|
195
|
+
selfIds: ReadonlySet<string>
|
|
196
|
+
/** The confirmed domain of this meeting, or null. */
|
|
197
|
+
meetingDomain: string | null
|
|
198
|
+
/** This meeting's local date (YYYY-MM-DD): a file is dated with the meeting it was attached to. */
|
|
199
|
+
today: string
|
|
200
|
+
/** The Tableau data candidate (live-cues-v2-data.ts dataCandidate), already triggered and domain-checked, or none. */
|
|
201
|
+
data?: GroundedNote | null
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The candidates for the judge, in order M1..M4, N1, F1, with ids that keep their kind. `excludedSelf` counts the notes dropped
|
|
205
|
+
* as this conversation or its twin. Files are the owner's own attachments to this meeting, so the self rule does not apply
|
|
206
|
+
* to them: a document the room reads from is what a file cue is for. */
|
|
207
|
+
export function groundedCandidates(i: GroundedInputs): { candidates: GroundedNote[]; excludedSelf: number; domainUnknown: number } {
|
|
208
|
+
const transcript = i.transcript
|
|
209
|
+
const seen = new Set<string>()
|
|
210
|
+
const out: GroundedNote[] = []
|
|
211
|
+
let excludedSelf = 0
|
|
212
|
+
const take = (list: MemoryNote[], arm: GroundedArm, limit: number, prefix: string) => {
|
|
213
|
+
let n = 0
|
|
214
|
+
for (const raw of list) {
|
|
215
|
+
if (n >= limit || out.length >= GROUNDED_LIMITS.maxCandidates) return
|
|
216
|
+
if (!wellFormed(raw)) continue
|
|
217
|
+
const note = describe(raw, arm)
|
|
218
|
+
const identities = candidateIdentities(note)
|
|
219
|
+
// A copy of this conversation also keeps every other copy of the same meeting out, whatever its text says.
|
|
220
|
+
if (isSelfCopy(note, transcript, i.selfIds)) { for (const id of identities) seen.add(id); excludedSelf += 1; continue }
|
|
221
|
+
if (isForeignPersonal(note.domain, i.meetingDomain)) continue
|
|
222
|
+
if (identities.some(id => seen.has(id))) continue
|
|
223
|
+
for (const id of identities) seen.add(id)
|
|
224
|
+
n += 1
|
|
225
|
+
out.push({ ...note, id: `${prefix}${n}` })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
take(i.cueMemory, 'cue_memory', GROUNDED_LIMITS.cueMemoryTop, 'M')
|
|
229
|
+
take(i.notes, 'notes', GROUNDED_LIMITS.notesTop, 'N')
|
|
230
|
+
if (out.length < GROUNDED_LIMITS.maxCandidates) {
|
|
231
|
+
const hit = bestFilePassage(usableFiles(i.files), i.window)
|
|
232
|
+
if (hit) {
|
|
233
|
+
out.push({ id: 'F1', date: i.today, title: hit.file.name.replace(/\s+/g, ' ').trim().slice(0, 90), notes: hit.passage, key: `file:${hit.file.id}`,
|
|
234
|
+
arm: 'files', kind: 'file', fileId: hit.file.id })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (i.data) {
|
|
238
|
+
// When the six are full, D1 displaces the lowest-ranked cue_memory candidate (the last one), never the file passage the room is
|
|
239
|
+
// reading from. Memory ranks below it: the data candidate only exists when the room named a group beside a figure.
|
|
240
|
+
if (out.length >= GROUNDED_LIMITS.maxCandidates) {
|
|
241
|
+
const at = out.map(c => c.arm).lastIndexOf('cue_memory')
|
|
242
|
+
if (at >= 0) out.splice(at, 1)
|
|
243
|
+
}
|
|
244
|
+
if (out.length < GROUNDED_LIMITS.maxCandidates) out.push({ ...i.data, id: 'D1', arm: 'data', kind: 'data' })
|
|
245
|
+
}
|
|
246
|
+
// The personal-domain rule can only work on a note that says its domain; this counts the notes that did not.
|
|
247
|
+
const domainUnknown = out.filter(c => c.arm !== 'files' && c.arm !== 'data' && !c.domain).length
|
|
248
|
+
return { candidates: out, excludedSelf, domainUnknown }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The cue source for a kept candidate: the opaque id, the date and title as before, and (v3) its kind with its one locator. */
|
|
252
|
+
export function groundedSource(note: GroundedNote, id: string): CueSourceRef {
|
|
253
|
+
// A Tableau data note (kind 'data') is not a meeting, memory, file or page, and has no locator. It goes out as a `file` source with none, which
|
|
254
|
+
// every reader already shows as plain text with its title and date: Control 0.5.363 reads only id, date and title; 0.5.364 keeps
|
|
255
|
+
// `kind` as a free string and opens nothing without a fileId; the companion shows the headline only. A new kind would be one
|
|
256
|
+
// that a rolled-back server rejects when it reloads the cue log (isCueSourceExtras).
|
|
257
|
+
const base: CueSourceRef = { id, date: note.date, title: note.title, kind: note.kind === 'data' ? 'file' : note.kind }
|
|
258
|
+
if (note.kind === 'memory' && note.memoryId) return { ...base, memoryId: note.memoryId }
|
|
259
|
+
if (note.kind === 'file' && note.fileId) return { ...base, fileId: note.fileId }
|
|
260
|
+
if (note.kind === 'meeting' && note.recordId) return { ...base, recordId: note.recordId }
|
|
261
|
+
return base
|
|
262
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Live cues v3 grounded judge ("judge B"): ONE Jev request per evaluation that asks, for each candidate, whether it is directly
|
|
2
|
+
// relevant to what is being said now (a `choice` {yes, no} question per candidate), plus production's own `same` choice as a
|
|
3
|
+
// logged diagnostic. The wording below is copied verbatim from the replay that measured it (cue_rrf_replay prompts.py:
|
|
4
|
+
// REL_YES, REL_NO, the rel_<id> instructions, note_line at 300 characters). A candidate is kept when its own yes probability
|
|
5
|
+
// reaches judgeYes, so two relevant notes no longer split one probability and both lose, which is what the single `same`
|
|
6
|
+
// question did (the replay's kept>0 rate went from 18% to 45% with 79% directly useful and none irrelevant).
|
|
7
|
+
//
|
|
8
|
+
// Hand-written on purpose: live-cues-v2-prompts.ts is GENERATED and its hash is pinned. The `same` question reuses that
|
|
9
|
+
// file's instructions and none sentence unchanged.
|
|
10
|
+
|
|
11
|
+
import { CUE_JUDGE_INSTRUCTIONS, CUE_JUDGE_NONE } from './live-cues-v2-prompts.js'
|
|
12
|
+
|
|
13
|
+
export const JUDGE_B_WINDOW_CHARS = 2_200
|
|
14
|
+
export const JUDGE_B_NOTE_CHARS = 300
|
|
15
|
+
export const CUE_REL_YES = 'Yes: the note is about the same client, project, brand, person or decision as the conversation, and would help the wearer right now.'
|
|
16
|
+
export const CUE_REL_NO = 'No: the note is about something else, or is only loosely related to the conversation.'
|
|
17
|
+
|
|
18
|
+
export interface JudgeBCandidate { id: string; date: string; title: string; notes: string }
|
|
19
|
+
|
|
20
|
+
/** The note as the judge reads it: date, title, then the first 300 characters of the note. */
|
|
21
|
+
export function judgeNoteLine(c: JudgeBCandidate): string {
|
|
22
|
+
return `${c.date} ${c.title}: ${c.notes.slice(0, JUDGE_B_NOTE_CHARS)}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The `same` question first (production's wording and criteria, so it stays comparable), then one yes/no question per candidate. */
|
|
26
|
+
export function judgeBQuestions(cands: JudgeBCandidate[]): Record<string, unknown> {
|
|
27
|
+
const criteria: Record<string, string> = Object.fromEntries(cands.map(c => [c.id, judgeNoteLine(c)]))
|
|
28
|
+
criteria.none = CUE_JUDGE_NONE
|
|
29
|
+
const questions: Record<string, unknown> = { same: { type: 'choice', instructions: CUE_JUDGE_INSTRUCTIONS, criteria } }
|
|
30
|
+
for (const c of cands) {
|
|
31
|
+
questions['rel_' + c.id] = { type: 'choice',
|
|
32
|
+
instructions: 'Is this past meeting note directly relevant to what is being discussed in the last part of `window`? Past note: ' + judgeNoteLine(c),
|
|
33
|
+
criteria: { yes: CUE_REL_YES, no: CUE_REL_NO } }
|
|
34
|
+
}
|
|
35
|
+
return questions
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Each candidate's yes probability and its `same` probability. A missing, non-numeric or out-of-range value reads as 0, so a
|
|
39
|
+
* malformed answer can never keep a note. */
|
|
40
|
+
export function parseJudgeB(answers: Record<string, { probabilities?: Record<string, number> } | undefined> | undefined,
|
|
41
|
+
ids: string[]): { yes: Record<string, number>; same: Record<string, number> } {
|
|
42
|
+
const prob = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : 0)
|
|
43
|
+
const yes: Record<string, number> = {}, same: Record<string, number> = {}
|
|
44
|
+
for (const id of ids) {
|
|
45
|
+
yes[id] = prob(answers?.['rel_' + id]?.probabilities?.yes)
|
|
46
|
+
same[id] = prob(answers?.same?.probabilities?.[id])
|
|
47
|
+
}
|
|
48
|
+
return { yes, same }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The candidates the writer sees: yes at or above the threshold, best first (ties keep candidate order), at most `max`. */
|
|
52
|
+
export function selectJudgeB<T extends { id: string }>(cands: T[], yes: Record<string, number>, threshold: number, max: number): T[] {
|
|
53
|
+
return cands
|
|
54
|
+
.map((c, order) => ({ c, order, p: yes[c.id] ?? 0 }))
|
|
55
|
+
.filter(x => x.p >= threshold)
|
|
56
|
+
.sort((a, b) => b.p - a.p || a.order - b.order)
|
|
57
|
+
.slice(0, max)
|
|
58
|
+
.map(x => x.c)
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// 6.97.8: the spellings the cue writer may use for a name. The transcript misspells names ("Bori" for Vori, "Niall" for
|
|
2
|
+
// Niala), and the server already knows the right ones: the speakers the ASR path reported for THIS meeting, the owner, and the
|
|
3
|
+
// terms the owner taught the decoder that are written like names. Enrolled voice profiles are deliberately left out: that store holds
|
|
4
|
+
// everyone the glasses ever heard, household included, and none of it belongs in a work meeting's prompt. No new data source and
|
|
5
|
+
// no network: the same readers the general-cue name gate uses (live-cues-general-topic.ts ProfileNameSource), handed to the same
|
|
6
|
+
// local writer that already sees the transcript. Pure but for those readers; a reader that fails just contributes nothing.
|
|
7
|
+
|
|
8
|
+
import { isGenericSpeakerLabel, realProfileNames, type ProfileNameSource } from './live-cues-general-topic.js'
|
|
9
|
+
|
|
10
|
+
/** Most names a prompt carries. A meeting names a handful of people and products; the rest is the owner's whole vocabulary. */
|
|
11
|
+
export const KNOWN_NAMES_MAX = 60
|
|
12
|
+
/** Most characters of names a prompt carries. */
|
|
13
|
+
export const KNOWN_NAMES_CHARS = 700
|
|
14
|
+
const NAME_MAX_CHARS = 32
|
|
15
|
+
const NAME_MAX_WORDS = 3
|
|
16
|
+
|
|
17
|
+
/** What the writer is told about the list. It rides in the prompt data, so no writer rule text changes. */
|
|
18
|
+
export const KNOWN_NAMES_USE = 'Spell a person, company or product exactly as listed when a word in the window sounds like it and the context fits. Speech to text misspells names. Never add a name the window does not hint at.'
|
|
19
|
+
|
|
20
|
+
/** A vocabulary term worth listing: short, and written like a name or a product (a capital letter somewhere). Plain work words
|
|
21
|
+
* the owner taught the decoder ("retail", "pos") are not names, and a long phrase is not a spelling. */
|
|
22
|
+
function looksLikeName(term: string): boolean {
|
|
23
|
+
return term.length >= 3 && term.length <= NAME_MAX_CHARS && term.split(/\s+/).length <= NAME_MAX_WORDS && /\p{Lu}/u.test(term) && !/[\x00-\x1f\x7f]/.test(term)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per name in the prompt: the quotes, the comma and the space the JSON adds around it. */
|
|
27
|
+
const NAME_OVERHEAD_CHARS = 4
|
|
28
|
+
|
|
29
|
+
/** The names for one meeting, most useful first: the meeting's speakers, the owner, then the vocabulary names. A name that does not
|
|
30
|
+
* fit is skipped, not the end of the list. */
|
|
31
|
+
export async function knownNamesFor(speakers: readonly string[], src: ProfileNameSource = realProfileNames): Promise<string[]> {
|
|
32
|
+
const candidates: string[] = []
|
|
33
|
+
for (const s of speakers) if (typeof s === 'string') candidates.push(s)
|
|
34
|
+
try { candidates.push(src.ownerName()) } catch { /* no profile */ }
|
|
35
|
+
try { candidates.push(...src.vocabulary().filter(looksLikeName)) } catch { /* no vocabulary */ }
|
|
36
|
+
const seen = new Set<string>(), out: string[] = []
|
|
37
|
+
let chars = 0
|
|
38
|
+
for (const raw of candidates) {
|
|
39
|
+
if (typeof raw !== 'string') continue
|
|
40
|
+
const name = raw.replace(/\s+/g, ' ').trim()
|
|
41
|
+
const key = name.toLowerCase()
|
|
42
|
+
// The owner's placeholder name ("User") and the ASR's generic labels ("Speaker 2") are not names, wherever they came from.
|
|
43
|
+
if (!name || name.length > NAME_MAX_CHARS || seen.has(key) || key === 'user' || isGenericSpeakerLabel(name)) continue
|
|
44
|
+
if (out.length >= KNOWN_NAMES_MAX) break
|
|
45
|
+
if (chars + name.length + NAME_OVERHEAD_CHARS > KNOWN_NAMES_CHARS) continue
|
|
46
|
+
seen.add(key); out.push(name); chars += name.length + NAME_OVERHEAD_CHARS
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
@@ -18,7 +18,9 @@ export const NOTES_TOP = 4
|
|
|
18
18
|
const NOTE_CHARS = 520
|
|
19
19
|
const TITLE_CHARS = 90
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/** `domain` and `sessionId` (live cues v3) are set only when the source said them; grounded cues use them to keep personal notes
|
|
22
|
+
* out of other meetings and to recognise this meeting's own filed copy. The judge and the writer never see them. */
|
|
23
|
+
export interface NoteCandidate { id: string; date: string; title: string; notes: string; key: string; domain?: string; sessionId?: string }
|
|
22
24
|
export interface NotesResult { notes: NoteCandidate[]; error?: string }
|
|
23
25
|
|
|
24
26
|
export interface NotesSearchers {
|
|
@@ -73,18 +75,20 @@ export function notesQueries(terms: string[]): string[] {
|
|
|
73
75
|
|
|
74
76
|
const dateOf = localDateOf
|
|
75
77
|
const flat = (v: unknown, max: number): string => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
|
|
78
|
+
const domainOf = (v: unknown): { domain?: string } => (typeof v === 'string' && v.trim() ? { domain: v.trim() } : {})
|
|
76
79
|
|
|
77
80
|
function memoryCandidate(hit: ContextSearchHit): NoteCandidate | null {
|
|
78
81
|
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}
|
|
82
|
+
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}`, ...domainOf(hit.domain) }
|
|
80
83
|
}
|
|
81
84
|
function meetingCandidate(hit: MeetingSearchHit): NoteCandidate | null {
|
|
82
85
|
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}
|
|
86
|
+
return { id: '', date: dateOf(hit.date), title: flat(hit.title, TITLE_CHARS), notes: flat(hit.snippet || hit.title, NOTE_CHARS), key: `meeting:${hit.recordId}`,
|
|
87
|
+
...domainOf(hit.domain), ...(hit.sessionId ? { sessionId: hit.sessionId } : {}) }
|
|
84
88
|
}
|
|
85
89
|
function bridgeCandidate(row: BridgeMemory): NoteCandidate {
|
|
86
90
|
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}
|
|
91
|
+
return { id: '', date: dateOf(row.created_at), title: flat(row.summary || firstLine, TITLE_CHARS), notes: flat(row.content, NOTE_CHARS), key: `memory:${row.id}`, ...domainOf(row.domain) }
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
/** Bridge rows ranked for one query, best first (the shared scorer). */
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// 6.97.8: fold the "nobody named an owner or a date, settle it" cues into one combined cue.
|
|
2
|
+
//
|
|
3
|
+
// On 2026-11-02, 34 of 100 cues were that one template, each taking a lens slot. This module decides which cues are open items
|
|
4
|
+
// and keeps the held ones for a combined cue: at most one every 10 minutes, at most 3 items, each short.
|
|
5
|
+
//
|
|
6
|
+
// What counts as an open item (QA fix: the item must come from the writer, never from a guess at the wording):
|
|
7
|
+
// 1. the writer returned its own `open` item (the extended-kind prompt, writer v4, asks for it; the legacy prompt never returns
|
|
8
|
+
// one, so a legacy meeting folds nothing and every cue is shown as written);
|
|
9
|
+
// 2. the cue is also, in its wording, an owner or date gap (a cue that carries a risk or a figure and merely mentions an owner is
|
|
10
|
+
// never reduced to a label);
|
|
11
|
+
// 3. never a decision, and never a cue that cites a past meeting or note.
|
|
12
|
+
// A held cue is never lost: the engine logs it when it is held, keeps it in the writer's recent-cue history, and the fold hands
|
|
13
|
+
// it back for display: in a combined cue (the newest three, once 10 minutes have passed since the last), or, when nothing joined
|
|
14
|
+
// it within 20 minutes, on its own exactly as the writer wrote it.
|
|
15
|
+
// Pure: no clock, no model, no I/O. The engine owns the session state and the emit.
|
|
16
|
+
|
|
17
|
+
import { cueOverlap, cueTokens } from './live-cues-filter.js'
|
|
18
|
+
import type { CueKind, LiveCueV2 } from '../../shared/live-cues-v2.js'
|
|
19
|
+
|
|
20
|
+
/** At most one combined cue per this long. */
|
|
21
|
+
export const OPEN_ITEMS_EVERY_MS = 10 * 60_000
|
|
22
|
+
/** At most this many items in one combined cue. */
|
|
23
|
+
export const OPEN_ITEMS_MAX = 3
|
|
24
|
+
/** A held item that nothing joined for this long is shown on its own. */
|
|
25
|
+
export const OPEN_ITEM_STALE_MS = 20 * 60_000
|
|
26
|
+
/** Longest item, in characters: the writer is asked for 50. Three of them and the lead-in stay inside the 220-character cue detail, one with " +2" inside the 64-character headline. */
|
|
27
|
+
export const OPEN_ITEM_MAX_CHARS = 50
|
|
28
|
+
/** Two items this alike (token overlap) are the same item. */
|
|
29
|
+
const SAME_ITEM = 0.6
|
|
30
|
+
|
|
31
|
+
const ABSENT = /\b(?:no|nobody|none|not yet|hasn'?t|haven'?t|wasn'?t|weren'?t|isn'?t|never)\b[^.!?]{0,60}?\b(?:owner|owners|date|dates|deadline|named|taken|assigned)\b/i
|
|
32
|
+
const ASK = /\b(?:name|pick|assign|set|settle|pin|pin down|agree|choose|decide|fix|confirm)\b[^.!?]{0,40}?\b(?:an? owner|the owner|who|when|an? date|the date|a deadline|a day|cadence)\b/i
|
|
33
|
+
|
|
34
|
+
/** The wording rule alone (rule 3 above). */
|
|
35
|
+
export function isOwnerDateGap(detail: string, kind: CueKind): boolean {
|
|
36
|
+
const text = detail || ''
|
|
37
|
+
if (!ABSENT.test(text)) return false
|
|
38
|
+
return ASK.test(text) || kind === 'followUp' || kind === 'promise'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One item in the writer's words, trimmed to the item limit; null when it says nothing. */
|
|
42
|
+
export function cleanOpenItem(raw: unknown): string | null {
|
|
43
|
+
if (typeof raw !== 'string') return null
|
|
44
|
+
const text = raw.replace(/[—–]/g, '-').replace(/\s+/g, ' ').trim().replace(/^[\s:;,.-]+|[\s:;,.-]+$/g, '')
|
|
45
|
+
if (text.length < 3) return null
|
|
46
|
+
return clipAtWord(text, OPEN_ITEM_MAX_CHARS)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clipAtWord(text: string, max: number): string {
|
|
50
|
+
if (text.length <= max) return text
|
|
51
|
+
const cut = text.slice(0, max - 1)
|
|
52
|
+
const space = cut.lastIndexOf(' ')
|
|
53
|
+
return (space > max / 2 ? cut.slice(0, space) : cut).replace(/[,;:.\s]+$/, '') + '…'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OpenItemVerdict { label: string }
|
|
57
|
+
|
|
58
|
+
/** Is this cue an open item, and what is the item? `field` is the writer's own `open` value, `cited` how many past sources it has. */
|
|
59
|
+
export function openItemOf(cue: { kind: CueKind; detail: string; cited: number; field?: unknown }): OpenItemVerdict | null {
|
|
60
|
+
if (cue.kind === 'decision' || cue.cited > 0) return null
|
|
61
|
+
const label = cleanOpenItem(cue.field)
|
|
62
|
+
return label && isOwnerDateGap(cue.detail, cue.kind) ? { label } : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The combined cue's wording. Plain words, one sentence, no dashes. */
|
|
66
|
+
export function openItemsDetail(items: readonly string[]): string {
|
|
67
|
+
return `Still open, no owner or date: ${items.join('; ')}.`
|
|
68
|
+
}
|
|
69
|
+
/** The lens footer shows only this: the first item, and how many more are in the cue. */
|
|
70
|
+
export function openItemsHeadline(items: readonly string[]): string {
|
|
71
|
+
return items.length > 1 ? `${items[0]} +${items.length - 1}` : items[0]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HeldItem { label: string; at: number; cue: LiveCueV2; before: string; tokens: Set<string>; seen: Set<string> }
|
|
75
|
+
/** What the fold hands back when something is due: a combined cue of the newest three, the oldest stale item on its own, or the
|
|
76
|
+
* oldest stale item dropped because the room has talked about it again since it was held (it may well be settled now). */
|
|
77
|
+
export type FlushPlan = { mode: 'digest'; items: HeldItem[] } | { mode: 'alone'; items: [HeldItem] } | { mode: 'drop'; items: [HeldItem] }
|
|
78
|
+
|
|
79
|
+
/** The share of an item's scored words that must have been said again since it was held for the item to count as talked about. */
|
|
80
|
+
export const OPEN_ITEM_SAID_SHARE = 0.6
|
|
81
|
+
|
|
82
|
+
/** The held items of one meeting, and when something is due. */
|
|
83
|
+
export class OpenItemFold {
|
|
84
|
+
private pending: HeldItem[] = []
|
|
85
|
+
private everHeld: LiveCueV2[] = []
|
|
86
|
+
private shownLabels: string[] = []
|
|
87
|
+
private lastDigestAt: number | null = null
|
|
88
|
+
|
|
89
|
+
/** Hold a cue as an item, unless it is the same item as one held or already shown (it is then only remembered). Returns whether it waits. */
|
|
90
|
+
hold(label: string, cue: LiveCueV2, now: number, before = ''): boolean {
|
|
91
|
+
this.everHeld.push(cue)
|
|
92
|
+
const tokens = cueTokens(label)
|
|
93
|
+
const same = (other: string) => cueOverlap(tokens, cueTokens(other)) >= SAME_ITEM
|
|
94
|
+
if (this.pending.some(p => same(p.label)) || this.shownLabels.some(same)) return false
|
|
95
|
+
this.pending.push({ label, at: now, cue, before, tokens, seen: new Set() })
|
|
96
|
+
return true
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** How many items wait. */
|
|
100
|
+
waiting(): number { return this.pending.length }
|
|
101
|
+
|
|
102
|
+
/** Every cue ever held, for the writer's recent-cue history and the repeat rules: a held item must not be raised again. */
|
|
103
|
+
history(): readonly LiveCueV2[] { return this.everHeld }
|
|
104
|
+
|
|
105
|
+
/** What the room says after an item was held: remember which of each waiting item's scored words came up again. */
|
|
106
|
+
noteSaid(text: string): void {
|
|
107
|
+
if (!this.pending.length) return
|
|
108
|
+
const said = cueTokens(text)
|
|
109
|
+
for (const p of this.pending) for (const t of p.tokens) if (said.has(t)) p.seen.add(t)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Has the room talked about this item again since it was held (most of its scored words, at least one)? */
|
|
113
|
+
talkedAbout(item: HeldItem): boolean {
|
|
114
|
+
return item.seen.size >= Math.max(1, Math.ceil(item.tokens.size * OPEN_ITEM_SAID_SHARE)) // a label with no scored words can never be seen again
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What is due now. A combined cue needs two waiting and 10 minutes since the last; a stale item is shown alone. */
|
|
118
|
+
next(now: number): FlushPlan | null {
|
|
119
|
+
const spaced = this.lastDigestAt === null || now - this.lastDigestAt >= OPEN_ITEMS_EVERY_MS
|
|
120
|
+
if (spaced && this.pending.length >= 2) return { mode: 'digest', items: this.pending.slice(-OPEN_ITEMS_MAX) }
|
|
121
|
+
const stale = this.pending.find(p => now - p.at >= OPEN_ITEM_STALE_MS)
|
|
122
|
+
if (!stale) return null
|
|
123
|
+
return this.talkedAbout(stale) ? { mode: 'drop', items: [stale] } : { mode: 'alone', items: [stale] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The plan was carried out (shown, or for a drop, set aside). */
|
|
127
|
+
markShown(plan: FlushPlan, now: number): void {
|
|
128
|
+
this.pending = this.pending.filter(p => !plan.items.includes(p))
|
|
129
|
+
if (plan.mode !== 'drop') this.shownLabels.push(...plan.items.map(i => i.label))
|
|
130
|
+
if (plan.mode === 'digest') this.lastDigestAt = now
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 6.97.8: pace the per-meeting cue cap so a meeting cannot spend it all early. On 2026-11-02 three of the four long meetings
|
|
2
|
+
// reached the 25-cue cap in 18 to 30 minutes of a 35 to 47 minute meeting, and the last 5 to 22 minutes got nothing.
|
|
3
|
+
//
|
|
4
|
+
// The user's two settings keep their meaning: the cap is still the most cues a meeting can ever show, and the gap (cooldown) is
|
|
5
|
+
// still the least time between two cues. The pace only decides how soon the cap is available.
|
|
6
|
+
//
|
|
7
|
+
// burst = ceil(cap / 2) half the cap is open from the first minute, at the gap pace
|
|
8
|
+
// refill = max(gap, horizon / (cap - burst)) the other half comes back one cue at a time, evenly, over the horizon
|
|
9
|
+
// allowed(t) = min(cap, burst + floor(t / refill))
|
|
10
|
+
//
|
|
11
|
+
// The horizon is the meeting's own length when the calendar match gives one (clamped to 15 to 180 minutes), else one meeting
|
|
12
|
+
// hour. With the defaults (cap 25, gap 60 s, one hour): burst 13, one more cue every 5 minutes, the 25th at minute 60. A meeting
|
|
13
|
+
// that runs longer than the horizon simply has its whole cap open after it. A cap of 4 or fewer is not paced at all: with so few
|
|
14
|
+
// cues the first minutes are the only ones worth spending them on, and the engine tests of a small cap rely on it.
|
|
15
|
+
|
|
16
|
+
/** The meeting length the cap is spread over when the calendar does not say: one hour. */
|
|
17
|
+
export const PACE_HORIZON_MS = 60 * 60_000
|
|
18
|
+
export const PACE_HORIZON_MIN_MS = 15 * 60_000
|
|
19
|
+
export const PACE_HORIZON_MAX_MS = 180 * 60_000
|
|
20
|
+
/** Caps up to this are not paced. */
|
|
21
|
+
export const PACE_MIN_CAP = 5
|
|
22
|
+
|
|
23
|
+
/** A calendar length as a horizon: null or nonsense falls back to the default, anything else is clamped. */
|
|
24
|
+
export function paceHorizon(durationMs: number | null | undefined): number {
|
|
25
|
+
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs <= 0) return PACE_HORIZON_MS
|
|
26
|
+
return Math.min(PACE_HORIZON_MAX_MS, Math.max(PACE_HORIZON_MIN_MS, durationMs))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function paceBurst(maxCues: number): number {
|
|
30
|
+
return Math.min(maxCues, Math.ceil(maxCues / 2))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Milliseconds between two refills; Infinity when the whole cap is already open (a cap of 1). */
|
|
34
|
+
export function paceRefillMs(maxCues: number, cooldownMs: number, horizonMs: number = PACE_HORIZON_MS): number {
|
|
35
|
+
const rest = maxCues - paceBurst(maxCues)
|
|
36
|
+
return rest <= 0 ? Number.POSITIVE_INFINITY : Math.max(cooldownMs, Math.floor(horizonMs / rest))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How many cues a meeting may have shown `elapsedMs` after it started. Never more than the cap; a negative elapsed counts as 0. */
|
|
40
|
+
export function paceAllowed(maxCues: number, cooldownMs: number, elapsedMs: number, horizonMs: number = PACE_HORIZON_MS): number {
|
|
41
|
+
if (maxCues < PACE_MIN_CAP) return maxCues
|
|
42
|
+
const refill = paceRefillMs(maxCues, cooldownMs, horizonMs)
|
|
43
|
+
const refilled = Number.isFinite(refill) ? Math.floor(Math.max(0, elapsedMs) / refill) : 0
|
|
44
|
+
return Math.min(maxCues, paceBurst(maxCues) + refilled)
|
|
45
|
+
}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// byte-identical to what the dev and held-out replays measured, and every meeting without the extended kinds (every
|
|
4
4
|
// glasses meeting today) still runs them.
|
|
5
5
|
//
|
|
6
|
-
// v4 is v3 with
|
|
7
|
-
// what the room said
|
|
6
|
+
// v4 is v3 with four segments swapped: rule 3 (the kinds), rule 4 (a record of what the room settled is allowed to say
|
|
7
|
+
// what the room said, and 6.97.8: an optional `open` item when the cue's main point is a missing owner or date) and the
|
|
8
|
+
// kind list and the optional `open` key in the schema line. Nothing else changes: one cue, at most 200 characters, the
|
|
8
9
|
// same evidence rules, the same null. The swap throws when v3 no longer holds a segment, so a regenerated v3 fails the
|
|
9
10
|
// suite (live-cues-v2-kinds.test.ts) instead of silently shipping v3 under the v4 label.
|
|
10
11
|
//
|
|
@@ -24,9 +25,13 @@ export const V3_RULE_4 = '4. Add something the room has not said: the missing ow
|
|
|
24
25
|
+ ' If the cue would only restate what was just said, output null.\n'
|
|
25
26
|
export const V4_RULE_4 = '4. A reinforce or challenge cue must add something the room has not said: the missing owner, deadline or criterion, a risk, a contradiction, or a past decision from memory.\n'
|
|
26
27
|
+ ' If it would only restate what was just said, output null. A decision, promise or followUp records what the room settled, took on or left open, in a few plain words.\n'
|
|
28
|
+
+ ' When the main point of a cue is that nobody named an owner or a date for something, add "open": that thing in at most 50 characters, in plain words (for example "Thanksgiving week agenda"). Otherwise leave "open" out.\n'
|
|
27
29
|
|
|
28
30
|
export const V3_SCHEMA_KIND = '"kind":"reinforce|challenge"'
|
|
29
31
|
export const V4_SCHEMA_KIND = '"kind":"reinforce|challenge|decision|promise|followUp"'
|
|
32
|
+
/** 6.97.8: the optional `open` key, added after `specific`. */
|
|
33
|
+
export const V3_SCHEMA_OPEN = '"specific":"the specific fact used, copied"}'
|
|
34
|
+
export const V4_SCHEMA_OPEN = '"specific":"the specific fact used, copied","open":"<=50 chars, optional"}'
|
|
30
35
|
|
|
31
36
|
function swap(text: string, from: string, to: string): string {
|
|
32
37
|
const at = text.indexOf(from)
|
|
@@ -34,5 +39,5 @@ function swap(text: string, from: string, to: string): string {
|
|
|
34
39
|
return text.slice(0, at) + to + text.slice(at + from.length)
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
/** Writer v4: v3 plus the decision, promise and followUp kinds. */
|
|
38
|
-
export const CUE_WRITER_SYSTEM_V4 = swap(swap(swap(CUE_WRITER_SYSTEM_V3, V3_RULE_3, V4_RULE_3), V3_RULE_4, V4_RULE_4), V3_SCHEMA_KIND, V4_SCHEMA_KIND)
|
|
42
|
+
/** Writer v4: v3 plus the decision, promise and followUp kinds, and the optional `open` item. */
|
|
43
|
+
export const CUE_WRITER_SYSTEM_V4 = swap(swap(swap(swap(CUE_WRITER_SYSTEM_V3, V3_RULE_3, V4_RULE_3), V3_RULE_4, V4_RULE_4), V3_SCHEMA_KIND, V4_SCHEMA_KIND), V3_SCHEMA_OPEN, V4_SCHEMA_OPEN)
|