@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. 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
- export interface NoteCandidate { id: string; date: string; title: string; notes: string; key: string }
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 three 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 the kind list in the schema line. Nothing else changes: one cue, at most 200 characters, the
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)